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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -0
  3. data/README.md +1101 -22
  4. data/Rakefile +16 -0
  5. data/app/assets/stylesheets/support_desk.css +10 -0
  6. data/app/controllers/support_desk/tickets_controller.rb +28 -4
  7. data/app/helpers/support_desk/engine_helper.rb +23 -1
  8. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  9. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  10. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  11. data/app/views/support_desk/console/tickets/_context_card.html.erb +33 -0
  12. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  13. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  14. data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
  15. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +28 -0
  16. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  17. data/app/views/support_desk/console/tickets/index.html.erb +14 -2
  18. data/app/views/support_desk/console/tickets/new.html.erb +25 -0
  19. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  20. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  21. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  22. data/config/console_routes.rb +1 -1
  23. data/config/locales/support_desk.console.en.yml +89 -0
  24. data/config/locales/support_desk.console.es.yml +91 -0
  25. data/config/locales/support_desk.en.yml +18 -0
  26. data/config/locales/support_desk.es.yml +29 -0
  27. data/config/routes.rb +7 -1
  28. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  29. data/lib/generators/support_desk/console_generator.rb +1 -1
  30. data/lib/generators/support_desk/install_generator.rb +26 -0
  31. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  32. data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
  33. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  34. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  35. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  36. data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
  37. data/lib/generators/support_desk/templates/initializer.rb +63 -0
  38. data/lib/generators/support_desk/upgrade_generator.rb +66 -0
  39. data/lib/support_desk/assistant_policy.rb +213 -0
  40. data/lib/support_desk/brief.rb +283 -0
  41. data/lib/support_desk/configuration.rb +699 -3
  42. data/lib/support_desk/console.rb +634 -30
  43. data/lib/support_desk/console_engine.rb +2 -0
  44. data/lib/support_desk/console_routes.rb +20 -8
  45. data/lib/support_desk/context_card.rb +33 -1
  46. data/lib/support_desk/doctor.rb +195 -2
  47. data/lib/support_desk/engine.rb +10 -0
  48. data/lib/support_desk/errors.rb +42 -0
  49. data/lib/support_desk/events.rb +12 -5
  50. data/lib/support_desk/macros.rb +41 -12
  51. data/lib/support_desk/models/assignment.rb +3 -1
  52. data/lib/support_desk/models/assistant.rb +153 -0
  53. data/lib/support_desk/models/concerns/agent.rb +39 -4
  54. data/lib/support_desk/models/concerns/requester.rb +45 -12
  55. data/lib/support_desk/models/desk.rb +26 -3
  56. data/lib/support_desk/models/draft.rb +266 -0
  57. data/lib/support_desk/models/event.rb +15 -1
  58. data/lib/support_desk/models/ticket/assistance.rb +675 -0
  59. data/lib/support_desk/models/ticket.rb +781 -120
  60. data/lib/support_desk/outcome.rb +38 -0
  61. data/lib/support_desk/queue.rb +41 -5
  62. data/lib/support_desk/summary.rb +1 -1
  63. data/lib/support_desk/test_helpers.rb +160 -3
  64. data/lib/support_desk/timeline.rb +40 -11
  65. data/lib/support_desk/topic.rb +22 -0
  66. data/lib/support_desk/topic_tree.rb +7 -1
  67. data/lib/support_desk/transcript.rb +237 -0
  68. data/lib/support_desk/version.rb +1 -1
  69. data/lib/support_desk/wizard.rb +4 -12
  70. data/lib/support_desk.rb +153 -0
  71. data/lib/tasks/support_desk.rake +73 -0
  72. metadata +35 -8
@@ -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 `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:
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
- TRANSITIONS = %i[reply take assign hand_off release close reopen note change_topic].freeze
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 ] + TRANSITIONS).freeze
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
- # 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
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: TRANSITIONS
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}", holder: support_console_holder)
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
- requested = params[:desk].presence&.to_sym
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
- @ticket.desk.agents.detect { |agent| agent.id.to_s == id.to_s }
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
- return true if defined?(Chats::Error) && error.is_a?(Chats::Error)
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 = ERROR_KEYS[error.class.name] || "generic"
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)