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