support_desk 0.1.3 → 0.3.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 +126 -0
- data/README.md +1101 -22
- data/Rakefile +16 -0
- data/app/assets/stylesheets/support_desk.css +10 -0
- data/app/controllers/support_desk/tickets_controller.rb +28 -4
- data/app/helpers/support_desk/engine_helper.rb +23 -1
- data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
- data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +33 -0
- data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
- 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 +28 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
- 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/app/views/support_desk/console/tickets/show.html.erb +5 -0
- data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
- data/config/console_routes.rb +1 -1
- data/config/locales/support_desk.console.en.yml +89 -0
- data/config/locales/support_desk.console.es.yml +91 -0
- data/config/locales/support_desk.en.yml +18 -0
- data/config/locales/support_desk.es.yml +29 -0
- data/config/routes.rb +7 -1
- data/lib/generators/support_desk/assistant_generator.rb +193 -0
- data/lib/generators/support_desk/console_generator.rb +1 -1
- data/lib/generators/support_desk/install_generator.rb +26 -0
- data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
- data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
- data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
- data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
- data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
- data/lib/generators/support_desk/templates/initializer.rb +63 -0
- data/lib/generators/support_desk/upgrade_generator.rb +66 -0
- data/lib/support_desk/assistant_policy.rb +213 -0
- data/lib/support_desk/brief.rb +283 -0
- data/lib/support_desk/configuration.rb +699 -3
- data/lib/support_desk/console.rb +634 -30
- 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 +33 -1
- data/lib/support_desk/doctor.rb +195 -2
- data/lib/support_desk/engine.rb +10 -0
- data/lib/support_desk/errors.rb +42 -0
- data/lib/support_desk/events.rb +12 -5
- data/lib/support_desk/macros.rb +41 -12
- data/lib/support_desk/models/assignment.rb +3 -1
- data/lib/support_desk/models/assistant.rb +153 -0
- data/lib/support_desk/models/concerns/agent.rb +39 -4
- data/lib/support_desk/models/concerns/requester.rb +45 -12
- data/lib/support_desk/models/desk.rb +26 -3
- data/lib/support_desk/models/draft.rb +266 -0
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/ticket/assistance.rb +675 -0
- data/lib/support_desk/models/ticket.rb +781 -120
- data/lib/support_desk/outcome.rb +38 -0
- data/lib/support_desk/queue.rb +41 -5
- data/lib/support_desk/summary.rb +1 -1
- data/lib/support_desk/test_helpers.rb +160 -3
- data/lib/support_desk/timeline.rb +40 -11
- data/lib/support_desk/topic.rb +22 -0
- data/lib/support_desk/topic_tree.rb +7 -1
- data/lib/support_desk/transcript.rb +237 -0
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +4 -12
- data/lib/support_desk.rb +153 -0
- data/lib/tasks/support_desk.rake +73 -0
- metadata +35 -8
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,33 @@ 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
|
+
# The four the assistants added sit at the end, in the order a case
|
|
56
|
+
# meets them: decide her proposal, then switch her off or back on.
|
|
57
|
+
MEMBER_VERBS = %i[
|
|
58
|
+
reply take assign hand_off release close reopen note change_topic
|
|
59
|
+
send_draft reject_draft pause_assistant resume_assistant
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
# The verbs that work on the QUEUE rather than on a case, and the HTTP
|
|
63
|
+
# method each one is drawn with. `open_conversation` is the only write
|
|
64
|
+
# here: there is no ticket yet, which is the whole point of it.
|
|
65
|
+
COLLECTION_VERBS = { next: :get, open_conversation: :post }.freeze
|
|
66
|
+
|
|
67
|
+
# What 0.1 called the member verbs, kept for a release: a host may have
|
|
68
|
+
# written it into their own routes or their own tests.
|
|
69
|
+
TRANSITIONS = MEMBER_VERBS
|
|
52
70
|
|
|
53
71
|
# Actions that work on one ticket, so `set_support_ticket` runs for them.
|
|
54
72
|
# `show` is the host's, but it still wants the ticket found safely.
|
|
55
|
-
MEMBER_ACTIONS = ([ :show ] +
|
|
73
|
+
MEMBER_ACTIONS = ([ :show ] + MEMBER_VERBS).freeze
|
|
74
|
+
|
|
75
|
+
# The two actions that have no ticket: the form, and the send behind it.
|
|
76
|
+
# `authorize_console` is asked about both with a nil ticket.
|
|
77
|
+
CONVERSATION_ACTIONS = %i[new open_conversation].freeze
|
|
56
78
|
|
|
57
79
|
# Which entry in `ticket.actions_for(agent)` each verb needs. The
|
|
58
80
|
# console renders exactly what that method returns, so it must accept
|
|
@@ -62,9 +84,16 @@ module SupportDesk
|
|
|
62
84
|
#
|
|
63
85
|
# `take` and `assign` are both :assign — taking a case is assigning it
|
|
64
86
|
# to yourself, which is the model's vocabulary, not two permissions.
|
|
87
|
+
#
|
|
88
|
+
# `send_draft` is its OWN entry rather than :reply, because
|
|
89
|
+
# `actions_for` offers it only while there is a proposal to send: mapped
|
|
90
|
+
# to :reply it would have accepted a POST naming a draft somebody had
|
|
91
|
+
# already decided about.
|
|
65
92
|
OFFERED_AS = {
|
|
66
93
|
reply: :reply, take: :assign, assign: :assign, hand_off: :hand_off, release: :release,
|
|
67
|
-
close: :close, reopen: :reopen, note: :note, change_topic: :change_topic
|
|
94
|
+
close: :close, reopen: :reopen, note: :note, change_topic: :change_topic,
|
|
95
|
+
send_draft: :send_draft, reject_draft: :reject_draft,
|
|
96
|
+
pause_assistant: :pause_assistant, resume_assistant: :resume_assistant
|
|
68
97
|
}.freeze
|
|
69
98
|
|
|
70
99
|
# Everything a transition raises because of WHO asked, WHEN, or from WHAT
|
|
@@ -81,19 +110,31 @@ module SupportDesk
|
|
|
81
110
|
SupportDesk::UnknownTopic,
|
|
82
111
|
SupportDesk::NotSupportable,
|
|
83
112
|
SupportDesk::RateLimited,
|
|
84
|
-
SupportDesk::TooManyOpenTickets
|
|
113
|
+
SupportDesk::TooManyOpenTickets,
|
|
114
|
+
# Nobody to write to: a closed account, or a record that was never a
|
|
115
|
+
# requester at all.
|
|
116
|
+
SupportDesk::NotARequester,
|
|
117
|
+
# Everything chats refuses at the write — a blocked pair, a locked
|
|
118
|
+
# conversation, a host policy that says these two may not talk. Its
|
|
119
|
+
# CONFIGURATION error is deliberately not covered by this; see
|
|
120
|
+
# #support_console_rescuable?. Chats is loaded by the spine, so naming
|
|
121
|
+
# it here costs no constant that might not exist.
|
|
122
|
+
Chats::Error
|
|
85
123
|
].freeze
|
|
86
124
|
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
125
|
+
# A refusal the console spotted in the REQUEST rather than in the domain:
|
|
126
|
+
# a token that names nothing, a Hash where text belongs, somebody this
|
|
127
|
+
# desk has no way to look up. It carries the locale key its flash reads,
|
|
128
|
+
# and it is deliberately NOT a SupportDesk::Error — nothing outside this
|
|
129
|
+
# concern should be rescuing it.
|
|
130
|
+
class InvalidInput < StandardError
|
|
131
|
+
attr_reader :key
|
|
132
|
+
|
|
133
|
+
def initialize(key)
|
|
134
|
+
@key = key.to_s
|
|
135
|
+
super("support_desk.console.errors.#{@key}")
|
|
136
|
+
end
|
|
137
|
+
end
|
|
97
138
|
|
|
98
139
|
included do
|
|
99
140
|
before_action :require_support_agent!
|
|
@@ -101,11 +142,16 @@ module SupportDesk
|
|
|
101
142
|
before_action :set_support_current_actor
|
|
102
143
|
before_action :set_support_ticket, only: MEMBER_ACTIONS
|
|
103
144
|
before_action :authorize_support_console!
|
|
104
|
-
before_action :require_offered_action!, only:
|
|
145
|
+
before_action :require_offered_action!, only: MEMBER_VERBS
|
|
146
|
+
before_action :require_conversation_duty!, only: :open_conversation
|
|
105
147
|
after_action :mark_support_transcript_read, only: :show
|
|
106
148
|
|
|
107
149
|
helper_method :current_agent, :support_desk_record, :support_queue, :support_transcript,
|
|
108
|
-
:console_ticket_path, :console_tickets_path, :console_file_path
|
|
150
|
+
:console_ticket_path, :console_tickets_path, :console_file_path,
|
|
151
|
+
:support_conversation_available?, :support_conversation_offered?,
|
|
152
|
+
:support_conversation_topics, :support_conversation_sendable?,
|
|
153
|
+
:support_pending_draft, :support_assistant, :support_seen_turn,
|
|
154
|
+
:support_editing_draft, :support_console_timeline_sentence
|
|
109
155
|
end
|
|
110
156
|
|
|
111
157
|
# --- The verbs --------------------------------------------------------------
|
|
@@ -178,6 +224,99 @@ module SupportDesk
|
|
|
178
224
|
attempt(:topic_changed) { @ticket.change_topic!(to: topic, by: current_agent, request: request) }
|
|
179
225
|
end
|
|
180
226
|
|
|
227
|
+
# --- Her proposal, and the switch ------------------------------------------
|
|
228
|
+
#
|
|
229
|
+
# Four verbs, and only one of them is interesting. A draft is written by
|
|
230
|
+
# a machine and SENT BY A PERSON: they read it, they may rewrite it, and
|
|
231
|
+
# the requester sees their signature on it. Everything here exists to
|
|
232
|
+
# make that reading real — which is why a send carries the turn the page
|
|
233
|
+
# was rendered with, and why a mismatch comes back as the same screen
|
|
234
|
+
# rather than as a redirect that throws the reviewer's edit away.
|
|
235
|
+
|
|
236
|
+
# Send the proposal, verbatim or edited.
|
|
237
|
+
def send_draft
|
|
238
|
+
draft = support_reviewable_draft
|
|
239
|
+
return if draft.nil?
|
|
240
|
+
|
|
241
|
+
seen = params[:seen_turn]
|
|
242
|
+
return refuse(:invalid_input) unless seen.is_a?(String) && seen.present?
|
|
243
|
+
|
|
244
|
+
body = params[:body].is_a?(String) ? params[:body] : nil
|
|
245
|
+
# The model refuses an edit that is blank with no attachment, and it
|
|
246
|
+
# refuses it with an ArgumentError — a bug in the caller, not a flash.
|
|
247
|
+
# So the console asks the same question first, in the words an agent
|
|
248
|
+
# needs: an empty box is "write something", never "descártala".
|
|
249
|
+
return refuse(:blank_message) if body && body.strip.empty? && !draft.files_attached?
|
|
250
|
+
|
|
251
|
+
draft.send!(by: current_agent, seen_turn: seen, body: body, request: request)
|
|
252
|
+
flash[:notice] = support_console_t("flashes.draft_sent")
|
|
253
|
+
respond_to_transition
|
|
254
|
+
rescue SupportDesk::StaleTurn
|
|
255
|
+
refuse_stale_draft
|
|
256
|
+
rescue StandardError => error
|
|
257
|
+
raise unless support_console_rescuable?(error)
|
|
258
|
+
|
|
259
|
+
flash[:alert] = support_console_error_message(error)
|
|
260
|
+
respond_to_transition
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Throw it away, with a reason worth reading later: the rejections are
|
|
264
|
+
# what tell a host whether the assistant is ready for a higher level.
|
|
265
|
+
def reject_draft
|
|
266
|
+
draft = support_reviewable_draft
|
|
267
|
+
return if draft.nil?
|
|
268
|
+
|
|
269
|
+
attempt(:draft_rejected) do
|
|
270
|
+
draft.reject!(by: current_agent, reason: support_console_text(:reason), request: request)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Switch her off on THIS case: a delicate conversation, a customer who
|
|
275
|
+
# has had enough, a thread somebody wants to handle themselves.
|
|
276
|
+
def pause_assistant
|
|
277
|
+
attempt(:assistant_paused) do
|
|
278
|
+
@ticket.pause_assistant!(by: current_agent, reason: support_console_text(:reason), request: request)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Let her back in on this case.
|
|
283
|
+
def resume_assistant
|
|
284
|
+
attempt(:assistant_resumed) { @ticket.resume_assistant!(by: current_agent, request: request) }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The form for writing to somebody who hasn't written to us — "Escribir
|
|
288
|
+
# a alguien". Renders with whatever the caller supplied: a requester
|
|
289
|
+
# GlobalID from one of your own pages (a user's admin screen, a ride),
|
|
290
|
+
# a subject to be about, a topic. Blank is an empty form; a token that
|
|
291
|
+
# names nothing is the same refusal here as it is on the send, because a
|
|
292
|
+
# form that quietly drops the person it was opened for is worse than one
|
|
293
|
+
# that says it couldn't find them.
|
|
294
|
+
def new
|
|
295
|
+
assign_conversation_draft
|
|
296
|
+
rescue StandardError => error
|
|
297
|
+
raise unless support_console_rescuable?(error)
|
|
298
|
+
|
|
299
|
+
refuse_conversation(error)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Send it. One case either way: a new one when this person has nothing
|
|
303
|
+
# open that covers it, an ordinary reply into the one they do — under
|
|
304
|
+
# this desk's reply policy, and only if the host still says this agent
|
|
305
|
+
# may answer THAT case.
|
|
306
|
+
def open_conversation
|
|
307
|
+
assign_conversation_draft
|
|
308
|
+
ticket = SupportDesk::Ticket.open_or_reply!(**conversation_arguments)
|
|
309
|
+
|
|
310
|
+
@ticket = ticket
|
|
311
|
+
redirect_to after_transition_path(ticket), status: :see_other,
|
|
312
|
+
notice: support_console_t("flashes.message_sent",
|
|
313
|
+
requester: Chats.display_name_for(@requester))
|
|
314
|
+
rescue StandardError => error
|
|
315
|
+
raise unless support_console_rescuable?(error)
|
|
316
|
+
|
|
317
|
+
refuse_conversation(error)
|
|
318
|
+
end
|
|
319
|
+
|
|
181
320
|
# The most urgent thing this agent should be looking at. The whole
|
|
182
321
|
# "work the queue" loop is this one button.
|
|
183
322
|
def next
|
|
@@ -225,6 +364,70 @@ module SupportDesk
|
|
|
225
364
|
scope
|
|
226
365
|
end
|
|
227
366
|
|
|
367
|
+
# The proposal waiting on this case, or nil. What `show` renders the
|
|
368
|
+
# draft card from, and what the composer's edit mode is about.
|
|
369
|
+
def support_pending_draft(ticket = @ticket)
|
|
370
|
+
ticket&.pending_draft
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# This desk's assistant, or nil. A view asks it to decide whether there
|
|
374
|
+
# is a switch to draw at all — never to decide what may be pressed.
|
|
375
|
+
def support_assistant(ticket = @ticket)
|
|
376
|
+
ticket&.assistant
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# The turn the page is being rendered with, which every send carries
|
|
380
|
+
# back. A case that has never been touched still has one, so this is
|
|
381
|
+
# never nil and the form never has an empty hidden field.
|
|
382
|
+
def support_seen_turn(ticket = @ticket)
|
|
383
|
+
ticket&.assistant_turn
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# The draft the composer is EDITING: the reviewer pressed "Editar"
|
|
387
|
+
# (`?compose=reply&draft=ID`), or a stale send came back as this screen
|
|
388
|
+
# with their text still in it. Nil unless it names the pending proposal
|
|
389
|
+
# AND this agent is offered the send — a composer in edit mode with no
|
|
390
|
+
# way to submit is worse than no edit mode at all.
|
|
391
|
+
def support_editing_draft(ticket = @ticket)
|
|
392
|
+
draft = support_pending_draft(ticket)
|
|
393
|
+
return nil if draft.nil?
|
|
394
|
+
return nil unless support_offered_actions(ticket).include?(:send_draft)
|
|
395
|
+
|
|
396
|
+
named = [ params[:draft], params[:draft_id] ].detect { |value| value.is_a?(String) && value.present? }
|
|
397
|
+
named == draft.id.to_s ? draft : nil
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# One line for the timeline kinds the assistants added — "Lucía envió la
|
|
401
|
+
# propuesta de Rose" — from the event's own payload. Nil for everything
|
|
402
|
+
# else, so the partial falls back to the label-and-actor shape 0.2 had.
|
|
403
|
+
#
|
|
404
|
+
# It lives here rather than in the partial because the sentence is COPY:
|
|
405
|
+
# an ejected view must not have to carry a `case` over event kinds to
|
|
406
|
+
# keep saying the right thing in Spanish.
|
|
407
|
+
def support_console_timeline_sentence(entry, actor_name = nil)
|
|
408
|
+
return nil unless entry.event?
|
|
409
|
+
|
|
410
|
+
payload = entry.event.payload.is_a?(Hash) ? entry.event.payload : {}
|
|
411
|
+
assistant = support_console_assistant_name(payload["assistant"])
|
|
412
|
+
|
|
413
|
+
case entry.kind
|
|
414
|
+
when :escalated
|
|
415
|
+
support_console_t("timeline.escalated", actor: actor_name,
|
|
416
|
+
reason: support_console_reason_word(payload["reason"]))
|
|
417
|
+
when :human_requested then support_console_t("timeline.human_requested")
|
|
418
|
+
when :assistant_paused then support_console_t("timeline.assistant_paused", actor: actor_name)
|
|
419
|
+
when :assistant_resumed then support_console_t("timeline.assistant_resumed", actor: actor_name)
|
|
420
|
+
when :draft_sent
|
|
421
|
+
support_console_t("timeline.draft_sent#{"_edited" if payload["edited"]}",
|
|
422
|
+
actor: actor_name, assistant: assistant)
|
|
423
|
+
when :draft_rejected
|
|
424
|
+
support_console_t("timeline.draft_rejected", actor: actor_name, assistant: assistant)
|
|
425
|
+
when :assistant_withheld
|
|
426
|
+
support_console_t("timeline.assistant_withheld", assistant: assistant,
|
|
427
|
+
reason: support_console_withheld_word(payload["reason"]))
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
228
431
|
# The default queue tab and the tickets behind it, for hosts that want
|
|
229
432
|
# the obvious index. Entirely optional — everything it does is three
|
|
230
433
|
# lines of Layer 1.
|
|
@@ -268,7 +471,7 @@ module SupportDesk
|
|
|
268
471
|
# that no test notices until somebody counts.
|
|
269
472
|
def support_queue_tickets
|
|
270
473
|
support_queue.scope(@scope)
|
|
271
|
-
.includes(:requester, :assignee, :desk, :subject,
|
|
474
|
+
.includes(:requester, :assignee, :desk, :subject, :opened_by, :pending_draft,
|
|
272
475
|
conversation: { last_message: %i[sender author] })
|
|
273
476
|
.limit(support_tickets_per_page)
|
|
274
477
|
end
|
|
@@ -278,8 +481,248 @@ module SupportDesk
|
|
|
278
481
|
def support_tickets_per_page = 50
|
|
279
482
|
end
|
|
280
483
|
|
|
484
|
+
# --- Writing first ------------------------------------------------------------
|
|
485
|
+
#
|
|
486
|
+
# Two overridable seams, and their contract:
|
|
487
|
+
#
|
|
488
|
+
# * `support_conversation_requester` returns the person this conversation
|
|
489
|
+
# is FOR, or nil when nothing was supplied. It NEVER returns somebody
|
|
490
|
+
# other than the one that was asked for: a supplied GlobalID is
|
|
491
|
+
# authoritative, and a bad one is a refusal
|
|
492
|
+
# (`raise InvalidInput, :invalid_requester`), never a silent fallback
|
|
493
|
+
# to the typed query.
|
|
494
|
+
# * `support_conversation_subject` returns what the case is about, or
|
|
495
|
+
# nil when nothing was supplied; a supplied token that doesn't resolve,
|
|
496
|
+
# or resolves to something this requester may not talk about, is
|
|
497
|
+
# `raise InvalidInput, :invalid_subject`.
|
|
498
|
+
#
|
|
499
|
+
# Both are looked up inside the classes that declared themselves
|
|
500
|
+
# (`SupportDesk.requester_classes`, `.supportable_classes`) — a raw
|
|
501
|
+
# GlobalID is an identifier, never permission to call `find` on whatever
|
|
502
|
+
# class it names. A multi-tenant host narrows BOTH ways in, because
|
|
503
|
+
# `config.find_requester` only guards the typed one:
|
|
504
|
+
#
|
|
505
|
+
# def support_conversation_requester
|
|
506
|
+
# found = super
|
|
507
|
+
# return nil if found.nil?
|
|
508
|
+
# raise SupportDesk::Console::InvalidInput, :invalid_requester unless
|
|
509
|
+
# found.account_id == current_agent.account_id
|
|
510
|
+
#
|
|
511
|
+
# found
|
|
512
|
+
# end
|
|
513
|
+
#
|
|
514
|
+
# (The model checks eligibility on its own either way — see
|
|
515
|
+
# `Ticket.open!` — so an override that forgets is a narrower door, never
|
|
516
|
+
# a wider one.)
|
|
517
|
+
|
|
518
|
+
# The person this conversation is for, from a GlobalID one of your own
|
|
519
|
+
# pages handed over, or from what an agent typed into the form.
|
|
520
|
+
def support_conversation_requester
|
|
521
|
+
token = conversation_param(:requester)
|
|
522
|
+
return locate_conversation_record(token, SupportDesk.requester_classes, :invalid_requester) if token.present?
|
|
523
|
+
return nil if @requester_query.blank?
|
|
524
|
+
|
|
525
|
+
finder = support_desk_record.config.find_requester
|
|
526
|
+
raise InvalidInput, :no_requester_lookup if finder.nil?
|
|
527
|
+
|
|
528
|
+
found = finder.call(@requester_query)
|
|
529
|
+
raise InvalidInput, :unknown_requester if found.nil?
|
|
530
|
+
raise InvalidInput, :invalid_requester unless SupportDesk.requester_class?(found.class)
|
|
531
|
+
|
|
532
|
+
found
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# What the case is about, when the page that opened the form knew.
|
|
536
|
+
def support_conversation_subject
|
|
537
|
+
token = conversation_param(:about)
|
|
538
|
+
return nil if token.blank?
|
|
539
|
+
|
|
540
|
+
subject = locate_conversation_record(token, SupportDesk.supportable_classes, :invalid_subject)
|
|
541
|
+
# Checked here so the form can't show a card for something this person
|
|
542
|
+
# may not talk about, and checked AGAIN by the model at the write.
|
|
543
|
+
raise InvalidInput, :invalid_subject unless @requester && subject.supportable_by?(@requester)
|
|
544
|
+
|
|
545
|
+
subject
|
|
546
|
+
end
|
|
547
|
+
|
|
281
548
|
private
|
|
282
549
|
|
|
550
|
+
# Everything the form renders, set BEFORE anything can fail: a refusal
|
|
551
|
+
# has to come back with the draft still in it, or an agent retypes their
|
|
552
|
+
# message every time they mistype an email.
|
|
553
|
+
def assign_conversation_draft
|
|
554
|
+
@requester = nil
|
|
555
|
+
@about = nil
|
|
556
|
+
# Capture every independently valid field before any validation raises.
|
|
557
|
+
@requester_query, @topic, @body = %i[requester_query topic body].map do |name|
|
|
558
|
+
params[name].is_a?(String) ? params[name] : ""
|
|
559
|
+
end
|
|
560
|
+
@topic = @topic.presence
|
|
561
|
+
@files = []
|
|
562
|
+
|
|
563
|
+
@requester = support_conversation_requester
|
|
564
|
+
@about = support_conversation_subject
|
|
565
|
+
# A supplied topic wins; a subject's own topic is the obvious default.
|
|
566
|
+
@topic ||= @about&.support_topic
|
|
567
|
+
%i[requester_query topic body].each { |name| conversation_param(name) }
|
|
568
|
+
validate_conversation_desk!
|
|
569
|
+
@conversation_requester_unavailable = @requester.present?
|
|
570
|
+
SupportDesk::Ticket.ensure_requester!(@requester) if @requester
|
|
571
|
+
@conversation_requester_unavailable = false
|
|
572
|
+
@files = conversation_files
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def validate_conversation_desk!
|
|
576
|
+
return unless params.key?(:desk)
|
|
577
|
+
|
|
578
|
+
key = params[:desk]
|
|
579
|
+
return if key.is_a?(String) && support_visible_desks.any? { |desk| desk.key.to_s == key }
|
|
580
|
+
|
|
581
|
+
@invalid_conversation_desk = true
|
|
582
|
+
raise InvalidInput, :invalid_input
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# Validate transport shapes here; MIME/size/count policy stays on Message.
|
|
586
|
+
def conversation_files
|
|
587
|
+
files = params[:files]
|
|
588
|
+
return [] if files.nil?
|
|
589
|
+
raise InvalidInput, :invalid_input unless files.is_a?(Array)
|
|
590
|
+
|
|
591
|
+
files.reject { |file| file.nil? || file == "" }.map do |file|
|
|
592
|
+
case file
|
|
593
|
+
when ActionDispatch::Http::UploadedFile then file
|
|
594
|
+
when String
|
|
595
|
+
raise InvalidInput, :invalid_input unless defined?(ActiveStorage::Blob)
|
|
596
|
+
|
|
597
|
+
begin
|
|
598
|
+
ActiveStorage::Blob.find_signed!(file)
|
|
599
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
|
|
600
|
+
raise InvalidInput, :invalid_input
|
|
601
|
+
end
|
|
602
|
+
else raise InvalidInput, :invalid_input
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def conversation_arguments
|
|
608
|
+
raise InvalidInput, :unknown_requester if @requester.nil?
|
|
609
|
+
# The same answer the member `reply` action gives, for the same
|
|
610
|
+
# mistake. The MODEL refuses a blank staff message too (and rolls the
|
|
611
|
+
# case back with it), but "Escribe algo antes de enviar" is what an
|
|
612
|
+
# agent needs to read, not a validation error about a message body.
|
|
613
|
+
raise InvalidInput, :blank_message if @body.strip.empty? && @files.empty?
|
|
614
|
+
# A dual-role account (an admin who is also a customer) writing to
|
|
615
|
+
# themselves is ambiguous: the low-level API would read it as them
|
|
616
|
+
# asking for help, which is not what this form is for.
|
|
617
|
+
if SupportDesk::Ticket.same_actor?(@requester, current_agent)
|
|
618
|
+
raise InvalidInput, :writing_to_yourself
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
{
|
|
622
|
+
requester: @requester, by: current_agent, message: @body, about: @about, topic: @topic,
|
|
623
|
+
files: @files, via: :in_app, desk: support_desk_record, request: request,
|
|
624
|
+
requester_role: @requester.class.support_desk_requester_options[:as],
|
|
625
|
+
authorize_reuse: method(:authorize_conversation_reuse)
|
|
626
|
+
}
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
# Run under the REUSED case's row lock, before the reply policy has done
|
|
630
|
+
# anything: a host may let this agent write to this person and still
|
|
631
|
+
# refuse them this particular case, and the answer has to be the same one
|
|
632
|
+
# the member `reply` action would have given. Raising rolls the whole
|
|
633
|
+
# operation back.
|
|
634
|
+
def authorize_conversation_reuse(ticket)
|
|
635
|
+
@ticket = ticket
|
|
636
|
+
return if SupportDesk.config.console_authorized?(current_agent, ticket, :reply) &&
|
|
637
|
+
ticket.actions_for(current_agent).include?(:reply)
|
|
638
|
+
|
|
639
|
+
raise SupportDesk::NotAllowed,
|
|
640
|
+
"#{current_agent.class}##{current_agent.id} may not reply to ticket #{ticket.reference}"
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Text fields are text. A Hash or an Array where a string belongs is a
|
|
644
|
+
# crafted request, not something to `.to_s` and then look up.
|
|
645
|
+
def conversation_param(name)
|
|
646
|
+
value = params[name]
|
|
647
|
+
return "" if value.nil?
|
|
648
|
+
raise InvalidInput, :invalid_input unless value.is_a?(String)
|
|
649
|
+
|
|
650
|
+
value
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# A GlobalID is an identifier, not an authorization: it resolves only
|
|
654
|
+
# inside the classes that declared themselves, only for this app, and a
|
|
655
|
+
# token that is malformed, foreign, out of that set or pointing at a row
|
|
656
|
+
# that is gone is one refusal — never a different target.
|
|
657
|
+
def locate_conversation_record(token, allowed, refusal)
|
|
658
|
+
gid = GlobalID.parse(token)
|
|
659
|
+
raise InvalidInput, refusal if gid.nil? || gid.app.to_s != GlobalID.app.to_s
|
|
660
|
+
|
|
661
|
+
# safe_constantize ignores missing token constants, but propagates bugs
|
|
662
|
+
# inside an autoload. The host finder runs outside that boundary.
|
|
663
|
+
model = gid.model_name.safe_constantize
|
|
664
|
+
raise InvalidInput, refusal unless model.is_a?(Class) && allowed.any? { |klass| model <= klass }
|
|
665
|
+
|
|
666
|
+
GlobalID::Locator.locate(gid, only: allowed) || raise(InvalidInput, refusal)
|
|
667
|
+
rescue ActiveRecord::RecordNotFound, GlobalID::Locator::InvalidModelIdError
|
|
668
|
+
# A class name nothing answers to, an id the model can't read, a row
|
|
669
|
+
# that is gone: the same bad token in the same field.
|
|
670
|
+
raise InvalidInput, refusal
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# Off duty is off duty on both surfaces: `actions_for` gives an off-duty
|
|
674
|
+
# agent nothing that speaks to a requester, and neither does this.
|
|
675
|
+
def require_conversation_duty!
|
|
676
|
+
return if support_conversation_available?
|
|
677
|
+
|
|
678
|
+
assign_conversation_draft
|
|
679
|
+
flash.now[:alert] = support_console_t("errors.off_duty")
|
|
680
|
+
render :new, formats: [ :html ], status: :unprocessable_entity
|
|
681
|
+
rescue StandardError => error
|
|
682
|
+
raise unless support_console_rescuable?(error)
|
|
683
|
+
|
|
684
|
+
refuse_conversation(error)
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
# Whether this agent may send from the form at all — what the form reads
|
|
688
|
+
# to disable its own button rather than offering one that only ever 422s.
|
|
689
|
+
def support_conversation_available?
|
|
690
|
+
!current_agent.respond_to?(:on_duty?) || current_agent.on_duty?
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def support_conversation_sendable?
|
|
694
|
+
support_conversation_available? && !@invalid_conversation_desk && !@conversation_requester_unavailable
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Whether to show the door on the queue at all: on duty, and the host's
|
|
698
|
+
# policy says so. Both are asked again at the form and at the send — this
|
|
699
|
+
# is what keeps the queue from offering a button that only refuses.
|
|
700
|
+
def support_conversation_offered?
|
|
701
|
+
support_conversation_available? &&
|
|
702
|
+
SupportDesk.config.console_authorized?(current_agent, nil, :open_conversation)
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# The topics a case can be opened onto from here: the leaves somebody
|
|
706
|
+
# can write freely under. A subject brings its own topic with it, so
|
|
707
|
+
# this is the picker for everything else.
|
|
708
|
+
def support_conversation_topics
|
|
709
|
+
support_desk_record.config.topics.leaves.select(&:free_form?)
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# A handled refusal: the form again, with everything the agent typed
|
|
713
|
+
# still in it, and the reason on top. 422 rather than a redirect for
|
|
714
|
+
# HTML and Turbo alike — a stream refresh would throw the draft away.
|
|
715
|
+
def refuse_conversation(error)
|
|
716
|
+
flash.now[:alert] = support_conversation_error_message(error)
|
|
717
|
+
render :new, formats: [ :html ], status: :unprocessable_entity
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def support_conversation_error_message(error)
|
|
721
|
+
return support_console_t("errors.#{error.key}") if error.is_a?(InvalidInput)
|
|
722
|
+
|
|
723
|
+
support_console_error_message(error)
|
|
724
|
+
end
|
|
725
|
+
|
|
283
726
|
def mark_support_transcript_read
|
|
284
727
|
return unless response.successful? && @support_read_through
|
|
285
728
|
|
|
@@ -333,7 +776,9 @@ module SupportDesk
|
|
|
333
776
|
offered = @ticket.actions_for(current_agent)
|
|
334
777
|
return if offered.include?(OFFERED_AS.fetch(action_name.to_sym))
|
|
335
778
|
|
|
336
|
-
flash[:alert] = support_console_t("errors.#{unavailable_reason}",
|
|
779
|
+
flash[:alert] = support_console_t("errors.#{unavailable_reason}",
|
|
780
|
+
holder: support_console_holder,
|
|
781
|
+
status: support_draft_status_word(support_named_draft))
|
|
337
782
|
respond_to_transition
|
|
338
783
|
end
|
|
339
784
|
|
|
@@ -342,12 +787,25 @@ module SupportDesk
|
|
|
342
787
|
# taken first.
|
|
343
788
|
def unavailable_reason
|
|
344
789
|
return "closed_case" if @ticket.closed?
|
|
790
|
+
return draft_unavailable_reason if %i[send_draft reject_draft].include?(action_name.to_sym)
|
|
345
791
|
return "unavailable_action" unless %i[reply hand_off].include?(action_name.to_sym)
|
|
346
792
|
return "take_it_first" if @ticket.unassigned?
|
|
347
793
|
|
|
348
794
|
"held_by_somebody_else"
|
|
349
795
|
end
|
|
350
796
|
|
|
797
|
+
# Pressing the same button twice is the common way to get here, and
|
|
798
|
+
# "there is no proposal" is the wrong thing to read after sending one.
|
|
799
|
+
# The row the request names is the whole answer: still there and already
|
|
800
|
+
# decided, or gone.
|
|
801
|
+
def draft_unavailable_reason
|
|
802
|
+
named = support_named_draft
|
|
803
|
+
return "draft_already_reviewed" if named && !named.pending?
|
|
804
|
+
return "no_pending_draft" if @ticket.pending_draft.nil?
|
|
805
|
+
|
|
806
|
+
"unavailable_action"
|
|
807
|
+
end
|
|
808
|
+
|
|
351
809
|
# Who has the case, for a refusal that names them.
|
|
352
810
|
def support_console_holder
|
|
353
811
|
@ticket&.assignee&.try(:support_agent_name) || support_console_t("assignment.nobody")
|
|
@@ -406,7 +864,12 @@ module SupportDesk
|
|
|
406
864
|
def support_desk_record
|
|
407
865
|
return @support_desk_record if defined?(@support_desk_record)
|
|
408
866
|
|
|
409
|
-
|
|
867
|
+
# A desk key is text. A Hash or an Array here is not a desk anybody has
|
|
868
|
+
# — it is a crafted parameter — and every console screen reads this, so
|
|
869
|
+
# it answers the way an unknown key does (the first visible desk)
|
|
870
|
+
# rather than taking the whole console down with a NoMethodError.
|
|
871
|
+
requested = params[:desk]
|
|
872
|
+
requested = requested.is_a?(String) ? requested.presence&.to_sym : nil
|
|
410
873
|
@support_desk_record =
|
|
411
874
|
(requested && support_visible_desks.detect { |desk| desk.key.to_sym == requested }) ||
|
|
412
875
|
support_visible_desks.first
|
|
@@ -420,10 +883,113 @@ module SupportDesk
|
|
|
420
883
|
# desk — never the one `?desk=` names. Those are different desks the
|
|
421
884
|
# moment a host has two, and reading the parameter let a billing agent
|
|
422
885
|
# be assigned to a case on another desk entirely.
|
|
886
|
+
# Values are `SupportDesk.actor_key(agent)` — a GlobalID, which says
|
|
887
|
+
# WHICH CLASS as well as which row. The pool is people and the desk's
|
|
888
|
+
# assistant, and their ids are drawn from different tables: a picker
|
|
889
|
+
# posting a bare "1" could mean either of them, and "assign the case to
|
|
890
|
+
# the machine" is not a mistake to make on a coin flip.
|
|
891
|
+
#
|
|
892
|
+
# A bare id still resolves, because a host may have written one into
|
|
893
|
+
# their own form — but only while exactly one member of the pool answers
|
|
894
|
+
# to it. Two matches is a refusal, not a guess.
|
|
423
895
|
def support_console_agent(id = params[:agent_id])
|
|
424
896
|
return nil if id.blank?
|
|
425
897
|
|
|
426
|
-
|
|
898
|
+
key = id.to_s
|
|
899
|
+
pool = @ticket.desk.agents
|
|
900
|
+
exact = pool.detect { |agent| SupportDesk.actor_key(agent) == key }
|
|
901
|
+
return exact if exact
|
|
902
|
+
|
|
903
|
+
matches = pool.select { |agent| agent.id.to_s == key }
|
|
904
|
+
matches.one? ? matches.first : nil
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
# --- The proposal a request names -------------------------------------------
|
|
908
|
+
|
|
909
|
+
# The draft this request names, in ANY status. Deliberately not
|
|
910
|
+
# `drafts.pending`: a second submit of the same button has to be able to
|
|
911
|
+
# read "ya se envió" instead of "no hay ninguna propuesta", and only the
|
|
912
|
+
# row itself knows which.
|
|
913
|
+
def support_console_draft
|
|
914
|
+
raise InvalidInput, :invalid_input unless params[:draft_id].is_a?(String)
|
|
915
|
+
|
|
916
|
+
support_named_draft
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
# The same lookup for the REFUSALS, which run before the action and must
|
|
920
|
+
# never raise: a crafted `draft_id` on a case with nothing pending is a
|
|
921
|
+
# flash, not a 500.
|
|
922
|
+
def support_named_draft
|
|
923
|
+
return @support_named_draft if defined?(@support_named_draft)
|
|
924
|
+
|
|
925
|
+
id = params[:draft_id]
|
|
926
|
+
@support_named_draft = (@ticket.drafts.find_by(id: id) if id.is_a?(String) && id.present?)
|
|
927
|
+
rescue ActiveRecord::StatementInvalid
|
|
928
|
+
# A uuid-keyed host: an id that isn't one is an id nothing has.
|
|
929
|
+
@support_named_draft = nil
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# The draft a decision may be made about, or nil with the refusal
|
|
933
|
+
# already rendered.
|
|
934
|
+
def support_reviewable_draft
|
|
935
|
+
draft = support_console_draft
|
|
936
|
+
if draft.nil?
|
|
937
|
+
refuse(:unknown_draft)
|
|
938
|
+
elsif !draft.pending?
|
|
939
|
+
refuse(:draft_already_reviewed, status: support_draft_status_word(draft))
|
|
940
|
+
else
|
|
941
|
+
return draft
|
|
942
|
+
end
|
|
943
|
+
nil
|
|
944
|
+
rescue InvalidInput => error
|
|
945
|
+
refuse(error.key)
|
|
946
|
+
nil
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
# What happened to a draft, as the word the flash reads: "ya se envió",
|
|
950
|
+
# "ya se descartó". Never the English status — a Spanish desk reading
|
|
951
|
+
# "ya se sent" is the bug this map exists to stop.
|
|
952
|
+
def support_draft_status_word(draft)
|
|
953
|
+
return nil if draft.nil?
|
|
954
|
+
|
|
955
|
+
support_console_t("draft.statuses.#{draft.status}", default: draft.status)
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
# The case moved between the render and the submit. A redirect here
|
|
959
|
+
# would throw away whatever the reviewer typed, so this is the SAME
|
|
960
|
+
# screen again with a 422: their text still in the composer, the CURRENT
|
|
961
|
+
# turn in the form, and the reason on top. There is no "send anyway" —
|
|
962
|
+
# reading the case again is the whole point.
|
|
963
|
+
def refuse_stale_draft
|
|
964
|
+
@ticket.reload
|
|
965
|
+
flash.now[:alert] = support_console_t("errors.stale_turn")
|
|
966
|
+
rerender_support_case
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
# The case screen again, as the host renders it: their own `show` runs,
|
|
970
|
+
# so whatever it sets up is set up, and the two things every bundled
|
|
971
|
+
# view needs are filled in when it didn't.
|
|
972
|
+
def rerender_support_case(status: :unprocessable_entity)
|
|
973
|
+
show if respond_to?(:show)
|
|
974
|
+
@actions ||= @ticket.actions_for(current_agent)
|
|
975
|
+
@context_card ||= @ticket.context_card
|
|
976
|
+
render :show, formats: [ :html ], status: status unless performed?
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
# The buttons this agent is offered on +ticket+, asked once per render.
|
|
980
|
+
# `show` usually set them already; a 422 re-render may not have.
|
|
981
|
+
def support_offered_actions(ticket = @ticket)
|
|
982
|
+
return @actions if ticket == @ticket && @actions
|
|
983
|
+
|
|
984
|
+
ticket.actions_for(current_agent)
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# A free-text field a verb takes: text, or nothing. A Hash where a
|
|
988
|
+
# reason belongs is a crafted request, and `.to_s` on it would be
|
|
989
|
+
# written into an event payload forever.
|
|
990
|
+
def support_console_text(name)
|
|
991
|
+
value = params[name]
|
|
992
|
+
value.is_a?(String) ? value.presence : nil
|
|
427
993
|
end
|
|
428
994
|
|
|
429
995
|
def support_agent_name(agent)
|
|
@@ -447,8 +1013,8 @@ module SupportDesk
|
|
|
447
1013
|
|
|
448
1014
|
# A refusal the console spotted before the model was asked (an empty
|
|
449
1015
|
# message, an agent who isn't in the pool).
|
|
450
|
-
def refuse(reason)
|
|
451
|
-
flash[:alert] = support_console_t("errors.#{reason}")
|
|
1016
|
+
def refuse(reason, **interpolations)
|
|
1017
|
+
flash[:alert] = support_console_t("errors.#{reason}", **interpolations)
|
|
452
1018
|
respond_to_transition
|
|
453
1019
|
end
|
|
454
1020
|
|
|
@@ -472,8 +1038,15 @@ module SupportDesk
|
|
|
472
1038
|
end
|
|
473
1039
|
|
|
474
1040
|
def support_console_rescuable?(error)
|
|
1041
|
+
# A chats CONFIGURATION error is a bug in the host's wiring, not a
|
|
1042
|
+
# refusal an agent can do anything about, and it is a Chats::Error —
|
|
1043
|
+
# so it has to be taken back out before the base class goes in.
|
|
1044
|
+
return false if error.is_a?(Chats::ConfigurationError)
|
|
1045
|
+
return true if error.is_a?(InvalidInput)
|
|
475
1046
|
return true if RESCUED_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
476
|
-
|
|
1047
|
+
# A RUNTIME check on purpose: `require "support_desk"` in a fresh
|
|
1048
|
+
# process does not load ActiveRecord (chats is required, ActiveRecord
|
|
1049
|
+
# is not), and naming the constant in RESCUED_ERRORS would break that.
|
|
477
1050
|
return true if defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
|
|
478
1051
|
|
|
479
1052
|
false
|
|
@@ -485,9 +1058,40 @@ module SupportDesk
|
|
|
485
1058
|
# a Spanish desk read "doesn't hold ticket T-AB12CD". `holder` carries
|
|
486
1059
|
# the one detail worth keeping, and the console knows it without having
|
|
487
1060
|
# to parse the error.
|
|
1061
|
+
# The key is the error's own name (`NotTheAssignee` →
|
|
1062
|
+
# "not_the_assignee"), so a new error needs copy and nothing else — and a
|
|
1063
|
+
# host that overrode one of these keys keeps its wording, because the
|
|
1064
|
+
# names are the ones the old hand-written table used.
|
|
488
1065
|
def support_console_error_message(error)
|
|
489
|
-
key =
|
|
490
|
-
support_console_t("errors.#{key}", detail: error.message, holder: support_console_holder
|
|
1066
|
+
key = error.class.name.demodulize.underscore
|
|
1067
|
+
support_console_t("errors.#{key}", detail: error.message, holder: support_console_holder,
|
|
1068
|
+
default: :"support_desk.console.errors.generic")
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
# Who proposed it, in a word. The payloads carry a key on one kind and
|
|
1072
|
+
# an actor key on another, and this desk's own assistant answers to
|
|
1073
|
+
# both; anything else is history (a host swapped assistants), so the
|
|
1074
|
+
# readable half of the token stands in rather than a GlobalID nobody
|
|
1075
|
+
# can read.
|
|
1076
|
+
def support_console_assistant_name(token)
|
|
1077
|
+
token = token.to_s
|
|
1078
|
+
assistant = @ticket&.assistant
|
|
1079
|
+
return assistant.name if assistant && [ assistant.key.to_s, SupportDesk.actor_key(assistant) ].include?(token)
|
|
1080
|
+
return token.humanize if token.match?(/\A[a-z0-9_]+\z/i)
|
|
1081
|
+
|
|
1082
|
+
@ticket&.desk&.name.to_s
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
# Why a case was handed to a person, as a sentence rather than as the
|
|
1086
|
+
# token the model writes. A reason a host's own harness invented falls
|
|
1087
|
+
# back to itself — a strange word in the timeline beats no word at all.
|
|
1088
|
+
def support_console_reason_word(reason)
|
|
1089
|
+
support_console_t("escalation_reasons.#{reason}", default: reason.to_s.humanize)
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
# Why she said nothing.
|
|
1093
|
+
def support_console_withheld_word(reason)
|
|
1094
|
+
support_console_t("withheld_reasons.#{reason}", default: reason.to_s.humanize)
|
|
491
1095
|
end
|
|
492
1096
|
|
|
493
1097
|
def support_console_t(key, **interpolations)
|