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);
}
}
Source