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