parts/mgmt/date_presets.js

/**
* This module is meant for handling date presets.
* @module api/parts/mgmt/date_presets
*/

/** @lends module:api/parts/mgmt/date_presets */
var presetsApi = {},
    common = require('../../utils/common.js');

/**
* Get presets 
* @param {params} params - params object
* @returns {boolean} true if successful
**/
presetsApi.getAll = async function(params) {
    var member = params.member,
        memberId = member._id + "",
        memberEmail = member.email,
        filterCond = {};

    if (!member.global_admin) {
        var groups = member.group_id || [];
        if (!Array.isArray(groups)) {
            groups = [groups];
        }
        groups = groups.map(group_id => group_id + "");
        filterCond = {
            $or: [
                {owner_id: memberId},
                {share_with: "all-users"},
                {shared_email_view: memberEmail},
                {shared_email_edit: memberEmail},
                {shared_user_groups_edit: {$in: groups}},
                {shared_user_groups_view: {$in: groups}}
            ]
        };
    }

    common.db.collection('date_presets').aggregate([
        { $match: filterCond },
        {
            $addFields: {
                owner_id_objectId: { $toObjectId: '$owner_id' }
            }
        },
        {
            $lookup: {
                from: 'members',
                localField: 'owner_id_objectId',
                foreignField: '_id',
                as: 'owner'
            }
        },
        { $unwind: '$owner' },
        {
            $project: {
                _id: 1,
                name: 1,
                range: 1,
                share_with: 1,
                shared_email_edit: {
                    $cond: {
                        if: {
                            $or: [
                                member.global_admin,
                                { $eq: ['$owner_id', memberId] },
                                { $in: [groups, '$shared_user_groups_edit'] },
                                { $in: [memberEmail, '$shared_email_edit'] }
                            ]
                        },
                        then: '$shared_email_edit',
                        else: false
                    }
                },
                shared_email_view: {
                    $cond: {
                        if: {
                            $or: [
                                member.global_admin,
                                { $eq: ['$owner_id', memberId] },
                                { $in: [groups, '$shared_user_groups_edit'] },
                                { $in: [memberEmail, '$shared_email_edit'] }
                            ]
                        },
                        then: '$shared_email_view',
                        else: false
                    }
                },
                shared_user_groups_edit: {
                    $cond: {
                        if: {
                            $or: [
                                member.global_admin,
                                { $eq: ['$owner_id', memberId] },
                                { $in: [groups, '$shared_user_groups_edit'] },
                                { $in: [memberEmail, '$shared_email_edit']}
                            ]
                        },
                        then: '$shared_user_groups_edit',
                        else: false
                    }
                },
                shared_user_groups_view: {
                    $cond: {
                        if: {
                            $or: [
                                member.global_admin,
                                { $eq: ['$owner_id', memberId] },
                                { $in: [groups, '$shared_user_groups_edit'] },
                                { $in: [memberEmail, '$shared_email_edit']}
                            ]
                        },
                        then: '$shared_user_groups_view',
                        else: false
                    }
                },
                exclude_current_day: 1,
                owner_id: 1,
                owner_name: '$owner.full_name',
                fav: {
                    $in: [memberId, '$fav']
                },
                is_owner: {
                    $cond: {
                        if: {
                            $eq: ['$owner_id', memberId]
                        },
                        then: true,
                        else: member.global_admin
                    }
                },
                created_at: 1,
                edited_at: 1,
                sort_order: 1
            }
        },
        {
            $sort: {
                sort_order: 1
            }
        }
    ]).toArray(function(err, presets) {
        if (err || !presets || !presets.length) {
            console.log("Error getting presets", err, presets);
            common.returnMessage(params, 500, 'Error getting presets');
            return false;
        }
        common.returnOutput(params, presets);
        return true;
    });

    return true;
};

