feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ export async function fetchCommunityQuestions(
case ROLE.TUTOR:
// Tutors can see questions in courses they have access to
const tutorCourses = await getOrgCourses({ orgId, profileId: userId });
const tutorCourseIds = tutorCourses.map((course) => course.id);
const tutorCourseIds = tutorCourses.items.map((course) => course.id);
discussions = await getCommunityQuestions({ courseIds: tutorCourseIds });
break;
case ROLE.STUDENT:
@@ -16,7 +16,11 @@ import {
updateCourseSectionService
} from '@api/services/course/section';
import { createLesson, listLessons, updateLessonService, deleteLessonService } from '@api/services/lesson';
import { upsertLessonLanguageService, updateLessonLanguageService } from '@api/services/lesson-language';
import {
deleteLessonLanguageService,
upsertLessonLanguageService,
updateLessonLanguageService
} from '@api/services/lesson-language';
import {
createCourseImportDraft,
getCourseImportDraftById,
@@ -76,6 +80,10 @@ type PublishDraftToExistingCourseResult = PublishDraftResult & {
updatedExercises: number;
createdLessonLanguages: number;
updatedLessonLanguages: number;
deletedSections: number;
deletedLessons: number;
deletedExercises: number;
deletedLessonLanguages: number;
untouchedSections: number;
untouchedLessons: number;
untouchedExercises: number;
@@ -579,7 +587,7 @@ export async function createCourseImportDraftFromCourseService(
export async function getCourseImportStructureService(orgId: string, courseId: string) {
try {
return await buildCourseStructureSnapshot(orgId, courseId);
return buildCourseStructureSnapshot(orgId, courseId);
} catch (error) {
if (error instanceof AppError) {
throw error;
@@ -863,6 +871,7 @@ export async function publishCourseImportDraftToExistingCourseService(
const existingDraft = await getCourseImportDraftService(orgId, draftId);
const draft = parseStoredDraft(existingDraft);
const course = await getCourseRecord(orgId, overrides.courseId);
const syncMode = overrides.syncMode ?? 'merge';
const courseTitle = overrides.title ?? draft.course.title;
const courseDescription = overrides.description ?? draft.course.description;
const courseType = overrides.type ?? draft.course.type;
@@ -1003,10 +1012,10 @@ export async function publishCourseImportDraftToExistingCourseService(
}
const appliedTagNames = normalizeDraftTagNames(draft.tags);
if (appliedTagNames.length > 0) {
if (appliedTagNames.length > 0 || syncMode === 'replace') {
await assignCourseTagsByName(orgId, course.id, {
tagNames: appliedTagNames,
mode: 'merge'
mode: syncMode
});
}
@@ -1015,6 +1024,72 @@ export async function publishCourseImportDraftToExistingCourseService(
const referencedExerciseIds = new Set(
(draft.exercises ?? []).map((exercise) => exercise.externalId).filter((id) => existingExerciseIds.has(id))
);
let deletedLessonLanguages = 0;
let deletedExercises = 0;
let deletedLessons = 0;
let deletedSections = 0;
if (syncMode === 'replace') {
const referencedLanguageKeys = new Set(
draft.lessonLanguages
.map((lessonLanguage) => {
const lessonId = lessonIdMap.get(lessonLanguage.lessonExternalId);
if (!lessonId || !referencedLessonIds.has(lessonId)) {
return null;
}
return `${lessonId}:${lessonLanguage.locale}`;
})
.filter((languageKey): languageKey is string => Boolean(languageKey))
);
for (const lessonLanguage of existingLessonLanguages) {
const lessonId = lessonLanguage.lessonId;
if (!lessonId) {
continue;
}
if (!referencedLessonIds.has(lessonId)) {
continue;
}
const languageKey = `${lessonId}:${lessonLanguage.locale}`;
if (referencedLanguageKeys.has(languageKey)) {
continue;
}
await deleteLessonLanguageService(lessonId, lessonLanguage.locale as TLocale);
deletedLessonLanguages += 1;
}
for (const existingExercise of existingExercises) {
if (referencedExerciseIds.has(existingExercise.id)) {
continue;
}
await deleteExerciseService(existingExercise.id);
deletedExercises += 1;
}
for (const existingLesson of existingLessons) {
if (referencedLessonIds.has(existingLesson.id)) {
continue;
}
await deleteLessonService(existingLesson.id);
deletedLessons += 1;
}
for (const existingSection of existingSections) {
if (referencedSectionIds.has(existingSection.id)) {
continue;
}
await deleteCourseSectionService(existingSection.id);
deletedSections += 1;
}
}
const courseUrl = await resolveCourseUrl(orgId, course.id, courseTitle);
await updateCourseImportDraft(orgId, draftId, {
@@ -1026,8 +1101,14 @@ export async function publishCourseImportDraftToExistingCourseService(
publishedCourseId: course.id,
updatedSections,
updatedLessons,
updatedExercises,
createdLessonLanguages,
updatedLessonLanguages
updatedLessonLanguages,
deletedSections,
deletedLessons,
deletedExercises,
deletedLessonLanguages,
syncMode
}
});
@@ -1044,9 +1125,13 @@ export async function publishCourseImportDraftToExistingCourseService(
updatedExercises,
createdLessonLanguages,
updatedLessonLanguages,
untouchedSections: existingSections.length - referencedSectionIds.size,
untouchedLessons: existingLessons.length - referencedLessonIds.size,
untouchedExercises: existingExercises.length - referencedExerciseIds.size,
deletedSections,
deletedLessons,
deletedExercises,
deletedLessonLanguages,
untouchedSections: Math.max(existingSections.length - referencedSectionIds.size - deletedSections, 0),
untouchedLessons: Math.max(existingLessons.length - referencedLessonIds.size - deletedLessons, 0),
untouchedExercises: Math.max(existingExercises.length - referencedExerciseIds.size - deletedExercises, 0),
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
};
} catch (error) {
+76 -28
View File
@@ -1,6 +1,6 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
import { addGroupMember, createGroup } from '@cio/db/queries/group';
import { addGroupMember, createGroup, getCourseProgramAccess } from '@cio/db/queries/group';
import {
createCourseNewsfeed,
createCourse as createCourseQuery,
@@ -21,6 +21,7 @@ import {
import { ContentType, ROLE } from '@cio/utils/constants';
import type { TCourse } from '@cio/db/types';
import { db } from '@cio/db/drizzle';
import * as schema from '@db/schema';
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
import { getProfileById } from '@cio/db/queries/auth';
@@ -29,6 +30,45 @@ import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type Co
const DEFAULT_CONTENT_GROUPING = true;
const DEFAULT_SECTION_TITLE = 'First Section [edit me]';
export async function ensureProgramCourseAccess(courseId: string, profileId: string) {
const access = await getCourseProgramAccess(courseId, profileId);
if (!access) {
return false;
}
const organizationId = access.organizationId;
if (!organizationId) {
return false;
}
const normalizedEmail = access.profileEmail?.trim() || undefined;
await db.transaction(async (tx) => {
await tx
.insert(schema.organizationmember)
.values({
organizationId,
roleId: access.roleId,
profileId,
email: normalizedEmail,
verified: true
})
.onConflictDoNothing();
await tx
.insert(schema.groupmember)
.values({
groupId: access.courseGroupId,
roleId: access.roleId,
profileId,
email: normalizedEmail
})
.onConflictDoNothing();
});
return true;
}
function sanitizeCourseMetadata(metadata: TCourse['metadata'] | undefined) {
if (!metadata) return metadata;
@@ -133,49 +173,57 @@ export async function createCourse(
try {
const description = sanitizeHtml(data.description);
const result = await db.transaction(async () => {
// 1. Create group
const [newGroup] = await createGroup({
name: data.title,
description,
organizationId: data.organizationId
});
const result = await db.transaction(async (tx) => {
const [newGroup] = await createGroup(
{
name: data.title,
description,
organizationId: data.organizationId
},
tx
);
if (!newGroup) {
throw new AppError('Failed to create group', ErrorCodes.INTERNAL_ERROR, 500);
}
// 2. Create course with group_id
const [newCourse] = await createCourseQuery({
title: data.title,
description,
type: data.type,
groupId: newGroup.id
});
const [newCourse] = await createCourseQuery(
{
title: data.title,
description,
type: data.type,
groupId: newGroup.id
},
tx
);
if (!newCourse) {
throw new AppError('Failed to create course', ErrorCodes.INTERNAL_ERROR, 500);
}
// 3. Add group member (creator as TUTOR)
const [newMember] = await addGroupMember({
profileId,
groupId: newGroup.id,
roleId: ROLE.TUTOR
});
const [newMember] = await addGroupMember(
{
profileId,
groupId: newGroup.id,
roleId: ROLE.TUTOR
},
tx
);
if (!newMember) {
throw new AppError('Failed to add group member', ErrorCodes.INTERNAL_ERROR, 500);
}
// 4. Add default news feed
await createCourseNewsfeed({
content: sanitizeHtml(`<h2>Welcome to this course 🎉&nbsp;</h2>
await createCourseNewsfeed(
{
content: sanitizeHtml(`<h2>Welcome to this course 🎉&nbsp;</h2>
<p>Thank you for joining this course and I hope you get the best out of it.</p>`),
courseId: newCourse.id,
isPinned: true,
authorId: newMember.id
});
courseId: newCourse.id,
isPinned: true,
authorId: newMember.id
},
tx
);
return {
course: newCourse,
+21 -14
View File
@@ -1,16 +1,21 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash';
import {
getCourseStats,
getDashOrgStats,
getRecentCertifications,
getTotalCertificatesIssued
} from '@cio/db/queries/dash';
import { OrganisationAnalytics } from '@api/types';
import { getOrgIdBySiteName } from '@cio/db/queries';
export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> {
const analytics: OrganisationAnalytics = {
revenue: 0,
totalCertificates: 0,
numberOfCourses: 0,
totalStudents: 0,
topCourses: [],
enrollments: []
recentCertifications: []
};
let resolvedOrgId = orgId;
@@ -26,13 +31,14 @@ export async function getOrganisationAnalytics(orgId?: string, siteName?: string
}
try {
const [stats, topCourses, enrollments] = await Promise.all([
const [stats, topCourses, recentCertificationRows, certificateCountRows] = await Promise.all([
getDashOrgStats(resolvedOrgId!),
getCourseStats(resolvedOrgId!),
getRecentEnrollments(resolvedOrgId!)
getRecentCertifications(resolvedOrgId!),
getTotalCertificatesIssued(resolvedOrgId!)
]);
analytics.revenue = 0;
analytics.totalCertificates = certificateCountRows[0]?.count ?? 0;
analytics.numberOfCourses = stats?.[0]?.noOfCourses ?? 0;
analytics.totalStudents = stats?.[0]?.enrolledStudents ?? 0;
@@ -40,16 +46,17 @@ export async function getOrganisationAnalytics(orgId?: string, siteName?: string
id: c.courseId,
title: c.courseTitle,
enrollments: c.totalStudents,
completion: c.completionPercentage
completion: c.completionPercentage,
certification: c.certificationPercentage
}));
analytics.enrollments = enrollments.map((e) => ({
id: e.profileId,
avatarUrl: e.avatarUrl,
name: e.fullname,
courseId: e.courseId,
course: e.courseTitle,
date: e.enrolledAt ?? ''
analytics.recentCertifications = recentCertificationRows.map((row) => ({
id: row.profileId,
avatarUrl: row.avatarUrl,
name: row.fullname,
courseId: row.courseId,
course: row.courseTitle,
date: row.earnedAt ?? ''
}));
return analytics;
+32 -3
View File
@@ -20,6 +20,7 @@ import {
updateOptions,
updateQuestions
} from '@cio/db/queries/exercise';
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
import { db } from '@cio/db/drizzle';
import type { DbOrTxClient } from '@cio/db/drizzle';
@@ -123,7 +124,13 @@ export async function createExercise(data: TExerciseCreate): Promise<TExercise>
}
// Fetch the complete exercise with questions and options
return await getExercise(exercise.id);
const result = await getExercise(exercise.id);
if (sanitizedData.courseId) {
await touchCourseUpdatedAt(sanitizedData.courseId);
}
return result;
} catch (error) {
console.error('createExercise error:', error);
if (error instanceof AppError) {
@@ -193,7 +200,9 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
try {
const sanitizedData = sanitizeExercisePayload(data);
return await db.transaction(async (tx) => {
const existing = await getExerciseById(exerciseId);
const result = await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
// Update exercise basic fields only if they changed
@@ -252,6 +261,13 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
// Return the updated exercise
return await getExercise(exerciseId, txClient);
});
const courseId = existing?.courseId;
if (courseId) {
await touchCourseUpdatedAt(courseId);
}
return result;
} catch (error) {
console.error('updateExerciseService error:', error);
if (error instanceof AppError) {
@@ -270,7 +286,9 @@ export async function replaceExerciseService(exerciseId: string, data: TExercise
try {
const sanitizedData = sanitizeExercisePayload(data);
return await db.transaction(async (tx) => {
const existing = await getExerciseById(exerciseId);
const result = await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
const exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
@@ -303,6 +321,13 @@ export async function replaceExerciseService(exerciseId: string, data: TExercise
return await getExercise(exerciseId, txClient);
});
const courseId = existing?.courseId;
if (courseId) {
await touchCourseUpdatedAt(courseId);
}
return result;
} catch (error) {
console.error('replaceExerciseService error:', error);
if (error instanceof AppError) {
@@ -330,6 +355,10 @@ export async function deleteExerciseService(exerciseId: string): Promise<TExerci
throw new AppError('Failed to delete exercise', ErrorCodes.INTERNAL_ERROR, 500);
}
if (exercise.courseId) {
await touchCourseUpdatedAt(exercise.courseId);
}
return deleted;
} catch (error) {
if (error instanceof AppError) {
+46
View File
@@ -2,6 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeOptionalHtml } from '@api/utils/sanitize-html';
import type { TLessonLanguage, TLocale, TNewLessonLanguage } from '@db/types';
import {
deleteLessonLanguage,
getLessonLanguageByLessonIdAndLocale,
getLessonLanguagesByLessonId,
updateLessonLanguage,
@@ -9,6 +10,7 @@ import {
} from '@cio/db/queries/lesson/language';
import { getLessonById } from '@cio/db/queries/lesson/lesson';
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
/**
* Gets all language translations for a lesson
@@ -70,6 +72,10 @@ export async function upsertLessonLanguageService(
lessonId
});
if (lesson.courseId) {
await touchCourseUpdatedAt(lesson.courseId);
}
return language;
} catch (error) {
if (error instanceof AppError) {
@@ -111,6 +117,11 @@ export async function updateLessonLanguageService(
...data,
content: sanitizeOptionalHtml(data.content)
});
if (lesson.courseId) {
await touchCourseUpdatedAt(lesson.courseId);
}
return language;
} catch (error) {
if (error instanceof AppError) {
@@ -123,3 +134,38 @@ export async function updateLessonLanguageService(
);
}
}
export async function deleteLessonLanguageService(lessonId: string, locale: TLocale): Promise<TLessonLanguage> {
try {
const lesson = await getLessonById(lessonId);
if (!lesson) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
const existing = await getLessonLanguageByLessonIdAndLocale(lessonId, locale);
if (!existing) {
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
}
const deleted = await deleteLessonLanguage(lessonId, locale);
if (!deleted) {
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
}
if (lesson.courseId) {
await touchCourseUpdatedAt(lesson.courseId);
}
return deleted;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete lesson language',
ErrorCodes.LESSON_LANGUAGE_DELETE_FAILED,
500
);
}
}
+19 -1
View File
@@ -18,6 +18,7 @@ import {
upsertLessonCompletion,
type LessonById
} from '@cio/db/queries/lesson';
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
import { enrichLessonWithPresignedUrls } from '@api/utils/lesson-media';
/**
@@ -38,6 +39,8 @@ export async function createLesson(courseId: string, data: TLessonCreate): Promi
throw new AppError('Failed to create lesson', ErrorCodes.INTERNAL_ERROR, 500);
}
await touchCourseUpdatedAt(courseId);
return lesson;
} catch (error) {
if (error instanceof AppError) {
@@ -91,6 +94,10 @@ export async function updateLessonService(lessonId: string, data: TLessonUpdate)
throw new AppError('Failed to update lesson', ErrorCodes.INTERNAL_ERROR, 500);
}
if (updated.courseId) {
await touchCourseUpdatedAt(updated.courseId);
}
return updated;
} catch (error) {
if (error instanceof AppError) {
@@ -117,6 +124,10 @@ export async function deleteLessonService(lessonId: string): Promise<TLesson> {
throw new AppError('Failed to delete lesson', ErrorCodes.INTERNAL_ERROR, 500);
}
if (deleted.courseId) {
await touchCourseUpdatedAt(deleted.courseId);
}
return deleted;
} catch (error) {
if (error instanceof AppError) {
@@ -168,7 +179,14 @@ export async function reorderLessons(lessons: TLessonReorder['lessons']): Promis
);
const updated = await Promise.all(updatePromises);
return updated.filter((l): l is TLesson => l !== null);
const validLessons = updated.filter((l): l is TLesson => l !== null);
const courseId = validLessons[0]?.courseId;
if (courseId) {
await touchCourseUpdatedAt(courseId);
}
return validLessons;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to reorder lessons',
+159 -26
View File
@@ -1,14 +1,18 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { OrgAudienceMember, OrgAudiencePagination, OrgAudienceQuery } from '@api/types/org';
import type { TGetAudienceQuery, TGetOrganizationCoursesQuery } from '@cio/utils/validation/organization';
import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan } from '@db/types';
import {
deleteOrganizationAudienceMember,
cancelOrganizationPlan,
createOrganizationPlan,
deleteOrganizationAudienceMember,
deleteOrganizationMember,
getOrgIdBySiteName,
getActiveOrganizationPlan,
getFirstOrganizationWithPlans,
getLatestOrgInvitesByEmails,
getOrgIdBySiteName,
getOrganizationAudience,
getOrganizationAudienceMember,
getOrganizationBySiteName,
getOrganizationTeam,
getOrganizations,
@@ -16,8 +20,8 @@ import {
updateOrganization,
updateOrganizationPlan
} from '@cio/db/queries/organization';
import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag';
import {
countPublishedCoursesBySiteName,
getCoursesById,
getCoursesBySiteNameForSetup,
getEnrolledCourses,
@@ -27,16 +31,18 @@ import {
getOrgCourses,
getPublishedCoursesBySiteName
} from '@cio/db/queries/course';
import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag';
import { getLastLogin, getProfileCourseProgress, getUserExercisesStats } from '@cio/db/queries/analytics';
import type { OrganizationWithPlans } from '@cio/db/queries/organization/types';
import { PLAN } from '@cio/utils/plans';
import { ROLE } from '@cio/utils/constants';
import { createOrganizationWithOwner } from '@api/services/onboarding';
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
import { getProfileById } from '@cio/db/queries/auth';
import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite';
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
import type { OrgAudienceMember, OrgAudiencePagination, OrgAudienceQuery } from '@api/types/org';
import type { TGetAudienceQuery } from '@cio/utils/validation/organization';
const PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT = 4;
/**
* Creates a new organization with the current user as owner
@@ -169,6 +175,41 @@ export async function getOrgAudience(
}
}
export async function getOrgAudienceMember(orgId: string, memberId: number): Promise<OrgAudienceMember> {
try {
const member = await getOrganizationAudienceMember(orgId, memberId);
if (!member) {
throw new AppError('Audience member not found', ErrorCodes.NOT_FOUND, 404);
}
if (!member.profileId && member.email) {
const invites = await getLatestOrgInvitesByEmails(orgId, [member.email.toLowerCase()]);
const invite = invites[0];
return {
...member,
status: deriveAudienceMemberStatus(member.profileId, invite)
};
}
return {
...member,
status: deriveAudienceMemberStatus(member.profileId, undefined)
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch audience member',
ErrorCodes.ORG_AUDIENCE_FETCH_FAILED,
500
);
}
}
/**
* Removes a student audience member from an organization
* @param orgId - The organization ID
@@ -218,20 +259,31 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
if (tagSlugs && tagSlugs.length > 0) {
filteredCourseIds = await getCourseIdsByTagSlugs(org.id, tagSlugs);
if (filteredCourseIds.length === 0) {
return [];
return {
courses: [],
hasMoreCourses: false
};
}
}
const courses = await getPublishedCoursesBySiteName(siteName, filteredCourseIds);
const totalCourses = await countPublishedCoursesBySiteName(siteName, filteredCourseIds);
const courses = await getPublishedCoursesBySiteName(
siteName,
filteredCourseIds,
PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT
);
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
org.id,
courses.map((course) => course.id)
);
return courses.map((course) => ({
...course,
tags: tagsByCourseId[course.id] ?? []
}));
return {
courses: courses.map((course) => ({
...course,
tags: tagsByCourseId[course.id] ?? []
})),
hasMoreCourses: totalCourses > courses.length
};
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
@@ -251,44 +303,110 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
* - Admins: all courses with totalStudents
* - Tutors: assigned courses with totalStudents
*/
export async function getOrganizationCourses(orgId: string, userId: string, userRole: number, tagSlugs?: string[]) {
export async function getOrganizationCourses(
orgId: string,
userId: string,
userRole: number,
query: TGetOrganizationCoursesQuery
) {
try {
if (!userRole) {
throw new AppError('Invalid permissions', ErrorCodes.UNAUTHORIZED, 403);
}
const { page, limit, search } = query;
const tagSlugs = query.tags
?.split(',')
.map((value) => value.trim())
.filter(Boolean);
let filteredCourseIds: string[] | undefined = undefined;
if (tagSlugs && tagSlugs.length > 0) {
filteredCourseIds = await getCourseIdsByTagSlugs(orgId, tagSlugs);
if (filteredCourseIds.length === 0) {
return [];
return {
items: [],
pagination: {
page,
limit,
total: 0,
totalPages: 0
},
query: {
page,
limit,
search,
tags: query.tags
}
};
}
}
switch (userRole) {
case ROLE.ADMIN: {
const courses = await getOrgCourses({ orgId, courseIds: filteredCourseIds });
const courses = await getOrgCourses({
orgId,
courseIds: filteredCourseIds,
page,
limit,
search
});
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
orgId,
courses.map((course) => course.id)
courses.items.map((course) => course.id)
);
return courses.map((course) => ({
...course,
tags: tagsByCourseId[course.id] ?? []
}));
return {
items: courses.items.map((course) => ({
...course,
tags: tagsByCourseId[course.id] ?? []
})),
pagination: {
page: courses.page,
limit: courses.limit,
total: courses.total,
totalPages: courses.totalPages
},
query: {
page,
limit,
search,
tags: query.tags
}
};
}
case ROLE.TUTOR: {
const courses = await getOrgCourses({ orgId, profileId: userId, courseIds: filteredCourseIds });
const courses = await getOrgCourses({
orgId,
profileId: userId,
courseIds: filteredCourseIds,
page,
limit,
search
});
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
orgId,
courses.map((course) => course.id)
courses.items.map((course) => course.id)
);
return courses.map((course) => ({
...course,
tags: tagsByCourseId[course.id] ?? []
}));
return {
items: courses.items.map((course) => ({
...course,
tags: tagsByCourseId[course.id] ?? []
})),
pagination: {
page: courses.page,
limit: courses.limit,
total: courses.total,
totalPages: courses.totalPages
},
query: {
page,
limit,
search,
tags: query.tags
}
};
}
default:
throw new AppError('Invalid permissions', ErrorCodes.UNAUTHORIZED, 403);
@@ -433,6 +551,21 @@ export async function createOrgPlan(data: TNewOrganizationPlan) {
*/
export async function updateOrg(orgId: string, data: Partial<TOrganization>) {
try {
// Enforce plan restriction: only paid plans can use non-minimal landing page themes
if (data.landingpage && typeof data.landingpage === 'object') {
const landingpage = data.landingpage as Record<string, unknown>;
const theme = landingpage.theme;
if (typeof theme === 'string' && theme !== 'minimal') {
const activePlan = await getActiveOrganizationPlan(orgId);
const planName = activePlan?.planName ?? PLAN.BASIC;
if (planName === PLAN.BASIC) {
throw new AppError('Landing page themes require a paid plan', ErrorCodes.UPGRADE_REQUIRED, 403);
}
}
}
const organization = await updateOrganization(orgId, data);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
+272 -28
View File
@@ -6,11 +6,13 @@ import type {
} from '@cio/utils/validation/organization';
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
import {
createOrganizationInvite,
createOrganizationInviteAudits,
createOrganizationInvites,
createOrganizationMembers,
getOrganizationAudienceMember,
getLatestOrganizationInviteRowByOrgAndEmail,
getOrgMembersByProfileIds,
getOrganizationById,
@@ -20,6 +22,7 @@ import {
revokeActiveOrganizationInvitesByEmails
} from '@cio/db/queries/organization';
import { getCourseGroupIds, getOrgCourseGroups, getOrgCourses } from '@cio/db/queries/course';
import { updateOrganizationAudienceMember } from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
import crypto from 'node:crypto';
@@ -113,16 +116,33 @@ async function resolveCourseIdsAndNamesForImport(orgId: string, data: TImportAud
let courseNames: string[] = [];
if (data.allCourses) {
const courses = await getOrgCourses({ orgId });
courseIds = courses.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean);
courseIds = courses.items.map((c) => c.id);
courseNames = courses.items.map((c) => c.title).filter(Boolean);
} else if (data.courseIds && data.courseIds.length > 0) {
const courses = await getOrgCourses({ orgId, courseIds: data.courseIds });
courseIds = courses.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean);
courseIds = courses.items.map((c) => c.id);
courseNames = courses.items.map((c) => c.title).filter(Boolean);
}
return { courseIds, courseNames };
}
async function resolveProgramIdsAndNamesForImport(orgId: string, data: TImportAudienceMembers) {
let programIds: string[] = [];
let programNames: 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);
}
return { programIds, programNames };
}
async function enrollAudienceStudentProfilesInCourses(
orgId: string,
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>,
@@ -202,16 +222,98 @@ async function enrollAudienceStudentProfilesInCourses(
};
}
async function enrollAudienceStudentProfilesInPrograms(
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>,
orgId: string,
profileIds: string[],
programIds: string[],
shouldSendEmail: boolean
): Promise<{ assigned: number; alreadyEnrolled: number; emailsSent: number }> {
if (programIds.length === 0 || profileIds.length === 0) {
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
}
const uniqueProfileIds = [...new Set(profileIds)];
const orgMembers = await getOrgMembersByProfileIds(orgId, uniqueProfileIds);
const studentMembers = orgMembers.filter((member) => member.profileId && member.roleId === ROLE.STUDENT);
const validProfileIds = new Set(studentMembers.map((member) => member.profileId!));
const profileEmailMap = new Map(
studentMembers.filter((member) => member.profileId).map((member) => [member.profileId!, member.email ?? ''])
);
const programs = await getProgramsByOrg(orgId, programIds);
if (programs.length === 0) {
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
}
const programNameById = new Map(programs.map((program) => [program.id, program.name || 'Program']));
const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
const validProgramIds = programs.map((program) => program.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 alreadyEnrolled = pairs.length - toInsert.length;
if (toInsert.length > 0) {
await Promise.all(
toInsert.map((pair) =>
addProgramMember({
programId: pair.programId,
roleId: ROLE.STUDENT,
profileId: pair.profileId,
email: profileEmailMap.get(pair.profileId) || undefined
})
)
);
}
let emailsSent = 0;
if (shouldSendEmail && toInsert.length > 0) {
await Promise.all(
toInsert
.filter((pair) => profileEmailMap.get(pair.profileId))
.map(async (pair) => {
const email = profileEmailMap.get(pair.profileId)!;
try {
await sendEmail('studentProgramWelcome', {
to: email,
fields: {
orgName: organization.name,
programName: programNameById.get(pair.programId) || 'Program',
loginUrl
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
emailsSent++;
} catch (emailError) {
console.error(`enrollAudienceStudentProfilesInPrograms email error for ${email}:`, emailError);
}
})
);
}
return {
assigned: toInsert.length,
alreadyEnrolled,
emailsSent
};
}
async function createStudentOrgInvitesAndSendEmails(input: {
orgId: string;
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>;
emails: string[];
courseIds: string[];
courseNamesLabel: string | undefined;
programIds: string[];
accessNamesLabel: string | undefined;
invitedByProfileId: string;
shouldSendEmail: boolean;
}): Promise<{ created: number; emailsSent: number; emailsFailed: number }> {
const { orgId, organization, emails, courseIds, courseNamesLabel, invitedByProfileId, shouldSendEmail } = input;
const { orgId, organization, emails, courseIds, programIds, accessNamesLabel, invitedByProfileId, shouldSendEmail } =
input;
if (emails.length === 0) {
return { created: 0, emailsSent: 0, emailsFailed: 0 };
@@ -236,7 +338,8 @@ async function createStudentOrgInvitesAndSendEmails(input: {
isRevoked: false,
metadata: {
source: 'AUDIENCE_IMPORT',
courseIds: courseIds.length > 0 ? courseIds : undefined
courseIds: courseIds.length > 0 ? courseIds : undefined,
programIds: programIds.length > 0 ? programIds : undefined
}
}
};
@@ -261,7 +364,8 @@ async function createStudentOrgInvitesAndSendEmails(input: {
roleId: ROLE.STUDENT,
roleName: 'Student',
expiresAt,
courseIds
courseIds,
programIds
}
};
})
@@ -283,7 +387,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
orgName: organization.name,
inviteLink,
expiresAt: getExpiryLabel(expiresAt),
courseNames: courseNamesLabel
courseNames: accessNamesLabel
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
@@ -388,15 +492,24 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
}
const { courseIds, courseNames } = await resolveCourseIdsAndNamesForImport(orgId, data);
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
const { programIds, programNames } = await resolveProgramIdsAndNamesForImport(orgId, data);
const accessNames = [...courseNames, ...programNames];
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
const assignResult = await enrollAudienceStudentProfilesInCourses(
const assignedToCourses = await enrollAudienceStudentProfilesInCourses(
orgId,
organization,
existingStudentProfileIds,
courseIds,
data.sendEmail
);
const assignedToPrograms = await enrollAudienceStudentProfilesInPrograms(
organization,
orgId,
existingStudentProfileIds,
programIds,
data.sendEmail
);
let imported = 0;
let importEmailsSent = 0;
@@ -425,12 +538,26 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
}
}
if (programIds.length > 0) {
const profiles = await getProfilesByEmails(newEmails);
if (profiles.length > 0) {
await enrollAudienceStudentProfilesInPrograms(
organization,
orgId,
profiles.map((profile) => profile.id),
programIds,
false
);
}
}
const inviteOutcome = await createStudentOrgInvitesAndSendEmails({
orgId,
organization,
emails: newEmails,
courseIds,
courseNamesLabel,
programIds,
accessNamesLabel,
invitedByProfileId,
shouldSendEmail: data.sendEmail
});
@@ -447,7 +574,8 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
organization,
emails: pendingStudentEmails,
courseIds,
courseNamesLabel,
programIds,
accessNamesLabel,
invitedByProfileId,
shouldSendEmail: data.sendEmail
});
@@ -457,11 +585,12 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
return {
imported,
assigned: assignResult.assigned,
alreadyEnrolledInCourses: assignResult.alreadyEnrolled,
assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
alreadyEnrolledInCourses: assignedToCourses.alreadyEnrolled,
alreadyEnrolledInPrograms: assignedToPrograms.alreadyEnrolled,
pendingInvitesRenewed: pendingStudentEmails.length,
duplicates: recipients.duplicates.length,
emailsSent: assignResult.emailsSent + importEmailsSent + pendingEmailsSent,
emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent + importEmailsSent + pendingEmailsSent,
emailsFailed: importEmailsFailed + pendingEmailsFailed
};
}
@@ -483,22 +612,32 @@ 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[] } | undefined;
const courseIdsFromMetadata = meta?.courseIds?.filter(Boolean) ?? [];
const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {};
const courseIdsFromMetadata = meta.courseIds?.filter(Boolean) ?? [];
const programIdsFromMetadata = meta.programIds?.filter(Boolean) ?? [];
let courseIds: string[] = [];
let courseNames: string[] = [];
if (courseIdsFromMetadata.length > 0) {
const courses = await getOrgCourses({ orgId, courseIds: courseIdsFromMetadata });
courseIds = courses.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean);
courseIds = courses.items.map((c) => c.id);
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);
}
await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], invitedByProfileId);
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
const token = generateToken();
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
const accessNames = [...courseNames, ...programNames];
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
const invite = await createOrganizationInvite({
organizationId: orgId,
@@ -510,7 +649,8 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
isRevoked: false,
metadata: {
source: 'AUDIENCE_RESEND',
courseIds: courseIds.length > 0 ? courseIds : undefined
courseIds: courseIds.length > 0 ? courseIds : undefined,
programIds: programIds.length > 0 ? programIds : undefined
}
});
@@ -527,7 +667,8 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
roleId: ROLE.STUDENT,
roleName: 'Student',
expiresAt,
courseIds
courseIds,
programIds
}
}
]);
@@ -542,7 +683,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
orgName: organization.name,
inviteLink,
expiresAt: getExpiryLabel(expiresAt),
courseNames: courseNamesLabel
courseNames: accessNamesLabel
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
@@ -633,10 +774,113 @@ export async function assignAudienceToCourses(orgId: string, data: TAssignAudien
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const courseGroups = await getOrgCourseGroups(orgId, data.courseIds);
if (courseGroups.length === 0) {
throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds');
const courseIds = data.courseIds ?? [];
const programIds = data.programIds ?? [];
let assignedToCourses = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
let assignedToPrograms = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
if (courseIds.length > 0) {
const courseGroups = await getOrgCourseGroups(orgId, courseIds);
if (courseGroups.length === 0) {
throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds');
}
assignedToCourses = await enrollAudienceStudentProfilesInCourses(
orgId,
organization,
data.profileIds,
courseIds,
data.sendEmail
);
}
return enrollAudienceStudentProfilesInCourses(orgId, organization, data.profileIds, data.courseIds, data.sendEmail);
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');
}
assignedToPrograms = await enrollAudienceStudentProfilesInPrograms(
organization,
orgId,
data.profileIds,
programIds,
data.sendEmail
);
}
return {
assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
alreadyEnrolled: assignedToCourses.alreadyEnrolled + assignedToPrograms.alreadyEnrolled,
emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent
};
}
export async function updatePendingAudienceMemberEmail(
orgId: string,
memberId: number,
data: { email: string; sendEmail: boolean },
invitedByProfileId: string
) {
const organization = await getOrganizationById(orgId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const existingMember = await getOrganizationAudienceMember(orgId, memberId);
if (!existingMember) {
throw new AppError('Audience member not found', ErrorCodes.NOT_FOUND, 404);
}
if (existingMember.profileId) {
throw new AppError(
'Only pending audience members can be updated through this endpoint',
ErrorCodes.VALIDATION_ERROR,
400,
'memberId'
);
}
const normalizedEmail = data.email.toLowerCase().trim();
const currentEmail = existingMember.email.toLowerCase().trim();
if (!normalizedEmail) {
throw new AppError('Email is required', ErrorCodes.VALIDATION_ERROR, 400, 'email');
}
if (normalizedEmail !== currentEmail) {
const matchingMembers = await getOrganizationMembersByNormalizedEmails(orgId, [normalizedEmail]);
if (matchingMembers.length > 0) {
throw new AppError('An audience member with this email already exists', ErrorCodes.CONFLICT, 409, 'email');
}
}
await revokeActiveOrganizationInvitesByEmails(orgId, [currentEmail, normalizedEmail], invitedByProfileId);
const updatedMember = await updateOrganizationAudienceMember(orgId, memberId, {
email: normalizedEmail,
verified: false
});
if (!updatedMember) {
throw new AppError('Audience member not found', ErrorCodes.NOT_FOUND, 404);
}
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, currentEmail);
const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {};
await createStudentOrgInvitesAndSendEmails({
orgId,
organization,
emails: [normalizedEmail],
courseIds: meta.courseIds?.filter(Boolean) ?? [],
programIds: meta.programIds?.filter(Boolean) ?? [],
accessNamesLabel: undefined,
invitedByProfileId,
shouldSendEmail: data.sendEmail
});
return updatedMember;
}
@@ -16,6 +16,7 @@ import {
MCP_TOOL_CREDIT_COST,
type TMcpToolName
} from '@cio/utils/plans';
import { PLAN } from '@cio/utils/plans/constants';
export type OrganizationAutomationUsageSummary = {
type: TOrganizationApiKeyType;
@@ -56,11 +57,16 @@ export async function assertOrganizationAutomationKeyCreationAllowed(
organizationId: string,
type: TOrganizationApiKeyType
): Promise<void> {
const planName = await getOrganizationPlanName(organizationId);
if (type === AUTOMATION_TYPE.API && planName === PLAN.BASIC) {
throw new AppError('Public API keys require an Early Adopter or Enterprise plan', ErrorCodes.UPGRADE_REQUIRED, 403);
}
if (type !== AUTOMATION_TYPE.MCP) {
return;
}
const planName = await getOrganizationPlanName(organizationId);
const limits = getMcpAutomationLimits(planName);
const activeKeys = await countActiveOrganizationApiKeys(organizationId, type);
+235 -1
View File
@@ -7,18 +7,20 @@ import {
createOrganizationInvite,
createOrganizationInviteAudit,
createOrganizationMembers,
getActivePendingOrgInviteForEmail,
getOrganizationById,
getOrganizationInviteByTokenHash,
revokeActiveOrganizationInvitesByEmails
} 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 { ROLE } from '@cio/utils/constants';
import crypto from 'node:crypto';
import { db } from '@cio/db/drizzle';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata } from '@api/utils/org';
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { sendEmail } from '@cio/email';
@@ -426,6 +428,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);
if (courseIds.length > 0) {
try {
@@ -441,6 +444,30 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
console.error('acceptOrganizationInvite course enrollment error:', error);
}
}
if (programIds.length > 0) {
try {
const existingProgramMemberships = await getExistingProgramMembers(
programIds.map((programId) => ({ programId, profileId: user.id }))
);
const programIdsToInsert = programIds.filter(
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
);
await Promise.all(
programIdsToInsert.map((programId) =>
addProgramMember({
programId,
roleId: invite.invite.roleId,
profileId: user.id,
email: normalizedEmail
})
)
);
} catch (error) {
console.error('acceptOrganizationInvite program enrollment error:', error);
}
}
}
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
@@ -452,3 +479,210 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
redirectTo
};
}
/**
* Returns the pending org invite for the currently logged-in user, if one exists.
* Used by the LMS dashboard to prompt the student to accept on first load.
*/
export async function getPendingOrgInviteForUser(orgId: string, email: string) {
if (!orgId || !email) {
return null;
}
const data = await getActivePendingOrgInviteForEmail(orgId, email);
if (!data) {
return null;
}
return {
id: data.invite.id,
email: data.invite.email,
roleId: data.invite.roleId,
roleLabel: getRoleLabel(data.invite.roleId),
expiresAt: data.invite.expiresAt,
organization: data.organization
};
}
/**
* Accepts an organization invite by invite ID (for logged-in students with a pending invite).
* Replicates the acceptance logic of acceptOrganizationInvite but looks up by invite ID instead of token.
*/
export async function acceptOrganizationInviteById(
inviteId: string,
user: TAuthUser,
context: TInviteRequestContext = {}
) {
if (!user.id || !user.email) {
throw new AppError('Authenticated user email is required', ErrorCodes.UNAUTHORIZED, 401);
}
const normalizedEmail = user.email.toLowerCase().trim();
const result = await db.transaction(async (tx) => {
const [row] = await tx
.select({
invite: schema.organizationInvite,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
}
})
.from(schema.organizationInvite)
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
.where(eq(schema.organizationInvite.id, inviteId))
.limit(1);
if (!row) {
throw new AppError('Invalid invite', ErrorCodes.NOT_FOUND, 404);
}
const status = getInviteStatus(row.invite);
if (status === 'REVOKED') {
throw new AppError('This invite has been revoked', ErrorCodes.UNAUTHORIZED, 403);
}
if (status === 'EXPIRED') {
throw new AppError('This invite has expired', ErrorCodes.VALIDATION_ERROR, 400);
}
if (row.invite.email.toLowerCase().trim() !== normalizedEmail) {
throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403);
}
if (status === 'ACCEPTED') {
await markUserAndProfileEmailVerified(user.id, tx);
return { organization: row.organization, invite: row.invite, roleId: row.invite.roleId, alreadyAccepted: true };
}
const [orgMemberByEmail] = await tx
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, row.invite.organizationId),
eq(schema.organizationmember.email, normalizedEmail)
)
)
.limit(1);
if (orgMemberByEmail) {
if (orgMemberByEmail.profileId && orgMemberByEmail.profileId !== user.id) {
throw new AppError('This invite is linked to another account', ErrorCodes.UNAUTHORIZED, 403);
}
await tx
.update(schema.organizationmember)
.set({ profileId: user.id, roleId: row.invite.roleId, email: normalizedEmail, verified: true })
.where(eq(schema.organizationmember.id, orgMemberByEmail.id));
} else {
const [orgMemberByProfile] = await tx
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, row.invite.organizationId),
eq(schema.organizationmember.profileId, user.id)
)
)
.limit(1);
if (orgMemberByProfile) {
await tx
.update(schema.organizationmember)
.set({ roleId: row.invite.roleId, email: normalizedEmail, verified: true })
.where(eq(schema.organizationmember.id, orgMemberByProfile.id));
} else {
await tx.insert(schema.organizationmember).values({
organizationId: row.invite.organizationId,
roleId: row.invite.roleId,
profileId: user.id,
email: normalizedEmail,
verified: true
});
}
}
await markUserAndProfileEmailVerified(user.id, tx);
const [acceptedInvite] = await tx
.update(schema.organizationInvite)
.set({
acceptedAt: new Date().toISOString(),
acceptedByProfileId: user.id,
updatedAt: new Date().toISOString()
})
.where(
and(
eq(schema.organizationInvite.id, row.invite.id),
eq(schema.organizationInvite.isRevoked, false),
isNull(schema.organizationInvite.acceptedAt)
)
)
.returning({ id: schema.organizationInvite.id });
if (!acceptedInvite) {
throw new AppError('Invite is no longer available', ErrorCodes.VALIDATION_ERROR, 409);
}
return { organization: row.organization, invite: row.invite, roleId: row.invite.roleId, alreadyAccepted: false };
});
await recordOrganizationInviteAudit(result.invite.id, result.invite.organizationId, 'ACCEPTED', {
actorProfileId: user.id,
targetEmail: normalizedEmail,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
metadata: { alreadyAccepted: result.alreadyAccepted }
});
const metadata = result.invite.metadata;
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
const programIds = parseProgramIdsFromInviteMetadata(metadata);
if (courseIds.length > 0) {
try {
const courseGroupMappings = await getCourseGroupIds(courseIds);
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
await enrollUsersInCourseGroups(
validGroupIds,
[{ profileId: user.id, email: normalizedEmail }],
result.invite.roleId
);
} catch (error) {
console.error('acceptOrganizationInviteById course enrollment error:', error);
}
}
if (programIds.length > 0) {
try {
const existingProgramMemberships = await getExistingProgramMembers(
programIds.map((programId) => ({ programId, profileId: user.id }))
);
const programIdsToInsert = programIds.filter(
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
);
await Promise.all(
programIdsToInsert.map((programId) =>
addProgramMember({ programId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail })
)
);
} catch (error) {
console.error('acceptOrganizationInviteById program enrollment error:', error);
}
}
const siteName = result.organization.siteName || '';
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
return {
organizationId: result.organization.id,
roleId: result.roleId,
alreadyAccepted: result.alreadyAccepted,
redirectTo
};
}
+580
View File
@@ -0,0 +1,580 @@
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,
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 { getProfileByEmail } from '@cio/db/queries/auth';
import { ROLE } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
import * as schema from '@db/schema';
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 tx.insert(schema.organizationmember).values(organizationMemberRows).onConflictDoNothing();
await tx.insert(schema.groupmember).values(groupMemberRows).onConflictDoNothing();
});
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 tx
.insert(schema.programMember)
.values({
programId,
profileId,
roleId,
email: normalizedEmail ?? undefined
})
.returning();
if (member && member.roleId === ROLE.STUDENT && member.profileId && courseGroupIds.length > 0) {
const validCourseGroupIds = courseGroupIds.filter((groupId): groupId is string => Boolean(groupId));
await tx
.insert(schema.organizationmember)
.values({
organizationId: program.organizationId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: normalizedEmail ?? undefined,
verified: true
})
.onConflictDoNothing();
await tx
.insert(schema.groupmember)
.values(
validCourseGroupIds.map((groupId) => ({
groupId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: normalizedEmail ?? undefined
}))
)
.onConflictDoNothing();
}
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);
}
}
+64
View File
@@ -0,0 +1,64 @@
import type {
TPublicApiAssignAudienceCourses,
TPublicApiAudienceMemberParam,
TPublicApiAudienceQuery,
TPublicApiCreateAudience,
TPublicApiUpdateAudience
} from '@cio/utils/validation/public-api';
import {
assignAudienceToCourses,
importAudienceMembers,
updatePendingAudienceMemberEmail
} from '@api/services/organization/audience';
import { getOrgAudience, getOrgAudienceMember, removeAudienceMember } from '@api/services/organization';
import { AppError, ErrorCodes } from '@api/utils/errors';
export async function listAudienceService(orgId: string, query: TPublicApiAudienceQuery) {
return getOrgAudience(orgId, query);
}
export async function createAudienceService(orgId: string, actorId: string | null, payload: TPublicApiCreateAudience) {
if (!actorId) {
throw new AppError('Automation actor is required', ErrorCodes.UNAUTHORIZED, 401);
}
return importAudienceMembers(
orgId,
{
recipientCsv: payload.email,
courseIds: payload.courseIds,
allCourses: false,
allPrograms: false,
sendEmail: payload.sendEmail
},
actorId
);
}
export async function getAudienceMemberService(orgId: string, params: TPublicApiAudienceMemberParam) {
return getOrgAudienceMember(orgId, params.memberId);
}
export async function assignAudienceCoursesService(orgId: string, payload: TPublicApiAssignAudienceCourses) {
return assignAudienceToCourses(orgId, payload);
}
export async function removeAudienceService(orgId: string, memberId: number, actorId?: string | null) {
return removeAudienceMember(orgId, memberId, actorId ?? undefined);
}
export async function updateAudienceService(
orgId: string,
memberId: number,
actorId: string | null,
payload: TPublicApiUpdateAudience
) {
if (!actorId) {
throw new AppError('Automation actor is required', ErrorCodes.UNAUTHORIZED, 401);
}
await updatePendingAudienceMemberEmail(orgId, memberId, payload, actorId);
return getOrgAudienceMember(orgId, memberId);
}
+135
View File
@@ -0,0 +1,135 @@
import type {
TPublicApiCourseParam,
TPublicApiCoursesQuery,
TPublicApiCreateCourse,
TPublicApiUpdateCourse,
TPublicApiUpdateCourseStructure
} from '@cio/utils/validation/public-api';
import { ROLE } from '@cio/utils/constants';
import {
createCourse as createCourseService,
deleteCourse as deleteCourseService,
getCourse,
updateCourse as updateCourseService
} from '@api/services/course/course';
import {
createCourseImportDraftService,
getCourseImportStructureService,
publishCourseImportDraftToExistingCourseService
} from '@api/services/course-import/course-import';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { getOrganizationCourses } from '@api/services/organization';
import { listCourseMembers } from '@api/services/course/people';
import { AppError, ErrorCodes } from '@api/utils/errors';
async function assertCourseBelongsToOrganization(orgId: string, courseId: string): Promise<void> {
const courseOrganizationId = await getCourseOrganizationId(courseId);
if (!courseOrganizationId || courseOrganizationId !== orgId) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
}
export async function listCoursesService(orgId: string, query: TPublicApiCoursesQuery) {
const result = await getOrganizationCourses(orgId, '', ROLE.ADMIN, {
page: 1,
limit: 100,
search: undefined,
tags: query.tags
});
return result.items;
}
export async function getCourseService(orgId: string, params: TPublicApiCourseParam) {
await assertCourseBelongsToOrganization(orgId, params.courseId);
return getCourse(params.courseId);
}
export async function listCourseStudentsService(orgId: string, params: TPublicApiCourseParam) {
await assertCourseBelongsToOrganization(orgId, params.courseId);
const members = await listCourseMembers(params.courseId);
return members.filter((member) => member.roleId === ROLE.STUDENT);
}
export async function exportCourseService(orgId: string, params: TPublicApiCourseParam) {
await assertCourseBelongsToOrganization(orgId, params.courseId);
return getCourseImportStructureService(orgId, params.courseId);
}
export async function createPublicApiCourseService(
orgId: string,
actorId: string | null,
payload: TPublicApiCreateCourse
) {
if (!actorId) {
throw new AppError('Automation actor is required', ErrorCodes.UNAUTHORIZED, 401);
}
return createCourseService(actorId, {
...payload,
organizationId: orgId
});
}
export async function updatePublicApiCourseService(
orgId: string,
params: TPublicApiCourseParam,
payload: TPublicApiUpdateCourse
) {
await assertCourseBelongsToOrganization(orgId, params.courseId);
return updateCourseService(params.courseId, payload);
}
export async function deletePublicApiCourseService(orgId: string, params: TPublicApiCourseParam) {
await assertCourseBelongsToOrganization(orgId, params.courseId);
return deleteCourseService(params.courseId);
}
export async function updatePublicApiCourseStructureService(
orgId: string,
actorId: string | null,
params: TPublicApiCourseParam,
payload: TPublicApiUpdateCourseStructure
) {
if (!actorId) {
throw new AppError('Automation actor is required', ErrorCodes.UNAUTHORIZED, 401);
}
await assertCourseBelongsToOrganization(orgId, params.courseId);
const draft = await createCourseImportDraftService(orgId, actorId, {
sourceType: 'course',
idempotencyKey: payload.idempotencyKey,
summary: {
sourceCourseId: params.courseId,
syncMode: payload.mode,
...(payload.summary ?? {})
},
sourceArtifacts: [
{
type: 'course',
courseId: params.courseId,
label: payload.draft.course.title
},
...(payload.sourceArtifacts ?? [])
],
draft: payload.draft
});
const result = await publishCourseImportDraftToExistingCourseService(orgId, draft.id, {
courseId: params.courseId,
syncMode: payload.mode
});
return {
draftId: draft.id,
syncMode: payload.mode,
...result
};
}