telepat-io/telepat-api

View on GitHub
controllers/object.js

Summary

Maintainability
D
2 days
Test Coverage
var express = require('express');
var router = express.Router();
var Models = require('telepat-models');
var security = require('./security');
var microtime = require('microtime-nodejs');
var clone = require('clone');
var async = require('async');

router.use(security.applicationIdValidation);
router.use(security.apiKeyValidation);
router.use(['/subscribe', '/unsubscribe'], security.deviceIdValidation);

router.use(['/subscribe', '/unsubscribe'], security.objectACL('read_acl'));
router.use(['/create', '/update', '/delete'], security.objectACL('write_acl'));
router.use(['/count'], security.objectACL('meta_read_acl'));

var validateContext = function(appId, context, callback) {
    Models.Application.hasContext(appId, context, function(err, result) {
        if (err)
            return callback(err);
        else if (result === false) {
            callback(new Models.TelepatError(Models.TelepatError.errors.InvalidContext, [context, appId]));
        } else
            callback();
    });
};

/**
 * @api {post} /object/subscribe Subscribe
 * @apiDescription Subscribe to an object or a collection of objects (by a filter). Returns a the resulting object(s).
 * Subsequent subscription on the same channel and filter will have no effect but will return the objects.
 * @apiName ObjectSubscribe
 * @apiGroup Object
 * @apiVersion 0.4.0
 *
 * @apiHeader {String} Content-type application/json
 * @apiHeader {String} Authorization
                       The authorization token obtained in the login endpoint.
                       Should have the format: <i>Bearer $TOKEN</i>
 * @apiHeader {String} X-BLGREQ-APPID Custom header which contains the application ID
 * @apiHeader {String} X-BLGREQ-SIGN Custom header containing the SHA256-ed API key of the application
 * @apiHeader {String} X-BLGREQ-UDID Custom header containing the device ID (obtained from device/register)
 *
 * @apiParam {Object} channel Object representing the channel
 * @apiParam {Object} filters Object representing channel filters
 * @apiParam {Object} sort Object representing the sort order by a field
 * @apiParam {Number} offset (optional) Starting offset (default: 0)
 * @apiParam {Number} limit (optional) Number of objects to return (default: depends on API configuration)
 * @apiParam {Boolean} no_subscribe (optional) If set to truethful value the device will not be subscribed, only objects
 * will be returned.
 *
 * @apiExample {json} Client Request
 * {
 *         "channel": {
 *             "id": 1,
 *             "context": 1,
 *            "model": "comment",
 *            "parent": {
 *                "id": 1,
 *                "model": "event"
 *            },
 *            "user": 2
 *         },
 *        "filters": {
 *            "or": [
 *                {
 *                    "and": [
 *                        {
 *                          "is": {
 *                            "gender": "male",
 *                            "age": 23
 *                          }
 *                        },
 *                        {
 *                          "range": {
 *                            "experience": {
 *                              "gte": 1,
 *                              "lte": 6
 *                            }
 *                          }
 *                        }
 *                      ]
 *                    },
 *                    {
 *                      "and": [
 *                        {
 *                          "like": {
 *                            "image_url": "png",
 *                            "website": "png"
 *                          }
 *                        }
 *                      ]
 *                    }
 *                  ]
 *        },
 *        "sort": {
 *            "points": "desc"
 *        },
 *        "offset": 0,
 *        "limit": 64
 * }
 *
 *    @apiSuccessExample {json} Success Response
 *     {
 *         "status": 200,
 *         "content": [
 *             {
 *                 //item properties
 *             }
 *         ]
 *     }
 *
 * @apiError 400 [027]InvalidChannel When trying to subscribe to an invalid channel
 *
 */