/**
* Add new preset
* @param {params} params - params object
* @returns {boolean} true if successful
**/
presetsApi.create = function(params) {
    var argProps = {
            'name': {
                'required': true,
                'type': 'String',
                'non-empty': true
            },
            'range': {
                'required': true,
                'type': [
                    'Array',
                    'Object',
                    'String'
                ],
                'multiple': true
            },
            'share_with': {
                'required': true,
                'type': 'String',
                'default': 'none', //none, all-users, selected-users
                'non-empty': true
            },
            'shared_email_edit': {
                'required': false,
                'type': 'Array'
            },
            'shared_email_view': {
                'required': false,
                'type': 'Array'
            },
            'shared_user_groups_edit': {
                'required': false,
                'type': 'Array'
            },
            'shared_user_groups_view': {
                'required': false,
                'type': 'Array'
            },
            'exclude_current_day': {
                'required': false,
                'type': 'Boolean',
                'default': false
            },
            'app_id': {
                'required': true,
                'type': 'String',
                'min-length': 24,
                'max-length': 24,
                'exclude-from-ret-obj': true
            }
        },
        newPreset = {};

    if (params.qstring.range) {
        try {
            params.qstring.range = JSON.parse(params.qstring.range);
            if (Array.isArray(params.qstring.range) && params.qstring.range.length !== 2) {
                throw new SyntaxError();
            }
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid range');
            return false;
        }
    }

    if (params.qstring.shared_email_edit) {
        try {
            params.qstring.shared_email_edit = JSON.parse(params.qstring.shared_email_edit);
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid shared_email_edit');
            return false;
        }
    }

    if (params.qstring.shared_email_view) {
        try {
            params.qstring.shared_email_view = JSON.parse(params.qstring.shared_email_view);
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid shared_email_view');
            return false;
        }
    }

    if (params.qstring.shared_user_groups_edit) {
        try {
            params.qstring.shared_user_groups_edit = JSON.parse(params.qstring.shared_user_groups_edit);
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid shared_user_groups_edit');
            return false;
        }
    }

    if (params.qstring.shared_user_groups_view) {
        try {
            params.qstring.shared_user_groups_view = JSON.parse(params.qstring.shared_user_groups_view);
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid shared_user_groups_view');
            return false;
        }
    }

    if (params.qstring.exclude_current_day) {
        try {
            params.qstring.exclude_current_day = JSON.parse(params.qstring.exclude_current_day);
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid exclude_current_day');
            return false;
        }
    }

    if (!(newPreset = common.validateArgs(params.qstring, argProps))) {
        common.returnMessage(params, 400, 'Not enough args');
        return false;
    }

    newPreset.created_at = Math.floor(((new Date()).getTime()) / 1000);

    newPreset.owner_id = params.member._id + "";

    newPreset.fav = [];

    if (newPreset.share_with !== "selected-users") {
        newPreset.shared_email_edit = [];
        newPreset.shared_email_view = [];
        newPreset.shared_user_groups_edit = [];
        newPreset.shared_user_groups_view = [];
    }

    newPreset.sort_order = 0;

    common.db.collection('date_presets').insert(newPreset, { safe: true }, function(err, preset) {
        if (!err && preset && preset.ops && preset.ops[0] && preset.ops[0]._id) {

            //Update sort order of other presets
            common.db.collection('date_presets').updateMany({_id: {$ne: preset.ops[0]._id}}, {$inc: {sort_order: 1}}, function(err1) {
                if (err1) {
                    common.returnMessage(params, 500, 'Error updating sort order of other presets');
                    return false;
                }
                common.returnOutput(params, preset.ops[0]);
                return true;
            });
        }
        else {
            common.returnMessage(params, 500, 'Error adding preset');
            return false;
        }
    });
};

