support_desk 0.1.0

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