router.post('/subscribe', function(req, res, next) {
    var offset = req.body.offset;
    var limit = req.body.limit;
    var channel = req.body.channel;
    var sort = req.body.sort;
    var noSubscribe = req.body.no_subscribe;

    var id = channel.id,
        context = channel.context,
        mdl = channel.model,
        parent = channel.parent,// eg: {model: "event", id: 1}
        user = channel.user,
        filters = req.body.filters,
        deviceId = req._telepat.device_id,
        appId = req._telepat.applicationId;

    var channelObject = new Models.Channel(appId);

    if (id) {
        channelObject.model(mdl, id);
    } else {
        channelObject.model(mdl);

        if (context)
            channelObject.context(context);

        if (parent)
            channelObject.parent(parent);

        if (user)
            channelObject.user(user);

        if (filters)
            channelObject.setFilter(filters);
    }


    //only valid for application objects
    if (Models.Channel.builtInModels.indexOf(mdl) === -1 && !(req.user && req.user.isAdmin)) {
        var appSchema = Models.Application.loadedAppModels[appId].schema;

        if (appSchema[mdl]['read_acl'] & 8) {
            if ((appSchema[mdl]['write_acl'] & 8) && !req.user) {
                return next(new Models.TelepatError(Models.TelepatError.errors.OperationNotAllowed));
            }

            addAuthorFilters(channelObject, appSchema, mdl, req.user.id);
        }
    }

    if (!channelObject.isValid()) {
        return next(new Models.TelepatError(Models.TelepatError.errors.InvalidChannel, [channelObject.errorMessage]));
    }

    var objects = [];

    if (mdl === 'breakingnews') {
        console.log(Date.now(), '====== (CONTROLLER) DEVICE  ' + req._telepat.device_id + ' SUBSCRIBED TO BREAKINGNEWS', req.body);
    }

    async.series([
        //verify if context belongs to app
        function(callback) {
            if (context)
                validateContext(appId, context, callback);
            else
                callback();
        },
        function(callback) {
            //only add subscription on initial /subscribe
            if ((offset && offset > 0) || noSubscribe)
                return callback();
            Models.Subscription.add(appId, deviceId, channelObject,  function(err) {
                if (err && err.status === 409)
                    return callback();

                callback(err);
            });
        },
        function(callback) {
            if (id) {
                Models.Model(id, function(err, results) {
                    if (err) return callback(err);

                    objects.push(results);

                    callback();
                });
            } else {
                Models.Model.search(channelObject, sort, offset, limit, function(err, results) {
                    if (err) return callback(err);

                    if (Array.isArray(results))    {
                        if (mdl == 'user') {
                            results = results.map(function(userResult) {
                                delete userResult.password;

                                if (!(req.user && req.user.isAdmin)) {
                                    delete userResult.email;
                                    delete userResult.username;
                                }

                                return userResult;
                            });
                        }
                        objects = objects.concat(results);
                    }

                    callback();
                });
            }
        }
    ], function(err) {
        if (err)
            return next(err);

        res.status(200).json({status: 200, content: objects});
    });
});

/**
 * @api {post} /object/unsubscribe Unsubscribe
 * @apiDescription Unsubscribe to an object or a collection of objects (by a filter)
 * @apiName ObjectUnsubscribe
 * @apiGroup Object
 * @apiVersion 0.4.0
 *
 * @apiHeader {String} Content-type application/json
 * @apiHeader {String} Authorization
                       The authorization token obtained in the login endpoint.
                       Should have the format: <i>Bearer $TOKEN</i>
 * @apiHeader {String} X-BLGREQ-APPID Custom header which contains the application ID
 * @apiHeader {String} X-BLGREQ-SIGN Custom header containing the SHA256-ed API key of the application
 * @apiHeader {String} X-BLGREQ-UDID Custom header containing the device ID (obtained from device/register)
 *
 * @apiParam {Object} channel Object representing the channel
 * @apiParam {Object} filters Object representing the filters for the channel
 *
 * @apiExample {json} Client Request
 * {
 *         //exactly the same as with the subscribe method
 * }
 *
 * @apiSuccessExample {json} Success Response
 *     {
 *         "status": 200,
 *         "content": "Subscription removed"
 *     }
 *
 * @apiError 400 [027]InvalidChannel When trying to subscribe to an invalid channel
 */
