import mongoose from "mongoose";
import { z } from "zod";
import { FIELD_HISTORY_LIMIT_MAX, FIELD_HISTORY_LIMIT_MIN, GRADE_MAX, GRADE_MIN, INVESTIGATION_VERSIONS_LIMIT_MAX, INVESTIGATION_VERSIONS_LIMIT_MIN, INVESTIGATIONS_LIST_LIMIT_MAX, INVESTIGATIONS_LIST_LIMIT_MIN, } from "../types/investigation/constants";
import { InvestigationGroupType, InvestigationSectionType, InvestigationSortBy, InvestigationStatus, SortOrder, } from "../types/investigation/enums";
/**
* Zod validation schemas for investigation endpoints
*/
// Vector3 validation schema
/**
* Vector3 validation schema
* @category Schemas
* @returns {z.ZodObject<{ x: z.ZodNumber, y: z.ZodNumber, z: z.ZodNumber }>} The Vector3 schema
*/
const Vector3Schema = z.object({
x: z.number(),
y: z.number(),
z: z.number(),
});
// Interaction validation schema
const InteractionSchema = z.object({
name: z.string().min(1).max(100),
});
// ViewComponent validation schema
const ViewComponentSchema = z.object({
mesh: z.string().min(1),
material: z.string().min(1),
texture: z.string().min(1),
interactions: z.array(InteractionSchema),
});
// RigidBodyComponent validation schema
const RigidBodyComponentSchema = z.object({
mass: z.number().min(0),
velocity: Vector3Schema,
angularVelocity: Vector3Schema,
drag: z.number().min(0),
angularDrag: z.number().min(0),
useGravity: z.boolean(),
isKinematic: z.boolean(),
interactions: z.array(InteractionSchema),
});
// FluidRigidBodyComponent validation schema
const FluidRigidBodyComponentSchema = z.object({
density: z.number().min(0),
viscosity: z.number().min(0),
surfaceTension: z.number().min(0),
particleSize: z.number().min(0),
simulationMethod: z.enum(["SPH", "Grid"]),
maxParticles: z.number().min(1),
interactions: z.array(InteractionSchema),
});
// CollisionMeshComponent validation schema
const CollisionMeshComponentSchema = z.object({
type: z.enum(["Box", "Sphere", "Capsule", "Mesh", "Convex"]),
size: Vector3Schema,
isTrigger: z.boolean(),
material: z.string().min(1),
interactions: z.array(InteractionSchema),
});
// TemperatureComponent validation schema
const TemperatureComponentSchema = z.object({
value: z.number(),
unit: z.enum(["C", "F", "K"]),
conductivity: z.number().min(0),
specificHeat: z.number().min(0),
interactions: z.array(InteractionSchema),
});
// StateOfMatterComponent validation schema
const StateOfMatterComponentSchema = z.object({
currentState: z.enum(["Solid", "Liquid", "Gas", "Plasma"]),
meltingPoint: z.number(),
boilingPoint: z.number(),
sublimationPoint: z.number().optional(),
stateChangeObjects: z.object({
solid: z.string().optional(),
liquid: z.string().optional(),
gas: z.string().optional(),
plasma: z.string().optional(),
}),
interactions: z.array(InteractionSchema),
});
// ShatterComponent validation schema
const ShatterComponentSchema = z.object({
health: z.number().min(0),
maxHealth: z.number().min(0),
shatterThreshold: z.number().min(0),
shatterEffect: z.enum(["Visual", "Vaporize", "Fragment"]),
fragmentCount: z.number().min(0).optional(),
fragmentObject: z.string().optional(),
interactions: z.array(InteractionSchema),
});
// HardAttachComponent validation schema
const HardAttachComponentSchema = z.object({
attachedObjects: z.array(z.string()),
attachmentStrength: z.number().min(0),
canDetach: z.boolean(),
detachForce: z.number().min(0),
interactions: z.array(InteractionSchema),
});
// FillComponent validation schema
const FillComponentSchema = z.object({
maxVolume: z.number().min(0),
currentVolume: z.number().min(0),
currentLiquid: z.string().optional(),
fillRate: z.number().min(0),
drainRate: z.number().min(0),
hasOverflow: z.boolean(),
interactions: z.array(InteractionSchema),
});
// Object validation schema
export const ObjectSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(200),
position: Vector3Schema,
rotation: Vector3Schema,
size: z.number().min(0),
view: ViewComponentSchema,
rigidBody: RigidBodyComponentSchema.optional(),
fluidRigidBody: FluidRigidBodyComponentSchema.optional(),
collisionMesh: CollisionMeshComponentSchema.optional(),
temperature: TemperatureComponentSchema.optional(),
stateOfMatter: StateOfMatterComponentSchema.optional(),
shatter: ShatterComponentSchema.optional(),
hardAttach: HardAttachComponentSchema.optional(),
fill: FillComponentSchema.optional(),
});
// Editable field payload schema - supports both simple value and {value, aiGeneratedValue} format
const EditableFieldStringSchema = z.union([
z.string(),
z.object({
value: z.string(),
aiGeneratedValue: z.string().nullable().optional(),
}),
]);
const EditableFieldBooleanSchema = z.union([
z.boolean(),
z.object({
value: z.boolean(),
aiGeneratedValue: z.boolean().nullable().optional(),
}),
]);
// Simple Step validation schema (for API input)
// Supports both simple values and {value, aiGeneratedValue} format
export const SimpleStepSchema = z.object({
title: EditableFieldStringSchema.nullable().optional(),
desiredOutcome: EditableFieldStringSchema.nullable().optional(),
alternativeOutcome: EditableFieldStringSchema.nullable().optional(),
name: z.string().max(200).optional(), // Legacy field, kept for backward compatibility
descriptionEn: EditableFieldStringSchema.nullable().optional(),
skippable: EditableFieldBooleanSchema.nullable().optional(),
skippable_after_marking: EditableFieldBooleanSchema.nullable().optional(),
});
// Step validation schema (for database storage with EditableField)
// export const StepSchema = z.object({
// title: z.string().min(1).optional(),
// desiredOutcome: z.string().min(1).optional(),
// alternativeOutcome: z.string().min(1).optional(),
// name: z.string().min(1).max(200).optional(),
// descriptionEn: z.string().min(1).max(2000).optional(),
// skippable: EditableFieldSchema.optional(),
// skippable_after_marking: EditableFieldSchema.optional(),
// });
const createVectorSchema = z.object({
x: z.number().optional(),
y: z.number().optional(),
z: z.number().optional(),
});
const CreateObjectSchema = z.object({
name: z.string().min(0),
objectId: z.string(),
position: createVectorSchema.optional(),
rotation: createVectorSchema.optional(),
size: z.number().optional(),
photo: z.string().optional(),
});
// Update Object schema - supports {value, aiGeneratedValue} format
const UpdateObjectFieldSchema = z
.union([
z.string(),
z.object({
value: z.string(),
aiGeneratedValue: z.string().nullable().optional(),
}),
])
.nullable()
.optional();
const UpdateObjectSchema = z.object({
name: UpdateObjectFieldSchema,
objectId: UpdateObjectFieldSchema,
position: createVectorSchema.optional(),
rotation: createVectorSchema.optional(),
size: z.number().optional(),
photo: UpdateObjectFieldSchema,
});
// API Input validation schema (simple values for creating investigation)
export const CreateInvestigationSchema = z.object({
// Basic fields (all optional as requested)
curriculum: z.string().optional(),
grade: z.string().optional(),
title: z.string().optional(),
unitNumberAndTitle: z.string().optional(),
lessonNumberAndTitle: z.string().optional(),
ngss: z.string().optional(),
day: z.string().optional(),
objectives: z.string().optional(),
// Additional content fields
analyticalFacts: z.string().optional(),
goals: z.string().optional(),
// Complex nested fields
steps: z.array(SimpleStepSchema).optional(),
objects: z.array(CreateObjectSchema).optional(),
});
/**
* Helper function to create a range validation refine
* Validates that a "from" field is less than or equal to a "to" field
* @template {Record<string, unknown>} T
* @param {string} fromField - Name of the "from" field
* @param {string} toField - Name of the "to" field
* @returns {Function} Zod refine function that validates the range
*/
const createRangeValidation = (fromField, toField) => {
return (data) => {
const fromValue = data[fromField];
const toValue = data[toField];
// If both values exist, validate that from <= to
if (fromValue !== undefined &&
fromValue !== null &&
toValue !== undefined &&
toValue !== null) {
// Works for both numbers and dates (which have valueOf())
return fromValue <= toValue;
}
return true;
};
};
// Query parameters validation schema for GET investigations
export const GetInvestigationsQuerySchema = z
.object({
section: z.nativeEnum(InvestigationSectionType).optional(),
group: z.nativeEnum(InvestigationGroupType).optional(),
sortBy: z.nativeEnum(InvestigationSortBy).optional(),
sortOrder: z.nativeEnum(SortOrder).optional(),
search: z.string().min(1).optional(),
limit: z.coerce
.number()
.int()
.min(INVESTIGATIONS_LIST_LIMIT_MIN)
.max(INVESTIGATIONS_LIST_LIMIT_MAX)
.optional(),
offset: z.coerce.number().int().min(0).optional(),
status: z
.string()
.refine((val) => {
const statuses = val.split(",").map((s) => s.trim());
const validStatuses = Object.values(InvestigationStatus);
return statuses.every((s) => validStatuses.includes(s));
}, {
message: `Invalid status value. Must be one of: ${Object.values(InvestigationStatus).join(", ")}`,
})
.optional(),
curriculum: z.string().optional(),
gradeFrom: z.coerce.number().int().min(GRADE_MIN).max(GRADE_MAX).optional(),
gradeTo: z.coerce.number().int().min(GRADE_MIN).max(GRADE_MAX).optional(),
dateCreatedFrom: z.coerce.date().optional(),
dateCreatedTo: z.coerce.date().optional(),
datePublishedFrom: z.coerce.date().optional(),
datePublishedTo: z.coerce.date().optional(),
sentToDevelopmentFrom: z.coerce.date().optional(),
sentToDevelopmentTo: z.coerce.date().optional(),
editors: z.string().optional(),
authors: z.string().optional(),
})
.refine(createRangeValidation("gradeFrom", "gradeTo"), {
message: "gradeFrom must be less than or equal to gradeTo",
path: ["gradeFrom"],
})
.refine(createRangeValidation("dateCreatedFrom", "dateCreatedTo"), {
message: "dateCreatedFrom must be less than or equal to dateCreatedTo",
path: ["dateCreatedFrom"],
})
.refine(createRangeValidation("datePublishedFrom", "datePublishedTo"), {
message: "datePublishedFrom must be less than or equal to datePublishedTo",
path: ["datePublishedFrom"],
})
.refine(createRangeValidation("sentToDevelopmentFrom", "sentToDevelopmentTo"), {
message: "sentToDevelopmentFrom must be less than or equal to sentToDevelopmentTo",
path: ["sentToDevelopmentFrom"],
});
const ObjectIdParamSchema = z.string().refine((val) => mongoose.Types.ObjectId.isValid(val), {
message: "Invalid MongoDB ObjectId format",
});
// Query parameters validation schema for GET single investigation
export const GetInvestigationParamsSchema = z.object({
id: ObjectIdParamSchema,
});
// Params validation schema for DELETE single investigation
export const DeleteInvestigationParamsSchema = z.object({
id: ObjectIdParamSchema,
});
// Update investigation params validation schema with MongoDB ObjectId validation
export const UpdateInvestigationParamsSchema = z.object({
id: z
.string()
.min(1)
.regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ObjectId format"),
});
// Step update validation schema for individual step field updates
// All fields are required when steps array is provided
export const StepUpdateSchema = z.object({
index: z.number().int().min(0),
key: z.string().min(1),
value: z.union([z.string(), z.number(), z.boolean(), z.null()]), // Properly typed union
});
// Object update validation schema
export const ObjectUpdateSchema = {
isContradicting: z.boolean(),
targetFieldName: z.string().nullable(),
contradictionReason: z.string().nullable(),
value: z.array(z.object({
name: z.string(),
position: Vector3Schema,
rotation: Vector3Schema,
size: z.number(),
view: z.object({
mesh: z.string(),
material: z.string(),
texture: z.string(),
}),
})),
};
// New update investigation validation schema for the new API format
export const UpdateInvestigationSchema = z.object({
// String fields (curriculum, gradeAndUnit, title, correspondence, objectives, discussionTopics, analyticalFacts, introAndGoals, references)
curriculum: z.string().optional(),
title: z.string().optional(),
objectives: z.string().optional(),
analyticalFacts: z.string().optional(),
goals: z.string().optional(),
grade: z.string().optional(),
unitNumberAndTitle: z.string().optional(),
lessonNumberAndTitle: z.string().optional(),
ngss: z.string().optional(),
day: z.string().optional(),
// Step updates (array of step objects where each field can be simple value or {value, aiGeneratedValue})
steps: z.array(SimpleStepSchema).optional(),
objects: z.array(UpdateObjectSchema).optional(),
});
// Get field history params validation schema
export const GetFieldHistoryParamsSchema = z.object({
id: ObjectIdParamSchema,
fieldPath: z.string().min(1),
});
// Get field history query validation schema
export const GetFieldHistoryQuerySchema = z.object({
limit: z.coerce
.number()
.int()
.min(FIELD_HISTORY_LIMIT_MIN)
.max(FIELD_HISTORY_LIMIT_MAX)
.optional(),
});
export const GetInvestigationVersionsParamsSchema = z.object({
id: ObjectIdParamSchema,
});
export const GetInvestigationVersionsQuerySchema = z.object({
limit: z.coerce
.number()
.int()
.min(INVESTIGATION_VERSIONS_LIMIT_MIN)
.max(INVESTIGATION_VERSIONS_LIMIT_MAX)
.optional(),
});
// Regenerate other fields params validation schema
export const RegenerateOtherFieldsParamsSchema = z.object({
id: ObjectIdParamSchema,
});
// Regenerate other fields body validation schema
export const RegenerateOtherFieldsBodySchema = z.object({
fieldName: z.union([
// Block fields
z.enum([
"title",
"curriculum",
"unitNumberAndTitle",
"grade",
"lessonNumberAndTitle",
"objectives",
"ngss",
"analyticalFacts",
"goals",
"day",
]),
// Step fields (format: steps.{index}.{field})
z.string().regex(/^steps\.\d+\.(title|descriptionEn|desiredOutcome|alternativeOutcome)$/),
// Object fields (format: objects.{index}.name)
z.union([z.literal("name"), z.string().regex(/^objects\.\d+\.name$/)]),
]),
});
// Resolve contradiction params validation schema
export const ResolveContradictionParamsSchema = z.object({
id: ObjectIdParamSchema,
});
// Resolve contradiction body validation schema
export const ResolveContradictionBodySchema = z.object({
fieldName: z.union([
// Block fields
z.enum([
"title",
"curriculum",
"unitNumberAndTitle",
"grade",
"lessonNumberAndTitle",
"objectives",
"ngss",
"analyticalFacts",
"goals",
"day",
]),
// Step fields (format: steps.{index}.{field})
z.string().regex(/^steps\.\d+\.(title|descriptionEn|desiredOutcome|alternativeOutcome)$/),
// Object fields (format: objects.{index}.name)
z.union([z.literal("name"), z.string().regex(/^objects\.\d+\.name$/)]),
]),
});
// Upload object photo params validation schema
export const UploadObjectPhotoParamsSchema = z.object({
id: ObjectIdParamSchema,
objectId: z.string().min(1),
objectIndex: z.string().regex(/^\d+$/, "Object index must be a non-negative integer"),
});
// Upload object photo body validation schema
export const UploadObjectPhotoBodySchema = z.object({
photo: z
.string()
.min(1)
.regex(/^data:image\/(jpeg|jpg|png|gif|webp);base64,/, {
message: "Photo must be a valid base64 image (jpeg, jpg, png, gif, or webp)",
}),
});
// Delete object photo params validation schema
export const DeleteObjectPhotoParamsSchema = z.object({
id: ObjectIdParamSchema,
objectId: z.string().min(1),
objectIndex: z.string().regex(/^\d+$/, "Object index must be a non-negative integer"),
});
Source