Files
classroomio/apps/api/src/services/exercise/utils.ts
T
Rotimi BestandRotimi Best bfafc685e0 fix(exercise): persist completion policy and improve dev build workflow (#754)
* fix(exercise): persist completion policy and pass threshold on save

buildExerciseUpdateFields accepted completionPolicy and passThreshold in
the validation schema but never mapped them to the database update, so
exercise settings appeared to save while reverting on refresh.

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): build workspace deps before compiling

@cio/db imports @cio/utils/notifications, which only exists after
@cio/utils is built. Building db in isolation failed when utils dist
was missing or stale.

- Add a db prebuild step for question-types, utils, and email
- Clean dist and tsbuildinfo before utils and email builds so
  incremental tsc cannot skip emission after dist was removed

Co-authored-by: Rotimi Best <[email protected]>

* chore(dev): build api workspace deps before api:dev

Run turbo build for @cio/api and @cio/jobs-worker dependencies before
starting the dev servers, so missing dist/ folders do not break startup.

Co-authored-by: Rotimi Best <[email protected]>

* feat(course): improve exercise progression grading UX

---------

Co-authored-by: Rotimi Best <[email protected]>
2026-07-15 03:26:56 +01:00

492 lines
16 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 {
QUESTION_TYPE_IDS,
QUESTION_TYPE_KEY,
resolveTrueFalseCorrectValue,
syncTrueFalseOptions,
type TrueFalseAnswerOption
} from '@cio/question-types';
import type { DbOrTxClient } from '@cio/db/drizzle';
import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
type CurrentQuestion = TNewQuestion & { options?: TNewOption[] };
type CurrentOption = TNewOption;
type ExerciseUpdateQuestion = NonNullable<TExerciseUpdate['questions']>[number];
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);
}
const TRUE_FALSE_QUESTION_TYPE_ID = QUESTION_TYPE_IDS[QUESTION_TYPE_KEY.TRUE_FALSE];
function normalizeTrueFalseQuestion(
question: ExerciseUpdateQuestion,
currentQuestion?: CurrentQuestion
): ExerciseUpdateQuestion {
const questionTypeId = question.questionTypeId ?? currentQuestion?.questionTypeId;
if (questionTypeId !== TRUE_FALSE_QUESTION_TYPE_ID) return question;
const settings = normalizeSettings(question.settings ?? currentQuestion?.settings);
const options = question.options ?? currentQuestion?.options ?? [];
const correctValue = resolveTrueFalseCorrectValue(settings, options as TrueFalseAnswerOption[]);
return {
...question,
settings: { ...settings, correctValue },
options: question.options ? syncTrueFalseOptions(question.options, correctValue) : question.options
};
}
/**
* 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.completionPolicy !== undefined) exerciseUpdate.completionPolicy = data.completionPolicy;
if (data.passThreshold !== undefined) exerciseUpdate.passThreshold = data.passThreshold;
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 rawQuestion of questions) {
const q = normalizeTrueFalseQuestion(rawQuestion);
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 normalizedIncomingQuestions = incomingQuestions.map((incoming) =>
normalizeTrueFalseQuestion(incoming, incoming.id ? questionMap.get(incoming.id) : undefined)
);
const deletedQuestionIds: number[] = [];
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
const newQuestions: NonNullable<TExerciseUpdate['questions']> = [];
// Process incoming questions
for (const incoming of normalizedIncomingQuestions) {
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 normalizedIncomingQuestions) {
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 normalizedQuestions = newQuestions.map((question) => normalizeTrueFalseQuestion(question));
const createdQuestions = await createQuestions(
normalizedQuestions.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 = normalizedQuestions[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));
}