router.post('/unsubscribe', function(req, res, next) {
    var channel = req.body.channel;

    var id = channel.id,
    context = channel.context,
    mdl = channel.model,
    parent = channel.parent,// eg: {model: "event", id: 1}
    user = channel.user,
    filters = req.body.filters,
    deviceId = req._telepat.device_id,
    appId = req._telepat.applicationId;

    var channelObject = new Models.Channel(appId);

    if (id) {
        channelObject.model(mdl, id);
    } else {
        channelObject.model(mdl);

        if (context)
            channelObject.context(context);

        if (parent)
            channelObject.parent(parent);

        if (user)
            channelObject.user(user);

        if (filters)
            channelObject.setFilter(filters);
    }

    if (!channelObject.isValid()) {
        return next(new Models.TelepatError(Models.TelepatError.errors.InvalidChannel, [channelObject.errorMessage]));
    }

    if (mdl === 'breakingnews') {
        console.log(Date.now(), '====== (CONTROLLER) DEVICE  ' + req._telepat.device_id + ' UNSUBSCRIBED TO BREAKINGNEWS', req.body);
    }

    async.series([
        //verify if context belongs to app
        function(callback) {
            if (context)
                validateContext(appId, context, callback);
            else
                callback();
        },
        function(callback) {
            Models.Subscription.remove(appId, deviceId, channelObject, callback);
        }/*,
        function(result, callback) {
            app.kafkaProducer.send([{
                topic: 'track',
                messages: [JSON.stringify({
                    op: 'unsub',
                    object: {device_id: deviceId, channel: channel, filters: filters},
                    applicationId: appId
                })],
                attributes: 0
            }], function(err, data) {
                if (err)
                    err.message = "Failed to send message to track worker.";

                callback(err, result);
            });
        }*/
    ], function(err) {
        if (err) {
            return next(err);
        } else {
            res.status(200).json({status: 200, content: 'Subscription removed'});
        }
    });
});

/**
 * @api {post} /object/create Create
 * @apiDescription Creates a new object. The object is not immediately created.
 * @apiName ObjectCreate
 * @apiGroup Object
 * @apiVersion 0.4.0
 *
 * @apiHeader {String} Content-type application/json
 * @apiHeader {String} Authorization
                       The authorization token obtained in the login endpoint.
                       Should have the format: <i>Bearer $TOKEN</i>
 * @apiHeader {String} X-BLGREQ-APPID Custom header which contains the application ID
 * @apiHeader {String} X-BLGREQ-SIGN Custom header containing the SHA256-ed API key of the application
 *
 * @apiParam {String} model The type of object to subscribe to
 * @apiParam {Object} content Content of the object
 *
 * @apiExample {json} Client Request
 * {
 *         "model": "comment",
 *         "context": 1,
 *         "content": {
 *            //object properties
 *         }
 * }
 *
 * @apiSuccessExample {json} Success Response
 *     {
 *         "status": 202,
 *         "content": "Created"
 *     }
 *
 */
router.post('/create', function(req, res, next) {
    var modifiedMicrotime = microtime.now();
    var content = req.body.content;
    var mdl = req.body.model;
    var context = req.body.context;
    var appId = req._telepat.applicationId;

    if (!context)
        return next(new Models.TelepatError(Models.TelepatError.errors.MissingRequiredField, ['context']));
    if (!content)
        return next(new Models.TelepatError(Models.TelepatError.errors.MissingRequiredField, ['content']));

    if (req.user)
        content.user_id = req.user.id;
    content.type = mdl;
    content.context_id = context;
    content.application_id = appId;

    if (Models.Application.loadedAppModels[appId].schema[mdl].belongsTo &&
                Models.Application.loadedAppModels[appId].schema[mdl].belongsTo.length) {

        var parentValidation = false;

        for (var parent in Models.Application.loadedAppModels[appId].schema[mdl].belongsTo) {
            var parentModel = Models.Application.loadedAppModels[appId].schema[mdl].belongsTo[parent].parentModel;

            if (content[parentModel+'_id']) {
                parentValidation = true;

                if (Models.Application.loadedAppModels[appId].schema[mdl].belongsTo[0].relationType == 'hasSome' &&
                    content[Models.Application.loadedAppModels[appId].schema[parentModel].hasSome_property+'_index'] === undefined ||
                    content[Models.Application.loadedAppModels[appId].schema[parentModel].hasSome_property+'_index'] === null) {
                    return next(new Models.TelepatError(Models.TelepatError.errors.MissingRequiredField,
                        [Models.Application.loadedAppModels[appId].schema[parentModel].hasSome_property+'_index']));
                }

                break;
            }
        }

        if (!parentValidation)
            return next(new Models.TelepatError(Models.TelepatError.errors.MissingRequiredField, ['a field with the parent ID is missing']));

    }

    var hasSomeProperty = Models.Application.loadedAppModels[appId].schema[mdl].hasSome_property;

    if(hasSomeProperty && !content[hasSomeProperty])
        return next(new Models.TelepatError(Models.TelepatError.errors.MissingRequiredField, [hasSomeProperty]));

    async.series([
        function(aggCallback) {
            app.messagingClient.send([JSON.stringify({
                op: 'create',
                object: content,
                application_id: appId,
                timestamp: modifiedMicrotime
            })], 'aggregation', function(err) {
                if (err){
                    err = new Models.TelepatError(Models.TelepatError.errors.ServerFailure, [err]);
                }
                aggCallback(err);
            });
        }
    ], function(err, results) {
        if (err) {
            return next(err);
        }

        res.status(202).json({status: 202, content: 'Created'});
    });
});

