Source

services/investigation/versioning-helpers.js

import { getLogger } from "@utils/asyncLocalStorage";
/**
 * Recursively sets all `aiGeneratedValue` fields to `null`.
 *
 * This method is schema-agnostic and works regardless of changes to the investigation structure.
 * @category Services
 * @param {Record<string, unknown>} obj - The object whose `aiGeneratedValue` fields should be cleared.
 */
export function removeAiGeneratedValues(obj) {
    if (!obj || typeof obj !== "object") {
        return;
    }
    // Set aiGeneratedValue to null if it exists
    if ("aiGeneratedValue" in obj) {
        obj.aiGeneratedValue = null;
    }
    // Recursively process all properties
    for (const key in obj) {
        if (obj.hasOwnProperty(key)) {
            const value = obj[key];
            // If it's an object, recurse into it
            if (value && typeof value === "object" && !Array.isArray(value)) {
                removeAiGeneratedValues(value);
            }
            // If it's an array, process each element
            else if (Array.isArray(value)) {
                value.forEach((item) => {
                    if (item && typeof item === "object") {
                        removeAiGeneratedValues(item);
                    }
                });
            }
        }
    }
}
/**
 * Restores an investigation from a snapshot
 * @param {IInvestigation} investigation - The investigation document to restore
 * @param {IInvestigationSnapshot} snapshot - The snapshot data to apply
 * @returns {IInvestigation} - The investigation with snapshot data applied
 */
export function restoreInvestigationFromSnapshot(investigation, snapshot) {
    // Apply snapshot data directly to the investigation document
    // This preserves the Mongoose document structure
    const investigationRecord = investigation;
    const snapshotRecord = snapshot;
    const excludedKeys = new Set([
        "_id",
        "id",
        "__v",
        "versions",
        "currentVersionIndex",
        "createdAt",
        "updatedAt",
    ]);
    const isMongooseDoc = investigation &&
        typeof investigation.set === "function" &&
        typeof investigation.toObject === "function";
    const mongooseDoc = isMongooseDoc
        ? investigation
        : null;
    const investigationKeys = mongooseDoc
        ? Object.keys(mongooseDoc.toObject())
        : Object.keys(investigationRecord);
    const updateData = {};
    Object.keys(snapshotRecord).forEach((key) => {
        if (!excludedKeys.has(key)) {
            updateData[key] = snapshotRecord[key];
        }
    });
    investigationKeys.forEach((key) => {
        if (!excludedKeys.has(key) && !(key in snapshotRecord) && !(key in updateData)) {
            updateData[key] = null;
        }
    });
    Object.keys(updateData).forEach((key) => {
        if (mongooseDoc) {
            try {
                mongooseDoc.set(key, updateData[key]);
            }
            catch (error) {
                const logger = getLogger();
                logger.warn({ error, key, value: updateData[key] }, "Failed to set field during snapshot restore");
            }
        }
        else {
            investigationRecord[key] = updateData[key];
        }
    });
    return investigation;
}
function trackFieldHistory(oldField, newField, fieldPath, updatedBy, historyUpdates) {
    if (oldField && typeof oldField === "object" && "value" in oldField) {
        const oldValue = oldField.value;
        let newValue = null;
        if (newField && typeof newField === "object" && "value" in newField) {
            newValue = newField.value ?? null;
        }
        else if (newField !== undefined) {
            newValue = newField;
        }
        if (oldValue !== newValue && oldValue !== undefined && oldValue !== null) {
            historyUpdates[fieldPath] = {
                historyEntry: {
                    value: oldValue,
                    updatedBy,
                    updatedAt: new Date(),
                },
                fieldPath,
            };
        }
    }
}
/**
 * Computes history updates for an investigation update payload.
 * @param {IInvestigation} investigation - Current investigation document (used as the source of "old" values).
 * @param {MongoUpdateFields} updateFields - Flat update payload (typically produced by the builder) to compare against current values.
 * @param {(Types.ObjectId | null)} updatedBy - User id who made the change (null when not available).
 * @returns {Record<string, { historyEntry: *, fieldPath: string }>} History entries keyed by base field path.
 */
