152 lines
5.4 KiB
TypeScript
152 lines
5.4 KiB
TypeScript
import { AppError, ErrorCodes } from '@api/utils/errors';
|
|||
|
|
import {
|
||
|
|
getCourseRowBySlug,
|
||
|
|
getPublicCourseItem as getPublicCourseItemQuery,
|
||
|
|
getPublicCourseTreeBySlug,
|
||
|
|
type PublicCourseItemContent,
|
||
|
|
type PublicCourseTree
|
||
|
|
} from '@cio/db/queries/course';
|
||
|
|
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
|
||
|
|
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise/exercise-section';
|
||
|
|
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Anonymous-safe: fetch the full tree for a published public course by slug.
|
||
|
|
* Callers should return 404 when this resolves to `null`.
|
||
|
|
*/
|
||
|
|
export async function getPublicCourseTreeService(courseSlug: string): Promise<PublicCourseTree | null> {
|
||
|
|
try {
|
||
|
|
return await getPublicCourseTreeBySlug(courseSlug);
|
||
|
|
} catch (error) {
|
||
|
|
if (error instanceof AppError) throw error;
|
||
|
|
throw new AppError('Failed to load public course', ErrorCodes.COURSE_FETCH_FAILED, 500);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export type PublicItemResponse =
|
||
|
|
| (Extract<PublicCourseItemContent, { kind: 'lesson' }> & { kind: 'lesson' })
|
||
|
|
| (Extract<PublicCourseItemContent, { kind: 'exercise' }> & {
|
||
|
|
kind: 'exercise';
|
||
|
|
questions: PublicExercisePayload;
|
||
|
|
});
|
||
|
|
|
||
|
|
export interface PublicExercisePayload {
|
||
|
|
unsectioned: PublicExerciseQuestion[];
|
||
|
|
sections: Array<{
|
||
|
|
id: string;
|
||
|
|
title: string;
|
||
|
|
description: string | null;
|
||
|
|
order: number;
|
||
|
|
colorTheme: string;
|
||
|
|
afterBehavior: unknown;
|
||
|
|
questions: PublicExerciseQuestion[];
|
||
|
|
}>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface PublicExerciseQuestion {
|
||
|
|
id: number | string;
|
||
|
|
title: string;
|
||
|
|
questionTypeId: number;
|
||
|
|
questionTypeKey: string;
|
||
|
|
points: number;
|
||
|
|
order: number | null;
|
||
|
|
settings: Record<string, unknown>;
|
||
|
|
options: Array<{
|
||
|
|
id: number | string;
|
||
|
|
label: string;
|
||
|
|
/** Answer keys are intentionally shipped to the client for PUBLIC courses. */
|
||
|
|
isCorrect: boolean;
|
||
|
|
settings: Record<string, unknown>;
|
||
|
|
}>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Anonymous-safe: fetch a single lesson or exercise by its per-course slug.
|
||
|
|
* For exercises, questions + options are returned with answer keys included
|
||
|
|
* because PUBLIC courses grade entirely on the client.
|
||
|
|
*/
|
||
|
|
export async function getPublicCourseItemService(
|
||
|
|
courseSlug: string,
|
||
|
|
itemSlug: string
|
||
|
|
): Promise<PublicItemResponse | null> {
|
||
|
|
try {
|
||
|
|
const baseItem = await getPublicCourseItemQuery(courseSlug, itemSlug);
|
||
|
|
if (!baseItem) return null;
|
||
|
|
|
||
|
|
if (baseItem.kind === 'lesson') {
|
||
|
|
return baseItem;
|
||
|
|
}
|
||
|
|
|
||
|
|
const { exercise, questions } = await getExerciseWithRelationsOptimized(baseItem.id);
|
||
|
|
const questionTypeMap = new Map(questions.map((question) => [question.questionType.id, question.questionType]));
|
||
|
|
const allOptions = questions.flatMap((question) => question.options ?? []);
|
||
|
|
const strippedQuestions = questions.map(({ questionType: _type, options: _options, ...rest }) => rest);
|
||
|
|
const transformed = transformQuestions(strippedQuestions, allOptions, questionTypeMap);
|
||
|
|
const sectionRows = await getExerciseSectionsByExerciseId(exercise.id);
|
||
|
|
|
||
|
|
const sectionMap = new Map(sectionRows.map((section) => [section.id, section]));
|
||
|
|
const unsectioned: PublicExerciseQuestion[] = [];
|
||
|
|
const sectionBuckets = new Map<string, PublicExerciseQuestion[]>();
|
||
|
|
|
||
|
|
for (const question of transformed) {
|
||
|
|
// `transformQuestions` only keeps `{ id, label }` on `question.questionType`, so the DB
|
||
|
|
// `typename` (e.g. RADIO) must come from the map built before stripping relations.
|
||
|
|
const questionTypeRow = questionTypeMap.get(question.questionTypeId);
|
||
|
|
const payloadQuestion: PublicExerciseQuestion = {
|
||
|
|
id: question.id,
|
||
|
|
title: question.title ?? '',
|
||
|
|
questionTypeId: question.questionTypeId,
|
||
|
|
questionTypeKey: questionTypeRow?.typename ?? '',
|
||
|
|
points: question.points ?? 0,
|
||
|
|
order: typeof question.order === 'number' ? question.order : null,
|
||
|
|
settings: (question.settings ?? {}) as Record<string, unknown>,
|
||
|
|
options: (question.options ?? []).map((option) => ({
|
||
|
|
id: option.id,
|
||
|
|
label: option.label ?? '',
|
||
|
|
isCorrect: option.isCorrect ?? false,
|
||
|
|
settings: (option.settings ?? {}) as Record<string, unknown>
|
||
|
|
}))
|
||
|
|
};
|
||
|
|
|
||
|
|
const sectionId = question.exerciseSectionId ?? null;
|
||
|
|
if (sectionId && sectionMap.has(sectionId)) {
|
||
|
|
const bucket = sectionBuckets.get(sectionId) ?? [];
|
||
|
|
bucket.push(payloadQuestion);
|
||
|
|
sectionBuckets.set(sectionId, bucket);
|
||
|
|
} else {
|
||
|
|
unsectioned.push(payloadQuestion);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const sections = sectionRows.map((section) => ({
|
||
|
|
id: section.id,
|
||
|
|
title: section.title,
|
||
|
|
description: section.description,
|
||
|
|
order: Number(section.order),
|
||
|
|
colorTheme: section.colorTheme,
|
||
|
|
afterBehavior: section.afterBehavior,
|
||
|
|
questions: sectionBuckets.get(section.id) ?? []
|
||
|
|
}));
|
||
|
|
|
||
|
|
return {
|
||
|
|
...baseItem,
|
||
|
|
questions: {
|
||
|
|
unsectioned,
|
||
|
|
sections
|
||
|
|
}
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
if (error instanceof AppError) throw error;
|
||
|
|
console.error('getPublicCourseItemService error:', error);
|
||
|
|
throw new AppError('Failed to load public course item', ErrorCodes.COURSE_FETCH_FAILED, 500);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Resolve a course slug to the backing course row with minimal fields. Used by
|
||
|
|
* rate limiting / logging that need the course id without the full tree.
|
||
|
|
*/
|
||
|
|
export async function resolvePublicCourseBySlug(courseSlug: string) {
|
||
|
|
return getCourseRowBySlug(courseSlug);
|
||
|
|
}
|