diff --git a/app/assets/javascripts/app/controllers/_plugin/keyboard_shortcuts.coffee b/app/assets/javascripts/app/controllers/_plugin/keyboard_shortcuts.coffee index fa3d487a9..e29fb6dcc 100644 --- a/app/assets/javascripts/app/controllers/_plugin/keyboard_shortcuts.coffee +++ b/app/assets/javascripts/app/controllers/_plugin/keyboard_shortcuts.coffee @@ -399,16 +399,22 @@ App.Config.set( shortcuts: [ { key: '::' - hotkeys: false, + hotkeys: false description: 'Inserts Text module' globalEvent: 'richtext-insert-text-module' } { key: '??' - hotkeys: false, + hotkeys: false description: 'Inserts Knowledge Base answer' globalEvent: 'richtext-insert-kb-answer' } + { + key: '@@' + hotkeys: false + description: 'Inserts a mention for a user' + globalEvent: 'richtext-insert-mention-user' + } ] } diff --git a/app/assets/javascripts/app/controllers/_profile/notification.coffee b/app/assets/javascripts/app/controllers/_profile/notification.coffee index 03cf17993..75836bae5 100644 --- a/app/assets/javascripts/app/controllers/_profile/notification.coffee +++ b/app/assets/javascripts/app/controllers/_profile/notification.coffee @@ -50,18 +50,19 @@ class ProfileNotification extends App.ControllerSubContent render: => - # matrix + matrix = + create: + name: 'New Ticket' + update: + name: 'Ticket update' + reminder_reached: + name: 'Ticket reminder reached' + escalation: + name: 'Ticket escalation' + config = group_ids: [] - matrix: - create: - name: 'New Ticket' - update: - name: 'Ticket update' - reminder_reached: - name: 'Ticket reminder reached' - escalation: - name: 'Ticket escalation' + matrix: {} user_config = @Session.get('preferences').notification_config if user_config @@ -89,6 +90,7 @@ class ProfileNotification extends App.ControllerSubContent sound.selected = sound.file is App.OnlineNotification.soundFile() ? true : false @html App.view('profile/notification') + matrix: matrix groups: groups config: config sounds: @sounds @@ -102,6 +104,7 @@ class ProfileNotification extends App.ControllerSubContent params.notification_config = {} form_params = @formParam(e.target) + for key, value of form_params if key is 'group_ids' if typeof value isnt 'object' @@ -118,11 +121,12 @@ class ProfileNotification extends App.ControllerSubContent if !params.notification_config[area[0]][area[1]] params.notification_config[area[0]][area[1]] = {} if !params.notification_config[area[0]][area[1]][area[2]] - params.notification_config[area[0]][area[1]][area[2]] = { - owned_by_me: false - owned_by_nobody: false - no: false - } + params.notification_config[area[0]][area[1]][area[2]] = {} + + for recipientKey in ['owned_by_me', 'owned_by_nobody', 'mentioned', 'no'] + if params.notification_config[area[0]][area[1]][area[2]][recipientKey] == undefined + params.notification_config[area[0]][area[1]][area[2]][recipientKey] = false + params.notification_config[area[0]][area[1]][area[2]][area[3]] = value if area[2] is 'channel' if !params.notification_config[area[0]] diff --git a/app/assets/javascripts/app/controllers/_ui_element/ticket_selector.coffee b/app/assets/javascripts/app/controllers/_ui_element/ticket_selector.coffee index ab430f335..591191d0d 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/ticket_selector.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/ticket_selector.coffee @@ -118,6 +118,15 @@ class App.UiElement.ticket_selector translate: true operator: ['is', 'is not'] + elements['ticket.mention_user_ids'] = + name: 'mention_user_ids' + display: 'Mention' + tag: 'autocompletion_ajax' + relation: 'User' + null: false + translate: true + operator: ['is', 'is not'] + [defaults, groups, elements] @rowContainer: (groups, elements, attribute) -> diff --git a/app/assets/javascripts/app/controllers/ticket_zoom.coffee b/app/assets/javascripts/app/controllers/ticket_zoom.coffee index a3b2797bb..fdb1ff1c5 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom.coffee @@ -158,6 +158,10 @@ class App.TicketZoom extends App.Controller else @ticketUpdatedAtLastCall = newTicketRaw.updated_at + # make sure to load assets for mentions if cache is not up to date + if !_.isEqual(data.mentions, @mentions) + loadAssets = true + # load assets if loadAssets @@ -180,6 +184,9 @@ class App.TicketZoom extends App.Controller # remember tags @tags = data.tags + # remember mentions + @mentions = data.mentions + App.Collection.loadAssets(data.assets, targetModel: 'Ticket') # get ticket @@ -515,6 +522,7 @@ class App.TicketZoom extends App.Controller formMeta: @formMeta markForm: @markForm tags: @tags + mentions: @mentions links: @links ) @@ -550,8 +558,9 @@ class App.TicketZoom extends App.Controller if @sidebarWidget @sidebarWidget.reload( - tags: @tags - links: @links + tags: @tags + mentions: @mentions + links: @links ) if !@initDone @@ -891,8 +900,15 @@ class App.TicketZoom extends App.Controller return # verify if time accounting is active for ticket - selector = ticket.clone() - selector.tags = @tags + selector = ticket.clone() + selector.tags = @tags + # always have a empy value to make sure that the condition gets checked + selector.mentions = [''] + for id in @mentions + mention = App.Mention.find(id) + continue if !mention + selector.mentions.push(mention.user_id) + time_accounting_selector = @Config.get('time_accounting_selector') if !App.Ticket.selector(selector, time_accounting_selector['condition']) @submitPost(e, ticket, macro) diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.coffee index 2a626f11f..34f4c147f 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.coffee @@ -34,6 +34,7 @@ class App.TicketZoomSidebar extends App.ControllerObserver formMeta: @formMeta markForm: @markForm tags: @tags + mentions: @mentions links: @links ) else @@ -43,6 +44,7 @@ class App.TicketZoomSidebar extends App.ControllerObserver formMeta: @formMeta markForm: @markForm tags: @tags + mentions: @mentions links: @links ) @sidebarItems.push @sidebarBackends[key] diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee index 2fa7bee5d..6d585dce8 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee @@ -102,6 +102,8 @@ class SidebarTicket extends App.Controller if @tagWidget if args.tags @tagWidget.reload(args.tags) + if args.mentions + @mentionWidget.reload(args.mentions) if args.tagAdd @tagWidget.add(args.tagAdd, args.source) if args.tagRemove @@ -128,6 +130,11 @@ class SidebarTicket extends App.Controller ) if @ticket.currentView() is 'agent' + @mentionWidget = new App.WidgetMention( + el: localEl.filter('.mentions') + object: @ticket + mentions: @mentions + ) @tagWidget = new App.WidgetTag( el: localEl.filter('.tags') object_type: 'Ticket' diff --git a/app/assets/javascripts/app/controllers/widget/mention.coffee b/app/assets/javascripts/app/controllers/widget/mention.coffee new file mode 100644 index 000000000..be4731dfb --- /dev/null +++ b/app/assets/javascripts/app/controllers/widget/mention.coffee @@ -0,0 +1,102 @@ +class App.WidgetMention extends App.Controller + events: + 'click .js-subscribe': 'subscribe' + 'click .js-unsubscribe': 'unsubscribe' + elements: + '.js-subscribe input[type=button]': 'subscribeButton' + '.js-unsubscribe input[type=button]': 'unsubscribeButton' + + constructor: -> + super + + @mentions = [] + App.Event.bind('Mention:create Mention:destroy', + (data) => + return if !data + return if data.mentionable_type isnt 'Ticket' + return if data.mentionable_id isnt @object.id + @fetch() + ) + @render() + + fetch: => + App.Mention.fetchMentionable( + 'Ticket', + @object.id, + (data) => + @mentions = data.record_ids + App.Collection.loadAssets(data.assets) + @render() + ) + + reload: (mentions) => + @mentions = mentions + @render() + + render: => + subscribed = false + mentions = [] + counter = 1 + for id in @mentions + mention = App.Mention.find(id) + continue if !mention + + user = App.User.find(mention.user_id) + continue if !user + + if mention.user_id is App.Session.get().id + subscribed = true + + # no break because we need to check if user is subscribed + continue if counter > 10 + + mention.avatar = user.avatar('30', '', '') + + mentions.push(mention) + counter++ + + @html App.view('widget/mention')( + subscribed: subscribed + mentions: mentions + ) + + subscribe: (e) => + e.preventDefault() + e.stopPropagation() + @subscribeButton.prop('readonly', true) + @subscribeButton.prop('disabled', true) + + mention = new App.Mention + mention.load( + mentionable_type: 'Ticket' + mentionable_id: @object.id + user_id: App.Session.get().id + ) + mention.save( + done: => + @subscribeButton.prop('readonly', false) + @subscribeButton.prop('disabled', false) + $(e.currentTarget).addClass('hidden') + $(e.currentTarget).closest('form').find('.js-unsubscribe').removeClass('hidden') + ) + + unsubscribe: (e) => + e.preventDefault() + e.stopPropagation() + @unsubscribeButton.prop('readonly', true) + @unsubscribeButton.prop('disabled', true) + + for id in @mentions + mention = App.Mention.find(id) + continue if !mention + continue if mention.user_id isnt App.Session.get().id + + mention.destroy( + done: => + @unsubscribeButton.prop('readonly', false) + @unsubscribeButton.prop('disabled', false) + $(e.currentTarget).addClass('hidden') + $(e.currentTarget).closest('form').find('.js-subscribe').removeClass('hidden') + ) + + break diff --git a/app/assets/javascripts/app/lib/base/jquery.textmodule.js b/app/assets/javascripts/app/lib/base/jquery.textmodule.js index 6ba675b69..a6c59ecbd 100644 --- a/app/assets/javascripts/app/lib/base/jquery.textmodule.js +++ b/app/assets/javascripts/app/lib/base/jquery.textmodule.js @@ -609,6 +609,82 @@ KbAnswer.trigger = '??' - Plugin.prototype.helpers = [Collection, KbAnswer] + function Mention() {} + + Mention.renderValue = function(textmodule, elem, callback) { + textmodule.emptyResultsContainer() + + var element = $('
  • ').text(App.i18n.translateInline('Please wait...')) + textmodule.appendResults(element) + + var form_id = textmodule.$element.closest('form').find('[name=form_id]').val() + + var user_id = $(elem).data('id') + var user = App.User.find(user_id) + if (!user) { + callback('') + } + + fqdn = App.Config.get('fqdn') + http_type = App.Config.get('http_type') + + $replace = $('', { + href: http_type + '://' + fqdn + '/' + user.uiUrl(), + 'data-mention-user-id': user_id, + text: user.firstname + ' ' + user.lastname + }) + + callback($replace[0].outerHTML) + } + + Mention.renderResults = function(textmodule, term) { + textmodule.emptyResultsContainer() + + if(!term) { + var element = $('
  • ').text(App.i18n.translateInline('Start typing to search for users...')) + textmodule.appendResults(element) + + return + } + + var element = $('
  • ').text(App.i18n.translateInline('Loading...')) + textmodule.appendResults(element) + + App.Delay.set(function() { + items = [] + + App.Mention.searchUser(term, function(data) { + textmodule.emptyResultsContainer() + + activeSet = false + $.each(data.user_ids, function(index, user_id) { + user = App.User.find(user_id) + if (!user) return true + if (!user.active) return true + + item = $('
  • ', { + 'data-id': user_id, + text: user.firstname + ' ' + user.lastname + ' <' + user.email + '>' + }) + if (!activeSet) { + activeSet = true + item.addClass('is-active') + } + + items.push(item) + }) + + if(items.length == 0) { + items.push($('
  • ').text(App.i18n.translateInline('No results found'))) + } + + textmodule.appendResults(items) + }) + }, 200, 'textmoduleMentionDelay', 'textmodule') + } + + Mention.trigger = '@@' + + Plugin.prototype.helpers = [Collection, KbAnswer, Mention] }(jQuery, window)); diff --git a/app/assets/javascripts/app/models/mention.coffee b/app/assets/javascripts/app/models/mention.coffee new file mode 100644 index 000000000..9fcf8c073 --- /dev/null +++ b/app/assets/javascripts/app/models/mention.coffee @@ -0,0 +1,41 @@ +class App.Mention extends App.Model + @configure 'Mention', 'mentionable_id', 'mentionable_type' + @extend Spine.Model.Ajax + @url: @apiPath + '/mentions' + @configure_attributes = [ + { name: 'user_id', display: 'User', tag: 'select', multiple: false, limit: 100, null: true, relation: 'User', width: '12%', edit: true }, + ] + + @fetchMentionable: (mentionable_type, mentionable_id, callback) -> + App.Ajax.request( + type: 'GET' + url: "#{@apiPath}/mentions" + data: + mentionable_type: mentionable_type + mentionable_id: mentionable_id + full: true + processData: true + success: (data, status, xhr) -> + if data.assets + App.Collection.loadAssets(data.assets, targetModel: @className) + callback(data) + ) + + @searchUser: (query, callback) -> + roles = App.Role.withPermissions('ticket.agent') + role_ids = roles.map (role) -> role.id + + App.Ajax.request( + type: 'GET' + url: "#{@apiPath}/users/search" + data: + limit: 10 + query: query + role_ids: role_ids + full: true + processData: true + success: (data, status, xhr) -> + if data.assets + App.Collection.loadAssets(data.assets, targetModel: @className) + callback(data) + ) diff --git a/app/assets/javascripts/app/models/role.coffee b/app/assets/javascripts/app/models/role.coffee index 13202c324..974ad85c3 100644 --- a/app/assets/javascripts/app/models/role.coffee +++ b/app/assets/javascripts/app/models/role.coffee @@ -35,3 +35,21 @@ class App.Role extends App.Model data['permissions'].push permission data + + @withPermissions: (permissions) -> + if !_.isArray(permissions) + permissions = [permissions] + + roles = [] + for role in App.Role.all() + found = false + for permission in permissions + id = App.Permission.findByAttribute('name', permission)?.id + continue if !id + continue if !_.contains(role.permission_ids, id) + found = true + break + continue if !found + roles.push(role) + roles + diff --git a/app/assets/javascripts/app/models/ticket.coffee b/app/assets/javascripts/app/models/ticket.coffee index db99eca84..c5b12856a 100644 --- a/app/assets/javascripts/app/models/ticket.coffee +++ b/app/assets/javascripts/app/models/ticket.coffee @@ -196,6 +196,15 @@ class App.Ticket extends App.Model objectName = 'ticket' attributeName = 'title' + if objectAttribute == 'ticket.mention_user_ids' + if condition['pre_condition'] isnt 'not_set' + if condition['pre_condition'] is 'specific' + condition.value = parseInt(condition.value) + if condition.operator is 'is' + condition.operator = 'contains one' + else if condition.operator is 'is not' + condition.operator = 'contains all not' + # for new articles there is no created_by_id so we set the current user # if no id is given if objectAttribute == 'article.created_by_id' && !ticket['article']['created_by_id'] diff --git a/app/assets/javascripts/app/views/profile/notification.jst.eco b/app/assets/javascripts/app/views/profile/notification.jst.eco index 188fff03c..d6483c06d 100644 --- a/app/assets/javascripts/app/views/profile/notification.jst.eco +++ b/app/assets/javascripts/app/views/profile/notification.jst.eco @@ -9,38 +9,47 @@ - <%- @T('My Tickets') %> - <%- @T('Not Assigned') %>* - <%- @T('All Tickets') %>* + <%- @T('My Tickets') %> + <%- @T('Not Assigned') %>* + <%- @T('Mentioned Tickets') %> + <%- @T('All Tickets') %>* <%- @T('Also notify via email') %> - <% if @config.matrix: %> - <% for key, value of @config.matrix: %> + <% if @matrix: %> + <% for key, value of @matrix: %> <%- @T(value.name) %> + <% criteria = @config.matrix[key]?.criteria %> + <% channel = @config.matrix[key]?.channel %> + + @@ -51,7 +60,7 @@ <% if @groups: %> -

    * <%- @T( 'Limit Groups' ) %>

    +

    * <%- @T('Limit Groups') %>

    diff --git a/app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket.jst.eco b/app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket.jst.eco index 289917212..7349c5014 100644 --- a/app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket.jst.eco +++ b/app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket.jst.eco @@ -5,3 +5,4 @@
    +
    diff --git a/app/assets/javascripts/app/views/widget/mention.jst.eco b/app/assets/javascripts/app/views/widget/mention.jst.eco new file mode 100644 index 000000000..5f3818166 --- /dev/null +++ b/app/assets/javascripts/app/views/widget/mention.jst.eco @@ -0,0 +1,14 @@ + + +
    + +
    +
    + +
    + +
    +<% for mention in @mentions: %> + <%- mention.avatar %> +<% end %> +
    diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index fdb56649b..17f1aa8cb 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -5613,6 +5613,10 @@ footer { cursor: help; } + .notification-icon-help { + opacity: .2; + } + .stat-label { color: #444a4f; @extend .u-textTruncate; diff --git a/app/controllers/mentions_controller.rb b/app/controllers/mentions_controller.rb new file mode 100644 index 000000000..b5e9dddc1 --- /dev/null +++ b/app/controllers/mentions_controller.rb @@ -0,0 +1,100 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class MentionsController < ApplicationController + prepend_before_action -> { authorize! } + prepend_before_action { authentication_check } + + # GET /api/v1/mentions + def list + list = Mention.where(condition).order(created_at: :desc) + + if response_full? + assets = {} + item_ids = [] + list.each do |item| + item_ids.push item.id + assets = item.assets(assets) + end + render json: { + record_ids: item_ids, + assets: assets, + }, status: :ok + return + end + + # return result + render json: { + mentions: list, + } + end + + # POST /api/v1/mentions + def create + success = Mention.create!( + mentionable: mentionable!, + user: current_user, + ) + if success + render json: success, status: :created + else + render json: success.errors, status: :unprocessable_entity + end + end + + # DELETE /api/v1/mentions + def destroy + success = Mention.find_by(user: current_user, id: params[:id]).destroy + if success + render json: success, status: :ok + else + render json: success.errors, status: :unprocessable_entity + end + end + + private + + def ensure_mentionable_type! + return if ['Ticket'].include?(params[:mentionable_type]) + + raise 'Invalid mentionable_type!' + end + + def mentionable! + ensure_mentionable_type! + + object = params[:mentionable_type].constantize.find(params[:mentionable_id]) + authorize!(object, :update?) + object + end + + def fill_condition_mentionable(condition) + condition[:mentionable_type] = params[:mentionable_type] + return if params[:mentionable_id].blank? + + condition[:mentionable_id] = params[:mentionable_id] + end + + def fill_condition_id(condition) + return if params[:id].blank? + + condition[:id] = params[:id] + end + + def fill_condition_user(condition) + return if params[:user_id].blank? + + condition[:user] = User.find(params[:user_id]) + end + + def condition + condition = {} + fill_condition_id(condition) + fill_condition_user(condition) + + return condition if params[:mentionable_type].blank? + + mentionable! + fill_condition_mentionable(condition) + condition + end +end diff --git a/app/controllers/tickets_controller.rb b/app/controllers/tickets_controller.rb index a28245ca5..5c6d2378a 100644 --- a/app/controllers/tickets_controller.rb +++ b/app/controllers/tickets_controller.rb @@ -163,6 +163,14 @@ class TicketsController < ApplicationController end end + # create mentions if given + if params[:mentions].present? + authorize!(Mention.new, :create?) + Array(params[:mentions]).each do |user_id| + Mention.where(mentionable: ticket, user_id: user_id).first_or_create(mentionable: ticket, user_id: user_id) + end + end + # create article if given if params[:article] article_create(ticket, params[:article]) @@ -702,6 +710,12 @@ class TicketsController < ApplicationController # get tags tags = ticket.tag_list + # get mentions + mentions = Mention.where(mentionable: ticket).order(created_at: :desc) + mentions.each do |mention| + assets = mention.assets(assets) + end + # return result { ticket_id: ticket.id, @@ -709,6 +723,7 @@ class TicketsController < ApplicationController assets: assets, links: links, tags: tags, + mentions: mentions.pluck(:id), form_meta: attributes_to_change[:form_meta], } end diff --git a/app/models/concerns/checks_client_notification.rb b/app/models/concerns/checks_client_notification.rb index 79ba45e3c..85862c6bc 100644 --- a/app/models/concerns/checks_client_notification.rb +++ b/app/models/concerns/checks_client_notification.rb @@ -17,12 +17,19 @@ module ChecksClientNotification { message: { event: "#{class_name}:#{event}", - data: { id: id, updated_at: updated_at } + data: notify_clients_data_attributes }, type: 'authenticated', } end + def notify_clients_data_attributes + { + id: id, + updated_at: updated_at + } + end + def notify_clients_send(data) return notify_clients_send_to(data[:message]) if client_notification_send_to.present? @@ -104,38 +111,28 @@ module ChecksClientNotification # methods defined here are going to extend the class, not the instance of it class_methods do -=begin - -serve method to ignore events - -class Model < ApplicationModel - include ChecksClientNotification - client_notification_events_ignored :create, :update, :touch -end - -=end - + # serve method to ignore events + # + # @example + # class Model < ApplicationModel + # include ChecksClientNotification + # client_notification_events_ignored :create, :update, :touch + # end def client_notification_events_ignored(*attributes) @client_notification_events_ignored ||= [] @client_notification_events_ignored |= attributes end -=begin - -serve method to define recipient user ids - -class Model < ApplicationModel - include ChecksClientNotification - client_notification_send_to :user_id -end - -=end - + # serve method to define recipient user ids + # + # @example + # class Model < ApplicationModel + # include ChecksClientNotification + # client_notification_send_to :user_id + # end def client_notification_send_to(*attributes) @client_notification_send_to ||= [] @client_notification_send_to |= attributes end - end - end diff --git a/app/models/concerns/has_history.rb b/app/models/concerns/has_history.rb index 69298e320..fcf7ac8ba 100644 --- a/app/models/concerns/has_history.rb +++ b/app/models/concerns/has_history.rb @@ -204,7 +204,7 @@ returns =end def history_get(fulldata = false) - relation_object = self.class.instance_variable_get(:@history_relation_object) || nil + relation_object = history_relation_object if !fulldata return History.list(self.class.name, self['id'], relation_object) @@ -213,12 +213,16 @@ returns # get related objects history = History.list(self.class.name, self['id'], relation_object, true) history[:list].each do |item| - record = item['object'].constantize.find(item['o_id']) + record = item['object'].constantize.lookup(id: item['o_id']) - history[:assets] = record.assets(history[:assets]) + if record.present? + history[:assets] = record.assets(history[:assets]) + end - if item['related_object'] - record = item['related_object'].constantize.find(item['related_o_id']) + next if !item['related_object'] + + record = item['related_object'].constantize.lookup(id: item['related_o_id']) + if record.present? history[:assets] = record.assets(history[:assets]) end end @@ -228,6 +232,10 @@ returns } end + def history_relation_object + @history_relation_object ||= self.class.instance_variable_get(:@history_relation_object) || [] + end + # methods defined here are going to extend the class, not the instance of it class_methods do =begin @@ -256,8 +264,9 @@ end =end - def history_relation_object(attribute) - @history_relation_object = attribute + def history_relation_object(*attributes) + @history_relation_object ||= [] + @history_relation_object |= attributes end end diff --git a/app/models/history.rb b/app/models/history.rb index 68efb33e5..ee12dce15 100644 --- a/app/models/history.rb +++ b/app/models/history.rb @@ -124,7 +124,7 @@ returns return all history entries of an object and it's related history objects - history_list = History.list('Ticket', 123, true) + history_list = History.list('Ticket', 123, 'Ticket::Article') returns @@ -137,7 +137,7 @@ returns return all history entries of an object and it's assets - history = History.list('Ticket', 123, nil, true) + history = History.list('Ticket', 123, nil, ['Ticket::Article']) returns @@ -148,16 +148,21 @@ returns =end - def self.list(requested_object, requested_object_id, related_history_object = nil, assets = nil) + def self.list(requested_object, requested_object_id, related_history_object = [], assets = nil) histories = History.where( history_object_id: object_lookup(requested_object).id, o_id: requested_object_id ) if related_history_object.present? + object_ids = [] + Array(related_history_object).each do |object| + object_ids << object_lookup(object).id + end + histories = histories.or( History.where( - history_object_id: object_lookup(related_history_object).id, + history_object_id: object_ids, related_o_id: requested_object_id ) ) diff --git a/app/models/mention.rb b/app/models/mention.rb new file mode 100644 index 000000000..4c47fd674 --- /dev/null +++ b/app/models/mention.rb @@ -0,0 +1,54 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class Mention < ApplicationModel + include ChecksClientNotification + include HasHistory + + include Mention::Assets + + after_create :update_mentionable + after_destroy :update_mentionable + + belongs_to :created_by, class_name: 'User' + belongs_to :updated_by, class_name: 'User' + belongs_to :user, class_name: 'User' + belongs_to :mentionable, polymorphic: true + + association_attributes_ignored :created_by, :updated_by + client_notification_events_ignored :update, :touch + + validates_with Mention::Validation + + def notify_clients_data_attributes + super.merge( + 'mentionable_id' => mentionable_id, + 'mentionable_type' => mentionable_type, + ) + end + + def history_log_attributes + { + related_o_id: mentionable_id, + related_history_object: mentionable_type, + } + end + + def history_destroy + history_log('removed', created_by_id) + end + + def self.duplicates(mentionable1, mentionable2) + Mention.joins(', mentions as mentionsb').where(' + mentions.user_id = mentionsb.user_id + AND mentions.mentionable_type = ? + AND mentions.mentionable_id = ? + AND mentionsb.mentionable_type = ? + AND mentionsb.mentionable_id = ? + ', mentionable1.class.to_s, mentionable1.id, mentionable2.class.to_s, mentionable2.id) + end + + def update_mentionable + mentionable.update(updated_by: updated_by) + mentionable.touch # rubocop:disable Rails/SkipsModelValidations + end +end diff --git a/app/models/mention/assets.rb b/app/models/mention/assets.rb new file mode 100644 index 000000000..7d9993fba --- /dev/null +++ b/app/models/mention/assets.rb @@ -0,0 +1,28 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class Mention + module Assets + extend ActiveSupport::Concern + + def assets_attributes(data) + app_model = self.class.to_app_model + + data[ app_model ] ||= {} + return data if data[ app_model ][ id ] + + data[ app_model ][ id ] = attributes_with_association_ids + + data + end + + def assets(data) + assets_attributes(data) + + if mentionable.present? + data = mentionable.assets(data) + end + + user.assets(data) + end + end +end diff --git a/app/models/mention/validation.rb b/app/models/mention/validation.rb new file mode 100644 index 000000000..3444fbcf5 --- /dev/null +++ b/app/models/mention/validation.rb @@ -0,0 +1,21 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +class Mention::Validation < ActiveModel::Validator + attr_reader :record + + def validate(record) + @record = record + check_user_permission + end + + private + + def check_user_permission + return if MentionPolicy.new(record.user, record).create? + + invalid_because(:user, 'has no ticket.agent permissions') + end + + def invalid_because(attribute, message) + record.errors.add attribute, message + end +end diff --git a/app/models/ticket.rb b/app/models/ticket.rb index 2e5727e54..006e97895 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -66,7 +66,7 @@ class Ticket < ApplicationModel :article_count, :preferences - history_relation_object 'Ticket::Article' + history_relation_object 'Ticket::Article', 'Mention' sanitized_html :note @@ -75,6 +75,7 @@ class Ticket < ApplicationModel has_many :articles, class_name: 'Ticket::Article', after_add: :cache_update, after_remove: :cache_update, dependent: :destroy, inverse_of: :ticket has_many :ticket_time_accounting, class_name: 'Ticket::TimeAccounting', dependent: :destroy, inverse_of: :ticket has_many :flags, class_name: 'Ticket::Flag', dependent: :destroy + has_many :mentions, as: :mentionable, dependent: :destroy belongs_to :state, class_name: 'Ticket::State', optional: true belongs_to :priority, class_name: 'Ticket::Priority', optional: true belongs_to :owner, class_name: 'User', optional: true @@ -84,7 +85,7 @@ class Ticket < ApplicationModel belongs_to :create_article_type, class_name: 'Ticket::Article::Type', optional: true belongs_to :create_article_sender, class_name: 'Ticket::Article::Sender', optional: true - association_attributes_ignored :flags + association_attributes_ignored :flags, :mentions self.inheritance_column = nil @@ -364,6 +365,10 @@ returns updated_by_id: data[:user_id], ) + # search for mention duplicates and destroy them before moving mentions + Mention.duplicates(self, target_ticket).destroy_all + Mention.where(mentionable: self).update_all(mentionable_id: target_ticket.id) # rubocop:disable Rails/SkipsModelValidations + # reassign links to the new ticket # rubocop:disable Rails/SkipsModelValidations ticket_source_id = Link::Object.find_by(name: 'Ticket').id @@ -574,17 +579,19 @@ condition example # get tables to join tables = '' - selectors.each_key do |attribute| - selector = attribute.split('.') - next if !selector[1] - next if selector[0] == 'ticket' - next if selector[0] == 'execution_time' - next if tables.include?(selector[0]) + selectors.each do |attribute, selector_raw| + attributes = attribute.split('.') + selector = selector_raw.stringify_keys + next if !attributes[1] + next if attributes[0] == 'execution_time' + next if tables.include?(attributes[0]) + next if attributes[0] == 'ticket' && attributes[1] != 'mention_user_ids' + next if attributes[0] == 'ticket' && attributes[1] == 'mention_user_ids' && selector['pre_condition'] == 'not_set' if query != '' query += ' AND ' end - case selector[0] + case attributes[0] when 'customer' tables += ', users customers' query += 'tickets.customer_id = customers.id' @@ -600,8 +607,13 @@ condition example when 'ticket_state' tables += ', ticket_states' query += 'tickets.state_id = ticket_states.id' + when 'ticket' + if attributes[1] == 'mention_user_ids' + tables += ', mentions' + query += "tickets.id = mentions.mentionable_id AND mentions.mentionable_type = 'Ticket'" + end else - raise "invalid selector #{attribute.inspect}->#{selector.inspect}" + raise "invalid selector #{attribute.inspect}->#{attributes.inspect}" end end @@ -662,6 +674,29 @@ condition example query += ' AND ' end + # because of no grouping support we select not_set by sub select for mentions + if attributes[0] == 'ticket' && attributes[1] == 'mention_user_ids' + if selector['pre_condition'] == 'not_set' + query += if selector['operator'] == 'is' + "(SELECT 1 FROM mentions mentions_sub WHERE mentions_sub.mentionable_type = 'Ticket' AND mentions_sub.mentionable_id = tickets.id) IS NULL" + else + "1 = (SELECT 1 FROM mentions mentions_sub WHERE mentions_sub.mentionable_type = 'Ticket' AND mentions_sub.mentionable_id = tickets.id)" + end + else + query += if selector['operator'] == 'is' + 'mentions.user_id IN (?)' + else + 'mentions.user_id NOT IN (?)' + end + if selector['pre_condition'] == 'current_user.id' + bind_params.push current_user_id + else + bind_params.push selector['value'] + end + end + next + end + if selector['operator'] == 'is' if selector['pre_condition'] == 'not_set' if attributes[1].match?(/^(created_by|updated_by|owner|customer|user)_id/) diff --git a/app/models/ticket/article.rb b/app/models/ticket/article.rb index 8afcc1c99..2d75686fb 100644 --- a/app/models/ticket/article.rb +++ b/app/models/ticket/article.rb @@ -31,7 +31,8 @@ class Ticket::Article < ApplicationModel belongs_to :updated_by, class_name: 'User', optional: true belongs_to :origin_by, class_name: 'User', optional: true - before_save :touch_ticket_if_needed + before_validation :check_mentions, on: :create + before_save :touch_ticket_if_needed before_create :check_subject, :check_body, :check_message_id_md5 before_update :check_subject, :check_body, :check_message_id_md5 after_destroy :store_delete, :update_time_units @@ -323,6 +324,26 @@ returns self.body = body[0, limit] end + def check_mentions + begin + mention_user_ids = Nokogiri::HTML(body).css('a[data-mention-user-id]').map do |link| + link['data-mention-user-id'] + end + rescue => e + Rails.logger.error "Can't parse body '#{body}' as HTML for extracting Mentions." + Rails.logger.error e + return + end + + return if mention_user_ids.blank? + raise "User #{updated_by_id} has no permission to mention other Users!" if !MentionPolicy.new(updated_by, Mention.new).create? + + user_ids = User.where(id: mention_user_ids).pluck(:id) + user_ids.each do |user_id| + Mention.where(mentionable: ticket, user_id: user_id).first_or_create(mentionable: ticket, user_id: user_id) + end + end + def history_log_attributes { related_o_id: self['ticket_id'], diff --git a/app/models/ticket/search_index.rb b/app/models/ticket/search_index.rb index 9aa0b0055..6aad0166a 100644 --- a/app/models/ticket/search_index.rb +++ b/app/models/ticket/search_index.rb @@ -8,10 +8,10 @@ module Ticket::SearchIndex # collect article data # add tags - tags = tag_list - if tags.present? - attributes[:tags] = tags - end + attributes['tags'] = tag_list + + # mentions + attributes['mention_user_ids'] = mentions.pluck(:user_id) # current payload size total_size_current = 0 diff --git a/app/models/transaction/notification.rb b/app/models/transaction/notification.rb index 4b4a813cb..8faba6ff7 100644 --- a/app/models/transaction/notification.rb +++ b/app/models/transaction/notification.rb @@ -50,9 +50,22 @@ class Transaction::Notification recipients_and_channels = [] recipients_reason = {} - # loop through all users + # loop through all group users possible_recipients = possible_recipients_of_group(ticket.group_id) + # loop through all mention users + mention_users = Mention.where(mentionable_type: @item[:object], mentionable_id: @item[:object_id]).map(&:user) + if mention_users.present? + + # only notify if read permission on group are given + mention_users.each do |mention_user| + next if !mention_user.group_access?(ticket.group_id, 'read') + + possible_recipients.push mention_user + recipients_reason[mention_user.id] = 'are mentioned' + end + end + # apply owner if ticket.owner_id != 1 possible_recipients.push ticket.owner diff --git a/app/models/user.rb b/app/models/user.rb index 865352202..8eb1421e0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -34,6 +34,7 @@ class User < ApplicationModel has_one :chat_agent_updated_by, class_name: 'Chat::Agent', foreign_key: :updated_by_id, dependent: :destroy, inverse_of: :updated_by has_many :chat_sessions, class_name: 'Chat::Session', dependent: :destroy has_many :karma_user, class_name: 'Karma::User', dependent: :destroy + has_many :mentions, dependent: :destroy has_many :karma_activity_logs, class_name: 'Karma::ActivityLog', dependent: :destroy has_many :cti_caller_ids, class_name: 'Cti::CallerId', dependent: :destroy has_many :customer_tickets, class_name: 'Ticket', foreign_key: :customer_id, dependent: :destroy, inverse_of: :customer @@ -54,7 +55,7 @@ class User < ApplicationModel store :preferences - association_attributes_ignored :online_notifications, :templates, :taskbars, :user_devices, :chat_sessions, :karma_activity_logs, :cti_caller_ids, :text_modules, :customer_tickets, :owner_tickets, :created_recent_views, :chat_agents, :data_privacy_tasks, :overviews + association_attributes_ignored :online_notifications, :templates, :taskbars, :user_devices, :chat_sessions, :karma_activity_logs, :cti_caller_ids, :text_modules, :customer_tickets, :owner_tickets, :created_recent_views, :chat_agents, :data_privacy_tasks, :overviews, :mentions activity_stream_permission 'admin.user' diff --git a/app/policies/controllers/mentions_controller_policy.rb b/app/policies/controllers/mentions_controller_policy.rb new file mode 100644 index 000000000..857b86fae --- /dev/null +++ b/app/policies/controllers/mentions_controller_policy.rb @@ -0,0 +1,3 @@ +class Controllers::MentionsControllerPolicy < Controllers::ApplicationControllerPolicy + default_permit!('ticket.agent') +end diff --git a/app/policies/mention_policy.rb b/app/policies/mention_policy.rb new file mode 100644 index 000000000..6a3d00da8 --- /dev/null +++ b/app/policies/mention_policy.rb @@ -0,0 +1,5 @@ +class MentionPolicy < ApplicationPolicy + def create? + user.permissions?('ticket.agent') + end +end diff --git a/app/views/mailer/ticket_update/cs.html.erb b/app/views/mailer/ticket_update/cs.html.erb index e5e41198b..e9341a135 100644 --- a/app/views/mailer/ticket_update/cs.html.erb +++ b/app/views/mailer/ticket_update/cs.html.erb @@ -25,5 +25,5 @@ Ticket (#{ticket.title}) byl aktualizován uživatelem "#{current_user.longna <% end %>
    - #{t('View this in Zammad')} + #{t('View this in Zammad')}
    diff --git a/app/views/mailer/ticket_update/de.html.erb b/app/views/mailer/ticket_update/de.html.erb index 77d907a6f..7b3e3527d 100644 --- a/app/views/mailer/ticket_update/de.html.erb +++ b/app/views/mailer/ticket_update/de.html.erb @@ -25,5 +25,5 @@ Ticket (#{ticket.title}) wurde von "#{current_user.longname}" aktualisier <% end %>
    - #{t('View this in Zammad')} + #{t('View this in Zammad')}
    diff --git a/app/views/mailer/ticket_update/en.html.erb b/app/views/mailer/ticket_update/en.html.erb index 0800630c1..c2e08b78d 100644 --- a/app/views/mailer/ticket_update/en.html.erb +++ b/app/views/mailer/ticket_update/en.html.erb @@ -25,5 +25,5 @@ Ticket (#{ticket.title}) has been updated by "#{current_user.longname}". <% end %>
    - #{t('View this in Zammad')} + #{t('View this in Zammad')}
    diff --git a/app/views/mailer/ticket_update/es.html.erb b/app/views/mailer/ticket_update/es.html.erb index e05ec4c07..a1053151b 100644 --- a/app/views/mailer/ticket_update/es.html.erb +++ b/app/views/mailer/ticket_update/es.html.erb @@ -25,5 +25,5 @@ Ticket (#{ticket.title}) ha sido actualizado por "#{current_user.longname}
    - #{t('View this in Zammad')} + #{t('View this in Zammad')}
    diff --git a/app/views/mailer/ticket_update/fr.html.erb b/app/views/mailer/ticket_update/fr.html.erb index 5aa78af18..d229325c6 100644 --- a/app/views/mailer/ticket_update/fr.html.erb +++ b/app/views/mailer/ticket_update/fr.html.erb @@ -25,5 +25,5 @@ Le ticket (#{ticket.title}) a été mis à jour par "#{current_user.longname} <% end %>
    - #{t('View this in Zammad')} + #{t('View this in Zammad')}
    diff --git a/app/views/mailer/ticket_update/it.html.erb b/app/views/mailer/ticket_update/it.html.erb index f642eb691..af3cb69d3 100644 --- a/app/views/mailer/ticket_update/it.html.erb +++ b/app/views/mailer/ticket_update/it.html.erb @@ -25,5 +25,5 @@ Il ticket (#{ticket.title}) è stato aggiornato da "#{current_user.longname}< <% end %>
    diff --git a/app/views/mailer/ticket_update/pt-br.html.erb b/app/views/mailer/ticket_update/pt-br.html.erb index 3bd7a1bd4..ac27cde67 100644 --- a/app/views/mailer/ticket_update/pt-br.html.erb +++ b/app/views/mailer/ticket_update/pt-br.html.erb @@ -25,5 +25,5 @@ O chamado (#{ticket.title}) foi atualizado por "#{current_user.longname}" <% end %>
    diff --git a/app/views/mailer/ticket_update/zh-cn.html.erb b/app/views/mailer/ticket_update/zh-cn.html.erb index 38589d4c7..5d587436a 100644 --- a/app/views/mailer/ticket_update/zh-cn.html.erb +++ b/app/views/mailer/ticket_update/zh-cn.html.erb @@ -25,5 +25,5 @@ <% end %>
    diff --git a/app/views/mailer/ticket_update/zh-tw.html.erb b/app/views/mailer/ticket_update/zh-tw.html.erb index 5f0d7540c..fa8841ddc 100644 --- a/app/views/mailer/ticket_update/zh-tw.html.erb +++ b/app/views/mailer/ticket_update/zh-tw.html.erb @@ -25,5 +25,5 @@ <% end %>
    diff --git a/config/application.rb b/config/application.rb index 02eb0083f..f0a57a17d 100644 --- a/config/application.rb +++ b/config/application.rb @@ -49,6 +49,7 @@ module Zammad criteria: { owned_by_me: true, owned_by_nobody: true, + mentioned: true, no: false, }, channel: { @@ -60,6 +61,7 @@ module Zammad criteria: { owned_by_me: true, owned_by_nobody: true, + mentioned: true, no: false, }, channel: { @@ -71,6 +73,7 @@ module Zammad criteria: { owned_by_me: true, owned_by_nobody: false, + mentioned: false, no: false, }, channel: { @@ -82,6 +85,7 @@ module Zammad criteria: { owned_by_me: true, owned_by_nobody: false, + mentioned: false, no: false, }, channel: { diff --git a/config/initializers/html_sanitizer.rb b/config/initializers/html_sanitizer.rb index 6c8c2252f..19ca550d2 100644 --- a/config/initializers/html_sanitizer.rb +++ b/config/initializers/html_sanitizer.rb @@ -26,7 +26,7 @@ Rails.application.config.html_sanitizer_tags_whitelist = %w[ # attributes allowed for tags Rails.application.config.html_sanitizer_attributes_whitelist = { :all => %w[class dir lang title translate data-signature data-signature-id], - 'a' => %w[href hreflang name rel data-target-id data-target-type], + 'a' => %w[href hreflang name rel data-target-id data-target-type data-mention-user-id], 'abbr' => %w[title], 'blockquote' => %w[type cite], 'col' => %w[span width], diff --git a/config/routes/mention.rb b/config/routes/mention.rb new file mode 100644 index 000000000..cf06f103b --- /dev/null +++ b/config/routes/mention.rb @@ -0,0 +1,7 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/mentions', to: 'mentions#list', via: :get + match api_path + '/mentions', to: 'mentions#create', via: :post + match api_path + '/mentions/:id', to: 'mentions#destroy', via: :delete +end diff --git a/db/migrate/20120101000001_create_base.rb b/db/migrate/20120101000001_create_base.rb index 79aaa5330..32b881e93 100644 --- a/db/migrate/20120101000001_create_base.rb +++ b/db/migrate/20120101000001_create_base.rb @@ -747,5 +747,17 @@ class CreateBase < ActiveRecord::Migration[4.2] t.timestamps limit: 3, null: false end add_index :data_privacy_tasks, [:state] + + create_table :mentions do |t| + t.references :mentionable, polymorphic: true, null: false + t.column :user_id, :integer, null: false + t.column :updated_by_id, :integer, null: false + t.column :created_by_id, :integer, null: false + t.timestamps limit: 3, null: false + end + add_index :mentions, %i[mentionable_id mentionable_type user_id], unique: true, name: 'index_mentions_mentionable_user' + add_foreign_key :mentions, :users, column: :created_by_id + add_foreign_key :mentions, :users, column: :updated_by_id + add_foreign_key :mentions, :users, column: :user_id end end diff --git a/db/migrate/20201110000001_mention_init.rb b/db/migrate/20201110000001_mention_init.rb new file mode 100644 index 000000000..95e2552dc --- /dev/null +++ b/db/migrate/20201110000001_mention_init.rb @@ -0,0 +1,66 @@ +class MentionInit < ActiveRecord::Migration[5.2] + def change + + # return if it's a new setup + return if !Setting.exists?(name: 'system_init_done') + + create_table :mentions do |t| + t.references :mentionable, polymorphic: true, null: false + t.column :user_id, :integer, null: false + t.column :updated_by_id, :integer, null: false + t.column :created_by_id, :integer, null: false + t.timestamps limit: 3, null: false + end + add_index :mentions, %i[mentionable_id mentionable_type user_id], unique: true, name: 'index_mentions_mentionable_user' + add_foreign_key :mentions, :users, column: :created_by_id + add_foreign_key :mentions, :users, column: :updated_by_id + add_foreign_key :mentions, :users, column: :user_id + + Mention.reset_column_information + create_overview + update_user_matrix + end + + def create_overview + Overview.create_if_not_exists( + name: 'My mentioned Tickets', + link: 'my_mentioned_tickets', + prio: 1025, + role_ids: Role.with_permissions('ticket.agent').pluck(:id), + condition: { 'ticket.mention_user_ids'=>{ 'operator' => 'is', 'pre_condition' => 'current_user.id', 'value' => '', 'value_completion' => '' } }, + order: { + by: 'created_at', + direction: 'ASC', + }, + view: { + d: %w[title customer group created_at], + s: %w[title customer group created_at], + m: %w[number title customer group created_at], + view_mode_default: 's', + }, + created_by_id: 1, + updated_by_id: 1, + ) + end + + def update_user_matrix + User.with_permissions('ticket.agent').each do |user| + next if user.preferences.blank? + next if user.preferences['notification_config'].blank? + next if user.preferences['notification_config']['matrix'].blank? + + update_user_matrix_by_user(user) + end + end + + def update_user_matrix_by_user(user) + %w[create update].each do |type| + user.preferences['notification_config']['matrix'][type]['criteria']['mentioned'] = true + end + + %w[reminder_reached escalation].each do |type| + user.preferences['notification_config']['matrix'][type]['criteria']['mentioned'] = false + end + user.save! + end +end diff --git a/db/seeds/overviews.rb b/db/seeds/overviews.rb index 71630793a..9d551c035 100644 --- a/db/seeds/overviews.rb +++ b/db/seeds/overviews.rb @@ -85,6 +85,24 @@ Overview.create_if_not_exists( }, ) +Overview.create_if_not_exists( + name: 'My mentioned Tickets', + link: 'my_mentioned_tickets', + prio: 1025, + role_ids: [overview_role.id], + condition: { 'ticket.mention_user_ids'=>{ 'operator' => 'is', 'pre_condition' => 'current_user.id', 'value' => '', 'value_completion' => '' } }, + order: { + by: 'created_at', + direction: 'ASC', + }, + view: { + d: %w[title customer group created_at], + s: %w[title customer group created_at], + m: %w[number title customer group created_at], + view_mode_default: 's', + }, +) + Overview.create_if_not_exists( name: 'Open', link: 'all_open', diff --git a/lib/html_sanitizer.rb b/lib/html_sanitizer.rb index 14fcc6797..53d335cbc 100644 --- a/lib/html_sanitizer.rb +++ b/lib/html_sanitizer.rb @@ -13,7 +13,9 @@ satinize html string based on whiltelist def self.strict(string, external = false, timeout: true) Timeout.timeout(timeout ? PROCESSING_TIMEOUT : nil) do - @fqdn = Setting.get('fqdn') + @fqdn = Setting.get('fqdn') + http_type = Setting.get('http_type') + web_app_url_prefix = "#{http_type}://#{@fqdn}/\#".downcase # config tags_remove_content = Rails.configuration.html_sanitizer_tags_remove_content @@ -179,7 +181,11 @@ satinize html string based on whiltelist node.set_attribute('href', href) node.set_attribute('rel', 'nofollow noreferrer noopener') - node.set_attribute('target', '_blank') + + # do not "target=_blank" WebApp URLs (e.g. mentions) + if !href.downcase.start_with?(web_app_url_prefix) + node.set_attribute('target', '_blank') + end end if node.name == 'a' && node['href'].blank? diff --git a/lib/notification_factory/mailer.rb b/lib/notification_factory/mailer.rb index 1d6787265..c72c9f7fa 100644 --- a/lib/notification_factory/mailer.rb +++ b/lib/notification_factory/mailer.rb @@ -48,6 +48,7 @@ returns owned_by_nobody = false owned_by_me = false + mentioned = false case ticket.owner_id when 1 owned_by_nobody = true @@ -69,6 +70,11 @@ returns end end + # always trigger notifications for user if he is mentioned + if owned_by_me == false && ticket.mentions.exists?(user: user) + mentioned = true + end + # check if group is in selected groups if !owned_by_me selected_group_ids = user_preferences['notification_config']['group_ids'] @@ -109,6 +115,12 @@ returns channels: channels } end + if data['criteria']['mentioned'] && mentioned + return { + user: user, + channels: channels + } + end return if !data['criteria']['no'] { diff --git a/lib/search_index_backend.rb b/lib/search_index_backend.rb index 3cee95df5..bcb121ad9 100644 --- a/lib/search_index_backend.rb +++ b/lib/search_index_backend.rb @@ -489,7 +489,10 @@ example for aggregations within one year minute: 'm', } if selector.present? + operators_is_isnot = ['is', 'is not'] + selector.each do |key, data| + data = data.clone table, key_tmp = key.split('.') if key_tmp.blank? @@ -510,8 +513,6 @@ example for aggregations within one year when 'not_set' data['value'] = if key_tmp.match?(/^(created_by|updated_by|owner|customer|user)_id/) 1 - else - 'NULL' end when 'current_user.id' raise "Use current_user.id in selector, but no current_user is set #{data.inspect}" if !current_user_id @@ -562,6 +563,22 @@ example for aggregations within one year end end + # for pre condition not_set we want to check if values are defined for the object by exists + if data['pre_condition'] == 'not_set' && operators_is_isnot.include?(data['operator']) && data['value'].nil? + t['exists'] = { + field: key_tmp, + } + + case data['operator'] + when 'is' + query_must_not.push t + when 'is not' + query_must.push t + end + next + + end + if table != 'ticket' key_tmp = "#{table}.#{key_tmp}" end diff --git a/public/assets/tests/ticket_selector.js b/public/assets/tests/ticket_selector.js index 7c7ac3c0f..fd3b37e9e 100644 --- a/public/assets/tests/ticket_selector.js +++ b/public/assets/tests/ticket_selector.js @@ -131,6 +131,7 @@ window.onload = function() { "id": 434 }, "tags": ["tag a", "tag b"], + "mention_user_ids": [1,3,5,6], "escalation_at": "2017-02-09T09:16:56.192Z", "last_contact_agent_at": "2017-02-09T09:16:56.192Z", "last_contact_agent_at": "2017-02-09T09:16:56.192Z", @@ -1106,4 +1107,12 @@ window.onload = function() { testContains('organization.domain', 'cool', ticket); }); + + test("ticket mention user_id", function() { + ticket = new App.Ticket(); + ticket.load(ticketData); + + testPreConditionUser('ticket.mention_user_ids', '6', ticket, sessionData); + }); + } diff --git a/spec/factories/mention.rb b/spec/factories/mention.rb new file mode 100644 index 000000000..2936adff4 --- /dev/null +++ b/spec/factories/mention.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :mention do + mentionable { create(:ticket) } + user_id { 1 } + created_by_id { 1 } + updated_by_id { 1 } + end +end diff --git a/spec/lib/search_index_backend_spec.rb b/spec/lib/search_index_backend_spec.rb index 7058cbeae..71bef53ed 100644 --- a/spec/lib/search_index_backend_spec.rb +++ b/spec/lib/search_index_backend_spec.rb @@ -193,6 +193,7 @@ RSpec.describe SearchIndexBackend, searchindex: true do before do Ticket.destroy_all # needed to remove not created tickets + create(:mention, mentionable: ticket1, user: agent1) ticket1.search_index_update_backend travel 1.second ticket2.search_index_update_backend @@ -631,5 +632,99 @@ RSpec.describe SearchIndexBackend, searchindex: true do end end + + context 'mentions' do + it 'finds records with pre_condition is not_set' do + result = described_class.selectors('Ticket', + { + 'ticket.mention_user_ids' => { + 'pre_condition' => 'not_set', + 'operator' => 'is', + }, + }, + { current_user: agent1 }, + { + field: 'created_at', # sort to verify result + }) + expect(result).to eq({ count: 7, ticket_ids: [ticket8.id.to_s, ticket7.id.to_s, ticket6.id.to_s, ticket5.id.to_s, ticket4.id.to_s, ticket3.id.to_s, ticket2.id.to_s] }) + end + + it 'finds records with pre_condition is not not_set' do + result = described_class.selectors('Ticket', + { + 'ticket.mention_user_ids' => { + 'pre_condition' => 'not_set', + 'operator' => 'is not', + }, + }, + { current_user: agent1 }, + { + field: 'created_at', # sort to verify result + }) + expect(result).to eq({ count: 1, ticket_ids: [ticket1.id.to_s] }) + end + + it 'finds records with pre_condition is current_user.id' do + result = described_class.selectors('Ticket', + { + 'ticket.mention_user_ids' => { + 'pre_condition' => 'current_user.id', + 'operator' => 'is', + }, + }, + { current_user: agent1 }, + { + field: 'created_at', # sort to verify result + }) + expect(result).to eq({ count: 1, ticket_ids: [ticket1.id.to_s] }) + end + + it 'finds records with pre_condition is not current_user.id' do + result = described_class.selectors('Ticket', + { + 'ticket.mention_user_ids' => { + 'pre_condition' => 'current_user.id', + 'operator' => 'is not', + }, + }, + { current_user: agent1 }, + { + field: 'created_at', # sort to verify result + }) + expect(result).to eq({ count: 7, ticket_ids: [ticket8.id.to_s, ticket7.id.to_s, ticket6.id.to_s, ticket5.id.to_s, ticket4.id.to_s, ticket3.id.to_s, ticket2.id.to_s] }) + end + + it 'finds records with pre_condition is specific' do + result = described_class.selectors('Ticket', + { + 'ticket.mention_user_ids' => { + 'pre_condition' => 'specific', + 'operator' => 'is', + 'value' => agent1.id, + }, + }, + {}, + { + field: 'created_at', # sort to verify result + }) + expect(result).to eq({ count: 1, ticket_ids: [ticket1.id.to_s] }) + end + + it 'finds records with pre_condition is not specific' do + result = described_class.selectors('Ticket', + { + 'ticket.mention_user_ids' => { + 'pre_condition' => 'specific', + 'operator' => 'is not', + 'value' => agent1.id, + }, + }, + {}, + { + field: 'created_at', # sort to verify result + }) + expect(result).to eq({ count: 7, ticket_ids: [ticket8.id.to_s, ticket7.id.to_s, ticket6.id.to_s, ticket5.id.to_s, ticket4.id.to_s, ticket3.id.to_s, ticket2.id.to_s] }) + end + end end end diff --git a/spec/models/concerns/has_history_examples.rb b/spec/models/concerns/has_history_examples.rb index 95e78d0e8..e923d9237 100644 --- a/spec/models/concerns/has_history_examples.rb +++ b/spec/models/concerns/has_history_examples.rb @@ -1,4 +1,4 @@ -RSpec.shared_examples 'HasHistory' do |history_relation_object: nil| +RSpec.shared_examples 'HasHistory' do |history_relation_object: []| describe 'auto-creation of history records' do let(:histories) { History.where(history_object_id: History::Object.find_by(name: described_class.name)) } diff --git a/spec/models/mention_spec.rb b/spec/models/mention_spec.rb new file mode 100644 index 000000000..f12d4a2e8 --- /dev/null +++ b/spec/models/mention_spec.rb @@ -0,0 +1,11 @@ +require 'rails_helper' + +RSpec.describe Mention, type: :model do + let(:ticket) { create(:ticket) } + + describe 'validation' do + it 'does not allow mentions for customers' do + expect { create(:mention, mentionable: ticket, user: create(:customer)) }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: User has no ticket.agent permissions') + end + end +end diff --git a/spec/models/ticket_spec.rb b/spec/models/ticket_spec.rb index 1db0be18b..8ae5c9731 100644 --- a/spec/models/ticket_spec.rb +++ b/spec/models/ticket_spec.rb @@ -21,7 +21,7 @@ RSpec.describe Ticket, type: :model do it_behaves_like 'ApplicationModel' it_behaves_like 'CanBeImported' it_behaves_like 'CanCsvImport' - it_behaves_like 'HasHistory', history_relation_object: 'Ticket::Article' + it_behaves_like 'HasHistory', history_relation_object: ['Ticket::Article', 'Mention'] it_behaves_like 'HasTags' it_behaves_like 'TagWritesToTicketHistory' it_behaves_like 'HasTaskbars' @@ -196,6 +196,20 @@ RSpec.describe Ticket, type: :model do end end + context 'when both tickets having mentions to the same user' do + let(:watcher) { create(:agent) } + + before do + create(:mention, mentionable: ticket, user: watcher) + create(:mention, mentionable: target_ticket, user: watcher) + ticket.merge_to(ticket_id: target_ticket.id, user_id: 1) + end + + it 'does remove the link from the merged ticket' do + expect(target_ticket.mentions.count).to eq(1) # one mention to watcher user + end + end + context 'when merging' do let(:merge_user) { create(:user) } @@ -1509,6 +1523,210 @@ RSpec.describe Ticket, type: :model do end end + describe 'Mentions:', sends_notification_emails: true do + context 'when notifications' do + let(:prefs_matrix_no_mentions) do + { 'notification_config' => + { 'matrix' => + { 'create' => { 'criteria' => { 'owned_by_me' => true, 'owned_by_nobody' => true, 'mentioned' => false, 'no' => true }, 'channel' => { 'email' => true, 'online' => true } }, + 'update' => { 'criteria' => { 'owned_by_me' => true, 'owned_by_nobody' => true, 'mentioned' => false, 'no' => true }, 'channel' => { 'email' => true, 'online' => true } }, + 'reminder_reached' => { 'criteria' => { 'owned_by_me' => false, 'owned_by_nobody' => false, 'mentioned' => false, 'no' => false }, 'channel' => { 'email' => false, 'online' => false } }, + 'escalation' => { 'criteria' => { 'owned_by_me' => false, 'owned_by_nobody' => false, 'mentioned' => false, 'no' => false }, 'channel' => { 'email' => false, 'online' => false } } } } } + end + + let(:prefs_matrix_only_mentions) do + { 'notification_config' => + { 'matrix' => + { 'create' => { 'criteria' => { 'owned_by_me' => false, 'owned_by_nobody' => false, 'mentioned' => true, 'no' => false }, 'channel' => { 'email' => true, 'online' => true } }, + 'update' => { 'criteria' => { 'owned_by_me' => false, 'owned_by_nobody' => false, 'mentioned' => true, 'no' => false }, 'channel' => { 'email' => true, 'online' => true } }, + 'reminder_reached' => { 'criteria' => { 'owned_by_me' => false, 'owned_by_nobody' => false, 'mentioned' => true, 'no' => false }, 'channel' => { 'email' => false, 'online' => false } }, + 'escalation' => { 'criteria' => { 'owned_by_me' => false, 'owned_by_nobody' => false, 'mentioned' => true, 'no' => false }, 'channel' => { 'email' => false, 'online' => false } } } } } + end + + let(:mention_group) { create(:group) } + let(:no_access_group) { create(:group) } + let(:user_only_mentions) { create(:agent, groups: [mention_group], preferences: prefs_matrix_only_mentions) } + let(:user_no_mentions) { create(:agent, groups: [mention_group], preferences: prefs_matrix_no_mentions) } + let(:ticket) { create(:ticket, group: mention_group, owner: user_no_mentions) } + + it 'does inform mention user about the ticket update' do + create(:mention, mentionable: ticket, user: user_only_mentions) + create(:mention, mentionable: ticket, user: user_no_mentions) + Observer::Transaction.commit + Scheduler.worker(true) + + check_notification do + ticket.update(priority: Ticket::Priority.find_by(name: '3 high')) + Observer::Transaction.commit + Scheduler.worker(true) + sent( + template: 'ticket_update', + user: user_no_mentions, + ) + sent( + template: 'ticket_update', + user: user_only_mentions, + ) + end + end + + it 'does not inform mention user about the ticket update' do + ticket + Observer::Transaction.commit + Scheduler.worker(true) + + check_notification do + ticket.update(priority: Ticket::Priority.find_by(name: '3 high')) + Observer::Transaction.commit + Scheduler.worker(true) + sent( + template: 'ticket_update', + user: user_no_mentions, + ) + not_sent( + template: 'ticket_update', + user: user_only_mentions, + ) + end + end + + it 'does inform mention user about ticket creation' do + check_notification do + ticket = create(:ticket, owner: user_no_mentions, group: mention_group) + create(:mention, mentionable: ticket, user: user_only_mentions) + Observer::Transaction.commit + Scheduler.worker(true) + sent( + template: 'ticket_create', + user: user_no_mentions, + ) + sent( + template: 'ticket_create', + user: user_only_mentions, + ) + end + end + + it 'does not inform mention user about ticket creation' do + check_notification do + create(:ticket, owner: user_no_mentions, group: mention_group) + Observer::Transaction.commit + Scheduler.worker(true) + sent( + template: 'ticket_create', + user: user_no_mentions, + ) + not_sent( + template: 'ticket_create', + user: user_only_mentions, + ) + end + end + + it 'does not inform mention user about ticket creation because of no permissions' do + check_notification do + ticket = create(:ticket, group: no_access_group) + create(:mention, mentionable: ticket, user: user_only_mentions) + Observer::Transaction.commit + Scheduler.worker(true) + not_sent( + template: 'ticket_create', + user: user_only_mentions, + ) + end + end + end + + context 'selectors' do + let(:mention_group) { create(:group) } + let(:ticket_mentions) { create(:ticket, group: mention_group) } + let(:ticket_normal) { create(:ticket, group: mention_group) } + let(:user_mentions) { create(:agent, groups: [mention_group]) } + let(:user_no_mentions) { create(:agent, groups: [mention_group]) } + + before do + described_class.destroy_all + ticket_normal + user_no_mentions + create(:mention, mentionable: ticket_mentions, user: user_mentions) + end + + it 'pre condition is not_set' do + condition = { + 'ticket.mention_user_ids' => { + pre_condition: 'not_set', + operator: 'is', + }, + } + + expect(described_class.selectors(condition, limit: 100, access: 'full')) + .to match_array([1, [ticket_normal].to_a]) + end + + it 'pre condition is not not_set' do + condition = { + 'ticket.mention_user_ids' => { + pre_condition: 'not_set', + operator: 'is not', + }, + } + + expect(described_class.selectors(condition, limit: 100, access: 'full')) + .to match_array([1, [ticket_mentions].to_a]) + end + + it 'pre condition is current_user.id' do + condition = { + 'ticket.mention_user_ids' => { + pre_condition: 'current_user.id', + operator: 'is', + }, + } + + expect(described_class.selectors(condition, limit: 100, access: 'full', current_user: user_mentions)) + .to match_array([1, [ticket_mentions].to_a]) + end + + it 'pre condition is not current_user.id' do + condition = { + 'ticket.mention_user_ids' => { + pre_condition: 'current_user.id', + operator: 'is not', + }, + } + + expect(described_class.selectors(condition, limit: 100, access: 'full', current_user: user_mentions)) + .to match_array([0, []]) + end + + it 'pre condition is specific' do + condition = { + 'ticket.mention_user_ids' => { + pre_condition: 'specific', + operator: 'is', + value: user_mentions.id + }, + } + + expect(described_class.selectors(condition, limit: 100, access: 'full')) + .to match_array([1, [ticket_mentions].to_a]) + end + + it 'pre condition is not specific' do + condition = { + 'ticket.mention_user_ids' => { + pre_condition: 'specific', + operator: 'is not', + value: user_mentions.id + }, + } + + expect(described_class.selectors(condition, limit: 100, access: 'full')) + .to match_array([0, []]) + end + end + end + describe '.search_index_attribute_lookup_oversized?' do subject!(:ticket) { create(:ticket) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 61018ea37..bc04eb952 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -869,9 +869,10 @@ RSpec.describe User, type: :model do 'User' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, 'Organization' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, 'Macro' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, + 'Mention' => { 'created_by_id' => 1, 'updated_by_id' => 0, 'user_id' => 1 }, 'Channel' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, 'Role' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, - 'History' => { 'created_by_id' => 2 }, + 'History' => { 'created_by_id' => 3 }, 'Webhook' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, 'Overview' => { 'created_by_id' => 1, 'updated_by_id' => 0 }, 'ActivityStream' => { 'created_by_id' => 0 }, @@ -893,6 +894,8 @@ RSpec.describe User, type: :model do recent_view = create(:recent_view, created_by: user) avatar = create(:avatar, o_id: user.id) overview = create(:overview, created_by_id: user.id, user_ids: [user.id]) + mention = create(:mention, mentionable: create(:ticket), user: user) + mention_created_by = create(:mention, mentionable: create(:ticket), user: create(:agent), created_by: user) expect(overview.reload.user_ids).to eq([user.id]) # create a chat agent for admin user (id=1) before agent user @@ -930,6 +933,8 @@ RSpec.describe User, type: :model do expect { customer_ticket2.reload }.to raise_exception(ActiveRecord::RecordNotFound) expect { customer_ticket3.reload }.to raise_exception(ActiveRecord::RecordNotFound) expect { chat_agent_user.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { mention.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect(mention_created_by.reload.created_by_id).not_to eq(user.id) expect(overview.reload.user_ids).to eq([]) # move ownership objects diff --git a/spec/requests/mention_spec.rb b/spec/requests/mention_spec.rb new file mode 100644 index 000000000..9b08de87c --- /dev/null +++ b/spec/requests/mention_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +RSpec.describe 'Mention', type: :request, authenticated_as: -> { user } do + let(:group) { create(:group) } + let(:ticket1) { create(:ticket, group: group) } + let(:ticket2) { create(:ticket, group: group) } + let(:ticket3) { create(:ticket, group: group) } + let(:ticket4) { create(:ticket, group: group) } + let(:user) { create(:agent, groups: [group]) } + + describe 'GET /api/v1/mentions' do + before do + create(:mention, mentionable: ticket1, user: user) + create(:mention, mentionable: ticket2, user: user) + create(:mention, mentionable: ticket3, user: user) + end + + it 'returns good status code' do + get '/api/v1/mentions', params: {}, as: :json + expect(response).to have_http_status(:ok) + end + + it 'returns mentions by user' do + get '/api/v1/mentions', params: {}, as: :json + expect(json_response['mentions'].count).to eq(3) + end + + it 'returns mentions by mentionable' do + get '/api/v1/mentions', params: { mentionable_type: 'Ticket', mentionable_id: ticket3.id }, as: :json + expect(json_response['mentions'].count).to eq(1) + end + + it 'returns mentions by id' do + mention = create(:mention, mentionable: ticket4, user: user) + get '/api/v1/mentions', params: { id: mention.id }, as: :json + expect(json_response['mentions'].count).to eq(1) + end + end + + describe 'POST /api/v1/mentions' do + + let(:params) do + { + mentionable_type: 'Ticket', + mentionable_id: ticket1.id + } + end + + it 'returns good status code for subscribe' do + post '/api/v1/mentions', params: params, as: :json + expect(response).to have_http_status(:created) + end + + it 'updates mention count' do + expect { post '/api/v1/mentions', params: params, as: :json }.to change(Mention, :count).from(0).to(1) + end + end + + describe 'DELETE /api/v1/mentions/:id' do + + let!(:mention) { create(:mention, user: user) } + + it 'returns good status code' do + delete "/api/v1/mentions/#{mention.id}", params: {}, as: :json + expect(response).to have_http_status(:ok) + end + + it 'clears mention count' do + expect { delete "/api/v1/mentions/#{mention.id}", params: {}, as: :json }.to change(Mention, :count).from(1).to(0) + end + end +end diff --git a/spec/requests/ticket/article_spec.rb b/spec/requests/ticket/article_spec.rb index f6e468f4d..6c8929d7e 100644 --- a/spec/requests/ticket/article_spec.rb +++ b/spec/requests/ticket/article_spec.rb @@ -486,6 +486,36 @@ AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO expect(json_response['attachments']).to be_truthy expect(json_response['attachments'].count).to eq(0) end + + it 'does ticket create with mentions' do + params = { + title: 'a new ticket #1', + group: 'Users', + customer_id: customer.id, + article: { + body: "some body agent", + } + } + authenticated_as(agent) + post '/api/v1/tickets', params: params, as: :json + expect(response).to have_http_status(:created) + expect(Mention.where(mentionable: Ticket.last).count).to eq(1) + end + + it 'does not ticket create with mentions when customer' do + params = { + title: 'a new ticket #1', + group: 'Users', + customer_id: customer.id, + article: { + body: "some body agent", + } + } + authenticated_as(customer) + post '/api/v1/tickets', params: params, as: :json + expect(response).to have_http_status(:internal_server_error) + expect(Mention.count).to eq(0) + end end describe 'DELETE /api/v1/ticket_articles/:id', authenticated_as: -> { user } do diff --git a/spec/requests/ticket_spec.rb b/spec/requests/ticket_spec.rb index 184c0f8cd..2bc4b1ba6 100644 --- a/spec/requests/ticket_spec.rb +++ b/spec/requests/ticket_spec.rb @@ -2200,6 +2200,47 @@ RSpec.describe 'Ticket', type: :request do end + describe 'mentions' do + let(:user1) { create(:agent, groups: [ticket_group]) } + let(:user2) { create(:agent, groups: [ticket_group]) } + let(:user3) { create(:agent, groups: [ticket_group]) } + + def new_ticket_with_mentions + params = { + title: 'a new ticket #11', + group: ticket_group.name, + customer: { + firstname: 'some firstname', + lastname: 'some lastname', + email: 'some_new_customer@example.com', + }, + article: { + body: 'some test 123', + }, + mentions: [user1.id, user2.id, user3.id] + } + authenticated_as(agent) + post '/api/v1/tickets', params: params, as: :json + expect(response).to have_http_status(:created) + + json_response + end + + it 'create ticket with mentions' do + new_ticket_with_mentions + expect(Mention.all.count).to eq(3) + end + + it 'check ticket get' do + ticket = new_ticket_with_mentions + + get "/api/v1/tickets/#{ticket['id']}?all=true", params: {}, as: :json + expect(response).to have_http_status(:ok) + expect(json_response['mentions'].count).to eq(3) + expect(json_response['assets']['Mention'].count).to eq(3) + end + end + describe 'stats' do let(:ticket1) { create(:ticket, customer: customer, organization: organization, group: ticket_group) } let(:ticket2) { create(:ticket, customer: customer, organization: organization, group: ticket_group) } diff --git a/spec/system/ticket/zoom_spec.rb b/spec/system/ticket/zoom_spec.rb index 62f9874a7..634e37553 100644 --- a/spec/system/ticket/zoom_spec.rb +++ b/spec/system/ticket/zoom_spec.rb @@ -1277,6 +1277,36 @@ RSpec.describe 'Ticket zoom', type: :system do end end + describe 'mentions' do + context 'when logged in as agent' do + let(:ticket) { create(:ticket, group: Group.find_by(name: 'Users')) } + let!(:other_agent) { create(:agent, groups: [Group.find_by(name: 'Users')]) } + + it 'can subscribe and unsubscribe' do + ensure_websocket do + visit "ticket/zoom/#{ticket.id}" + + click '.mentions .js-subscribe input' + expect(page).to have_selector('.mentions .js-unsubscribe input', wait: 10) + expect(page).to have_selector('.mentions span.avatar', wait: 10) + + click '.mentions .js-unsubscribe input' + expect(page).to have_selector('.mentions .js-subscribe input', wait: 10) + expect(page).to have_no_selector('.mentions span.avatar', wait: 10) + + create(:mention, mentionable: ticket, user: other_agent) + expect(page).to have_selector('.mentions span.avatar', wait: 10) + + # check history for mention entries + click 'h2.sidebar-header-headline.js-headline' + click 'li[data-type=ticket-history] a' + expect(page).to have_text('created Mention', wait: 10) + expect(page).to have_text('removed Mention', wait: 10) + end + end + end + end + # https://github.com/zammad/zammad/issues/2671 describe 'Pending time field in ticket sidebar', authenticated_as: :customer do let(:customer) { create(:customer) }