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]>
This commit is contained in:
Rotimi Best
2026-07-15 03:26:56 +01:00
committed by GitHub
co-authored by Rotimi Best
parent fdc19aac41
commit bfafc685e0
53 changed files with 2026 additions and 285 deletions
@@ -83,6 +83,12 @@ export const questionSchema = z.object({
),
points: z.number().min(0).default(1),
order: z.number().int().min(0),
settings: z
.record(z.string(), z.unknown())
.optional()
.describe(
'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
),
options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() }))
});
@@ -118,7 +124,22 @@ export const updateExerciseParam = z
.optional()
.describe('Due date in ISO 8601 format (e.g. 2026-05-01T23:59:00Z). Omit to keep unchanged.'),
isUnlocked: z.boolean().optional().describe('Whether the exercise is unlocked for students.'),
allowMultipleAttempts: z.boolean().optional().describe('Whether students can re-take the exercise.')
allowMultipleAttempts: z.boolean().optional().describe('Whether students can re-take the exercise.'),
completionPolicy: z
.enum(['submitted', 'passed'])
.optional()
.describe(
'"submitted" means any submitted attempt completes the exercise. "passed" means the student must meet or exceed passThreshold before the exercise is considered complete.'
),
passThreshold: z
.number()
.int()
.min(0)
.max(100)
.optional()
.describe(
'Passing score percentage from 0 to 100. Use with completionPolicy "passed" when the teacher asks students to pass a threshold before progressing.'
)
})
.refine(
(data) =>
@@ -129,7 +150,9 @@ export const updateExerciseParam = z
data.order !== undefined ||
data.dueBy !== undefined ||
data.isUnlocked !== undefined ||
data.allowMultipleAttempts !== undefined,
data.allowMultipleAttempts !== undefined ||
data.completionPolicy !== undefined ||
data.passThreshold !== undefined,
{
message: 'Provide at least one field to update'
}
@@ -189,7 +212,7 @@ export const updateQuestionSettingsSchema = z
message: 'Provide at least one settings field'
})
.describe(
'Per-type correct-answer storage. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
);
export const updateOptionSchema = z.object({
+10 -3
View File
@@ -685,6 +685,7 @@ export function buildAgentTools(
questionTypeId: q.questionTypeId,
points: q.points,
order: q.order ?? i,
settings: q.settings,
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
}))
});
@@ -695,7 +696,7 @@ export function buildAgentTools(
update_exercise: tool({
description:
"Update an existing exercise's metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts). Use this for editing the exercise itself — not for changing its questions. To edit questions, use update_questions; to add questions, use add_questions.",
'Update an existing exercise\'s metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts, completion policy, passing threshold). Use this for editing the exercise itself — not for changing its questions. To require students to pass before progress/completion, set completionPolicy to "passed" and passThreshold to the required percentage. To edit questions, use update_questions; to add questions, use add_questions.',
inputSchema: updateExerciseParam,
execute: async (args) => {
return executeAgentTool('update_exercise', { orgId, userId, courseId, args }, async () => {
@@ -717,7 +718,9 @@ export function buildAgentTools(
order: args.order,
dueBy: args.dueBy,
isUnlocked: args.isUnlocked,
allowMultipleAttempts: args.allowMultipleAttempts
allowMultipleAttempts: args.allowMultipleAttempts,
completionPolicy: args.completionPolicy,
passThreshold: args.passThreshold
});
return {
@@ -725,6 +728,8 @@ export function buildAgentTools(
title: updated.title,
description: updated.description ?? null,
dueBy: updated.dueBy ?? null,
completionPolicy: updated.completionPolicy ?? null,
passThreshold: updated.passThreshold ?? null,
updated: true
};
});
@@ -812,6 +817,7 @@ export function buildAgentTools(
questionTypeId: q.questionTypeId,
points: q.points,
order: q.order ?? nextOrder + i,
settings: q.settings,
...(args.exerciseSectionId !== undefined ? { exerciseSectionId: args.exerciseSectionId } : {}),
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
}));
@@ -823,6 +829,7 @@ export function buildAgentTools(
questionTypeId: eq.questionTypeId,
points: eq.points,
order: eq.order,
settings: eq.settings,
options: eq.options.map((o) => ({ id: Number(o.id), label: o.label || '', isCorrect: o.isCorrect }))
})),
...newQuestions
@@ -841,7 +848,7 @@ export function buildAgentTools(
update_questions: tool({
description:
'Update existing questions in an exercise. Pass only fields you want to change; `id` identifies the question. Optional `exerciseSectionId` moves the question to another in-exercise block (use get_exercise_details section ids), or null to unassign. For NUMERIC, the correct answer is `settings.correctValue` (number) — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue`. For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX/TRUE_FALSE use `options[].isCorrect` (include option `id` to edit, omit `id` to add). `settings` is shallow-merged with existing settings. Omit `options` entirely to leave existing options untouched.',
'Update existing questions in an exercise. Pass only fields you want to change; `id` identifies the question. Optional `exerciseSectionId` moves the question to another in-exercise block (use get_exercise_details section ids), or null to unassign. For TRUE_FALSE use `settings.correctValue` (boolean) and keep exactly two options labeled True and False. For NUMERIC, the correct answer is `settings.correctValue` (number) — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue`. For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX use `options[].isCorrect` (include option `id` to edit, omit `id` to add). `settings` is shallow-merged with existing settings. Omit `options` entirely to leave existing options untouched.',
inputSchema: updateQuestionsParam,
execute: async (args) => {
return executeAgentTool('update_questions', { orgId, userId, courseId, args }, async () => {
+41 -5
View File
@@ -12,11 +12,19 @@ import {
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,
@@ -68,6 +76,26 @@ function areSettingsEqual(left: unknown, right: unknown): boolean {
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
*/
@@ -81,6 +109,8 @@ export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial<TExerc
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;
@@ -95,7 +125,9 @@ export function categorizeQuestions(questions: NonNullable<TExerciseUpdate['ques
const newQuestions: typeof questions = [];
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
for (const q of questions) {
for (const rawQuestion of questions) {
const q = normalizeTrueFalseQuestion(rawQuestion);
if (q.deletedAt && q.id) {
deletedIds.push(q.id);
} else if (q.id) {
@@ -230,13 +262,16 @@ export function computeExerciseDiff(
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 incomingQuestions) {
for (const incoming of normalizedIncomingQuestions) {
if (incoming.deletedAt && incoming.id) {
deletedQuestionIds.push(incoming.id);
} else if (incoming.id) {
@@ -260,7 +295,7 @@ export function computeExerciseDiff(
const optionUpdates: Array<{ id: number; data: Partial<TNewOption> }> = [];
const optionCreates: TNewOption[] = [];
for (const incoming of incomingQuestions) {
for (const incoming of normalizedIncomingQuestions) {
if (!incoming.id || incoming.deletedAt) continue;
const current = questionMap.get(incoming.id);
@@ -336,8 +371,9 @@ export async function createNewQuestionsWithOptions(
newQuestions: NonNullable<TExerciseUpdate['questions']>,
txClient: DbOrTxClient
): Promise<void> {
const normalizedQuestions = newQuestions.map((question) => normalizeTrueFalseQuestion(question));
const createdQuestions = await createQuestions(
newQuestions.map((q) => ({
normalizedQuestions.map((q) => ({
exerciseId,
title: q.question,
questionTypeId: q.questionTypeId || 1,
@@ -351,7 +387,7 @@ export async function createNewQuestionsWithOptions(
const newOptions: TNewOption[] = [];
createdQuestions.forEach((newQuestion, index) => {
const questionData = newQuestions[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) {
+2 -1
View File
@@ -46,7 +46,8 @@ export async function createLesson(courseId: string, data: TLessonCreate): Promi
const lessonData = {
...data,
courseId,
slug
slug,
isUnlocked: data.isUnlocked ?? true
};
const [lesson] = await createLessons([lessonData]);