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,341 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Drivers
|
|
3
|
+
class LocalDriver
|
|
4
|
+
ALLOWED_SORT_COLUMNS = %w[created_at updated_at status priority subject reference assigned_to department_id resolved_at closed_at].freeze
|
|
5
|
+
|
|
6
|
+
def create_ticket(params)
|
|
7
|
+
ticket = Escalated::Ticket.new(
|
|
8
|
+
subject: params[:subject],
|
|
9
|
+
description: params[:description],
|
|
10
|
+
priority: params[:priority] || Escalated.configuration.default_priority,
|
|
11
|
+
status: :open,
|
|
12
|
+
requester: params[:requester],
|
|
13
|
+
assigned_to: params[:assigned_to],
|
|
14
|
+
department_id: params[:department_id],
|
|
15
|
+
reference: Escalated::Ticket.generate_reference,
|
|
16
|
+
metadata: params[:metadata] || {}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
ActiveRecord::Base.transaction do
|
|
20
|
+
ticket.save!
|
|
21
|
+
|
|
22
|
+
if params[:tag_ids].present?
|
|
23
|
+
ticket.tags = Escalated::Tag.where(id: params[:tag_ids])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attach_sla_policy(ticket)
|
|
27
|
+
log_activity(ticket, params[:requester], "ticket_created", { subject: ticket.subject })
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
instrument("escalated.ticket.created", ticket: ticket)
|
|
31
|
+
ticket
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def update_ticket(ticket, params, actor:)
|
|
35
|
+
ActiveRecord::Base.transaction do
|
|
36
|
+
changes = {}
|
|
37
|
+
|
|
38
|
+
if params[:subject].present? && params[:subject] != ticket.subject
|
|
39
|
+
changes[:subject] = [ticket.subject, params[:subject]]
|
|
40
|
+
ticket.subject = params[:subject]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if params[:description].present? && params[:description] != ticket.description
|
|
44
|
+
changes[:description] = [ticket.description, params[:description]]
|
|
45
|
+
ticket.description = params[:description]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if params[:metadata].present?
|
|
49
|
+
ticket.metadata = ticket.metadata.merge(params[:metadata])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
ticket.save!
|
|
53
|
+
|
|
54
|
+
if changes.any?
|
|
55
|
+
log_activity(ticket, actor, "ticket_updated", changes)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
instrument("escalated.ticket.updated", ticket: ticket)
|
|
60
|
+
ticket
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def transition_status(ticket, new_status, actor:, note: nil)
|
|
64
|
+
old_status = ticket.status
|
|
65
|
+
|
|
66
|
+
ActiveRecord::Base.transaction do
|
|
67
|
+
ticket.update!(status: new_status)
|
|
68
|
+
|
|
69
|
+
if new_status.to_s == "resolved"
|
|
70
|
+
ticket.update!(resolved_at: Time.current)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if new_status.to_s == "closed"
|
|
74
|
+
ticket.update!(closed_at: Time.current)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if new_status.to_s == "reopened"
|
|
78
|
+
ticket.update!(resolved_at: nil, closed_at: nil)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
log_activity(ticket, actor, "status_changed", {
|
|
82
|
+
from: old_status,
|
|
83
|
+
to: new_status,
|
|
84
|
+
note: note
|
|
85
|
+
})
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
instrument("escalated.ticket.status_changed", ticket: ticket, from: old_status, to: new_status)
|
|
89
|
+
ticket
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def assign_ticket(ticket, agent, actor:)
|
|
93
|
+
old_assignee_id = ticket.assigned_to
|
|
94
|
+
|
|
95
|
+
ActiveRecord::Base.transaction do
|
|
96
|
+
ticket.update!(assigned_to: agent.id, status: :in_progress)
|
|
97
|
+
|
|
98
|
+
log_activity(ticket, actor, "ticket_assigned", {
|
|
99
|
+
from_agent_id: old_assignee_id,
|
|
100
|
+
to_agent_id: agent.id
|
|
101
|
+
})
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
instrument("escalated.ticket.assigned", ticket: ticket, agent: agent)
|
|
105
|
+
ticket
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def unassign_ticket(ticket, actor:)
|
|
109
|
+
old_assignee_id = ticket.assigned_to
|
|
110
|
+
|
|
111
|
+
ActiveRecord::Base.transaction do
|
|
112
|
+
ticket.update!(assigned_to: nil, status: :open)
|
|
113
|
+
|
|
114
|
+
log_activity(ticket, actor, "ticket_unassigned", {
|
|
115
|
+
from_agent_id: old_assignee_id
|
|
116
|
+
})
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
instrument("escalated.ticket.unassigned", ticket: ticket)
|
|
120
|
+
ticket
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def add_reply(ticket, params)
|
|
124
|
+
reply = nil
|
|
125
|
+
|
|
126
|
+
ActiveRecord::Base.transaction do
|
|
127
|
+
reply = ticket.replies.create!(
|
|
128
|
+
body: params[:body],
|
|
129
|
+
author: params[:author],
|
|
130
|
+
is_internal: params[:is_internal] || false,
|
|
131
|
+
is_system: params[:is_system] || false
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Update first response time if this is the first agent reply
|
|
135
|
+
if !params[:is_internal] && ticket.first_response_at.nil? && is_agent?(params[:author])
|
|
136
|
+
ticket.update!(first_response_at: Time.current)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Update status based on who replied
|
|
140
|
+
if is_agent?(params[:author]) && !params[:is_internal]
|
|
141
|
+
ticket.update!(status: :waiting_on_customer) if ticket.open? || ticket.in_progress?
|
|
142
|
+
elsif !is_agent?(params[:author])
|
|
143
|
+
ticket.update!(status: :waiting_on_agent) if ticket.waiting_on_customer?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
log_activity(ticket, params[:author], params[:is_internal] ? "internal_note_added" : "reply_added", {
|
|
147
|
+
reply_id: reply.id
|
|
148
|
+
})
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
instrument("escalated.ticket.reply_added", ticket: ticket, reply: reply)
|
|
152
|
+
reply
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def get_ticket(id)
|
|
156
|
+
Escalated::Ticket.find(id)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def list_tickets(filters = {})
|
|
160
|
+
scope = Escalated::Ticket.all
|
|
161
|
+
|
|
162
|
+
scope = scope.where(status: filters[:status]) if filters[:status].present?
|
|
163
|
+
scope = scope.where(priority: filters[:priority]) if filters[:priority].present?
|
|
164
|
+
scope = scope.where(assigned_to: filters[:assigned_to]) if filters[:assigned_to].present?
|
|
165
|
+
scope = scope.where(department_id: filters[:department_id]) if filters[:department_id].present?
|
|
166
|
+
scope = scope.where(requester: filters[:requester]) if filters[:requester].present?
|
|
167
|
+
scope = scope.search(filters[:search]) if filters[:search].present?
|
|
168
|
+
|
|
169
|
+
if filters[:sla_breached]
|
|
170
|
+
scope = scope.breached_sla
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
order_col = filters[:order_by].to_s
|
|
174
|
+
order_col = 'created_at' unless ALLOWED_SORT_COLUMNS.include?(order_col)
|
|
175
|
+
order_dir = filters[:order_dir].to_s.downcase == 'asc' ? :asc : :desc
|
|
176
|
+
scope = scope.order(order_col => order_dir)
|
|
177
|
+
|
|
178
|
+
if filters[:page].present?
|
|
179
|
+
scope = scope.page(filters[:page]).per(filters[:per_page] || 25)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
scope
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def add_tags(ticket, tag_ids, actor:)
|
|
186
|
+
tags = Escalated::Tag.where(id: tag_ids)
|
|
187
|
+
new_tags = tags - ticket.tags
|
|
188
|
+
|
|
189
|
+
ActiveRecord::Base.transaction do
|
|
190
|
+
ticket.tags << new_tags
|
|
191
|
+
|
|
192
|
+
if new_tags.any?
|
|
193
|
+
log_activity(ticket, actor, "tags_added", {
|
|
194
|
+
tag_names: new_tags.map(&:name)
|
|
195
|
+
})
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
instrument("escalated.ticket.tags_added", ticket: ticket, tags: new_tags)
|
|
200
|
+
ticket
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def remove_tags(ticket, tag_ids, actor:)
|
|
204
|
+
tags_to_remove = ticket.tags.where(id: tag_ids)
|
|
205
|
+
|
|
206
|
+
ActiveRecord::Base.transaction do
|
|
207
|
+
ticket.tags.delete(tags_to_remove)
|
|
208
|
+
|
|
209
|
+
if tags_to_remove.any?
|
|
210
|
+
log_activity(ticket, actor, "tags_removed", {
|
|
211
|
+
tag_names: tags_to_remove.map(&:name)
|
|
212
|
+
})
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
instrument("escalated.ticket.tags_removed", ticket: ticket, tags: tags_to_remove)
|
|
217
|
+
ticket
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def change_department(ticket, department, actor:)
|
|
221
|
+
old_department_id = ticket.department_id
|
|
222
|
+
|
|
223
|
+
ActiveRecord::Base.transaction do
|
|
224
|
+
ticket.update!(department_id: department.id)
|
|
225
|
+
|
|
226
|
+
log_activity(ticket, actor, "department_changed", {
|
|
227
|
+
from_department_id: old_department_id,
|
|
228
|
+
to_department_id: department.id
|
|
229
|
+
})
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
instrument("escalated.ticket.department_changed", ticket: ticket, department: department)
|
|
233
|
+
ticket
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def change_priority(ticket, new_priority, actor:)
|
|
237
|
+
old_priority = ticket.priority
|
|
238
|
+
|
|
239
|
+
ActiveRecord::Base.transaction do
|
|
240
|
+
ticket.update!(priority: new_priority)
|
|
241
|
+
|
|
242
|
+
log_activity(ticket, actor, "priority_changed", {
|
|
243
|
+
from: old_priority,
|
|
244
|
+
to: new_priority
|
|
245
|
+
})
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
instrument("escalated.ticket.priority_changed", ticket: ticket, from: old_priority, to: new_priority)
|
|
249
|
+
ticket
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
def log_activity(ticket, actor, action, details = {})
|
|
255
|
+
ticket.activities.create!(
|
|
256
|
+
action: action,
|
|
257
|
+
causer: actor,
|
|
258
|
+
details: details
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def instrument(event, payload = {})
|
|
263
|
+
ActiveSupport::Notifications.instrument(event, payload)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def attach_sla_policy(ticket)
|
|
267
|
+
return unless Escalated.configuration.sla_enabled?
|
|
268
|
+
|
|
269
|
+
policy = if ticket.department&.default_sla_policy_id.present?
|
|
270
|
+
Escalated::SlaPolicy.find_by(id: ticket.department.default_sla_policy_id)
|
|
271
|
+
else
|
|
272
|
+
Escalated::SlaPolicy.default_policy.first
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
if policy
|
|
276
|
+
ticket.update!(
|
|
277
|
+
sla_policy_id: policy.id,
|
|
278
|
+
sla_first_response_due_at: calculate_due_date(policy.first_response_hours_for(ticket.priority)),
|
|
279
|
+
sla_resolution_due_at: calculate_due_date(policy.resolution_hours_for(ticket.priority))
|
|
280
|
+
)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def calculate_due_date(hours)
|
|
285
|
+
return nil unless hours
|
|
286
|
+
|
|
287
|
+
if Escalated.configuration.business_hours_only?
|
|
288
|
+
calculate_business_hours_due_date(hours)
|
|
289
|
+
else
|
|
290
|
+
Time.current + hours.hours
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def calculate_business_hours_due_date(hours)
|
|
295
|
+
bh = Escalated.configuration.business_hours
|
|
296
|
+
start_hour = bh[:start] || 9
|
|
297
|
+
end_hour = bh[:end] || 17
|
|
298
|
+
working_days = bh[:working_days] || [1, 2, 3, 4, 5]
|
|
299
|
+
tz = bh[:timezone] || "UTC"
|
|
300
|
+
|
|
301
|
+
current_time = Time.current.in_time_zone(tz)
|
|
302
|
+
remaining_hours = hours.to_f
|
|
303
|
+
hours_per_day = end_hour - start_hour
|
|
304
|
+
|
|
305
|
+
while remaining_hours > 0
|
|
306
|
+
if working_days.include?(current_time.wday)
|
|
307
|
+
day_start = current_time.change(hour: start_hour, min: 0, sec: 0)
|
|
308
|
+
day_end = current_time.change(hour: end_hour, min: 0, sec: 0)
|
|
309
|
+
|
|
310
|
+
if current_time < day_start
|
|
311
|
+
current_time = day_start
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
if current_time < day_end
|
|
315
|
+
available_hours = (day_end - current_time) / 3600.0
|
|
316
|
+
|
|
317
|
+
if remaining_hours <= available_hours
|
|
318
|
+
return current_time + remaining_hours.hours
|
|
319
|
+
else
|
|
320
|
+
remaining_hours -= available_hours
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
current_time = (current_time + 1.day).change(hour: start_hour, min: 0, sec: 0)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
current_time
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def is_agent?(user)
|
|
332
|
+
return false unless user.present?
|
|
333
|
+
|
|
334
|
+
# Check if user responds to agent-like methods
|
|
335
|
+
user.respond_to?(:escalated_agent?) && user.escalated_agent?
|
|
336
|
+
rescue StandardError
|
|
337
|
+
false
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
require "escalated/drivers/local_driver"
|
|
2
|
+
require "escalated/drivers/hosted_api_client"
|
|
3
|
+
|
|
4
|
+
module Escalated
|
|
5
|
+
module Drivers
|
|
6
|
+
class SyncedDriver < LocalDriver
|
|
7
|
+
def create_ticket(params)
|
|
8
|
+
ticket = super
|
|
9
|
+
sync_to_cloud(:create_ticket, ticket_payload(ticket))
|
|
10
|
+
ticket
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def update_ticket(ticket, params, actor:)
|
|
14
|
+
result = super
|
|
15
|
+
sync_to_cloud(:update_ticket, ticket_payload(result))
|
|
16
|
+
result
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def transition_status(ticket, new_status, actor:, note: nil)
|
|
20
|
+
result = super
|
|
21
|
+
sync_to_cloud(:transition_status, {
|
|
22
|
+
ticket_reference: result.reference,
|
|
23
|
+
status: new_status,
|
|
24
|
+
note: note
|
|
25
|
+
})
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def assign_ticket(ticket, agent, actor:)
|
|
30
|
+
result = super
|
|
31
|
+
sync_to_cloud(:assign_ticket, {
|
|
32
|
+
ticket_reference: result.reference,
|
|
33
|
+
agent_email: agent.email
|
|
34
|
+
})
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def unassign_ticket(ticket, actor:)
|
|
39
|
+
result = super
|
|
40
|
+
sync_to_cloud(:unassign_ticket, {
|
|
41
|
+
ticket_reference: result.reference
|
|
42
|
+
})
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def add_reply(ticket, params)
|
|
47
|
+
reply = super
|
|
48
|
+
sync_to_cloud(:add_reply, {
|
|
49
|
+
ticket_reference: ticket.reference,
|
|
50
|
+
body: reply.body,
|
|
51
|
+
author_email: reply.author&.email,
|
|
52
|
+
is_internal: reply.is_internal
|
|
53
|
+
})
|
|
54
|
+
reply
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def add_tags(ticket, tag_ids, actor:)
|
|
58
|
+
result = super
|
|
59
|
+
sync_to_cloud(:add_tags, {
|
|
60
|
+
ticket_reference: result.reference,
|
|
61
|
+
tag_names: result.tags.pluck(:name)
|
|
62
|
+
})
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def remove_tags(ticket, tag_ids, actor:)
|
|
67
|
+
result = super
|
|
68
|
+
sync_to_cloud(:remove_tags, {
|
|
69
|
+
ticket_reference: result.reference,
|
|
70
|
+
tag_names: result.tags.pluck(:name)
|
|
71
|
+
})
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def change_department(ticket, department, actor:)
|
|
76
|
+
result = super
|
|
77
|
+
sync_to_cloud(:change_department, {
|
|
78
|
+
ticket_reference: result.reference,
|
|
79
|
+
department_name: department.name
|
|
80
|
+
})
|
|
81
|
+
result
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def change_priority(ticket, new_priority, actor:)
|
|
85
|
+
result = super
|
|
86
|
+
sync_to_cloud(:change_priority, {
|
|
87
|
+
ticket_reference: result.reference,
|
|
88
|
+
priority: new_priority
|
|
89
|
+
})
|
|
90
|
+
result
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def sync_to_cloud(action, payload)
|
|
96
|
+
HostedApiClient.emit(action, payload)
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
Rails.logger.error("[Escalated::SyncedDriver] Cloud sync failed for #{action}: #{e.message}")
|
|
99
|
+
ActiveSupport::Notifications.instrument("escalated.sync.failed", {
|
|
100
|
+
action: action,
|
|
101
|
+
error: e.message
|
|
102
|
+
})
|
|
103
|
+
# Local operation already succeeded - don't re-raise
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def ticket_payload(ticket)
|
|
107
|
+
{
|
|
108
|
+
reference: ticket.reference,
|
|
109
|
+
subject: ticket.subject,
|
|
110
|
+
description: ticket.description,
|
|
111
|
+
status: ticket.status,
|
|
112
|
+
priority: ticket.priority,
|
|
113
|
+
requester_email: ticket.requester&.email,
|
|
114
|
+
assignee_email: ticket.assignee&.email,
|
|
115
|
+
department_name: ticket.department&.name,
|
|
116
|
+
tag_names: ticket.tags.pluck(:name),
|
|
117
|
+
metadata: ticket.metadata,
|
|
118
|
+
created_at: ticket.created_at&.iso8601,
|
|
119
|
+
updated_at: ticket.updated_at&.iso8601
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace Escalated
|
|
4
|
+
|
|
5
|
+
initializer "escalated.configuration" do |app|
|
|
6
|
+
# Allow host app to configure Escalated before boot
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
initializer "escalated.assets" do |app|
|
|
10
|
+
# Make engine assets available to host app
|
|
11
|
+
app.config.assets.precompile += %w[escalated_manifest.js] if app.config.respond_to?(:assets)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
initializer "escalated.migrations" do |app|
|
|
15
|
+
unless app.root.to_s.match?(root.to_s)
|
|
16
|
+
config.paths["db/migrate"].expanded.each do |expanded_path|
|
|
17
|
+
app.config.paths["db/migrate"] << expanded_path
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
initializer "escalated.pundit" do
|
|
23
|
+
ActiveSupport.on_load(:action_controller) do
|
|
24
|
+
# Pundit policies are auto-discovered via namespace
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
initializer "escalated.append_routes" do |app|
|
|
29
|
+
app.routes.append do
|
|
30
|
+
mount Escalated::Engine, at: "/#{Escalated.configuration.route_prefix}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
initializer "escalated.inertia" do
|
|
35
|
+
ActiveSupport.on_load(:action_controller) do
|
|
36
|
+
# Configure Inertia shared data at engine level
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
config.generators do |g|
|
|
41
|
+
g.test_framework :rspec
|
|
42
|
+
g.fixture_replacement :factory_bot, dir: "spec/factories"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Mail
|
|
3
|
+
module Adapters
|
|
4
|
+
class BaseAdapter
|
|
5
|
+
# Parse the incoming webhook/request into an InboundMessage.
|
|
6
|
+
# Subclasses must implement this method.
|
|
7
|
+
#
|
|
8
|
+
# @param request [ActionDispatch::Request] the raw HTTP request
|
|
9
|
+
# @return [Escalated::Mail::InboundMessage]
|
|
10
|
+
def parse_request(request)
|
|
11
|
+
raise NotImplementedError, "#{self.class.name}#parse_request must be implemented"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Verify the authenticity of the incoming request (signature check, etc.).
|
|
15
|
+
# Returns true if the request is valid, false otherwise.
|
|
16
|
+
# Default implementation returns true (no verification).
|
|
17
|
+
#
|
|
18
|
+
# @param request [ActionDispatch::Request] the raw HTTP request
|
|
19
|
+
# @return [Boolean]
|
|
20
|
+
def verify_request(request)
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Human-readable adapter name for logging and storage
|
|
25
|
+
#
|
|
26
|
+
# @return [String]
|
|
27
|
+
def adapter_name
|
|
28
|
+
self.class.name.demodulize.underscore.sub(/_adapter\z/, "")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Safely extract a value from params, returning nil if missing
|
|
34
|
+
def safe_param(params, key, default = nil)
|
|
35
|
+
value = params[key]
|
|
36
|
+
value.present? ? value : default
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parse an email address string like "John Doe <john@example.com>"
|
|
40
|
+
# into [name, email] tuple
|
|
41
|
+
def parse_email_address(address_string)
|
|
42
|
+
return [nil, nil] if address_string.blank?
|
|
43
|
+
|
|
44
|
+
if match = address_string.match(/\A\s*(.+?)\s*<([^>]+)>\s*\z/)
|
|
45
|
+
[match[1].strip.gsub(/\A["']|["']\z/, ""), match[2].strip.downcase]
|
|
46
|
+
else
|
|
47
|
+
[nil, address_string.strip.downcase]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Parse a comma-separated list of email addresses
|
|
52
|
+
def parse_references(references_string)
|
|
53
|
+
return [] if references_string.blank?
|
|
54
|
+
|
|
55
|
+
references_string.scan(/<([^>]+)>/).flatten
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|