escalated 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +302 -0
- data/app/controllers/escalated/admin/bulk_actions_controller.rb +42 -0
- data/app/controllers/escalated/admin/canned_responses_controller.rb +73 -0
- data/app/controllers/escalated/admin/departments_controller.rb +135 -0
- data/app/controllers/escalated/admin/escalation_rules_controller.rb +121 -0
- data/app/controllers/escalated/admin/macros_controller.rb +73 -0
- data/app/controllers/escalated/admin/reports_controller.rb +152 -0
- data/app/controllers/escalated/admin/settings_controller.rb +111 -0
- data/app/controllers/escalated/admin/sla_policies_controller.rb +109 -0
- data/app/controllers/escalated/admin/tags_controller.rb +67 -0
- data/app/controllers/escalated/admin/tickets_controller.rb +299 -0
- data/app/controllers/escalated/agent/bulk_actions_controller.rb +42 -0
- data/app/controllers/escalated/agent/dashboard_controller.rb +94 -0
- data/app/controllers/escalated/agent/tickets_controller.rb +330 -0
- data/app/controllers/escalated/application_controller.rb +110 -0
- data/app/controllers/escalated/customer/satisfaction_ratings_controller.rb +44 -0
- data/app/controllers/escalated/customer/tickets_controller.rb +169 -0
- data/app/controllers/escalated/guest/tickets_controller.rb +231 -0
- data/app/controllers/escalated/inbound_controller.rb +79 -0
- data/app/jobs/escalated/check_sla_job.rb +36 -0
- data/app/jobs/escalated/close_resolved_job.rb +51 -0
- data/app/jobs/escalated/evaluate_escalations_job.rb +24 -0
- data/app/jobs/escalated/poll_imap_job.rb +74 -0
- data/app/jobs/escalated/purge_activities_job.rb +24 -0
- data/app/mailers/escalated/application_mailer.rb +6 -0
- data/app/mailers/escalated/ticket_mailer.rb +93 -0
- data/app/models/escalated/application_record.rb +5 -0
- data/app/models/escalated/attachment.rb +46 -0
- data/app/models/escalated/canned_response.rb +45 -0
- data/app/models/escalated/department.rb +43 -0
- data/app/models/escalated/escalated_setting.rb +43 -0
- data/app/models/escalated/escalation_rule.rb +96 -0
- data/app/models/escalated/inbound_email.rb +60 -0
- data/app/models/escalated/macro.rb +18 -0
- data/app/models/escalated/reply.rb +42 -0
- data/app/models/escalated/satisfaction_rating.rb +21 -0
- data/app/models/escalated/sla_policy.rb +54 -0
- data/app/models/escalated/tag.rb +28 -0
- data/app/models/escalated/ticket.rb +166 -0
- data/app/models/escalated/ticket_activity.rb +60 -0
- data/app/policies/escalated/canned_response_policy.rb +40 -0
- data/app/policies/escalated/department_policy.rb +36 -0
- data/app/policies/escalated/escalation_rule_policy.rb +36 -0
- data/app/policies/escalated/sla_policy_policy.rb +36 -0
- data/app/policies/escalated/tag_policy.rb +36 -0
- data/app/policies/escalated/ticket_policy.rb +111 -0
- data/config/routes.rb +81 -0
- data/db/migrate/001_create_escalated_departments.rb +18 -0
- data/db/migrate/002_create_escalated_sla_policies.rb +23 -0
- data/db/migrate/003_create_escalated_tags.rb +15 -0
- data/db/migrate/004_create_escalated_tickets.rb +48 -0
- data/db/migrate/005_create_escalated_replies.rb +21 -0
- data/db/migrate/006_create_escalated_attachments.rb +17 -0
- data/db/migrate/007_create_escalated_ticket_tags.rb +13 -0
- data/db/migrate/008_create_escalated_support_tables.rb +49 -0
- data/db/migrate/009_create_escalated_ticket_activities.rb +20 -0
- data/db/migrate/010_create_escalated_settings.rb +29 -0
- data/db/migrate/011_add_guest_fields_to_escalated_tickets.rb +28 -0
- data/db/migrate/012_create_escalated_inbound_emails.rb +30 -0
- data/db/migrate/013_create_escalated_macros.rb +18 -0
- data/db/migrate/014_create_escalated_ticket_followers.rb +18 -0
- data/db/migrate/015_create_escalated_satisfaction_ratings.rb +21 -0
- data/db/migrate/016_add_is_pinned_to_escalated_replies.rb +6 -0
- data/lib/escalated/configuration.rb +111 -0
- data/lib/escalated/drivers/cloud_driver.rb +134 -0
- data/lib/escalated/drivers/hosted_api_client.rb +166 -0
- data/lib/escalated/drivers/local_driver.rb +341 -0
- data/lib/escalated/drivers/synced_driver.rb +124 -0
- data/lib/escalated/engine.rb +45 -0
- data/lib/escalated/mail/adapters/base_adapter.rb +60 -0
- data/lib/escalated/mail/adapters/imap_adapter.rb +209 -0
- data/lib/escalated/mail/adapters/mailgun_adapter.rb +93 -0
- data/lib/escalated/mail/adapters/postmark_adapter.rb +94 -0
- data/lib/escalated/mail/adapters/ses_adapter.rb +179 -0
- data/lib/escalated/mail/inbound_message.rb +78 -0
- data/lib/escalated/manager.rb +33 -0
- data/lib/escalated/services/assignment_service.rb +85 -0
- data/lib/escalated/services/attachment_service.rb +110 -0
- data/lib/escalated/services/escalation_service.rb +159 -0
- data/lib/escalated/services/inbound_email_service.rb +255 -0
- data/lib/escalated/services/macro_service.rb +49 -0
- data/lib/escalated/services/notification_service.rb +157 -0
- data/lib/escalated/services/sla_service.rb +203 -0
- data/lib/escalated/services/ticket_service.rb +113 -0
- data/lib/escalated.rb +25 -0
- data/lib/generators/escalated/install_generator.rb +75 -0
- data/lib/generators/escalated/templates/initializer.rb +89 -0
- metadata +227 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class CannedResponse < ApplicationRecord
|
|
3
|
+
self.table_name = Escalated.table_name("canned_responses")
|
|
4
|
+
|
|
5
|
+
belongs_to :creator,
|
|
6
|
+
class_name: Escalated.configuration.user_class,
|
|
7
|
+
foreign_key: :created_by
|
|
8
|
+
|
|
9
|
+
validates :title, presence: true
|
|
10
|
+
validates :body, presence: true
|
|
11
|
+
validates :shortcode, uniqueness: { case_sensitive: false }, allow_nil: true
|
|
12
|
+
|
|
13
|
+
scope :shared, -> { where(is_shared: true) }
|
|
14
|
+
scope :personal, -> { where(is_shared: false) }
|
|
15
|
+
scope :for_user, ->(user_id) { where(is_shared: true).or(where(created_by: user_id)) }
|
|
16
|
+
scope :by_category, ->(category) { where(category: category) }
|
|
17
|
+
scope :search, ->(term) {
|
|
18
|
+
where("title LIKE :term OR body LIKE :term OR shortcode LIKE :term",
|
|
19
|
+
term: "%#{sanitize_sql_like(term)}%")
|
|
20
|
+
}
|
|
21
|
+
scope :ordered, -> { order(:title) }
|
|
22
|
+
|
|
23
|
+
def shared?
|
|
24
|
+
is_shared
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def personal?
|
|
28
|
+
!is_shared
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Render body with variable interpolation
|
|
32
|
+
# Variables: {{ticket.subject}}, {{ticket.requester_name}}, {{agent.name}}
|
|
33
|
+
def render(variables = {})
|
|
34
|
+
rendered = body.dup
|
|
35
|
+
|
|
36
|
+
variables.each do |key, value|
|
|
37
|
+
rendered.gsub!("{{#{key}}}", value.to_s)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Remove any unmatched variables
|
|
41
|
+
rendered.gsub!(/\{\{[^}]+\}\}/, "")
|
|
42
|
+
rendered
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class Department < ApplicationRecord
|
|
3
|
+
self.table_name = Escalated.table_name("departments")
|
|
4
|
+
|
|
5
|
+
has_many :tickets, class_name: "Escalated::Ticket", dependent: :nullify
|
|
6
|
+
has_and_belongs_to_many :agents,
|
|
7
|
+
join_table: Escalated.table_name("department_agents"),
|
|
8
|
+
class_name: Escalated.configuration.user_class,
|
|
9
|
+
foreign_key: :department_id,
|
|
10
|
+
association_foreign_key: :agent_id
|
|
11
|
+
|
|
12
|
+
belongs_to :default_sla_policy,
|
|
13
|
+
class_name: "Escalated::SlaPolicy",
|
|
14
|
+
optional: true
|
|
15
|
+
|
|
16
|
+
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
|
17
|
+
validates :slug, presence: true, uniqueness: true
|
|
18
|
+
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_nil: true
|
|
19
|
+
|
|
20
|
+
before_validation :generate_slug
|
|
21
|
+
|
|
22
|
+
scope :active, -> { where(is_active: true) }
|
|
23
|
+
scope :ordered, -> { order(:name) }
|
|
24
|
+
|
|
25
|
+
def active?
|
|
26
|
+
is_active
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def open_ticket_count
|
|
30
|
+
tickets.by_open.count
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def agent_count
|
|
34
|
+
agents.count
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def generate_slug
|
|
40
|
+
self.slug = name&.parameterize if slug.blank?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class EscalatedSetting < ApplicationRecord
|
|
3
|
+
self.table_name = Escalated.table_name("settings")
|
|
4
|
+
|
|
5
|
+
validates :key, presence: true, uniqueness: true
|
|
6
|
+
|
|
7
|
+
# Class-level accessors
|
|
8
|
+
|
|
9
|
+
def self.get(key, default = nil)
|
|
10
|
+
record = find_by(key: key)
|
|
11
|
+
record ? record.value : default
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.set(key, value)
|
|
15
|
+
record = find_or_initialize_by(key: key)
|
|
16
|
+
record.value = value.nil? ? nil : value.to_s
|
|
17
|
+
record.save!
|
|
18
|
+
record
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.get_bool(key, default: false)
|
|
22
|
+
val = get(key)
|
|
23
|
+
return default if val.nil?
|
|
24
|
+
|
|
25
|
+
%w[1 true yes].include?(val.to_s.downcase)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.get_int(key, default: 0)
|
|
29
|
+
val = get(key)
|
|
30
|
+
return default if val.nil?
|
|
31
|
+
|
|
32
|
+
Integer(val, exception: false) || default
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.guest_tickets_enabled?
|
|
36
|
+
get_bool("guest_tickets_enabled", default: true)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.all_as_hash
|
|
40
|
+
all.each_with_object({}) { |s, hash| hash[s.key] = s.value }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class EscalationRule < ApplicationRecord
|
|
3
|
+
self.table_name = Escalated.table_name("escalation_rules")
|
|
4
|
+
|
|
5
|
+
validates :name, presence: true
|
|
6
|
+
validates :conditions, presence: true
|
|
7
|
+
validates :actions, presence: true
|
|
8
|
+
|
|
9
|
+
scope :active, -> { where(is_active: true) }
|
|
10
|
+
scope :ordered, -> { order(priority: :asc, created_at: :asc) }
|
|
11
|
+
|
|
12
|
+
# conditions is a JSON column:
|
|
13
|
+
# {
|
|
14
|
+
# "status": ["open", "in_progress"],
|
|
15
|
+
# "priority": ["high", "urgent", "critical"],
|
|
16
|
+
# "sla_breached": true,
|
|
17
|
+
# "unassigned_for_minutes": 30,
|
|
18
|
+
# "no_response_for_minutes": 60,
|
|
19
|
+
# "department_ids": [1, 2]
|
|
20
|
+
# }
|
|
21
|
+
#
|
|
22
|
+
# actions is a JSON column:
|
|
23
|
+
# {
|
|
24
|
+
# "change_priority": "critical",
|
|
25
|
+
# "change_status": "escalated",
|
|
26
|
+
# "assign_to_agent_id": 5,
|
|
27
|
+
# "assign_to_department_id": 2,
|
|
28
|
+
# "send_notification": true,
|
|
29
|
+
# "notification_recipients": ["admin@example.com"],
|
|
30
|
+
# "add_tags": ["escalated", "urgent"],
|
|
31
|
+
# "add_internal_note": "Auto-escalated due to SLA breach"
|
|
32
|
+
# }
|
|
33
|
+
|
|
34
|
+
def matches?(ticket)
|
|
35
|
+
return false unless is_active
|
|
36
|
+
|
|
37
|
+
check_status(ticket) &&
|
|
38
|
+
check_priority(ticket) &&
|
|
39
|
+
check_sla_breach(ticket) &&
|
|
40
|
+
check_unassigned_duration(ticket) &&
|
|
41
|
+
check_no_response_duration(ticket) &&
|
|
42
|
+
check_department(ticket)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def active?
|
|
46
|
+
is_active
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def check_status(ticket)
|
|
52
|
+
return true unless conditions["status"].present?
|
|
53
|
+
|
|
54
|
+
Array(conditions["status"]).include?(ticket.status)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def check_priority(ticket)
|
|
58
|
+
return true unless conditions["priority"].present?
|
|
59
|
+
|
|
60
|
+
Array(conditions["priority"]).include?(ticket.priority)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_sla_breach(ticket)
|
|
64
|
+
return true unless conditions["sla_breached"]
|
|
65
|
+
|
|
66
|
+
ticket.sla_first_response_breached? || ticket.sla_resolution_breached?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_unassigned_duration(ticket)
|
|
70
|
+
return true unless conditions["unassigned_for_minutes"].present?
|
|
71
|
+
return false if ticket.assigned_to.present?
|
|
72
|
+
|
|
73
|
+
minutes = conditions["unassigned_for_minutes"].to_i
|
|
74
|
+
ticket.created_at < minutes.minutes.ago
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def check_no_response_duration(ticket)
|
|
78
|
+
return true unless conditions["no_response_for_minutes"].present?
|
|
79
|
+
|
|
80
|
+
minutes = conditions["no_response_for_minutes"].to_i
|
|
81
|
+
last_reply = ticket.replies.public_replies.chronological.last
|
|
82
|
+
|
|
83
|
+
if last_reply
|
|
84
|
+
last_reply.created_at < minutes.minutes.ago
|
|
85
|
+
else
|
|
86
|
+
ticket.created_at < minutes.minutes.ago
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def check_department(ticket)
|
|
91
|
+
return true unless conditions["department_ids"].present?
|
|
92
|
+
|
|
93
|
+
Array(conditions["department_ids"]).include?(ticket.department_id)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class InboundEmail < ApplicationRecord
|
|
3
|
+
self.table_name = Escalated.table_name("inbound_emails")
|
|
4
|
+
|
|
5
|
+
belongs_to :ticket, class_name: "Escalated::Ticket", optional: true
|
|
6
|
+
belongs_to :reply, class_name: "Escalated::Reply", optional: true
|
|
7
|
+
|
|
8
|
+
enum :status, {
|
|
9
|
+
pending: "pending",
|
|
10
|
+
processed: "processed",
|
|
11
|
+
failed: "failed",
|
|
12
|
+
spam: "spam"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
validates :from_email, presence: true
|
|
16
|
+
validates :to_email, presence: true
|
|
17
|
+
validates :subject, presence: true
|
|
18
|
+
validates :adapter, presence: true
|
|
19
|
+
validates :message_id, uniqueness: true, allow_nil: true
|
|
20
|
+
|
|
21
|
+
scope :unprocessed, -> { where(status: :pending) }
|
|
22
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
23
|
+
|
|
24
|
+
def mark_processed!(ticket:, reply: nil)
|
|
25
|
+
update!(
|
|
26
|
+
status: :processed,
|
|
27
|
+
ticket: ticket,
|
|
28
|
+
reply: reply,
|
|
29
|
+
processed_at: Time.current
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def mark_failed!(error)
|
|
34
|
+
update!(
|
|
35
|
+
status: :failed,
|
|
36
|
+
error_message: error.to_s,
|
|
37
|
+
processed_at: Time.current
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def mark_spam!
|
|
42
|
+
update!(
|
|
43
|
+
status: :spam,
|
|
44
|
+
processed_at: Time.current
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def processed?
|
|
49
|
+
status == "processed"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def duplicate?
|
|
53
|
+
return false if message_id.blank?
|
|
54
|
+
|
|
55
|
+
self.class.where(message_id: message_id)
|
|
56
|
+
.where.not(id: id)
|
|
57
|
+
.exists?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class Macro < ApplicationRecord
|
|
3
|
+
self.table_name = Escalated.table_name("macros")
|
|
4
|
+
|
|
5
|
+
belongs_to :creator,
|
|
6
|
+
class_name: Escalated.configuration.user_class,
|
|
7
|
+
foreign_key: :created_by,
|
|
8
|
+
optional: true
|
|
9
|
+
|
|
10
|
+
validates :name, presence: true
|
|
11
|
+
validates :actions, presence: true
|
|
12
|
+
|
|
13
|
+
scope :shared, -> { where(is_shared: true) }
|
|
14
|
+
scope :personal, -> { where(is_shared: false) }
|
|
15
|
+
scope :for_agent, ->(user_id) { where(is_shared: true).or(where(created_by: user_id)) }
|
|
16
|
+
scope :ordered, -> { order(:order, :name) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class Reply < ApplicationRecord
|
|
3
|
+
self.table_name = Escalated.table_name("replies")
|
|
4
|
+
|
|
5
|
+
belongs_to :ticket, class_name: "Escalated::Ticket"
|
|
6
|
+
belongs_to :author, polymorphic: true
|
|
7
|
+
has_many :attachments, as: :attachable, dependent: :destroy, class_name: "Escalated::Attachment"
|
|
8
|
+
|
|
9
|
+
validates :body, presence: true
|
|
10
|
+
|
|
11
|
+
scope :public_replies, -> { where(is_internal: false) }
|
|
12
|
+
scope :internal_notes, -> { where(is_internal: true) }
|
|
13
|
+
scope :system_messages, -> { where(is_system: true) }
|
|
14
|
+
scope :pinned, -> { where(is_pinned: true) }
|
|
15
|
+
scope :chronological, -> { order(created_at: :asc) }
|
|
16
|
+
scope :reverse_chronological, -> { order(created_at: :desc) }
|
|
17
|
+
|
|
18
|
+
after_create :touch_ticket
|
|
19
|
+
|
|
20
|
+
def public?
|
|
21
|
+
!is_internal
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def internal?
|
|
25
|
+
is_internal
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def system?
|
|
29
|
+
is_system
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def pinned?
|
|
33
|
+
is_pinned
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def touch_ticket
|
|
39
|
+
ticket.touch
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class SatisfactionRating < ApplicationRecord
|
|
3
|
+
self.table_name = Escalated.table_name("satisfaction_ratings")
|
|
4
|
+
|
|
5
|
+
belongs_to :ticket, class_name: "Escalated::Ticket"
|
|
6
|
+
belongs_to :rated_by, polymorphic: true, optional: true
|
|
7
|
+
|
|
8
|
+
validates :rating, presence: true,
|
|
9
|
+
numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 5 }
|
|
10
|
+
validates :comment, length: { maximum: 2000 }, allow_nil: true
|
|
11
|
+
validates :ticket_id, uniqueness: true
|
|
12
|
+
|
|
13
|
+
before_create :set_created_at
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def set_created_at
|
|
18
|
+
self.created_at ||= Time.current
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class SlaPolicy < ApplicationRecord
|
|
3
|
+
self.table_name = Escalated.table_name("sla_policies")
|
|
4
|
+
|
|
5
|
+
has_many :tickets, class_name: "Escalated::Ticket", dependent: :nullify
|
|
6
|
+
has_many :departments,
|
|
7
|
+
class_name: "Escalated::Department",
|
|
8
|
+
foreign_key: :default_sla_policy_id,
|
|
9
|
+
dependent: :nullify
|
|
10
|
+
|
|
11
|
+
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
|
12
|
+
validates :first_response_hours, presence: true
|
|
13
|
+
validates :resolution_hours, presence: true
|
|
14
|
+
|
|
15
|
+
scope :active, -> { where(is_active: true) }
|
|
16
|
+
scope :default_policy, -> { where(is_default: true) }
|
|
17
|
+
scope :ordered, -> { order(:name) }
|
|
18
|
+
|
|
19
|
+
# first_response_hours and resolution_hours are JSON columns
|
|
20
|
+
# stored as: { "low": 24, "medium": 8, "high": 4, "urgent": 2, "critical": 1 }
|
|
21
|
+
|
|
22
|
+
def first_response_hours_for(priority)
|
|
23
|
+
return nil unless first_response_hours.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
hours = first_response_hours[priority.to_s]
|
|
26
|
+
hours&.to_f
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def resolution_hours_for(priority)
|
|
30
|
+
return nil unless resolution_hours.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
hours = resolution_hours[priority.to_s]
|
|
33
|
+
hours&.to_f
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def active?
|
|
37
|
+
is_active
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def default?
|
|
41
|
+
is_default
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def priority_targets
|
|
45
|
+
Escalated::Ticket.priorities.keys.map do |priority|
|
|
46
|
+
{
|
|
47
|
+
priority: priority,
|
|
48
|
+
first_response: first_response_hours_for(priority),
|
|
49
|
+
resolution: resolution_hours_for(priority)
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class Tag < ApplicationRecord
|
|
3
|
+
self.table_name = Escalated.table_name("tags")
|
|
4
|
+
|
|
5
|
+
has_and_belongs_to_many :tickets,
|
|
6
|
+
join_table: Escalated.table_name("ticket_tags"),
|
|
7
|
+
class_name: "Escalated::Ticket"
|
|
8
|
+
|
|
9
|
+
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
|
10
|
+
validates :slug, presence: true, uniqueness: true
|
|
11
|
+
validates :color, format: { with: /\A#[0-9a-fA-F]{6}\z/, message: "must be a valid hex color" }, allow_nil: true
|
|
12
|
+
|
|
13
|
+
before_validation :generate_slug
|
|
14
|
+
|
|
15
|
+
scope :ordered, -> { order(:name) }
|
|
16
|
+
scope :by_name, ->(name) { where("name LIKE ?", "%#{sanitize_sql_like(name)}%") }
|
|
17
|
+
|
|
18
|
+
def ticket_count
|
|
19
|
+
tickets.count
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def generate_slug
|
|
25
|
+
self.slug = name&.parameterize if slug.blank?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class Ticket < ApplicationRecord
|
|
3
|
+
self.table_name = Escalated.table_name("tickets")
|
|
4
|
+
|
|
5
|
+
belongs_to :requester, polymorphic: true, optional: true
|
|
6
|
+
belongs_to :assignee,
|
|
7
|
+
class_name: Escalated.configuration.user_class,
|
|
8
|
+
optional: true,
|
|
9
|
+
foreign_key: :assigned_to
|
|
10
|
+
belongs_to :department, optional: true
|
|
11
|
+
belongs_to :sla_policy, optional: true
|
|
12
|
+
has_many :replies, dependent: :destroy
|
|
13
|
+
has_many :attachments, as: :attachable, dependent: :destroy
|
|
14
|
+
has_many :activities, class_name: "Escalated::TicketActivity", dependent: :destroy
|
|
15
|
+
has_and_belongs_to_many :tags,
|
|
16
|
+
join_table: Escalated.table_name("ticket_tags"),
|
|
17
|
+
class_name: "Escalated::Tag"
|
|
18
|
+
has_and_belongs_to_many :followers,
|
|
19
|
+
join_table: Escalated.table_name("ticket_followers"),
|
|
20
|
+
class_name: Escalated.configuration.user_class,
|
|
21
|
+
foreign_key: :ticket_id,
|
|
22
|
+
association_foreign_key: :user_id
|
|
23
|
+
has_one :satisfaction_rating, class_name: "Escalated::SatisfactionRating", dependent: :destroy
|
|
24
|
+
has_many :pinned_notes, -> { where(is_internal: true, is_pinned: true) },
|
|
25
|
+
class_name: "Escalated::Reply"
|
|
26
|
+
|
|
27
|
+
enum :status, {
|
|
28
|
+
open: 0,
|
|
29
|
+
in_progress: 1,
|
|
30
|
+
waiting_on_customer: 2,
|
|
31
|
+
waiting_on_agent: 3,
|
|
32
|
+
escalated: 4,
|
|
33
|
+
resolved: 5,
|
|
34
|
+
closed: 6,
|
|
35
|
+
reopened: 7
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
enum :priority, {
|
|
39
|
+
low: 0,
|
|
40
|
+
medium: 1,
|
|
41
|
+
high: 2,
|
|
42
|
+
urgent: 3,
|
|
43
|
+
critical: 4
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
validates :subject, presence: true, length: { maximum: 255 }
|
|
47
|
+
validates :description, presence: true
|
|
48
|
+
validates :reference, uniqueness: true, allow_nil: true
|
|
49
|
+
|
|
50
|
+
before_create :set_reference
|
|
51
|
+
|
|
52
|
+
# Scopes
|
|
53
|
+
scope :by_open, -> { where(status: [:open, :in_progress, :waiting_on_customer, :waiting_on_agent, :escalated, :reopened]) }
|
|
54
|
+
scope :unassigned, -> { where(assigned_to: nil) }
|
|
55
|
+
scope :assigned_to, ->(agent_id) { where(assigned_to: agent_id) }
|
|
56
|
+
scope :breached_sla, -> {
|
|
57
|
+
where(sla_breached: true)
|
|
58
|
+
.or(where("sla_first_response_due_at < ? AND first_response_at IS NULL", Time.current))
|
|
59
|
+
.or(where("sla_resolution_due_at < ? AND resolved_at IS NULL AND status NOT IN (?)", Time.current, [5, 6]))
|
|
60
|
+
}
|
|
61
|
+
scope :search, ->(term) {
|
|
62
|
+
where("#{table_name}.subject LIKE :term OR #{table_name}.description LIKE :term OR #{table_name}.reference LIKE :term",
|
|
63
|
+
term: "%#{sanitize_sql_like(term)}%")
|
|
64
|
+
}
|
|
65
|
+
scope :by_priority, ->(priority) { where(priority: priority) }
|
|
66
|
+
scope :by_department, ->(department_id) { where(department_id: department_id) }
|
|
67
|
+
scope :created_between, ->(from, to) { where(created_at: from..to) }
|
|
68
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
69
|
+
|
|
70
|
+
def self.generate_reference
|
|
71
|
+
prefix = Escalated::EscalatedSetting.get("ticket_reference_prefix", "ESC")
|
|
72
|
+
timestamp = Time.current.strftime("%y%m")
|
|
73
|
+
sequence = SecureRandom.alphanumeric(6).upcase
|
|
74
|
+
"#{prefix}-#{timestamp}-#{sequence}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def open?
|
|
78
|
+
%w[open in_progress waiting_on_customer waiting_on_agent escalated reopened].include?(status)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def sla_first_response_breached?
|
|
82
|
+
return false unless sla_first_response_due_at
|
|
83
|
+
return false if first_response_at
|
|
84
|
+
|
|
85
|
+
Time.current > sla_first_response_due_at
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def sla_resolution_breached?
|
|
89
|
+
return false unless sla_resolution_due_at
|
|
90
|
+
return false if resolved_at
|
|
91
|
+
|
|
92
|
+
Time.current > sla_resolution_due_at
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def sla_first_response_warning?
|
|
96
|
+
return false unless sla_first_response_due_at
|
|
97
|
+
return false if first_response_at
|
|
98
|
+
|
|
99
|
+
warning_threshold = sla_first_response_due_at - 1.hour
|
|
100
|
+
Time.current > warning_threshold && Time.current <= sla_first_response_due_at
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def sla_resolution_warning?
|
|
104
|
+
return false unless sla_resolution_due_at
|
|
105
|
+
return false if resolved_at
|
|
106
|
+
|
|
107
|
+
warning_threshold = sla_resolution_due_at - 2.hours
|
|
108
|
+
Time.current > warning_threshold && Time.current <= sla_resolution_due_at
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def time_to_first_response
|
|
112
|
+
return nil unless first_response_at
|
|
113
|
+
|
|
114
|
+
first_response_at - created_at
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def time_to_resolution
|
|
118
|
+
return nil unless resolved_at
|
|
119
|
+
|
|
120
|
+
resolved_at - created_at
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Guest ticket helpers
|
|
124
|
+
|
|
125
|
+
def guest?
|
|
126
|
+
requester_type.nil? && guest_token.present?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def requester_name
|
|
130
|
+
return guest_name || "Guest" if guest?
|
|
131
|
+
|
|
132
|
+
if requester
|
|
133
|
+
requester.respond_to?(:name) ? requester.name : requester.to_s
|
|
134
|
+
else
|
|
135
|
+
"Unknown"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def requester_email
|
|
140
|
+
return guest_email || "" if guest?
|
|
141
|
+
|
|
142
|
+
requester&.respond_to?(:email) ? requester.email : ""
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Follower helpers
|
|
146
|
+
|
|
147
|
+
def followed_by?(user_id)
|
|
148
|
+
followers.where(id: user_id).exists?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def follow(user_id)
|
|
152
|
+
user = Escalated.configuration.user_model.find(user_id)
|
|
153
|
+
followers << user unless followed_by?(user_id)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def unfollow(user_id)
|
|
157
|
+
followers.delete(Escalated.configuration.user_model.find(user_id))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def set_reference
|
|
163
|
+
self.reference ||= self.class.generate_reference
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class TicketActivity < ApplicationRecord
|
|
3
|
+
self.table_name = Escalated.table_name("ticket_activities")
|
|
4
|
+
|
|
5
|
+
belongs_to :ticket, class_name: "Escalated::Ticket"
|
|
6
|
+
belongs_to :causer, polymorphic: true, optional: true
|
|
7
|
+
|
|
8
|
+
validates :action, presence: true
|
|
9
|
+
|
|
10
|
+
scope :chronological, -> { order(created_at: :asc) }
|
|
11
|
+
scope :reverse_chronological, -> { order(created_at: :desc) }
|
|
12
|
+
scope :by_action, ->(action) { where(action: action) }
|
|
13
|
+
scope :by_causer, ->(causer) { where(causer: causer) }
|
|
14
|
+
scope :recent, ->(limit = 20) { reverse_chronological.limit(limit) }
|
|
15
|
+
|
|
16
|
+
# Known actions:
|
|
17
|
+
# ticket_created, ticket_updated, status_changed, ticket_assigned,
|
|
18
|
+
# ticket_unassigned, reply_added, internal_note_added, tags_added,
|
|
19
|
+
# tags_removed, department_changed, priority_changed, sla_breached,
|
|
20
|
+
# ticket_escalated, ticket_merged
|
|
21
|
+
|
|
22
|
+
def description
|
|
23
|
+
case action
|
|
24
|
+
when "ticket_created"
|
|
25
|
+
"Ticket created"
|
|
26
|
+
when "ticket_updated"
|
|
27
|
+
changes = details&.keys&.reject { |k| k == "note" }&.join(", ")
|
|
28
|
+
"Ticket updated: #{changes}"
|
|
29
|
+
when "status_changed"
|
|
30
|
+
"Status changed from #{details['from']} to #{details['to']}"
|
|
31
|
+
when "ticket_assigned"
|
|
32
|
+
"Ticket assigned"
|
|
33
|
+
when "ticket_unassigned"
|
|
34
|
+
"Ticket unassigned"
|
|
35
|
+
when "reply_added"
|
|
36
|
+
"Reply added"
|
|
37
|
+
when "internal_note_added"
|
|
38
|
+
"Internal note added"
|
|
39
|
+
when "tags_added"
|
|
40
|
+
"Tags added: #{Array(details['tag_names']).join(', ')}"
|
|
41
|
+
when "tags_removed"
|
|
42
|
+
"Tags removed: #{Array(details['tag_names']).join(', ')}"
|
|
43
|
+
when "department_changed"
|
|
44
|
+
"Department changed"
|
|
45
|
+
when "priority_changed"
|
|
46
|
+
"Priority changed from #{details['from']} to #{details['to']}"
|
|
47
|
+
when "sla_breached"
|
|
48
|
+
"SLA breached: #{details['breach_type']}"
|
|
49
|
+
when "ticket_escalated"
|
|
50
|
+
"Ticket escalated"
|
|
51
|
+
else
|
|
52
|
+
action.humanize
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def system_activity?
|
|
57
|
+
causer.nil?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|