Fixes #208 - Notify / Mention other agents / Subscribe to Tickets.

This commit is contained in:
Rolf Schmidt 2021-03-16 08:59:32 +00:00 committed by Martin Edenhofer
parent d0dcae4776
commit 2354b5f53f
59 changed files with 1388 additions and 102 deletions

View file

@ -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'
}
]
}

View file

@ -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]]

View file

@ -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) ->

View file

@ -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)

View file

@ -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]

View file

@ -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'

View file

@ -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

View file

@ -609,6 +609,82 @@
KbAnswer.trigger = '??'
Plugin.prototype.helpers = [Collection, KbAnswer]
function Mention() {}
Mention.renderValue = function(textmodule, elem, callback) {
textmodule.emptyResultsContainer()
var element = $('<li>').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 = $('<a></a>', {
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 = $('<li>').text(App.i18n.translateInline('Start typing to search for users...'))
textmodule.appendResults(element)
return
}
var element = $('<li>').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 = $('<li>', {
'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($('<li>').text(App.i18n.translateInline('No results found')))
}
textmodule.appendResults(items)
})
}, 200, 'textmoduleMentionDelay', 'textmodule')
}
Mention.trigger = '@@'
Plugin.prototype.helpers = [Collection, KbAnswer, Mention]
}(jQuery, window));

View file

@ -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)
)

View file

@ -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

View file

@ -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']

View file

@ -9,38 +9,47 @@
<thead>
<tr>
<th>
<th width="18%" style="text-align: center;"><%- @T('My Tickets') %>
<th width="18%" style="text-align: center;"><%- @T('Not Assigned') %>*
<th width="18%" style="text-align: center;"><%- @T('All Tickets') %>*
<th width="16%" style="text-align: center;"><%- @T('My Tickets') %>
<th width="16%" style="text-align: center;"><%- @T('Not Assigned') %>*
<th width="16%" style="text-align: center;"><%- @T('Mentioned Tickets') %>
<th width="16%" style="text-align: center;"><%- @T('All Tickets') %>*
<th width="120px" class="settings-list-separator" style="text-align: center;"><%- @T('Also notify via email') %>
</thead>
<tbody>
<% if @config.matrix: %>
<% for key, value of @config.matrix: %>
<% if @matrix: %>
<% for key, value of @matrix: %>
<tr>
<td>
<%- @T(value.name) %>
<% criteria = @config.matrix[key]?.criteria %>
<% channel = @config.matrix[key]?.channel %>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.criteria.owned_by_me" value="true" <% if value.criteria && value.criteria.owned_by_me: %>checked<% end %>/>
<input type="checkbox" name="matrix.<%= key %>.criteria.owned_by_me" value="true"<% if criteria && criteria.owned_by_me: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.criteria.owned_by_nobody" value="true" <% if value.criteria && value.criteria.owned_by_nobody: %>checked<% end %>/>
<input type="checkbox" name="matrix.<%= key %>.criteria.owned_by_nobody" value="true"<% if criteria && criteria.owned_by_nobody: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.criteria.no" value="true" <% if value.criteria && value.criteria.no: %>checked<% end %>/>
<input type="checkbox" name="matrix.<%= key %>.criteria.mentioned" value="true"<% if criteria && criteria.mentioned: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.criteria.no" value="true"<% if criteria && criteria.no: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<td class="u-positionOrigin settings-list-separator">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.channel" value="email" <% if value.channel && value.channel.email: %>checked<% end %>/>
<input type="checkbox" name="matrix.<%= key %>.channel" value="email"<% if channel && channel.email: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
@ -51,7 +60,7 @@
</div>
<% if @groups: %>
<h2>* <%- @T( 'Limit Groups' ) %></h2>
<h2>* <%- @T('Limit Groups') %></h2>
<div class="settings-entry">
<table class="settings-list">
<thead>

View file

