<%- @T(value.name) %>
+ <% criteria = @config.matrix[key]?.criteria %>
+ <% channel = @config.matrix[key]?.channel %>
- checked<% end %>/>
+ checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
- checked<% end %>/>
+ checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
- checked<% end %>/>
+ checked<% end %> />
+ <%- @Icon('checkbox', 'icon-unchecked') %>
+ <%- @Icon('checkbox-checked', 'icon-checked') %>
+
+
+
+ checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
- checked<% end %>/>
+ checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
@@ -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 @@
+<%- @T('Mentions') %>
+
+
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 %>
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 %>
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 %>
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}
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 %>
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) }