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,231 @@
1
+ module Escalated
2
+ module Guest
3
+ class TicketsController < ActionController::Base
4
+ protect_from_forgery with: :exception
5
+
6
+ before_action :ensure_guest_tickets_enabled
7
+ before_action :set_ticket_by_token, only: [:show, :reply, :rate]
8
+ before_action :set_inertia_shared_data
9
+
10
+ def create
11
+ render inertia: "Escalated/Guest/Create", props: {
12
+ departments: Escalated::Department.active.ordered.map { |d|
13
+ { id: d.id, name: d.name }
14
+ },
15
+ priorities: Escalated::Ticket.priorities.keys,
16
+ default_priority: Escalated.configuration.default_priority.to_s
17
+ }
18
+ end
19
+
20
+ def store
21
+ errors = validate_guest_params
22
+ if errors.any?
23
+ render inertia: "Escalated/Guest/Create", props: {
24
+ errors: errors,
25
+ old: guest_ticket_params.to_h,
26
+ departments: Escalated::Department.active.ordered.map { |d|
27
+ { id: d.id, name: d.name }
28
+ },
29
+ priorities: Escalated::Ticket.priorities.keys,
30
+ default_priority: Escalated.configuration.default_priority.to_s
31
+ }
32
+ return
33
+ end
34
+
35
+ guest_token = SecureRandom.hex(32) # 64-character hex string
36
+
37
+ ticket = Escalated::Ticket.create!(
38
+ requester: nil,
39
+ guest_name: guest_ticket_params[:name],
40
+ guest_email: guest_ticket_params[:email],
41
+ guest_token: guest_token,
42
+ subject: guest_ticket_params[:subject],
43
+ description: guest_ticket_params[:description],
44
+ priority: guest_ticket_params[:priority] || Escalated.configuration.default_priority,
45
+ department_id: guest_ticket_params[:department_id].presence
46
+ )
47
+
48
+ if guest_ticket_params[:attachments].present?
49
+ Services::AttachmentService.attach(ticket, guest_ticket_params[:attachments])
50
+ end
51
+
52
+ redirect_to "#{escalated_mount_path}/guest/#{guest_token}", notice: "Ticket created successfully."
53
+ rescue Services::AttachmentService::TooManyAttachmentsError,
54
+ Services::AttachmentService::FileTooLargeError,
55
+ Services::AttachmentService::InvalidFileTypeError => e
56
+ redirect_back fallback_location: "#{escalated_mount_path}/guest/create", alert: e.message
57
+ end
58
+
59
+ def show
60
+ replies = @ticket.replies
61
+ .where(is_internal: false, is_system: false)
62
+ .order(created_at: :asc)
63
+ .includes(:author, :attachments)
64
+
65
+ render inertia: "Escalated/Guest/Show", props: {
66
+ ticket: guest_ticket_json(@ticket),
67
+ replies: replies.map { |r| guest_reply_json(r) },
68
+ token: params[:token],
69
+ can_reply: @ticket.open?
70
+ }
71
+ end
72
+
73
+ def reply
74
+ unless @ticket.open?
75
+ redirect_to "#{escalated_mount_path}/guest/#{params[:token]}", alert: "This ticket is closed."
76
+ return
77
+ end
78
+
79
+ body = params[:body].to_s.strip
80
+ if body.blank?
81
+ redirect_to "#{escalated_mount_path}/guest/#{params[:token]}"
82
+ return
83
+ end
84
+
85
+ reply = Escalated::Reply.create!(
86
+ ticket: @ticket,
87
+ author: nil,
88
+ body: body,
89
+ is_internal: false,
90
+ is_system: false
91
+ )
92
+
93
+ # Update ticket status if waiting on customer
94
+ if @ticket.waiting_on_customer?
95
+ @ticket.update!(status: :open)
96
+ end
97
+
98
+ if params[:attachments].present?
99
+ Services::AttachmentService.attach(reply, params[:attachments])
100
+ end
101
+
102
+ redirect_to "#{escalated_mount_path}/guest/#{params[:token]}", notice: "Reply sent."
103
+ rescue Services::AttachmentService::TooManyAttachmentsError,
104
+ Services::AttachmentService::FileTooLargeError,
105
+ Services::AttachmentService::InvalidFileTypeError => e
106
+ redirect_to "#{escalated_mount_path}/guest/#{params[:token]}", alert: e.message
107
+ end
108
+
109
+ def rate
110
+ unless %w[resolved closed].include?(@ticket.status)
111
+ redirect_to "#{escalated_mount_path}/guest/#{params[:token]}",
112
+ alert: "You can only rate resolved or closed tickets."
113
+ return
114
+ end
115
+
116
+ if @ticket.satisfaction_rating.present?
117
+ redirect_to "#{escalated_mount_path}/guest/#{params[:token]}",
118
+ alert: "This ticket has already been rated."
119
+ return
120
+ end
121
+
122
+ rating = Escalated::SatisfactionRating.new(
123
+ ticket: @ticket,
124
+ rating: params[:rating].to_i,
125
+ comment: params[:comment]
126
+ )
127
+
128
+ if rating.save
129
+ redirect_to "#{escalated_mount_path}/guest/#{params[:token]}",
130
+ notice: "Thank you for your feedback!"
131
+ else
132
+ redirect_to "#{escalated_mount_path}/guest/#{params[:token]}",
133
+ alert: rating.errors.full_messages.join(", ")
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ def ensure_guest_tickets_enabled
140
+ unless Escalated::EscalatedSetting.guest_tickets_enabled?
141
+ render plain: "Guest tickets are not enabled.", status: :not_found
142
+ end
143
+ end
144
+
145
+ def set_ticket_by_token
146
+ @ticket = Escalated::Ticket.find_by!(guest_token: params[:token])
147
+ rescue ActiveRecord::RecordNotFound
148
+ render plain: "Ticket not found.", status: :not_found
149
+ end
150
+
151
+ def set_inertia_shared_data
152
+ inertia_share(
153
+ escalated: {
154
+ route_prefix: Escalated.configuration.route_prefix,
155
+ guest_tickets_enabled: Escalated::EscalatedSetting.guest_tickets_enabled?
156
+ },
157
+ flash: {
158
+ success: flash[:success],
159
+ error: flash[:error],
160
+ notice: flash[:notice],
161
+ alert: flash[:alert]
162
+ }
163
+ )
164
+ end
165
+
166
+ def guest_ticket_params
167
+ params.permit(:name, :email, :subject, :description, :priority, :department_id, attachments: [])
168
+ end
169
+
170
+ def validate_guest_params
171
+ errors = {}
172
+ errors[:name] = "Name is required." if guest_ticket_params[:name].blank?
173
+ errors[:email] = "Email is required." if guest_ticket_params[:email].blank?
174
+ errors[:subject] = "Subject is required." if guest_ticket_params[:subject].blank?
175
+ errors[:description] = "Description is required." if guest_ticket_params[:description].blank?
176
+ errors
177
+ end
178
+
179
+ def escalated_mount_path
180
+ "/#{Escalated.configuration.route_prefix}"
181
+ end
182
+
183
+ def guest_ticket_json(ticket)
184
+ {
185
+ id: ticket.id,
186
+ reference: ticket.reference,
187
+ subject: ticket.subject,
188
+ description: ticket.description,
189
+ status: ticket.status,
190
+ priority: ticket.priority,
191
+ is_guest: ticket.guest?,
192
+ guest_name: ticket.guest_name,
193
+ guest_email: ticket.guest_email,
194
+ requester_name: ticket.requester_name,
195
+ requester_email: ticket.requester_email,
196
+ department: ticket.department ? { id: ticket.department.id, name: ticket.department.name } : nil,
197
+ created_at: ticket.created_at&.iso8601,
198
+ updated_at: ticket.updated_at&.iso8601,
199
+ satisfaction_rating: ticket.satisfaction_rating ? {
200
+ id: ticket.satisfaction_rating.id,
201
+ rating: ticket.satisfaction_rating.rating,
202
+ comment: ticket.satisfaction_rating.comment,
203
+ created_at: ticket.satisfaction_rating.created_at&.iso8601
204
+ } : nil
205
+ }
206
+ end
207
+
208
+ def guest_reply_json(reply)
209
+ author_name = if reply.author
210
+ reply.author.respond_to?(:name) ? reply.author.name : reply.author&.email
211
+ else
212
+ @ticket.guest_name || "Guest"
213
+ end
214
+
215
+ {
216
+ id: reply.id,
217
+ body: reply.body,
218
+ author: {
219
+ name: author_name,
220
+ is_agent: reply.author.respond_to?(:escalated_agent?) ? reply.author.escalated_agent? : false
221
+ },
222
+ attachments: reply.attachments.map { |a|
223
+ { id: a.id, filename: a.filename, size: a.human_size }
224
+ },
225
+ created_at: reply.created_at&.iso8601,
226
+ is_system: reply.is_system
227
+ }
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,79 @@
1
+ module Escalated
2
+ class InboundController < ActionController::Base
3
+ skip_before_action :verify_authenticity_token
4
+
5
+ before_action :ensure_inbound_enabled
6
+
7
+ def webhook
8
+ adapter = resolve_adapter(params[:adapter])
9
+
10
+ unless adapter
11
+ render json: { error: "Unknown adapter: #{params[:adapter]}" }, status: :bad_request
12
+ return
13
+ end
14
+
15
+ # Verify request authenticity (signature, token, etc.)
16
+ unless adapter.verify_request(request)
17
+ Rails.logger.warn(
18
+ "[Escalated::InboundController] Webhook verification failed for adapter: #{params[:adapter]}"
19
+ )
20
+ render json: { error: "Verification failed" }, status: :unauthorized
21
+ return
22
+ end
23
+
24
+ # Parse the request into an InboundMessage
25
+ message = adapter.parse_request(request)
26
+
27
+ # SES subscription confirmations return nil — acknowledge silently
28
+ unless message
29
+ render json: { status: "ok" }, status: :ok
30
+ return
31
+ end
32
+
33
+ # Process the inbound email
34
+ inbound_email = Services::InboundEmailService.process(
35
+ message,
36
+ adapter_name: adapter.adapter_name
37
+ )
38
+
39
+ if inbound_email&.processed?
40
+ render json: {
41
+ status: "processed",
42
+ ticket_id: inbound_email.ticket_id,
43
+ reply_id: inbound_email.reply_id
44
+ }, status: :ok
45
+ elsif inbound_email&.failed?
46
+ render json: {
47
+ status: "failed",
48
+ error: inbound_email.error_message
49
+ }, status: :unprocessable_entity
50
+ else
51
+ render json: { status: "ok" }, status: :ok
52
+ end
53
+ rescue StandardError => e
54
+ Rails.logger.error(
55
+ "[Escalated::InboundController] Unexpected error: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
56
+ )
57
+ render json: { error: "Internal error" }, status: :internal_server_error
58
+ end
59
+
60
+ private
61
+
62
+ def ensure_inbound_enabled
63
+ unless Escalated.configuration.inbound_email_enabled
64
+ render json: { error: "Inbound email is disabled" }, status: :not_found
65
+ end
66
+ end
67
+
68
+ ADAPTER_MAP = {
69
+ "mailgun" => -> { Escalated::Mail::Adapters::MailgunAdapter.new },
70
+ "postmark" => -> { Escalated::Mail::Adapters::PostmarkAdapter.new },
71
+ "ses" => -> { Escalated::Mail::Adapters::SesAdapter.new }
72
+ }.freeze
73
+
74
+ def resolve_adapter(adapter_name)
75
+ factory = ADAPTER_MAP[adapter_name.to_s.downcase]
76
+ factory&.call
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,36 @@
1
+ module Escalated
2
+ class CheckSlaJob < ActiveJob::Base
3
+ queue_as :escalated
4
+
5
+ def perform
6
+ return unless Escalated.configuration.sla_enabled?
7
+
8
+ Rails.logger.info("[Escalated::CheckSlaJob] Checking SLA breaches...")
9
+
10
+ breached = Services::SlaService.check_breaches
11
+ warnings = Services::SlaService.check_warnings
12
+
13
+ Rails.logger.info(
14
+ "[Escalated::CheckSlaJob] Found #{breached.size} breaches and #{warnings.size} warnings"
15
+ )
16
+
17
+ # Send warning notifications
18
+ warnings.each do |warning|
19
+ ticket = warning[:ticket]
20
+ type = warning[:type]
21
+
22
+ ActiveSupport::Notifications.instrument("escalated.sla.warning", {
23
+ ticket: ticket,
24
+ warning_type: type
25
+ })
26
+ end
27
+
28
+ # Check if any breached tickets should be escalated
29
+ breached.each do |ticket|
30
+ Services::EscalationService.evaluate_ticket(ticket)
31
+ end
32
+
33
+ { breaches: breached.size, warnings: warnings.size }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,51 @@
1
+ module Escalated
2
+ class CloseResolvedJob < ActiveJob::Base
3
+ queue_as :escalated
4
+
5
+ def perform
6
+ days = Escalated.configuration.auto_close_resolved_after_days
7
+ return if days.nil? || days <= 0
8
+
9
+ cutoff = days.days.ago
10
+
11
+ tickets = Escalated::Ticket
12
+ .where(status: :resolved)
13
+ .where("resolved_at < ?", cutoff)
14
+
15
+ count = 0
16
+
17
+ tickets.find_each do |ticket|
18
+ ActiveRecord::Base.transaction do
19
+ ticket.update!(status: :closed, closed_at: Time.current)
20
+
21
+ ticket.activities.create!(
22
+ action: "status_changed",
23
+ causer: nil,
24
+ details: {
25
+ from: "resolved",
26
+ to: "closed",
27
+ reason: "auto_closed",
28
+ note: "Automatically closed after #{days} days in resolved status"
29
+ }
30
+ )
31
+
32
+ ticket.replies.create!(
33
+ body: "This ticket was automatically closed after #{days} days in resolved status. " \
34
+ "If you need further assistance, please reopen or create a new ticket.",
35
+ author: nil,
36
+ is_internal: false,
37
+ is_system: true
38
+ )
39
+ end
40
+
41
+ count += 1
42
+ end
43
+
44
+ Rails.logger.info(
45
+ "[Escalated::CloseResolvedJob] Auto-closed #{count} tickets resolved before #{cutoff}"
46
+ )
47
+
48
+ { closed_count: count }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,24 @@
1
+ module Escalated
2
+ class EvaluateEscalationsJob < ActiveJob::Base
3
+ queue_as :escalated
4
+
5
+ def perform
6
+ Rails.logger.info("[Escalated::EvaluateEscalationsJob] Evaluating escalation rules...")
7
+
8
+ results = Services::EscalationService.evaluate_all
9
+
10
+ Rails.logger.info(
11
+ "[Escalated::EvaluateEscalationsJob] Escalated #{results.size} tickets"
12
+ )
13
+
14
+ results.each do |result|
15
+ Rails.logger.info(
16
+ "[Escalated::EvaluateEscalationsJob] Ticket #{result[:ticket].reference} " \
17
+ "matched rule '#{result[:rule].name}'"
18
+ )
19
+ end
20
+
21
+ { escalated_count: results.size }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,74 @@
1
+ module Escalated
2
+ class PollImapJob < ActiveJob::Base
3
+ queue_as :escalated
4
+
5
+ # Poll the configured IMAP mailbox for unread messages and process them
6
+ # as inbound emails.
7
+ #
8
+ # This job should be scheduled periodically (e.g., every 2-5 minutes)
9
+ # via a cron scheduler like `whenever`, `sidekiq-cron`, or `good_job`.
10
+ #
11
+ # Example with sidekiq-cron:
12
+ # Sidekiq::Cron::Job.create(
13
+ # name: "Poll IMAP for inbound emails",
14
+ # cron: "*/5 * * * *",
15
+ # class: "Escalated::PollImapJob"
16
+ # )
17
+ def perform
18
+ unless Escalated.configuration.inbound_email_enabled
19
+ Rails.logger.debug("[Escalated::PollImapJob] Inbound email is disabled, skipping")
20
+ return
21
+ end
22
+
23
+ unless Escalated.configuration.inbound_email_adapter.to_s == "imap"
24
+ Rails.logger.debug("[Escalated::PollImapJob] IMAP adapter not configured, skipping")
25
+ return
26
+ end
27
+
28
+ unless imap_configured?
29
+ Rails.logger.warn("[Escalated::PollImapJob] IMAP credentials not configured")
30
+ return
31
+ end
32
+
33
+ Rails.logger.info("[Escalated::PollImapJob] Polling IMAP mailbox...")
34
+
35
+ adapter = Escalated::Mail::Adapters::ImapAdapter.new
36
+ messages = adapter.fetch_messages
37
+
38
+ Rails.logger.info("[Escalated::PollImapJob] Found #{messages.size} unread messages")
39
+
40
+ processed = 0
41
+ failed = 0
42
+
43
+ messages.each do |message|
44
+ result = Services::InboundEmailService.process(message, adapter_name: "imap")
45
+
46
+ if result&.processed?
47
+ processed += 1
48
+ else
49
+ failed += 1
50
+ end
51
+ rescue StandardError => e
52
+ failed += 1
53
+ Rails.logger.error(
54
+ "[Escalated::PollImapJob] Failed to process message from #{message.from_email}: #{e.message}"
55
+ )
56
+ end
57
+
58
+ Rails.logger.info(
59
+ "[Escalated::PollImapJob] Completed: #{processed} processed, #{failed} failed out of #{messages.size} total"
60
+ )
61
+
62
+ { total: messages.size, processed: processed, failed: failed }
63
+ end
64
+
65
+ private
66
+
67
+ def imap_configured?
68
+ config = Escalated.configuration
69
+ config.imap_host.present? &&
70
+ config.imap_username.present? &&
71
+ config.imap_password.present?
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,24 @@
1
+ module Escalated
2
+ class PurgeActivitiesJob < ActiveJob::Base
3
+ queue_as :escalated_low
4
+
5
+ RETENTION_DAYS = 180
6
+
7
+ def perform(retention_days: RETENTION_DAYS)
8
+ cutoff = retention_days.days.ago
9
+
10
+ # Only purge activities for closed tickets
11
+ count = Escalated::TicketActivity
12
+ .joins(:ticket)
13
+ .where(escalated_tickets: { status: :closed })
14
+ .where("#{Escalated.table_name('ticket_activities')}.created_at < ?", cutoff)
15
+ .delete_all
16
+
17
+ Rails.logger.info(
18
+ "[Escalated::PurgeActivitiesJob] Purged #{count} activities older than #{retention_days} days"
19
+ )
20
+
21
+ { purged_count: count }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,6 @@
1
+ module Escalated
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: -> { Escalated.configuration.respond_to?(:mailer_from) ? Escalated.configuration.mailer_from : "support@example.com" }
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,93 @@
1
+ module Escalated
2
+ class TicketMailer < ApplicationMailer
3
+ def new_ticket(ticket)
4
+ @ticket = ticket
5
+ @requester = ticket.requester
6
+
7
+ mail(
8
+ to: @requester.email,
9
+ subject: "[#{ticket.reference}] Ticket Created: #{ticket.subject}"
10
+ )
11
+ end
12
+
13
+ def reply_received(ticket, reply)
14
+ @ticket = ticket
15
+ @reply = reply
16
+
17
+ # Notify the requester if an agent replied, or notify the assignee if the customer replied
18
+ recipient = if reply.author == ticket.requester
19
+ ticket.assignee&.email
20
+ else
21
+ ticket.requester.email
22
+ end
23
+
24
+ return unless recipient
25
+
26
+ mail(
27
+ to: recipient,
28
+ subject: "Re: [#{ticket.reference}] #{ticket.subject}"
29
+ )
30
+ end
31
+
32
+ def ticket_assigned(ticket)
33
+ @ticket = ticket
34
+ @assignee = ticket.assignee
35
+
36
+ return unless @assignee&.email
37
+
38
+ mail(
39
+ to: @assignee.email,
40
+ subject: "[#{ticket.reference}] Ticket Assigned: #{ticket.subject}"
41
+ )
42
+ end
43
+
44
+ def status_changed(ticket)
45
+ @ticket = ticket
46
+
47
+ mail(
48
+ to: ticket.requester.email,
49
+ subject: "[#{ticket.reference}] Status Updated: #{ticket.status.humanize}"
50
+ )
51
+ end
52
+
53
+ def sla_breach(ticket)
54
+ @ticket = ticket
55
+
56
+ recipients = []
57
+ recipients << ticket.assignee.email if ticket.assignee&.email
58
+ recipients << ticket.department&.email if ticket.department&.email
59
+
60
+ return if recipients.empty?
61
+
62
+ mail(
63
+ to: recipients.compact.uniq,
64
+ subject: "[SLA BREACH] [#{ticket.reference}] #{ticket.subject}"
65
+ )
66
+ end
67
+
68
+ def ticket_escalated(ticket, rule)
69
+ @ticket = ticket
70
+ @rule = rule
71
+
72
+ recipients = Array(rule.actions["notification_recipients"])
73
+ recipients << ticket.assignee&.email if ticket.assignee
74
+ recipients << ticket.department&.email if ticket.department
75
+
76
+ return if recipients.compact.empty?
77
+
78
+ mail(
79
+ to: recipients.compact.uniq,
80
+ subject: "[ESCALATED] [#{ticket.reference}] #{ticket.subject}"
81
+ )
82
+ end
83
+
84
+ def ticket_resolved(ticket)
85
+ @ticket = ticket
86
+
87
+ mail(
88
+ to: ticket.requester.email,
89
+ subject: "[#{ticket.reference}] Ticket Resolved: #{ticket.subject}"
90
+ )
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,5 @@
1
+ module Escalated
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,46 @@
1
+ module Escalated
2
+ class Attachment < ApplicationRecord
3
+ self.table_name = Escalated.table_name("attachments")
4
+
5
+ belongs_to :attachable, polymorphic: true
6
+
7
+ has_one_attached :file
8
+
9
+ validates :filename, presence: true
10
+ validates :content_type, presence: true
11
+ validates :byte_size, presence: true,
12
+ numericality: {
13
+ less_than_or_equal_to: -> { Escalated.configuration.max_attachment_size_kb * 1024 }
14
+ }
15
+
16
+ before_validation :set_metadata_from_file, if: -> { file.attached? && filename.blank? }
17
+
18
+ scope :images, -> { where("content_type LIKE ?", "image/%") }
19
+ scope :documents, -> { where.not("content_type LIKE ?", "image/%") }
20
+ scope :recent, -> { order(created_at: :desc) }
21
+
22
+ def image?
23
+ content_type&.start_with?("image/")
24
+ end
25
+
26
+ def human_size
27
+ if byte_size < 1024
28
+ "#{byte_size} B"
29
+ elsif byte_size < 1_048_576
30
+ "#{(byte_size / 1024.0).round(1)} KB"
31
+ else
32
+ "#{(byte_size / 1_048_576.0).round(1)} MB"
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def set_metadata_from_file
39
+ return unless file.attached?
40
+
41
+ self.filename = file.filename.to_s
42
+ self.content_type = file.content_type
43
+ self.byte_size = file.byte_size
44
+ end
45
+ end
46
+ end