Improve MCP tooling and draft exercise publishing

This commit is contained in:
Rotimi Best
2026-03-21 10:04:39 +01:00
parent 345055421c
commit e9c4082f89
21 changed files with 711 additions and 72 deletions
@@ -1,6 +1,7 @@
import * as z from 'zod';
import { ZCourseMetadata } from '../course/course';
import { ZExerciseQuestionTypeId } from '../exercise/exercise';
const ZSupportedLocale = z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
const LESSON_BODY_HTML_DESCRIPTION =
@@ -54,6 +55,35 @@ export const ZCourseImportDraftLessonLanguage = z.object({
});
export type TCourseImportDraftLessonLanguage = z.infer<typeof ZCourseImportDraftLessonLanguage>;
export const ZCourseImportDraftExerciseOption = z.object({
label: z.string().min(1),
isCorrect: z.boolean(),
settings: z.record(z.string(), z.unknown()).optional()
});
export type TCourseImportDraftExerciseOption = z.infer<typeof ZCourseImportDraftExerciseOption>;
export const ZCourseImportDraftExerciseQuestion = z.object({
question: z.string().min(1),
questionTypeId: ZExerciseQuestionTypeId.optional(),
points: z.number().int().min(0).optional(),
order: z.number().int().min(0).optional(),
settings: z.record(z.string(), z.unknown()).optional(),
options: z.array(ZCourseImportDraftExerciseOption).optional()
});
export type TCourseImportDraftExerciseQuestion = z.infer<typeof ZCourseImportDraftExerciseQuestion>;
export const ZCourseImportDraftExercise = z.object({
externalId: z.string().min(1),
lessonExternalId: z.string().min(1).optional(),
sectionExternalId: z.string().min(1).optional(),
title: z.string().min(1),
description: z.string().optional(),
order: z.number().int().min(0).optional(),
dueBy: z.string().optional(),
questions: z.array(ZCourseImportDraftExerciseQuestion).optional()
});
export type TCourseImportDraftExercise = z.infer<typeof ZCourseImportDraftExercise>;
export const ZCourseImportDraftPayload = z
.object({
course: ZCourseImportDraftCourse,
@@ -61,7 +91,7 @@ export const ZCourseImportDraftPayload = z
sections: z.array(ZCourseImportDraftSection).min(1),
lessons: z.array(ZCourseImportDraftLesson).min(1),
lessonLanguages: z.array(ZCourseImportDraftLessonLanguage).min(1),
exercises: z.array(z.record(z.string(), z.unknown())).optional(),
exercises: z.array(ZCourseImportDraftExercise).optional(),
sourceReferences: z.array(ZCourseImportSourceReference).optional(),
warnings: z.array(ZCourseImportWarning).default([])
})
@@ -120,6 +150,34 @@ export const ZCourseImportDraftPayload = z
});
}
});
const exerciseIds = new Set<string>();
value.exercises?.forEach((exercise, index) => {
if (exerciseIds.has(exercise.externalId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['exercises', index, 'externalId'],
message: 'Exercise externalId must be unique'
});
}
exerciseIds.add(exercise.externalId);
if (exercise.lessonExternalId && !lessonIds.has(exercise.lessonExternalId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['exercises', index, 'lessonExternalId'],
message: 'Exercise lessonExternalId must reference an existing lesson'
});
}
if (exercise.sectionExternalId && !sectionIds.has(exercise.sectionExternalId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['exercises', index, 'sectionExternalId'],
message: 'Exercise sectionExternalId must reference an existing section'
});
}
});
});
export type TCourseImportDraftPayload = z.infer<typeof ZCourseImportDraftPayload>;
@@ -2,6 +2,24 @@ import * as z from 'zod';
import { QUESTION_TYPE } from '../constants';
const EXERCISE_QUESTION_TYPE_ID_LITERALS = [
z.literal(QUESTION_TYPE.RADIO),
z.literal(QUESTION_TYPE.CHECKBOX),
z.literal(QUESTION_TYPE.TEXTAREA),
z.literal(QUESTION_TYPE.TRUE_FALSE),
z.literal(QUESTION_TYPE.SHORT_ANSWER),
z.literal(QUESTION_TYPE.NUMERIC),
z.literal(QUESTION_TYPE.FILL_BLANK),
z.literal(QUESTION_TYPE.FILE_UPLOAD),
z.literal(QUESTION_TYPE.MATCHING),
z.literal(QUESTION_TYPE.ORDERING),
z.literal(QUESTION_TYPE.HOTSPOT),
z.literal(QUESTION_TYPE.LINK)
] as const;
export const ZExerciseQuestionTypeId = z.union(EXERCISE_QUESTION_TYPE_ID_LITERALS);
export type TExerciseQuestionTypeId = z.infer<typeof ZExerciseQuestionTypeId>;
const QUESTION_VALIDATION_RULES: Record<
number,
Array<(question: { options?: Array<{ isCorrect: boolean }> }) => string | null>
@@ -61,7 +79,7 @@ const QUESTION_VALIDATION_RULES: Record<
const ZExerciseUpdateQuestionBase = z.object({
id: z.number().int().optional(),
question: z.string().min(1),
questionTypeId: z.number().int().min(1).optional(), // Added to support question type updates
questionTypeId: ZExerciseQuestionTypeId.optional(),
points: z.number().int().min(0).optional(),
order: z.number().int().min(0).optional(),
settings: z.record(z.string(), z.unknown()).optional(),
@@ -98,6 +116,25 @@ function validateQuestionOptions(question: z.infer<typeof ZExerciseUpdateQuestio
// Final schema with validation refinement
const ZExerciseUpdateQuestion = ZExerciseUpdateQuestionBase.superRefine(validateQuestionOptions);
const ZExerciseCreateQuestionBase = z.object({
question: z.string().min(1),
questionTypeId: ZExerciseQuestionTypeId.optional(),
points: z.number().int().min(0).optional(),
order: z.number().int().min(0).optional(),
settings: z.record(z.string(), z.unknown()).optional(),
options: z
.array(
z.object({
label: z.string().min(1),
isCorrect: z.boolean(),
settings: z.record(z.string(), z.unknown()).optional()
})
)
.optional()
});
const ZExerciseCreateQuestion = ZExerciseCreateQuestionBase.superRefine(validateQuestionOptions);
export const ZExerciseCreate = z.object({
title: z.string().min(1),
description: z.string().optional(),
@@ -106,25 +143,7 @@ export const ZExerciseCreate = z.object({
order: z.number().int().optional(),
courseId: z.string().min(1),
dueBy: z.string().optional(),
questions: z
.array(
z.object({
question: z.string().min(1),
questionTypeId: z.number().int().min(1).optional(),
points: z.number().int().min(0).optional(),
settings: z.record(z.string(), z.unknown()).optional(),
options: z
.array(
z.object({
label: z.string().min(1),
isCorrect: z.boolean(),
settings: z.record(z.string(), z.unknown()).optional()
})
)
.min(2)
})
)
.optional()
questions: z.array(ZExerciseCreateQuestion).optional()
});
export type TExerciseCreate = z.infer<typeof ZExerciseCreate>;