chore: ai, public course and other ui improvements
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user