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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +302 -0
  4. data/app/controllers/escalated/admin/bulk_actions_controller.rb +42 -0
  5. data/app/controllers/escalated/admin/canned_responses_controller.rb +73 -0
  6. data/app/controllers/escalated/admin/departments_controller.rb +135 -0
  7. data/app/controllers/escalated/admin/escalation_rules_controller.rb +121 -0
  8. data/app/controllers/escalated/admin/macros_controller.rb +73 -0
  9. data/app/controllers/escalated/admin/reports_controller.rb +152 -0
  10. data/app/controllers/escalated/admin/settings_controller.rb +111 -0
  11. data/app/controllers/escalated/admin/sla_policies_controller.rb +109 -0
  12. data/app/controllers/escalated/admin/tags_controller.rb +67 -0
  13. data/app/controllers/escalated/admin/tickets_controller.rb +299 -0
  14. data/app/controllers/escalated/agent/bulk_actions_controller.rb +42 -0
  15. data/app/controllers/escalated/agent/dashboard_controller.rb +94 -0
  16. data/app/controllers/escalated/agent/tickets_controller.rb +330 -0
  17. data/app/controllers/escalated/application_controller.rb +110 -0
  18. data/app/controllers/escalated/customer/satisfaction_ratings_controller.rb +44 -0
  19. data/app/controllers/escalated/customer/tickets_controller.rb +169 -0
  20. data/app/controllers/escalated/guest/tickets_controller.rb +231 -0
  21. data/app/controllers/escalated/inbound_controller.rb +79 -0
  22. data/app/jobs/escalated/check_sla_job.rb +36 -0
  23. data/app/jobs/escalated/close_resolved_job.rb +51 -0
  24. data/app/jobs/escalated/evaluate_escalations_job.rb +24 -0
  25. data/app/jobs/escalated/poll_imap_job.rb +74 -0
  26. data/app/jobs/escalated/purge_activities_job.rb +24 -0
  27. data/app/mailers/escalated/application_mailer.rb +6 -0
  28. data/app/mailers/escalated/ticket_mailer.rb +93 -0
  29. data/app/models/escalated/application_record.rb +5 -0
  30. data/app/models/escalated/attachment.rb +46 -0
  31. data/app/models/escalated/canned_response.rb +45 -0
  32. data/app/models/escalated/department.rb +43 -0
  33. data/app/models/escalated/escalated_setting.rb +43 -0
  34. data/app/models/escalated/escalation_rule.rb +96 -0
  35. data/app/models/escalated/inbound_email.rb +60 -0
  36. data/app/models/escalated/macro.rb +18 -0
  37. data/app/models/escalated/reply.rb +42 -0
  38. data/app/models/escalated/satisfaction_rating.rb +21 -0
  39. data/app/models/escalated/sla_policy.rb +54 -0
  40. data/app/models/escalated/tag.rb +28 -0
  41. data/app/models/escalated/ticket.rb +166 -0
  42. data/app/models/escalated/ticket_activity.rb +60 -0
  43. data/app/policies/escalated/canned_response_policy.rb +40 -0
  44. data/app/policies/escalated/department_policy.rb +36 -0
  45. data/app/policies/escalated/escalation_rule_policy.rb +36 -0
  46. data/app/policies/escalated/sla_policy_policy.rb +36 -0
  47. data/app/policies/escalated/tag_policy.rb +36 -0
  48. data/app/policies/escalated/ticket_policy.rb +111 -0
  49. data/config/routes.rb +81 -0
  50. data/db/migrate/001_create_escalated_departments.rb +18 -0
  51. data/db/migrate/002_create_escalated_sla_policies.rb +23 -0
  52. data/db/migrate/003_create_escalated_tags.rb +15 -0
  53. data/db/migrate/004_create_escalated_tickets.rb +48 -0
  54. data/db/migrate/005_create_escalated_replies.rb +21 -0
  55. data/db/migrate/006_create_escalated_attachments.rb +17 -0
  56. data/db/migrate/007_create_escalated_ticket_tags.rb +13 -0
  57. data/db/migrate/008_create_escalated_support_tables.rb +49 -0
  58. data/db/migrate/009_create_escalated_ticket_activities.rb +20 -0
  59. data/db/migrate/010_create_escalated_settings.rb +29 -0
  60. data/db/migrate/011_add_guest_fields_to_escalated_tickets.rb +28 -0
  61. data/db/migrate/012_create_escalated_inbound_emails.rb +30 -0
  62. data/db/migrate/013_create_escalated_macros.rb +18 -0
  63. data/db/migrate/014_create_escalated_ticket_followers.rb +18 -0
  64. data/db/migrate/015_create_escalated_satisfaction_ratings.rb +21 -0
  65. data/db/migrate/016_add_is_pinned_to_escalated_replies.rb +6 -0
  66. data/lib/escalated/configuration.rb +111 -0
  67. data/lib/escalated/drivers/cloud_driver.rb +134 -0
  68. data/lib/escalated/drivers/hosted_api_client.rb +166 -0
  69. data/lib/escalated/drivers/local_driver.rb +341 -0
  70. data/lib/escalated/drivers/synced_driver.rb +124 -0
  71. data/lib/escalated/engine.rb +45 -0
  72. data/lib/escalated/mail/adapters/base_adapter.rb +60 -0
  73. data/lib/escalated/mail/adapters/imap_adapter.rb +209 -0
  74. data/lib/escalated/mail/adapters/mailgun_adapter.rb +93 -0
  75. data/lib/escalated/mail/adapters/postmark_adapter.rb +94 -0
  76. data/lib/escalated/mail/adapters/ses_adapter.rb +179 -0
  77. data/lib/escalated/mail/inbound_message.rb +78 -0
  78. data/lib/escalated/manager.rb +33 -0
  79. data/lib/escalated/services/assignment_service.rb +85 -0
  80. data/lib/escalated/services/attachment_service.rb +110 -0
  81. data/lib/escalated/services/escalation_service.rb +159 -0
  82. data/lib/escalated/services/inbound_email_service.rb +255 -0
  83. data/lib/escalated/services/macro_service.rb +49 -0
  84. data/lib/escalated/services/notification_service.rb +157 -0
  85. data/lib/escalated/services/sla_service.rb +203 -0
  86. data/lib/escalated/services/ticket_service.rb +113 -0
  87. data/lib/escalated.rb +25 -0
  88. data/lib/generators/escalated/install_generator.rb +75 -0
  89. data/lib/generators/escalated/templates/initializer.rb +89 -0
  90. 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