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,330 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Agent
|
|
3
|
+
class TicketsController < Escalated::ApplicationController
|
|
4
|
+
before_action :require_agent!
|
|
5
|
+
before_action :set_ticket, only: [:show, :update, :reply, :note, :assign, :status, :priority, :tags, :department, :apply_macro, :follow, :presence, :pin]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
scope = Escalated::Ticket.all.recent
|
|
9
|
+
|
|
10
|
+
scope = scope.where(status: params[:status]) if params[:status].present?
|
|
11
|
+
scope = scope.where(priority: params[:priority]) if params[:priority].present?
|
|
12
|
+
scope = scope.assigned_to(params[:assigned_to]) if params[:assigned_to].present?
|
|
13
|
+
scope = scope.where(department_id: params[:department_id]) if params[:department_id].present?
|
|
14
|
+
scope = scope.unassigned if params[:unassigned] == "true"
|
|
15
|
+
scope = scope.breached_sla if params[:sla_breached] == "true"
|
|
16
|
+
scope = scope.search(params[:search]) if params[:search].present?
|
|
17
|
+
|
|
18
|
+
# Following filter: tickets the current user follows
|
|
19
|
+
if params[:following] == "true"
|
|
20
|
+
followed_ticket_ids = Escalated::Ticket
|
|
21
|
+
.joins("INNER JOIN #{Escalated.table_name('ticket_followers')} ON #{Escalated.table_name('ticket_followers')}.ticket_id = #{Escalated.table_name('tickets')}.id")
|
|
22
|
+
.where("#{Escalated.table_name('ticket_followers')}.user_id = ?", escalated_current_user.id)
|
|
23
|
+
.pluck(:id)
|
|
24
|
+
scope = scope.where(id: followed_ticket_ids)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
result = paginate(scope)
|
|
28
|
+
|
|
29
|
+
render inertia: "Escalated/Agent/TicketIndex", props: {
|
|
30
|
+
tickets: result[:data].includes(:requester, :department, :assignee, :tags).map { |t| ticket_list_json(t) },
|
|
31
|
+
meta: result[:meta],
|
|
32
|
+
filters: {
|
|
33
|
+
status: params[:status],
|
|
34
|
+
priority: params[:priority],
|
|
35
|
+
assigned_to: params[:assigned_to],
|
|
36
|
+
department_id: params[:department_id],
|
|
37
|
+
unassigned: params[:unassigned],
|
|
38
|
+
sla_breached: params[:sla_breached],
|
|
39
|
+
search: params[:search],
|
|
40
|
+
following: params[:following]
|
|
41
|
+
},
|
|
42
|
+
departments: Escalated::Department.active.ordered.map { |d| { id: d.id, name: d.name } },
|
|
43
|
+
agents: agent_list,
|
|
44
|
+
tags: Escalated::Tag.ordered.map { |t| { id: t.id, name: t.name, color: t.color } },
|
|
45
|
+
statuses: Escalated::Ticket.statuses.keys,
|
|
46
|
+
priorities: Escalated::Ticket.priorities.keys
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def show
|
|
51
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
52
|
+
|
|
53
|
+
replies = @ticket.replies.chronological.includes(:author, :attachments)
|
|
54
|
+
activities = @ticket.activities.reverse_chronological.limit(50)
|
|
55
|
+
|
|
56
|
+
render inertia: "Escalated/Agent/TicketShow", props: {
|
|
57
|
+
ticket: ticket_detail_json(@ticket),
|
|
58
|
+
replies: replies.map { |r| reply_json(r) },
|
|
59
|
+
activities: activities.map { |a| activity_json(a) },
|
|
60
|
+
departments: Escalated::Department.active.ordered.map { |d| { id: d.id, name: d.name } },
|
|
61
|
+
agents: agent_list,
|
|
62
|
+
tags: Escalated::Tag.ordered.map { |t| { id: t.id, name: t.name, color: t.color } },
|
|
63
|
+
canned_responses: Escalated::CannedResponse.for_user(escalated_current_user.id).ordered.map { |c|
|
|
64
|
+
{ id: c.id, title: c.title, body: c.body, shortcode: c.shortcode }
|
|
65
|
+
},
|
|
66
|
+
macros: Escalated::Macro.for_agent(escalated_current_user.id).ordered.map { |m|
|
|
67
|
+
{ id: m.id, name: m.name, description: m.description, actions: m.actions }
|
|
68
|
+
},
|
|
69
|
+
is_following: @ticket.followed_by?(escalated_current_user.id),
|
|
70
|
+
followers_count: @ticket.followers.count,
|
|
71
|
+
statuses: Escalated::Ticket.statuses.keys,
|
|
72
|
+
priorities: Escalated::Ticket.priorities.keys
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def update
|
|
77
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
78
|
+
|
|
79
|
+
Services::TicketService.update(@ticket, update_params, actor: escalated_current_user)
|
|
80
|
+
redirect_to agent_ticket_path(@ticket), notice: "Ticket updated."
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reply
|
|
84
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
85
|
+
|
|
86
|
+
reply = Services::TicketService.reply(@ticket, {
|
|
87
|
+
body: params[:body],
|
|
88
|
+
author: escalated_current_user,
|
|
89
|
+
is_internal: false
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if params[:attachments].present?
|
|
93
|
+
Services::AttachmentService.attach(reply, params[:attachments])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
redirect_to agent_ticket_path(@ticket), notice: "Reply sent."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def note
|
|
100
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
101
|
+
|
|
102
|
+
Services::TicketService.reply(@ticket, {
|
|
103
|
+
body: params[:body],
|
|
104
|
+
author: escalated_current_user,
|
|
105
|
+
is_internal: true
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
redirect_to agent_ticket_path(@ticket), notice: "Internal note added."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def assign
|
|
112
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
113
|
+
|
|
114
|
+
if params[:agent_id].present?
|
|
115
|
+
agent = Escalated.configuration.user_model.find(params[:agent_id])
|
|
116
|
+
Services::AssignmentService.assign(@ticket, agent, actor: escalated_current_user)
|
|
117
|
+
redirect_to agent_ticket_path(@ticket), notice: "Ticket assigned to #{agent.respond_to?(:name) ? agent.name : agent.email}."
|
|
118
|
+
else
|
|
119
|
+
Services::AssignmentService.unassign(@ticket, actor: escalated_current_user)
|
|
120
|
+
redirect_to agent_ticket_path(@ticket), notice: "Ticket unassigned."
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def status
|
|
125
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
126
|
+
|
|
127
|
+
Services::TicketService.transition_status(
|
|
128
|
+
@ticket,
|
|
129
|
+
params[:status],
|
|
130
|
+
actor: escalated_current_user,
|
|
131
|
+
note: params[:note]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
redirect_to agent_ticket_path(@ticket), notice: "Status updated to #{params[:status].humanize}."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def priority
|
|
138
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
139
|
+
|
|
140
|
+
Services::TicketService.change_priority(@ticket, params[:priority], actor: escalated_current_user)
|
|
141
|
+
redirect_to agent_ticket_path(@ticket), notice: "Priority updated to #{params[:priority]}."
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def tags
|
|
145
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
146
|
+
|
|
147
|
+
if params[:add_tag_ids].present?
|
|
148
|
+
Services::TicketService.add_tags(@ticket, params[:add_tag_ids], actor: escalated_current_user)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
if params[:remove_tag_ids].present?
|
|
152
|
+
Services::TicketService.remove_tags(@ticket, params[:remove_tag_ids], actor: escalated_current_user)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
redirect_to agent_ticket_path(@ticket), notice: "Tags updated."
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def department
|
|
159
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
160
|
+
|
|
161
|
+
dept = Escalated::Department.find(params[:department_id])
|
|
162
|
+
Services::TicketService.change_department(@ticket, dept, actor: escalated_current_user)
|
|
163
|
+
redirect_to agent_ticket_path(@ticket), notice: "Department changed to #{dept.name}."
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def apply_macro
|
|
167
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
168
|
+
|
|
169
|
+
macro = Escalated::Macro.for_agent(escalated_current_user.id).find(params[:macro_id])
|
|
170
|
+
Services::MacroService.apply(macro, @ticket, actor: escalated_current_user)
|
|
171
|
+
|
|
172
|
+
redirect_to agent_ticket_path(@ticket), notice: "Macro \"#{macro.name}\" applied."
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def follow
|
|
176
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
177
|
+
|
|
178
|
+
if @ticket.followed_by?(escalated_current_user.id)
|
|
179
|
+
@ticket.unfollow(escalated_current_user.id)
|
|
180
|
+
redirect_to agent_ticket_path(@ticket), notice: "Unfollowed ticket."
|
|
181
|
+
else
|
|
182
|
+
@ticket.follow(escalated_current_user.id)
|
|
183
|
+
redirect_to agent_ticket_path(@ticket), notice: "Following ticket."
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def presence
|
|
188
|
+
user_id = escalated_current_user.id
|
|
189
|
+
user_name = escalated_current_user.respond_to?(:name) ? escalated_current_user.name : escalated_current_user.email
|
|
190
|
+
cache_key = "escalated.presence.#{@ticket.id}.#{user_id}"
|
|
191
|
+
|
|
192
|
+
Rails.cache.write(cache_key, { id: user_id, name: user_name }, expires_in: 30.seconds)
|
|
193
|
+
|
|
194
|
+
# Track active user IDs for this ticket
|
|
195
|
+
list_key = "escalated.presence_list.#{@ticket.id}"
|
|
196
|
+
active_ids = Rails.cache.read(list_key) || []
|
|
197
|
+
active_ids << user_id unless active_ids.include?(user_id)
|
|
198
|
+
Rails.cache.write(list_key, active_ids, expires_in: 2.minutes)
|
|
199
|
+
|
|
200
|
+
# Collect viewers (exclude current user)
|
|
201
|
+
viewers = []
|
|
202
|
+
active_ids.each do |uid|
|
|
203
|
+
next if uid == user_id
|
|
204
|
+
|
|
205
|
+
viewer = Rails.cache.read("escalated.presence.#{@ticket.id}.#{uid}")
|
|
206
|
+
viewers << viewer if viewer
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
render json: { viewers: viewers }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def pin
|
|
213
|
+
reply = @ticket.replies.find(params[:reply_id])
|
|
214
|
+
|
|
215
|
+
unless reply.is_internal
|
|
216
|
+
redirect_to agent_ticket_path(@ticket), alert: "Only internal notes can be pinned."
|
|
217
|
+
return
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
reply.update!(is_pinned: !reply.is_pinned)
|
|
221
|
+
|
|
222
|
+
redirect_to agent_ticket_path(@ticket),
|
|
223
|
+
notice: reply.is_pinned ? "Note pinned." : "Note unpinned."
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def set_ticket
|
|
229
|
+
@ticket = Escalated::Ticket.find_by!(reference: params[:id])
|
|
230
|
+
rescue ActiveRecord::RecordNotFound
|
|
231
|
+
@ticket = Escalated::Ticket.find(params[:id])
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def update_params
|
|
235
|
+
params.require(:ticket).permit(:subject, :description)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def agent_ticket_path(ticket)
|
|
239
|
+
escalated.agent_ticket_path(ticket)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def agent_list
|
|
243
|
+
if Escalated.configuration.user_model.respond_to?(:escalated_agents)
|
|
244
|
+
Escalated.configuration.user_model.escalated_agents.map { |a|
|
|
245
|
+
{ id: a.id, name: a.respond_to?(:name) ? a.name : a.email, email: a.email }
|
|
246
|
+
}
|
|
247
|
+
else
|
|
248
|
+
[]
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def ticket_list_json(ticket)
|
|
253
|
+
{
|
|
254
|
+
id: ticket.id,
|
|
255
|
+
reference: ticket.reference,
|
|
256
|
+
subject: ticket.subject,
|
|
257
|
+
status: ticket.status,
|
|
258
|
+
priority: ticket.priority,
|
|
259
|
+
requester: {
|
|
260
|
+
name: ticket.requester.respond_to?(:name) ? ticket.requester.name : ticket.requester&.email
|
|
261
|
+
},
|
|
262
|
+
assignee: ticket.assignee ? {
|
|
263
|
+
id: ticket.assignee.id,
|
|
264
|
+
name: ticket.assignee.respond_to?(:name) ? ticket.assignee.name : ticket.assignee.email
|
|
265
|
+
} : nil,
|
|
266
|
+
department: ticket.department ? { id: ticket.department.id, name: ticket.department.name } : nil,
|
|
267
|
+
tags: ticket.tags.map { |t| { id: t.id, name: t.name, color: t.color } },
|
|
268
|
+
sla_breached: ticket.sla_breached,
|
|
269
|
+
created_at: ticket.created_at&.iso8601,
|
|
270
|
+
updated_at: ticket.updated_at&.iso8601
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def ticket_detail_json(ticket)
|
|
275
|
+
ticket_list_json(ticket).merge(
|
|
276
|
+
description: ticket.description,
|
|
277
|
+
metadata: ticket.metadata,
|
|
278
|
+
sla_policy: ticket.sla_policy ? { id: ticket.sla_policy.id, name: ticket.sla_policy.name } : nil,
|
|
279
|
+
sla_first_response_due_at: ticket.sla_first_response_due_at&.iso8601,
|
|
280
|
+
sla_resolution_due_at: ticket.sla_resolution_due_at&.iso8601,
|
|
281
|
+
first_response_at: ticket.first_response_at&.iso8601,
|
|
282
|
+
resolved_at: ticket.resolved_at&.iso8601,
|
|
283
|
+
closed_at: ticket.closed_at&.iso8601,
|
|
284
|
+
reply_count: ticket.replies.count,
|
|
285
|
+
attachment_count: ticket.attachments.count,
|
|
286
|
+
satisfaction_rating: ticket.satisfaction_rating ? {
|
|
287
|
+
id: ticket.satisfaction_rating.id,
|
|
288
|
+
rating: ticket.satisfaction_rating.rating,
|
|
289
|
+
comment: ticket.satisfaction_rating.comment,
|
|
290
|
+
created_at: ticket.satisfaction_rating.created_at&.iso8601
|
|
291
|
+
} : nil,
|
|
292
|
+
pinned_notes: ticket.pinned_notes.includes(:author).map { |n| reply_json(n) }
|
|
293
|
+
)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def reply_json(reply)
|
|
297
|
+
{
|
|
298
|
+
id: reply.id,
|
|
299
|
+
body: reply.body,
|
|
300
|
+
is_internal: reply.is_internal,
|
|
301
|
+
is_internal_note: reply.is_internal,
|
|
302
|
+
is_system: reply.is_system,
|
|
303
|
+
is_pinned: reply.respond_to?(:is_pinned) ? reply.is_pinned : false,
|
|
304
|
+
author: reply.author ? {
|
|
305
|
+
id: reply.author.id,
|
|
306
|
+
name: reply.author.respond_to?(:name) ? reply.author.name : reply.author.email,
|
|
307
|
+
is_agent: reply.author.respond_to?(:escalated_agent?) ? reply.author.escalated_agent? : false
|
|
308
|
+
} : { name: "System", is_agent: true },
|
|
309
|
+
attachments: reply.attachments.map { |a|
|
|
310
|
+
{ id: a.id, filename: a.filename, size: a.human_size, content_type: a.content_type }
|
|
311
|
+
},
|
|
312
|
+
created_at: reply.created_at&.iso8601
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def activity_json(activity)
|
|
317
|
+
{
|
|
318
|
+
id: activity.id,
|
|
319
|
+
action: activity.action,
|
|
320
|
+
description: activity.description,
|
|
321
|
+
causer: activity.causer ? {
|
|
322
|
+
name: activity.causer.respond_to?(:name) ? activity.causer.name : activity.causer.email
|
|
323
|
+
} : nil,
|
|
324
|
+
details: activity.details,
|
|
325
|
+
created_at: activity.created_at&.iso8601
|
|
326
|
+
}
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class ApplicationController < ActionController::Base
|
|
3
|
+
include Pundit::Authorization
|
|
4
|
+
|
|
5
|
+
protect_from_forgery with: :exception
|
|
6
|
+
|
|
7
|
+
before_action :apply_middleware
|
|
8
|
+
before_action :set_inertia_shared_data
|
|
9
|
+
|
|
10
|
+
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
|
|
11
|
+
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
12
|
+
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def apply_middleware
|
|
17
|
+
Escalated.configuration.middleware.each do |middleware_method|
|
|
18
|
+
send(middleware_method) if respond_to?(middleware_method, true)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def set_inertia_shared_data
|
|
23
|
+
inertia_share(
|
|
24
|
+
current_user: current_user_data,
|
|
25
|
+
escalated: {
|
|
26
|
+
route_prefix: Escalated.configuration.route_prefix,
|
|
27
|
+
allow_customer_close: Escalated.configuration.allow_customer_close,
|
|
28
|
+
max_attachments: Escalated.configuration.max_attachments,
|
|
29
|
+
max_attachment_size_kb: Escalated.configuration.max_attachment_size_kb,
|
|
30
|
+
guest_tickets_enabled: Escalated::EscalatedSetting.guest_tickets_enabled?
|
|
31
|
+
},
|
|
32
|
+
flash: {
|
|
33
|
+
success: flash[:success],
|
|
34
|
+
error: flash[:error],
|
|
35
|
+
notice: flash[:notice],
|
|
36
|
+
alert: flash[:alert]
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def current_user_data
|
|
42
|
+
return nil unless respond_to?(:current_user) && current_user
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
id: current_user.id,
|
|
46
|
+
name: current_user.respond_to?(:name) ? current_user.name : current_user.email,
|
|
47
|
+
email: current_user.email,
|
|
48
|
+
is_agent: current_user.respond_to?(:escalated_agent?) ? current_user.escalated_agent? : false,
|
|
49
|
+
is_admin: current_user.respond_to?(:escalated_admin?) ? current_user.escalated_admin? : false
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def escalated_current_user
|
|
54
|
+
return nil unless respond_to?(:current_user)
|
|
55
|
+
|
|
56
|
+
current_user
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def require_agent!
|
|
60
|
+
unless current_user_data&.dig(:is_agent) || current_user_data&.dig(:is_admin)
|
|
61
|
+
redirect_to main_app.root_path, alert: "Access denied."
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def require_admin!
|
|
66
|
+
unless current_user_data&.dig(:is_admin)
|
|
67
|
+
redirect_to main_app.root_path, alert: "Access denied."
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def user_not_authorized
|
|
72
|
+
render inertia: "Escalated/Error", props: {
|
|
73
|
+
status: 403,
|
|
74
|
+
message: "You are not authorized to perform this action."
|
|
75
|
+
}, status: :forbidden
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def not_found
|
|
79
|
+
render inertia: "Escalated/Error", props: {
|
|
80
|
+
status: 404,
|
|
81
|
+
message: "The requested resource was not found."
|
|
82
|
+
}, status: :not_found
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def unprocessable_entity(exception)
|
|
86
|
+
redirect_back(
|
|
87
|
+
fallback_location: main_app.root_path,
|
|
88
|
+
alert: exception.record.errors.full_messages.join(", ")
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def paginate(scope, per_page: 25)
|
|
93
|
+
page = (params[:page] || 1).to_i
|
|
94
|
+
per = (params[:per_page] || per_page).to_i
|
|
95
|
+
|
|
96
|
+
total = scope.count
|
|
97
|
+
records = scope.offset((page - 1) * per).limit(per)
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
data: records,
|
|
101
|
+
meta: {
|
|
102
|
+
current_page: page,
|
|
103
|
+
per_page: per,
|
|
104
|
+
total: total,
|
|
105
|
+
total_pages: (total.to_f / per).ceil
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Customer
|
|
3
|
+
class SatisfactionRatingsController < Escalated::ApplicationController
|
|
4
|
+
before_action :set_ticket
|
|
5
|
+
|
|
6
|
+
def create
|
|
7
|
+
unless %w[resolved closed].include?(@ticket.status)
|
|
8
|
+
redirect_back fallback_location: escalated.customer_ticket_path(@ticket),
|
|
9
|
+
alert: "You can only rate resolved or closed tickets."
|
|
10
|
+
return
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
if @ticket.satisfaction_rating.present?
|
|
14
|
+
redirect_back fallback_location: escalated.customer_ticket_path(@ticket),
|
|
15
|
+
alert: "This ticket has already been rated."
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
rating = Escalated::SatisfactionRating.new(
|
|
20
|
+
ticket: @ticket,
|
|
21
|
+
rating: params[:rating].to_i,
|
|
22
|
+
comment: params[:comment],
|
|
23
|
+
rated_by: escalated_current_user
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if rating.save
|
|
27
|
+
redirect_back fallback_location: escalated.customer_ticket_path(@ticket),
|
|
28
|
+
notice: "Thank you for your feedback!"
|
|
29
|
+
else
|
|
30
|
+
redirect_back fallback_location: escalated.customer_ticket_path(@ticket),
|
|
31
|
+
alert: rating.errors.full_messages.join(", ")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def set_ticket
|
|
38
|
+
@ticket = Escalated::Ticket.find_by!(reference: params[:id])
|
|
39
|
+
rescue ActiveRecord::RecordNotFound
|
|
40
|
+
@ticket = Escalated::Ticket.find(params[:id])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Customer
|
|
3
|
+
class TicketsController < Escalated::ApplicationController
|
|
4
|
+
before_action :set_ticket, only: [:show, :reply, :close, :reopen]
|
|
5
|
+
|
|
6
|
+
def index
|
|
7
|
+
scope = Escalated::Ticket.where(
|
|
8
|
+
requester: escalated_current_user
|
|
9
|
+
).recent
|
|
10
|
+
|
|
11
|
+
scope = scope.where(status: params[:status]) if params[:status].present?
|
|
12
|
+
scope = scope.search(params[:search]) if params[:search].present?
|
|
13
|
+
|
|
14
|
+
result = paginate(scope)
|
|
15
|
+
|
|
16
|
+
render inertia: "Escalated/Customer/Index", props: {
|
|
17
|
+
tickets: result[:data].map { |t| ticket_json(t) },
|
|
18
|
+
meta: result[:meta],
|
|
19
|
+
filters: {
|
|
20
|
+
status: params[:status],
|
|
21
|
+
search: params[:search]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create
|
|
27
|
+
render inertia: "Escalated/Customer/Create", props: {
|
|
28
|
+
departments: Escalated::Department.active.ordered.map { |d|
|
|
29
|
+
{ id: d.id, name: d.name }
|
|
30
|
+
},
|
|
31
|
+
priorities: Escalated::Ticket.priorities.keys,
|
|
32
|
+
default_priority: Escalated.configuration.default_priority.to_s
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def store
|
|
37
|
+
ticket = Services::TicketService.create(
|
|
38
|
+
subject: ticket_params[:subject],
|
|
39
|
+
description: ticket_params[:description],
|
|
40
|
+
priority: ticket_params[:priority] || Escalated.configuration.default_priority,
|
|
41
|
+
department_id: ticket_params[:department_id],
|
|
42
|
+
requester: escalated_current_user,
|
|
43
|
+
metadata: {}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if ticket_params[:attachments].present?
|
|
47
|
+
Services::AttachmentService.attach(ticket, ticket_params[:attachments])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
redirect_to customer_ticket_path(ticket), notice: "Ticket created successfully."
|
|
51
|
+
rescue Services::AttachmentService::TooManyAttachmentsError,
|
|
52
|
+
Services::AttachmentService::FileTooLargeError,
|
|
53
|
+
Services::AttachmentService::InvalidFileTypeError => e
|
|
54
|
+
redirect_back fallback_location: new_customer_ticket_path, alert: e.message
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def show
|
|
58
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
59
|
+
|
|
60
|
+
replies = @ticket.replies
|
|
61
|
+
.public_replies
|
|
62
|
+
.chronological
|
|
63
|
+
.includes(:author, :attachments)
|
|
64
|
+
|
|
65
|
+
render inertia: "Escalated/Customer/Show", props: {
|
|
66
|
+
ticket: ticket_json(@ticket),
|
|
67
|
+
replies: replies.map { |r| reply_json(r) },
|
|
68
|
+
can_close: Escalated.configuration.allow_customer_close && @ticket.open?,
|
|
69
|
+
can_reopen: %w[resolved closed].include?(@ticket.status)
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reply
|
|
74
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
75
|
+
|
|
76
|
+
reply = Services::TicketService.reply(@ticket, {
|
|
77
|
+
body: params[:body],
|
|
78
|
+
author: escalated_current_user,
|
|
79
|
+
is_internal: false
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
if params[:attachments].present?
|
|
83
|
+
Services::AttachmentService.attach(reply, params[:attachments])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
redirect_to customer_ticket_path(@ticket), notice: "Reply sent."
|
|
87
|
+
rescue Services::AttachmentService::TooManyAttachmentsError,
|
|
88
|
+
Services::AttachmentService::FileTooLargeError,
|
|
89
|
+
Services::AttachmentService::InvalidFileTypeError => e
|
|
90
|
+
redirect_to customer_ticket_path(@ticket), alert: e.message
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def close
|
|
94
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
95
|
+
|
|
96
|
+
unless Escalated.configuration.allow_customer_close
|
|
97
|
+
redirect_to customer_ticket_path(@ticket), alert: "Customers cannot close tickets."
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
Services::TicketService.close(@ticket, actor: escalated_current_user)
|
|
102
|
+
redirect_to customer_ticket_path(@ticket), notice: "Ticket closed."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def reopen
|
|
106
|
+
authorize @ticket, policy_class: Escalated::TicketPolicy
|
|
107
|
+
|
|
108
|
+
Services::TicketService.reopen(@ticket, actor: escalated_current_user)
|
|
109
|
+
redirect_to customer_ticket_path(@ticket), notice: "Ticket reopened."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def set_ticket
|
|
115
|
+
@ticket = Escalated::Ticket.find(params[:id])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ticket_params
|
|
119
|
+
params.require(:ticket).permit(:subject, :description, :priority, :department_id, attachments: [])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def ticket_json(ticket)
|
|
123
|
+
{
|
|
124
|
+
id: ticket.id,
|
|
125
|
+
reference: ticket.reference,
|
|
126
|
+
subject: ticket.subject,
|
|
127
|
+
description: ticket.description,
|
|
128
|
+
status: ticket.status,
|
|
129
|
+
priority: ticket.priority,
|
|
130
|
+
department: ticket.department ? { id: ticket.department.id, name: ticket.department.name } : nil,
|
|
131
|
+
created_at: ticket.created_at&.iso8601,
|
|
132
|
+
updated_at: ticket.updated_at&.iso8601,
|
|
133
|
+
resolved_at: ticket.resolved_at&.iso8601,
|
|
134
|
+
reply_count: ticket.replies.public_replies.count,
|
|
135
|
+
satisfaction_rating: ticket.satisfaction_rating ? {
|
|
136
|
+
id: ticket.satisfaction_rating.id,
|
|
137
|
+
rating: ticket.satisfaction_rating.rating,
|
|
138
|
+
comment: ticket.satisfaction_rating.comment,
|
|
139
|
+
created_at: ticket.satisfaction_rating.created_at&.iso8601
|
|
140
|
+
} : nil
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def reply_json(reply)
|
|
145
|
+
{
|
|
146
|
+
id: reply.id,
|
|
147
|
+
body: reply.body,
|
|
148
|
+
author: {
|
|
149
|
+
name: reply.author.respond_to?(:name) ? reply.author.name : reply.author&.email,
|
|
150
|
+
is_agent: reply.author.respond_to?(:escalated_agent?) ? reply.author.escalated_agent? : false
|
|
151
|
+
},
|
|
152
|
+
attachments: reply.attachments.map { |a|
|
|
153
|
+
{ id: a.id, filename: a.filename, size: a.human_size, url: Services::AttachmentService.url_for(a) }
|
|
154
|
+
},
|
|
155
|
+
created_at: reply.created_at&.iso8601,
|
|
156
|
+
is_system: reply.is_system
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def customer_ticket_path(ticket)
|
|
161
|
+
escalated.customer_ticket_path(ticket)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def new_customer_ticket_path
|
|
165
|
+
escalated.new_customer_ticket_path
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|