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