Source

schemas/investigation.validation.js

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"),
});