export function saveFieldHistory(investigation, updateFields, updatedBy) {
    const logger = getLogger();
    const historyUpdates = {};
    for (const [fieldPath, newValue] of Object.entries(updateFields)) {
        if (fieldPath === "metadata.dateModified") {
            continue;
        }
        if (fieldPath === "objects") {
            const oldObjects = investigation.objects || [];
            const newObjects = Array.isArray(newValue) ? newValue : [];
            for (let i = 0; i < Math.max(oldObjects.length, newObjects.length); i++) {
                const oldObj = oldObjects[i];
                const newObj = newObjects[i] || null;
                if (oldObj) {
                    const simpleFields = ["name", "objectId", "size"];
                    for (const fieldName of simpleFields) {
                        const oldField = oldObj[fieldName];
                        const newField = newObj && typeof newObj === "object" && fieldName in newObj
                            ? newObj[fieldName]
                            : null;
                        trackFieldHistory(oldField, newField, `objects.${i}.${fieldName}`, updatedBy, historyUpdates);
                    }
                    if (oldObj.position) {
                        const newPosition = newObj && typeof newObj === "object" && "position" in newObj
                            ? newObj.position
                            : null;
                        const positionFields = ["x", "y", "z"];
                        for (const coord of positionFields) {
                            const oldCoordField = oldObj.position[coord];
                            const newCoordField = newPosition?.[coord];
                            trackFieldHistory(oldCoordField, newCoordField, `objects.${i}.position.${coord}`, updatedBy, historyUpdates);
                        }
                    }
                    if (oldObj.rotation) {
                        const newRotation = newObj && typeof newObj === "object" && "rotation" in newObj
                            ? newObj.rotation
                            : null;
                        const rotationFields = ["x", "y", "z"];
                        for (const coord of rotationFields) {
                            const oldCoordField = oldObj.rotation[coord];
                            const newCoordField = newRotation?.[coord];
                            trackFieldHistory(oldCoordField, newCoordField, `objects.${i}.rotation.${coord}`, updatedBy, historyUpdates);
                        }
                    }
                }
            }
            logger.debug({ investigationId: investigation._id, historyCount: Object.keys(historyUpdates).length }, "Objects array update: tracked history for individual object fields");
            continue;
        }
        if (!fieldPath.includes(".value")) {
            continue;
        }
        const baseFieldPath = fieldPath.replace(".value", "");
        let currentValue = null;
        let currentField = null;
        const pathParts = baseFieldPath.split(".");
        let current = investigation;
        try {
            for (const part of pathParts) {
                if (Array.isArray(current) && /^\d+$/.test(part)) {
                    current = current[parseInt(part, 10)];
                }
                else if (current && typeof current === "object" && current !== null && part in current) {
                    current = current[part];
                }
                else {
                    current = null;
                    break;
                }
            }
            currentField = current;
            if (currentField &&
                typeof currentField === "object" &&
                currentField !== null &&
                "value" in currentField) {
                currentValue = currentField.value;
            }
        }
        catch (error) {
            logger.warn({ error, fieldPath }, "Failed to get current value for history");
            continue;
        }
        if (currentValue !== newValue && currentValue !== undefined) {
            historyUpdates[baseFieldPath] = {
                historyEntry: {
                    value: currentValue,
                    updatedBy,
                    updatedAt: new Date(),
                },
                fieldPath: baseFieldPath,
            };
        }
    }
    return historyUpdates;
}
/**
 * Builds a plain JSON snapshot of the investigation state for versioning.
 * @param {IInvestigation} investigation - Investigation document to snapshot.
 * @returns {Record<string, unknown>} Snapshot of the investigation without versioning metadata.
 */
export function buildInvestigationSnapshot(investigation) {
    // Create a deep copy of the investigation state to ensure it doesn't get mutated
    // This captures the OLD state before any updates are applied
    const snapshot = JSON.parse(JSON.stringify(investigation.toObject({
        depopulate: true,
        minimize: false,
        getters: false,
        virtuals: false,
        versionKey: false,
    })));
    // Remove versioning metadata - these are not part of the investigation state snapshot
    delete snapshot._id;
    delete snapshot.id;
    delete snapshot.__v;
    delete snapshot.versions;
    delete snapshot.currentVersionIndex;
    delete snapshot.lastChangeWithAI;
    return snapshot;
}
/**
 * Creates a new version entry from the current investigation state.
 * @param {IInvestigation} investigation - Investigation document to version.
 * @param {(Types.ObjectId | null)} updatedBy - User id who made the change (null when not available).
 * @param {boolean} [isAiGenerated] - Whether the versioned state was AI-generated (defaults to false).
 * @returns {IInvestigationVersionEntryModel} Version entry containing snapshot and metadata.
 */
export function createVersionEntry(investigation, updatedBy, isAiGenerated = false) {
    const snapshot = buildInvestigationSnapshot(investigation);
    const versions = Array.isArray(investigation.versions) ? investigation.versions : [];
    // Get the latest version number (newest version is at the end of the array)
    const latestVersionNumber = versions.length > 0 ? (versions[versions.length - 1]?.versionNumber ?? versions.length) : 0;
    return {
        versionNumber: latestVersionNumber + 1,
        snapshot,
        updatedBy,
        updatedAt: new Date(),
        isAiGenerated,
    };
}
/**
 * Removes document metadata from a snapshot payload before restore.
 * @param {Record<string, unknown>} snapshot - Snapshot data as stored in versions.
 * @returns {Record<string, unknown>} Snapshot payload without Mongo/Mongoose metadata fields.
 */
export function prepareSnapshotForRestore(snapshot) {
    const payload = { ...snapshot };
    delete payload._id;
    delete payload.id;
    delete payload.__v;
    delete payload.versions;
    return payload;
}