support_desk 0.1.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/.rubocop.yml +32 -0
- data/.simplecov +53 -0
- data/Appraisals +18 -0
- data/CHANGELOG.md +135 -0
- data/LICENSE.txt +21 -0
- data/README.md +469 -0
- data/Rakefile +55 -0
- data/app/assets/stylesheets/support_desk.css +242 -0
- data/app/controllers/support_desk/application_controller.rb +92 -0
- data/app/controllers/support_desk/console/application_controller.rb +18 -0
- data/app/controllers/support_desk/console/tickets_controller.rb +26 -0
- data/app/controllers/support_desk/tickets_controller.rb +119 -0
- data/app/helpers/support_desk/engine_helper.rb +196 -0
- data/app/views/chats/slots/_inbox_top.html.erb +31 -0
- data/app/views/chats/slots/_locked_composer.html.erb +18 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +29 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +60 -0
- data/app/views/support_desk/console/tickets/_composer.html.erb +71 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +53 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +48 -0
- data/app/views/support_desk/console/tickets/_nav_badge.html.erb +19 -0
- data/app/views/support_desk/console/tickets/_tabs.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +56 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +37 -0
- data/app/views/support_desk/console/tickets/_transcript.html.erb +26 -0
- data/app/views/support_desk/console/tickets/index.html.erb +37 -0
- data/app/views/support_desk/console/tickets/show.html.erb +45 -0
- data/app/views/support_desk/tickets/_context_card.html.erb +14 -0
- data/app/views/support_desk/tickets/_door.html.erb +12 -0
- data/app/views/support_desk/tickets/_pick_thing.html.erb +55 -0
- data/app/views/support_desk/tickets/_pick_topic.html.erb +30 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +31 -0
- data/app/views/support_desk/tickets/_wizard_header.html.erb +23 -0
- data/app/views/support_desk/tickets/_write.html.erb +56 -0
- data/app/views/support_desk/tickets/index.html.erb +53 -0
- data/app/views/support_desk/tickets/new.html.erb +14 -0
- data/app/views/support_desk/tickets/rate_limited.html.erb +40 -0
- data/config/console_routes.rb +16 -0
- data/config/locales/support_desk.console.en.yml +93 -0
- data/config/locales/support_desk.console.es.yml +93 -0
- data/config/locales/support_desk.en.yml +79 -0
- data/config/locales/support_desk.es.yml +84 -0
- data/config/routes.rb +24 -0
- data/context7.json +4 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/gemfiles/rails_8.0.gemfile +34 -0
- data/gemfiles/rails_8.1.gemfile +34 -0
- data/lib/generators/support_desk/console_generator.rb +94 -0
- data/lib/generators/support_desk/install_generator.rb +86 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +43 -0
- data/lib/generators/support_desk/templates/console/resource.rb.erb +44 -0
- data/lib/generators/support_desk/templates/create_support_desk_tables.rb.erb +224 -0
- data/lib/generators/support_desk/templates/initializer.rb +186 -0
- data/lib/generators/support_desk/views_generator.rb +50 -0
- data/lib/support_desk/configuration.rb +675 -0
- data/lib/support_desk/console.rb +487 -0
- data/lib/support_desk/console_engine.rb +63 -0
- data/lib/support_desk/console_routes.rb +107 -0
- data/lib/support_desk/context_card.rb +90 -0
- data/lib/support_desk/current.rb +26 -0
- data/lib/support_desk/doctor.rb +220 -0
- data/lib/support_desk/engine.rb +141 -0
- data/lib/support_desk/errors.rb +49 -0
- data/lib/support_desk/events.rb +122 -0
- data/lib/support_desk/macros.rb +73 -0
- data/lib/support_desk/models/application_record.rb +11 -0
- data/lib/support_desk/models/assignment.rb +83 -0
- data/lib/support_desk/models/concerns/agent.rb +79 -0
- data/lib/support_desk/models/concerns/requester.rb +71 -0
- data/lib/support_desk/models/concerns/supportable.rb +88 -0
- data/lib/support_desk/models/desk.rb +101 -0
- data/lib/support_desk/models/event.rb +72 -0
- data/lib/support_desk/models/ticket.rb +1124 -0
- data/lib/support_desk/queue.rb +171 -0
- data/lib/support_desk/summary.rb +70 -0
- data/lib/support_desk/test_helpers.rb +137 -0
- data/lib/support_desk/timeline.rb +104 -0
- data/lib/support_desk/topic.rb +290 -0
- data/lib/support_desk/topic_tree.rb +214 -0
- data/lib/support_desk/version.rb +5 -0
- data/lib/support_desk/wizard.rb +392 -0
- data/lib/support_desk.rb +288 -0
- metadata +229 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module SupportDesk
|
|
6
|
+
# The agent console as a controller concern: every verb an agent needs,
|
|
7
|
+
# wired to the model, with nothing decided about how it looks.
|
|
8
|
+
#
|
|
9
|
+
# # config/routes.rb
|
|
10
|
+
# namespace :madmin do
|
|
11
|
+
# resources :support_tickets, only: %i[index show], concerns: :support_console
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# class Madmin::SupportTicketsController < Madmin::ApplicationController
|
|
15
|
+
# include SupportDesk::Console # the verbs
|
|
16
|
+
# include SupportDesk::Console::Index # optional: @queue, @scope, @tickets
|
|
17
|
+
#
|
|
18
|
+
# def current_agent = current_user # or rely on config.current_agent_method
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# `index` and `show` stay yours — they are the UI, and Layer 1 (Queue,
|
|
22
|
+
# ContextCard, Timeline, actions_for) is everything they need. What this
|
|
23
|
+
# concern owns is the boring, easy-to-get-wrong half:
|
|
24
|
+
#
|
|
25
|
+
# * <b>Who is asking.</b> `current_agent` must be an eligible agent, or the
|
|
26
|
+
# request is a 403. `SupportDesk::Current.actor` is set from it, so a
|
|
27
|
+
# transition anywhere downstream is attributed even when nobody passed
|
|
28
|
+
# `by:`.
|
|
29
|
+
# * <b>What they may reach.</b> Tickets are found through
|
|
30
|
+
# `config.visible_desks_for`, so a desk an agent may not work is a plain
|
|
31
|
+
# 404 — never a 403 that confirms the case exists.
|
|
32
|
+
# * <b>Whether the host agrees.</b> `config.authorize_console` is consulted
|
|
33
|
+
# before every action, including `index`.
|
|
34
|
+
# * <b>Errors are flashes.</b> Every refusal the domain can raise — a
|
|
35
|
+
# drop-in under `:assignee_only`, a hand-off by somebody who doesn't hold
|
|
36
|
+
# the ticket, a reply into a locked case — lands in `flash[:alert]` and a
|
|
37
|
+
# redirect. A console that 500s on a policy is a console nobody trusts.
|
|
38
|
+
#
|
|
39
|
+
# == Paths
|
|
40
|
+
#
|
|
41
|
+
# The verbs have to redirect somewhere, and the same views have to render
|
|
42
|
+
# under `/admin/support` and under `/madmin/support_tickets`. Rather than
|
|
43
|
+
# hard-code either, the concern derives paths from the controller's OWN
|
|
44
|
+
# route (`console_ticket_path`, `console_tickets_path`), so one view set
|
|
45
|
+
# works wherever it is mounted. Override `after_transition_path` to land
|
|
46
|
+
# somewhere else.
|
|
47
|
+
module Console
|
|
48
|
+
extend ActiveSupport::Concern
|
|
49
|
+
|
|
50
|
+
# The verbs, in the order the routing concern draws them.
|
|
51
|
+
TRANSITIONS = %i[reply take assign hand_off release close reopen note change_topic].freeze
|
|
52
|
+
|
|
53
|
+
# Actions that work on one ticket, so `set_support_ticket` runs for them.
|
|
54
|
+
# `show` is the host's, but it still wants the ticket found safely.
|
|
55
|
+
MEMBER_ACTIONS = ([ :show ] + TRANSITIONS).freeze
|
|
56
|
+
|
|
57
|
+
# Which entry in `ticket.actions_for(agent)` each verb needs. The
|
|
58
|
+
# console renders exactly what that method returns, so it must accept
|
|
59
|
+
# exactly that too: a POST the UI would never have offered is refused,
|
|
60
|
+
# whether it came from a stale tab, a second agent who got there first,
|
|
61
|
+
# or somebody with curl.
|
|
62
|
+
#
|
|
63
|
+
# `take` and `assign` are both :assign — taking a case is assigning it
|
|
64
|
+
# to yourself, which is the model's vocabulary, not two permissions.
|
|
65
|
+
OFFERED_AS = {
|
|
66
|
+
reply: :reply, take: :assign, assign: :assign, hand_off: :hand_off, release: :release,
|
|
67
|
+
close: :close, reopen: :reopen, note: :note, change_topic: :change_topic
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
70
|
+
# Everything a transition raises because of WHO asked, WHEN, or from WHAT
|
|
71
|
+
# state. All of it is a flash; anything else is a bug and still 500s.
|
|
72
|
+
RESCUED_ERRORS = [
|
|
73
|
+
SupportDesk::NotAllowed,
|
|
74
|
+
SupportDesk::NotTheAssignee,
|
|
75
|
+
SupportDesk::NotAnAgent,
|
|
76
|
+
# Locked is a subclass of InvalidTransition and so already covered.
|
|
77
|
+
# It is listed anyway: a reader shouldn't have to know the hierarchy
|
|
78
|
+
# to know that replying into a locked case is a flash.
|
|
79
|
+
SupportDesk::InvalidTransition,
|
|
80
|
+
SupportDesk::Locked,
|
|
81
|
+
SupportDesk::UnknownTopic,
|
|
82
|
+
SupportDesk::NotSupportable,
|
|
83
|
+
SupportDesk::RateLimited,
|
|
84
|
+
SupportDesk::TooManyOpenTickets
|
|
85
|
+
].freeze
|
|
86
|
+
|
|
87
|
+
# The flash an error translates into. Unlisted ones fall back to
|
|
88
|
+
# `support_desk.console.errors.generic`.
|
|
89
|
+
ERROR_KEYS = {
|
|
90
|
+
"SupportDesk::NotAllowed" => "not_allowed",
|
|
91
|
+
"SupportDesk::NotTheAssignee" => "not_the_assignee",
|
|
92
|
+
"SupportDesk::NotAnAgent" => "not_an_agent",
|
|
93
|
+
"SupportDesk::InvalidTransition" => "invalid_transition",
|
|
94
|
+
"SupportDesk::Locked" => "locked",
|
|
95
|
+
"SupportDesk::UnknownTopic" => "unknown_topic"
|
|
96
|
+
}.freeze
|
|
97
|
+
|
|
98
|
+
included do
|
|
99
|
+
before_action :require_support_agent!
|
|
100
|
+
before_action :require_visible_desk!
|
|
101
|
+
before_action :set_support_current_actor
|
|
102
|
+
before_action :set_support_ticket, only: MEMBER_ACTIONS
|
|
103
|
+
before_action :authorize_support_console!
|
|
104
|
+
before_action :require_offered_action!, only: TRANSITIONS
|
|
105
|
+
|
|
106
|
+
helper_method :current_agent, :support_desk_record, :support_queue, :support_transcript,
|
|
107
|
+
:console_ticket_path, :console_tickets_path, :console_file_path
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- The verbs --------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
# Answer the requester. Under `reply_policy: :anyone` an unheld ticket is
|
|
113
|
+
# taken by whoever answers first; under `:assignee_only` a drop-in is a
|
|
114
|
+
# flash, not a 500.
|
|
115
|
+
def reply
|
|
116
|
+
body = params[:body].to_s
|
|
117
|
+
files = Array(params[:files]).reject(&:blank?)
|
|
118
|
+
return refuse(:blank_message) if body.strip.empty? && files.empty?
|
|
119
|
+
|
|
120
|
+
attempt(:replied) { @ticket.reply!(body, by: current_agent, files: files, request: request) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Take an unheld ticket (or one somebody else holds, which is an
|
|
124
|
+
# override — `hand_off` is the polite version).
|
|
125
|
+
def take
|
|
126
|
+
attempt(:taken) { @ticket.assign!(to: current_agent, by: current_agent, request: request) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Give it to somebody else. The target must be in this desk's pool.
|
|
130
|
+
def assign
|
|
131
|
+
agent = support_console_agent
|
|
132
|
+
return refuse(:unknown_agent) if agent.nil?
|
|
133
|
+
|
|
134
|
+
attempt(:assigned, agent: support_agent_name(agent)) do
|
|
135
|
+
@ticket.assign!(to: agent, by: current_agent, request: request)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# `assign` said by the person holding it, with a note for whoever picks
|
|
140
|
+
# it up. Somebody who doesn't hold it gets a flash saying who does.
|
|
141
|
+
def hand_off
|
|
142
|
+
agent = support_console_agent
|
|
143
|
+
return refuse(:unknown_agent) if agent.nil?
|
|
144
|
+
|
|
145
|
+
attempt(:handed_off, agent: support_agent_name(agent)) do
|
|
146
|
+
@ticket.hand_off!(to: agent, note: params[:note].presence, by: current_agent, request: request)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Put it back in the unassigned pile.
|
|
151
|
+
def release
|
|
152
|
+
attempt(:released) { @ticket.release!(by: current_agent, request: request) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def close
|
|
156
|
+
attempt(:closed) { @ticket.close!(by: current_agent, request: request) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def reopen
|
|
160
|
+
attempt(:reopened) { @ticket.reopen!(by: current_agent, request: request) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# An internal note: timeline and console only, never the conversation.
|
|
164
|
+
def note
|
|
165
|
+
body = params[:body].to_s
|
|
166
|
+
return refuse(:blank_note) if body.strip.empty?
|
|
167
|
+
|
|
168
|
+
attempt(:noted) { @ticket.note!(body, by: current_agent, request: request) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Refile the case. Misfiling is normal — the wizard can only offer the
|
|
172
|
+
# tree, and people describe problems in their own words.
|
|
173
|
+
def change_topic
|
|
174
|
+
topic = params[:topic].to_s
|
|
175
|
+
return refuse(:blank_topic) if topic.strip.empty?
|
|
176
|
+
|
|
177
|
+
attempt(:topic_changed) { @ticket.change_topic!(to: topic, by: current_agent, request: request) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The most urgent thing this agent should be looking at. The whole
|
|
181
|
+
# "work the queue" loop is this one button.
|
|
182
|
+
def next
|
|
183
|
+
ticket = support_queue.next
|
|
184
|
+
return redirect_to(console_tickets_path, notice: support_console_t("flashes.queue_empty")) if ticket.nil?
|
|
185
|
+
|
|
186
|
+
redirect_to console_ticket_path(ticket)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# --- Paths ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
# This console's path to one ticket — derived from the controller's own
|
|
192
|
+
# route, so the same view renders under any mount point or namespace.
|
|
193
|
+
def console_ticket_path(ticket, action = :show, **params)
|
|
194
|
+
url_for(controller: "/#{controller_path}", action: action, id: ticket.to_param,
|
|
195
|
+
only_path: true, **params)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# This console's collection path (`:index` by default, `:next` for the
|
|
199
|
+
# "work the queue" button).
|
|
200
|
+
def console_tickets_path(action = :index, **params)
|
|
201
|
+
url_for(controller: "/#{controller_path}", action: action, only_path: true, **params)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Where a verb lands when it's done. Override to go back to the queue,
|
|
205
|
+
# or on to `next`.
|
|
206
|
+
def after_transition_path(ticket) = console_ticket_path(ticket)
|
|
207
|
+
|
|
208
|
+
# A URL for a message attachment that resolves the same in both places
|
|
209
|
+
# this console renders. Inside a mounted engine a bare `url_for(blob)`
|
|
210
|
+
# looks for Active Storage's routes in the ENGINE's route set and blows
|
|
211
|
+
# up; the main app's helpers are right either way.
|
|
212
|
+
def console_file_path(file)
|
|
213
|
+
Rails.application.routes.url_helpers.rails_blob_path(file, only_path: true)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The case as the requester experienced it: every visible message,
|
|
217
|
+
# oldest first, with senders, authors and attachments preloaded.
|
|
218
|
+
# Override to paginate a long one.
|
|
219
|
+
def support_transcript(ticket = @ticket)
|
|
220
|
+
scope = ticket.messages.visible.oldest_first.includes(:sender, :author)
|
|
221
|
+
scope.respond_to?(:with_attached_files) ? scope.with_attached_files : scope
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The default queue tab and the tickets behind it, for hosts that want
|
|
225
|
+
# the obvious index. Entirely optional — everything it does is three
|
|
226
|
+
# lines of Layer 1.
|
|
227
|
+
#
|
|
228
|
+
# class Madmin::SupportTicketsController < Madmin::ApplicationController
|
|
229
|
+
# include SupportDesk::Console
|
|
230
|
+
# include SupportDesk::Console::Index
|
|
231
|
+
# end
|
|
232
|
+
module Index
|
|
233
|
+
extend ActiveSupport::Concern
|
|
234
|
+
|
|
235
|
+
included do
|
|
236
|
+
helper_method :support_tickets_per_page
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# `@queue`, `@scope` and `@tickets` — the queue, the tab being looked
|
|
240
|
+
# at, and its rows.
|
|
241
|
+
def index
|
|
242
|
+
@queue = support_queue
|
|
243
|
+
@scope = support_queue_scope
|
|
244
|
+
@tickets = support_queue_tickets
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
# `?tab=` when it names a real tab, else the tab an agent would have
|
|
250
|
+
# opened anyway: what needs an answer, or their own pile when nothing
|
|
251
|
+
# does.
|
|
252
|
+
def support_queue_scope
|
|
253
|
+
requested = params[:tab].presence&.to_sym
|
|
254
|
+
return requested if SupportDesk::Queue::TABS.include?(requested)
|
|
255
|
+
|
|
256
|
+
support_queue.awaiting.exists? ? :awaiting : :mine
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# The rows, with everything a row renders already loaded.
|
|
260
|
+
#
|
|
261
|
+
# `:subject` is the one that is easy to forget and expensive to miss:
|
|
262
|
+
# every row prints `ticket.label`, which falls through to the
|
|
263
|
+
# subject's own `support_label`, so leaving it out is a SELECT per row
|
|
264
|
+
# that no test notices until somebody counts.
|
|
265
|
+
def support_queue_tickets
|
|
266
|
+
support_queue.scope(@scope)
|
|
267
|
+
.includes(:requester, :assignee, :desk, :subject,
|
|
268
|
+
conversation: { last_message: %i[sender author] })
|
|
269
|
+
.limit(support_tickets_per_page)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# How many rows the index shows. Override, or paginate the relation
|
|
273
|
+
# with whatever your app already uses.
|
|
274
|
+
def support_tickets_per_page = 50
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
# --- Who is asking ------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
# The person answering. Define `current_agent` in your controller, or
|
|
282
|
+
# point `config.current_agent_method` at the method you already have.
|
|
283
|
+
def current_agent
|
|
284
|
+
return @current_agent if defined?(@current_agent)
|
|
285
|
+
|
|
286
|
+
method_name = SupportDesk.config.current_agent_method
|
|
287
|
+
unless respond_to?(method_name, true)
|
|
288
|
+
raise SupportDesk::ConfigurationError,
|
|
289
|
+
"support_desk can't find ##{method_name} on #{self.class.name}. Define `current_agent` in " \
|
|
290
|
+
"your console controller, or set config.current_agent_method to the method that returns " \
|
|
291
|
+
"the logged-in agent."
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
@current_agent = send(method_name)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def require_support_agent!
|
|
298
|
+
agent = current_agent
|
|
299
|
+
return if agent.respond_to?(:support_agent?) && agent.support_agent?
|
|
300
|
+
|
|
301
|
+
support_console_forbidden
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Every transition takes `by:` explicitly; this is the belt for anything
|
|
305
|
+
# a host calls downstream without one (a job kicked off from an event,
|
|
306
|
+
# a callback in their own model).
|
|
307
|
+
def set_support_current_actor
|
|
308
|
+
SupportDesk::Current.actor = current_agent
|
|
309
|
+
SupportDesk::Current.request = request
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def authorize_support_console!
|
|
313
|
+
return if SupportDesk.config.console_authorized?(current_agent, @ticket, action_name.to_sym)
|
|
314
|
+
|
|
315
|
+
support_console_forbidden
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Refuse a verb the console wouldn't have offered. Without this the UI
|
|
319
|
+
# and the endpoint can disagree — `actions_for` drops :reply on a closed
|
|
320
|
+
# case, but the model happily posts one, so the composer vanished while
|
|
321
|
+
# the POST behind it still flashed success.
|
|
322
|
+
def require_offered_action!
|
|
323
|
+
offered = @ticket.actions_for(current_agent)
|
|
324
|
+
return if offered.include?(OFFERED_AS.fetch(action_name.to_sym))
|
|
325
|
+
|
|
326
|
+
flash[:alert] = support_console_t("errors.#{unavailable_reason}", holder: support_console_holder)
|
|
327
|
+
respond_to_transition
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Why the button wasn't there, in the words that help most: the case is
|
|
331
|
+
# done, somebody else has it, or nobody does and this desk wants it
|
|
332
|
+
# taken first.
|
|
333
|
+
def unavailable_reason
|
|
334
|
+
return "closed_case" if @ticket.closed?
|
|
335
|
+
return "unavailable_action" unless %i[reply hand_off].include?(action_name.to_sym)
|
|
336
|
+
return "take_it_first" if @ticket.unassigned?
|
|
337
|
+
|
|
338
|
+
"held_by_somebody_else"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Who has the case, for a refusal that names them.
|
|
342
|
+
def support_console_holder
|
|
343
|
+
@ticket&.assignee&.try(:support_agent_name) || support_console_t("assignment.nobody")
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# A 403 that says why, in the host's locale. Override for a prettier one.
|
|
347
|
+
# A refusal the agent can actually see.
|
|
348
|
+
#
|
|
349
|
+
# A 403 with a plain-text body is the right answer to a GET and the
|
|
350
|
+
# wrong one to a Turbo form submission: Turbo only renders an error
|
|
351
|
+
# response it can read as HTML, so a text/plain 403 is dropped on the
|
|
352
|
+
# floor and the button looks broken rather than refused. The stream
|
|
353
|
+
# branch carries a real flash and refreshes, so the agent reads why.
|
|
354
|
+
def support_console_forbidden
|
|
355
|
+
respond_to do |format|
|
|
356
|
+
format.turbo_stream do
|
|
357
|
+
flash[:alert] = support_console_t("errors.forbidden")
|
|
358
|
+
render_support_console_refresh
|
|
359
|
+
end
|
|
360
|
+
format.any { render plain: support_console_t("errors.forbidden"), status: :forbidden }
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# --- What they may reach --------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
def set_support_ticket
|
|
367
|
+
@ticket = support_visible_tickets.find(params[:id])
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Tickets on the desks this agent may work. A ticket outside them is
|
|
371
|
+
# `ActiveRecord::RecordNotFound` — a 404, which is the honest answer.
|
|
372
|
+
def support_visible_tickets
|
|
373
|
+
SupportDesk::Ticket.where(desk: support_visible_desks)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# The desks this agent may work, asked once per request. EVERYTHING the
|
|
377
|
+
# console reaches for is scoped through this — the ticket, the queue,
|
|
378
|
+
# the tab counts, the badge and `next` — because scoping only the member
|
|
379
|
+
# actions leaves the index answering 200 with a reference, a requester's
|
|
380
|
+
# name and a preview on it.
|
|
381
|
+
def support_visible_desks
|
|
382
|
+
@support_visible_desks ||= SupportDesk.config.desks_visible_to(current_agent).to_a
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Nothing to work is not the same as "this case is none of your
|
|
386
|
+
# business": there is no case yet. A 403 says so, and it stops `?desk=`
|
|
387
|
+
# from being a way to ask about desks that were never on offer.
|
|
388
|
+
def require_visible_desk!
|
|
389
|
+
support_console_forbidden if support_visible_desks.empty?
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# The desk this console is working. `?desk=billing` switches between
|
|
393
|
+
# them on a multi-desk host — but only among the ones this agent may
|
|
394
|
+
# see; a key outside that set quietly falls back to the first visible
|
|
395
|
+
# desk rather than confirming that it exists.
|
|
396
|
+
def support_desk_record
|
|
397
|
+
return @support_desk_record if defined?(@support_desk_record)
|
|
398
|
+
|
|
399
|
+
requested = params[:desk].presence&.to_sym
|
|
400
|
+
@support_desk_record =
|
|
401
|
+
(requested && support_visible_desks.detect { |desk| desk.key.to_sym == requested }) ||
|
|
402
|
+
support_visible_desks.first
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def support_queue
|
|
406
|
+
@support_queue ||= SupportDesk::Queue.for(current_agent, desk: support_desk_record)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# The assign / hand-off target, resolved inside the pool of the TICKET's
|
|
410
|
+
# desk — never the one `?desk=` names. Those are different desks the
|
|
411
|
+
# moment a host has two, and reading the parameter let a billing agent
|
|
412
|
+
# be assigned to a case on another desk entirely.
|
|
413
|
+
def support_console_agent(id = params[:agent_id])
|
|
414
|
+
return nil if id.blank?
|
|
415
|
+
|
|
416
|
+
@ticket.desk.agents.detect { |agent| agent.id.to_s == id.to_s }
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def support_agent_name(agent)
|
|
420
|
+
agent.try(:support_agent_name) || agent.to_s
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# --- Running a verb --------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
# Run a transition, turn every refusal into a flash, and answer in the
|
|
426
|
+
# format that was asked for. The one place a console action can end.
|
|
427
|
+
def attempt(outcome, **interpolations)
|
|
428
|
+
yield
|
|
429
|
+
flash[:notice] = support_console_t("flashes.#{outcome}", **interpolations)
|
|
430
|
+
respond_to_transition
|
|
431
|
+
rescue StandardError => error
|
|
432
|
+
raise unless support_console_rescuable?(error)
|
|
433
|
+
|
|
434
|
+
flash[:alert] = support_console_error_message(error)
|
|
435
|
+
respond_to_transition
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# A refusal the console spotted before the model was asked (an empty
|
|
439
|
+
# message, an agent who isn't in the pool).
|
|
440
|
+
def refuse(reason)
|
|
441
|
+
flash[:alert] = support_console_t("errors.#{reason}")
|
|
442
|
+
respond_to_transition
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def respond_to_transition
|
|
446
|
+
respond_to do |format|
|
|
447
|
+
format.turbo_stream { render_support_console_refresh }
|
|
448
|
+
format.any { redirect_to after_transition_path(@ticket) }
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# A Turbo 8 page refresh (morph), written as a raw tag rather than
|
|
453
|
+
# `turbo_stream.refresh` so the gem needs no turbo-rails version floor.
|
|
454
|
+
# The flash is a real flash, so it survives the refetch.
|
|
455
|
+
#
|
|
456
|
+
# `render body:` rather than `render html:`: the html renderer forces
|
|
457
|
+
# text/html and quietly ignores `content_type:`, and a stream Turbo
|
|
458
|
+
# doesn't recognise as a stream is a stream Turbo throws away.
|
|
459
|
+
def render_support_console_refresh
|
|
460
|
+
render body: '<turbo-stream action="refresh"></turbo-stream>',
|
|
461
|
+
content_type: "text/vnd.turbo-stream.html"
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def support_console_rescuable?(error)
|
|
465
|
+
return true if RESCUED_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
466
|
+
return true if defined?(Chats::Error) && error.is_a?(Chats::Error)
|
|
467
|
+
return true if defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
|
|
468
|
+
|
|
469
|
+
false
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# A translated sentence, never the exception's own text. The model
|
|
473
|
+
# raises in English on purpose — those messages are written for whoever
|
|
474
|
+
# is reading a stack trace — so passing one straight into a flash meant
|
|
475
|
+
# a Spanish desk read "doesn't hold ticket T-AB12CD". `holder` carries
|
|
476
|
+
# the one detail worth keeping, and the console knows it without having
|
|
477
|
+
# to parse the error.
|
|
478
|
+
def support_console_error_message(error)
|
|
479
|
+
key = ERROR_KEYS[error.class.name] || "generic"
|
|
480
|
+
support_console_t("errors.#{key}", detail: error.message, holder: support_console_holder)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def support_console_t(key, **interpolations)
|
|
484
|
+
I18n.t("support_desk.console.#{key}", **interpolations)
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module SupportDesk
|
|
6
|
+
# The turnkey console, for hosts with no admin framework to hang one off:
|
|
7
|
+
#
|
|
8
|
+
# mount SupportDesk::ConsoleEngine => "/admin/support"
|
|
9
|
+
#
|
|
10
|
+
# It is Layer 2 with the views filled in — the same controller concern any
|
|
11
|
+
# host would include, and the same templates `rails g support_desk:console`
|
|
12
|
+
# writes into an app. One source of truth: the generator copies these
|
|
13
|
+
# files, so what you see mounted is what you get when you eject.
|
|
14
|
+
#
|
|
15
|
+
# Authentication and layout come from `config.console_parent_controller`,
|
|
16
|
+
# the way the requester engine takes `config.parent_controller`. Mounting
|
|
17
|
+
# this engine grants nothing: `require_support_agent!` still runs, and so
|
|
18
|
+
# does `config.authorize_console`.
|
|
19
|
+
#
|
|
20
|
+
# == Why it isolates SupportDesk::Console
|
|
21
|
+
#
|
|
22
|
+
# Two engines can't isolate the same namespace — the second would steal
|
|
23
|
+
# `SupportDesk.railtie_namespace` from the first, and the requester
|
|
24
|
+
# engine's URL helpers with it. So the console's controllers live under
|
|
25
|
+
# `SupportDesk::Console`, which is also the concern hosts include: one name
|
|
26
|
+
# for the console, whichever layer you use it from.
|
|
27
|
+
#
|
|
28
|
+
# ⚠️ One side effect worth knowing before you add anything to that
|
|
29
|
+
# namespace: `isolate_namespace` defines `table_name_prefix` on it, so a
|
|
30
|
+
# future `SupportDesk::Console::Something < ActiveRecord::Base` would look
|
|
31
|
+
# for a table called `support_desk_console_somethings` rather than the
|
|
32
|
+
# `support_desk_` prefix the rest of the gem uses. Nothing here is a
|
|
33
|
+
# model and nothing here should become one — the console is a web layer
|
|
34
|
+
# over `SupportDesk::Ticket`. Anything that needs to persist belongs in
|
|
35
|
+
# lib/support_desk/models, under the requester engine's namespace.
|
|
36
|
+
class ConsoleEngine < ::Rails::Engine
|
|
37
|
+
isolate_namespace SupportDesk::Console
|
|
38
|
+
|
|
39
|
+
# Its own routes file: the requester engine already owns
|
|
40
|
+
# `config/routes.rb`, and an engine that doesn't say otherwise would draw
|
|
41
|
+
# that same file a second time.
|
|
42
|
+
paths["config/routes.rb"] = "config/console_routes.rb"
|
|
43
|
+
|
|
44
|
+
# Both engines are rooted at the gem, so without this every locale file
|
|
45
|
+
# is registered twice — once by each engine's :add_locales. Harmless for
|
|
46
|
+
# precedence (identical files, adjacent in load_path) but it doubles the
|
|
47
|
+
# YAML parsed at boot, and a duplicated load_path is the kind of thing
|
|
48
|
+
# that makes a later precedence bug much harder to read. SupportDesk
|
|
49
|
+
# ::Engine already ships them for both namespaces.
|
|
50
|
+
paths["config/locales"] = []
|
|
51
|
+
|
|
52
|
+
# `concerns: :support_console` in the HOST's routes file. Registered from
|
|
53
|
+
# an initializer, which is early enough: the app's routes are not drawn
|
|
54
|
+
# until every railtie initializer has run.
|
|
55
|
+
#
|
|
56
|
+
# This lives on the console engine rather than the requester one because
|
|
57
|
+
# defining the class is what loads it — a host that never mounts this
|
|
58
|
+
# engine still gets the concern.
|
|
59
|
+
initializer "support_desk.console.routing_concern" do
|
|
60
|
+
SupportDesk::ConsoleRoutes.install!
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# The `:support_console` routing concern — the one line that turns two
|
|
5
|
+
# RESTful actions into a working console:
|
|
6
|
+
#
|
|
7
|
+
# namespace :madmin do
|
|
8
|
+
# resources :support_tickets, only: %i[index show], concerns: :support_console
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# It draws the collection route first and the member routes after, which is
|
|
12
|
+
# what keeps `/madmin/support_tickets/next` from being swallowed by
|
|
13
|
+
# `/madmin/support_tickets/:id` (a `resources` block draws its concerns
|
|
14
|
+
# before its own member mappings, so "next" wins).
|
|
15
|
+
#
|
|
16
|
+
# == Why a Mapper patch
|
|
17
|
+
#
|
|
18
|
+
# Routing concerns live in `@concerns`, a Hash created fresh inside every
|
|
19
|
+
# `Mapper`, i.e. once per `routes.draw` block. There is no registry a gem
|
|
20
|
+
# can add to, so seeding the Hash at Mapper construction is the only way to
|
|
21
|
+
# make `concerns: :support_console` read the way the PRD promises in the
|
|
22
|
+
# HOST's routes file. The patch adds exactly one key and calls `super`
|
|
23
|
+
# first; concerns the host defines themselves still win, because they are
|
|
24
|
+
# assigned after this ran.
|
|
25
|
+
module ConsoleRoutes
|
|
26
|
+
# The name a host writes in their routes file.
|
|
27
|
+
CONCERN = :support_console
|
|
28
|
+
|
|
29
|
+
# The member verbs, in the order an agent uses them.
|
|
30
|
+
MEMBER_VERBS = %i[reply take assign hand_off release close reopen note change_topic].freeze
|
|
31
|
+
|
|
32
|
+
# Rails' own message for this ("can't use collection outside resource(s)
|
|
33
|
+
# scope") is true and says nothing about which concern caused it, which
|
|
34
|
+
# is a bad half-hour when the only support_desk line in the file is the
|
|
35
|
+
# one word `concerns`.
|
|
36
|
+
OUTSIDE_RESOURCE_SCOPE = <<~MESSAGE
|
|
37
|
+
`concerns: :support_console` has to sit inside a `resources` block: it draws member routes
|
|
38
|
+
(reply, take, assign, …) and a collection route (next) for a ticket resource, and neither
|
|
39
|
+
means anything without one.
|
|
40
|
+
|
|
41
|
+
namespace :madmin do
|
|
42
|
+
resources :support_tickets, only: %i[index show], concerns: :support_console
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
or, if you prefer the block form:
|
|
46
|
+
|
|
47
|
+
resources :support_tickets, only: %i[index show] do
|
|
48
|
+
concerns :support_console
|
|
49
|
+
end
|
|
50
|
+
MESSAGE
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
# Make `concerns: :support_console` available in every route set.
|
|
54
|
+
# Idempotent — the engine calls it at boot, tests may call it again.
|
|
55
|
+
def install!
|
|
56
|
+
return false if @installed
|
|
57
|
+
|
|
58
|
+
require "action_dispatch"
|
|
59
|
+
ActionDispatch::Routing::Mapper.prepend(MapperExtension)
|
|
60
|
+
@installed = true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def installed? = !!@installed
|
|
64
|
+
|
|
65
|
+
# Register the concern on one mapper. Also the escape hatch for a host
|
|
66
|
+
# that would rather not have the patch at all:
|
|
67
|
+
#
|
|
68
|
+
# Rails.application.routes.draw do
|
|
69
|
+
# SupportDesk::ConsoleRoutes.register(self)
|
|
70
|
+
# namespace(:madmin) { resources :support_tickets, concerns: :support_console }
|
|
71
|
+
# end
|
|
72
|
+
def register(mapper)
|
|
73
|
+
mapper.concern(CONCERN, Drawer.new)
|
|
74
|
+
mapper
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The concern itself, as the callable object Rails' `concern` documents —
|
|
79
|
+
# so the routes are a plain object you can read, test and call.
|
|
80
|
+
class Drawer
|
|
81
|
+
# +options+ are whatever the host passed alongside the concern; they go
|
|
82
|
+
# to every route, which is how `concerns :support_console, path: "t"`
|
|
83
|
+
# keeps working.
|
|
84
|
+
def call(mapper, options = {})
|
|
85
|
+
mapper.collection do
|
|
86
|
+
mapper.get :next, **options
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
mapper.member do
|
|
90
|
+
MEMBER_VERBS.each { |verb| mapper.post verb, **options }
|
|
91
|
+
end
|
|
92
|
+
rescue ArgumentError => e
|
|
93
|
+
raise unless e.message.include?("outside resource")
|
|
94
|
+
|
|
95
|
+
raise SupportDesk::ConfigurationError, OUTSIDE_RESOURCE_SCOPE
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Seeds the concern into every Mapper the moment one is built.
|
|
100
|
+
module MapperExtension
|
|
101
|
+
def initialize(...)
|
|
102
|
+
super
|
|
103
|
+
SupportDesk::ConsoleRoutes.register(self)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|