PhiloMind / src /courses /validators /node-schema.validator.ts
github-actions[bot]
Deploy Backend from GitHub Actions Commit: 5b9f23e2605d9daaaf1a4543a92b259c445aca14
497adc2
Raw
History Blame Contribute Delete
10.1 kB
import { BadRequestException } from "@nestjs/common";
const SUPPORTED_COMPONENT_TYPES = new Set([
"component_group",
"dialogue",
"media",
"markdown",
"target_matching",
"category_sorting",
"mindmap_reveal",
"mcq",
"quiz_sequence",
"multi_select",
"matching_columns",
"true_false",
"sequence_sorting",
"chain_sorting",
"knowledge_piece",
"map_target_matching",
"progression_spiral",
"timeline_explorer",
"hotspot_gallery",
"shinkei_matching",
"final_summary",
"fill_in_blanks",
]);
function requireArray(value: any, label: string) {
if (!Array.isArray(value)) {
throw new BadRequestException(`${label} must be an array`);
}
}
function requireString(value: any, label: string) {
if (typeof value !== "string" || value.trim() === "") {
throw new BadRequestException(`${label} must be a non-empty string`);
}
}
function requireObject(value: any, label: string) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new BadRequestException(`${label} must be a JSON object`);
}
}
export class NodeSchemaValidator {
static validateLessonFlow(data: any) {
requireArray(data, "lessonFlow");
const ids = new Set<string>();
data.forEach((component: any, idx: number) => {
this.validateComponent(component, `lessonFlow[${idx}]`, ids);
});
}
private static validateComponent(
component: any,
path: string,
ids: Set<string>,
isChild = false,
) {
requireObject(component, path);
requireString(component.id, `${path}.id`);
requireString(component.type, `${path}.type`);
if (ids.has(component.id)) {
throw new BadRequestException(`${path}.id duplicates "${component.id}"`);
}
ids.add(component.id);
if (!SUPPORTED_COMPONENT_TYPES.has(component.type)) {
throw new BadRequestException(
`${path}.type "${component.type}" is not supported`,
);
}
if (isChild && component.type === "component_group") {
throw new BadRequestException(`${path}.type cannot be component_group`);
}
requireObject(component.config, `${path}.config`);
this.validateComponentConfig(component, path, ids);
if (component.completionRule) {
requireObject(component.completionRule, `${path}.completionRule`);
requireString(
component.completionRule.type,
`${path}.completionRule.type`,
);
}
}
private static validateComponentConfig(
component: any,
path: string,
ids: Set<string>,
) {
const config = component.config;
const label = `${path}.config`;
if (component.type === "component_group") {
requireArray(config.components, `${label}.components`);
if (config.components.length === 0) {
throw new BadRequestException(
`${label}.components must contain at least one child component`,
);
}
config.components.forEach((child: any, childIdx: number) => {
this.validateComponent(
child,
`${label}.components[${childIdx}]`,
ids,
true,
);
});
}
if (component.type === "dialogue") {
requireArray(config.lines, `${label}.lines`);
config.lines.forEach((line: any, lineIdx: number) => {
requireString(line.text, `${label}.lines[${lineIdx}].text`);
});
}
if (component.type === "media") {
requireString(config.url, `${label}.url`);
}
if (component.type === "markdown") {
requireString(config.content, `${label}.content`);
}
if (
component.type === "target_matching" ||
component.type === "map_target_matching"
) {
requireArray(config.targets, `${label}.targets`);
requireArray(config.items, `${label}.items`);
}
if (component.type === "category_sorting") {
requireArray(config.categories, `${label}.categories`);
requireArray(config.cards, `${label}.cards`);
}
if (component.type === "mcq") {
requireString(config.question, `${label}.question`);
requireArray(config.options, `${label}.options`);
if (
!config.options.some(
(option: any) => option.isCorrect === true || option.correct === true,
)
) {
throw new BadRequestException(
`${label}.options must contain at least one correct option`,
);
}
}
if (component.type === "quiz_sequence") {
requireArray(config.questions, `${label}.questions`);
config.questions.forEach((question: any, questionIdx: number) => {
requireString(
question.question || question.prompt,
`${label}.questions[${questionIdx}].question`,
);
requireArray(
question.options,
`${label}.questions[${questionIdx}].options`,
);
if (
typeof question.correctIndex !== "number" &&
!question.options.some(
(option: any) =>
option?.isCorrect === true || option?.correct === true,
)
) {
throw new BadRequestException(
`${label}.questions[${questionIdx}] must define correctIndex or a correct option`,
);
}
});
}
if (component.type === "multi_select") {
requireString(config.question, `${label}.question`);
requireArray(config.options, `${label}.options`);
if (
!config.options.some(
(option: any) => option.isCorrect === true || option.correct === true,
)
) {
throw new BadRequestException(
`${label}.options must contain at least one correct option`,
);
}
}
if (component.type === "matching_columns") {
requireArray(config.leftColumn, `${label}.leftColumn`);
requireArray(config.rightColumn, `${label}.rightColumn`);
requireArray(config.correctPairs, `${label}.correctPairs`);
}
if (component.type === "shinkei_matching") {
requireArray(config.pairs, `${label}.pairs`);
if (config.pairs.length === 0) {
throw new BadRequestException(
`${label}.pairs must contain at least one pair`,
);
}
}
if (component.type === "true_false") {
requireString(config.statement, `${label}.statement`);
if (typeof config.correctAnswer !== "boolean") {
throw new BadRequestException(
`${label}.correctAnswer must be a boolean`,
);
}
}
if (
component.type === "sequence_sorting" ||
component.type === "chain_sorting"
) {
requireArray(config.items, `${label}.items`);
}
if (component.type === "knowledge_piece") {
requireString(config.label || component.title, `${label}.label`);
if (config.takeaways !== undefined) {
requireArray(config.takeaways, `${label}.takeaways`);
}
}
if (component.type === "progression_spiral") {
requireArray(config.milestones, `${label}.milestones`);
}
if (component.type === "timeline_explorer") {
requireArray(config.periods, `${label}.periods`);
}
if (component.type === "hotspot_gallery") {
requireArray(config.items, `${label}.items`);
}
if (component.type === "final_summary") {
if (config.keyTakeaways !== undefined) {
requireArray(config.keyTakeaways, `${label}.keyTakeaways`);
}
}
if (component.type === "fill_in_blanks") {
requireString(config.textWithBlanks, `${label}.textWithBlanks`);
requireArray(config.blanks, `${label}.blanks`);
config.blanks.forEach((blank: any, blankIdx: number) => {
requireString(blank.id, `${label}.blanks[${blankIdx}].id`);
requireString(
blank.correctAnswer,
`${label}.blanks[${blankIdx}].correctAnswer`,
);
});
}
if (component.type === "mindmap_reveal") {
requireArray(config.nodes, `${label}.nodes`);
// Validate each node supports both legacy format and new front/back format
config.nodes.forEach((node: any, nodeIdx: number) => {
if (!node.id) {
throw new BadRequestException(
`${label}.nodes[${nodeIdx}].id is required`,
);
}
// Must have either legacy label/detail or new front/back
const hasLegacy = node.label !== undefined || node.detail !== undefined;
const hasNewFormat =
node.front !== undefined || node.back !== undefined;
if (!hasLegacy && !hasNewFormat) {
throw new BadRequestException(
`${label}.nodes[${nodeIdx}] must have either label/detail or front/back fields`,
);
}
});
// Validate optional layout config
if (config.layoutConfig) {
const validTypes = ["vertical", "horizontal", "matrix", "custom"];
if (!validTypes.includes(config.layoutConfig.type)) {
throw new BadRequestException(
`${label}.layoutConfig.type must be one of: ${validTypes.join(", ")}`,
);
}
}
}
}
/**
* Validate the lessonMedia array (center column media items).
* Each item must have id, type, and url.
*/
static validateLessonMedia(data: any) {
if (data === null || data === undefined) return; // Optional field
requireArray(data, "lessonMedia");
const ids = new Set<string>();
data.forEach((item: any, idx: number) => {
requireObject(item, `lessonMedia[${idx}]`);
requireString(item.id, `lessonMedia[${idx}].id`);
requireString(item.type, `lessonMedia[${idx}].type`);
requireString(item.url, `lessonMedia[${idx}].url`);
if (ids.has(item.id)) {
throw new BadRequestException(
`lessonMedia[${idx}].id duplicates "${item.id}"`,
);
}
ids.add(item.id);
const validTypes = ["video", "image"];
if (!validTypes.includes(item.type)) {
throw new BadRequestException(
`lessonMedia[${idx}].type must be one of: ${validTypes.join(", ")}`,
);
}
});
}
static validateNode(lessonFlow: any) {
this.validateLessonFlow(lessonFlow);
}
}