Source

services/investigation/versions.js

import { Investigation } from "@models/investigation/investigation.model";
import { InvestigationStatus } from "@typez/investigation";
import { getLogger } from "@utils/asyncLocalStorage";
import { Types } from "mongoose";
import { getInvestigationById } from "./crud";
/**
 * Gets the history for a specific field in an investigation.
 * @category Services
 * @param {string} investigationId - The ID of the investigation.
 * @param {string} fieldPath - The path to the field (e.g., "title", "steps.0.title", "objects.0.name").
 * @param {number} limit - Maximum number of history entries to return (default: 50).
 * @returns {Promise<{fieldPath: string, history: unknown[], totalCount: number}>} The field history.
 * @throws {Error} If the investigation is not found.
 */
export async function getFieldHistory(investigationId, fieldPath, limit = 50) {
    const logger = getLogger();
    try {
        const investigation = await Investigation.findById(investigationId);
        if (!investigation) {
            throw new Error("Investigation not found");
        }
        const pathParts = fieldPath.split(".");
        let field = investigation;
        for (const part of pathParts) {
            if (Array.isArray(field) && /^\d+$/.test(part)) {
                field = field[parseInt(part, 10)];
            }
            else if (field && typeof field === "object" && field !== null && part in field) {
                field = field[part];
            }
            else {
                field = null;
                break;
            }
        }
        if (!field || typeof field !== "object" || field === null || !("history" in field)) {
            return {
                fieldPath,
                history: [],
                totalCount: 0,
            };
        }
        const fieldWithHistory = field;
        const history = Array.isArray(fieldWithHistory.history) ? fieldWithHistory.history : [];
        const limitedHistory = history.slice(0, limit);
        return {
            fieldPath,
            history: limitedHistory,
            totalCount: history.length,
        };
    }
    catch (error) {
        logger.error({ error, investigationId, fieldPath }, "Failed to get field history");
        throw new Error("Failed to get field history");
    }
}
/**
 * Gets the versions of an investigation.
 * @category Services
 * @param {string} investigationId - The ID of the investigation.
 * @param {number} limit - The number of versions to return (default: 20).
 * @returns {Promise<IInvestigationVersionListResponse>} - The investigation versions.
 * @throws {Error} If the investigation is not found.
 */
export async function getInvestigationVersions(investigationId, limit = 20) {
    const logger = getLogger();
    try {
        const investigation = await Investigation.findById(investigationId).select("versions");
        if (!investigation) {
            throw new Error("Investigation not found");
        }
        const versions = Array.isArray(investigation.versions) ? investigation.versions : [];
        // Get the last N versions (newest are at the end of the array)
        const limitedVersions = versions.slice(-limit);
        return {
            investigationId,
            totalCount: versions.length,
            versions: limitedVersions.map((entry) => ({
                versionNumber: entry.versionNumber,
                updatedAt: entry.updatedAt,
                updatedBy: entry.updatedBy ? entry.updatedBy.toString() : null,
                snapshot: entry.snapshot,
                isAiGenerated: entry.isAiGenerated ?? false,
            })),
        };
    }
    catch (error) {
        logger.error({ error, investigationId }, "Failed to get investigation versions");
        throw new Error("Failed to get investigation versions");
    }
}
/**
 * Undo investigation - decrement currentVersionIndex and return investigation at that version
 * @category Services
 * @param {string} investigationId - The ID of the investigation.
 * @returns {Promise<IInvestigation>} - The investigation at the new index.
 * @throws {Error} If the investigation is not found or if the index is out of bounds.
 */
export async function undoInvestigation(investigationId) {
    const logger = getLogger();
    try {
        const investigation = await Investigation.findById(investigationId);
        if (!investigation) {
            throw new Error("Investigation not found");
        }
        if (investigation.status === InvestigationStatus.IN_DEVELOPMENT) {
            throw new Error("Unable to undo investigation, when its status is InDevelopment.");
        }
        const currentIndex = investigation.currentVersionIndex ?? 0;
        const versions = Array.isArray(investigation.versions) ? investigation.versions : [];
        // Versions are stored with oldest first, newest last
        // Index -1 = special empty state (cannot undo from here)
        // Index 0 = can undo to go to -1 (even if no versions exist)
        // Index versions.length = current state (not in versions array)
        // Can undo from current state (index = versions.length) to go to the previous saved version
        // Can undo from index 0 to go to index -1 (empty state) - even if no versions exist
        // Can't undo if already at index -1 (empty state)
        // Undo decrements the index (goes to a lower index number)
        if (currentIndex === -1) {
            throw new Error("Cannot undo: already at the oldest saved version");
        }
        // Decrement index (go to an older saved version or empty state)
        // If at index 2 (current state, versions.length=2), go to index 1 (versions[1] = newest saved version)
        // If at index 1, go to index 0 (versions[0] = oldest saved version)
        // If at index 0, go to index -1 (empty state)
        const newIndex = currentIndex - 1;
        logger.info({ investigationId, currentIndex, newIndex, versionsLength: versions.length }, "Undoing investigation: decrementing currentVersionIndex");
        // Update currentVersionIndex
        await Investigation.findByIdAndUpdate(investigationId, { $set: { currentVersionIndex: newIndex } }, { new: true });
        // Get and return the investigation at the new index
        const restoredInvestigation = await getInvestigationById(investigationId);
        logger.info({
            investigationId,
            newIndex,
            restoredVersionIndex: restoredInvestigation.currentVersionIndex,
        }, "Undo complete: investigation retrieved");
        return restoredInvestigation;
    }
    catch (error) {
        logger.error({ error, investigationId }, "Failed to undo investigation");
        throw error;
    }
}
/**
 * Redo investigation - increment currentVersionIndex and return investigation at that version
 * @category Services
 * @param {string} investigationId - The ID of the investigation.
 * @returns {Promise<IInvestigation>} - The investigation at the new index.
 * @throws {Error} If the investigation is not found or if the index is out of bounds.
 */
