Source

services/investigation/crud.js

import { Investigation } from "@models/investigation/investigation.model";
import { InvestigationStatus } from "@typez/investigation";
import { getLogger } from "@utils/asyncLocalStorage";
import { InvestigationBuilder } from "../investigation.builder";
import { buildInvestigationSnapshot, restoreInvestigationFromSnapshot } from "./versioning-helpers";
const MAX_INVESTIGATION_VERSIONS = 20;
/**
 * Creates a new investigation.
 * @category Services
 * @param {ICreateInvestigationDto} createDto - The data required to create the investigation.
 * @returns {Promise<IInvestigation>} - The created investigation.
 */
export async function createInvestigation(createDto) {
    const logger = getLogger();
    try {
        logger.info({ createDto }, "Creating new investigation");
        // Use builder to create clean investigation data (default: human-created flow)
        const investigationData = InvestigationBuilder.build(createDto, false);
        logger.info({ investigationData }, "Investigation data by builder is built !");
        // Create the investigation document
        const investigation = new Investigation(investigationData);
        logger.info({ investigation }, "Investigation document created ! ");
        // Save the investigation
        const savedInvestigation = await investigation.save();
        logger.info({ investigationId: savedInvestigation._id }, "Investigation created successfully");
        return savedInvestigation;
    }
    catch (error) {
        logger.error({ error, createDto }, "Failed to create investigation");
        throw new Error("Failed to create investigation");
    }
}
/**
 * Retrieves a single investigation by its ID.
 * @category Services
 * @param {string} investigationId - The ID of the investigation to retrieve.
 * @returns {Promise<IInvestigation>} - The investigation data.
 * @throws {Error} If the investigation is not found.
 */
export async function getInvestigationById(investigationId) {
    const logger = getLogger();
    try {
        logger.info({ investigationId }, "Fetching investigation by ID");
        const investigation = await Investigation.findById(investigationId);
        if (!investigation) {
            throw new Error("Investigation not found");
        }
        // Get the version at currentVersionIndex
        // Note: Index 0 = versions[0] (oldest saved version)
        //       Index 1 = versions[1]
        //       ...
        //       Index versions.length - 1 = versions[versions.length - 1] (newest saved version)
        //       Index versions.length = current state (not in versions array)
        const currentIndex = investigation.currentVersionIndex ?? 0;
        const versions = Array.isArray(investigation.versions) ? investigation.versions : [];
        // Versions are stored with newest at the end of the array (no need to sort)
        // versions[0] = oldest saved version
        // versions[versions.length - 1] = newest saved version
        const sortedVersions = versions;
        // Index versions.length means current state (not in versions array)
        if (currentIndex === sortedVersions.length) {
            logger.info({ investigationId, currentIndex }, "Returning current investigation (index = versions.length = current state)");
            return investigation;
        }
        // If currentIndex is out of bounds, return current investigation
        // Valid indices: 0 to versions.length (inclusive)
        if (currentIndex < 0 || currentIndex > sortedVersions.length) {
            logger.info({ investigationId, currentIndex, versionsLength: sortedVersions.length }, "Returning current investigation (invalid index)");
            return investigation;
        }
        // Get the version at currentIndex (index 0 = versions[0], index 1 = versions[1], etc.)
        const versionToRestore = sortedVersions[currentIndex];
        if (!versionToRestore || !versionToRestore.snapshot) {
            logger.info({ investigationId, currentIndex, versionsLength: sortedVersions.length }, "Version snapshot not found, returning current investigation");
            return investigation;
        }
        logger.info({
            investigationId,
            currentIndex,
            versionNumber: versionToRestore.versionNumber,
            versionsLength: sortedVersions.length,
            availableVersions: sortedVersions.map((v) => v.versionNumber),
        }, "Restoring investigation from version snapshot");
        // Restore the investigation from the snapshot
        const restoredInvestigation = restoreInvestigationFromSnapshot(investigation, versionToRestore.snapshot);
        logger.info({ investigationId, currentIndex, versionNumber: versionToRestore.versionNumber }, "Returning investigation at version index");
        return restoredInvestigation;
    }
    catch (error) {
        logger.error({ error, investigationId }, "Failed to fetch investigation");
        throw error;
    }
}
/**
 * Updates an investigation using the new API format.
 * @category Services
 * @param {string} investigationId - The ID of the investigation to update.
 * @param {IUpdateInvestigationDto} updateData - The update data in the new format, including string/number fields and step updates.
 * @param {object} obj - Optional object used to determine if the update data should be built from scratch.
 * @param {boolean} obj.needsBuildUpdate - Whether the update data should be built from scratch.
 * @param {Types.ObjectId | null} obj.updatedBy - The user making the change (optional).
 * @param {boolean} obj.isAiGenerated - Whether this update is AI-generated (default: false).
 * @param {boolean} obj.skipVersioning - Whether to skip versioning (default: false).
 * @returns {Promise<IUpdateInvestigationResponse>} - The result of the update operation.
 * @throws {Error} If the investigation is not found or is locked.
 */
