UNPKG

mongoose-audit-trail

Version:

Manage Mongo Collection diff History and versions

317 lines (289 loc) 8.7 kB
const omit = require("omit-deep"); const pick = require("lodash.pick"); const mongoose = require("mongoose"); const { assign } = require("power-assign"); const empty = require("deep-empty-object"); // try to find an id property, otherwise just use the index in the array const objectHash = (obj, idx) => obj._id || obj.id || `$$index: ${idx}`; const diffPatcher = require("jsondiffpatch").create({ objectHash }); const History = require("./auditModel").model; const isValidCb = cb => { return cb && typeof cb === "function"; }; //https://eslint.org/docs/rules/complexity#when-not-to-use-it /* eslint-disable complexity */ function checkRequired(opts, queryObject, updatedObject) { if (queryObject && !queryObject.options && !updatedObject) { return; } const { __user: user, __reason: reason } = (queryObject && queryObject.options) || updatedObject; if ( opts.required && ((opts.required.includes("user") && !user) || (opts.required.includes("reason") && !reason)) ) { return true; } } function saveDiffObject( currentObject, original, updated, opts, queryObject, method ) { const { __user: user, __reason: reason, __session: session } = (queryObject && queryObject.options) || currentObject; let diff = diffPatcher.diff( JSON.parse(JSON.stringify(original)), JSON.parse(JSON.stringify(updated)) ); if (opts.omit) { omit(diff, opts.omit, { cleanEmpty: true }); } if (opts.pick) { diff = pick(diff, opts.pick); } if (!diff || !Object.keys(diff).length || empty.all(diff)) { return; } const collectionId = currentObject._id; const collectionName = currentObject.constructor.modelName || queryObject.model.modelName; return History.findOne({ collectionId, collectionName }) .sort("-version") .then(lastHistory => { const history = new History({ collectionId, collectionName, method, diff, user, reason, version: lastHistory ? lastHistory.version + 1 : 0 }); if (session) { return history.save({ session }); } return history.save(); }); } /* eslint-disable complexity */ const saveDiffHistory = (queryObject, currentObject, opts, method) => { const update = JSON.parse(JSON.stringify(queryObject._update)); /* eslint-disable security/detect-object-injection */ const updateParams = Object.assign( ...Object.keys(update).map(function(key) { if (typeof update[key] === "object") { return update[key]; } return update; }) ); /* eslint-enable security/detect-object-injection */ delete queryObject._update["$setOnInsert"]; var set = queryObject._update["$set"]; queryObject._update = { ...queryObject._update, ...set }; delete queryObject._update["$set"]; const dbObject = pick(currentObject, Object.keys(updateParams)); return saveDiffObject( currentObject, dbObject, assign(dbObject, queryObject._update), opts, queryObject, method ); }; const saveDiffs = (queryObject, opts, method) => queryObject .find(queryObject._conditions) .lean(false) .cursor() .eachAsync(result => saveDiffHistory(queryObject, result, opts, method)); const getVersion = (model, id, version, queryOpts, cb) => { if (typeof queryOpts === "function") { cb = queryOpts; queryOpts = undefined; } return model .findById(id, null, queryOpts) .then(latest => { latest = latest || {}; return History.find( { collectionName: model.modelName, collectionId: id, version: { $gte: parseInt(version, 10) } }, { diff: 1, version: 1 }, { sort: "-version" } ) .lean() .cursor() .eachAsync(history => { diffPatcher.unpatch(latest, history.diff); }) .then(() => { if (isValidCb(cb)) return cb(null, latest); return latest; }); }) .catch(err => { if (isValidCb(cb)) return cb(err, null); throw err; }); }; const getDiffs = (modelName, id, opts, cb) => { opts = opts || {}; if (typeof opts === "function") { cb = opts; opts = {}; } return History.find( { collectionName: modelName, collectionId: id }, null, opts ) .lean() .then(histories => { if (isValidCb(cb)) return cb(null, histories); return histories; }) .catch(err => { if (isValidCb(cb)) return cb(err, null); throw err; }); }; const getHistories = (modelName, id, expandableFields, cb) => { expandableFields = expandableFields || []; if (typeof expandableFields === "function") { cb = expandableFields; expandableFields = []; } const histories = []; return History.find({ collectionName: modelName, collectionId: id }) .lean() .cursor() .eachAsync(history => { const changedValues = []; const changedFields = []; for (const key in history.diff) { if (history.diff.hasOwnProperty(key)) { if (expandableFields.indexOf(key) > -1) { const oldValue = history.diff[key][0]; const newValue = history.diff[key][1]; changedValues.push(key + " from " + oldValue + " to " + newValue); } else { changedFields.push(key); } } } const comment = "modified " + changedFields.concat(changedValues).join(", "); histories.push({ changedBy: history.user, changedAt: history.createdAt, updatedAt: history.updatedAt, reason: history.reason, comment: comment }); }) .then(() => { if (isValidCb(cb)) return cb(null, histories); return histories; }) .catch(err => { if (isValidCb(cb)) return cb(err, null); throw err; }); }; /** * @param {Object} schema - Schema object passed by Mongoose Schema.plugin * @param {Object} [opts] - Options passed by Mongoose Schema.plugin * @param {string} [opts.uri] - URI for MongoDB (necessary, for instance, when not using mongoose.connect). * @param {string|string[]} [opts.omit] - fields to omit from diffs (ex. ['a', 'b.c.d']). */ const plugin = function lastModifiedPlugin(schema, opts = {}) { if (opts.uri) { const mongoVersion = parseInt(mongoose.version); if (mongoVersion < 5) { mongoose.connect(opts.uri, { useMongoClient: true }).catch(e => { console.error("mongoose-diff-history connection error:", e); }); } else { mongoose.connect(opts.uri, { useNewUrlParser: true }).catch(e => { console.error("mongoose-diff-history connection error:", e); }); } } if (opts.omit && !Array.isArray(opts.omit)) { if (typeof opts.omit === "string") { opts.omit = [opts.omit]; } else { const errMsg = `opts.omit expects string or array, instead got '${typeof opts.omit}'`; throw new TypeError(errMsg); } } schema.pre("save", function(next) { var method; this.isNew ? (method = "create") : (method = "update"); this.constructor .findOne({ _id: this._id }) .then(original => { if (checkRequired(opts, {}, this)) { return; } return saveDiffObject( this, original ? original : {}, this.toObject({ depopulate: true }), opts, null, method ); }) .then(() => next()) .catch(next); }); schema.pre("findOneAndUpdate", function(next) { if (checkRequired(opts, this)) { return next(); } saveDiffs(this, opts, "update") .then(() => next()) .catch(next); }); schema.pre("update", function(next) { if (checkRequired(opts, this)) { return next(); } saveDiffs(this, opts, "update") .then(() => next()) .catch(next); }); schema.pre("updateOne", function(next) { if (checkRequired(opts, this)) { return next(); } saveDiffs(this, opts, "update") .then(() => next()) .catch(next); }); schema.pre("remove", function(next) { if (checkRequired(opts, this)) { return next(); } saveDiffObject(this, this, {}, opts, null, "remove") .then(() => next()) .catch(next); }); }; module.exports = { plugin, getVersion, getDiffs, getHistories };