UNPKG

mongoose-log-history

Version:

A Mongoose plugin to automatically track and log changes (create, update, delete) to your models, with detailed audit history and flexible configuration.

314 lines (286 loc) 10.8 kB
'use strict'; const { getValueByPath, areValuesEqual, valueToString, exists, setByPath, arrayToKeyMap, diffSimpleArray, } = require('./utils'); /** * Extracts context fields from the document and/or array item for logging. * @param {Array|Object} contextFields - Context fields config. * - If an array, fields are extracted from the document itself. * - If an object, it can have: * - `doc`: array of field paths from the document itself * - `item`: array of field paths from the array item (for arrays of objects) * @param {Object} originalDoc - The original document. * @param {Object} updatedDoc - The updated document. * @param {Object} [beforeItem] - The array item before change (optional). * @param {Object} [afterItem] - The array item after change (optional). * @returns {Object|undefined} The extracted context object, or undefined if none. */ function extractLogContext(contextFields, originalDoc, updatedDoc, beforeItem = null, afterItem = null) { let context = undefined; if (contextFields) { context = {}; if (Array.isArray(contextFields)) { context.doc = {}; for (const ctxField of contextFields) { const ctxValue = getValueByPath(updatedDoc, ctxField) || getValueByPath(originalDoc, ctxField); setByPath(context.doc, ctxField, ctxValue); } } else if (contextFields.doc) { context.doc = {}; for (const ctxField of contextFields.doc) { const ctxValue = getValueByPath(updatedDoc, ctxField) || getValueByPath(originalDoc, ctxField); setByPath(context.doc, ctxField, ctxValue); } } if (contextFields.item && Array.isArray(contextFields.item)) { context.item = {}; const item = afterItem || beforeItem; for (const ctxField of contextFields.item) { const ctxValue = getValueByPath(item, ctxField); setByPath(context.item, ctxField, ctxValue); } } } return context; } /** * Process changes for a simple (non-array) field. * @param {Object} field - The tracked field config. * @param {*} beforeValue - The value before the change. * @param {*} afterValue - The value after the change. * @param {Object} originalDoc - The original document. * @param {Object} updatedDoc - The updated document. * @returns {Array} Array of change log objects for this field. */ function processGenericFieldChanges(field, beforeValue, afterValue, originalDoc, updatedDoc) { const log = []; const path = field.value; const beforeExists = exists(beforeValue); const afterExists = exists(afterValue); const beforeStr = valueToString(beforeValue); const afterStr = valueToString(afterValue); const context = extractLogContext(field.contextFields, originalDoc, updatedDoc); if (!beforeExists && !afterExists) { return log; } if (!beforeExists && afterExists) { log.push({ field_name: path, from_value: beforeStr, to_value: afterStr, change_type: 'add', ...(context ? { context } : {}), }); return log; } if (beforeExists && !afterExists) { log.push({ field_name: path, from_value: beforeStr, to_value: afterStr, change_type: 'remove', ...(context ? { context } : {}), }); return log; } if (!areValuesEqual(beforeValue, afterValue)) { log.push({ field_name: path, from_value: beforeStr, to_value: afterStr, change_type: 'edit', ...(context ? { context } : {}), }); } return log; } /** * Process changes for a simple array field (array of primitives). * @param {Object} field - The tracked field config. * @param {Array} beforeValue - The array before the change. * @param {Array} afterValue - The array after the change. * @param {Object} originalDoc - The original document. * @param {Object} updatedDoc - The updated document. * @param {string} [parentFieldName] - The parent field name for nested arrays (optional). * @returns {Array} Array of change log objects for this field. */ function processSimpleArrayChanges(field, beforeValue, afterValue, originalDoc, updatedDoc, parentFieldName = null) { const log = []; const { added, removed } = diffSimpleArray(beforeValue, afterValue); const fieldName = parentFieldName || field.value; const context = extractLogContext(field.contextFields, originalDoc, updatedDoc); for (const item of added) { log.push({ field_name: fieldName, from_value: null, to_value: valueToString(item), change_type: 'add', ...(context ? { context } : {}), }); } for (const item of removed) { log.push({ field_name: fieldName, from_value: valueToString(item), to_value: null, change_type: 'remove', ...(context ? { context } : {}), }); } return log; } /** * Process changes for a custom-key array field (array of objects with a key). * @param {Object} field - The tracked field config. * @param {Array} beforeValue - The array before the change. * @param {Array} afterValue - The array after the change. * @param {Object} originalDoc - The original document. * @param {Object} updatedDoc - The updated document. * @param {string} [parentFieldName] - The parent field name for nested arrays (optional). * @returns {Array} Array of change log objects for this field. */ function processCustomKeyArrayChanges(field, beforeValue, afterValue, originalDoc, updatedDoc, parentFieldName = null) { const log = []; const beforeMap = arrayToKeyMap(beforeValue, field.arrayKey); const afterMap = arrayToKeyMap(afterValue, field.arrayKey); const allKeys = new Set([...Object.keys(beforeMap), ...Object.keys(afterMap)]); const fieldName = parentFieldName || field.value; for (const key of allKeys) { const beforeItem = beforeMap[key]; const afterItem = afterMap[key]; const beforeExists = exists(beforeItem); const afterExists = exists(afterItem); const fromValue = beforeItem && field.valueField ? valueToString(beforeItem[field.valueField]) : undefined; const toValue = afterItem && field.valueField ? valueToString(afterItem[field.valueField]) : undefined; const context = extractLogContext(field.contextFields, originalDoc, updatedDoc, beforeItem, afterItem); if (!beforeExists && !afterExists) { continue; } if (!beforeExists && afterExists) { log.push({ field_name: fieldName, from_value: null, to_value: toValue, change_type: 'add', ...(context ? { context } : {}), }); continue; } if (beforeExists && !afterExists) { log.push({ field_name: fieldName, from_value: fromValue, to_value: null, change_type: 'remove', ...(context ? { context } : {}), }); continue; } if (beforeExists && afterExists && Array.isArray(field.trackedFields)) { log.push(...processSubFieldChanges(field, beforeItem, afterItem, originalDoc, updatedDoc, fieldName)); } } return log; } /** * Process changes for nested tracked fields inside an array of objects. * @param {Object} field - The tracked field config. * @param {Object} beforeItem - The array item before the change. * @param {Object} afterItem - The array item after the change. * @param {Object} originalDoc - The original document. * @param {Object} updatedDoc - The updated document. * @param {string} [parentFieldName] - The parent field name for nested arrays (optional). * @returns {Array} Array of change log objects for nested fields. */ function processSubFieldChanges(field, beforeItem, afterItem, originalDoc, updatedDoc, parentFieldName = null) { const log = []; for (const subField of field.trackedFields) { const subPath = subField.value; const beforeVal = getValueByPath(beforeItem, subPath); const afterVal = getValueByPath(afterItem, subPath); const fieldName = `${parentFieldName || field.value}.${subPath}`; if (subField.arrayType === 'simple') { log.push(...processSimpleArrayChanges(subField, beforeVal, afterVal, beforeItem, afterItem, fieldName)); } else if (subField.arrayType === 'custom-key' && subField.arrayKey) { log.push(...processCustomKeyArrayChanges(subField, beforeVal, afterVal, beforeItem, afterItem, fieldName)); } else { const beforeSubExists = exists(beforeVal); const afterSubExists = exists(afterVal); const beforeStr = valueToString(beforeVal); const afterStr = valueToString(afterVal); const context = extractLogContext(field.contextFields, originalDoc, updatedDoc, beforeItem, afterItem); if (!beforeSubExists && !afterSubExists) { continue; } if (!beforeSubExists && afterSubExists) { log.push({ field_name: fieldName, from_value: beforeStr, to_value: afterStr, change_type: 'add', ...(context ? { context } : {}), }); continue; } if (beforeSubExists && !afterSubExists) { log.push({ field_name: fieldName, from_value: beforeStr, to_value: afterStr, change_type: 'remove', ...(context ? { context } : {}), }); continue; } if (!areValuesEqual(beforeVal, afterVal)) { log.push({ field_name: fieldName, from_value: beforeStr, to_value: afterStr, change_type: 'edit', ...(context ? { context } : {}), }); } } } return log; } /** * Get the list of tracked changes between two documents. * @param {Object} original - The original document. * @param {Object} updated - The updated document. * @param {Array} trackedFields - Array of tracked field configs. * @returns {Array} Array of change log objects. */ function getTrackedChanges(original, updated, trackedFields) { const log = []; for (const field of trackedFields) { const path = field.value; const beforeValue = getValueByPath(original, path); const afterValue = getValueByPath(updated, path); let fieldChanges = []; if (field.arrayType === 'simple') { fieldChanges = processSimpleArrayChanges(field, beforeValue, afterValue, original, updated); } else if (field.arrayType === 'custom-key' && field.arrayKey) { fieldChanges = processCustomKeyArrayChanges(field, beforeValue, afterValue, original, updated); } else { fieldChanges = processGenericFieldChanges(field, beforeValue, afterValue, original, updated); } log.push(...fieldChanges); } return log; } module.exports = { extractLogContext, processGenericFieldChanges, processSimpleArrayChanges, processCustomKeyArrayChanges, processSubFieldChanges, getTrackedChanges, };