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
+13 -3
View File
@@ -41,7 +41,7 @@ export function buildTeacherSystemPrompt(context: AgentContext, options?: BuildT
## Your Capabilities
You have access to specific tools listed below. Use them to read course content, read a lesson's uploaded video transcript via get_lesson_transcript (the video's spoken content is not in the lesson body — use this whenever a question is about what the video says or explains), create sections and lessons, update existing course-outline section metadata via update_section, update existing lesson metadata via update_lesson, write lesson content, create exercises with questions, create exercise question blocks via create_exercise_section, edit exercise-level metadata (title, description, linked lesson, course section placement, order, due date, lock state, allow-multiple-attempts) via update_exercise, edit each existing in-exercise question block heading and intro via update_exercise_section (ids from get_exercise_details sections array — not course section ids), add or update questions inside an existing exercise (use add_questions with exerciseSectionId from that sections array when the exercise groups questions into blocks), update course landing-page copy/settings, check go-live readiness, and publish the course when it is ready. To change a question's text, points, options, or correct answer, use update_questions; to add new questions, use add_questions.
You have access to specific tools listed below. Use them to read course content, read a lesson's uploaded video transcript via get_lesson_transcript (the video's spoken content is not in the lesson body — use this whenever a question is about what the video says or explains), create sections and lessons, update existing course-outline section metadata via update_section, update existing lesson metadata via update_lesson, write lesson content, create exercises with questions, create exercise question blocks via create_exercise_section, edit exercise-level metadata (title, description, linked lesson, course section placement, order, due date, lock state, allow-multiple-attempts, completion policy, passing threshold) via update_exercise, edit each existing in-exercise question block heading and intro via update_exercise_section (ids from get_exercise_details sections array — not course section ids), add or update questions inside an existing exercise (use add_questions with exerciseSectionId from that sections array when the exercise groups questions into blocks), update course landing-page copy/settings, check go-live readiness, and publish the course when it is ready. To change a question's text, points, options, or correct answer, use update_questions; to add new questions, use add_questions.
## Question Types
@@ -215,15 +215,25 @@ If a lesson has \`hasExercise: true\`, the linked exercise takes the next order
- If the teacher asks to change a section name or order, first call get_course_structure, then use update_section with the existing section ID. Do NOT create a replacement section.
- If the teacher asks to change a lesson name, move a lesson to another section, reorder it, schedule it, or change its visibility/unlock settings, first call get_course_structure, then use update_lesson with the existing lesson ID. Do NOT create a replacement lesson.
- If the teacher asks to change exercise metadata such as title, description, linked lesson, course section placement, order, due date, lock state, or multiple-attempt behavior, use update_exercise. Do NOT create a replacement exercise.
- If the teacher asks to change exercise metadata such as title, description, linked lesson, course section placement, order, due date, lock state, multiple-attempt behavior, completion policy, or passing threshold, use update_exercise. Do NOT create a replacement exercise.
- If the teacher asks to create a new **block of questions inside an exercise**, use create_exercise_section. If they ask to rename or add intro text for an existing block, call get_exercise_details first, then use update_exercise_section with the exercise id and the section id from the sections array. Do NOT use update_section for that — update_section only changes course outline sections.
- Only use create_section or create_lesson when the teacher clearly wants a brand-new item added to the course.
## Exercise Completion Gates
Exercises have two independent controls that affect student completion:
- \`completionPolicy: "submitted"\` — submitting an attempt marks the exercise complete.
- \`completionPolicy: "passed"\` plus \`passThreshold: <0-100>\` — the student must score at least that percentage before the exercise is complete.
When the teacher says students must "pass", "hit a score", "meet a threshold", "get 70%", "score at least X%", or similar, use \`update_exercise\` with \`completionPolicy: "passed"\` and the requested \`passThreshold\`. If the teacher says students must complete/pass an exercise **before progressing to later course items**, also make sure the course uses sequential progression by calling \`update_course_landing_page\` with \`metadata.progressionMode: "sequential"\` when it is not already sequential. Sequential progression decides whether later items are locked; the exercise completion policy decides whether this exercise counts as complete after any submission or only after a passing score.
## Editing Existing Questions
To change an existing question (its text, points, order, in-exercise section, correct answer, or options), use update_questions — never use add_questions for edits, as that creates duplicates. Use \`exerciseSectionId\` on update_questions to move a question to another block from get_exercise_details \`sections[].id\`, or null to unassign. When adding questions with add_questions, pass exerciseSectionId from get_exercise_details \`sections[].id\` if the exercise has multiple in-exercise blocks so new items land in the right group. Where the correct answer lives depends on the question type:
- RADIO, CHECKBOX, TRUE_FALSE: on \`options[].isCorrect\`. Include an option's \`id\` to edit it; omit \`id\` to add a new option. The system automatically balances which position holds the correct answer across an exercise, so you don't need to vary it yourself — but DO keep the correct option from being an obvious tell: don't always make it the longest or most detailed choice, vary True/False answers, and avoid leaning on "all of the above"-style options.
- RADIO, CHECKBOX: on \`options[].isCorrect\`. Include an option's \`id\` to edit it; omit \`id\` to add a new option. The system automatically balances which position holds the correct answer across an exercise, so you don't need to vary it yourself — but DO keep the correct option from being an obvious tell: don't always make it the longest or most detailed choice, and avoid leaning on "all of the above"-style options.
- TRUE_FALSE: on \`settings.correctValue\` as a boolean. Use \`true\` when True is correct and \`false\` when False is correct. Keep exactly two options labeled True and False; their \`isCorrect\` values are synchronized from \`settings.correctValue\`.
- NUMERIC: see the dedicated NUMERIC block below — \`settings.correctValue\` is required and \`settings.tolerance\` should almost always be set. \`options\` MUST be empty/absent.
- STAR: on \`settings.correctValue\` (1..max stars).
- WORD_BANK: on \`settings.correctAnswers\` (array, one per \`___\` blank) and \`settings.template\`.
+35 -6
View File
@@ -138,6 +138,12 @@ export const createExerciseSchema = {
),
points: z.number().min(0).default(1).describe('Points for this question'),
order: z.number().int().min(0).describe('Display order'),
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({
@@ -146,7 +152,7 @@ export const createExerciseSchema = {
})
)
.describe(
'Answer options. RADIO and CHECKBOX questions MUST have at least 4 options with plausible distractors (not obvious filler). RADIO needs exactly one correct option; CHECKBOX needs at least 2 correct and at least 1 incorrect. TRUE_FALSE uses exactly 2 options. Leave empty for NUMERIC/STAR/WORD_BANK.'
'Answer options. RADIO and CHECKBOX questions MUST have at least 4 options with plausible distractors (not obvious filler). RADIO needs exactly one correct option; CHECKBOX needs at least 2 correct and at least 1 incorrect. TRUE_FALSE uses exactly 2 options labeled True and False; set the correct answer in settings.correctValue. Leave empty for NUMERIC/STAR/WORD_BANK.'
)
})
)
@@ -189,7 +195,7 @@ export const updateExerciseSectionSchema = {
export const updateExerciseSchema = {
description:
"Update an existing exercise's metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts). Use this to edit the exercise itself — not its questions. To change 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 to edit the exercise itself — not its questions. To require students to pass before progress/completion, set completionPolicy to "passed" and passThreshold to the required percentage. To only require submission, set completionPolicy to "submitted". To change questions, use update_questions; to add questions, use add_questions.',
parameters: z
.object({
courseId: z.string().describe('The course ID'),
@@ -207,7 +213,22 @@ export const updateExerciseSchema = {
.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) =>
@@ -218,7 +239,9 @@ export const updateExerciseSchema = {
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'
}
@@ -251,6 +274,12 @@ export const addQuestionsSchema = {
),
points: z.number().min(0).default(1).describe('Points for this question'),
order: z.number().int().min(0).describe('Display order'),
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).describe('Option text'),
@@ -264,7 +293,7 @@ export const addQuestionsSchema = {
export const updateQuestionsSchema = {
description:
'Update existing questions in an exercise. Pass only the fields you want to change; `id` identifies the question. Optional `exerciseSectionId` (from get_exercise_details sections) moves the question to another in-exercise block; use null to clear section assignment. For NUMERIC the correct answer is `settings.correctValue` (a number), not an option — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue` (1..max). For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX/TRUE_FALSE use `options[].isCorrect`: include an option `id` to edit it, omit `id` to add a new one. `settings` is shallow-merged with the existing settings. Omit `options` entirely to leave existing options unchanged — if you pass an options array, any existing option not listed in it will be removed.',
'Update existing questions in an exercise. Pass only the fields you want to change; `id` identifies the question. Optional `exerciseSectionId` (from get_exercise_details sections) moves the question to another in-exercise block; use null to clear section assignment. For TRUE_FALSE use `settings.correctValue` (boolean) and keep exactly two options labeled True and False. For NUMERIC the correct answer is `settings.correctValue` (a number), not an option — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue` (1..max). For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX use `options[].isCorrect`: include an option `id` to edit it, omit `id` to add a new one. `settings` is shallow-merged with the existing settings. Omit `options` entirely to leave existing options unchanged — if you pass an options array, any existing option not listed in it will be removed.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The existing exercise ID'),
@@ -293,7 +322,7 @@ export const updateQuestionsSchema = {
.record(z.string(), z.unknown())
.optional()
.describe(
'Per-type correct-answer storage. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }. Shallow-merged with existing settings.'
'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }. Shallow-merged with existing settings.'
),
options: z
.array(
@@ -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]);
+1
View File
@@ -50,6 +50,7 @@
"dist"
],
"scripts": {
"prebuild": "pnpm --filter @cio/question-types run build && pnpm --filter @cio/utils run build && pnpm --filter @cio/email run build",
"build": "rimraf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
"dev": "tsc --watch",
"db": "drizzle-kit",
+20 -1
View File
@@ -1,7 +1,7 @@
import * as schema from '@db/schema';
import { TGroupmember, TNewGroupmember } from '@db/types';
import { and, eq, or } from 'drizzle-orm';
import { and, eq, isNull, or } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
@@ -325,6 +325,25 @@ export async function setMemberCertificateEarned(memberId: string, earnedAt: str
}
}
/**
* Atomically claims the certificate for a member: only succeeds if it was not earned yet.
* Returns true when this caller won the claim, so concurrent evaluations issue the
* certificate (and downstream email) exactly once.
*/
export async function claimMemberCertificateEarned(memberId: string, earnedAt: string): Promise<boolean> {
try {
const updated = await db
.update(schema.groupmember)
.set({ certificateEarnedAt: earnedAt })
.where(and(eq(schema.groupmember.id, memberId), isNull(schema.groupmember.certificateEarnedAt)))
.returning({ id: schema.groupmember.id });
return updated.length > 0;
} catch (error) {
console.error('claimMemberCertificateEarned error:', error);
throw new Error(`Failed to claim certificate earned: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Records that the certification congratulations email was sent.
*/
+1 -1
View File
@@ -996,7 +996,7 @@ export const lesson = pgTable(
callUrl: text('call_url'),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
order: bigint({ mode: 'number' }),
isUnlocked: boolean('is_unlocked').default(false),
isUnlocked: boolean('is_unlocked').default(true),
completionPolicy: varchar('completion_policy').default('manual').notNull(),
videoWatchThreshold: integer('video_watch_threshold').default(95),
videos: jsonb().default([]).$type<
+1 -1
View File
@@ -8,7 +8,7 @@
"dist"
],
"scripts": {
"build": "tsc && tsc-alias",
"build": "rimraf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
"dev": "tsc --watch",
"format": "prettier --write \"src/**/*.ts\"",
"test": "vitest run",
+1
View File
@@ -18,3 +18,4 @@ export * from './renderer-registry';
export * from './textarea-validation';
export * from './star-rating-settings';
export * from './video-recording-settings';
export * from './true-false-answer-key';
@@ -5,6 +5,7 @@ import { getQuestionTypeById } from './question-type-lookup';
import { QUESTION_TYPE_ID_TO_KEY } from './question-type-registry';
import { supportsPartialCredit } from './question-type-capabilities';
import { getStarRatingMaxFromSettings, isValidStarRatingValue } from './star-rating-settings';
import { resolveTrueFalseCorrectValue } from './true-false-answer-key';
export type DbQuestionForScoring = {
id: number;
@@ -112,20 +113,7 @@ function scoreTrueFalse(
answer: Extract<AnswerData, { type: 'TRUE_FALSE' }>,
maxPoints: number
): number {
const opts = question.options ?? [];
const trueOpt = opts.find((o) => normalizeText(o.label) === 'true');
const falseOpt = opts.find((o) => normalizeText(o.label) === 'false');
const correctFromSettings = question.settings?.correctValue;
let correctIsTrue: boolean;
if (typeof correctFromSettings === 'boolean') {
correctIsTrue = correctFromSettings;
} else if (trueOpt?.isCorrect) {
correctIsTrue = true;
} else if (falseOpt?.isCorrect) {
correctIsTrue = false;
} else {
correctIsTrue = true;
}
const correctIsTrue = resolveTrueFalseCorrectValue(question.settings, question.options);
return answer.value === correctIsTrue ? maxPoints : 0;
}
@@ -0,0 +1,59 @@
export type TrueFalseAnswerOption = {
label?: string | null;
value?: string | number | boolean | null;
isCorrect?: boolean;
deletedAt?: unknown;
};
function normalizeBooleanText(value: unknown): string {
return String(value ?? '')
.trim()
.toLowerCase();
}
export function getTrueFalseOptionValue(option: TrueFalseAnswerOption): boolean | undefined {
const normalizedValue = normalizeBooleanText(option.value);
if (normalizedValue === 'true' || normalizedValue === '1') return true;
if (normalizedValue === 'false' || normalizedValue === '0') return false;
const normalizedLabel = normalizeBooleanText(option.label);
if (normalizedLabel === 'true' || normalizedLabel === '1') return true;
if (normalizedLabel === 'false' || normalizedLabel === '0') return false;
return undefined;
}
export function resolveTrueFalseCorrectValue(
settings: Record<string, unknown> | undefined,
options: TrueFalseAnswerOption[] | undefined,
fallback = true
): boolean {
if (typeof settings?.correctValue === 'boolean') return settings.correctValue;
const correctOption = options?.find((option) => !option.deletedAt && option.isCorrect);
const correctValue = correctOption ? getTrueFalseOptionValue(correctOption) : undefined;
return typeof correctValue === 'boolean' ? correctValue : fallback;
}
export function syncTrueFalseOptions<T extends TrueFalseAnswerOption>(
options: T[] | undefined,
correctValue: boolean
): T[] {
return (options ?? []).map((option) => {
const optionValue = getTrueFalseOptionValue(option);
if (typeof optionValue !== 'boolean') return option;
return { ...option, isCorrect: optionValue === correctValue };
});
}
export function getCanonicalTrueFalseSettings(
settings: Record<string, unknown> | undefined,
options: TrueFalseAnswerOption[] | undefined
): Record<string, unknown> {
return {
...(settings ?? {}),
correctValue: resolveTrueFalseCorrectValue(settings, options)
};
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { resolveTrueFalseCorrectValue, syncTrueFalseOptions } from '../src';
describe('true/false answer key helpers', () => {
it('uses settings.correctValue as the canonical answer when present', () => {
const options = [
{ label: 'True', value: 'true', isCorrect: true },
{ label: 'False', value: 'false', isCorrect: false }
];
expect(resolveTrueFalseCorrectValue({ correctValue: false }, options)).toBe(false);
});
it('falls back to the correct option label for legacy questions', () => {
const options = [
{ label: 'True', value: 'fb6c9fa8-4fd9-4e71-b319-3e969656a935', isCorrect: false },
{ label: 'False', value: '2e191a1e-4aca-4bef-afae-dbd076d0b60b', isCorrect: true }
];
expect(resolveTrueFalseCorrectValue({}, options)).toBe(false);
});
it('syncs option correctness from the canonical boolean', () => {
const options = [
{ label: 'True', value: 'true', isCorrect: true },
{ label: 'False', value: 'false', isCorrect: false }
];
expect(syncTrueFalseOptions(options, false)).toEqual([
{ label: 'True', value: 'true', isCorrect: false },
{ label: 'False', value: 'false', isCorrect: true }
]);
});
});
@@ -1,7 +1,8 @@
<script lang="ts">
import * as Chart from '../../../../base/chart';
import type * as Chart from '../../../../base/chart';
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import SubmissionResponseBarChart from '../shared/submission-response-bar-chart.svelte';
import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils';
let {
@@ -75,19 +76,9 @@
});
</script>
{#if chartData.length > 0}
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full">
<Chart.BarChart
data={chartData}
xScale={Chart.scaleBand().padding(0.25)}
x="label"
axis="x"
tooltip={false}
{series}
/>
</Chart.Container>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">
{getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
</p>
{/if}
<SubmissionResponseBarChart
{chartData}
{chartConfig}
{series}
emptyLabel={getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
/>
@@ -1,7 +1,8 @@
<script lang="ts">
import * as Chart from '../../../../base/chart';
import type * as Chart from '../../../../base/chart';
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import SubmissionResponseBarChart from '../shared/submission-response-bar-chart.svelte';
import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils';
let {
@@ -91,19 +92,9 @@
});
</script>
{#if chartData.length > 0}
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full">
<Chart.BarChart
data={chartData}
xScale={Chart.scaleBand().padding(0.25)}
x="label"
axis="x"
tooltip={false}
{series}
/>
</Chart.Container>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">
{getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
</p>
{/if}
<SubmissionResponseBarChart
{chartData}
{chartConfig}
{series}
emptyLabel={getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
/>
@@ -0,0 +1,48 @@
<script lang="ts">
import * as Chart from '../../../../base/chart';
import type { ChartConfig } from '../../../../base/chart';
interface Row {
key: string;
label: string;
responses: number;
color?: string;
}
interface Props {
chartData: Row[];
chartConfig: ChartConfig;
emptyLabel: string;
series: Array<{ key: string; value: string; label: string; color: string }>;
}
let { chartData, chartConfig, emptyLabel, series }: Props = $props();
const ROW_HEIGHT_PX = 48;
const MIN_CHART_HEIGHT_PX = 120;
const AXIS_LABEL_MAX_LENGTH = 28;
const chartHeightPx = $derived(Math.max(MIN_CHART_HEIGHT_PX, chartData.length * ROW_HEIGHT_PX));
function truncateAxisLabel(label: string): string {
if (label.length <= AXIS_LABEL_MAX_LENGTH) return label;
return `${label.slice(0, AXIS_LABEL_MAX_LENGTH - 1).trimEnd()}…`;
}
</script>
{#if chartData.length > 0}
<Chart.Container config={chartConfig} class="ui:aspect-auto ui:w-full" style="height: {chartHeightPx}px">
<Chart.BarChart
data={chartData}
orientation="horizontal"
y="label"
axis="y"
bandPadding={0.25}
props={{ yAxis: { format: truncateAxisLabel } }}
{series}
/>
</Chart.Container>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">{emptyLabel}</p>
{/if}
@@ -1,6 +1,8 @@
<script lang="ts">
import {
getExerciseQuestionLabel,
resolveTrueFalseCorrectValue,
syncTrueFalseOptions,
type ExerciseQuestionModel,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
@@ -18,14 +20,7 @@
if (value !== 'true' && value !== 'false') return;
const correctValue = value === 'true';
const opts = question.options ?? [];
const options = opts.map((opt) => {
const v = String(opt.value ?? opt.label ?? '').toLowerCase();
const isTrue = v === 'true' || v === '1';
const isFalse = v === 'false' || v === '0';
const isCorrect = (isTrue && correctValue) || (isFalse && !correctValue);
return isTrue || isFalse ? { ...opt, isCorrect } : opt;
});
const options = syncTrueFalseOptions(question.options, correctValue);
patchQuestion({
settings: { ...(question.settings ?? {}), correctValue },
@@ -40,13 +35,9 @@
}
const selectedValue = $derived.by(() => {
const correctOpt = (question.options ?? []).find((o) => o.isCorrect);
if (correctOpt) {
const v = String(correctOpt.value ?? correctOpt.label).toLowerCase();
return v === 'false' || v === '0' ? 'false' : 'true';
}
const fromSettings = question.settings?.correctValue as boolean | undefined;
return typeof fromSettings === 'boolean' ? String(fromSettings) : 'true';
return String(
resolveTrueFalseCorrectValue(question.settings as Record<string, unknown> | undefined, question.options)
);
});
</script>
@@ -7,7 +7,7 @@
getExerciseQuestionLabel(labels, key, fallback);
const correctValue = $derived(
getTrueFalseCorrectIsTrue(question.settings as Record<string, unknown> | undefined)
getTrueFalseCorrectIsTrue(question.settings as Record<string, unknown> | undefined, question.options)
? label('true_false.true_label')
: label('true_false.false_label')
);
@@ -5,7 +5,9 @@
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
const correctIsTrue = $derived(getTrueFalseCorrectIsTrue(question.settings as Record<string, unknown> | undefined));
const correctIsTrue = $derived(
getTrueFalseCorrectIsTrue(question.settings as Record<string, unknown> | undefined, question.options)
);
const studentValue = $derived.by(() => {
if (answer?.type !== 'TRUE_FALSE') return '';
@@ -1,6 +1,11 @@
import { resolveTrueFalseCorrectValue, type ExerciseQuestionOption } from '@cio/question-types';
/**
* Matches preview/review: undefined `correctValue` defaults to true.
* Matches backend scoring: settings.correctValue wins, then option correctness, then true.
*/
export function getTrueFalseCorrectIsTrue(settings: Record<string, unknown> | undefined): boolean {
return (settings?.correctValue as boolean | undefined) !== false;
export function getTrueFalseCorrectIsTrue(
settings: Record<string, unknown> | undefined,
options: ExerciseQuestionOption[] | undefined
): boolean {
return resolveTrueFalseCorrectValue(settings, options);
}
@@ -1,7 +1,8 @@
<script lang="ts">
import * as Chart from '../../../../base/chart';
import type * as Chart from '../../../../base/chart';
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import SubmissionResponseBarChart from '../shared/submission-response-bar-chart.svelte';
import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils';
let {
@@ -94,19 +95,9 @@
});
</script>
{#if chartData.length > 0}
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full">
<Chart.BarChart
data={chartData}
xScale={Chart.scaleBand().padding(0.25)}
x="label"
axis="x"
tooltip={false}
{series}
/>
</Chart.Container>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">
{getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
</p>
{/if}
<SubmissionResponseBarChart
{chartData}
{chartConfig}
{series}
emptyLabel={getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
/>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -71,7 +71,7 @@
}
},
"scripts": {
"build": "tsc && tsc-alias",
"build": "rimraf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
"dev": "tsc --watch",
"format": "prettier --write ."
},
@@ -46,6 +46,10 @@ const QUESTION_VALIDATION_RULES: Record<number, Array<(question: QuestionRuleInp
return null;
},
(question) => {
if (typeof question.settings?.correctValue === 'boolean') {
return null;
}
const options = question.options || [];
if (options.length > 0 && !options.some((opt) => opt.isCorrect === true)) {
return 'Please mark an option as the correct answer';