1886 lines
64 KiB
Ruby
1886 lines
64 KiB
Ruby
# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/
|
||
|
||
class Ticket < ApplicationModel
|
||
include CanBeImported
|
||
include HasActivityStreamLog
|
||
include ChecksClientNotification
|
||
include ChecksLatestChangeObserved
|
||
include CanCsvImport
|
||
include ChecksHtmlSanitized
|
||
include HasHistory
|
||
include HasTags
|
||
include HasSearchIndexBackend
|
||
include HasOnlineNotifications
|
||
include HasKarmaActivityLog
|
||
include HasLinks
|
||
include HasObjectManagerAttributes
|
||
include HasTaskbars
|
||
include Ticket::CallsStatsTicketReopenLog
|
||
include Ticket::EnqueuesUserTicketCounterJob
|
||
include Ticket::ResetsPendingTimeSeconds
|
||
include Ticket::SetsCloseTime
|
||
include Ticket::SetsOnlineNotificationSeen
|
||
include Ticket::TouchesAssociations
|
||
|
||
include ::Ticket::Escalation
|
||
include ::Ticket::Subject
|
||
include ::Ticket::Assets
|
||
include ::Ticket::SearchIndex
|
||
include ::Ticket::Search
|
||
include ::Ticket::MergeHistory
|
||
|
||
store :preferences
|
||
before_create :check_generate, :check_defaults, :check_title, :set_default_state, :set_default_priority
|
||
before_update :check_defaults, :check_title, :reset_pending_time, :check_owner_active
|
||
|
||
# This must be loaded late as it depends on the internal before_create and before_update handlers of ticket.rb.
|
||
include Ticket::SetsLastOwnerUpdateTime
|
||
|
||
include HasTransactionDispatcher
|
||
|
||
# workflow checks should run after before_create and before_update callbacks
|
||
include ChecksCoreWorkflow
|
||
|
||
validates :group_id, presence: true
|
||
|
||
activity_stream_permission 'ticket.agent'
|
||
|
||
activity_stream_attributes_ignored :organization_id, # organization_id will change automatically on user update
|
||
:create_article_type_id,
|
||
:create_article_sender_id,
|
||
:article_count,
|
||
:first_response_at,
|
||
:first_response_escalation_at,
|
||
:first_response_in_min,
|
||
:first_response_diff_in_min,
|
||
:close_at,
|
||
:close_escalation_at,
|
||
:close_in_min,
|
||
:close_diff_in_min,
|
||
:update_escalation_at,
|
||
:update_in_min,
|
||
:update_diff_in_min,
|
||
:last_contact_at,
|
||
:last_contact_agent_at,
|
||
:last_contact_customer_at,
|
||
:last_owner_update_at,
|
||
:preferences
|
||
|
||
history_attributes_ignored :create_article_type_id,
|
||
:create_article_sender_id,
|
||
:article_count,
|
||
:preferences
|
||
|
||
history_relation_object 'Ticket::Article', 'Mention'
|
||
|
||
sanitized_html :note
|
||
|
||
belongs_to :group, optional: true
|
||
belongs_to :organization, optional: true
|
||
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
|
||
belongs_to :customer, class_name: 'User', optional: true
|
||
belongs_to :created_by, class_name: 'User', optional: true
|
||
belongs_to :updated_by, class_name: 'User', optional: true
|
||
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, :mentions
|
||
|
||
attr_accessor :callback_loop
|
||
|
||
=begin
|
||
|
||
processes tickets which have reached their pending time and sets next state_id
|
||
|
||
processed_tickets = Ticket.process_pending
|
||
|
||
returns
|
||
|
||
processed_tickets = [<Ticket>, ...]
|
||
|
||
=end
|
||
|
||
def self.process_pending
|
||
result = []
|
||
|
||
# process pending action tickets
|
||
pending_action = Ticket::StateType.find_by(name: 'pending action')
|
||
ticket_states_pending_action = Ticket::State.where(state_type_id: pending_action)
|
||
.where.not(next_state_id: nil)
|
||
if ticket_states_pending_action.present?
|
||
next_state_map = {}
|
||
ticket_states_pending_action.each do |state|
|
||
next_state_map[state.id] = state.next_state_id
|
||
end
|
||
|
||
tickets = where(state_id: next_state_map.keys)
|
||
.where('pending_time <= ?', Time.zone.now)
|
||
|
||
tickets.find_each(batch_size: 500) do |ticket|
|
||
Transaction.execute do
|
||
ticket.state_id = next_state_map[ticket.state_id]
|
||
ticket.updated_at = Time.zone.now
|
||
ticket.updated_by_id = 1
|
||
ticket.save!
|
||
end
|
||
result.push ticket
|
||
end
|
||
end
|
||
|
||
# process pending reminder tickets
|
||
pending_reminder = Ticket::StateType.find_by(name: 'pending reminder')
|
||
ticket_states_pending_reminder = Ticket::State.where(state_type_id: pending_reminder)
|
||
|
||
if ticket_states_pending_reminder.present?
|
||
reminder_state_map = {}
|
||
ticket_states_pending_reminder.each do |state|
|
||
reminder_state_map[state.id] = state.next_state_id
|
||
end
|
||
|
||
tickets = where(state_id: reminder_state_map.keys)
|
||
.where('pending_time <= ?', Time.zone.now)
|
||
|
||
tickets.find_each(batch_size: 500) do |ticket|
|
||
|
||
article_id = nil
|
||
article = Ticket::Article.last_customer_agent_article(ticket.id)
|
||
if article
|
||
article_id = article.id
|
||
end
|
||
|
||
# send notification
|
||
TransactionJob.perform_now(
|
||
object: 'Ticket',
|
||
type: 'reminder_reached',
|
||
object_id: ticket.id,
|
||
article_id: article_id,
|
||
user_id: 1,
|
||
)
|
||
|
||
result.push ticket
|
||
end
|
||
end
|
||
|
||
result
|
||
end
|
||
|
||
=begin
|
||
|
||
processes escalated tickets
|
||
|
||
processed_tickets = Ticket.process_escalation
|
||
|
||
returns
|
||
|
||
processed_tickets = [<Ticket>, ...]
|
||
|
||
=end
|
||
|
||
def self.process_escalation
|
||
result = []
|
||
|
||
# fetch all escalated and soon to be escalating tickets
|
||
where('escalation_at <= ?', Time.zone.now + 15.minutes).find_each(batch_size: 500) do |ticket|
|
||
|
||
article_id = nil
|
||
article = Ticket::Article.last_customer_agent_article(ticket.id)
|
||
if article
|
||
article_id = article.id
|
||
end
|
||
|
||
# send escalation
|
||
if ticket.escalation_at < Time.zone.now
|
||
TransactionJob.perform_now(
|
||
object: 'Ticket',
|
||
type: 'escalation',
|
||
object_id: ticket.id,
|
||
article_id: article_id,
|
||
user_id: 1,
|
||
)
|
||
result.push ticket
|
||
next
|
||
end
|
||
|
||
# check if warning need to be sent
|
||
TransactionJob.perform_now(
|
||
object: 'Ticket',
|
||
type: 'escalation_warning',
|
||
object_id: ticket.id,
|
||
article_id: article_id,
|
||
user_id: 1,
|
||
)
|
||
result.push ticket
|
||
end
|
||
result
|
||
end
|
||
|
||
=begin
|
||
|
||
processes tickets which auto unassign time has reached
|
||
|
||
processed_tickets = Ticket.process_auto_unassign
|
||
|
||
returns
|
||
|
||
processed_tickets = [<Ticket>, ...]
|
||
|
||
=end
|
||
|
||
def self.process_auto_unassign
|
||
|
||
# process pending action tickets
|
||
state_ids = Ticket::State.by_category(:work_on).pluck(:id)
|
||
return [] if state_ids.blank?
|
||
|
||
result = []
|
||
groups = Group.where(active: true).where('assignment_timeout IS NOT NULL AND groups.assignment_timeout != 0')
|
||
return [] if groups.blank?
|
||
|
||
groups.each do |group|
|
||
next if group.assignment_timeout.blank?
|
||
|
||
ticket_ids = Ticket.where('state_id IN (?) AND owner_id != 1 AND group_id = ? AND last_owner_update_at IS NOT NULL', state_ids, group.id).limit(600).pluck(:id)
|
||
ticket_ids.each do |ticket_id|
|
||
ticket = Ticket.find_by(id: ticket_id)
|
||
next if !ticket
|
||
|
||
minutes_since_last_assignment = Time.zone.now - ticket.last_owner_update_at
|
||
next if (minutes_since_last_assignment / 60) <= group.assignment_timeout
|
||
|
||
Transaction.execute do
|
||
ticket.owner_id = 1
|
||
ticket.updated_at = Time.zone.now
|
||
ticket.updated_by_id = 1
|
||
ticket.save!
|
||
end
|
||
result.push ticket
|
||
end
|
||
end
|
||
|
||
result
|
||
end
|
||
|
||
=begin
|
||
|
||
merge tickets
|
||
|
||
ticket = Ticket.find(123)
|
||
result = ticket.merge_to(
|
||
ticket_id: 123,
|
||
user_id: 123,
|
||
)
|
||
|
||
returns
|
||
|
||
result = true|false
|
||
|
||
=end
|
||
|
||
def merge_to(data)
|
||
|
||
# prevent cross merging tickets
|
||
target_ticket = Ticket.find_by(id: data[:ticket_id])
|
||
raise 'no target ticket given' if !target_ticket
|
||
raise Exceptions::UnprocessableEntity, 'ticket already merged, no merge into merged ticket possible' if target_ticket.state.state_type.name == 'merged'
|
||
|
||
# check different ticket ids
|
||
raise Exceptions::UnprocessableEntity, 'Can\'t merge ticket with it self!' if id == target_ticket.id
|
||
|
||
# update articles
|
||
Transaction.execute context: 'merge' do
|
||
|
||
Ticket::Article.where(ticket_id: id).each(&:touch)
|
||
|
||
# quiet update of reassign of articles
|
||
Ticket::Article.where(ticket_id: id).update_all(['ticket_id = ?', data[:ticket_id]]) # rubocop:disable Rails/SkipsModelValidations
|
||
|
||
# mark target ticket as updated
|
||
# otherwise the "received_merge" history entry
|
||
# will be the same as the last updated_at
|
||
# which might be a long time ago
|
||
target_ticket.updated_at = Time.zone.now
|
||
|
||
# add merge event to both ticket's history (Issue #2469 - Add information "Ticket merged" to History)
|
||
target_ticket.history_log(
|
||
'received_merge',
|
||
data[:user_id],
|
||
id_to: target_ticket.id,
|
||
id_from: id,
|
||
)
|
||
history_log(
|
||
'merged_into',
|
||
data[:user_id],
|
||
id_to: target_ticket.id,
|
||
id_from: id,
|
||
)
|
||
|
||
# create new merge article
|
||
Ticket::Article.create(
|
||
ticket_id: id,
|
||
type_id: Ticket::Article::Type.lookup(name: 'note').id,
|
||
sender_id: Ticket::Article::Sender.lookup(name: 'Agent').id,
|
||
body: 'merged',
|
||
internal: false,
|
||
created_by_id: data[:user_id],
|
||
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
|
||
|
||
# search for all duplicate source and target links and destroy them
|
||
# before link merging
|
||
Link.duplicates(
|
||
object1_id: ticket_source_id,
|
||
object1_value: id,
|
||
object2_value: data[:ticket_id]
|
||
).destroy_all
|
||
Link.where(
|
||
link_object_source_id: ticket_source_id,
|
||
link_object_source_value: id,
|
||
).update_all(link_object_source_value: data[:ticket_id])
|
||
Link.where(
|
||
link_object_target_id: ticket_source_id,
|
||
link_object_target_value: id,
|
||
).update_all(link_object_target_value: data[:ticket_id])
|
||
# rubocop:enable Rails/SkipsModelValidations
|
||
|
||
# link tickets
|
||
Link.add(
|
||
link_type: 'parent',
|
||
link_object_source: 'Ticket',
|
||
link_object_source_value: data[:ticket_id],
|
||
link_object_target: 'Ticket',
|
||
link_object_target_value: id
|
||
)
|
||
|
||
# external sync references
|
||
ExternalSync.migrate('Ticket', id, target_ticket.id)
|
||
|
||
# set state to 'merged'
|
||
self.state_id = Ticket::State.lookup(name: 'merged').id
|
||
|
||
# rest owner
|
||
self.owner_id = 1
|
||
|
||
# save ticket
|
||
save!
|
||
|
||
# touch new ticket (to broadcast change)
|
||
target_ticket.touch # rubocop:disable Rails/SkipsModelValidations
|
||
|
||
EventBuffer.add('transaction', {
|
||
object: target_ticket.class.name,
|
||
type: 'update.received_merge',
|
||
data: target_ticket,
|
||
changes: {},
|
||
id: target_ticket.id,
|
||
user_id: UserInfo.current_user_id,
|
||
created_at: Time.zone.now,
|
||
})
|
||
|
||
EventBuffer.add('transaction', {
|
||
object: self.class.name,
|
||
type: 'update.merged_into',
|
||
data: self,
|
||
changes: {},
|
||
id: id,
|
||
user_id: UserInfo.current_user_id,
|
||
created_at: Time.zone.now,
|
||
})
|
||
end
|
||
true
|
||
end
|
||
|
||
=begin
|
||
|
||
check if online notification should be shown in general as already seen with current state
|
||
|
||
ticket = Ticket.find(1)
|
||
seen = ticket.online_notification_seen_state(user_id_check)
|
||
|
||
returns
|
||
|
||
result = true # or false
|
||
|
||
=end
|
||
|
||
def online_notification_seen_state(user_id_check = nil)
|
||
state = Ticket::State.lookup(id: state_id)
|
||
state_type = Ticket::StateType.lookup(id: state.state_type_id)
|
||
|
||
# always to set unseen for ticket owner and users which did not the update
|
||
return false if state_type.name != 'merged' && user_id_check && user_id_check == owner_id && user_id_check != updated_by_id
|
||
|
||
# set all to seen if pending action state is a closed or merged state
|
||
if state_type.name == 'pending action' && state.next_state_id
|
||
state = Ticket::State.lookup(id: state.next_state_id)
|
||
state_type = Ticket::StateType.lookup(id: state.state_type_id)
|
||
end
|
||
|
||
# set all to seen if new state is pending reminder state
|
||
if state_type.name == 'pending reminder'
|
||
if user_id_check
|
||
return false if owner_id == 1
|
||
return false if updated_by_id != owner_id && user_id_check == owner_id
|
||
|
||
return true
|
||
end
|
||
return true
|
||
end
|
||
|
||
# set all to seen if new state is a closed or merged state
|
||
return true if state_type.name == 'closed'
|
||
return true if state_type.name == 'merged'
|
||
|
||
false
|
||
end
|
||
|
||
=begin
|
||
|
||
get count of tickets and tickets which match on selector
|
||
|
||
@param [Hash] selectors hash with conditions
|
||
@oparam [Hash] options
|
||
|
||
@option options [String] :access can be 'full', 'read', 'create' or 'ignore' (ignore means a selector over all tickets), defaults to 'full'
|
||
@option options [Integer] :limit of tickets to return
|
||
@option options [User] :user is a current user
|
||
@option options [Integer] :execution_time is a current user
|
||
|
||
@return [Integer, [<Ticket>]]
|
||
|
||
@example
|
||
ticket_count, tickets = Ticket.selectors(params[:condition], limit: limit, current_user: current_user, access: 'full')
|
||
|
||
ticket_count # count of found tickets
|
||
tickets # tickets
|
||
|
||
=end
|
||
|
||
def self.selectors(selectors, options)
|
||
limit = options[:limit] || 10
|
||
current_user = options[:current_user]
|
||
access = options[:access] || 'full'
|
||
raise 'no selectors given' if !selectors
|
||
|
||
query, bind_params, tables = selector2sql(selectors, current_user: current_user, execution_time: options[:execution_time])
|
||
return [] if !query
|
||
|
||
ActiveRecord::Base.transaction(requires_new: true) do
|
||
|
||
if !current_user || access == 'ignore'
|
||
ticket_count = Ticket.distinct.where(query, *bind_params).joins(tables).count
|
||
tickets = Ticket.distinct.where(query, *bind_params).joins(tables).limit(limit)
|
||
return [ticket_count, tickets]
|
||
end
|
||
|
||
tickets = "TicketPolicy::#{access.camelize}Scope".constantize
|
||
.new(current_user).resolve
|
||
.distinct
|
||
.where(query, *bind_params)
|
||
.joins(tables)
|
||
|
||
return [tickets.count, tickets.limit(limit)]
|
||
rescue ActiveRecord::StatementInvalid => e
|
||
Rails.logger.error e
|
||
raise ActiveRecord::Rollback
|
||
|
||
end
|
||
[]
|
||
end
|
||
|
||
=begin
|
||
|
||
generate condition query to search for tickets based on condition
|
||
|
||
query_condition, bind_condition, tables = selector2sql(params[:condition], current_user: current_user)
|
||
|
||
condition example
|
||
|
||
{
|
||
'ticket.title' => {
|
||
operator: 'contains', # contains not
|
||
value: 'some value',
|
||
},
|
||
'ticket.state_id' => {
|
||
operator: 'is',
|
||
value: [1,2,5]
|
||
},
|
||
'ticket.created_at' => {
|
||
operator: 'after (absolute)', # after,before
|
||
value: '2015-10-17T06:00:00.000Z',
|
||
},
|
||
'ticket.created_at' => {
|
||
operator: 'within next (relative)', # within next, within last, after, before
|
||
range: 'day', # minute|hour|day|month|year
|
||
value: '25',
|
||
},
|
||
'ticket.owner_id' => {
|
||
operator: 'is', # is not
|
||
pre_condition: 'current_user.id',
|
||
},
|
||
'ticket.owner_id' => {
|
||
operator: 'is', # is not
|
||
pre_condition: 'specific',
|
||
value: 4711,
|
||
},
|
||
'ticket.escalation_at' => {
|
||
operator: 'is not', # not
|
||
value: nil,
|
||
},
|
||
'ticket.tags' => {
|
||
operator: 'contains all', # contains all|contains one|contains all not|contains one not
|
||
value: 'tag1, tag2',
|
||
},
|
||
}
|
||
|
||
=end
|
||
|
||
def self.selector2sql(selectors, options = {})
|
||
current_user = options[:current_user]
|
||
current_user_id = UserInfo.current_user_id
|
||
if current_user
|
||
current_user_id = current_user.id
|
||
end
|
||
return if !selectors
|
||
|
||
# remember query and bind params
|
||
query = ''
|
||
bind_params = []
|
||
like = Rails.application.config.db_like
|
||
|
||
if selectors.respond_to?(:permit!)
|
||
selectors = selectors.permit!.to_h
|
||
end
|
||
|
||
# get tables to join
|
||
tables = ''
|
||
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 attributes[0]
|
||
when 'customer'
|
||
tables += ', users customers'
|
||
query += 'tickets.customer_id = customers.id'
|
||
when 'organization'
|
||
tables += ', organizations'
|
||
query += 'tickets.organization_id = organizations.id'
|
||
when 'owner'
|
||
tables += ', users owners'
|
||
query += 'tickets.owner_id = owners.id'
|
||
when 'article'
|
||
tables += ', ticket_articles articles'
|
||
query += 'tickets.id = articles.ticket_id'
|
||
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}->#{attributes.inspect}"
|
||
end
|
||
end
|
||
|
||
# add conditions
|
||
no_result = false
|
||
selectors.each do |attribute, selector_raw|
|
||
|
||
# validation
|
||
raise "Invalid selector #{selector_raw.inspect}" if !selector_raw
|
||
raise "Invalid selector #{selector_raw.inspect}" if !selector_raw.respond_to?(:key?)
|
||
|
||
selector = selector_raw.stringify_keys
|
||
raise "Invalid selector, operator missing #{selector.inspect}" if !selector['operator']
|
||
raise "Invalid selector, operator #{selector['operator']} is invalid #{selector.inspect}" if !selector['operator'].match?(%r{^(is|is\snot|contains|contains\s(not|all|one|all\snot|one\snot)|(after|before)\s\(absolute\)|(within\snext|within\slast|after|before|till|from)\s\(relative\))|(is\sin\sworking\stime|is\snot\sin\sworking\stime)$})
|
||
|
||
# validate value / allow blank but only if pre_condition exists and is not specific
|
||
if !selector.key?('value') ||
|
||
(selector['value'].instance_of?(Array) && selector['value'].respond_to?(:blank?) && selector['value'].blank?) ||
|
||
(selector['operator'].start_with?('contains') && selector['value'].respond_to?(:blank?) && selector['value'].blank?)
|
||
return nil if selector['pre_condition'].nil?
|
||
return nil if selector['pre_condition'].respond_to?(:blank?) && selector['pre_condition'].blank?
|
||
return nil if selector['pre_condition'] == 'specific'
|
||
end
|
||
|
||
# validate pre_condition values
|
||
return nil if selector['pre_condition'] && selector['pre_condition'] !~ %r{^(not_set|current_user\.|specific)}
|
||
|
||
# get attributes
|
||
attributes = attribute.split('.')
|
||
attribute = "#{ActiveRecord::Base.connection.quote_table_name("#{attributes[0]}s")}.#{ActiveRecord::Base.connection.quote_column_name(attributes[1])}"
|
||
|
||
# magic selectors
|
||
if attributes[0] == 'ticket' && attributes[1] == 'out_of_office_replacement_id'
|
||
attribute = "#{ActiveRecord::Base.connection.quote_table_name("#{attributes[0]}s")}.#{ActiveRecord::Base.connection.quote_column_name('owner_id')}"
|
||
end
|
||
|
||
if attributes[0] == 'ticket' && attributes[1] == 'tags'
|
||
selector['value'] = selector['value'].split(',').collect(&:strip)
|
||
end
|
||
|
||
if selector['operator'].include?('in working time')
|
||
next if attributes[1] != 'calendar_id'
|
||
raise 'Please enable execution_time feature to use it (currently only allowed for triggers and schedulers)' if !options[:execution_time]
|
||
|
||
biz = Calendar.lookup(id: selector['value'])&.biz
|
||
next if biz.blank?
|
||
|
||
if (selector['operator'] == 'is in working time' && !biz.in_hours?(Time.zone.now)) || (selector['operator'] == 'is not in working time' && biz.in_hours?(Time.zone.now))
|
||
no_result = true
|
||
break
|
||
end
|
||
|
||
# skip to next condition
|
||
next
|
||
end
|
||
|
||
if query != ''
|
||
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?(%r{^(created_by|updated_by|owner|customer|user)_id})
|
||
query += "(#{attribute} IS NULL OR #{attribute} IN (?))"
|
||
bind_params.push 1
|
||
else
|
||
query += "#{attribute} IS NULL"
|
||
end
|
||
elsif selector['pre_condition'] == 'current_user.id'
|
||
raise "Use current_user.id in selector, but no current_user is set #{selector.inspect}" if !current_user_id
|
||
|
||
query += "#{attribute} IN (?)"
|
||
if attributes[1] == 'out_of_office_replacement_id'
|
||
bind_params.push User.find(current_user_id).out_of_office_agent_of.pluck(:id)
|
||
else
|
||
bind_params.push current_user_id
|
||
end
|
||
elsif selector['pre_condition'] == 'current_user.organization_id'
|
||
raise "Use current_user.id in selector, but no current_user is set #{selector.inspect}" if !current_user_id
|
||
|
||
query += "#{attribute} IN (?)"
|
||
user = User.find_by(id: current_user_id)
|
||
bind_params.push user.organization_id
|
||
else
|
||
# rubocop:disable Style/IfInsideElse
|
||
if selector['value'].nil?
|
||
query += "#{attribute} IS NULL"
|
||
else
|
||
if attributes[1] == 'out_of_office_replacement_id'
|
||
query += "#{attribute} IN (?)"
|
||
bind_params.push User.find(selector['value']).out_of_office_agent_of.pluck(:id)
|
||
else
|
||
if selector['value'].class != Array
|
||
selector['value'] = [selector['value']]
|
||
end
|
||
query += if selector['value'].include?('')
|
||
"(#{attribute} IN (?) OR #{attribute} IS NULL)"
|
||
else
|
||
"#{attribute} IN (?)"
|
||
end
|
||
bind_params.push selector['value']
|
||
end
|
||
end
|
||
# rubocop:enable Style/IfInsideElse
|
||
end
|
||
elsif selector['operator'] == 'is not'
|
||
if selector['pre_condition'] == 'not_set'
|
||
if attributes[1].match?(%r{^(created_by|updated_by|owner|customer|user)_id})
|
||
query += "(#{attribute} IS NOT NULL AND #{attribute} NOT IN (?))"
|
||
bind_params.push 1
|
||
else
|
||
query += "#{attribute} IS NOT NULL"
|
||
end
|
||
elsif selector['pre_condition'] == 'current_user.id'
|
||
query += "(#{attribute} IS NULL OR #{attribute} NOT IN (?))"
|
||
if attributes[1] == 'out_of_office_replacement_id'
|
||
bind_params.push User.find(current_user_id).out_of_office_agent_of.pluck(:id)
|
||
else
|
||
bind_params.push current_user_id
|
||
end
|
||
elsif selector['pre_condition'] == 'current_user.organization_id'
|
||
query += "(#{attribute} IS NULL OR #{attribute} NOT IN (?))"
|
||
user = User.find_by(id: current_user_id)
|
||
bind_params.push user.organization_id
|
||
else
|
||
# rubocop:disable Style/IfInsideElse
|
||
if selector['value'].nil?
|
||
query += "#{attribute} IS NOT NULL"
|
||
else
|
||
if attributes[1] == 'out_of_office_replacement_id'
|
||
bind_params.push User.find(selector['value']).out_of_office_agent_of.pluck(:id)
|
||
query += "(#{attribute} IS NULL OR #{attribute} NOT IN (?))"
|
||
else
|
||
if selector['value'].class != Array
|
||
selector['value'] = [selector['value']]
|
||
end
|
||
query += if selector['value'].include?('')
|
||
"(#{attribute} IS NOT NULL AND #{attribute} NOT IN (?))"
|
||
else
|
||
"(#{attribute} IS NULL OR #{attribute} NOT IN (?))"
|
||
end
|
||
bind_params.push selector['value']
|
||
end
|
||
end
|
||
# rubocop:enable Style/IfInsideElse
|
||
end
|
||
elsif selector['operator'] == 'contains'
|
||
query += "#{attribute} #{like} (?)"
|
||
value = "%#{selector['value']}%"
|
||
bind_params.push value
|
||
elsif selector['operator'] == 'contains not'
|
||
query += "#{attribute} NOT #{like} (?)"
|
||
value = "%#{selector['value']}%"
|
||
bind_params.push value
|
||
elsif selector['operator'] == 'contains all' && attributes[0] == 'ticket' && attributes[1] == 'tags'
|
||
query += "? = (
|
||
SELECT
|
||
COUNT(*)
|
||
FROM
|
||
tag_objects,
|
||
tag_items,
|
||
tags
|
||
WHERE
|
||
tickets.id = tags.o_id AND
|
||
tag_objects.id = tags.tag_object_id AND
|
||
tag_objects.name = 'Ticket' AND
|
||
tag_items.id = tags.tag_item_id AND
|
||
tag_items.name IN (?)
|
||
)"
|
||
bind_params.push selector['value'].count
|
||
bind_params.push selector['value']
|
||
elsif selector['operator'] == 'contains one' && attributes[0] == 'ticket' && attributes[1] == 'tags'
|
||
tables += ', tag_objects, tag_items, tags'
|
||
query += "
|
||
tickets.id = tags.o_id AND
|
||
tag_objects.id = tags.tag_object_id AND
|
||
tag_objects.name = 'Ticket' AND
|
||
tag_items.id = tags.tag_item_id AND
|
||
tag_items.name IN (?)"
|
||
|
||
bind_params.push selector['value']
|
||
elsif selector['operator'] == 'contains all not' && attributes[0] == 'ticket' && attributes[1] == 'tags'
|
||
query += "0 = (
|
||
SELECT
|
||
COUNT(*)
|
||
FROM
|
||
tag_objects,
|
||
tag_items,
|
||
tags
|
||
WHERE
|
||
tickets.id = tags.o_id AND
|
||
tag_objects.id = tags.tag_object_id AND
|
||
tag_objects.name = 'Ticket' AND
|
||
tag_items.id = tags.tag_item_id AND
|
||
tag_items.name IN (?)
|
||
)"
|
||
bind_params.push selector['value']
|
||
elsif selector['operator'] == 'contains one not' && attributes[0] == 'ticket' && attributes[1] == 'tags'
|
||
query += "(
|
||
SELECT
|
||
COUNT(*)
|
||
FROM
|
||
tag_objects,
|
||
tag_items,
|
||
tags
|
||
WHERE
|
||
tickets.id = tags.o_id AND
|
||
tag_objects.id = tags.tag_object_id AND
|
||
tag_objects.name = 'Ticket' AND
|
||
tag_items.id = tags.tag_item_id AND
|
||
tag_items.name IN (?)
|
||
) BETWEEN 0 AND 0"
|
||
bind_params.push selector['value']
|
||
elsif selector['operator'] == 'before (absolute)'
|
||
query += "#{attribute} <= ?"
|
||
bind_params.push selector['value']
|
||
elsif selector['operator'] == 'after (absolute)'
|
||
query += "#{attribute} >= ?"
|
||
bind_params.push selector['value']
|
||
elsif selector['operator'] == 'within last (relative)'
|
||
query += "#{attribute} BETWEEN ? AND ?"
|
||
time = nil
|
||
case selector['range']
|
||
when 'minute'
|
||
time = selector['value'].to_i.minutes.ago
|
||
when 'hour'
|
||
time = selector['value'].to_i.hours.ago
|
||
when 'day'
|
||
time = selector['value'].to_i.days.ago
|
||
when 'month'
|
||
time = selector['value'].to_i.months.ago
|
||
when 'year'
|
||
time = selector['value'].to_i.years.ago
|
||
else
|
||
raise "Unknown selector attributes '#{selector.inspect}'"
|
||
end
|
||
bind_params.push time
|
||
bind_params.push Time.zone.now
|
||
elsif selector['operator'] == 'within next (relative)'
|
||
query += "#{attribute} BETWEEN ? AND ?"
|
||
time = nil
|
||
case selector['range']
|
||
when 'minute'
|
||
time = selector['value'].to_i.minutes.from_now
|
||
when 'hour'
|
||
time = selector['value'].to_i.hours.from_now
|
||
when 'day'
|
||
time = selector['value'].to_i.days.from_now
|
||
when 'month'
|
||
time = selector['value'].to_i.months.from_now
|
||
when 'year'
|
||
time = selector['value'].to_i.years.from_now
|
||
else
|
||
raise "Unknown selector attributes '#{selector.inspect}'"
|
||
end
|
||
bind_params.push Time.zone.now
|
||
bind_params.push time
|
||
elsif selector['operator'] == 'before (relative)'
|
||
query += "#{attribute} <= ?"
|
||
time = nil
|
||
case selector['range']
|
||
when 'minute'
|
||
time = selector['value'].to_i.minutes.ago
|
||
when 'hour'
|
||
time = selector['value'].to_i.hours.ago
|
||
when 'day'
|
||
time = selector['value'].to_i.days.ago
|
||
when 'month'
|
||
time = selector['value'].to_i.months.ago
|
||
when 'year'
|
||
time = selector['value'].to_i.years.ago
|
||
else
|
||
raise "Unknown selector attributes '#{selector.inspect}'"
|
||
end
|
||
bind_params.push time
|
||
elsif selector['operator'] == 'after (relative)'
|
||
query += "#{attribute} >= ?"
|
||
time = nil
|
||
case selector['range']
|
||
when 'minute'
|
||
time = selector['value'].to_i.minutes.from_now
|
||
when 'hour'
|
||
time = selector['value'].to_i.hours.from_now
|
||
when 'day'
|
||
time = selector['value'].to_i.days.from_now
|
||
when 'month'
|
||
time = selector['value'].to_i.months.from_now
|
||
when 'year'
|
||
time = selector['value'].to_i.years.from_now
|
||
else
|
||
raise "Unknown selector attributes '#{selector.inspect}'"
|
||
end
|
||
bind_params.push time
|
||
elsif selector['operator'] == 'till (relative)'
|
||
query += "#{attribute} <= ?"
|
||
time = nil
|
||
case selector['range']
|
||
when 'minute'
|
||
time = selector['value'].to_i.minutes.from_now
|
||
when 'hour'
|
||
time = selector['value'].to_i.hours.from_now
|
||
when 'day'
|
||
time = selector['value'].to_i.days.from_now
|
||
when 'month'
|
||
time = selector['value'].to_i.months.from_now
|
||
when 'year'
|
||
time = selector['value'].to_i.years.from_now
|
||
else
|
||
raise "Unknown selector attributes '#{selector.inspect}'"
|
||
end
|
||
bind_params.push time
|
||
elsif selector['operator'] == 'from (relative)'
|
||
query += "#{attribute} >= ?"
|
||
time = nil
|
||
case selector['range']
|
||
when 'minute'
|
||
time = selector['value'].to_i.minutes.ago
|
||
when 'hour'
|
||
time = selector['value'].to_i.hours.ago
|
||
when 'day'
|
||
time = selector['value'].to_i.days.ago
|
||
when 'month'
|
||
time = selector['value'].to_i.months.ago
|
||
when 'year'
|
||
time = selector['value'].to_i.years.ago
|
||
else
|
||
raise "Unknown selector attributes '#{selector.inspect}'"
|
||
end
|
||
bind_params.push time
|
||
else
|
||
raise "Invalid operator '#{selector['operator']}' for '#{selector['value'].inspect}'"
|
||
end
|
||
end
|
||
|
||
return if no_result
|
||
|
||
[query, bind_params, tables]
|
||
end
|
||
|
||
=begin
|
||
|
||
perform changes on ticket
|
||
|
||
ticket.perform_changes(trigger, 'trigger', item, current_user_id)
|
||
|
||
# or
|
||
|
||
ticket.perform_changes(job, 'job', item, current_user_id)
|
||
|
||
=end
|
||
|
||
def perform_changes(performable, perform_origin, item = nil, current_user_id = nil)
|
||
|
||
perform = performable.perform
|
||
logger.debug { "Perform #{perform_origin} #{perform.inspect} on Ticket.find(#{id})" }
|
||
|
||
article = begin
|
||
Ticket::Article.find_by(id: item.try(:dig, :article_id))
|
||
rescue ArgumentError
|
||
nil
|
||
end
|
||
|
||
# if the configuration contains the deletion of the ticket then
|
||
# we skip all other ticket changes because they does not matter
|
||
if perform['ticket.action'].present? && perform['ticket.action']['value'] == 'delete'
|
||
perform.each_key do |key|
|
||
(object_name, attribute) = key.split('.', 2)
|
||
next if object_name != 'ticket'
|
||
next if attribute == 'action'
|
||
|
||
perform.delete(key)
|
||
end
|
||
end
|
||
|
||
perform_notification = {}
|
||
perform_article = {}
|
||
changed = false
|
||
perform.each do |key, value|
|
||
(object_name, attribute) = key.split('.', 2)
|
||
raise "Unable to update object #{object_name}.#{attribute}, only can update tickets, send notifications and create articles!" if object_name != 'ticket' && object_name != 'article' && object_name != 'notification'
|
||
|
||
# send notification/create article (after changes are done)
|
||
if object_name == 'article'
|
||
perform_article[key] = value
|
||
next
|
||
end
|
||
if object_name == 'notification'
|
||
perform_notification[key] = value
|
||
next
|
||
end
|
||
|
||
# Apply pending_time changes
|
||
if key == 'ticket.pending_time'
|
||
new_value = case value['operator']
|
||
when 'static'
|
||
value['value']
|
||
when 'relative'
|
||
pendtil = Time.zone.now
|
||
val = value['value'].to_i
|
||
|
||
case value['range']
|
||
when 'day'
|
||
pendtil += val.days
|
||
when 'minute'
|
||
pendtil += val.minutes
|
||
when 'hour'
|
||
pendtil += val.hours
|
||
when 'month'
|
||
pendtil += val.months
|
||
when 'year'
|
||
pendtil += val.years
|
||
end
|
||
|
||
pendtil
|
||
end
|
||
|
||
if new_value
|
||
self[attribute] = new_value
|
||
changed = true
|
||
next
|
||
end
|
||
end
|
||
|
||
# update tags
|
||
if key == 'ticket.tags'
|
||
next if value['value'].blank?
|
||
|
||
tags = value['value'].split(',')
|
||
case value['operator']
|
||
when 'add'
|
||
tags.each do |tag|
|
||
tag_add(tag, current_user_id || 1)
|
||
end
|
||
when 'remove'
|
||
tags.each do |tag|
|
||
tag_remove(tag, current_user_id || 1)
|
||
end
|
||
else
|
||
logger.error "Unknown #{attribute} operator #{value['operator']}"
|
||
end
|
||
next
|
||
end
|
||
|
||
# delete ticket
|
||
if key == 'ticket.action'
|
||
next if value['value'].blank?
|
||
next if value['value'] != 'delete'
|
||
|
||
logger.info { "Deleted ticket from #{perform_origin} #{perform.inspect} Ticket.find(#{id})" }
|
||
destroy!
|
||
next
|
||
end
|
||
|
||
# lookup pre_condition
|
||
if value['pre_condition']
|
||
if value['pre_condition'].start_with?('not_set')
|
||
value['value'] = 1
|
||
elsif value['pre_condition'].start_with?('current_user.')
|
||
raise 'Unable to use current_user, got no current_user_id for ticket.perform_changes' if !current_user_id
|
||
|
||
value['value'] = current_user_id
|
||
end
|
||
end
|
||
|
||
# update ticket
|
||
next if self[attribute].to_s == value['value'].to_s
|
||
|
||
changed = true
|
||
|
||
self[attribute] = value['value']
|
||
logger.debug { "set #{object_name}.#{attribute} = #{value['value'].inspect} for ticket_id #{id}" }
|
||
end
|
||
|
||
if changed
|
||
save!
|
||
end
|
||
|
||
objects = build_notification_template_objects(article)
|
||
|
||
perform_article.each do |key, value|
|
||
raise 'Unable to create article, we only support article.note' if key != 'article.note'
|
||
|
||
add_trigger_note(id, value, objects, perform_origin)
|
||
end
|
||
|
||
perform_notification.each do |key, value|
|
||
|
||
# send notification
|
||
case key
|
||
when 'notification.sms'
|
||
send_sms_notification(value, article, perform_origin)
|
||
next
|
||
when 'notification.email'
|
||
send_email_notification(value, article, perform_origin)
|
||
when 'notification.webhook'
|
||
TriggerWebhookJob.perform_later(performable, self, article)
|
||
end
|
||
end
|
||
|
||
true
|
||
end
|
||
|
||
=begin
|
||
|
||
perform changes on ticket
|
||
|
||
ticket.add_trigger_note(ticket_id, note, objects, perform_origin)
|
||
|
||
=end
|
||
|
||
def add_trigger_note(ticket_id, note, objects, perform_origin)
|
||
rendered_subject = NotificationFactory::Mailer.template(
|
||
templateInline: note[:subject],
|
||
objects: objects,
|
||
quote: true,
|
||
)
|
||
|
||
rendered_body = NotificationFactory::Mailer.template(
|
||
templateInline: note[:body],
|
||
objects: objects,
|
||
quote: true,
|
||
)
|
||
|
||
Ticket::Article.create!(
|
||
ticket_id: ticket_id,
|
||
subject: rendered_subject,
|
||
content_type: 'text/html',
|
||
body: rendered_body,
|
||
internal: note[:internal],
|
||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||
type: Ticket::Article::Type.find_by(name: 'note'),
|
||
preferences: {
|
||
perform_origin: perform_origin,
|
||
notification: true,
|
||
},
|
||
updated_by_id: 1,
|
||
created_by_id: 1,
|
||
)
|
||
end
|
||
|
||
=begin
|
||
|
||
perform active triggers on ticket
|
||
|
||
Ticket.perform_triggers(ticket, article, item, options)
|
||
|
||
=end
|
||
|
||
def self.perform_triggers(ticket, article, item, options = {})
|
||
recursive = Setting.get('ticket_trigger_recursive')
|
||
type = options[:type] || item[:type]
|
||
local_options = options.clone
|
||
local_options[:type] = type
|
||
local_options[:reset_user_id] = true
|
||
local_options[:disable] = ['Transaction::Notification']
|
||
local_options[:trigger_ids] ||= {}
|
||
local_options[:trigger_ids][ticket.id.to_s] ||= []
|
||
local_options[:loop_count] ||= 0
|
||
local_options[:loop_count] += 1
|
||
|
||
ticket_trigger_recursive_max_loop = Setting.get('ticket_trigger_recursive_max_loop')&.to_i || 10
|
||
if local_options[:loop_count] > ticket_trigger_recursive_max_loop
|
||
message = "Stopped perform_triggers for this object (Ticket/#{ticket.id}), because loop count was #{local_options[:loop_count]}!"
|
||
logger.info { message }
|
||
return [false, message]
|
||
end
|
||
|
||
triggers = if Rails.configuration.db_case_sensitive
|
||
::Trigger.where(active: true).order(Arel.sql('LOWER(name)'))
|
||
else
|
||
::Trigger.where(active: true).order(:name)
|
||
end
|
||
return [true, 'No triggers active'] if triggers.blank?
|
||
|
||
# check if notification should be send because of customer emails
|
||
send_notification = true
|
||
if local_options[:send_notification] == false
|
||
send_notification = false
|
||
elsif item[:article_id]
|
||
article = Ticket::Article.lookup(id: item[:article_id])
|
||
if article&.preferences && article.preferences['send-auto-response'] == false
|
||
send_notification = false
|
||
end
|
||
end
|
||
|
||
Transaction.execute(local_options) do
|
||
triggers.each do |trigger|
|
||
logger.debug { "Probe trigger (#{trigger.name}/#{trigger.id}) for this object (Ticket:#{ticket.id}/Loop:#{local_options[:loop_count]})" }
|
||
|
||
condition = trigger.condition
|
||
|
||
# check if one article attribute is used
|
||
one_has_changed_done = false
|
||
article_selector = false
|
||
trigger.condition.each_key do |key|
|
||
(object_name, attribute) = key.split('.', 2)
|
||
next if object_name != 'article'
|
||
next if attribute == 'id'
|
||
|
||
article_selector = true
|
||
end
|
||
if article && article_selector
|
||
one_has_changed_done = true
|
||
end
|
||
if article && type == 'update'
|
||
one_has_changed_done = true
|
||
end
|
||
|
||
# check ticket "has changed" options
|
||
has_changed_done = true
|
||
condition.each do |key, value|
|
||
next if value.blank?
|
||
next if value['operator'].blank?
|
||
next if !value['operator']['has changed']
|
||
|
||
# remove condition item, because it has changed
|
||
(object_name, attribute) = key.split('.', 2)
|
||
next if object_name != 'ticket'
|
||
next if item[:changes].blank?
|
||
next if !item[:changes].key?(attribute)
|
||
|
||
condition.delete(key)
|
||
one_has_changed_done = true
|
||
end
|
||
|
||
# check if we have not matching "has changed" attributes
|
||
condition.each_value do |value|
|
||
next if value.blank?
|
||
next if value['operator'].blank?
|
||
next if !value['operator']['has changed']
|
||
|
||
has_changed_done = false
|
||
break
|
||
end
|
||
|
||
# check ticket action
|
||
if condition['ticket.action']
|
||
next if condition['ticket.action']['operator'] == 'is' && condition['ticket.action']['value'] != type
|
||
next if condition['ticket.action']['operator'] != 'is' && condition['ticket.action']['value'] == type
|
||
|
||
condition.delete('ticket.action')
|
||
end
|
||
next if !has_changed_done
|
||
|
||
# check in min one attribute of condition has changed on update
|
||
one_has_changed_condition = false
|
||
if type == 'update'
|
||
|
||
# verify if ticket condition exists
|
||
condition.each_key do |key|
|
||
(object_name, attribute) = key.split('.', 2)
|
||
next if object_name != 'ticket'
|
||
|
||
one_has_changed_condition = true
|
||
next if item[:changes].blank?
|
||
next if !item[:changes].key?(attribute)
|
||
|
||
one_has_changed_done = true
|
||
break
|
||
end
|
||
next if one_has_changed_condition && !one_has_changed_done
|
||
end
|
||
|
||
# check if ticket selector is matching
|
||
condition['ticket.id'] = {
|
||
operator: 'is',
|
||
value: ticket.id,
|
||
}
|
||
next if article_selector && !article
|
||
|
||
# check if article selector is matching
|
||
if article_selector
|
||
condition['article.id'] = {
|
||
operator: 'is',
|
||
value: article.id,
|
||
}
|
||
end
|
||
|
||
user_id = ticket.updated_by_id
|
||
if article
|
||
user_id = article.updated_by_id
|
||
end
|
||
|
||
user = User.lookup(id: user_id)
|
||
|
||
# verify is condition is matching
|
||
ticket_count, tickets = Ticket.selectors(condition, limit: 1, execution_time: true, current_user: user, access: 'ignore')
|
||
|
||
next if ticket_count.blank?
|
||
next if ticket_count.zero?
|
||
next if tickets.first.id != ticket.id
|
||
|
||
if recursive == false && local_options[:loop_count] > 1
|
||
message = "Do not execute recursive triggers per default until Zammad 3.0. With Zammad 3.0 and higher the following trigger is executed '#{trigger.name}' on Ticket:#{ticket.id}. Please review your current triggers and change them if needed."
|
||
logger.info { message }
|
||
return [true, message]
|
||
end
|
||
|
||
if article && send_notification == false && trigger.perform['notification.email'] && trigger.perform['notification.email']['recipient']
|
||
recipient = trigger.perform['notification.email']['recipient']
|
||
local_options[:send_notification] = false
|
||
if recipient.include?('ticket_customer') || recipient.include?('article_last_sender')
|
||
logger.info { "Skip trigger (#{trigger.name}/#{trigger.id}) because sender do not want to get auto responder for object (Ticket/#{ticket.id}/Article/#{article.id})" }
|
||
next
|
||
end
|
||
end
|
||
|
||
if local_options[:trigger_ids][ticket.id.to_s].include?(trigger.id)
|
||
logger.info { "Skip trigger (#{trigger.name}/#{trigger.id}) because was already executed for this object (Ticket:#{ticket.id}/Loop:#{local_options[:loop_count]})" }
|
||
next
|
||
end
|
||
local_options[:trigger_ids][ticket.id.to_s].push trigger.id
|
||
logger.info { "Execute trigger (#{trigger.name}/#{trigger.id}) for this object (Ticket:#{ticket.id}/Loop:#{local_options[:loop_count]})" }
|
||
|
||
ticket.perform_changes(trigger, 'trigger', item, user_id)
|
||
|
||
if recursive == true
|
||
TransactionDispatcher.commit(local_options)
|
||
end
|
||
end
|
||
end
|
||
[true, ticket, local_options]
|
||
end
|
||
|
||
=begin
|
||
|
||
get all email references headers of a ticket, to exclude some, parse it as array into method
|
||
|
||
references = ticket.get_references
|
||
|
||
result
|
||
|
||
['message-id-1234', 'message-id-5678']
|
||
|
||
ignore references header(s)
|
||
|
||
references = ticket.get_references(['message-id-5678'])
|
||
|
||
result
|
||
|
||
['message-id-1234']
|
||
|
||
=end
|
||
|
||
def get_references(ignore = [])
|
||
references = []
|
||
Ticket::Article.select('in_reply_to, message_id').where(ticket_id: id).each do |article|
|
||
if article.in_reply_to.present?
|
||
references.push article.in_reply_to
|
||
end
|
||
next if article.message_id.blank?
|
||
|
||
references.push article.message_id
|
||
end
|
||
ignore.each do |item|
|
||
references.delete(item)
|
||
end
|
||
references
|
||
end
|
||
|
||
=begin
|
||
|
||
get all articles of a ticket in correct order (overwrite active record default method)
|
||
|
||
articles = ticket.articles
|
||
|
||
result
|
||
|
||
[article1, article2]
|
||
|
||
=end
|
||
|
||
def articles
|
||
Ticket::Article.where(ticket_id: id).order(:created_at, :id)
|
||
end
|
||
|
||
# Get whichever #last_contact_* was later
|
||
# This is not identical to #last_contact_at
|
||
# It returns time to last original (versus follow up) contact
|
||
# @return [Time, nil]
|
||
def last_original_update_at
|
||
[last_contact_agent_at, last_contact_customer_at].compact.max
|
||
end
|
||
|
||
# true if conversation did happen and agent responded
|
||
# false if customer is waiting for response or agent reached out and customer did not respond yet
|
||
# @return [Bool]
|
||
def agent_responded?
|
||
return false if last_contact_customer_at.blank?
|
||
return false if last_contact_agent_at.blank?
|
||
|
||
last_contact_customer_at < last_contact_agent_at
|
||
end
|
||
|
||
private
|
||
|
||
def check_generate
|
||
return true if number
|
||
|
||
self.number = Ticket::Number.generate
|
||
true
|
||
end
|
||
|
||
def check_title
|
||
return true if !title
|
||
|
||
title.gsub!(%r{\s|\t|\r}, ' ')
|
||
true
|
||
end
|
||
|
||
def check_defaults
|
||
if !owner_id
|
||
self.owner_id = 1
|
||
end
|
||
return true if !customer_id
|
||
|
||
customer = User.find_by(id: customer_id)
|
||
return true if !customer
|
||
return true if organization_id == customer.organization_id
|
||
|
||
self.organization_id = customer.organization_id
|
||
true
|
||
end
|
||
|
||
def reset_pending_time
|
||
|
||
# ignore if no state has changed
|
||
return true if !changes_to_save['state_id']
|
||
|
||
# ignore if new state is blank and
|
||
# let handle ActiveRecord the error
|
||
return if state_id.blank?
|
||
|
||
# check if new state isn't pending*
|
||
current_state = Ticket::State.lookup(id: state_id)
|
||
current_state_type = Ticket::StateType.lookup(id: current_state.state_type_id)
|
||
|
||
# in case, set pending_time to nil
|
||
return true if current_state_type.name.match?(%r{^pending}i)
|
||
|
||
self.pending_time = nil
|
||
true
|
||
end
|
||
|
||
def set_default_state
|
||
return true if state_id
|
||
|
||
default_ticket_state = Ticket::State.find_by(default_create: true)
|
||
return true if !default_ticket_state
|
||
|
||
self.state_id = default_ticket_state.id
|
||
true
|
||
end
|
||
|
||
def set_default_priority
|
||
return true if priority_id
|
||
|
||
default_ticket_priority = Ticket::Priority.find_by(default_create: true)
|
||
return true if !default_ticket_priority
|
||
|
||
self.priority_id = default_ticket_priority.id
|
||
true
|
||
end
|
||
|
||
def check_owner_active
|
||
return true if Setting.get('import_mode')
|
||
|
||
# only change the owner for non closed Tickets for historical/reporting reasons
|
||
return true if state.present? && Ticket::StateType.lookup(id: state.state_type_id)&.name == 'closed'
|
||
|
||
# return when ticket is unassigned
|
||
return true if owner_id.blank?
|
||
return true if owner_id == 1
|
||
|
||
# return if owner is active, is agent and has access to group of ticket
|
||
return true if owner.active? && owner.permissions?('ticket.agent') && owner.group_access?(group_id, 'full')
|
||
|
||
# else set the owner of the ticket to the default user as unassigned
|
||
self.owner_id = 1
|
||
true
|
||
end
|
||
|
||
# articles.last breaks (returns the wrong article)
|
||
# if another email notification trigger preceded this one
|
||
# (see https://github.com/zammad/zammad/issues/1543)
|
||
def build_notification_template_objects(article)
|
||
{
|
||
ticket: self,
|
||
article: article || articles.last
|
||
}
|
||
end
|
||
|
||
def send_email_notification(value, article, perform_origin)
|
||
# value['recipient'] was a string in the past (single-select) so we convert it to array if needed
|
||
value_recipient = Array(value['recipient'])
|
||
|
||
recipients_raw = []
|
||
value_recipient.each do |recipient|
|
||
case recipient
|
||
when 'article_last_sender'
|
||
if article.present?
|
||
if article.reply_to.present?
|
||
recipients_raw.push(article.reply_to)
|
||
elsif article.from.present?
|
||
recipients_raw.push(article.from)
|
||
elsif article.origin_by_id
|
||
email = User.find_by(id: article.origin_by_id).email
|
||
recipients_raw.push(email)
|
||
elsif article.created_by_id
|
||
email = User.find_by(id: article.created_by_id).email
|
||
recipients_raw.push(email)
|
||
end
|
||
end
|
||
when 'ticket_customer'
|
||
email = User.find_by(id: customer_id).email
|
||
recipients_raw.push(email)
|
||
when 'ticket_owner'
|
||
email = User.find_by(id: owner_id).email
|
||
recipients_raw.push(email)
|
||
when 'ticket_agents'
|
||
User.group_access(group_id, 'full').sort_by(&:login).each do |user|
|
||
recipients_raw.push(user.email)
|
||
end
|
||
when %r{\Auserid_(\d+)\z}
|
||
user = User.lookup(id: $1)
|
||
if !user
|
||
logger.warn "Can't find configured Trigger Email recipient User with ID '#{$1}'"
|
||
next
|
||
end
|
||
recipients_raw.push(user.email)
|
||
else
|
||
logger.error "Unknown email notification recipient '#{recipient}'"
|
||
next
|
||
end
|
||
end
|
||
|
||
recipients_checked = []
|
||
recipients_raw.each do |recipient_email|
|
||
|
||
users = User.where(email: recipient_email)
|
||
next if users.any? { |user| !trigger_based_notification?(user) }
|
||
|
||
# send notifications only to email addresses
|
||
next if recipient_email.blank?
|
||
|
||
# check if address is valid
|
||
begin
|
||
Mail::AddressList.new(recipient_email).addresses.each do |address|
|
||
recipient_email = address.address
|
||
email_address_validation = EmailAddressValidation.new(recipient_email)
|
||
break if recipient_email.present? && email_address_validation.valid_format?
|
||
end
|
||
rescue
|
||
if recipient_email.present?
|
||
if recipient_email !~ %r{^(.+?)<(.+?)@(.+?)>$}
|
||
next # no usable format found
|
||
end
|
||
|
||
recipient_email = "#{$2}@#{$3}" # rubocop:disable Lint/OutOfRangeRegexpRef
|
||
end
|
||
end
|
||
|
||
email_address_validation = EmailAddressValidation.new(recipient_email)
|
||
next if !email_address_validation.valid_format?
|
||
|
||
# do not send notification if system address
|
||
next if EmailAddress.exists?(email: recipient_email.downcase)
|
||
|
||
# do not sent notifications to this recipients
|
||
send_no_auto_response_reg_exp = Setting.get('send_no_auto_response_reg_exp')
|
||
begin
|
||
next if recipient_email.match?(%r{#{send_no_auto_response_reg_exp}}i)
|
||
rescue => e
|
||
logger.error "Invalid regex '#{send_no_auto_response_reg_exp}' in setting send_no_auto_response_reg_exp"
|
||
logger.error e
|
||
next if recipient_email.match?(%r{(mailer-daemon|postmaster|abuse|root|noreply|noreply.+?|no-reply|no-reply.+?)@.+?}i)
|
||
end
|
||
|
||
# check if notification should be send because of customer emails
|
||
if article.present? && article.preferences.fetch('is-auto-response', false) == true && article.from && article.from =~ %r{#{Regexp.quote(recipient_email)}}i
|
||
logger.info "Send no trigger based notification to #{recipient_email} because of auto response tagged incoming email"
|
||
next
|
||
end
|
||
|
||
# loop protection / check if maximal count of trigger mail has reached
|
||
map = {
|
||
10 => 10,
|
||
30 => 15,
|
||
60 => 25,
|
||
180 => 50,
|
||
600 => 100,
|
||
}
|
||
skip = false
|
||
map.each do |minutes, count|
|
||
already_sent = Ticket::Article.where(
|
||
ticket_id: id,
|
||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||
type: Ticket::Article::Type.find_by(name: 'email'),
|
||
).where('ticket_articles.created_at > ? AND ticket_articles.to LIKE ?', Time.zone.now - minutes.minutes, "%#{recipient_email.strip}%").count
|
||
next if already_sent < count
|
||
|
||
logger.info "Send no trigger based notification to #{recipient_email} because already sent #{count} for this ticket within last #{minutes} minutes (loop protection)"
|
||
skip = true
|
||
break
|
||
end
|
||
next if skip
|
||
|
||
map = {
|
||
10 => 30,
|
||
30 => 60,
|
||
60 => 120,
|
||
180 => 240,
|
||
600 => 360,
|
||
}
|
||
skip = false
|
||
map.each do |minutes, count|
|
||
already_sent = Ticket::Article.where(
|
||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||
type: Ticket::Article::Type.find_by(name: 'email'),
|
||
).where('ticket_articles.created_at > ? AND ticket_articles.to LIKE ?', Time.zone.now - minutes.minutes, "%#{recipient_email.strip}%").count
|
||
next if already_sent < count
|
||
|
||
logger.info "Send no trigger based notification to #{recipient_email} because already sent #{count} in total within last #{minutes} minutes (loop protection)"
|
||
skip = true
|
||
break
|
||
end
|
||
next if skip
|
||
|
||
email = recipient_email.downcase.strip
|
||
next if recipients_checked.include?(email)
|
||
|
||
recipients_checked.push(email)
|
||
end
|
||
|
||
return if recipients_checked.blank?
|
||
|
||
recipient_string = recipients_checked.join(', ')
|
||
|
||
group_id = self.group_id
|
||
return if !group_id
|
||
|
||
email_address = Group.find(group_id).email_address
|
||
if !email_address
|
||
logger.info "Unable to send trigger based notification to #{recipient_string} because no email address is set for group '#{group.name}'"
|
||
return
|
||
end
|
||
|
||
if !email_address.channel_id
|
||
logger.info "Unable to send trigger based notification to #{recipient_string} because no channel is set for email address '#{email_address.email}' (id: #{email_address.id})"
|
||
return
|
||
end
|
||
|
||
security = nil
|
||
if Setting.get('smime_integration')
|
||
sign = value['sign'].present? && value['sign'] != 'no'
|
||
encryption = value['encryption'].present? && value['encryption'] != 'no'
|
||
security = {
|
||
type: 'S/MIME',
|
||
sign: {
|
||
success: false,
|
||
},
|
||
encryption: {
|
||
success: false,
|
||
},
|
||
}
|
||
|
||
if sign
|
||
sign_found = false
|
||
begin
|
||
list = Mail::AddressList.new(email_address.email)
|
||
from = list.addresses.first.to_s
|
||
cert = SMIMECertificate.for_sender_email_address(from)
|
||
if cert && !cert.expired?
|
||
sign_found = true
|
||
security[:sign][:success] = true
|
||
security[:sign][:comment] = "certificate for #{email_address.email} found"
|
||
end
|
||
rescue # rubocop:disable Lint/SuppressedException
|
||
end
|
||
|
||
if value['sign'] == 'discard' && !sign_found
|
||
logger.info "Unable to send trigger based notification to #{recipient_string} because of missing group #{group.name} email #{email_address.email} certificate for signing (discarding notification)."
|
||
return
|
||
end
|
||
end
|
||
|
||
if encryption
|
||
certs_found = false
|
||
begin
|
||
SMIMECertificate.for_recipipent_email_addresses!(recipients_checked)
|
||
certs_found = true
|
||
security[:encryption][:success] = true
|
||
security[:encryption][:comment] = "certificates found for #{recipient_string}"
|
||
rescue # rubocop:disable Lint/SuppressedException
|
||
end
|
||
|
||
if value['encryption'] == 'discard' && !certs_found
|
||
logger.info "Unable to send trigger based notification to #{recipient_string} because public certificate is not available for encryption (discarding notification)."
|
||
return
|
||
end
|
||
end
|
||
end
|
||
|
||
objects = build_notification_template_objects(article)
|
||
|
||
# get subject
|
||
subject = NotificationFactory::Mailer.template(
|
||
templateInline: value['subject'],
|
||
objects: objects,
|
||
quote: false,
|
||
)
|
||
subject = subject_build(subject)
|
||
|
||
body = NotificationFactory::Mailer.template(
|
||
templateInline: value['body'],
|
||
objects: objects,
|
||
quote: true,
|
||
)
|
||
|
||
(body, attachments_inline) = HtmlSanitizer.replace_inline_images(body, id)
|
||
|
||
preferences = {}
|
||
preferences[:perform_origin] = perform_origin
|
||
if security.present?
|
||
preferences[:security] = security
|
||
end
|
||
|
||
message = Ticket::Article.create(
|
||
ticket_id: id,
|
||
to: recipient_string,
|
||
subject: subject,
|
||
content_type: 'text/html',
|
||
body: body,
|
||
internal: value['internal'] || false, # default to public if value was not set
|
||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||
type: Ticket::Article::Type.find_by(name: 'email'),
|
||
preferences: preferences,
|
||
updated_by_id: 1,
|
||
created_by_id: 1,
|
||
)
|
||
|
||
attachments_inline.each do |attachment|
|
||
Store.add(
|
||
object: 'Ticket::Article',
|
||
o_id: message.id,
|
||
data: attachment[:data],
|
||
filename: attachment[:filename],
|
||
preferences: attachment[:preferences],
|
||
)
|
||
end
|
||
|
||
original_article = objects[:article]
|
||
|
||
if ActiveModel::Type::Boolean.new.cast(value['include_attachments']) == true && original_article&.attachments.present?
|
||
original_article.clone_attachments('Ticket::Article', message.id, only_attached_attachments: true)
|
||
end
|
||
|
||
if original_article&.should_clone_inline_attachments? # rubocop:disable Style/GuardClause
|
||
original_article.clone_attachments('Ticket::Article', message.id, only_inline_attachments: true)
|
||
original_article.should_clone_inline_attachments = false # cancel the temporary flag after cloning
|
||
end
|
||
end
|
||
|
||
def sms_recipients_by_type(recipient_type, article)
|
||
case recipient_type
|
||
when 'article_last_sender'
|
||
return nil if article.blank?
|
||
|
||
if article.origin_by_id
|
||
article.origin_by_id
|
||
elsif article.created_by_id
|
||
article.created_by_id
|
||
end
|
||
when 'ticket_customer'
|
||
customer_id
|
||
when 'ticket_owner'
|
||
owner_id
|
||
when 'ticket_agents'
|
||
User.group_access(group_id, 'full').sort_by(&:login)
|
||
when %r{\Auserid_(\d+)\z}
|
||
return $1 if User.exists?($1)
|
||
|
||
logger.warn "Can't find configured Trigger SMS recipient User with ID '#{$1}'"
|
||
nil
|
||
else
|
||
logger.error "Unknown sms notification recipient '#{recipient}'"
|
||
nil
|
||
end
|
||
end
|
||
|
||
def build_sms_recipients_list(value, article)
|
||
Array(value['recipient'])
|
||
.each_with_object([]) { |recipient_type, sum| sum.concat(Array(sms_recipients_by_type(recipient_type, article))) }
|
||
.map { |user_or_id| user_or_id.is_a?(User) ? user_or_id : User.lookup(id: user_or_id) }
|
||
.uniq(&:id)
|
||
.select { |user| user.mobile.present? }
|
||
end
|
||
|
||
def send_sms_notification(value, article, perform_origin)
|
||
sms_recipients = build_sms_recipients_list(value, article)
|
||
|
||
if sms_recipients.blank?
|
||
logger.debug "No SMS recipients found for Ticket# #{number}"
|
||
return
|
||
end
|
||
|
||
sms_recipients_to = sms_recipients
|
||
.map { |recipient| "#{recipient.fullname} (#{recipient.mobile})" }
|
||
.join(', ')
|
||
|
||
channel = Channel.find_by(area: 'Sms::Notification')
|
||
if !channel.active?
|
||
# write info message since we have an active trigger
|
||
logger.info "Found possible SMS recipient(s) (#{sms_recipients_to}) for Ticket# #{number} but SMS channel is not active."
|
||
return
|
||
end
|
||
|
||
objects = build_notification_template_objects(article)
|
||
body = NotificationFactory::Renderer.new(
|
||
objects: objects,
|
||
template: value['body'],
|
||
escape: false
|
||
).render.html2text.tr(' ', ' ') # convert non-breaking space to simple space
|
||
|
||
# attributes content_type is not needed for SMS
|
||
Ticket::Article.create(
|
||
ticket_id: id,
|
||
subject: 'SMS notification',
|
||
to: sms_recipients_to,
|
||
body: body,
|
||
internal: value['internal'] || false, # default to public if value was not set
|
||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||
type: Ticket::Article::Type.find_by(name: 'sms'),
|
||
preferences: {
|
||
perform_origin: perform_origin,
|
||
sms_recipients: sms_recipients.map(&:mobile),
|
||
channel_id: channel.id,
|
||
},
|
||
updated_by_id: 1,
|
||
created_by_id: 1,
|
||
)
|
||
end
|
||
|
||
def trigger_based_notification?(user)
|
||
blocked_in_days = trigger_based_notification_blocked_in_days(user)
|
||
return true if blocked_in_days.zero?
|
||
|
||
logger.info "Send no trigger based notification to #{user.email} because email is marked as mail_delivery_failed for #{blocked_in_days} day(s)"
|
||
false
|
||
end
|
||
|
||
def trigger_based_notification_blocked_in_days(user)
|
||
return 0 if !user.preferences[:mail_delivery_failed]
|
||
return 0 if user.preferences[:mail_delivery_failed_data].blank?
|
||
|
||
# blocked for 60 full days
|
||
(user.preferences[:mail_delivery_failed_data].to_date - Time.zone.now.to_date).to_i + 61
|
||
end
|
||
end
|