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:
Rotimi Best
2026-06-07 18:43:05 +01:00
committed by GitHub
parent a2e70779a0
commit d5b07d356e
496 changed files with 19050 additions and 8312 deletions
-256
View File
@@ -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
);
}
}
-580
View File
@@ -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 🎉&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
},
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(/&nbsp;|&#160;/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
};
}
+41 -18
View File
@@ -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 -3
View File
@@ -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'
);
}
+53 -2
View File
@@ -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];
-235
View File
@@ -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
);
}
}
-42
View File
@@ -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);
}