export async function redoInvestigation(investigationId) {
    const logger = getLogger();
    try {
        const investigation = await Investigation.findById(investigationId);
        if (!investigation) {
            throw new Error("Investigation not found");
        }
        if (investigation.status === InvestigationStatus.IN_DEVELOPMENT) {
            throw new Error("Unable to redo investigation, when its status is InDevelopment.");
        }
        const currentIndex = investigation.currentVersionIndex ?? 0;
        const versions = Array.isArray(investigation.versions) ? investigation.versions : [];
        // Versions are stored with oldest first, newest last
        // Index -1 = special empty state - can redo to go to index 0 (even if no versions exist)
        // Index 0 = versions[0] (oldest saved version)
        // Index versions.length = current state (not in versions array)
        // Can't redo if already at index versions.length (current state, newest version)
        // Redo increments the index (goes to a higher index number, towards current state)
        const maxIndex = versions.length;
        // Allow redo from -1 to 0 even if no versions exist
        // Otherwise, require versions to exist and not be at maxIndex
        if (currentIndex === -1) {
            // Can redo from -1 to 0, proceed
        }
        else if (currentIndex >= maxIndex || versions.length === 0) {
            throw new Error("Cannot redo: already at the current state (newest version)");
        }
        // Increment index (go to a newer version, towards current state)
        // If at index -1, go to index 0 (oldest saved version)
        // If at index 0, go to index 1 (newer saved version)
        // If at index 1, go to index 2 (newer saved version)
        // If at index versions.length - 1, go to index versions.length (current state, newest)
        const newIndex = currentIndex + 1;
        // Update currentVersionIndex
        await Investigation.findByIdAndUpdate(investigationId, { $set: { currentVersionIndex: newIndex } }, { new: true });
        // Get and return the investigation at the new index
        return (await getInvestigationById(investigationId));
    }
    catch (error) {
        logger.error({ error, investigationId }, "Failed to redo investigation");
        throw error;
    }
}
/**
 * Updates investigation by adding new version.
 * @category Services
 * @param {IInvestigation} oldInvestigation - The latest investigation version.
 * @param {IInvestigation} investigation - The entire investigation model.
 * @param {ObjectId | null} userId - The ID of the user.
 * @param {boolean} isUserChangeExist - Whether the change was made by the user (true) or AI (false). Defaults to false.
 * @returns {Promise<IInvestigation>} - An investigation updated with new version.
 * @throws {Error} - If an error occurs during the update process.
 */
export async function updateInvestigationByVersion(oldInvestigation, investigation, userId, isUserChangeExist = false) {
    const logger = getLogger();
    try {
        // IMPORTANT: Capture all versioning data from OLD state BEFORE saving
        // This ensures we save the snapshot of the state BEFORE the AI-generated changes
        // Create a deep copy of the old state to ensure it doesn't get mutated
        const oldStateSnapshot = JSON.parse(JSON.stringify(oldInvestigation));
        // Remove versioning metadata - these are not part of the investigation state snapshot
        delete oldStateSnapshot._id;
        delete oldStateSnapshot.id;
        delete oldStateSnapshot.versions;
        delete oldStateSnapshot.currentVersionIndex;
        delete oldStateSnapshot.lastChangeWithAI;
        // Capture versioning metadata from OLD investigation state (before save)
        const versions = Array.isArray(oldInvestigation.versions) ? oldInvestigation.versions : [];
        const lastChangeWithAI = oldInvestigation.lastChangeWithAI ?? false;
        const versionIsAiGenerated = oldInvestigation.lastChangeWithAI ?? false;
        const updatedBy = userId ? new Types.ObjectId(userId.toString()) : null;
        // Use isUserChangeExist to determine if change is AI-generated
        // If user change exists, it's NOT AI-generated, so upcomingChangeWithAI = false
        // If no user change exists, it IS AI-generated, so upcomingChangeWithAI = true
        const upcomingChangeWithAI = !isUserChangeExist;
        logger.info({
            upcomingChangeWithAI,
            lastChangeWithAI,
            isUserChangeExist,
        });
        // Store version if:
        // 1. lastChangeWithAI !== isAiGenerated (change type switched)
        // 2. OR isAiGenerated === true (upcoming change is AI-generated)
        // Store version if change type switched OR if upcoming change is AI-generated
        const shouldStoreVersion = lastChangeWithAI !== upcomingChangeWithAI || upcomingChangeWithAI;
        // STEP 3: Store the old state snapshot in versions BEFORE saving the new investigation
        if (shouldStoreVersion) {
            // 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;
            // Store the OLD state in versions (snapshot was taken before any updates)
            const versionEntry = {
                versionNumber: latestVersionNumber + 1,
                snapshot: oldStateSnapshot, // This is the OLD state, captured before any updates
                updatedBy,
                updatedAt: new Date(),
                isAiGenerated: versionIsAiGenerated, // Use lastChangeWithAI from the old state
            };
            investigation.versions.push(versionEntry);
        }
        const newVersionIndex = investigation.versions.length;
        investigation.currentVersionIndex = newVersionIndex;
        investigation.lastChangeWithAI = upcomingChangeWithAI;
        await investigation.save();
        return investigation;
    }
    catch (error) {
        logger.error("Failed to update investigation");
        throw error;
    }
}