Files
classroomio/apps/api/src/services/exercise/utils.ts
T

456 lines
14 KiB
TypeScript

import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TExercise, TNewOption, TNewQuestion, TQuestionAnswer, TQuestionType } from '@cio/db/types';
import {
createOptions,
createQuestions,
deleteOptionsByIds,
deleteQuestionsByIds,
getOptionsByQuestionIds,
getQuestionTypesByIds,
getQuestionsByExerciseIds,
updateOptions,
updateQuestions
} from '@cio/db/queries/exercise';
import type { DbOrTxClient } from '@cio/db/drizzle';
import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
type CurrentQuestion = TNewQuestion & { options?: TNewOption[] };
type CurrentOption = TNewOption;
type ExerciseAnswer = Pick<
TQuestionAnswer,
'id' | 'answerData' | 'point' | 'questionId' | 'submissionId' | 'groupMemberId'
> & { id?: number };
export type QuestionWithRelations = {
id: number | string;
exerciseSectionId?: string | null;
value: string;
name: string;
title: string;
type: number;
points: number;
order: number;
questionType: {
id: number;
label: string;
};
questionTypeId: number;
settings?: Record<string, unknown>;
code?: string;
answers?: ExerciseAnswer[];
deletedAt?: string;
createdAt?: string;
updatedAt?: string;
options: {
id: number | string;
label: string | null;
value: string | null;
isCorrect: boolean;
settings?: Record<string, unknown>;
deletedAt?: string;
}[];
};
function normalizeSettings(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
function areSettingsEqual(left: unknown, right: unknown): boolean {
const leftNormalized = normalizeSettings(left);
const rightNormalized = normalizeSettings(right);
return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized);
}
/**
* Extracts exercise-level fields from the update payload
*/
export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial<TExercise> {
const exerciseUpdate: Partial<TExercise> = {};
if (data.title !== undefined) exerciseUpdate.title = data.title;
if (data.description !== undefined) exerciseUpdate.description = data.description;
if (data.lessonId !== undefined) exerciseUpdate.lessonId = data.lessonId || null;
if (data.sectionId !== undefined) exerciseUpdate.sectionId = data.sectionId || null;
if (data.order !== undefined) exerciseUpdate.order = data.order;
if (data.isUnlocked !== undefined) exerciseUpdate.isUnlocked = data.isUnlocked;
if (data.dueBy !== undefined) exerciseUpdate.dueBy = data.dueBy || null;
if (data.allowMultipleAttempts !== undefined) exerciseUpdate.allowMultipleAttempts = data.allowMultipleAttempts;
if (data.sectionDisplayMode !== undefined) exerciseUpdate.sectionDisplayMode = data.sectionDisplayMode;
if (data.slug !== undefined) exerciseUpdate.slug = data.slug;
return exerciseUpdate;
}
/**
* Categorizes questions into deleted, existing, and new
*/
export function categorizeQuestions(questions: NonNullable<TExerciseUpdate['questions']>) {
const deletedIds: number[] = [];
const existingQuestions: typeof questions = [];
const newQuestions: typeof questions = [];
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
for (const q of questions) {
if (q.deletedAt && q.id) {
deletedIds.push(q.id);
} else if (q.id) {
existingQuestions.push(q);
questionUpdates.push({
id: q.id,
data: {
title: q.question,
points: q.points,
questionTypeId: q.questionTypeId,
settings: normalizeSettings(q.settings)
}
});
} else {
newQuestions.push(q);
}
}
return { deletedIds, existingQuestions, newQuestions, questionUpdates };
}
/**
* Categorizes options for a single question into deleted, existing, and new
*/
export function categorizeOptions(
questionId: number,
options: NonNullable<TExerciseUpdate['questions']>[number]['options']
) {
const deletedIds: number[] = [];
const existingUpdates: Array<{ id: number; data: Partial<TNewOption> }> = [];
const newOptions: TNewOption[] = [];
if (!options || options.length === 0) {
return { deletedIds, existingUpdates, newOptions };
}
for (const opt of options) {
if (opt.deletedAt && opt.id) {
deletedIds.push(opt.id);
} else if (opt.id) {
existingUpdates.push({
id: opt.id,
data: {
label: opt.label,
isCorrect: opt.isCorrect,
settings: normalizeSettings(opt.settings)
}
});
} else {
newOptions.push({
questionId,
label: opt.label,
isCorrect: opt.isCorrect,
settings: normalizeSettings(opt.settings)
});
}
}
return { deletedIds, existingUpdates, newOptions };
}
/**
* Compares current question with incoming question data and returns only changed fields
*/
function diffQuestion(
current: CurrentQuestion,
incoming: NonNullable<TExerciseUpdate['questions']>[number]
): Partial<TNewQuestion> | null {
const changes: Partial<TNewQuestion> = {};
let hasChanges = false;
if (incoming.question !== current.title) {
changes.title = incoming.question;
hasChanges = true;
}
if (incoming.points !== undefined && incoming.points !== current.points) {
changes.points = incoming.points;
hasChanges = true;
}
if (incoming.questionTypeId !== undefined && incoming.questionTypeId !== current.questionTypeId) {
changes.questionTypeId = incoming.questionTypeId;
hasChanges = true;
}
if (incoming.order !== undefined && incoming.order !== (current.order ?? undefined)) {
changes.order = incoming.order;
hasChanges = true;
}
if (incoming.exerciseSectionId !== undefined && incoming.exerciseSectionId !== current.exerciseSectionId) {
changes.exerciseSectionId = incoming.exerciseSectionId;
hasChanges = true;
}
if (!areSettingsEqual(current.settings, incoming.settings)) {
changes.settings = normalizeSettings(incoming.settings);
hasChanges = true;
}
return hasChanges ? changes : null;
}
/**
* Compares current option with incoming option data and returns only changed fields
*/
function diffOption(
current: CurrentOption,
incoming: NonNullable<NonNullable<TExerciseUpdate['questions']>[number]['options']>[number]
): Partial<TNewOption> | null {
const changes: Partial<TNewOption> = {};
let hasChanges = false;
if (incoming.label !== current.label) {
changes.label = incoming.label;
hasChanges = true;
}
if (incoming.isCorrect !== current.isCorrect) {
changes.isCorrect = incoming.isCorrect;
hasChanges = true;
}
if (!areSettingsEqual(current.settings, incoming.settings)) {
changes.settings = normalizeSettings(incoming.settings);
hasChanges = true;
}
return hasChanges ? changes : null;
}
/**
* Computes diff between current exercise state and incoming update data
* Returns only the changes that need to be applied
*/
export function computeExerciseDiff(
currentQuestions: CurrentQuestion[],
incomingQuestions: NonNullable<TExerciseUpdate['questions']>
) {
const questionMap = new Map(currentQuestions.map((q) => [q.id, q]));
const deletedQuestionIds: number[] = [];
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
const newQuestions: NonNullable<TExerciseUpdate['questions']> = [];
// Process incoming questions
for (const incoming of incomingQuestions) {
if (incoming.deletedAt && incoming.id) {
deletedQuestionIds.push(incoming.id);
} else if (incoming.id) {
const current = questionMap.get(incoming.id);
if (current) {
const changes = diffQuestion(current, incoming);
if (changes) {
questionUpdates.push({ id: incoming.id, data: changes });
}
} else {
// Question ID exists but not in current state - treat as new
newQuestions.push(incoming);
}
} else {
newQuestions.push(incoming);
}
}
// Compute option diffs for existing questions
const optionDeletes: number[] = [];
const optionUpdates: Array<{ id: number; data: Partial<TNewOption> }> = [];
const optionCreates: TNewOption[] = [];
for (const incoming of incomingQuestions) {
if (!incoming.id || incoming.deletedAt) continue;
const current = questionMap.get(incoming.id);
if (!current) continue;
const currentOptionMap = new Map((current.options || []).map((opt) => [opt.id, opt]));
const incomingOptions = incoming.options || [];
const processedIncomingOptionIds = new Set<number>();
for (const incomingOpt of incomingOptions) {
if (incomingOpt.deletedAt && incomingOpt.id) {
optionDeletes.push(incomingOpt.id);
processedIncomingOptionIds.add(incomingOpt.id);
} else if (incomingOpt.id) {
processedIncomingOptionIds.add(incomingOpt.id);
const currentOpt = currentOptionMap.get(incomingOpt.id);
if (currentOpt) {
const changes = diffOption(currentOpt, incomingOpt);
if (changes) {
optionUpdates.push({ id: incomingOpt.id, data: changes });
}
} else {
// Option ID exists in incoming but not in current - treat as create
optionCreates.push({
questionId: incoming.id,
label: incomingOpt.label,
isCorrect: incomingOpt.isCorrect,
settings: normalizeSettings(incomingOpt.settings),
value: crypto.randomUUID()
});
}
} else {
// New option without ID
optionCreates.push({
questionId: incoming.id,
label: incomingOpt.label,
isCorrect: incomingOpt.isCorrect,
settings: normalizeSettings(incomingOpt.settings),
value: crypto.randomUUID()
});
}
}
// Check for options that were deleted (exist in current but not in incoming)
for (const [optId] of currentOptionMap) {
if (optId !== undefined && !processedIncomingOptionIds.has(optId)) {
optionDeletes.push(optId);
}
}
}
return {
questions: {
deletedIds: deletedQuestionIds,
updates: questionUpdates,
newQuestions
},
options: {
deletedIds: optionDeletes,
updates: optionUpdates,
creates: optionCreates
}
};
}
/**
* Creates new questions and their options in batch
*/
export async function createNewQuestionsWithOptions(
exerciseId: string,
newQuestions: NonNullable<TExerciseUpdate['questions']>,
txClient: DbOrTxClient
): Promise<void> {
const createdQuestions = await createQuestions(
newQuestions.map((q) => ({
exerciseId,
title: q.question,
questionTypeId: q.questionTypeId || 1,
points: q.points || 0,
order: typeof q.order === 'number' ? q.order : undefined,
exerciseSectionId: q.exerciseSectionId ?? null,
settings: normalizeSettings(q.settings)
})),
txClient
);
const newOptions: TNewOption[] = [];
createdQuestions.forEach((newQuestion, index) => {
const questionData = newQuestions[index];
if (newQuestion && questionData?.options && questionData.options.length > 0) {
// Only include non-deleted options for new questions
for (const opt of questionData.options) {
if (!opt.deletedAt) {
newOptions.push({
questionId: newQuestion.id,
label: opt.label,
isCorrect: opt.isCorrect,
settings: normalizeSettings(opt.settings)
});
}
}
}
});
if (newOptions.length > 0) {
await createOptions(newOptions, txClient);
}
}
/**
* Fetches questions and their options for an exercise
*/
export async function fetchQuestionsAndOptions(exerciseId: string, dbClient?: DbOrTxClient) {
const questions = await getQuestionsByExerciseIds([exerciseId], dbClient);
const questionIds = questions.map((q) => q.id).filter((id): id is number => id !== undefined);
const options = questionIds.length > 0 ? await getOptionsByQuestionIds(questionIds, dbClient) : [];
return { questions, options };
}
/**
* Fetches question types and creates a lookup map
*/
export async function fetchQuestionTypesMap(
questions: TNewQuestion[],
dbClient?: DbOrTxClient
): Promise<Map<number, TQuestionType>> {
const questionTypeIds = [
...new Set(questions.map((q) => q.questionTypeId).filter((id): id is number => id !== undefined))
];
const questionTypes: TQuestionType[] =
questionTypeIds.length > 0 ? await getQuestionTypesByIds(questionTypeIds, dbClient) : [];
return new Map(questionTypes.map((qt) => [qt.id, qt]));
}
/**
* Transforms a single question with its options to frontend format
*/
export function transformQuestion(
question: TNewQuestion,
options: TNewOption[],
questionTypeMap: Map<number, TQuestionType>
): QuestionWithRelations {
const questionType = questionTypeMap.get(question.questionTypeId);
const questionTypeId = question.questionTypeId;
const questionOptions = options
.filter((opt) => opt.questionId === question.id)
.map((opt) => ({
id: opt.id!,
label: opt.label ?? null,
value: opt.value ?? null,
isCorrect: opt.isCorrect ?? false,
settings: normalizeSettings(opt.settings)
}));
return {
id: question.id!,
exerciseSectionId: question.exerciseSectionId ?? null,
value: question.title || '', // value field defaults to title
name: question.name || '',
title: question.title,
type: questionTypeId, // type field (same as questionTypeId)
points: question.points || 0,
order: question.order || 0,
questionType: questionType
? {
id: questionType.id,
label: questionType.label
}
: {
id: questionTypeId,
label: ''
},
questionTypeId: questionTypeId,
settings: normalizeSettings(question.settings),
createdAt: question.createdAt || undefined,
updatedAt: question.updatedAt || undefined,
options: questionOptions
};
}
/**
* Transforms all questions to frontend format
*/
export function transformQuestions(
questions: TNewQuestion[],
options: TNewOption[],
questionTypeMap: Map<number, TQuestionType>
): QuestionWithRelations[] {
return questions.map((question) => transformQuestion(question, options, questionTypeMap));
}