/**
* Update preset
* @param {params} params - params object
* @returns {boolean} true if successful
**/
presetsApi.update = function(params) {
    var argProps = {
            'preset_id': {
                'required': true,
                'type': 'String',
                'min-length': 24,
                'max-length': 24,
                'exclude-from-ret-obj': true
            },
            'name': {
                'required': false,
                'type': 'String',
                'non-empty': true
            },
            'range': {
                'required': false,
                'type': [
                    'Array',
                    'Object',
                    'String'
                ],
                'multiple': true
            },
            'share_with': {
                'required': false,
                'type': 'String',
                'default': 'none', //none, all-users, selected-users
                'non-empty': true
            },
            'shared_email_edit': {
                'required': false,
                'type': 'Array'
            },
            'shared_email_view': {
                'required': false,
                'type': 'Array'
            },
            'shared_user_groups_edit': {
                'required': false,
                'type': 'Array'
            },
            'shared_user_groups_view': {
                'required': false,
                'type': 'Array'
            },
            'exclude_current_day': {
                'required': false,
                'type': 'Boolean',
                'default': false
            },
            'fav': {
                'required': false,
                'type': 'Boolean',
            },
            'sort_order': {
                'required': false,
                'type': 'Number'
            },
            'app_id': {
                'required': true,
                'type': 'String',
                'min-length': 24,
                'max-length': 24,
                'exclude-from-ret-obj': true
            }
        },
        updatedPreset = {},
        member = params.member,
        memberId = member._id + "",
        memberEmail = member.email,
        filterCond = {};

    var groups = member.group_id || [];
    if (!Array.isArray(groups)) {
        groups = [groups];
    }
    groups = groups.map(group_id => group_id + "");

    if (!member.global_admin) {
        filterCond = {
            $or: [
                {owner_id: memberId},
                {share_with: "all-users"},
                {shared_email_view: memberEmail},
                {shared_email_edit: memberEmail},
                {shared_user_groups_edit: {$in: groups}},
                {shared_user_groups_view: {$in: groups}}
            ]
        };
    }

    if (params.qstring.range) {
        try {
            params.qstring.range = JSON.parse(params.qstring.range);
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid range');
            return false;
        }
    }

    if (params.qstring.shared_email_edit) {
        try {
            params.qstring.shared_email_edit = JSON.parse(params.qstring.shared_email_edit);
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid shared_email_edit');
            return false;
        }
    }

    if (params.qstring.shared_email_view) {
        try {
            params.qstring.shared_email_view = JSON.parse(params.qstring.shared_email_view);
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid shared_email_view');
            return false;
        }
    }

    if (params.qstring.shared_user_groups_edit) {
        try {
            params.qstring.shared_user_groups_edit = JSON.parse(params.qstring.shared_user_groups_edit);
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid shared_user_groups_edit');
            return false;
        }
    }

    if (params.qstring.shared_user_groups_view) {
        try {
            params.qstring.shared_user_groups_view = JSON.parse(params.qstring.shared_user_groups_view);
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid shared_user_groups_view');
            return false;
        }
    }

    if (params.qstring.exclude_current_day) {
        try {
            params.qstring.exclude_current_day = JSON.parse(params.qstring.exclude_current_day);
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid exclude_current_day');
            return false;
        }
    }

    if (params.qstring.fav) {
        try {
            params.qstring.fav = JSON.parse(params.qstring.fav);
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid fav');
            return false;
        }
    }

    if (params.qstring.sort_order) {
        try {
            params.qstring.sort_order = JSON.parse(params.qstring.sort_order);
        }
        catch (SyntaxError) {
            common.returnMessage(params, 400, 'Invalid sort_order');
            return false;
        }
    }

    if (!(updatedPreset = common.validateArgs(params.qstring, argProps))) {
        common.returnMessage(params, 400, 'Not enough args');
        return false;
    }

    filterCond._id = common.db.ObjectID(params.qstring.preset_id);

    common.db.collection('date_presets').findOne(filterCond, function(err, presetBefore) {
        if (err || !presetBefore) {
            common.returnMessage(params, 500, 'Could not find preset');
            return false;
        }

        //The selector above is wider than edit rights on purpose, so it cannot decide
        //them. Two changes are open to anybody who can see a preset, and both reach
        //this same handler: marking it a favourite, which is stored on the preset as an
        //entry in fav per member, and dragging it in the management table, which writes
        //the shared sort_order. Everything else belongs to the owner and the members
        //named in the edit lists, which is what the drawer offers and what the delete
        //handler already requires.
        //
        //Both of those send the whole row, so a viewer's request carries the fields
        //they may not change as well. Those are dropped rather than refused, because
        //refusing would fail the two actions the UI does offer them.
        var canEdit = canEditPreset(presetBefore, member, groups);

        if (canEdit) {
            updatedPreset.edited_at = Math.floor(((new Date()).getTime()) / 1000);

            if (updatedPreset.share_with !== "selected-users") {
                updatedPreset.shared_email_edit = [];
                updatedPreset.shared_email_view = [];
                updatedPreset.shared_user_groups_edit = [];
                updatedPreset.shared_user_groups_view = [];
            }
        }
        else {
            //sort_order is the list's shared ordering, and the management table lets
            //anybody who can see a preset drag it, so it stays writable here next to
            //fav. Everything the report is about, the content, the sharing lists and
            //owner_id, does not.
            updatedPreset = typeof updatedPreset.sort_order === "undefined" ? {} : {sort_order: updatedPreset.sort_order};
        }

        //Handle fav
        if (typeof params.qstring.fav !== 'undefined') {
            let fav = presetBefore.fav || [];
            if (params.qstring.fav === true && !fav.includes(params.member._id + "")) {
                fav.push(params.member._id + "");
            }
            if (params.qstring.fav === false && fav.includes(params.member._id + "")) {
                fav.splice(fav.indexOf(params.member._id + ""), 1);
            }
            updatedPreset.fav = fav;
        }

        if (Object.keys(updatedPreset).length === 0) {
            common.returnMessage(params, 403, 'Not allowed to edit this preset');
            return false;
        }

        common.db.collection('date_presets').update({_id: presetBefore._id}, {$set: updatedPreset}, {safe: true}, function(err1) {
            if (err1) {
                common.returnMessage(params, 500, 'Error updating preset');
                return false;
            }

            //If sort order changed, update other presets
            if (updatedPreset.sort_order !== presetBefore.sort_order) {
                const oldSortOrder = presetBefore.sort_order;
                const newSortOrder = updatedPreset.sort_order;
                const sort_order_difference = oldSortOrder - newSortOrder;

                const updateQuery = {
                    sort_order: {
                        $gte: Math.min(oldSortOrder, newSortOrder),
                        $lte: Math.max(oldSortOrder, newSortOrder)
                    },
                    _id: { $ne: presetBefore._id }
                };

                const update = sort_order_difference > 0 ? { $inc: { sort_order: 1 } } : { $inc: { sort_order: -1 } };

                common.db.collection('date_presets').updateMany(updateQuery, update, {multi: true}, function(err2) {
                    if (err2) {
                        common.returnMessage(params, 500, 'Error updating sort order of other presets');
                        return false;
                    }
                    common.returnMessage(params, 200, 'Preset updated');
                    return true;
                });
            }
            else {
                common.returnMessage(params, 200, 'Preset updated');
                return true;
            }
        });
    });
    return true;
};

