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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +105 -10
  4. data/Rakefile +16 -0
  5. data/app/controllers/support_desk/tickets_controller.rb +5 -3
  6. data/app/helpers/support_desk/engine_helper.rb +7 -1
  7. data/app/views/support_desk/console/tickets/_context_card.html.erb +5 -0
  8. data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
  9. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +8 -0
  10. data/app/views/support_desk/console/tickets/index.html.erb +14 -2
  11. data/app/views/support_desk/console/tickets/new.html.erb +25 -0
  12. data/config/console_routes.rb +1 -1
  13. data/config/locales/support_desk.console.en.yml +26 -0
  14. data/config/locales/support_desk.console.es.yml +26 -0
  15. data/config/locales/support_desk.en.yml +3 -0
  16. data/config/locales/support_desk.es.yml +6 -0
  17. data/lib/generators/support_desk/console_generator.rb +1 -1
  18. data/lib/generators/support_desk/install_generator.rb +14 -0
  19. data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
  20. data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
  21. data/lib/generators/support_desk/templates/initializer.rb +30 -0
  22. data/lib/generators/support_desk/upgrade_generator.rb +56 -0
  23. data/lib/support_desk/configuration.rb +148 -2
  24. data/lib/support_desk/console.rb +347 -25
  25. data/lib/support_desk/console_engine.rb +2 -0
  26. data/lib/support_desk/console_routes.rb +20 -8
  27. data/lib/support_desk/context_card.rb +23 -0
  28. data/lib/support_desk/doctor.rb +51 -1
  29. data/lib/support_desk/errors.rb +5 -0
  30. data/lib/support_desk/macros.rb +28 -12
  31. data/lib/support_desk/models/assignment.rb +3 -1
  32. data/lib/support_desk/models/concerns/agent.rb +39 -4
  33. data/lib/support_desk/models/concerns/requester.rb +27 -12
  34. data/lib/support_desk/models/ticket.rb +464 -100
  35. data/lib/support_desk/summary.rb +1 -1
  36. data/lib/support_desk/test_helpers.rb +8 -3
  37. data/lib/support_desk/version.rb +1 -1
  38. data/lib/support_desk/wizard.rb +4 -12
  39. data/lib/support_desk.rb +45 -0
  40. data/lib/tasks/support_desk.rake +27 -0
  41. metadata +7 -2
@@ -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,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
- 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
+ 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 ] + TRANSITIONS).freeze
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
- # 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
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: TRANSITIONS
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
- requested = params[:desk].presence&.to_sym
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
- return true if defined?(Chats::Error) && error.is_a?(Chats::Error)
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 = ERROR_KEYS[error.class.name] || "generic"
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 two
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
- # It draws the collection route first and the member routes after, which is
12
- # what keeps `/madmin/support_tickets/next` from being swallowed by
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 member verbs, in the order an agent uses them.
30
- MEMBER_VERBS = %i[reply take assign hand_off release close reopen note change_topic].freeze
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.get :next, **options
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
@@ -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
- stale = Ticket.awaiting_reply.where("last_agent_message_at > last_requester_message_at").count
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")
@@ -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