API Docs for:
Show:

File: models/server/Ticket.js

"use strict";
var fs = require("fs");
var _ = require("lodash");
var Promise = require("bluebird");
var crypto = require("crypto");

var config = require("app/config");
var Base = require("./Base");
var Comment = require("./Comment");
var Tag = require("./Tag");
var RelatedUser = require("./RelatedUser");
var Visibility = require("./Visibility");
var Follower = require("./Follower");
var Handler = require("./Handler");
var Device = require("./Device");
var User = require("./User");
var Notification = require("./Notification");
var Title = require("./Title");

var debugEmail = require("debug")("app:email");


/**
 * Knex query helpers
 *
 * @namespace models.server
 * @private
 * @class Ticket.queries
 */
var queries = {

    /**
     * Get updates that are not soft deleted
     *
     * Example:
     *
     *      ticket.tags().query(queries.notSoftDeleted).fetch();
     *
     * @method notSoftDeleted
     */
    notSoftDeleted: function(qb) {
        qb.where("deleted", "=",  "0");
    },

    softDeleted: function(qb) {
        qb.where("deleted", "!=",  "0");
    }


};



/**
 * Server Ticket model
 *
 * @namespace models.server
 * @extends models.server.Base
 * @class Ticket
 */
var Ticket = Base.extend({
    tableName: "tickets",

    defaults: function() {
        return {
            createdAt: new Date(),
            updatedAt: new Date(),
            emailSecret: Ticket.generateSecret()
        };
    },

    /**
     * nodemailer email transport object
     *
     * https://github.com/andris9/Nodemailer
     *
     * @private
     * @property _mailTransport
     * @type Object
     */
    _mailTransport: config.mailTransport,

    /**
     *
     * @method initialize
     * @param attrs Model attributes
     * @param [options.mailTransport] custom mail transport
     */
    initialize: function(attrs, options) {
        var self = this;
        // tests can override the mail transport
        if (options && options.mailTransport) {
            this._mailTransport = options.mailTransport;
        }

        /**
         * See nodemailer module docs
         * https://github.com/andris9/Nodemailer#sending-mail
         *
         * @method sendMail
         * @param {Object} options
         * @return {Bluebird.Promise}
         * */
        this.sendMail = function(options) {
            return new Promise(function(resolve, reject){
                self._mailTransport.sendMail(options, function(err, res) {
                    if (err) return reject(err);
                    resolve(res);
                });
            });
        };

        this.on("created", this._setInitialTicketState.bind(this));
        this.on("update", this.onTicketUpdate.bind(this));

    },

    _setInitialTicketState: function (ticket) {
        return ticket.createdBy().fetch().then(function(user) {
            return Promise.join(
                ticket.setStatus("open", user, { force: true }),
                ticket.addHandler(user, user),
                ticket.addVisibility(user.getOrganisationAdminVisibility(), user)
            );
        });
    },

    /**
     *
     * @method comments
     * @return {Bookshelf.Collection} Bookshelf.Collection of Ticket comment models
     */
    comments: function() {
        return this.hasMany(Comment, "ticketId");
    },

    /**
     * Get unread comments for given user. Use `options.byEmail` to get those
     * comments that have not been sent out as email.
     *
     * @method unreadComments
     * @param {models.server.User|Number} user or user id
     * @param {options} [options]
     * @param {options} [options.byEmail] get comments that are not emailed
     * @return {Bookshelf.Collection}
     */
    unreadComments: function(user, options){
        var attr = "readAt";
        if (options && options.byEmail) {
            attr = "emailSentAt";
        }

        return this.comments().query(function(q) {
            q.join("notifications", function() {
                this.on("notifications.ticketId", "=", "comments.ticketId");
                this.on("notifications." + attr, "<", "comments.createdAt");
            });
            q.where({ "notifications.targetId": Base.toId(user) });
        });
    },

    /**
     *
     * @method titles
     * @return {Bookshelf.Collection} Bookshelf.Collection of Ticket title models
     */
    titles: function() {
        return this.hasMany(Title, "ticketId");
    },

    /**
     * @method visibilities
     * @return {models.server.Visibility}
     */
    visibilities: function() {
        return this.hasMany(Visibility, "ticketId");
    },

    notifications: function() {
        return this.hasMany(Notification, "ticketId");
    },

    /**
     * Add visibility to the ticket. If the visibility already exists for the
     * ticket the existing visibility is returned.
     *
     * Visibility strings can be accessed for example from User#getVisibilities()
     *
     * @method addVisibility
     * @param {String} visibility Visibility string
     * @param {models.server.User|Number} addedBy User model or id of user who adds the visibility
     * @param {String} [comment] Optional comment for the visibility
     * @return {Bluebird.Promise} with models.server.Visibility
     */
    addVisibility: function(visibility, addedBy, comment) {
        var self = this;
        if (typeof visibility !== "string" || !visibility) {
            throw new Error("visibility must be a non empty string");
        }

        return Visibility.forge({
                ticketId: self.get("id"),
                entity: visibility,
            }).fetch()
            .then(function(existingVisibility) {
                if (existingVisibility) return existingVisibility;
                return Visibility.forge({
                    ticketId: self.get("id"),
                    entity: visibility,
                    comment: comment,
                    createdById: Base.toId(addedBy)
                }).save();
            });
    },

    /**
     * @method triggerUpdate
     * @param {models.server.Base} model Any ticket relation model
     * @return {Bluebird.Promise}
     */
    triggerUpdate: function(model) {
        return this.triggerThen("update", {
            model: model
        }).return(model);
    },


    /**
     * Add comment to the ticket
     *
     * @method addComment
     * @param {String} comment
     * @param {models.server.User|Number} user Creator of the tag
     * @param {Boolean} [opts]
     * @param {Boolean} [opts.silent=false] Set to true to disable update notifications
     * @return {Bluebird.Promise} with models.server.Comment
     */
    addComment: function(comment, user, opts) {
        var self = this;
        return Promise.join(
            Comment.forge({
                ticketId: self.get("id"),
                comment: comment,
                createdById: Base.toId(user)
            }).save(),
            self.addFollower(user, user)
        ).then(function(comment) {
            return self.markAsRead(user).return(comment);
        })
        .spread(function(comment) {
            if (opts && opts.silent === false) return comment;
            return self.triggerUpdate(comment);
        });
    },

    /**
     * Add title to the ticket
     *
     * @method addTitle
     * @param {String} title
     * @param {models.server.User|Number} user Creator of the tag
     * @param {Boolean} [opts]
     * @param {Boolean} [opts.silent=false] Set to true to disable update notifications
     * @return {Bluebird.Promise} with models.server.Title
     */
    addTitle: function(title, user, opts) {
        return Title.forge({
            ticketId: this.get("id"),
            title: title,
            createdById: Base.toId(user)
        })
        .save()
        .bind(this)
        .then(function(title) {
            if (opts && opts.silent === false) return title;
            return this.triggerUpdate(title);
        });
    },

    /**
     * Add Tag to the ticket
     *
     * @method addTag
     * @param {String} tag
     * @param {models.server.User} user Creator of the tag
     * @return {Bluebird.Promise} with models.server.Tag
     */
    addTag: function(tag, user, options) {
        return Tag.forge({
            tag: tag,
            createdById: Base.toId(user),
            ticketId: this.get("id")
        }, options).save();
    },

    /**
     * Soft delete given tag
     *
     * @method removeTag
     * @param {String} tag
     * @param {models.server.User|Number} removedBy
     * @param {Object} [options]
     * @param {Boolean} [options.require=false] When true the promise is
     * rejected if the tag is missing
     * @return {Bluebird.Promise}
     */
    removeTag: function(tag, removedBy, options){
        var self = this;
        return Tag.collection()
            .query(queries.notSoftDeleted)
            .query(function(qb) {
                qb.where({
                    tag: tag,
                    ticketId: self.get("id"),
                });
            })
            .fetch()
            .then(function(tags) {
                if (options && options.require && tags.size() === 0) {
                    throw new Error("Cannot find tag '" + tag + "'");
                }
                return tags.invokeThen("softDelete", removedBy);
            });
    },

    /**
     * Get all tags for this ticket
     *
     * @method tags
     * @return {Bookshelf.Collection} Bookshelf.Collection of tag models
     */
    tags: function(){
        return this.hasMany(Tag, "ticketId");
    },

    /**
     * Set status of the ticket
     *
     * @method setStatus
     * @param {String} status
     * @param {models.server.User} user Creator of the status
     * @param {Boolean} [options.force=false] Set to true to skip manager validation
     * @return {Bluebird.Promise} models.server.Tag representing the status
     */
    setStatus: function(status, user, options){
        return this.addTag("status:" + status, user, options);
    },

    /**
     * Return collection containing only one models.server.Tag representing the
     * status of the ticket. When serialized as JSON this field will appear as
     * a simple string field. Example: `status: "open"`.
     *
     * @method status
     * @return {Bookshelf.Collection}
     */
    status: function() {
        return this.tags()
            .query(queries.notSoftDeleted)
            .query(function(qb) {
                qb.where("tag", "LIKE", "status:%");
            });
    },


    /**
     * Add follower to a ticket. Also adds a visibility for the follower.
     *
     * @method addFollower
     * @param {models.server.User|Number} follower User model or id of the follower
     * @param {models.server.User|Number} addedBy User model or id of the user who adds the handler
     * @return {Bluebird.Promise} with models.server.Handler
     */
    addFollower: function(follower, addedBy) {
        var self = this;

        var followerOp = Follower.forge({
            ticketId: self.get("id"),
            followedById: Base.toId(follower)
        })
        .query(queries.notSoftDeleted)
        .fetch()
        .then(function(followerRelation) {
            if (followerRelation) return followerRelation;
            return Follower.forge({
                ticketId: self.get("id"),
                followedById: Base.toId(follower),
                createdById: Base.toId(addedBy)
            }).save();
        });


        return Promise.join(
            followerOp,
            self.addVisibility(follower.getPersonalVisibility(), addedBy),
            self.ensureNotification(follower)
        ).spread(function(followerRelation) {
            return followerRelation;
        });

    },

    /**
     * Ensure notification relation for a user
     *
     * @method ensureNotification
     * @param {models.server.User|Number} user
     * @return {Bluebird.Promise} with models.server.Notification
     */
    ensureNotification: function(user) {
        var self = this;

        return Notification.forge({
            ticketId: this.get("id"),
            targetId: Base.toId(user)
        })
        .fetch()
        .then(function(notification) {
            if (notification) return notification;

            // if relation does not exists create one with zero date i.e. the
            // user has never read the ticket. This makes this ticket visible
            // in notifications api.
            return Notification.forge({
                ticketId: self.get("id"),
                targetId: Base.toId(user),
                readAt: new Date(0),
                emailSentAt: new Date(0),
            }).save();
        });
    },

    /**
     * Add handler to a ticket
     *
     * @method addHandler
     * @param {models.server.User|Number} handler User model or id of the handler
     * @param {models.server.User|Number} addedBy User model or id of the user who adds the handler
     * @return {Bluebird.Promise} with models.server.Handler
     */
    addHandler: function(handler, addedBy) {
        var self = this;

        if (Base.isModel(handler)) handler = Promise.cast(handler);
        else handler = User.byId(handler).fetch({ require: true });

        return handler.then(function(handler) {
            return Promise.join(

                Handler.fetchOrCreate({
                    ticketId: self.get("id"),
                    handler: Base.toId(handler)
                })
                .then(function(h) {
                    if (h.isNew()) {
                        h.set({
                            createdById: Base.toId(addedBy),
                        });
                        return h.save();
                    }
                }),

                self.addFollower(handler, addedBy)
            );
        })
        .spread(function(handler) {
            return handler;
        });

    },

    /**
     *
     * @method handlers
     * @return {Bookshelf.Collection} Bookshelf.Collection of Ticket handlers
     */
    handlers: function() {
        return this.hasMany(Handler, "ticketId");
    },

    /**
     * @method followers
     * @return {Bookshelf.Collection} Bookshelf.Collection of Ticket followers relations
     */
    followers: function(){
        return this.hasMany(Follower, "ticketId").query(queries.notSoftDeleted);
    },

    /**
     * Remove user from followers
     *
     * @method removeFollower
     * @param {models.server.User} user
     * @param {models.server.User} removedBy
     * @return {Bluebird.Promise}
     *
     */
    removeFollower: function(user, removedBy){
        return this.followers()
            .query(function(qb) {
                qb.where("followedById", "=", user.get("id"));
            })
            .fetch()
            .then(function(coll) {
                return coll.models;
            })
            .map(function(followerRelation) {
                return followerRelation.softDelete(removedBy);
            });
    },

    handlerUsers: function() {
        return this.belongsToMany(User, "handlers", "ticketId", "handler");
    },

    /**
     * Returns true if the user is handler for this ticket
     *
     * 'handlerUsers' relation must be loaded with 'withRelated' in fetch or
     * with Ticket#load("handlerUsers")
     *
     * @method isHandler
     * @param {models.server.User|Number}
     * @return {Boolean}
     */
    isHandler: function(user){
        if (!this.relations.handlerUsers) {
            throw new Error("'handlerUsers' relation not loaded");
        }

        return this.relations.handlerUsers.some(function(handlerUser) {
            return handlerUser.get("id") === Base.toId(user);
        });
    },

    /**
     * Get description ie. the first comment
     *
     * @method getDescription
     * @return {String}
     */
    getDescription: function() {
        if (!this.relations.comments) {
            throw new Error("'comments' relation not loaded");
        }

        if (this.relations.comments.length === 0) {
            throw new Error("No comments - no description!");
        }

        return _.min(this.relations.comments.models,  function(m) {
            return m.createdAt().getTime();
        }).get("comment");
    },

    /**
     * Add device relation
     *
     * @method addDevice
     * @param {models.server.Device|Number} device Device model or external id of the device
     * @param {models.server.User|Number} addedBy User model or id of user who adds the device
     * @return {Bluebird.Promise} with models.server.Device
     */
    addDevice: function(device, addedBy){
        return Device.forge({
                ticketId: this.get("id"),
                createdById: Base.toId(addedBy),
                hostname: Base.toAttr(device, "hostname")
            })
            .save();
    },

    /**
     *
     * @method devices
     * @return {Bookshelf.Collection} Bookshelf.Collection of Ticket devices
     */
    devices: function() {
        return this.hasMany(Device, "ticketId");
    },

    /**
     * Add related user to the ticket
     *
     * @method addRelatedUser
     * @param {models.server.User|Number} user User object or id for the relation
     * @param {models.server.User|Number} addedBy User model or id of user who adds the user
     * @return {Bluebird.Promise} with models.server.RelatedUser
     */
    addRelatedUser: function(user, addedBy){
        return RelatedUser.forge({
            ticketId: this.get("id"),
            createdById: Base.toId(addedBy),
            user: Base.toId(user)
        }).save();
    },

    /**
     *
     * @method relatedUsers
     * @return {Bookshelf.Collection} Bookshelf.Collection of Ticket related users
     */
    relatedUsers: function() {
        return this.hasMany(RelatedUser, "ticketId");
    },

    createdBy: function() {
        return this.belongsTo(User, "createdById");
    },

    /**
     * Mark ticket as read
     *
     * @method markAsRead
     * @param {models.server.User|Number} user User model or id of the user
     * @param {Object} [options]
     * @param {Object} [options.emailOnly] Set to true to mark as read from email
     * @return {Bluebird.Promise} with models.server.Notification
     */
    markAsRead: function(user, options) {
        var self = this;
        return Notification.fetchOrCreate({
            ticketId: self.get("id"),
            targetId: Base.toId(user)
        })
        .then(function(notification) {
            var now = new Date();

            notification.set({ emailSentAt: now });

            if (!options || !options.emailOnly) {
                notification.set({ readAt: now });
            }

            return notification.save();
        });
    },


    /**
     * Return the current title. Requires `titles` relation
     *
     * @method getCurrentTitle
     * @return {String}
     */
    getCurrentTitle: function() {
        if (!this.relations.titles) {
            throw new Error("titles relation not fetched. Use load or withRelated");
        }

        var titles = this.relations.titles.models;
        if (titles.length === 0) {
            throw new Error("Invalid Ticket model. No title!");
        }

        return _.max(titles, function(m) {
            return m.createdAt().getTime();
        }).get("title");
    },

    /**
     * @method getReplyEmailAddress
     * @return {String}
     */
    getReplyEmailAddress: function() {
        return [
            "tukipyynto", this.get("id"),
            "+", this.get("emailSecret"),
            "@", config.emailReplyDomain
        ].join("");
    },

    /**
     * @method sendBufferedEmailNotifications
     * @param {models.server.User|Number} user User model or id of the user
     * @return {Bluebird.Promise}
     */
    sendBufferedEmailNotifications: function(user){
        var self = this;
        var id = this.get("id");
        var email = user.getEmail();
        var title = this.getCurrentTitle();
        var url = "https://support.opinsys.fi/tickets/" + id;
        var subject = "Tukipyyntö \"" + title + "\" (" + id + ") on päivittynyt";

        return this.unreadComments(user, { byEmail: true }).fetch({
            withRelated: ["createdBy"]
        })
        .then(function(coll) {

            var lastComment = coll.max(function(comment) {
                return comment.createdAt();
            });

            var age = Date.now() - lastComment.createdAt().getTime();

            if (age < 1000 * 60 * 5) {
                debugEmail(
                    "Ticket %s has comments added within last 5min. Skipping email send for %s",
                    id, user.getDomainUsername()
                );
                return;
            }

            var comments = coll.map(function(comment) {
                return comment.toPlainText();
            }).join("\n----------------------------------------------\n");

            debugEmail(
                "Sending notification email about ticket %s for %s (%s)",
                id, user.getDomainUsername(), user.getEmail()
            );

            var from = "Opinsys tukipalvelu <"+ self.getReplyEmailAddress() +">";
            return self.sendMail({
                from: from,
                replyTo: from,
                to: email,
                subject: subject,
                text: renderEmailBufferedTemplate({
                    comments: comments,
                    url: url
                })
            })
            .then(function() {
                return self.markAsRead(user, { emailOnly: true });
            });
        });

    },

    /**
     * Update `updatedAt` column to current time. Should be called when ever a
     * update relation is added to the ticket
     *
     * @method updateTimestamp
     * @return {Bluebird.Promise}
     */
    updateTimestamp: function() {
        return this.set("updatedAt", new Date()).save();
    },

    onTicketUpdate: function(e){
        return this.updateTimestamp();
    },

    /**
     * @method getSocketIORoom
     * @return {String}
     */
    getSocketIORoom: function() {
        return "ticket:" + this.get("id");
    }

}, {

    /**
     * Generate secure random secret for email replies
     *
     * @method generateSecret
     * @return {String}
     */
    generateSecret: function() {
        return crypto.randomBytes(10).toString("hex");
    },


    /**
     * Shortcut for creating ticket with a title and description.
     *
     * @method create
     * @param {String} title
     * @param {String} description
     * @param {models.server.User|Number} createdBy
     * @param {Object} [options]
     * @param [options.mailTransport] custom mail transport
     * @return {Bluebird.Promise} with models.server.Ticket
     */
    create: function(title, description, createdBy, opts) {
        return this.forge({ createdById: Base.toId(createdBy) }, opts)
            .save()
            .then(function(ticket) {
                return Promise.join(
                    ticket.addTitle(title, createdBy),
                    ticket.addComment(description, createdBy)
                ).return(ticket);
            })
            .then(function(ticket) {
                return ticket.load(["comments", "comments.createdBy"]);
            });
    },

    /**
     * Fetch tickets by given visibilities.
     *
     * @static
     * @method byVisibilities
     * @param {Array} visibilities Array of visibility strings. Strings are in the
     * form of `organisation|school|user:<entity id>`.
     *
     *     Example: "school:2"
     *
     * @return {models.server.Base.Collection} with models.server.Ticket models
     */
    byVisibilities: function(visibilities) {
        return this.collection()
            .query(function(queryBuilder) {
                queryBuilder
                .join("visibilities", "tickets.id", "=", "visibilities.ticketId")
                .whereIn("visibilities.entity", visibilities)
                .whereNull("visibilities.deletedAt");
            });
    },


    /**
     * Query tickets with visibilities of the user
     *
     * @static
     * @method
     * @param {models.server.User} user
     */
    byUserVisibilities: function(user) {
        // Manager is not restricted by visibilities. Just return everything.
        if (user.isManager()) return this.collection();
        else return this.byVisibilities(user.getVisibilities());
    },

    /**
     * Fetch the ticket by id with visibilities of the user
     *
     * @static
     * @method fetchByIdConstrained
     * @param {models.server.User} user
     * @param {Number} ticketId Ticket id
     * @param {Object} options Options passed to Bookshelf fetchOne method
     * @return {Bluebird.Promise} With models.server.Ticket
     */
    fetchByIdConstrained: function(user, ticketId, opts) {
        return this.byUserVisibilities(user).query({
            where: { "tickets.id": Base.toId(ticketId) }
        }).fetchOne(_.extend({ require: true }, opts));
    },

    /**
     * Return collection of tickets that have unread comments by the user
     *
     * @static
     * @method withUnreadComments
     * @param {models.client.User|Number} user
     * @param {Object} [options]
     * @param {Object} [options.byEmail=false] Get tickets by unsent email notifications
     * @return {Bookshelf.Collection} of models.server.Ticket
     */
    withUnreadComments: function(user, options) {
        var attr = "readAt";
        if (options && options.byEmail) {
            attr = "emailSentAt";
        }

        return this.byUserVisibilities(user)
            .query(function(qb) {
                qb
                .distinct()
                .join("followers", function() {
                    this.on("tickets.id", "=", "followers.ticketId");
                })
                .join("notifications", function() {
                    this.on("tickets.id", "=", "notifications.ticketId");
                })
                .join("comments", function() {
                    this.on("tickets.id", "=", "comments.ticketId");
                    this.on("notifications." + attr, "<", "comments.createdAt");
                })
                .whereNull("followers.deletedAt")
                .where({
                    "followers.deleted": 0,
                    "followers.followedById": Base.toId(user),
                    "notifications.targetId": Base.toId(user),
                });
            });
    },
});


/**
 * Render buffered email update
 *
 * @private
 * @static
 * @method renderEmailBufferedTemplate
 * @param {Object} context
 * @return {String}
 */
var renderEmailBufferedTemplate = _.template(
    fs.readFileSync(__dirname + "/email_buffered_template.txt").toString()
);



module.exports = Ticket;