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; code?: string; answers?: ExerciseAnswer[]; deletedAt?: string; createdAt?: string; updatedAt?: string; options: { id: number | string; label: string | null; value: string | null; isCorrect: boolean; settings?: Record; deletedAt?: string; }[]; }; function normalizeSettings(value: unknown): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { return value as Record; } 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 { const exerciseUpdate: Partial = {}; 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) { const deletedIds: number[] = []; const existingQuestions: typeof questions = []; const newQuestions: typeof questions = []; const questionUpdates: Array<{ id: number; data: Partial }> = []; 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[number]['options'] ) { const deletedIds: number[] = []; const existingUpdates: Array<{ id: number; data: Partial }> = []; 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[number] ): Partial | null { const changes: Partial = {}; 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[number]['options']>[number] ): Partial | null { const changes: Partial = {}; 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 ) { const questionMap = new Map(currentQuestions.map((q) => [q.id, q])); const deletedQuestionIds: number[] = []; const questionUpdates: Array<{ id: number; data: Partial }> = []; const newQuestions: NonNullable = []; // 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 }> = []; 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(); 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, txClient: DbOrTxClient ): Promise { 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> { 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 ): 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 ): QuestionWithRelations[] { return questions.map((question) => transformQuestion(question, options, questionTypeMap)); }