/**
* Get presets 
* @param {params} params - params object
* @returns {boolean} true if successful
**/
presetsApi.delete = function(params) {
    var argProps = {
        'preset_id': {
            'required': true,
            'type': 'String',
            'min-length': 24,
            'max-length': 24,
            'exclude-from-ret-obj': true
        }
    };

    if (!common.validateArgs(params.qstring, argProps)) {
        common.returnMessage(params, 400, 'Not enough args');
        return false;
    }

    common.db.collection('date_presets').findOne({ _id: common.db.ObjectID(params.qstring.preset_id)}, function(err, presetBefore) {
        if (err || !presetBefore) {
            common.returnMessage(params, 500, 'Could not find preset');
            return false;
        }
        if (presetBefore.owner_id !== params.member._id + "" && !params.member.global_admin) {
            common.returnMessage(params, 401, 'Not authorized');
            return false;
        }
        common.db.collection('date_presets').remove({_id: common.db.ObjectID(params.qstring.preset_id)}, { safe: true }, function(err1, preset) {
            if (err1 || !preset) {
                common.returnMessage(params, 500, 'Error deleting preset');
                return false;
            }
            //Update sort order of other presets
            common.db.collection('date_presets').updateMany({sort_order: {$gt: presetBefore.sort_order}}, {$inc: {sort_order: -1}}, function(err2) {
                if (err2) {
                    common.returnMessage(params, 500, 'Error updating sort order of other presets');
                    return false;
                }
                common.returnOutput(params, preset);
                return true;
            });
        });
    });
};