/**
 * @api {post} /object/update Update
 * @apiDescription Updates an existing object. The object is not updated immediately.
 * @apiName ObjectUpdate
 * @apiGroup Object
 * @apiVersion 0.4.0
 *
 * @apiHeader {String} Content-type application/json
 * @apiHeader {String} Authorization
                       The authorization token obtained in the login endpoint.
                       Should have the format: <i>Bearer $TOKEN</i>
 * @apiHeader {String} X-BLGREQ-APPID Custom header which contains the application ID
 * @apiHeader {String} X-BLGREQ-SIGN Custom header containing the SHA256-ed API key of the application
 *
 * @apiParam {Number} id ID of the object (optional)
 * @apiParam {Number} context Context of the object
 * @apiParam {String} model The type of object to subscribe to
 * @apiParam {Array} patch An array of patches that modifies the object
 *
 * @apiExample {json} Client Request
 * {
 *         "model": "comment",
 *         "id": 1,
 *         "context": 1,
 *         "patches": [
 *             {
 *                 "op": "replace",
 *                 "path": "comment/1/text",
 *                 "value": "some edited text"
 *             },
 *             ...
 *         ],
 * }
 *
 * @apiSuccessExample {json} Success Response
 *     {
 *         "status": 202,
 *         "content": "Created"
 *     }
 */
router.post('/update', function(req, res, next) {
    var modifiedMicrotime = microtime.now();
    var patch = req.body.patches;
    var appId = req._telepat.applicationId;

    if (!Array.isArray(req.body.patches)) {
        return next(new Models.TelepatError(Models.TelepatError.errors.InvalidFieldValue,
            ['"patches" is not an array']));
    } else if (req.body.patches.length == 0) {
        return next(new Models.TelepatError(Models.TelepatError.errors.InvalidFieldValue,
            ['"patches" array is empty']));
    }

    var appSchema = Models.Application.loadedAppModels[appId].schema;

    var obj = {
        op: 'update',
        patches: patch,
        application_id: appId,
        timestamp: modifiedMicrotime
    };

    if (!(req.user && req.user.isAdmin)) {
        for(var p in patch) {
            var objectMdl = patch[p].path.split('/')[0];

            if (patch[p].path.split('/')[2] == 'user_id') {
                return next(Models.TelepatError(Models.TelepatError.errors.InvalidPatch, ['"user_id" cannot be modified"']));
            }
            if ((appSchema[objectMdl]['write_acl'] & 8) && !req.user) {
                return next(new Models.TelepatError(Models.TelepatError.errors.OperationNotAllowed));
            } else if (appSchema[objectMdl]['write_acl'] & 8) {
                patch[p].user_id = req.user.id;
            }
        }
    }

    async.series([
        function(aggCallback) {
            app.messagingClient.send([JSON.stringify(obj)], 'aggregation', function(err) {
                if (err){
                    err = new Models.TelepatError(Models.TelepatError.errors.ServerFailure, [err.message]);
                }
                aggCallback(err);
            });
        }
    ], function(err) {
        if (err) {
            return next(err);
        }

        res.status(202).json({status: 202, content: 'Updated'});
    });
});

/**
 * @api {delete} /object/delete Delete
 * @apiDescription Deletes an object. The object is not immediately deleted.
 * @apiName ObjectDelete
 * @apiGroup Object
 * @apiVersion 0.4.0
 *
 * @apiHeader {String} Content-type application/json
 * @apiHeader {String} Authorization
                       The authorization token obtained in the login endpoint.
                       Should have the format: <i>Bearer $TOKEN</i>
 * @apiHeader {String} X-BLGREQ-APPID Custom header which contains the application ID
 * @apiHeader {String} X-BLGREQ-SIGN Custom header containing the SHA256-ed API key of the application
 *
 * @apiParam {Number} id ID of the object (optional)
 * @apiParam {Number} context Context of the object
 * @apiParam {String} model The type of object to delete
 *
 * @apiExample {json} Client Request
 * {
 *         "model": "comment",
 *         "id": 1,
 *         "context": 1
 * }
 *
 * @apiSuccessExample {json} Success Response
 *     {
 *         "status": 202,
 *         "content": "Deleted"
 *     }
 *
 */