@ -5,3 +5,4 @@
<div class="links"></div>
<div class="link_kb_answers"></div>
<div class="js-timeUnit"></div>
<div class="mentions"></div>

View file

@ -0,0 +1,14 @@
<label><%- @T('Mentions') %></label>
<form class="ui-front mentionWidget">
<div class="js-subscribe<% if @subscribed: %> hidden<% end %>">
<input type="button" class="btn btn--fullWidth" name="subscribe" value="<%- @T('Subscribe') %>">
</div>
<div class="js-unsubscribe<% if !@subscribed: %> hidden<% end %>">
<input type="button" class="btn btn--fullWidth" name="unsubscribe" value="<%- @T('Unsubscribe') %>">
</div>
</form>
<div class="controls-label">
<% for mention in @mentions: %>
<a href="#user/profile/<%= mention.user_id %>"><%- mention.avatar %></a>
<% end %>
</div>

View file

@ -5613,6 +5613,10 @@ footer {
cursor: help;
}
.notification-icon-help {
opacity: .2;
}
.stat-label {
color: #444a4f;
@extend .u-textTruncate;

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
)
)

54
app/models/mention.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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/)

View file

@ -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'],

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -0,0 +1,3 @@
class Controllers::MentionsControllerPolicy < Controllers::ApplicationControllerPolicy
default_permit!('ticket.agent')
end

View file

@ -0,0 +1,5 @@
class MentionPolicy < ApplicationPolicy
def create?
user.permissions?('ticket.agent')
end
end

View file

@ -25,5 +25,5 @@ Ticket (#{ticket.title}) byl aktualizován uživatelem "<b>#{current_user.longna
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}<% if @objects[:article] %>/#{article.id}<% end %>" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View file

@ -25,5 +25,5 @@ Ticket (#{ticket.title}) wurde von "<b>#{current_user.longname}</b>" aktualisier
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}<% if @objects[:article] %>/#{article.id}<% end %>" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View file

@ -25,5 +25,5 @@ Ticket (#{ticket.title}) has been updated by "<b>#{current_user.longname}</b>".
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}<% if @objects[:article] %>/#{article.id}<% end %>" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View file

@ -25,5 +25,5 @@ Ticket (#{ticket.title}) ha sido actualizado por "<b>#{current_user.longname}</b
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}<% if @objects[:article] %>/#{article.id}<% end %>" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View file

@ -25,5 +25,5 @@ Le ticket (#{ticket.title}) a été mis à jour par "<b>#{current_user.longname}
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}<% if @objects[:article] %>/#{article.id}<% end %>" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View file

@ -25,5 +25,5 @@ Il ticket (#{ticket.title}) è stato aggiornato da "<b>#{current_user.longname}<
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}<% if @objects[:article] %>/#{article.id}<% end %>" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View file

@ -25,5 +25,5 @@ O chamado (#{ticket.title}) foi atualizado por "<b>#{current_user.longname}</b>"
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('Veja mais informações no Zammad')}</a>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}<% if @objects[:article] %>/#{article.id}<% end %>" target="zammad_app">#{t('Veja mais informações no Zammad')}</a>
</div>

View file

@ -25,5 +25,5 @@
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}<% if @objects[:article] %>/#{article.id}<% end %>" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View file

@ -25,5 +25,5 @@
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}<% if @objects[:article] %>/#{article.id}<% end %>" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View file

@ -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: {

View file

@ -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],

7
config/routes/mention.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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',

View file

@ -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?

View file

@ -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']
{

View file

@ -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

View file

@ -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);
});
}

View file

@ -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

View file

@ -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

View file

@ -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)) }

View file

@ -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

View file

@ -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) }

View file

@ -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

View file

@ -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

View file

@ -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 <a data-mention-user-id=\"#{agent.id}\">agent</a>",
}
}
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 <a data-mention-user-id=\"#{agent.id}\">agent</a>",
}
}
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

View file

@ -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) }

View file

@ -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) }