presetsApi.getById = function(params) {
    var argProps = {
        'preset_id': {
            'required': true,
            'type': 'String',
            'min-length': 24,
            'max-length': 24,
            'exclude-from-ret-obj': true
        },
        'app_id': {
            'required': true,
            'type': 'String',
            'min-length': 24,
            'max-length': 24,
            'exclude-from-ret-obj': true
        }
    };

    var member = params.member,
        memberId = member._id + "",
        memberEmail = member.email,
        filterCond = {};

    if (!member.global_admin) {
        var groups = member.group_id || [];
        if (!Array.isArray(groups)) {
            groups = [groups];
        }
        groups = groups.map(group_id => group_id + "");
        filterCond = {
            $or: [
                {owner_id: memberId},
                {share_with: "all-users"},
                {shared_email_view: memberEmail},
                {shared_email_edit: memberEmail},
                {shared_user_groups_edit: {$in: groups}},
                {shared_user_groups_view: {$in: groups}}
            ]
        };
    }

    if (!common.validateArgs(params.qstring, argProps)) {
        common.returnMessage(params, 400, 'Not enough args');
        return false;
    }

    filterCond._id = common.db.ObjectID(params.qstring.preset_id);

    common.db.collection('date_presets').findOne(filterCond, {projection: PRESET_FIELDS}, function(err, preset) {
        if (err || !preset) {
            common.returnMessage(params, 500, 'Could not find preset');
            return false;
        }
        preset.is_owner = (preset.owner_id === memberId) || member.global_admin;
        //who a preset is shared with is shown only to those who may edit it, as in getAll
        if (!canEditPreset(preset, member, groups || [])) {
            preset.shared_email_edit = false;
            preset.shared_email_view = false;
            preset.shared_user_groups_edit = false;
            preset.shared_user_groups_view = false;
        }
        let fav = preset.fav || [];
        preset.fav = fav.includes(memberId);
        common.returnOutput(params, preset);
        return true;
    });
};

/**
* The fields a preset is made of. Used as a projection so a stored document cannot
* return anything beyond them, and as the shape both write handlers persist.
**/
const PRESET_FIELDS = {
    name: 1,
    range: 1,
    exclude_current_day: 1,
    share_with: 1,
    shared_email_edit: 1,
    shared_email_view: 1,
    shared_user_groups_edit: 1,
    shared_user_groups_view: 1,
    owner_id: 1,
    fav: 1,
    sort_order: 1,
    created_at: 1,
    edited_at: 1
};

/**
* Whether a member may change a preset rather than only see it. Mirrors what the
* drawer offers and what the delete handler already requires: the owner, a global
* admin, or somebody named in one of the edit lists. Being in a view list, or the
* preset being shared with all users, grants no write.
* @param {object} preset - the stored preset
* @param {object} member - params.member
* @param {array} groups - the member's group ids, as strings
* @returns {boolean} true when the member may edit the preset
**/
function canEditPreset(preset, member, groups) {
    if (member.global_admin || preset.owner_id === member._id + "") {
        return true;
    }
    if ((preset.shared_email_edit || []).includes(member.email)) {
        return true;
    }
    return (preset.shared_user_groups_edit || []).some(function(group) {
        return groups.includes(group + "");
    });
}

module.exports = presetsApi;