router.delete('/delete', function(req, res, next) {
    var modifiedMicrotime = microtime.now();
    var id = req.body.id;
    var mdl = req.body.model;
    var appId = req._telepat.applicationId;

    if (!id)
        return next(new Models.TelepatError(Models.TelepatError.errors.MissingRequiredField, ['id']));

    var appSchema = Models.Application.loadedAppModels[appId].schema;

    var obj = {
        op: 'delete',
        object: {
            model: mdl,
            id: id
        },
        application_id: appId,
        timestamp: modifiedMicrotime
    };

    if (!(req.user && req.user.isAdmin)) {
        if ((appSchema[mdl]['write_acl'] & 8) && !req.user) {
            return next(new Models.TelepatError(Models.TelepatError.errors.OperationNotAllowed));
        } else if (appSchema[mdl]['write_acl'] & 8) {
            obj.user_id = req.user.id;
        }
    }

    async.series([
        function(aggCallback) {
            app.messagingClient.send([JSON.stringify(obj)], 'aggregation', aggCallback);
        }
    ], function(err) {
        if (err) return next(err);

        res.status(202).json({status: 202, content: 'Deleted'});
    });
});

/**
 * @api {post} /object/count Count
 * @apiDescription Gets the object count of a certain filter/subscription
 * @apiName ObjectCount
 * @apiGroup Object
 * @apiVersion 0.4.0
 *
 * @apiHeader {String} Content-type application/json
 * @apiHeader {String} Authorization
                       The authorization token obtained in the login endpoint.
                       Should have the format: <i>Bearer $TOKEN</i>
 * @apiHeader {String} X-BLGREQ-APPID Custom header which contains the application ID
 * @apiHeader {String} X-BLGREQ-SIGN Custom header containing the SHA256-ed API key of the application
 *
 * @apiParam {Object} channel The object representing a channel
 * @apiParam {Object} filters Additional filters to the subscription channel
 *
 */
router.post('/count', function(req, res, next) {
    var appId = req._telepat.applicationId,
        channel = req.body.channel,
        mdl = channel.model;

    var aggregation = req.body.aggregation;

    var channelObject = new Models.Channel(appId);

    if (channel.model)
        channelObject.model(channel.model);

    if (channel.context)
        channelObject.context(channel.context);

    if (channel.parent)
        channelObject.parent(channel.parent);

    if (channel.user)
        channelObject.user(channel.user);

    if (req.body.filters)
        channelObject.setFilter(req.body.filters);

    var appSchema = Models.Application.loadedAppModels[appId].schema;

    if (mdl !== 'user' && appSchema[mdl]['read_acl'] & 8) {
        if (!req.user.id) {
            return next(Models.TelepatError(Models.TelepatError.errors.OperationNotAllowed));
        }

        addAuthorFilters(channelObject, appSchema, mdl, req.user.id);
    }

    if (!channelObject.isValid()) {
        return next(new Models.TelepatError(Models.TelepatError.errors.InvalidChannel));
    }

    Models.Model.modelCountByChannel(channelObject, aggregation, function(err, result) {
        if (err) return next(err);

        res.status(200).json({status: 200, content: result});
    });
});

function addAuthorFilters(channelObject, appSchema, mdl, userId) {
    if (channelObject.filter) {
        if (!channelObject.filter.and && !channelObject.filter.or) {
            channelObject.filter.and = [];
        }

        var operator = Object.keys(channelObject.filter || {})[0] || null;

        //we need to wrap this in an and in order for the author filter to work correctly
        if (operator == 'or') {
            var filterClone = clone(channelObject.filter);
            channelObject.filter = {and: [filterClone]};
        }
    } else {
        channelObject.filter = {and: []};
    }

    var idx = channelObject.filter.and.push({or: []}) - 1;

    var authorFields = (appSchema[mdl].author_fields || []).concat(['user_id']);

    authorFields.forEach(function(field) {
        var isFilter = {is: {}};
        isFilter.is[field] = userId;
        channelObject.filter.and[idx].or.push(isFilter);
    });
}

module.exports = router;