feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)
New features: - HLS video encoding/streaming (hls routes, hls-encoder) and a media transcripts pipeline (transcripts route + media-transcript queries) - AI assistant chat vs. run model: agent-course-generation worker/queue, run-monitor pane, chat-changes card, content ask-ai bar - Per-theme course-page previews across org landing-page themes (bold, classic, corporate, editorial, etc.) with a shared course token/component set - View-as-student mode and login-link / link-invite invite flows Refactor: - Extract packages/core consolidating config (env, storage, dashboard-url), services and utils previously duplicated under apps/api/src/config - Remove legacy agent service modules (chat-tools, chat-context, context-window, document, fetch-url, lesson-content) - Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding - Add format script to tenant-router so turbo run format covers it
This commit is contained in:
@@ -1,256 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import type { DbOrTxClient } from '@cio/db/drizzle';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { applyCourseContentBulkUpdates, applyCourseSectionOrderUpdates } from '@cio/db/queries/course/content-batch';
|
||||
import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId } from '@cio/db/queries/course';
|
||||
import { OperationalQueryError } from '@cio/db/queries/query-errors';
|
||||
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||
import { deleteLesson } from '@cio/db/queries/lesson';
|
||||
import { deleteExercise } from '@cio/db/queries/exercise/exercise';
|
||||
import type { TCourseContentDelete, TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
type CourseContentReorderResult = {
|
||||
courseId: string;
|
||||
updatedSections: number;
|
||||
updatedItems: number;
|
||||
updatedLessons: number;
|
||||
updatedExercises: number;
|
||||
};
|
||||
|
||||
function buildContentKey(item: { id: string; type: string }) {
|
||||
return `${item.type}-${item.id}`;
|
||||
}
|
||||
|
||||
function normalizeDeleteItems<TItem extends { id: string; type: string }>(items: TItem[]): TItem[] {
|
||||
const seenKeys = new Set<string>();
|
||||
const uniqueItems: TItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const itemKey = buildContentKey(item);
|
||||
if (seenKeys.has(itemKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenKeys.add(itemKey);
|
||||
uniqueItems.push(item);
|
||||
}
|
||||
|
||||
const exercises = uniqueItems.filter((item) => item.type === ContentType.Exercise);
|
||||
const lessons = uniqueItems.filter((item) => item.type === ContentType.Lesson);
|
||||
|
||||
return [...exercises, ...lessons];
|
||||
}
|
||||
|
||||
async function getCourseContentMap(courseId: string) {
|
||||
const contentItems = await getCourseContentItems(courseId);
|
||||
const contentMap = new Map<string, CourseContentItemRow>();
|
||||
|
||||
contentItems.forEach((item) => {
|
||||
if (item.type === ContentType.Lesson || item.type === ContentType.Exercise) {
|
||||
contentMap.set(buildContentKey(item), item);
|
||||
}
|
||||
});
|
||||
|
||||
return contentMap;
|
||||
}
|
||||
|
||||
async function assertCourseContentItems(courseId: string, items: TCourseContentUpdate['items']) {
|
||||
const contentMap = await getCourseContentMap(courseId);
|
||||
|
||||
items.forEach((item) => {
|
||||
const key = buildContentKey({ id: item.id, type: item.type });
|
||||
|
||||
if (!contentMap.has(key)) {
|
||||
if (item.type === ContentType.Lesson) {
|
||||
throw new AppError('Lesson does not belong to this course', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (item.type === ContentType.Exercise) {
|
||||
throw new AppError('Exercise does not belong to this course', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
throw new AppError('Invalid content type', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCourseSections(courseId: string, sections: NonNullable<TCourseContentReorder['sections']>) {
|
||||
const existingSections = await getCourseSectionsByCourseId(courseId);
|
||||
const sectionIds = new Set(existingSections.map((section) => section.id));
|
||||
|
||||
sections.forEach((section) => {
|
||||
if (!sectionIds.has(section.id)) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function mapContentBatchError(error: unknown): AppError | null {
|
||||
if (error instanceof OperationalQueryError) {
|
||||
return new AppError(error.message, error.errorCode, error.statusCode as ContentfulStatusCode, error.field);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function transactionWithContentBatch<TResult>(runner: (tx: DbOrTxClient) => Promise<TResult>): Promise<TResult> {
|
||||
try {
|
||||
return await db.transaction(async (tx) => runner(tx));
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const mapped = mapContentBatchError(error);
|
||||
|
||||
if (mapped) {
|
||||
throw mapped;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCourseContent(courseId: string, items: TCourseContentUpdate['items']) {
|
||||
try {
|
||||
await assertCourseContentItems(courseId, items);
|
||||
|
||||
await transactionWithContentBatch(async (tx) => applyCourseContentBulkUpdates(tx, items));
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update course content',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderCourseContent(
|
||||
courseId: string,
|
||||
payload: TCourseContentReorder
|
||||
): Promise<CourseContentReorderResult> {
|
||||
try {
|
||||
if (payload.sections?.length) {
|
||||
await assertCourseSections(courseId, payload.sections);
|
||||
}
|
||||
|
||||
if (payload.items?.length) {
|
||||
await assertCourseContentItems(courseId, payload.items);
|
||||
}
|
||||
|
||||
return await transactionWithContentBatch(async (tx) => {
|
||||
const updatedSections = payload.sections?.length ? await applyCourseSectionOrderUpdates(tx, payload.sections) : 0;
|
||||
|
||||
const itemResult = payload.items?.length
|
||||
? await applyCourseContentBulkUpdates(tx, payload.items)
|
||||
: {
|
||||
updatedItems: 0,
|
||||
updatedLessons: 0,
|
||||
updatedExercises: 0
|
||||
};
|
||||
|
||||
return {
|
||||
courseId,
|
||||
updatedSections,
|
||||
...itemResult
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to reorder course content',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCourseContent(courseId: string, payload: TCourseContentDelete) {
|
||||
try {
|
||||
if (payload.sectionId) {
|
||||
const sectionId = payload.sectionId;
|
||||
const section = await getCourseSectionById(sectionId);
|
||||
if (!section || section.courseId !== courseId) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const contentItems = await getCourseContentItems(courseId);
|
||||
const itemsToDelete = normalizeDeleteItems(
|
||||
contentItems.filter(
|
||||
(item) =>
|
||||
(item.type === ContentType.Lesson || item.type === ContentType.Exercise) && item.sectionId === sectionId
|
||||
)
|
||||
);
|
||||
|
||||
console.log('Items to delete:', itemsToDelete);
|
||||
await db.transaction(async (tx) => {
|
||||
for (const item of itemsToDelete) {
|
||||
if (item.type === ContentType.Lesson) {
|
||||
const deleted = await deleteLesson(item.id, tx);
|
||||
if (!deleted) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const deleted = await deleteExercise(item.id, tx);
|
||||
if (!deleted) {
|
||||
console.log('delete exercise failed for id:', item.id);
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
|
||||
const deletedSection = await deleteCourseSection(sectionId, tx);
|
||||
if (!deletedSection) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.items) {
|
||||
throw new AppError('Content items are required', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const items = payload.items;
|
||||
await assertCourseContentItems(courseId, items);
|
||||
const normalizedItems = normalizeDeleteItems(items);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const item of normalizedItems) {
|
||||
if (item.type === ContentType.Lesson) {
|
||||
const deleted = await deleteLesson(item.id, tx);
|
||||
if (!deleted) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const deleted = await deleteExercise(item.id, tx);
|
||||
if (!deleted) {
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete course content',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,580 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
|
||||
import {
|
||||
createCourseNewsfeed,
|
||||
createCourse as createCourseQuery,
|
||||
createCourseSections,
|
||||
deleteCourse as deleteCourseQuery,
|
||||
getCourseProgress as getCourseProgressQuery,
|
||||
getCourseTypeById,
|
||||
getCourseWithRelations,
|
||||
updateCourse as updateCourseQuery,
|
||||
updateLessonsSectionId
|
||||
} from '@cio/db/queries/course';
|
||||
import {
|
||||
addGroupMember,
|
||||
createGroup,
|
||||
getCourseProgramAccess,
|
||||
insertGroupMembersOnConflictDoNothing
|
||||
} from '@cio/db/queries/group';
|
||||
import {
|
||||
getLastLogin,
|
||||
getLessonsWithCompletion,
|
||||
getProfileCourseProgress,
|
||||
getUserExercisesStats
|
||||
} from '@cio/db/queries/analytics/analytics';
|
||||
|
||||
import { ContentType, ROLE } from '@cio/utils/constants';
|
||||
import type { TCourse } from '@cio/db/types';
|
||||
import type { TCourseCreate } from '@cio/utils/validation/course';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
|
||||
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
|
||||
import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type CourseContent } from './utils';
|
||||
import { guardCourseTypeTransition } from './public-course-guard';
|
||||
|
||||
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 insertOrganizationMembersOnConflictDoNothing(
|
||||
[
|
||||
{
|
||||
organizationId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
}
|
||||
],
|
||||
tx
|
||||
);
|
||||
|
||||
await insertGroupMembersOnConflictDoNothing(
|
||||
[
|
||||
{
|
||||
groupId: access.courseGroupId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail ?? undefined
|
||||
}
|
||||
],
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function sanitizeCourseMetadata(metadata: TCourse['metadata'] | undefined) {
|
||||
if (!metadata) return metadata;
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
description: sanitizeOptionalHtml(metadata.description),
|
||||
goals: sanitizeOptionalHtml(metadata.goals),
|
||||
requirements: sanitizeOptionalHtml(metadata.requirements),
|
||||
reward: metadata.reward
|
||||
? {
|
||||
...metadata.reward,
|
||||
description: sanitizeHtml(metadata.reward.description)
|
||||
}
|
||||
: metadata.reward,
|
||||
instructor: metadata.instructor
|
||||
? {
|
||||
...metadata.instructor,
|
||||
description: sanitizeHtml(metadata.instructor.description)
|
||||
}
|
||||
: metadata.instructor,
|
||||
reviews: metadata.reviews?.map((review) => ({
|
||||
...review,
|
||||
description: sanitizeHtml(review.description)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeCourseCertificate(certificate: TCourse['certificate'] | undefined) {
|
||||
if (!certificate) return certificate;
|
||||
|
||||
return {
|
||||
...certificate,
|
||||
emailMessage: sanitizeOptionalHtml(certificate.emailMessage)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a course by ID or slug with all related data
|
||||
* @param courseId Course ID (optional if slug provided)
|
||||
* @param slug Course slug (optional if courseId provided)
|
||||
* @param profileId Profile ID of the current user (optional, for computing lesson completion)
|
||||
* @returns Course with all related data
|
||||
*/
|
||||
export async function getCourse(courseId?: string, slug?: string, profileId?: string) {
|
||||
try {
|
||||
if (!courseId && !slug) {
|
||||
throw new AppError('Either courseId or slug must be provided', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const course = await getCourseWithRelations(courseId, slug, profileId);
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const isContentGroupingEnabled = course.metadata?.isContentGroupingEnabled ?? DEFAULT_CONTENT_GROUPING;
|
||||
const content = buildCourseContent(course.contentItems, isContentGroupingEnabled);
|
||||
|
||||
const { contentItems, org: courseOrg, ...rest } = course;
|
||||
|
||||
const base = {
|
||||
...rest,
|
||||
content
|
||||
};
|
||||
|
||||
if (courseOrg) {
|
||||
return {
|
||||
...base,
|
||||
org: {
|
||||
id: courseOrg.id,
|
||||
name: courseOrg.name,
|
||||
siteName: courseOrg.siteName ?? '',
|
||||
theme: courseOrg.theme ?? undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
} catch (error) {
|
||||
console.error('Error getting course:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch course',
|
||||
ErrorCodes.COURSE_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new course with group, group member, and default newsfeed
|
||||
* @param profileId Profile ID of the creator
|
||||
* @param data Course creation data
|
||||
* @returns Created course with group and member information
|
||||
*/
|
||||
export async function createCourse(
|
||||
profileId: string,
|
||||
data: TCourseCreate
|
||||
): Promise<{ course: TCourse; groupId: string; memberId: string }> {
|
||||
try {
|
||||
const description = sanitizeHtml(data.description);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const [newCourse] = await createCourseQuery(
|
||||
{
|
||||
title: data.title,
|
||||
description,
|
||||
type: data.type,
|
||||
groupId: newGroup.id,
|
||||
compliance: data.compliance ? sanitizeUnknownStrings(data.compliance) : undefined
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!newCourse) {
|
||||
throw new AppError('Failed to create course', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
await createCourseNewsfeed(
|
||||
{
|
||||
content: sanitizeHtml(`<h2>Welcome to this course 🎉 </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
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return {
|
||||
course: newCourse,
|
||||
groupId: newGroup.id,
|
||||
memberId: newMember.id
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create course',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a course
|
||||
* @param courseId Course ID
|
||||
* @param data Course update data
|
||||
* @returns Updated course
|
||||
*/
|
||||
export async function updateCourse(courseId: string, data: Partial<TCourse>) {
|
||||
try {
|
||||
const sanitizedData: Partial<TCourse> = {
|
||||
...data,
|
||||
description: sanitizeOptionalHtml(data.description),
|
||||
overview: sanitizeOptionalHtml(data.overview),
|
||||
metadata: sanitizeCourseMetadata(data.metadata),
|
||||
certificate: sanitizeCourseCertificate(data.certificate),
|
||||
logo: data.logo,
|
||||
slug: data.slug
|
||||
};
|
||||
|
||||
if (data.type !== undefined) {
|
||||
const currentType = await getCourseTypeById(courseId);
|
||||
|
||||
await guardCourseTypeTransition({
|
||||
courseId,
|
||||
currentType: currentType ?? null,
|
||||
nextType: data.type
|
||||
});
|
||||
}
|
||||
|
||||
if (data.certificate?.requiredExerciseId) {
|
||||
const ok = await exerciseBelongsToCourse(data.certificate.requiredExerciseId, courseId);
|
||||
if (!ok) {
|
||||
throw new AppError('Certification exercise must belong to this course', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateCourseQuery(courseId, sanitizeUnknownStrings(sanitizedData));
|
||||
if (!updated) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const isContentGroupingEnabled = (updated.metadata?.isContentGroupingEnabled ?? DEFAULT_CONTENT_GROUPING) === true;
|
||||
|
||||
if (isContentGroupingEnabled) {
|
||||
const courseWithRelations = await getCourseWithRelations(courseId);
|
||||
if (courseWithRelations) {
|
||||
const contentItems = courseWithRelations.contentItems;
|
||||
const hasSections = contentItems.some((item) => item.type === ContentType.Section);
|
||||
const hasUngroupedItems = contentItems.some(
|
||||
(item) => (item.type === ContentType.Lesson || item.type === ContentType.Exercise) && item.sectionId === null
|
||||
);
|
||||
|
||||
if (!hasSections && hasUngroupedItems) {
|
||||
await db.transaction(async (tx) => {
|
||||
const [section] = await createCourseSections(
|
||||
[
|
||||
{
|
||||
title: DEFAULT_SECTION_TITLE,
|
||||
order: 1,
|
||||
courseId
|
||||
}
|
||||
],
|
||||
tx
|
||||
);
|
||||
|
||||
if (!section) {
|
||||
throw new AppError('Failed to create course section', ErrorCodes.COURSE_SECTION_NOT_FOUND, 500);
|
||||
}
|
||||
|
||||
await updateLessonsSectionId(courseId, section.id, tx);
|
||||
await updateExercisesSectionId(courseId, section.id, tx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update course',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft deletes a course by setting status to 'DELETED'
|
||||
* @param courseId Course ID
|
||||
* @returns Deleted course
|
||||
*/
|
||||
export async function deleteCourse(courseId: string) {
|
||||
try {
|
||||
const deleted = await deleteCourseQuery(courseId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete course',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets course progress for a profile
|
||||
* @param courseId Course ID
|
||||
* @param profileId Profile ID
|
||||
* @returns Course progress with counts
|
||||
*/
|
||||
export async function getCourseProgress(courseId: string, profileId: string) {
|
||||
try {
|
||||
const progress = await getCourseProgressQuery(courseId, profileId);
|
||||
return progress;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get course progress',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets course analytics including student progress, completion rates, and grades
|
||||
* @param courseId Course ID
|
||||
* @returns Course analytics data
|
||||
*/
|
||||
export async function getCourseAnalytics(courseId: string) {
|
||||
try {
|
||||
// Get course with members (no profileId needed for analytics)
|
||||
const course = await getCourseWithRelations(courseId);
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
// Get all members
|
||||
const members = course.group?.members || [];
|
||||
|
||||
// Filter students and tutors
|
||||
const students = members.filter((member) => member.roleId === ROLE.STUDENT);
|
||||
const tutors = members.filter((member) => member.roleId === ROLE.ADMIN || member.roleId === ROLE.TUTOR);
|
||||
|
||||
// Get lessons and exercises
|
||||
const lessons = course.contentItems.filter((item) => item.type === ContentType.Lesson);
|
||||
const exercises = course.contentItems.filter((item) => item.type === ContentType.Exercise);
|
||||
|
||||
// Get student analytics
|
||||
const studentAnalytics = await Promise.all(
|
||||
students
|
||||
.filter((student) => student.profileId !== null)
|
||||
.map(async (student) => {
|
||||
try {
|
||||
const [courseProgress, userExercisesStats, lastLoginDate] = await Promise.all([
|
||||
getCourseProgressQuery(courseId, student.profileId!),
|
||||
getUserExercisesStats(courseId, student.profileId!),
|
||||
getLastLogin(student.profileId!)
|
||||
]);
|
||||
|
||||
if (!courseProgress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedExercises = userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0;
|
||||
const totalExercises = userExercisesStats?.length || 0;
|
||||
|
||||
const totalEarnedPoints = userExercisesStats?.reduce((sum, exercise) => sum + exercise.score, 0) || 0;
|
||||
const totalPoints = userExercisesStats?.reduce((sum, exercise) => sum + exercise.totalPoints, 0) || 0;
|
||||
const averageGrade = calcPercentageWithRounding(totalEarnedPoints, totalPoints);
|
||||
|
||||
const lessonsCompleted = courseProgress.lessonsCompleted || 0;
|
||||
const totalLessons = courseProgress.lessonsCount || 0;
|
||||
const progressPercentage = calcPercentageWithRounding(lessonsCompleted, totalLessons);
|
||||
|
||||
return {
|
||||
id: student.profileId,
|
||||
profile: {
|
||||
fullname: student.profile?.fullname || 'Unknown',
|
||||
email: student.profile?.email || '',
|
||||
avatar_url: student.profile?.avatarUrl || ''
|
||||
},
|
||||
lessonsCompleted,
|
||||
totalLessons,
|
||||
exercisesSubmitted: completedExercises,
|
||||
totalExercises,
|
||||
averageGrade,
|
||||
lastSeen: formatLastSeen(lastLoginDate),
|
||||
progressPercentage
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting student overview:', error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const validStudentAnalytics = studentAnalytics.filter(
|
||||
(student): student is NonNullable<typeof student> => student !== null
|
||||
);
|
||||
|
||||
// Calculate aggregated metrics
|
||||
let lessonCompletionRate = 0;
|
||||
let exerciseCompletionRate = 0;
|
||||
let averageGrade = 0;
|
||||
|
||||
if (validStudentAnalytics.length > 0) {
|
||||
lessonCompletionRate = Math.round(
|
||||
validStudentAnalytics.reduce((sum, student) => sum + student.progressPercentage, 0) /
|
||||
validStudentAnalytics.length
|
||||
);
|
||||
|
||||
exerciseCompletionRate = Math.round(
|
||||
validStudentAnalytics.reduce((sum, student) => {
|
||||
const completionRate =
|
||||
student.totalExercises > 0 ? (student.exercisesSubmitted / student.totalExercises) * 100 : 0;
|
||||
return sum + completionRate;
|
||||
}, 0) / validStudentAnalytics.length
|
||||
);
|
||||
|
||||
averageGrade = Math.round(
|
||||
validStudentAnalytics.reduce((sum, student) => sum + student.averageGrade, 0) / validStudentAnalytics.length
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
totalTutors: tutors.length,
|
||||
totalStudents: students.length,
|
||||
totalLessons: lessons.length,
|
||||
totalExercises: exercises.length,
|
||||
lessonCompletionRate,
|
||||
exerciseCompletionRate,
|
||||
averageGrade,
|
||||
students: validStudentAnalytics
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get course analytics',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets user course analytics for a specific course
|
||||
* @param courseId Course ID
|
||||
* @param userId User ID (profile ID)
|
||||
* @returns User course analytics data
|
||||
*/
|
||||
export async function getUserCourseAnalytics(courseId: string, userId: string) {
|
||||
try {
|
||||
// Get user profile
|
||||
const profile = await getProfileById(userId);
|
||||
if (!profile) {
|
||||
throw new AppError('User profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
// Get last login
|
||||
const lastLoginDate = await getLastLogin(userId);
|
||||
|
||||
// Fetch user exercises stats, lessons with completion, and course progress
|
||||
const [userExercisesStats, lessons, courseProgress] = await Promise.all([
|
||||
getUserExercisesStats(courseId, userId),
|
||||
getLessonsWithCompletion(courseId, userId),
|
||||
getProfileCourseProgress(courseId, userId)
|
||||
]);
|
||||
|
||||
if (!userExercisesStats || !lessons || !courseProgress) {
|
||||
throw new AppError('Failed to fetch course analytics data', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
const totalEarnedPoints = userExercisesStats.reduce((sum, exercise) => sum + exercise.score, 0);
|
||||
const totalPoints = userExercisesStats.reduce((sum, exercise) => sum + exercise.totalPoints, 0);
|
||||
const averageGrade = calcPercentageWithRounding(totalEarnedPoints, totalPoints);
|
||||
|
||||
const completedLessons = lessons.filter((lesson) => lesson.completed);
|
||||
const progressPercentage = calcPercentageWithRounding(completedLessons.length, lessons.length);
|
||||
|
||||
const completedExercises = userExercisesStats.filter((exercise) => exercise.isCompleted).length;
|
||||
const totalExercises = courseProgress.exercises_count || 0;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: userId,
|
||||
fullName: profile.fullname || '',
|
||||
email: profile.email || '',
|
||||
avatarUrl: profile.avatarUrl || '',
|
||||
lastSeen: formatLastSeen(lastLoginDate)
|
||||
},
|
||||
averageGrade,
|
||||
userExercisesStats,
|
||||
totalExercises,
|
||||
completedExercises,
|
||||
progressPercentage
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError('Failed to get user exercises stats', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export type { CourseContent, CourseContentItem } from './utils';
|
||||
@@ -1,263 +0,0 @@
|
||||
import { getCourseById } from '@cio/db/queries/course';
|
||||
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||
import type { TCourse, TOrganization } from '@cio/db/types';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { updateCourse } from './course';
|
||||
import { buildCourseBaseUrl, ensureCourseSlug, generateUniqueCourseSlug } from './landing-page';
|
||||
|
||||
export type CourseGoLiveIssue = {
|
||||
code: string;
|
||||
message: string;
|
||||
target?: string;
|
||||
};
|
||||
|
||||
export type CourseGoLiveReadiness = {
|
||||
ready: boolean;
|
||||
blockers: CourseGoLiveIssue[];
|
||||
warnings: CourseGoLiveIssue[];
|
||||
suggestedFixes: {
|
||||
slug?: string;
|
||||
landingPage?: TCourseLandingPageUpdate;
|
||||
};
|
||||
courseUrl?: string;
|
||||
};
|
||||
|
||||
type CourseReadinessInput = {
|
||||
course: Pick<
|
||||
TCourse,
|
||||
'id' | 'title' | 'description' | 'overview' | 'slug' | 'logo' | 'bannerImage' | 'metadata' | 'type' | 'cost'
|
||||
>;
|
||||
contentItems: CourseContentItemRow[];
|
||||
suggestedSlug?: string;
|
||||
organization?: Pick<TOrganization, 'customDomain' | 'isCustomDomainVerified' | 'siteName'> | null;
|
||||
};
|
||||
|
||||
const PLACEHOLDER_PATTERNS = [
|
||||
/\[edit me\]/i,
|
||||
/^untitled\b/i,
|
||||
/^new course$/i,
|
||||
/^course title$/i,
|
||||
/^course description$/i,
|
||||
/welcome to this amazing course/i
|
||||
];
|
||||
|
||||
function normalizeReadableText(value: string | null | undefined): string {
|
||||
return (value ?? '')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ | /gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function hasMeaningfulText(value: string | null | undefined): boolean {
|
||||
const normalized = normalizeReadableText(value);
|
||||
|
||||
if (normalized.length < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function buildIssue(code: string, message: string, target?: string): CourseGoLiveIssue {
|
||||
return target ? { code, message, target } : { code, message };
|
||||
}
|
||||
|
||||
function hasLessonContent(item: CourseContentItemRow): boolean {
|
||||
return Boolean(
|
||||
item.hasNoteContent || item.hasSlideContent || (item.videosCount ?? 0) > 0 || (item.documentsCount ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
function buildCourseUrl(
|
||||
organization: CourseReadinessInput['organization'],
|
||||
slug: string | null | undefined
|
||||
): string | undefined {
|
||||
if (!organization || !slug) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
export function evaluateCourseGoLiveReadiness(input: CourseReadinessInput): CourseGoLiveReadiness {
|
||||
const { course, contentItems, organization, suggestedSlug } = input;
|
||||
const blockers: CourseGoLiveIssue[] = [];
|
||||
const warnings: CourseGoLiveIssue[] = [];
|
||||
const landingPageFixes: TCourseLandingPageUpdate = {};
|
||||
const metadataFixes: NonNullable<TCourseLandingPageUpdate['metadata']> = {};
|
||||
|
||||
if (!hasMeaningfulText(course.title)) {
|
||||
blockers.push(buildIssue('COURSE_TITLE_MISSING', 'Add a clear course title.', 'course.title'));
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.description)) {
|
||||
blockers.push(buildIssue('COURSE_DESCRIPTION_MISSING', 'Add a clear course description.', 'course.description'));
|
||||
}
|
||||
|
||||
if (!course.slug && !suggestedSlug) {
|
||||
blockers.push(buildIssue('COURSE_SLUG_MISSING', 'Generate a public course slug.', 'course.slug'));
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.overview)) {
|
||||
blockers.push(buildIssue('LANDING_OVERVIEW_MISSING', 'Add a landing-page overview.', 'course.overview'));
|
||||
landingPageFixes.overview = '';
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.description)) {
|
||||
blockers.push(
|
||||
buildIssue('LANDING_DESCRIPTION_MISSING', 'Add a landing-page description.', 'course.metadata.description')
|
||||
);
|
||||
metadataFixes.description = '';
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.goals)) {
|
||||
blockers.push(
|
||||
buildIssue('LANDING_GOALS_MISSING', 'Add course goals for the landing page.', 'course.metadata.goals')
|
||||
);
|
||||
metadataFixes.goals = '';
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.requirements)) {
|
||||
blockers.push(
|
||||
buildIssue(
|
||||
'LANDING_REQUIREMENTS_MISSING',
|
||||
'Add course requirements for the landing page.',
|
||||
'course.metadata.requirements'
|
||||
)
|
||||
);
|
||||
metadataFixes.requirements = '';
|
||||
}
|
||||
|
||||
if (!course.logo && !course.bannerImage) {
|
||||
blockers.push(buildIssue('LANDING_IMAGE_MISSING', 'Add a landing-page banner or course image.', 'course.logo'));
|
||||
landingPageFixes.generateImage = true;
|
||||
}
|
||||
|
||||
const learningItems = contentItems.filter(
|
||||
(item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise
|
||||
);
|
||||
|
||||
if (learningItems.length === 0) {
|
||||
blockers.push(buildIssue('COURSE_CONTENT_MISSING', 'Add at least one lesson or exercise.', 'course.content'));
|
||||
}
|
||||
|
||||
for (const item of contentItems) {
|
||||
if (item.type === ContentType.Lesson && !hasLessonContent(item)) {
|
||||
blockers.push(
|
||||
buildIssue(
|
||||
'LESSON_CONTENT_EMPTY',
|
||||
`Add content to lesson "${item.title ?? 'Untitled lesson'}".`,
|
||||
`lesson:${item.id}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === ContentType.Exercise && (item.questionCount ?? 0) === 0) {
|
||||
blockers.push(
|
||||
buildIssue(
|
||||
'EXERCISE_QUESTIONS_MISSING',
|
||||
`Add at least one question to exercise "${item.title ?? 'Untitled exercise'}".`,
|
||||
`exercise:${item.id}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.instructor?.name)) {
|
||||
warnings.push(
|
||||
buildIssue('INSTRUCTOR_MISSING', 'Add instructor information for more trust.', 'course.metadata.instructor')
|
||||
);
|
||||
}
|
||||
|
||||
if (!course.metadata?.reviews?.some((review) => !review.hide && hasMeaningfulText(review.description))) {
|
||||
warnings.push(
|
||||
buildIssue('REVIEWS_MISSING', 'Add testimonials or reviews when available.', 'course.metadata.reviews')
|
||||
);
|
||||
}
|
||||
|
||||
if ((course.cost ?? 0) > 0 && !hasMeaningfulText(course.metadata?.paymentLink)) {
|
||||
warnings.push(
|
||||
buildIssue('PAYMENT_LINK_MISSING', 'Add a payment link for this paid course.', 'course.metadata.paymentLink')
|
||||
);
|
||||
}
|
||||
|
||||
if (course.type === 'COMPLIANCE') {
|
||||
warnings.push(
|
||||
buildIssue('COMPLIANCE_REVIEW_RECOMMENDED', 'Review compliance settings before publishing.', 'course.compliance')
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(metadataFixes).length > 0) {
|
||||
landingPageFixes.metadata = metadataFixes;
|
||||
}
|
||||
|
||||
const suggestedFixes: CourseGoLiveReadiness['suggestedFixes'] = {};
|
||||
if (!course.slug && suggestedSlug) {
|
||||
suggestedFixes.slug = suggestedSlug;
|
||||
}
|
||||
|
||||
if (Object.keys(landingPageFixes).length > 0) {
|
||||
suggestedFixes.landingPage = landingPageFixes;
|
||||
}
|
||||
|
||||
return {
|
||||
ready: blockers.length === 0,
|
||||
blockers,
|
||||
warnings,
|
||||
suggestedFixes,
|
||||
courseUrl: buildCourseUrl(organization, course.slug ?? suggestedSlug)
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCourseGoLiveReadiness(courseId: string): Promise<CourseGoLiveReadiness> {
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const organizationId = await getCourseOrganizationId(courseId);
|
||||
const [contentItems, organization] = await Promise.all([
|
||||
getCourseContentItems(courseId),
|
||||
organizationId ? getOrganizationById(organizationId) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
const suggestedSlug = course.slug
|
||||
? undefined
|
||||
: await generateUniqueCourseSlug(generateSlug(course.title, { fallback: 'course' }));
|
||||
|
||||
return evaluateCourseGoLiveReadiness({
|
||||
course,
|
||||
contentItems,
|
||||
suggestedSlug,
|
||||
organization
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishCourseWhenReady(courseId: string) {
|
||||
const readiness = await getCourseGoLiveReadiness(courseId);
|
||||
if (!readiness.ready) {
|
||||
const blockerList = readiness.blockers.map((blocker) => blocker.message).join(' ');
|
||||
throw new AppError(`Course is not ready to go live. ${blockerList}`, 'COURSE_GO_LIVE_BLOCKED', 400);
|
||||
}
|
||||
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const slug = await ensureCourseSlug(courseId, course.title);
|
||||
const publishedCourse = await updateCourse(courseId, { slug, isPublished: true });
|
||||
const publishedReadiness = await getCourseGoLiveReadiness(courseId);
|
||||
|
||||
return {
|
||||
course: publishedCourse,
|
||||
readiness: publishedReadiness
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import { ROLE } from '@cio/utils/constants';
|
||||
import type { TCreateCourseInvite } from '@cio/utils/validation/course/invite';
|
||||
import type { TNewCourseInviteAudit } from '@db/types';
|
||||
import crypto from 'node:crypto';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { buildEmailFromName } from '@cio/email';
|
||||
@@ -37,6 +37,16 @@ import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
|
||||
|
||||
/**
|
||||
* Minimal org-url bundle threaded through invite/email paths so every URL
|
||||
* we generate respects the org's verified custom domain.
|
||||
*/
|
||||
type OrgUrlInfo = {
|
||||
siteName: string | null;
|
||||
customDomain: string | null;
|
||||
isCustomDomainVerified: boolean | null;
|
||||
};
|
||||
|
||||
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
||||
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
|
||||
|
||||
@@ -189,11 +199,12 @@ function getInviteStatus(invite: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the course enroll URL for invite links (emails, etc.).
|
||||
* Uses DASHBOARD_URL when set (self-hosted), otherwise org subdomain in production.
|
||||
* Builds the course enroll URL for invite links (emails, etc.). Respects the
|
||||
* org's verified custom domain when present, otherwise falls back to the
|
||||
* tenant subdomain.
|
||||
*/
|
||||
export function buildEnrollLink(courseSlug: string, token: string, orgSiteName?: string): string {
|
||||
const base = getDashboardBaseUrl(orgSiteName);
|
||||
export function buildEnrollLink(courseSlug: string, token: string, org?: OrgUrlInfo | null): string {
|
||||
const base = getDashboardBaseUrl(org ?? undefined);
|
||||
return `${base}/course/${encodeURIComponent(courseSlug)}/enroll?invite_token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
@@ -308,7 +319,7 @@ async function createSingleInvite(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
targetEmail: string | null,
|
||||
courseSlug: string,
|
||||
orgSiteName: string | null
|
||||
org: OrgUrlInfo
|
||||
) {
|
||||
const token = generateToken();
|
||||
const tokenHash = hashToken(token);
|
||||
@@ -338,7 +349,7 @@ async function createSingleInvite(
|
||||
|
||||
return {
|
||||
...created,
|
||||
inviteLink: buildEnrollLink(courseSlug, token, orgSiteName ?? undefined)
|
||||
inviteLink: buildEnrollLink(courseSlug, token, org)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -346,12 +357,12 @@ async function sendStudentJoinEmails(input: {
|
||||
courseId: string;
|
||||
courseName: string;
|
||||
orgName: string;
|
||||
orgSiteName: string | null;
|
||||
org: OrgUrlInfo;
|
||||
studentId: string;
|
||||
studentEmail: string;
|
||||
}) {
|
||||
try {
|
||||
const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined);
|
||||
const loginUrl = getDashboardBaseUrl(input.org);
|
||||
await enqueueTransactionalEmail('studentCourseWelcome', {
|
||||
to: input.studentEmail,
|
||||
fields: {
|
||||
@@ -402,7 +413,7 @@ async function createEmailInviteAndSend(input: {
|
||||
orgName: string;
|
||||
courseName: string;
|
||||
courseSlug: string;
|
||||
orgSiteName: string | null;
|
||||
org: OrgUrlInfo;
|
||||
metadata?: Record<string, unknown>;
|
||||
shouldSend: boolean;
|
||||
}) {
|
||||
@@ -421,7 +432,7 @@ async function createEmailInviteAndSend(input: {
|
||||
},
|
||||
input.recipientEmail,
|
||||
input.courseSlug,
|
||||
input.orgSiteName
|
||||
input.org
|
||||
);
|
||||
|
||||
if (!input.shouldSend) {
|
||||
@@ -503,7 +514,11 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
|
||||
|
||||
const courseTitle = course[0].title;
|
||||
const courseSlug = course[0].slug ?? (await ensureCourseSlug(course[0].id, courseTitle));
|
||||
const orgSiteName = courseOrgData.orgSiteName ?? null;
|
||||
const orgUrlInfo: OrgUrlInfo = {
|
||||
siteName: courseOrgData.orgSiteName ?? null,
|
||||
customDomain: courseOrgData.orgCustomDomain ?? null,
|
||||
isCustomDomainVerified: courseOrgData.orgIsCustomDomainVerified ?? null
|
||||
};
|
||||
|
||||
if (recipients.valid.length === 0) {
|
||||
const createdInvite = await createSingleInvite(
|
||||
@@ -515,7 +530,7 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
|
||||
data.metadata,
|
||||
null,
|
||||
courseSlug,
|
||||
orgSiteName
|
||||
orgUrlInfo
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -545,7 +560,7 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
|
||||
orgName,
|
||||
courseName,
|
||||
courseSlug,
|
||||
orgSiteName,
|
||||
org: orgUrlInfo,
|
||||
metadata: data.metadata,
|
||||
shouldSend: data.sendEmail
|
||||
})
|
||||
@@ -659,7 +674,7 @@ export async function enrollInCourse(
|
||||
courseId,
|
||||
courseName: title,
|
||||
orgName: org.name,
|
||||
orgSiteName: org.siteName,
|
||||
org: { siteName: org.siteName, customDomain: org.customDomain, isCustomDomainVerified: org.isCustomDomainVerified },
|
||||
studentId: user.id,
|
||||
studentEmail: normalizedEmail
|
||||
});
|
||||
@@ -925,7 +940,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
courseId: course.id,
|
||||
courseName: course.title,
|
||||
orgName: organization.name,
|
||||
orgSiteName: organization.siteName
|
||||
orgSiteName: organization.siteName,
|
||||
orgCustomDomain: organization.customDomain,
|
||||
orgIsCustomDomainVerified: organization.isCustomDomainVerified
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1010,7 +1027,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
courseId: course.id,
|
||||
courseName: course.title,
|
||||
orgName: organization.name,
|
||||
orgSiteName: organization.siteName
|
||||
orgSiteName: organization.siteName,
|
||||
orgCustomDomain: organization.customDomain,
|
||||
orgIsCustomDomainVerified: organization.isCustomDomainVerified
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1021,7 +1040,11 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
courseId: result.courseId,
|
||||
courseName: result.courseName,
|
||||
orgName: result.orgName,
|
||||
orgSiteName: result.orgSiteName,
|
||||
org: {
|
||||
siteName: result.orgSiteName,
|
||||
customDomain: result.orgCustomDomain,
|
||||
isCustomDomainVerified: result.orgIsCustomDomainVerified
|
||||
},
|
||||
studentId: user.id,
|
||||
studentEmail: normalizedEmail
|
||||
});
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { TCourse, TOrganization } from '@db/types';
|
||||
import type { TCourseLandingPageMetadataUpdate, TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
import { env } from '@api/config/env';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { getCourseById, isCourseSlugTaken, updateCourseSlug } from '@cio/db/queries/course';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||
import { updateCourse } from '@api/services/course/course';
|
||||
|
||||
type CourseLandingPageUpdateResult = {
|
||||
course: TCourse;
|
||||
courseUrl: string;
|
||||
bannerImageUrl: string | null;
|
||||
};
|
||||
|
||||
export async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
let suffix = 0;
|
||||
let candidate = baseSlug;
|
||||
|
||||
while (true) {
|
||||
const taken = await isCourseSlugTaken(candidate);
|
||||
if (!taken) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
candidate = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (course?.slug) {
|
||||
return course.slug;
|
||||
}
|
||||
|
||||
const baseSlug = generateSlug(title, { fallback: 'course' });
|
||||
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
|
||||
const updated = await updateCourseSlug(courseId, uniqueSlug);
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return updated.slug ?? uniqueSlug;
|
||||
}
|
||||
|
||||
export function buildCourseBaseUrl(
|
||||
organization: Pick<TOrganization, 'customDomain' | 'isCustomDomainVerified' | 'siteName'>
|
||||
) {
|
||||
if (organization.customDomain && organization.isCustomDomainVerified) {
|
||||
return `https://${organization.customDomain}`;
|
||||
}
|
||||
|
||||
return getDashboardBaseUrl(organization.siteName ?? undefined);
|
||||
}
|
||||
|
||||
export async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
|
||||
const organization = await getOrganizationById(organizationId);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const slug = await ensureCourseSlug(courseId, title);
|
||||
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
|
||||
if (!env.UNSPLASH_API_KEY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchQuery = (query?.trim() || courseTitle.trim() || 'education').slice(0, 120);
|
||||
const response = await fetch(
|
||||
`https://api.unsplash.com/search/photos?page=1&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}&query=${encodeURIComponent(searchQuery)}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
results?: Array<{
|
||||
urls?: {
|
||||
regular?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
const photos = data.results ?? [];
|
||||
if (photos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const photo = photos[Math.floor(Math.random() * photos.length)];
|
||||
return photo?.urls?.regular ?? null;
|
||||
}
|
||||
|
||||
async function resolveLandingPageImage(
|
||||
courseTitle: string,
|
||||
payload: Pick<TCourseLandingPageUpdate, 'generateImage' | 'imageQuery' | 'imageUrl'>
|
||||
) {
|
||||
if (payload.imageUrl) {
|
||||
return payload.imageUrl;
|
||||
}
|
||||
|
||||
if (!payload.generateImage && !payload.imageQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await resolveUnsplashBannerImage(courseTitle, payload.imageQuery);
|
||||
} catch (error) {
|
||||
console.error('resolveLandingPageImage error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeLandingPageMetadata(
|
||||
existing: TCourse['metadata'] | null | undefined,
|
||||
update: TCourseLandingPageMetadataUpdate | undefined
|
||||
): TCourse['metadata'] | undefined {
|
||||
if (!update) {
|
||||
return existing ?? undefined;
|
||||
}
|
||||
|
||||
const current: NonNullable<TCourse['metadata']> = {
|
||||
goals: '',
|
||||
description: '',
|
||||
requirements: '',
|
||||
allowNewStudent: true,
|
||||
...(existing ?? {})
|
||||
};
|
||||
|
||||
return {
|
||||
...current,
|
||||
...update,
|
||||
reward: update.reward
|
||||
? {
|
||||
show: update.reward.show ?? current.reward?.show ?? false,
|
||||
description: update.reward.description ?? current.reward?.description ?? ''
|
||||
}
|
||||
: current.reward,
|
||||
instructor: update.instructor
|
||||
? {
|
||||
name: update.instructor.name ?? current.instructor?.name ?? '',
|
||||
role: update.instructor.role ?? current.instructor?.role ?? '',
|
||||
coursesNo: update.instructor.coursesNo ?? current.instructor?.coursesNo ?? 0,
|
||||
description: update.instructor.description ?? current.instructor?.description ?? '',
|
||||
imgUrl: update.instructor.imgUrl ?? current.instructor?.imgUrl ?? ''
|
||||
}
|
||||
: current.instructor,
|
||||
certificate: update.certificate
|
||||
? {
|
||||
templateUrl: update.certificate.templateUrl ?? current.certificate?.templateUrl ?? ''
|
||||
}
|
||||
: current.certificate,
|
||||
sectionDisplay: update.sectionDisplay
|
||||
? { ...(current.sectionDisplay ?? {}), ...update.sectionDisplay }
|
||||
: current.sectionDisplay,
|
||||
reviews: update.reviews ?? current.reviews,
|
||||
lessonTabsOrder: update.lessonTabsOrder ?? current.lessonTabsOrder
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReviews(reviews: NonNullable<TCourse['metadata']>['reviews'] | undefined) {
|
||||
return reviews?.map((review, index) => ({
|
||||
id: review.id ?? Date.now() + index,
|
||||
hide: review.hide ?? false,
|
||||
name: review.name,
|
||||
avatar_url: review.avatar_url,
|
||||
rating: review.rating,
|
||||
created_at: review.created_at ?? Date.now(),
|
||||
description: review.description
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateCourseLandingPageService(
|
||||
courseId: string,
|
||||
payload: TCourseLandingPageUpdate
|
||||
): Promise<CourseLandingPageUpdateResult> {
|
||||
const [existingCourse] = await getCourseById(courseId);
|
||||
if (!existingCourse) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const nextTitle = payload.title ?? existingCourse.title;
|
||||
const metadata = mergeLandingPageMetadata(existingCourse.metadata ?? undefined, payload.metadata);
|
||||
const imageUrl = await resolveLandingPageImage(nextTitle, payload);
|
||||
|
||||
const updatedCourse = await updateCourse(courseId, {
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
overview: payload.overview,
|
||||
cost: payload.cost,
|
||||
currency: payload.currency,
|
||||
metadata: metadata
|
||||
? {
|
||||
...metadata,
|
||||
reviews: normalizeReviews(metadata.reviews)
|
||||
}
|
||||
: undefined,
|
||||
...(imageUrl ? { logo: imageUrl, bannerImage: imageUrl } : {})
|
||||
});
|
||||
|
||||
const organizationId = await getCourseOrganizationId(courseId);
|
||||
if (!organizationId) {
|
||||
throw new AppError('Course organization not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const courseUrl = await resolveCourseUrl(organizationId, courseId, updatedCourse.title);
|
||||
|
||||
return {
|
||||
course: updatedCourse,
|
||||
courseUrl,
|
||||
bannerImageUrl: updatedCourse.logo || imageUrl || null
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type { TAddCourseMembers } from '@cio/utils/validation/course/people';
|
||||
import type { TGroupmember } from '@cio/db/types';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { getCourseWithOrgData } from '@cio/db/queries/course';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { buildEmailFromName } from '@cio/email';
|
||||
@@ -68,7 +68,11 @@ export async function addMember(
|
||||
if (courseOrgData) {
|
||||
const courseName = courseOrgData.courseTitle || '';
|
||||
const orgName = courseOrgData.orgName || 'ClassroomIO';
|
||||
const loginUrl = getDashboardBaseUrl(courseOrgData.orgSiteName ?? undefined);
|
||||
const loginUrl = getDashboardBaseUrl({
|
||||
siteName: courseOrgData.orgSiteName,
|
||||
customDomain: courseOrgData.orgCustomDomain,
|
||||
isCustomDomainVerified: courseOrgData.orgIsCustomDomainVerified
|
||||
});
|
||||
|
||||
// Get student profile data
|
||||
let studentEmail: string | null = null;
|
||||
@@ -190,7 +194,11 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
|
||||
}
|
||||
|
||||
// Send welcome emails for teachers (TUTOR role = 2, ADMIN role = 1)
|
||||
const baseUrl = getDashboardBaseUrl(orgSiteName);
|
||||
const baseUrl = getDashboardBaseUrl({
|
||||
siteName: orgSiteName,
|
||||
customDomain: courseOrgData.orgCustomDomain,
|
||||
isCustomDomainVerified: courseOrgData.orgIsCustomDomainVerified
|
||||
});
|
||||
const inviteLink = `${baseUrl}/org/${orgSiteName}/courses`;
|
||||
|
||||
const teacherEmailPromises: Promise<unknown>[] = [];
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { findNonAutoGradableQuestionsInCourse, getCourseTypeById } from '@cio/db/queries/course';
|
||||
import { AUTO_GRADABLE_QUESTION_TYPE_IDS, isAutoGradableQuestionTypeId } from '@cio/question-types';
|
||||
|
||||
/**
|
||||
* Throws `QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE` when a non-auto-gradable question
|
||||
* type is being attached to an exercise that belongs to a `PUBLIC` course.
|
||||
*
|
||||
* Accepts the full list of question type ids being created / updated (ignore-null safe).
|
||||
*/
|
||||
export async function guardNonAutoGradableQuestionsForCourseType(params: {
|
||||
courseId: string;
|
||||
questionTypeIds: Array<number | null | undefined>;
|
||||
}): Promise<void> {
|
||||
const typeIds = params.questionTypeIds.filter((id): id is number => typeof id === 'number');
|
||||
if (typeIds.length === 0) return;
|
||||
|
||||
const hasDisallowed = typeIds.some((id) => !isAutoGradableQuestionTypeId(id));
|
||||
if (!hasDisallowed) return;
|
||||
|
||||
const rowType = await getCourseTypeById(params.courseId);
|
||||
|
||||
if (!rowType) return;
|
||||
|
||||
if (rowType === 'PUBLIC') {
|
||||
throw new AppError(
|
||||
'Public courses only support auto-gradable question types',
|
||||
ErrorCodes.QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE,
|
||||
400,
|
||||
'questions'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When converting a course to `PUBLIC`, enumerate any questions whose type is not
|
||||
* auto-gradable. Throws `PUBLIC_COURSE_CONVERSION_BLOCKED` with the offending list
|
||||
* in `message`; callers should surface the list to the creator so they can fix them.
|
||||
*/
|
||||
export async function guardCourseTypeTransition(params: {
|
||||
courseId: string;
|
||||
currentType: string | null;
|
||||
nextType: string | null | undefined;
|
||||
}): Promise<void> {
|
||||
if (!params.nextType) return;
|
||||
if (params.nextType !== 'PUBLIC') return;
|
||||
if (params.currentType === 'PUBLIC') return;
|
||||
|
||||
const offenders = await findNonAutoGradableQuestionsInCourse(params.courseId, AUTO_GRADABLE_QUESTION_TYPE_IDS);
|
||||
if (offenders.length === 0) return;
|
||||
|
||||
const preview = offenders.slice(0, 5).map((item) => `"${item.questionTitle}" in "${item.exerciseTitle}"`);
|
||||
const more = offenders.length > 5 ? ` (+${offenders.length - 5} more)` : '';
|
||||
|
||||
throw new AppError(
|
||||
`Remove or replace these non-auto-gradable questions before converting to Public: ${preview.join('; ')}${more}`,
|
||||
ErrorCodes.PUBLIC_COURSE_CONVERSION_BLOCKED,
|
||||
400,
|
||||
'type'
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import {
|
||||
generateTranscriptVttPresignedUrl,
|
||||
generateVideoDownloadPresignedUrls,
|
||||
TRANSCRIPT_VTT_PRESIGN_SECONDS
|
||||
} from '@api/utils/s3';
|
||||
} from '@cio/core/utils/s3';
|
||||
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
|
||||
import { getMediaTranscriptByAsset } from '@cio/db/queries';
|
||||
import { getAssetsByIds } from '@cio/db/queries/assets';
|
||||
import { mintHlsToken } from '@cio/core/services/assets/assets';
|
||||
import {
|
||||
getCourseRowBySlug,
|
||||
getPublicCourseItem as getPublicCourseItemQuery,
|
||||
@@ -72,6 +73,43 @@ export interface PublicExerciseQuestion {
|
||||
* For exercises, questions + options are returned with answer keys included
|
||||
* because PUBLIC courses grade entirely on the client.
|
||||
*/
|
||||
/**
|
||||
* Mint a `cio_hls` cookie for an anonymous learner viewing a public
|
||||
* lesson. The asset id is **derived from the public course tree**, not
|
||||
* accepted from the caller — so a learner who only knows a random asset
|
||||
* UUID cannot mint a cookie. They have to know a public course slug +
|
||||
* lesson slug whose video actually exposes the asset.
|
||||
*/
|
||||
export async function issuePublicHlsCookieService(courseSlug: string, itemSlug: string) {
|
||||
try {
|
||||
const item = await getPublicCourseItemQuery(courseSlug, itemSlug);
|
||||
if (!item || item.kind !== 'lesson') {
|
||||
throw new AppError('Public lesson not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const video = item.video;
|
||||
if (video?.type !== 'upload' || !video.assetId) {
|
||||
throw new AppError('Lesson has no playable HLS asset', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const assetId = video.assetId;
|
||||
const assets = await getAssetsByIds([assetId], item.courseOrganizationId ?? undefined);
|
||||
const asset = assets[0];
|
||||
if (!asset?.hlsManifestKey) {
|
||||
throw new AppError('Lesson has no playable HLS asset', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
return await mintHlsToken(assetId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to issue HLS cookie',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPublicCourseItemService(
|
||||
courseSlug: string,
|
||||
itemSlug: string
|
||||
@@ -87,17 +125,30 @@ export async function getPublicCourseItemService(
|
||||
|
||||
if (video?.type === 'upload') {
|
||||
let storageKey: string | undefined;
|
||||
let hlsManifestKey: string | null = null;
|
||||
|
||||
if (video.assetId) {
|
||||
const assets = await getAssetsByIds([video.assetId], courseOrganizationId ?? undefined);
|
||||
storageKey = assets[0]?.storageKey ?? undefined;
|
||||
hlsManifestKey = assets[0]?.hlsManifestKey ?? null;
|
||||
}
|
||||
|
||||
if (!storageKey && video.key) {
|
||||
storageKey = video.key;
|
||||
}
|
||||
|
||||
if (storageKey) {
|
||||
// HLS assets: serve the manifest URL via the api route (auth'd by
|
||||
// the cio_hls cookie that the public-cookie endpoint mints when the
|
||||
// learner loads the lesson). Don't presign storageKey — for HLS
|
||||
// it's null anyway.
|
||||
if (hlsManifestKey) {
|
||||
const { key: _omitKey, ...rest } = video;
|
||||
video = {
|
||||
...rest,
|
||||
link: `/hls/${hlsManifestKey}`.replace(/\/{2,}/g, '/'),
|
||||
hls: true
|
||||
};
|
||||
} else if (storageKey) {
|
||||
const urls = await generateVideoDownloadPresignedUrls([storageKey]);
|
||||
const signedLink = urls[storageKey];
|
||||
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import type { TCourseSection } from '@cio/db/types';
|
||||
import {
|
||||
createCourseSections,
|
||||
deleteCourseSection,
|
||||
getCourseSectionById,
|
||||
getCourseSectionsByCourseId,
|
||||
updateCourseSection,
|
||||
updateUngroupedLessonsSectionId
|
||||
} from '@cio/db/queries/course';
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type {
|
||||
TCourseSectionCreate,
|
||||
TCourseSectionPromoteUngrouped,
|
||||
TCourseSectionReorder,
|
||||
TCourseSectionUpdate
|
||||
} from '@cio/utils/validation/course/section';
|
||||
import { updateUngroupedExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
|
||||
/**
|
||||
* Creates a new course section
|
||||
* @param courseId Course ID
|
||||
* @param data Section creation data
|
||||
* @returns Created section
|
||||
*/
|
||||
export async function createCourseSection(courseId: string, data: TCourseSectionCreate): Promise<TCourseSection> {
|
||||
try {
|
||||
const sectionData = {
|
||||
...data,
|
||||
courseId
|
||||
};
|
||||
|
||||
const [section] = await createCourseSections([sectionData]);
|
||||
if (!section) {
|
||||
throw new AppError('Failed to create course section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
return section;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create course section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes synthetic "Ungrouped" content into a real persisted section.
|
||||
* Creates a section and reassigns all ungrouped lessons/exercises to it transactionally.
|
||||
*/
|
||||
export async function promoteUngroupedSection(
|
||||
courseId: string,
|
||||
data: TCourseSectionPromoteUngrouped
|
||||
): Promise<{ section: TCourseSection; movedLessons: number; movedExercises: number }> {
|
||||
try {
|
||||
const existingSections = await getCourseSectionsByCourseId(courseId);
|
||||
const nextOrder = Math.max(...existingSections.map((section) => section.order ?? 0), 0) + 1;
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [section] = await createCourseSections(
|
||||
[
|
||||
{
|
||||
title: data.title,
|
||||
courseId,
|
||||
order: nextOrder
|
||||
}
|
||||
],
|
||||
tx
|
||||
);
|
||||
|
||||
if (!section) {
|
||||
throw new AppError('Failed to create course section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
const [movedLessons, movedExercises] = await Promise.all([
|
||||
updateUngroupedLessonsSectionId(courseId, section.id, tx),
|
||||
updateUngroupedExercisesSectionId(courseId, section.id, tx)
|
||||
]);
|
||||
|
||||
if (movedLessons + movedExercises === 0) {
|
||||
throw new AppError('No ungrouped content found', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
return {
|
||||
section,
|
||||
movedLessons,
|
||||
movedExercises
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to promote ungrouped content',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a course section by ID
|
||||
* @param sectionId Section ID
|
||||
* @returns Section or throws error if not found
|
||||
*/
|
||||
export async function getCourseSection(sectionId: string): Promise<TCourseSection> {
|
||||
try {
|
||||
const section = await getCourseSectionById(sectionId);
|
||||
if (!section) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return section;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch course section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a course section
|
||||
* @param sectionId Section ID
|
||||
* @param data Partial section update data
|
||||
* @returns Updated section
|
||||
*/
|
||||
export async function updateCourseSectionService(
|
||||
sectionId: string,
|
||||
data: TCourseSectionUpdate
|
||||
): Promise<TCourseSection> {
|
||||
try {
|
||||
const section = await getCourseSectionById(sectionId);
|
||||
if (!section) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const updated = await updateCourseSection(sectionId, data);
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to update course section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update course section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a course section
|
||||
* @param sectionId Section ID
|
||||
* @returns Deleted section
|
||||
*/
|
||||
export async function deleteCourseSectionService(sectionId: string): Promise<TCourseSection> {
|
||||
try {
|
||||
const section = await getCourseSectionById(sectionId);
|
||||
if (!section) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const deleted = await deleteCourseSection(sectionId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Failed to delete course section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete course section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists course sections for a course
|
||||
* @param courseId Course ID
|
||||
* @returns Array of sections
|
||||
*/
|
||||
export async function listCourseSections(courseId: string): Promise<TCourseSection[]> {
|
||||
try {
|
||||
return await getCourseSectionsByCourseId(courseId);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list course sections',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders course sections
|
||||
* @param sections Array of section IDs and orders
|
||||
* @returns Updated sections
|
||||
*/
|
||||
export async function reorderCourseSections(sections: TCourseSectionReorder['sections']): Promise<TCourseSection[]> {
|
||||
try {
|
||||
const updatePromises = sections.map(({ id, order }) => updateCourseSection(id, { order }));
|
||||
|
||||
const updated = await Promise.all(updatePromises);
|
||||
return updated.filter((section): section is TCourseSection => section !== null);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to reorder course sections',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { getTakenItemSlugs } from '@cio/db/queries/course';
|
||||
import { SLUG_REGEX, resolveSlugCollision, slugifyTitle } from '@cio/utils/validation';
|
||||
|
||||
/**
|
||||
* Resolve an item (lesson or exercise) slug for a given course:
|
||||
* - If `requestedSlug` is provided, validate it and reject if already taken (excluding `ignoreItemId`).
|
||||
* - Otherwise derive a slug from `title` and append `-2`, `-3`, ... until free.
|
||||
*
|
||||
* Uniqueness is enforced across the union of `lesson.slug` and `exercise.slug` per course.
|
||||
*/
|
||||
export async function resolveItemSlug(params: {
|
||||
courseId: string;
|
||||
title: string;
|
||||
requestedSlug?: string;
|
||||
ignoreItemSlug?: string;
|
||||
}): Promise<string> {
|
||||
const { courseId, title, requestedSlug, ignoreItemSlug } = params;
|
||||
const takenSlugs = await getTakenItemSlugs(courseId);
|
||||
|
||||
if (ignoreItemSlug) {
|
||||
takenSlugs.delete(ignoreItemSlug);
|
||||
}
|
||||
|
||||
if (requestedSlug) {
|
||||
if (!SLUG_REGEX.test(requestedSlug)) {
|
||||
throw new AppError(
|
||||
'Slug must be lowercase letters, numbers, and single dashes',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'slug'
|
||||
);
|
||||
}
|
||||
if (takenSlugs.has(requestedSlug)) {
|
||||
throw new AppError('Slug is already in use in this course', ErrorCodes.SLUG_IN_USE, 409, 'slug');
|
||||
}
|
||||
return requestedSlug;
|
||||
}
|
||||
|
||||
const base = slugifyTitle(title);
|
||||
return resolveSlugCollision(base, takenSlugs);
|
||||
}
|
||||
Reference in New Issue
Block a user