export async function updateInvestigation(investigationId, updateData, obj) {
    const logger = getLogger();
    try {
        logger.info({ investigationId, updateData }, "Updating investigation with new format");
        // Find the investigation first
        const investigation = await Investigation.findById(investigationId);
        if (!investigation) {
            throw new Error("Investigation not found");
        }
        const updatedBy = obj?.updatedBy ?? null;
        const isAiGenerated = obj?.isAiGenerated ?? false;
        // If currentVersionIndex is -1, skip field updates (only send empty fields in response, don't update DB)
        const currentVersionIndex = investigation.currentVersionIndex ?? 0;
        if (currentVersionIndex === -1) {
            logger.info({ investigationId, currentVersionIndex }, "Skipping field updates: currentVersionIndex is -1 (empty state)");
            // Return the investigation without updating fields
            return {
                success: true,
                updatedInvestigation: investigation,
            };
        }
        // IMPORTANT: Capture snapshot of OLD state BEFORE any updates are applied
        // This ensures we save the state BEFORE the current changes
        const oldStateSnapshot = buildInvestigationSnapshot(investigation);
        // Use builder to create update fields
        let updateFields = updateData;
        if (obj?.needsBuildUpdate) {
            updateFields = InvestigationBuilder.buidlUpdate(updateData, isAiGenerated, investigation);
        }
        // History saving temporarily disabled
        // const historyUpdates = saveFieldHistory(investigation, updateFields, updatedBy);
        const historyUpdates = {};
        const hasUserUpdates = Object.keys(updateFields).length > 0;
        // Save snapshot of OLD state in versions - NOT the new/current state
        // Store version if:
        // 1. lastChangeWithAI !== isAiGenerated (change type switched)
        // 2. OR isAiGenerated === true (upcoming change is AI-generated)
        let versionEntry = null;
        const versions = Array.isArray(investigation.versions) ? investigation.versions : [];
        let newVersionIndex = investigation.currentVersionIndex ?? versions.length;
        if (hasUserUpdates && !obj?.skipVersioning) {
            const lastChangeWithAI = investigation.lastChangeWithAI ?? false;
            const upcomingChangeWithAI = isAiGenerated;
            // Store version if change type switched OR if upcoming change is AI-generated
            const shouldStoreVersion = lastChangeWithAI !== upcomingChangeWithAI || upcomingChangeWithAI;
            if (shouldStoreVersion) {
                // Use lastChangeWithAI from the investigation (state before this update) to mark the version
                // This represents whether the state we're saving was generated by AI
                const versionIsAiGenerated = investigation.lastChangeWithAI ?? false;
                // 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)
                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
                };
                // After saving old state to versions, currentVersionIndex should be versions.length (current state)
                // This will be updated after the version is pushed
                newVersionIndex = versions.length + 1; // +1 because we're about to push a new version
            }
        }
        // Separate fields that conflict with $push operations
        const setFields = { ...updateFields };
        const pushOps = {};
        // History saving temporarily disabled - no need to handle object history conflicts
        const mongoUpdateOps = {
            $set: setFields,
        };
        if (Object.keys(historyUpdates).length > 0 && !obj?.skipVersioning) {
            for (const [baseFieldPath, historyData] of Object.entries(historyUpdates)) {
                if (historyData && typeof historyData === "object" && "historyEntry" in historyData) {
                    const { historyEntry } = historyData;
                    pushOps[`${baseFieldPath}.history`] = {
                        $each: [historyEntry],
                        $position: 0,
                    };
                }
            }
        }
        if (versionEntry) {
            // Push the new version to the end of the array (snapshot of state before this update)
            pushOps.versions = {
                $each: [versionEntry],
                $slice: MAX_INVESTIGATION_VERSIONS,
            };
            // Update currentVersionIndex to point to the new version (current state)
            setFields.currentVersionIndex = newVersionIndex;
            // Update lastChangeWithAI based on whether this update is AI-generated
            setFields.lastChangeWithAI = isAiGenerated;
        }
        else {
            // Even when no version is created, update currentVersionIndex to point to the latest state
            // This ensures versionIndex always reflects the current state after an update
            setFields.currentVersionIndex = versions.length;
        }
        if (Object.keys(pushOps).length > 0) {
            mongoUpdateOps.$push = pushOps;
        }
        let updatedInvestigation = await Investigation.findByIdAndUpdate(investigationId, mongoUpdateOps, {
            new: true,
            runValidators: true,
        });
        if (updatedInvestigation) {
            updatedInvestigation = await updatedInvestigation.save();
        }
        logger.info({ investigationId }, "Investigation updated successfully with new format");
        return {
            success: true,
            updatedInvestigation: updatedInvestigation?.toObject(),
        };
    }
    catch (error) {
        logger.error({ error, investigationId, updateData }, "Failed to update investigation with new format");
        throw new Error("Failed to update investigation");
    }
}
/**
 * Throws when investigation is in development state.
 * @param {IInvestigation} investigation - Investigation to validate.
 * @param {string} message - Error message to throw.
 * @returns {void} No return value.
 */
export function assertNotInDevelopment(investigation, message) {
    if (investigation.status === InvestigationStatus.IN_DEVELOPMENT) {
        throw new Error(message);
    }
}