mimani/mongoose-diff-history

View on GitHub
diffHistory.js

Summary

Maintainability
D
2 days
Test Coverage
const mongoose = require('mongoose');

const omit = require('omit-deep');
const pick = require('lodash.pick');
const empty = require('deep-empty-object');
const { assign } = require('power-assign');

// 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('./diffHistoryModel').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) {
    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,
                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) => {
    const queryUpdate = queryObject.getUpdate();
    const schemaOptions = queryObject.model.schema.options || {};

    let keysToBeModified = [];
    let mongoUpdateOperations = [];
    let plainKeys = [];

    for (const key in queryUpdate) {
        const value = queryUpdate[key];
        if (key.startsWith('$') && typeof value === 'object') {
            const innerKeys = Object.keys(value);
            keysToBeModified = keysToBeModified.concat(innerKeys);
            if (key !== '$setOnInsert') {
                mongoUpdateOperations = mongoUpdateOperations.concat(key);
            }
        } else {
            keysToBeModified = keysToBeModified.concat(key);
            plainKeys = plainKeys.concat(key);
        }
    }

    const dbObject = pick(currentObject, keysToBeModified);
    let updatedObject = assign(
        dbObject,
        pick(queryUpdate, mongoUpdateOperations),
        pick(queryUpdate, plainKeys)
    );

    let { strict } = queryObject.options || {};
    // strict in Query options can override schema option
    strict = strict !== undefined ? strict : schemaOptions.strict;

    if (strict === true) {
        const validPaths = Object.keys(queryObject.model.schema.paths);
        updatedObject = pick(updatedObject, validPaths);
    }

    return saveDiffObject(
        currentObject,
        dbObject,
        updatedObject,
        opts,
        queryObject
    );
};

const saveDiffs = (queryObject, opts) =>
    queryObject
        .find(queryObject._conditions)
        .cursor()
        .eachAsync(result => saveDiffHistory(queryObject, result, opts));

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) {
        if (this.isNew) return next();
        this.constructor
            .findOne({ _id: this._id })
            .then(original => {
                if (checkRequired(opts, {}, this)) {
                    return;
                }
                return saveDiffObject(
                    this,
                    original,
                    this.toObject({ depopulate: true }),
                    opts
                );
            })
            .then(() => next())
            .catch(next);
    });

    schema.pre('findOneAndUpdate', function (next) {
        if (checkRequired(opts, this)) {
            return next();
        }
        saveDiffs(this, opts)
            .then(() => next())
            .catch(next);
    });

    schema.pre('update', function (next) {
        if (checkRequired(opts, this)) {
            return next();
        }
        saveDiffs(this, opts)
            .then(() => next())
            .catch(next);
    });

    schema.pre('updateOne', function (next) {
        if (checkRequired(opts, this)) {
            return next();
        }
        saveDiffs(this, opts)
            .then(() => next())
            .catch(next);
    });

    schema.pre('remove', function (next) {
        if (checkRequired(opts, this)) {
            return next();
        }
        saveDiffObject(this, this, {}, opts)
            .then(() => next())
            .catch(next);
    });
};

module.exports = {
    plugin,
    getVersion,
    getDiffs,
    getHistories
};