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:
@@ -0,0 +1,903 @@
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '../../utils/sanitize-html';
|
||||
import type { TExercise, TExerciseTemplate, TNewOption, TNewQuestion } from '@cio/db/types';
|
||||
import type { TExerciseCreate, TExerciseUpdate } from '@cio/utils/validation/exercise';
|
||||
import { getLessonById } from '@cio/db/queries/lesson';
|
||||
import {
|
||||
createExercises,
|
||||
syncOptionIdSequence,
|
||||
syncQuestionIdSequence,
|
||||
createOptions,
|
||||
createQuestions,
|
||||
deleteExercise,
|
||||
deleteOptionsByIds,
|
||||
deleteQuestionsByIds,
|
||||
getExerciseById,
|
||||
getExerciseCompletionByProfile,
|
||||
getExerciseCompletionsByProfile,
|
||||
getExerciseSectionsByExerciseId,
|
||||
getExerciseWithRelationsOptimized,
|
||||
getExercisesByCourseId,
|
||||
getLMSExercises,
|
||||
createExerciseSections,
|
||||
deleteExerciseSectionsByIds,
|
||||
updateExerciseSection,
|
||||
updateExercise,
|
||||
updateOptions,
|
||||
updateQuestions
|
||||
} from '@cio/db/queries/exercise';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
import { resolveItemSlug } from '../course/slug';
|
||||
import { guardNonAutoGradableQuestionsForCourseType } from '../course/public-course-guard';
|
||||
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import type { DbOrTxClient } from '@cio/db/drizzle';
|
||||
import {
|
||||
buildExerciseUpdateFields,
|
||||
computeExerciseDiff,
|
||||
createNewQuestionsWithOptions,
|
||||
fetchQuestionsAndOptions,
|
||||
transformQuestions,
|
||||
type QuestionWithRelations
|
||||
} from './utils';
|
||||
|
||||
type ExerciseWithQuestions = TExercise & {
|
||||
isComplete: boolean;
|
||||
questions?: QuestionWithRelations[];
|
||||
sections?: ExerciseSectionWithQuestions[];
|
||||
};
|
||||
|
||||
type ExerciseSectionWithQuestions = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
order: number;
|
||||
colorTheme: string;
|
||||
afterBehavior: unknown;
|
||||
questions: QuestionWithRelations[];
|
||||
};
|
||||
|
||||
type ExerciseWithCompletion = TExercise & {
|
||||
isComplete: boolean;
|
||||
};
|
||||
|
||||
function sanitizeExerciseQuestions(questions: TExerciseCreate['questions'] | TExerciseUpdate['questions']) {
|
||||
return questions?.map((question) => ({
|
||||
...question,
|
||||
question: sanitizeHtml(question.question),
|
||||
settings: question.settings ? sanitizeUnknownStrings(question.settings) : question.settings,
|
||||
options: question.options?.map((option) => ({
|
||||
...option,
|
||||
label: sanitizeHtml(option.label),
|
||||
settings: option.settings ? sanitizeUnknownStrings(option.settings) : option.settings
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
function sanitizeExercisePayload(data: TExerciseCreate): TExerciseCreate;
|
||||
function sanitizeExercisePayload(data: TExerciseUpdate): TExerciseUpdate;
|
||||
function sanitizeExercisePayload(data: TExerciseCreate | TExerciseUpdate): TExerciseCreate | TExerciseUpdate {
|
||||
return {
|
||||
...data,
|
||||
description: sanitizeOptionalHtml(data.description),
|
||||
sections:
|
||||
'sections' in data
|
||||
? data.sections?.map((section) => ({
|
||||
...section,
|
||||
title: sanitizeHtml(section.title),
|
||||
description: sanitizeOptionalHtml(section.description ?? undefined)
|
||||
}))
|
||||
: undefined,
|
||||
questions: sanitizeExerciseQuestions(data.questions)
|
||||
};
|
||||
}
|
||||
|
||||
function groupQuestionsBySections(
|
||||
sections: Awaited<ReturnType<typeof getExerciseSectionsByExerciseId>>,
|
||||
questions: QuestionWithRelations[]
|
||||
) {
|
||||
const questionsBySectionId = new Map<string, QuestionWithRelations[]>();
|
||||
const unsectionedQuestions: QuestionWithRelations[] = [];
|
||||
|
||||
for (const question of questions) {
|
||||
if (question.exerciseSectionId) {
|
||||
const sectionQuestions = questionsBySectionId.get(question.exerciseSectionId) ?? [];
|
||||
sectionQuestions.push(question);
|
||||
questionsBySectionId.set(question.exerciseSectionId, sectionQuestions);
|
||||
continue;
|
||||
}
|
||||
|
||||
unsectionedQuestions.push(question);
|
||||
}
|
||||
|
||||
return {
|
||||
sections: sections.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior,
|
||||
questions: questionsBySectionId.get(section.id) ?? []
|
||||
})),
|
||||
unsectionedQuestions
|
||||
};
|
||||
}
|
||||
|
||||
async function syncExerciseSections(
|
||||
exerciseId: string,
|
||||
sections: NonNullable<TExerciseUpdate['sections']>,
|
||||
txClient: DbOrTxClient
|
||||
) {
|
||||
const currentSections = await getExerciseSectionsByExerciseId(exerciseId, txClient);
|
||||
const currentSectionIds = new Set(currentSections.map((section) => section.id));
|
||||
const incomingSectionIds = new Set(sections.map((section) => section.id).filter((id): id is string => !!id));
|
||||
|
||||
for (const section of sections) {
|
||||
if (section.afterBehavior.action !== 'go_to_section') continue;
|
||||
if (!incomingSectionIds.has(section.afterBehavior.exerciseSectionId)) {
|
||||
throw new AppError('Section routing target must exist in this exercise', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const sectionsToDelete = currentSections
|
||||
.filter((section) => !incomingSectionIds.has(section.id))
|
||||
.map((section) => section.id);
|
||||
|
||||
if (sectionsToDelete.length > 0) {
|
||||
await deleteExerciseSectionsByIds(sectionsToDelete, txClient);
|
||||
}
|
||||
|
||||
const sectionsToCreate = sections
|
||||
.filter((section) => !section.id || !currentSectionIds.has(section.id))
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
exerciseId,
|
||||
title: section.title,
|
||||
description: section.description ?? null,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior
|
||||
}));
|
||||
|
||||
if (sectionsToCreate.length > 0) {
|
||||
await createExerciseSections(sectionsToCreate, txClient);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
sections
|
||||
.filter((section) => section.id && currentSectionIds.has(section.id))
|
||||
.map((section) =>
|
||||
updateExerciseSection(
|
||||
section.id!,
|
||||
{
|
||||
title: section.title,
|
||||
description: section.description ?? null,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior
|
||||
},
|
||||
txClient
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function validateSectionQuestionAssignments(data: TExerciseUpdate) {
|
||||
if (!data.sections || data.sections.length === 0 || !data.questions) return;
|
||||
|
||||
const sectionIds = new Set(data.sections.map((section) => section.id).filter((id): id is string => !!id));
|
||||
const unassignedQuestion = data.questions.find(
|
||||
(question) => !question.deletedAt && (!question.exerciseSectionId || !sectionIds.has(question.exerciseSectionId))
|
||||
);
|
||||
|
||||
if (unassignedQuestion) {
|
||||
throw new AppError(
|
||||
'All active questions must be assigned to an exercise section',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveExerciseCourseId(exercise: TExercise): Promise<string | null> {
|
||||
if (exercise.courseId) {
|
||||
return exercise.courseId;
|
||||
}
|
||||
|
||||
if (!exercise.lessonId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lesson = await getLessonById(exercise.lessonId);
|
||||
|
||||
return lesson?.courseId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new exercise with optional questions and options
|
||||
* @param data Exercise creation data
|
||||
* @returns Created exercise
|
||||
*/
|
||||
export async function createExercise(data: TExerciseCreate): Promise<TExercise> {
|
||||
try {
|
||||
const sanitizedData = sanitizeExercisePayload(data);
|
||||
|
||||
await guardNonAutoGradableQuestionsForCourseType({
|
||||
courseId: sanitizedData.courseId,
|
||||
questionTypeIds: (sanitizedData.questions ?? []).map((question) => question.questionTypeId)
|
||||
});
|
||||
|
||||
const slug = await resolveItemSlug({
|
||||
courseId: sanitizedData.courseId,
|
||||
title: sanitizedData.title,
|
||||
requestedSlug: sanitizedData.slug
|
||||
});
|
||||
|
||||
const exerciseData = {
|
||||
title: sanitizedData.title,
|
||||
description: sanitizedData.description ?? null,
|
||||
lessonId: sanitizedData.lessonId || null,
|
||||
courseId: sanitizedData.courseId,
|
||||
sectionId: sanitizedData.sectionId || null,
|
||||
order: sanitizedData.order ?? null,
|
||||
dueBy: sanitizedData.dueBy || null,
|
||||
slug
|
||||
};
|
||||
|
||||
const [exercise] = await createExercises([exerciseData]);
|
||||
if (!exercise) {
|
||||
throw new AppError('Failed to create exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
// Create questions and options if provided
|
||||
if (sanitizedData.questions && sanitizedData.questions.length > 0) {
|
||||
await db.transaction(async (tx) => {
|
||||
const txClient = tx as DbOrTxClient;
|
||||
const questionsData: TNewQuestion[] = sanitizedData.questions!.map((q) => ({
|
||||
exerciseId: exercise.id,
|
||||
title: q.question,
|
||||
questionTypeId: q.questionTypeId || 1,
|
||||
points: q.points || 0,
|
||||
order: typeof q.order === 'number' ? q.order : undefined,
|
||||
settings: q.settings ?? {}
|
||||
}));
|
||||
|
||||
const questions = await createQuestions(questionsData, txClient);
|
||||
const hasOptions = sanitizedData.questions!.some((question) => (question.options?.length ?? 0) > 0);
|
||||
|
||||
if (hasOptions) {
|
||||
await syncOptionIdSequence(txClient);
|
||||
}
|
||||
|
||||
// Create options for each question
|
||||
for (const [index, questionData] of sanitizedData.questions!.entries()) {
|
||||
const question = questions[index];
|
||||
if (!question) continue;
|
||||
|
||||
const optionsData: TNewOption[] = (questionData.options ?? []).map((opt) => ({
|
||||
questionId: question.id,
|
||||
label: opt.label,
|
||||
isCorrect: opt.isCorrect,
|
||||
settings: opt.settings ?? {}
|
||||
}));
|
||||
|
||||
await createOptions(optionsData, txClient);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch the complete exercise with questions and options
|
||||
const result = await getExercise(exercise.id);
|
||||
|
||||
if (sanitizedData.courseId) {
|
||||
await touchCourseUpdatedAt(sanitizedData.courseId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('createExercise error:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create exercise',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an exercise by ID with questions and options
|
||||
* @param exerciseId Exercise ID
|
||||
* @returns Exercise with questions and options
|
||||
*/
|
||||
export async function getExercise(
|
||||
exerciseId: string,
|
||||
dbClient?: DbOrTxClient,
|
||||
profileId?: string
|
||||
): Promise<ExerciseWithQuestions> {
|
||||
try {
|
||||
// Optimized: Fetch exercise + questions + questionTypes + options in one JOIN query
|
||||
const { exercise, questions } = await getExerciseWithRelationsOptimized(exerciseId, dbClient);
|
||||
|
||||
const isComplete = profileId ? await getExerciseCompletionByProfile(exerciseId, profileId, dbClient) : false;
|
||||
|
||||
// Build questionTypeMap from questions (already fetched with questionType)
|
||||
const questionTypeMap = new Map(questions.map((q) => [q.questionType.id, q.questionType]));
|
||||
|
||||
// Extract options from questions and transform
|
||||
const allOptions = questions.flatMap((q) => q.options || []);
|
||||
|
||||
// Transform questions (strip questionType and options since they're handled in transform)
|
||||
const questionsForTransform = questions.map(({ questionType, options, ...q }) => q);
|
||||
const questionsWithOptions = transformQuestions(questionsForTransform, allOptions, questionTypeMap);
|
||||
const sections = await getExerciseSectionsByExerciseId(exerciseId, dbClient);
|
||||
const sectionGroups = groupQuestionsBySections(sections, questionsWithOptions);
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
isComplete,
|
||||
questions: questionsWithOptions,
|
||||
sections: sectionGroups.sections
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch exercise',
|
||||
ErrorCodes.EXERCISE_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an exercise with questions and options.
|
||||
* Only updates what has changed by querying the current state first and computing a diff.
|
||||
* @param exerciseId Exercise ID
|
||||
* @param data Partial exercise update data
|
||||
* @returns Updated exercise
|
||||
*/
|
||||
export async function updateExerciseService(exerciseId: string, data: TExerciseUpdate): Promise<TExercise> {
|
||||
try {
|
||||
const sanitizedData = sanitizeExercisePayload(data);
|
||||
validateSectionQuestionAssignments(sanitizedData);
|
||||
|
||||
const existing = await getExerciseById(exerciseId);
|
||||
|
||||
if (existing?.courseId) {
|
||||
await guardNonAutoGradableQuestionsForCourseType({
|
||||
courseId: existing.courseId,
|
||||
questionTypeIds: (sanitizedData.questions ?? [])
|
||||
.filter((question) => !('deletedAt' in question && question.deletedAt))
|
||||
.map((question) => question.questionTypeId)
|
||||
});
|
||||
}
|
||||
|
||||
if (sanitizedData.slug !== undefined && existing?.courseId) {
|
||||
sanitizedData.slug = await resolveItemSlug({
|
||||
courseId: existing.courseId,
|
||||
title: sanitizedData.title ?? existing.title,
|
||||
requestedSlug: sanitizedData.slug,
|
||||
ignoreItemSlug: existing.slug ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const txClient = tx as DbOrTxClient;
|
||||
|
||||
// Update exercise basic fields only if they changed
|
||||
const exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
|
||||
if (Object.keys(exerciseUpdate).length > 0) {
|
||||
const updated = await updateExercise(exerciseId, exerciseUpdate, txClient);
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to update exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitizedData.sections !== undefined) {
|
||||
await syncExerciseSections(exerciseId, sanitizedData.sections, txClient);
|
||||
}
|
||||
|
||||
// Process questions - only update what changed
|
||||
if (sanitizedData.questions && sanitizedData.questions.length > 0) {
|
||||
// Query current state from DB
|
||||
const { questions: currentQuestions, options: currentOptions } = await fetchQuestionsAndOptions(
|
||||
exerciseId,
|
||||
txClient
|
||||
);
|
||||
|
||||
// Build current state with options mapped to their questions
|
||||
const currentQuestionsWithOptions = currentQuestions.map((q) => ({
|
||||
...q,
|
||||
options: currentOptions.filter((opt) => opt.questionId === q.id)
|
||||
}));
|
||||
|
||||
// Compute diff between current DB state and incoming data
|
||||
const diff = computeExerciseDiff(currentQuestionsWithOptions, sanitizedData.questions);
|
||||
|
||||
const hasNewQuestions = diff.questions.newQuestions.length > 0;
|
||||
const hasNewOptions = diff.options.creates.length > 0 || hasNewQuestions;
|
||||
|
||||
if (hasNewQuestions) {
|
||||
await syncQuestionIdSequence(txClient);
|
||||
}
|
||||
|
||||
if (hasNewOptions) {
|
||||
await syncOptionIdSequence(txClient);
|
||||
}
|
||||
|
||||
// Apply question/option graph changes in dependency order. Question deletes cascade
|
||||
// options, while new question creation can also create options.
|
||||
if (diff.questions.deletedIds.length > 0) {
|
||||
await deleteQuestionsByIds(diff.questions.deletedIds, txClient);
|
||||
}
|
||||
|
||||
if (diff.options.deletedIds.length > 0) {
|
||||
await deleteOptionsByIds(diff.options.deletedIds, txClient);
|
||||
}
|
||||
|
||||
if (diff.questions.updates.length > 0) {
|
||||
await updateQuestions(diff.questions.updates, txClient);
|
||||
}
|
||||
|
||||
if (diff.options.updates.length > 0) {
|
||||
await updateOptions(diff.options.updates, txClient);
|
||||
}
|
||||
|
||||
if (diff.options.creates.length > 0) {
|
||||
await createOptions(diff.options.creates, txClient);
|
||||
}
|
||||
|
||||
if (hasNewQuestions) {
|
||||
await createNewQuestionsWithOptions(exerciseId, diff.questions.newQuestions, txClient);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the updated exercise
|
||||
return await getExercise(exerciseId, txClient);
|
||||
});
|
||||
|
||||
const courseId = existing?.courseId;
|
||||
if (courseId) {
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('updateExerciseService error:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError('Failed to update exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateExerciseSectionMetadataService(
|
||||
exerciseId: string,
|
||||
exerciseSectionId: string,
|
||||
data: { title?: string; description?: string | null }
|
||||
) {
|
||||
try {
|
||||
const sections = await getExerciseSectionsByExerciseId(exerciseId);
|
||||
const belongs = sections.some((section) => section.id === exerciseSectionId);
|
||||
|
||||
if (!belongs) {
|
||||
throw new AppError(
|
||||
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
const patch: { title?: string; description?: string | null } = {};
|
||||
|
||||
if (data.title !== undefined) {
|
||||
patch.title = sanitizeHtml(data.title);
|
||||
}
|
||||
|
||||
if (data.description !== undefined) {
|
||||
patch.description = data.description === '' ? null : (sanitizeOptionalHtml(data.description) ?? null);
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw new AppError('No fields to update', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const updated = await updateExerciseSection(exerciseSectionId, patch);
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to update exercise section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
const existing = await getExerciseById(exerciseId);
|
||||
|
||||
if (existing?.courseId) {
|
||||
await touchCourseUpdatedAt(existing.courseId);
|
||||
}
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
description: updated.description ?? null,
|
||||
exerciseId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('updateExerciseSectionMetadataService error:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update exercise section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createExerciseSectionService(
|
||||
exerciseId: string,
|
||||
data: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
order: number;
|
||||
colorTheme?: 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
|
||||
afterBehavior?:
|
||||
| { action: 'continue' }
|
||||
| { action: 'submit' }
|
||||
| { action: 'go_to_section'; exerciseSectionId: string };
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const sections = await getExerciseSectionsByExerciseId(exerciseId);
|
||||
const afterBehavior = data.afterBehavior;
|
||||
|
||||
if (afterBehavior?.action === 'go_to_section') {
|
||||
const targetExists = sections.some((section) => section.id === afterBehavior.exerciseSectionId);
|
||||
|
||||
if (!targetExists) {
|
||||
throw new AppError(
|
||||
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
404
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [created] = await createExerciseSections([
|
||||
{
|
||||
exerciseId,
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
order: data.order,
|
||||
colorTheme: data.colorTheme ?? 'blue',
|
||||
afterBehavior: data.afterBehavior ?? { action: 'continue' }
|
||||
}
|
||||
]);
|
||||
|
||||
if (!created) {
|
||||
throw new AppError('Failed to create exercise section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
const existing = await getExerciseById(exerciseId);
|
||||
|
||||
if (existing?.courseId) {
|
||||
await touchCourseUpdatedAt(existing.courseId);
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
exerciseId: created.exerciseId,
|
||||
title: created.title,
|
||||
description: created.description ?? null,
|
||||
order: created.order,
|
||||
colorTheme: created.colorTheme,
|
||||
afterBehavior: created.afterBehavior
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('createExerciseSectionService error:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create exercise section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces an exercise's questions and options with the provided payload.
|
||||
* Existing questions are deleted and recreated from the replacement payload.
|
||||
*/
|
||||
export async function replaceExerciseService(exerciseId: string, data: TExerciseUpdate): Promise<TExercise> {
|
||||
try {
|
||||
const sanitizedData = sanitizeExercisePayload(data);
|
||||
|
||||
const existing = await getExerciseById(exerciseId);
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const txClient = tx as DbOrTxClient;
|
||||
|
||||
const exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
|
||||
if (Object.keys(exerciseUpdate).length > 0) {
|
||||
const updated = await updateExercise(exerciseId, exerciseUpdate, txClient);
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to update exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
}
|
||||
|
||||
const { questions: currentQuestions } = await fetchQuestionsAndOptions(exerciseId, txClient);
|
||||
const currentQuestionIds = currentQuestions
|
||||
.map((question) => question.id)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
|
||||
if (currentQuestionIds.length > 0) {
|
||||
await deleteQuestionsByIds(currentQuestionIds, txClient);
|
||||
}
|
||||
|
||||
const replacementQuestions = sanitizedData.questions ?? [];
|
||||
const hasReplacementOptions = replacementQuestions.some((question) => (question.options?.length ?? 0) > 0);
|
||||
|
||||
if (replacementQuestions.length > 0) {
|
||||
await syncQuestionIdSequence(txClient);
|
||||
|
||||
if (hasReplacementOptions) {
|
||||
await syncOptionIdSequence(txClient);
|
||||
}
|
||||
|
||||
await createNewQuestionsWithOptions(exerciseId, replacementQuestions, txClient);
|
||||
}
|
||||
|
||||
return await getExercise(exerciseId, txClient);
|
||||
});
|
||||
|
||||
const courseId = existing?.courseId;
|
||||
if (courseId) {
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('replaceExerciseService error:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError('Failed to replace exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an exercise (cascade deletes questions and options)
|
||||
* @param exerciseId Exercise ID
|
||||
* @returns Deleted exercise
|
||||
*/
|
||||
export async function deleteExerciseService(exerciseId: string): Promise<TExercise> {
|
||||
try {
|
||||
const exercise = await getExerciseById(exerciseId);
|
||||
if (!exercise) {
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const deleted = await deleteExercise(exerciseId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Failed to delete exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
if (exercise.courseId) {
|
||||
await touchCourseUpdatedAt(exercise.courseId);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete exercise',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteExerciseForCourseService(courseId: string, exerciseId: string): Promise<TExercise> {
|
||||
try {
|
||||
const exercise = await getExerciseById(exerciseId);
|
||||
if (!exercise) {
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const exerciseCourseId = await resolveExerciseCourseId(exercise);
|
||||
if (exerciseCourseId !== courseId) {
|
||||
throw new AppError('Exercise does not belong to this course', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const deleted = await deleteExercise(exerciseId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Failed to delete exercise', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
await touchCourseUpdatedAt(courseId);
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete exercise',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists exercises for a course
|
||||
* @param courseId Course ID
|
||||
* @param lessonId Optional lesson ID filter
|
||||
* @returns Array of exercises
|
||||
*/
|
||||
export async function listExercises(
|
||||
courseId: string,
|
||||
filters: { lessonId?: string; sectionId?: string } = {},
|
||||
profileId?: string
|
||||
): Promise<ExerciseWithCompletion[]> {
|
||||
try {
|
||||
const exercises = await getExercisesByCourseId(courseId, filters);
|
||||
|
||||
if (!profileId) {
|
||||
return exercises.map((exercise) => ({
|
||||
...exercise,
|
||||
isComplete: false
|
||||
}));
|
||||
}
|
||||
|
||||
const completedIds = await getExerciseCompletionsByProfile(
|
||||
exercises.map((exercise) => exercise.id),
|
||||
profileId
|
||||
);
|
||||
const completedSet = new Set(completedIds);
|
||||
|
||||
return exercises.map((exercise) => ({
|
||||
...exercise,
|
||||
isComplete: completedSet.has(exercise.id)
|
||||
}));
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list exercises',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an exercise from a template
|
||||
* @param courseId Course ID
|
||||
* @param lessonId Lesson ID
|
||||
* @param template Exercise template with questionnaire
|
||||
* @returns Created exercise with questions and options
|
||||
*/
|
||||
export async function createExerciseFromTemplate(
|
||||
courseId: string,
|
||||
lessonId: string | undefined,
|
||||
sectionId: string | undefined,
|
||||
order: number | undefined,
|
||||
template: TExerciseTemplate
|
||||
): Promise<TExercise> {
|
||||
try {
|
||||
// Create the exercise
|
||||
const exerciseData: TExerciseCreate = {
|
||||
title: template.title!,
|
||||
description: template.description ?? undefined,
|
||||
lessonId,
|
||||
sectionId,
|
||||
order,
|
||||
courseId
|
||||
};
|
||||
|
||||
const exercise = await createExercise(exerciseData);
|
||||
|
||||
// Process the questionnaire to create questions and options
|
||||
const { questions } = template.questionnaire!;
|
||||
|
||||
if (questions && questions.length > 0) {
|
||||
await db.transaction(async (tx) => {
|
||||
const txClient = tx as DbOrTxClient;
|
||||
const hasTemplateOptions = questions.some(
|
||||
(question) => question.question_type.id !== 3 && (question.options?.length ?? 0) > 0
|
||||
);
|
||||
|
||||
if (hasTemplateOptions) {
|
||||
await syncOptionIdSequence(txClient);
|
||||
}
|
||||
|
||||
for (const question of questions) {
|
||||
const { title, question_type, options, order, points } = question;
|
||||
const questionSettings = sanitizeUnknownStrings(
|
||||
(question as { settings?: Record<string, unknown> }).settings ?? {}
|
||||
);
|
||||
|
||||
// Create question
|
||||
const [newQuestion] = await createQuestions(
|
||||
[
|
||||
{
|
||||
exerciseId: exercise.id,
|
||||
title: sanitizeHtml(title),
|
||||
questionTypeId: question_type.id,
|
||||
points: points || 0,
|
||||
order: order || 0,
|
||||
settings: questionSettings
|
||||
}
|
||||
],
|
||||
txClient
|
||||
);
|
||||
|
||||
if (!newQuestion) continue;
|
||||
|
||||
// Create options if not TEXTAREA type
|
||||
if (question_type.id !== 3 && options && options.length > 0) {
|
||||
// Filter out deleted options
|
||||
|
||||
// value column is UUID (default gen_random_uuid()); label is the display text only
|
||||
const optionsData: TNewOption[] = options.map((opt) => ({
|
||||
questionId: newQuestion.id,
|
||||
label: sanitizeHtml(opt.label || ''),
|
||||
isCorrect: opt.is_correct || false,
|
||||
settings: sanitizeUnknownStrings((opt as { settings?: Record<string, unknown> }).settings ?? {})
|
||||
}));
|
||||
|
||||
await createOptions(optionsData, txClient);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch the complete exercise with questions and options
|
||||
return await getExercise(exercise.id);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create exercise from template',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets LMS exercises for a student in an organization
|
||||
* Returns exercises from unlocked lessons in courses where the student is a member
|
||||
* @param profileId - The profile ID of the student
|
||||
* @param orgId - The organization ID
|
||||
* @returns Array of exercises with related data
|
||||
*/
|
||||
export async function getLMSExercisesService(profileId: string, orgId: string) {
|
||||
try {
|
||||
const exercises = await getLMSExercises(profileId, orgId);
|
||||
return exercises;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch LMS exercises',
|
||||
ErrorCodes.EXERCISE_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user