support_desk 0.1.3 → 0.2.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 +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +105 -10
- data/Rakefile +16 -0
- data/app/controllers/support_desk/tickets_controller.rb +5 -3
- data/app/helpers/support_desk/engine_helper.rb +7 -1
- data/app/views/support_desk/console/tickets/_context_card.html.erb +5 -0
- data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +8 -0
- data/app/views/support_desk/console/tickets/index.html.erb +14 -2
- data/app/views/support_desk/console/tickets/new.html.erb +25 -0
- data/config/console_routes.rb +1 -1
- data/config/locales/support_desk.console.en.yml +26 -0
- data/config/locales/support_desk.console.es.yml +26 -0
- data/config/locales/support_desk.en.yml +3 -0
- data/config/locales/support_desk.es.yml +6 -0
- data/lib/generators/support_desk/console_generator.rb +1 -1
- data/lib/generators/support_desk/install_generator.rb +14 -0
- data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
- data/lib/generators/support_desk/templates/initializer.rb +30 -0
- data/lib/generators/support_desk/upgrade_generator.rb +56 -0
- data/lib/support_desk/configuration.rb +148 -2
- data/lib/support_desk/console.rb +347 -25
- data/lib/support_desk/console_engine.rb +2 -0
- data/lib/support_desk/console_routes.rb +20 -8
- data/lib/support_desk/context_card.rb +23 -0
- data/lib/support_desk/doctor.rb +51 -1
- data/lib/support_desk/errors.rb +5 -0
- data/lib/support_desk/macros.rb +28 -12
- data/lib/support_desk/models/assignment.rb +3 -1
- data/lib/support_desk/models/concerns/agent.rb +39 -4
- data/lib/support_desk/models/concerns/requester.rb +27 -12
- data/lib/support_desk/models/ticket.rb +464 -100
- data/lib/support_desk/summary.rb +1 -1
- data/lib/support_desk/test_helpers.rb +8 -3
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +4 -12
- data/lib/support_desk.rb +45 -0
- data/lib/tasks/support_desk.rake +27 -0
- metadata +7 -2
data/lib/support_desk/console.rb
CHANGED
|
@@ -8,7 +8,7 @@ module SupportDesk
|
|
|
8
8
|
#
|
|
9
9
|
# # config/routes.rb
|
|
10
10
|
# namespace :madmin do
|
|
11
|
-
# resources :support_tickets, only: %i[index show], concerns: :support_console
|
|
11
|
+
# resources :support_tickets, only: %i[index show new], concerns: :support_console
|
|
12
12
|
# end
|
|
13
13
|
#
|
|
14
14
|
# class Madmin::SupportTicketsController < Madmin::ApplicationController
|
|
@@ -18,9 +18,10 @@ module SupportDesk
|
|
|
18
18
|
# def current_agent = current_user # or rely on config.current_agent_method
|
|
19
19
|
# end
|
|
20
20
|
#
|
|
21
|
-
# `index` and `
|
|
22
|
-
# ContextCard, Timeline, actions_for) is everything they need
|
|
23
|
-
# concern
|
|
21
|
+
# `index`, `show` and `new` stay yours — they are the UI, and Layer 1
|
|
22
|
+
# (Queue, ContextCard, Timeline, actions_for) is everything they need; the
|
|
23
|
+
# concern fills `new`'s draft and answers the `open_conversation` behind
|
|
24
|
+
# it. What this concern owns is the boring, easy-to-get-wrong half:
|
|
24
25
|
#
|
|
25
26
|
# * <b>Who is asking.</b> `current_agent` must be an eligible agent, or the
|
|
26
27
|
# request is a 403. `SupportDesk::Current.actor` is set from it, so a
|
|
@@ -47,12 +48,28 @@ module SupportDesk
|
|
|
47
48
|
module Console
|
|
48
49
|
extend ActiveSupport::Concern
|
|
49
50
|
|
|
50
|
-
# The verbs, in the order the routing concern draws them.
|
|
51
|
-
|
|
51
|
+
# The verbs, in the order the routing concern draws them. ONE table: the
|
|
52
|
+
# routing concern reads it at draw time and `OFFERED_AS` is checked
|
|
53
|
+
# against it, because a verb added in two places is a console that
|
|
54
|
+
# accepts a POST its own Drawer never routed (or the other way round).
|
|
55
|
+
MEMBER_VERBS = %i[reply take assign hand_off release close reopen note change_topic].freeze
|
|
56
|
+
|
|
57
|
+
# The verbs that work on the QUEUE rather than on a case, and the HTTP
|
|
58
|
+
# method each one is drawn with. `open_conversation` is the only write
|
|
59
|
+
# here: there is no ticket yet, which is the whole point of it.
|
|
60
|
+
COLLECTION_VERBS = { next: :get, open_conversation: :post }.freeze
|
|
61
|
+
|
|
62
|
+
# What 0.1 called the member verbs, kept for a release: a host may have
|
|
63
|
+
# written it into their own routes or their own tests.
|
|
64
|
+
TRANSITIONS = MEMBER_VERBS
|
|
52
65
|
|
|
53
66
|
# Actions that work on one ticket, so `set_support_ticket` runs for them.
|
|
54
67
|
# `show` is the host's, but it still wants the ticket found safely.
|
|
55
|
-
MEMBER_ACTIONS = ([ :show ] +
|
|
68
|
+
MEMBER_ACTIONS = ([ :show ] + MEMBER_VERBS).freeze
|
|
69
|
+
|
|
70
|
+
# The two actions that have no ticket: the form, and the send behind it.
|
|
71
|
+
# `authorize_console` is asked about both with a nil ticket.
|
|
72
|
+
CONVERSATION_ACTIONS = %i[new open_conversation].freeze
|
|
56
73
|
|
|
57
74
|
# Which entry in `ticket.actions_for(agent)` each verb needs. The
|
|
58
75
|
# console renders exactly what that method returns, so it must accept
|
|
@@ -81,19 +98,31 @@ module SupportDesk
|
|
|
81
98
|
SupportDesk::UnknownTopic,
|
|
82
99
|
SupportDesk::NotSupportable,
|
|
83
100
|
SupportDesk::RateLimited,
|
|
84
|
-
SupportDesk::TooManyOpenTickets
|
|
101
|
+
SupportDesk::TooManyOpenTickets,
|
|
102
|
+
# Nobody to write to: a closed account, or a record that was never a
|
|
103
|
+
# requester at all.
|
|
104
|
+
SupportDesk::NotARequester,
|
|
105
|
+
# Everything chats refuses at the write — a blocked pair, a locked
|
|
106
|
+
# conversation, a host policy that says these two may not talk. Its
|
|
107
|
+
# CONFIGURATION error is deliberately not covered by this; see
|
|
108
|
+
# #support_console_rescuable?. Chats is loaded by the spine, so naming
|
|
109
|
+
# it here costs no constant that might not exist.
|
|
110
|
+
Chats::Error
|
|
85
111
|
].freeze
|
|
86
112
|
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
113
|
+
# A refusal the console spotted in the REQUEST rather than in the domain:
|
|
114
|
+
# a token that names nothing, a Hash where text belongs, somebody this
|
|
115
|
+
# desk has no way to look up. It carries the locale key its flash reads,
|
|
116
|
+
# and it is deliberately NOT a SupportDesk::Error — nothing outside this
|
|
117
|
+
# concern should be rescuing it.
|
|
118
|
+
class InvalidInput < StandardError
|
|
119
|
+
attr_reader :key
|
|
120
|
+
|
|
121
|
+
def initialize(key)
|
|
122
|
+
@key = key.to_s
|
|
123
|
+
super("support_desk.console.errors.#{@key}")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
97
126
|
|
|
98
127
|
included do
|
|
99
128
|
before_action :require_support_agent!
|
|
@@ -101,11 +130,14 @@ module SupportDesk
|
|
|
101
130
|
before_action :set_support_current_actor
|
|
102
131
|
before_action :set_support_ticket, only: MEMBER_ACTIONS
|
|
103
132
|
before_action :authorize_support_console!
|
|
104
|
-
before_action :require_offered_action!, only:
|
|
133
|
+
before_action :require_offered_action!, only: MEMBER_VERBS
|
|
134
|
+
before_action :require_conversation_duty!, only: :open_conversation
|
|
105
135
|
after_action :mark_support_transcript_read, only: :show
|
|
106
136
|
|
|
107
137
|
helper_method :current_agent, :support_desk_record, :support_queue, :support_transcript,
|
|
108
|
-
:console_ticket_path, :console_tickets_path, :console_file_path
|
|
138
|
+
:console_ticket_path, :console_tickets_path, :console_file_path,
|
|
139
|
+
:support_conversation_available?, :support_conversation_offered?,
|
|
140
|
+
:support_conversation_topics, :support_conversation_sendable?
|
|
109
141
|
end
|
|
110
142
|
|
|
111
143
|
# --- The verbs --------------------------------------------------------------
|
|
@@ -178,6 +210,39 @@ module SupportDesk
|
|
|
178
210
|
attempt(:topic_changed) { @ticket.change_topic!(to: topic, by: current_agent, request: request) }
|
|
179
211
|
end
|
|
180
212
|
|
|
213
|
+
# The form for writing to somebody who hasn't written to us — "Escribir
|
|
214
|
+
# a alguien". Renders with whatever the caller supplied: a requester
|
|
215
|
+
# GlobalID from one of your own pages (a user's admin screen, a ride),
|
|
216
|
+
# a subject to be about, a topic. Blank is an empty form; a token that
|
|
217
|
+
# names nothing is the same refusal here as it is on the send, because a
|
|
218
|
+
# form that quietly drops the person it was opened for is worse than one
|
|
219
|
+
# that says it couldn't find them.
|
|
220
|
+
def new
|
|
221
|
+
assign_conversation_draft
|
|
222
|
+
rescue StandardError => error
|
|
223
|
+
raise unless support_console_rescuable?(error)
|
|
224
|
+
|
|
225
|
+
refuse_conversation(error)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Send it. One case either way: a new one when this person has nothing
|
|
229
|
+
# open that covers it, an ordinary reply into the one they do — under
|
|
230
|
+
# this desk's reply policy, and only if the host still says this agent
|
|
231
|
+
# may answer THAT case.
|
|
232
|
+
def open_conversation
|
|
233
|
+
assign_conversation_draft
|
|
234
|
+
ticket = SupportDesk::Ticket.open_or_reply!(**conversation_arguments)
|
|
235
|
+
|
|
236
|
+
@ticket = ticket
|
|
237
|
+
redirect_to after_transition_path(ticket), status: :see_other,
|
|
238
|
+
notice: support_console_t("flashes.message_sent",
|
|
239
|
+
requester: Chats.display_name_for(@requester))
|
|
240
|
+
rescue StandardError => error
|
|
241
|
+
raise unless support_console_rescuable?(error)
|
|
242
|
+
|
|
243
|
+
refuse_conversation(error)
|
|
244
|
+
end
|
|
245
|
+
|
|
181
246
|
# The most urgent thing this agent should be looking at. The whole
|
|
182
247
|
# "work the queue" loop is this one button.
|
|
183
248
|
def next
|
|
@@ -268,7 +333,7 @@ module SupportDesk
|
|
|
268
333
|
# that no test notices until somebody counts.
|
|
269
334
|
def support_queue_tickets
|
|
270
335
|
support_queue.scope(@scope)
|
|
271
|
-
.includes(:requester, :assignee, :desk, :subject,
|
|
336
|
+
.includes(:requester, :assignee, :desk, :subject, :opened_by,
|
|
272
337
|
conversation: { last_message: %i[sender author] })
|
|
273
338
|
.limit(support_tickets_per_page)
|
|
274
339
|
end
|
|
@@ -278,8 +343,248 @@ module SupportDesk
|
|
|
278
343
|
def support_tickets_per_page = 50
|
|
279
344
|
end
|
|
280
345
|
|
|
346
|
+
# --- Writing first ------------------------------------------------------------
|
|
347
|
+
#
|
|
348
|
+
# Two overridable seams, and their contract:
|
|
349
|
+
#
|
|
350
|
+
# * `support_conversation_requester` returns the person this conversation
|
|
351
|
+
# is FOR, or nil when nothing was supplied. It NEVER returns somebody
|
|
352
|
+
# other than the one that was asked for: a supplied GlobalID is
|
|
353
|
+
# authoritative, and a bad one is a refusal
|
|
354
|
+
# (`raise InvalidInput, :invalid_requester`), never a silent fallback
|
|
355
|
+
# to the typed query.
|
|
356
|
+
# * `support_conversation_subject` returns what the case is about, or
|
|
357
|
+
# nil when nothing was supplied; a supplied token that doesn't resolve,
|
|
358
|
+
# or resolves to something this requester may not talk about, is
|
|
359
|
+
# `raise InvalidInput, :invalid_subject`.
|
|
360
|
+
#
|
|
361
|
+
# Both are looked up inside the classes that declared themselves
|
|
362
|
+
# (`SupportDesk.requester_classes`, `.supportable_classes`) — a raw
|
|
363
|
+
# GlobalID is an identifier, never permission to call `find` on whatever
|
|
364
|
+
# class it names. A multi-tenant host narrows BOTH ways in, because
|
|
365
|
+
# `config.find_requester` only guards the typed one:
|
|
366
|
+
#
|
|
367
|
+
# def support_conversation_requester
|
|
368
|
+
# found = super
|
|
369
|
+
# return nil if found.nil?
|
|
370
|
+
# raise SupportDesk::Console::InvalidInput, :invalid_requester unless
|
|
371
|
+
# found.account_id == current_agent.account_id
|
|
372
|
+
#
|
|
373
|
+
# found
|
|
374
|
+
# end
|
|
375
|
+
#
|
|
376
|
+
# (The model checks eligibility on its own either way — see
|
|
377
|
+
# `Ticket.open!` — so an override that forgets is a narrower door, never
|
|
378
|
+
# a wider one.)
|
|
379
|
+
|
|
380
|
+
# The person this conversation is for, from a GlobalID one of your own
|
|
381
|
+
# pages handed over, or from what an agent typed into the form.
|
|
382
|
+
def support_conversation_requester
|
|
383
|
+
token = conversation_param(:requester)
|
|
384
|
+
return locate_conversation_record(token, SupportDesk.requester_classes, :invalid_requester) if token.present?
|
|
385
|
+
return nil if @requester_query.blank?
|
|
386
|
+
|
|
387
|
+
finder = support_desk_record.config.find_requester
|
|
388
|
+
raise InvalidInput, :no_requester_lookup if finder.nil?
|
|
389
|
+
|
|
390
|
+
found = finder.call(@requester_query)
|
|
391
|
+
raise InvalidInput, :unknown_requester if found.nil?
|
|
392
|
+
raise InvalidInput, :invalid_requester unless SupportDesk.requester_class?(found.class)
|
|
393
|
+
|
|
394
|
+
found
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# What the case is about, when the page that opened the form knew.
|
|
398
|
+
def support_conversation_subject
|
|
399
|
+
token = conversation_param(:about)
|
|
400
|
+
return nil if token.blank?
|
|
401
|
+
|
|
402
|
+
subject = locate_conversation_record(token, SupportDesk.supportable_classes, :invalid_subject)
|
|
403
|
+
# Checked here so the form can't show a card for something this person
|
|
404
|
+
# may not talk about, and checked AGAIN by the model at the write.
|
|
405
|
+
raise InvalidInput, :invalid_subject unless @requester && subject.supportable_by?(@requester)
|
|
406
|
+
|
|
407
|
+
subject
|
|
408
|
+
end
|
|
409
|
+
|
|
281
410
|
private
|
|
282
411
|
|
|
412
|
+
# Everything the form renders, set BEFORE anything can fail: a refusal
|
|
413
|
+
# has to come back with the draft still in it, or an agent retypes their
|
|
414
|
+
# message every time they mistype an email.
|
|
415
|
+
def assign_conversation_draft
|
|
416
|
+
@requester = nil
|
|
417
|
+
@about = nil
|
|
418
|
+
# Capture every independently valid field before any validation raises.
|
|
419
|
+
@requester_query, @topic, @body = %i[requester_query topic body].map do |name|
|
|
420
|
+
params[name].is_a?(String) ? params[name] : ""
|
|
421
|
+
end
|
|
422
|
+
@topic = @topic.presence
|
|
423
|
+
@files = []
|
|
424
|
+
|
|
425
|
+
@requester = support_conversation_requester
|
|
426
|
+
@about = support_conversation_subject
|
|
427
|
+
# A supplied topic wins; a subject's own topic is the obvious default.
|
|
428
|
+
@topic ||= @about&.support_topic
|
|
429
|
+
%i[requester_query topic body].each { |name| conversation_param(name) }
|
|
430
|
+
validate_conversation_desk!
|
|
431
|
+
@conversation_requester_unavailable = @requester.present?
|
|
432
|
+
SupportDesk::Ticket.ensure_requester!(@requester) if @requester
|
|
433
|
+
@conversation_requester_unavailable = false
|
|
434
|
+
@files = conversation_files
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def validate_conversation_desk!
|
|
438
|
+
return unless params.key?(:desk)
|
|
439
|
+
|
|
440
|
+
key = params[:desk]
|
|
441
|
+
return if key.is_a?(String) && support_visible_desks.any? { |desk| desk.key.to_s == key }
|
|
442
|
+
|
|
443
|
+
@invalid_conversation_desk = true
|
|
444
|
+
raise InvalidInput, :invalid_input
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Validate transport shapes here; MIME/size/count policy stays on Message.
|
|
448
|
+
def conversation_files
|
|
449
|
+
files = params[:files]
|
|
450
|
+
return [] if files.nil?
|
|
451
|
+
raise InvalidInput, :invalid_input unless files.is_a?(Array)
|
|
452
|
+
|
|
453
|
+
files.reject { |file| file.nil? || file == "" }.map do |file|
|
|
454
|
+
case file
|
|
455
|
+
when ActionDispatch::Http::UploadedFile then file
|
|
456
|
+
when String
|
|
457
|
+
raise InvalidInput, :invalid_input unless defined?(ActiveStorage::Blob)
|
|
458
|
+
|
|
459
|
+
begin
|
|
460
|
+
ActiveStorage::Blob.find_signed!(file)
|
|
461
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
|
|
462
|
+
raise InvalidInput, :invalid_input
|
|
463
|
+
end
|
|
464
|
+
else raise InvalidInput, :invalid_input
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def conversation_arguments
|
|
470
|
+
raise InvalidInput, :unknown_requester if @requester.nil?
|
|
471
|
+
# The same answer the member `reply` action gives, for the same
|
|
472
|
+
# mistake. The MODEL refuses a blank staff message too (and rolls the
|
|
473
|
+
# case back with it), but "Escribe algo antes de enviar" is what an
|
|
474
|
+
# agent needs to read, not a validation error about a message body.
|
|
475
|
+
raise InvalidInput, :blank_message if @body.strip.empty? && @files.empty?
|
|
476
|
+
# A dual-role account (an admin who is also a customer) writing to
|
|
477
|
+
# themselves is ambiguous: the low-level API would read it as them
|
|
478
|
+
# asking for help, which is not what this form is for.
|
|
479
|
+
if SupportDesk::Ticket.same_actor?(@requester, current_agent)
|
|
480
|
+
raise InvalidInput, :writing_to_yourself
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
{
|
|
484
|
+
requester: @requester, by: current_agent, message: @body, about: @about, topic: @topic,
|
|
485
|
+
files: @files, via: :in_app, desk: support_desk_record, request: request,
|
|
486
|
+
requester_role: @requester.class.support_desk_requester_options[:as],
|
|
487
|
+
authorize_reuse: method(:authorize_conversation_reuse)
|
|
488
|
+
}
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Run under the REUSED case's row lock, before the reply policy has done
|
|
492
|
+
# anything: a host may let this agent write to this person and still
|
|
493
|
+
# refuse them this particular case, and the answer has to be the same one
|
|
494
|
+
# the member `reply` action would have given. Raising rolls the whole
|
|
495
|
+
# operation back.
|
|
496
|
+
def authorize_conversation_reuse(ticket)
|
|
497
|
+
@ticket = ticket
|
|
498
|
+
return if SupportDesk.config.console_authorized?(current_agent, ticket, :reply) &&
|
|
499
|
+
ticket.actions_for(current_agent).include?(:reply)
|
|
500
|
+
|
|
501
|
+
raise SupportDesk::NotAllowed,
|
|
502
|
+
"#{current_agent.class}##{current_agent.id} may not reply to ticket #{ticket.reference}"
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Text fields are text. A Hash or an Array where a string belongs is a
|
|
506
|
+
# crafted request, not something to `.to_s` and then look up.
|
|
507
|
+
def conversation_param(name)
|
|
508
|
+
value = params[name]
|
|
509
|
+
return "" if value.nil?
|
|
510
|
+
raise InvalidInput, :invalid_input unless value.is_a?(String)
|
|
511
|
+
|
|
512
|
+
value
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# A GlobalID is an identifier, not an authorization: it resolves only
|
|
516
|
+
# inside the classes that declared themselves, only for this app, and a
|
|
517
|
+
# token that is malformed, foreign, out of that set or pointing at a row
|
|
518
|
+
# that is gone is one refusal — never a different target.
|
|
519
|
+
def locate_conversation_record(token, allowed, refusal)
|
|
520
|
+
gid = GlobalID.parse(token)
|
|
521
|
+
raise InvalidInput, refusal if gid.nil? || gid.app.to_s != GlobalID.app.to_s
|
|
522
|
+
|
|
523
|
+
# safe_constantize ignores missing token constants, but propagates bugs
|
|
524
|
+
# inside an autoload. The host finder runs outside that boundary.
|
|
525
|
+
model = gid.model_name.safe_constantize
|
|
526
|
+
raise InvalidInput, refusal unless model.is_a?(Class) && allowed.any? { |klass| model <= klass }
|
|
527
|
+
|
|
528
|
+
GlobalID::Locator.locate(gid, only: allowed) || raise(InvalidInput, refusal)
|
|
529
|
+
rescue ActiveRecord::RecordNotFound, GlobalID::Locator::InvalidModelIdError
|
|
530
|
+
# A class name nothing answers to, an id the model can't read, a row
|
|
531
|
+
# that is gone: the same bad token in the same field.
|
|
532
|
+
raise InvalidInput, refusal
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Off duty is off duty on both surfaces: `actions_for` gives an off-duty
|
|
536
|
+
# agent nothing that speaks to a requester, and neither does this.
|
|
537
|
+
def require_conversation_duty!
|
|
538
|
+
return if support_conversation_available?
|
|
539
|
+
|
|
540
|
+
assign_conversation_draft
|
|
541
|
+
flash.now[:alert] = support_console_t("errors.off_duty")
|
|
542
|
+
render :new, formats: [ :html ], status: :unprocessable_entity
|
|
543
|
+
rescue StandardError => error
|
|
544
|
+
raise unless support_console_rescuable?(error)
|
|
545
|
+
|
|
546
|
+
refuse_conversation(error)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Whether this agent may send from the form at all — what the form reads
|
|
550
|
+
# to disable its own button rather than offering one that only ever 422s.
|
|
551
|
+
def support_conversation_available?
|
|
552
|
+
!current_agent.respond_to?(:on_duty?) || current_agent.on_duty?
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def support_conversation_sendable?
|
|
556
|
+
support_conversation_available? && !@invalid_conversation_desk && !@conversation_requester_unavailable
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Whether to show the door on the queue at all: on duty, and the host's
|
|
560
|
+
# policy says so. Both are asked again at the form and at the send — this
|
|
561
|
+
# is what keeps the queue from offering a button that only refuses.
|
|
562
|
+
def support_conversation_offered?
|
|
563
|
+
support_conversation_available? &&
|
|
564
|
+
SupportDesk.config.console_authorized?(current_agent, nil, :open_conversation)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# The topics a case can be opened onto from here: the leaves somebody
|
|
568
|
+
# can write freely under. A subject brings its own topic with it, so
|
|
569
|
+
# this is the picker for everything else.
|
|
570
|
+
def support_conversation_topics
|
|
571
|
+
support_desk_record.config.topics.leaves.select(&:free_form?)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# A handled refusal: the form again, with everything the agent typed
|
|
575
|
+
# still in it, and the reason on top. 422 rather than a redirect for
|
|
576
|
+
# HTML and Turbo alike — a stream refresh would throw the draft away.
|
|
577
|
+
def refuse_conversation(error)
|
|
578
|
+
flash.now[:alert] = support_conversation_error_message(error)
|
|
579
|
+
render :new, formats: [ :html ], status: :unprocessable_entity
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def support_conversation_error_message(error)
|
|
583
|
+
return support_console_t("errors.#{error.key}") if error.is_a?(InvalidInput)
|
|
584
|
+
|
|
585
|
+
support_console_error_message(error)
|
|
586
|
+
end
|
|
587
|
+
|
|
283
588
|
def mark_support_transcript_read
|
|
284
589
|
return unless response.successful? && @support_read_through
|
|
285
590
|
|
|
@@ -406,7 +711,12 @@ module SupportDesk
|
|
|
406
711
|
def support_desk_record
|
|
407
712
|
return @support_desk_record if defined?(@support_desk_record)
|
|
408
713
|
|
|
409
|
-
|
|
714
|
+
# A desk key is text. A Hash or an Array here is not a desk anybody has
|
|
715
|
+
# — it is a crafted parameter — and every console screen reads this, so
|
|
716
|
+
# it answers the way an unknown key does (the first visible desk)
|
|
717
|
+
# rather than taking the whole console down with a NoMethodError.
|
|
718
|
+
requested = params[:desk]
|
|
719
|
+
requested = requested.is_a?(String) ? requested.presence&.to_sym : nil
|
|
410
720
|
@support_desk_record =
|
|
411
721
|
(requested && support_visible_desks.detect { |desk| desk.key.to_sym == requested }) ||
|
|
412
722
|
support_visible_desks.first
|
|
@@ -472,8 +782,15 @@ module SupportDesk
|
|
|
472
782
|
end
|
|
473
783
|
|
|
474
784
|
def support_console_rescuable?(error)
|
|
785
|
+
# A chats CONFIGURATION error is a bug in the host's wiring, not a
|
|
786
|
+
# refusal an agent can do anything about, and it is a Chats::Error —
|
|
787
|
+
# so it has to be taken back out before the base class goes in.
|
|
788
|
+
return false if error.is_a?(Chats::ConfigurationError)
|
|
789
|
+
return true if error.is_a?(InvalidInput)
|
|
475
790
|
return true if RESCUED_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
476
|
-
|
|
791
|
+
# A RUNTIME check on purpose: `require "support_desk"` in a fresh
|
|
792
|
+
# process does not load ActiveRecord (chats is required, ActiveRecord
|
|
793
|
+
# is not), and naming the constant in RESCUED_ERRORS would break that.
|
|
477
794
|
return true if defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
|
|
478
795
|
|
|
479
796
|
false
|
|
@@ -485,9 +802,14 @@ module SupportDesk
|
|
|
485
802
|
# a Spanish desk read "doesn't hold ticket T-AB12CD". `holder` carries
|
|
486
803
|
# the one detail worth keeping, and the console knows it without having
|
|
487
804
|
# to parse the error.
|
|
805
|
+
# The key is the error's own name (`NotTheAssignee` →
|
|
806
|
+
# "not_the_assignee"), so a new error needs copy and nothing else — and a
|
|
807
|
+
# host that overrode one of these keys keeps its wording, because the
|
|
808
|
+
# names are the ones the old hand-written table used.
|
|
488
809
|
def support_console_error_message(error)
|
|
489
|
-
key =
|
|
490
|
-
support_console_t("errors.#{key}", detail: error.message, holder: support_console_holder
|
|
810
|
+
key = error.class.name.demodulize.underscore
|
|
811
|
+
support_console_t("errors.#{key}", detail: error.message, holder: support_console_holder,
|
|
812
|
+
default: :"support_desk.console.errors.generic")
|
|
491
813
|
end
|
|
492
814
|
|
|
493
815
|
def support_console_t(key, **interpolations)
|
|
@@ -48,6 +48,8 @@ module SupportDesk
|
|
|
48
48
|
# that makes a later precedence bug much harder to read. SupportDesk
|
|
49
49
|
# ::Engine already ships them for both namespaces.
|
|
50
50
|
paths["config/locales"] = []
|
|
51
|
+
# The requester engine owns task discovery too; both share this root.
|
|
52
|
+
paths["lib/tasks"] = []
|
|
51
53
|
|
|
52
54
|
# `concerns: :support_console` in the HOST's routes file. Registered from
|
|
53
55
|
# an initializer, which is early enough: the app's routes are not drawn
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SupportDesk
|
|
4
|
-
# The `:support_console` routing concern — the one line that turns
|
|
4
|
+
# The `:support_console` routing concern — the one line that turns a few
|
|
5
5
|
# RESTful actions into a working console:
|
|
6
6
|
#
|
|
7
7
|
# namespace :madmin do
|
|
8
|
-
# resources :support_tickets, only: %i[index show], concerns: :support_console
|
|
8
|
+
# resources :support_tickets, only: %i[index show new], concerns: :support_console
|
|
9
9
|
# end
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
11
|
+
# `new` stays yours — add it to `only:` when you render the form the
|
|
12
|
+
# console's `open_conversation` posts to ("Write to someone"). The concern
|
|
13
|
+
# draws the collection routes (`next`, `open_conversation`) and every
|
|
14
|
+
# member verb.
|
|
15
|
+
#
|
|
16
|
+
# It draws the collection routes first and the member routes after, which
|
|
17
|
+
# is what keeps `/madmin/support_tickets/next` from being swallowed by
|
|
13
18
|
# `/madmin/support_tickets/:id` (a `resources` block draws its concerns
|
|
14
19
|
# before its own member mappings, so "next" wins).
|
|
15
20
|
#
|
|
@@ -26,8 +31,11 @@ module SupportDesk
|
|
|
26
31
|
# The name a host writes in their routes file.
|
|
27
32
|
CONCERN = :support_console
|
|
28
33
|
|
|
29
|
-
# The
|
|
30
|
-
|
|
34
|
+
# The verbs, from the ONE table that has them: the concern that answers
|
|
35
|
+
# them owns it, so a verb can never be routed without an action or
|
|
36
|
+
# answered without a route. Kept here as an alias for a release, because
|
|
37
|
+
# a host may have read it.
|
|
38
|
+
MEMBER_VERBS = SupportDesk::Console::MEMBER_VERBS
|
|
31
39
|
|
|
32
40
|
# Rails' own message for this ("can't use collection outside resource(s)
|
|
33
41
|
# scope") is true and says nothing about which concern caused it, which
|
|
@@ -82,12 +90,16 @@ module SupportDesk
|
|
|
82
90
|
# to every route, which is how `concerns :support_console, path: "t"`
|
|
83
91
|
# keeps working.
|
|
84
92
|
def call(mapper, options = {})
|
|
93
|
+
# Read at DRAW time, from SupportDesk::Console — which is loaded by
|
|
94
|
+
# the spine long before any routes file runs, so there is no
|
|
95
|
+
# load-order risk in taking the verbs from the concern that answers
|
|
96
|
+
# them rather than keeping a second list here.
|
|
85
97
|
mapper.collection do
|
|
86
|
-
mapper.
|
|
98
|
+
SupportDesk::Console::COLLECTION_VERBS.each { |verb, method| mapper.public_send(method, verb, **options) }
|
|
87
99
|
end
|
|
88
100
|
|
|
89
101
|
mapper.member do
|
|
90
|
-
MEMBER_VERBS.each { |verb| mapper.post verb, **options }
|
|
102
|
+
SupportDesk::Console::MEMBER_VERBS.each { |verb| mapper.post verb, **options }
|
|
91
103
|
end
|
|
92
104
|
rescue ArgumentError => e
|
|
93
105
|
raise unless e.message.include?("outside resource")
|
|
@@ -62,6 +62,29 @@ module SupportDesk
|
|
|
62
62
|
# When this requester joined — context for "is this a new user?".
|
|
63
63
|
def requester_since = requester.try(:created_at)
|
|
64
64
|
|
|
65
|
+
# Who opened the case, as the console should print it: their name when
|
|
66
|
+
# the record still resolves, the desk's name in front of it when WE
|
|
67
|
+
# wrote first, and an honest label when there is no record to name.
|
|
68
|
+
#
|
|
69
|
+
# The three unnameable cases are not the same thing and must not read the
|
|
70
|
+
# same way: no provenance at all, an actor whose record is gone, and a
|
|
71
|
+
# requester-opened case, which is just their name.
|
|
72
|
+
#
|
|
73
|
+
# "Not recorded" rather than "automation": 0.2 opens every case as a
|
|
74
|
+
# record (automation openers are refused — docs/12-open-questions.md
|
|
75
|
+
# Q17), so a NULL pair is a row 0.1 wrote and nobody has backfilled yet,
|
|
76
|
+
# which is exactly what `SupportDesk.doctor` asks somebody to do.
|
|
77
|
+
def opened_by_label
|
|
78
|
+
opener = ticket.opened_by
|
|
79
|
+
return I18n.t("support_desk.console.context.not_recorded") if ticket.opened_by_id.blank?
|
|
80
|
+
return I18n.t("support_desk.console.context.unavailable") if opener.nil?
|
|
81
|
+
|
|
82
|
+
name = opener.try(:support_agent_name) || Chats.display_name_for(opener)
|
|
83
|
+
return name if ticket.opened_by_requester?
|
|
84
|
+
|
|
85
|
+
"#{ticket.desk.name} · #{name}"
|
|
86
|
+
end
|
|
87
|
+
|
|
65
88
|
# How many open cases this requester has right now, this one included.
|
|
66
89
|
def requester_open_tickets
|
|
67
90
|
Ticket.not_closed.where(requester: requester).count
|
data/lib/support_desk/doctor.rb
CHANGED
|
@@ -103,6 +103,13 @@ module SupportDesk
|
|
|
103
103
|
ok_with("#{tree.count} topic(s), #{tree.leaves.size} leaf/leaves")
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
checks << check("opening lines (#{desk.key})") do
|
|
107
|
+
problems = desk.opening_line_problems
|
|
108
|
+
next fail_with(problems.join("; ")) if problems.any?
|
|
109
|
+
|
|
110
|
+
ok_with("resolve")
|
|
111
|
+
end
|
|
112
|
+
|
|
106
113
|
checks << check("supportables (#{desk.key})") do
|
|
107
114
|
missing = desk.topics.about_class_names.reject do |name|
|
|
108
115
|
klass = name.safe_constantize
|
|
@@ -114,6 +121,24 @@ module SupportDesk
|
|
|
114
121
|
end
|
|
115
122
|
end
|
|
116
123
|
|
|
124
|
+
checks << check("find_requester") do
|
|
125
|
+
callable = SupportDesk.config.find_requester
|
|
126
|
+
next ok_with("not set — the console takes a GlobalID from your own pages") if callable.nil?
|
|
127
|
+
next fail_with("find_requester must respond to #call") unless callable.respond_to?(:call)
|
|
128
|
+
|
|
129
|
+
# Asked of the SHAPE, never by calling it: a diagnostic that runs a
|
|
130
|
+
# host's lookup is a diagnostic that queries production. A callable
|
|
131
|
+
# object is as valid as a lambda, so `arity` (Proc-only) is out.
|
|
132
|
+
parameters = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
|
|
133
|
+
required = parameters.count { |type, _| type == :req }
|
|
134
|
+
open_ended = parameters.any? { |type, _| %i[opt rest].include?(type) }
|
|
135
|
+
unless required <= 1 && (required == 1 || open_ended)
|
|
136
|
+
next fail_with("find_requester takes #{required} required argument(s); the console calls it with one")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
ok_with("the console can look people up")
|
|
140
|
+
end
|
|
141
|
+
|
|
117
142
|
checks << check("engine mount") do
|
|
118
143
|
path = SupportDesk.root_path
|
|
119
144
|
next warn_with("SupportDesk::Engine isn't mounted — requesters have nowhere to write") if path.nil?
|
|
@@ -185,8 +210,33 @@ module SupportDesk
|
|
|
185
210
|
|
|
186
211
|
ok_with("assignee matches the open assignment")
|
|
187
212
|
end,
|
|
213
|
+
check("provenance") do
|
|
214
|
+
next warn_with("no opened_by column — run `rails g support_desk:upgrade` and migrate") unless
|
|
215
|
+
Ticket.column_names.include?("opened_by_id")
|
|
216
|
+
|
|
217
|
+
half = Ticket.where(opened_by_type: nil).where.not(opened_by_id: nil)
|
|
218
|
+
.or(Ticket.where.not(opened_by_type: nil).where(opened_by_id: nil)).count
|
|
219
|
+
next fail_with("#{half} ticket(s) with half an opened_by (a type and no id, or the reverse)") if
|
|
220
|
+
half.positive?
|
|
221
|
+
|
|
222
|
+
# NULL is not automation: nothing in this version writes it, so
|
|
223
|
+
# every one of these is a row 0.1 opened, or one an old process
|
|
224
|
+
# wrote during the upgrade window.
|
|
225
|
+
legacy = Ticket.where(opened_by_id: nil).count
|
|
226
|
+
next warn_with("#{legacy} case(s) don't say who opened them — run " \
|
|
227
|
+
"`rake support_desk:backfill_opened_by`") if legacy.positive?
|
|
228
|
+
|
|
229
|
+
ok_with("every case says who opened it")
|
|
230
|
+
end,
|
|
188
231
|
check("awaiting") do
|
|
189
|
-
|
|
232
|
+
# The NULL leg is not decoration: `last_agent_message_at >
|
|
233
|
+
# last_requester_message_at` is NULL when the requester has never
|
|
234
|
+
# written, so a case waiting on the desk that only the desk has
|
|
235
|
+
# spoken in slips past a plain comparison.
|
|
236
|
+
stale = Ticket.awaiting_reply.where(
|
|
237
|
+
"last_agent_message_at > last_requester_message_at OR " \
|
|
238
|
+
"(last_agent_message_at IS NOT NULL AND last_requester_message_at IS NULL)"
|
|
239
|
+
).count
|
|
190
240
|
next fail_with("#{stale} ticket(s) waiting on the desk after the desk already answered") if stale.positive?
|
|
191
241
|
|
|
192
242
|
ok_with("awaiting agrees with the transcript")
|
data/lib/support_desk/errors.rb
CHANGED
|
@@ -17,6 +17,11 @@ module SupportDesk
|
|
|
17
17
|
# agent (no `acts_as_support_agent`, or its `if:` said no).
|
|
18
18
|
class NotAnAgent < Error; end
|
|
19
19
|
|
|
20
|
+
# Raised when the record handed to a requester-side operation isn't an
|
|
21
|
+
# eligible requester (no `has_support_tickets`, or its `if:` said no) — a
|
|
22
|
+
# closed account, for instance, can neither ask nor be written to.
|
|
23
|
+
class NotARequester < Error; end
|
|
24
|
+
|
|
20
25
|
# Raised by `hand_off!` when the actor doesn't currently hold the ticket.
|
|
21
26
|
class NotTheAssignee < Error; end
|
|
22
27
|
|