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,675 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+ require_relative "topic_tree"
5
+
6
+ module SupportDesk
7
+ # Everything a desk decides, with defaults that already work.
8
+ #
9
+ # SupportDesk.configure do |config|
10
+ # config.name = "Soporte CarHey"
11
+ # config.agents { User.admin }
12
+ # config.topics do
13
+ # topic :ride, about: Ride
14
+ # other
15
+ # end
16
+ # end
17
+ #
18
+ # Two rules shared with the rest of the gem ecosystem:
19
+ #
20
+ # 1. Class names are stored as STRINGS and constantized lazily, so the
21
+ # initializer can name app classes before they load and everything
22
+ # survives Zeitwerk reloads.
23
+ # 2. Setters validate ON ASSIGNMENT and raise SupportDesk::ConfigurationError
24
+ # with the fix in the message — a configuration mistake is a boot
25
+ # failure, never a 3am NoMethodError.
26
+ #
27
+ # == Desks
28
+ #
29
+ # Most apps have one desk and never think about it: the top-level setters
30
+ # configure the `:default` desk. Apps with more say so explicitly, and any
31
+ # setting a desk doesn't state falls back to the default desk's:
32
+ #
33
+ # config.desk :billing do |desk|
34
+ # desk.name = "Facturación"
35
+ # desk.reply_within = 8.hours
36
+ # end
37
+ class Configuration
38
+ # Everything a single desk decides. A desk reads its own value when it
39
+ # has one and the default desk's otherwise, so `config.reply_within =
40
+ # 24.hours` at the top level really does mean "every desk, unless it
41
+ # says otherwise".
42
+ class DeskConfiguration
43
+ REPLY_POLICIES = %i[anyone take_over assignee_only].freeze
44
+ ANNOUNCE_MODES = %i[always first_only never].freeze
45
+ CLOSED_TICKET_MODES = %i[reopen_on_reply locked].freeze
46
+ INBOX_ENTRY_MODES = %i[always when_tickets never].freeze
47
+ MIRROR_MODES = %i[always when_away never].freeze
48
+ ROUTING_STRATEGIES = %i[manual round_robin least_loaded].freeze
49
+ # Strategies that need the 0.3 duty/capacity tables; naming one now
50
+ # fails at boot instead of silently leaving tickets unassigned.
51
+ UNRELEASED_ROUTING_STRATEGIES = %i[round_robin least_loaded].freeze
52
+
53
+ DEFAULTS = {
54
+ name: nil,
55
+ avatar: nil,
56
+ email: nil,
57
+ reply_policy: :anyone,
58
+ announce_assignments: :first_only,
59
+ closed_tickets: :reopen_on_reply,
60
+ reply_within: 24 * 60 * 60,
61
+ at_risk_after: 4 * 60 * 60,
62
+ open_rate_limit: { to: 5, within: 60 * 60 },
63
+ max_open_tickets: 5,
64
+ inbox_entry: :always,
65
+ routing: :manual,
66
+ mirror_replies_by_email: :when_away,
67
+ auto_close_after: nil
68
+ }.freeze
69
+
70
+ attr_reader :key, :fallback
71
+
72
+ # A desk's settings, falling back to +fallback+ for anything it
73
+ # doesn't state (the default desk, for every desk but itself).
74
+ def initialize(key, fallback: nil)
75
+ @key = key.to_sym
76
+ @fallback = fallback
77
+ @settings = {}
78
+ @topics_block = nil
79
+ @topics = nil
80
+ end
81
+
82
+ # --- The desk's identity --------------------------------------------------
83
+
84
+ # What requesters see as the counterpart in their inbox.
85
+ def name = read(:name) || key.to_s.humanize
86
+
87
+ # nil un-sets the name, so the desk goes back to inheriting it (or to
88
+ # its humanized key). A blank string is a mistake, not an intention.
89
+ def name=(value)
90
+ return @settings.delete(:name) if value.nil?
91
+
92
+ @settings[:name] = ensure_present_string(value, "name")
93
+ end
94
+
95
+ # An asset path, a URL, or ->(desk) { … }. Anything `image_tag` accepts.
96
+ def avatar = read(:avatar)
97
+
98
+ # Set it, validating on assignment (see the reader above).
99
+ def avatar=(value)
100
+ unless value.nil? || value.is_a?(String) || value.respond_to?(:call)
101
+ raise ConfigurationError, "avatar must be a String, a callable, or nil, got #{value.inspect}"
102
+ end
103
+
104
+ @settings[:avatar] = value
105
+ end
106
+
107
+ # The address the email channel answers from (0.2).
108
+ def email = read(:email)
109
+
110
+ # Set it, validating on assignment (see the reader above).
111
+ def email=(value)
112
+ if value && !value.to_s.include?("@")
113
+ raise ConfigurationError, "email must be an email address, got #{value.inspect}"
114
+ end
115
+
116
+ @settings[:email] = value&.to_s
117
+ end
118
+
119
+ # --- Who answers ----------------------------------------------------------
120
+
121
+ # The agent pool: notified while a ticket is unassigned, offered in the
122
+ # "assign to" picker, and what routing chooses from.
123
+ #
124
+ # config.agents { User.admin }
125
+ # config.agents = -> { User.where(support: true) }
126
+ def agents(&block)
127
+ return @settings[:agents] = block if block
128
+
129
+ read(:agents)
130
+ end
131
+
132
+ # Set it, validating on assignment (see the reader above).
133
+ def agents=(value)
134
+ @settings[:agents] = ensure_callable(value, "agents")
135
+ end
136
+
137
+ # The pool, resolved. Raises ConfigurationError when the host's block
138
+ # hands back something that isn't a collection of records.
139
+ def agent_pool
140
+ callable = agents
141
+ return [] unless callable
142
+
143
+ result = callable.call
144
+ unless result.respond_to?(:each) || result.respond_to?(:to_a)
145
+ raise ConfigurationError,
146
+ "config.agents must return a relation or an array of agents, got #{result.inspect}"
147
+ end
148
+
149
+ result
150
+ end
151
+
152
+ # --- Topics ---------------------------------------------------------------
153
+
154
+ # The desk's topic tree. With a block, remembers it; with none, builds
155
+ # it (once) and hands it back.
156
+ #
157
+ # The block is kept rather than run immediately ON PURPOSE: an
158
+ # initializer runs before the host's own classes are autoloadable, and
159
+ # `topic :ride, about: Ride` has to keep reading like that. The tree is
160
+ # built at the first prepare — still boot, so a malformed tree is still
161
+ # a boot failure — and it stores class NAMES, so it survives reloads.
162
+ def topics(&block)
163
+ if block
164
+ @topics_block = block
165
+ @topics = nil
166
+ return block
167
+ end
168
+
169
+ @topics ||= if @topics_block
170
+ TopicTree.build(&@topics_block)
171
+ elsif fallback
172
+ fallback.topics
173
+ else
174
+ TopicTree.build
175
+ end
176
+ end
177
+
178
+ # Whether this desk declared a tree of its own.
179
+ def own_topics? = !@topics_block.nil?
180
+
181
+ # --- Behaviour ------------------------------------------------------------
182
+
183
+ # Who may reply to a ticket somebody else holds: :anyone (the reply
184
+ # posts, signed by the drop-in), :take_over (replying reassigns), or
185
+ # :assignee_only (raises NotAllowed).
186
+ def reply_policy = read(:reply_policy)
187
+
188
+ # Set it, validating on assignment (see the reader above).
189
+ def reply_policy=(value)
190
+ @settings[:reply_policy] = ensure_one_of(value, REPLY_POLICIES, "reply_policy")
191
+ end
192
+
193
+ # Whether the requester is told who picked up their ticket: :first_only
194
+ # (the first human to take it), :always (hand-offs too), or :never.
195
+ def announce_assignments = read(:announce_assignments)
196
+
197
+ # Set it, validating on assignment (see the reader above).
198
+ def announce_assignments=(value)
199
+ @settings[:announce_assignments] = ensure_one_of(value, ANNOUNCE_MODES, "announce_assignments")
200
+ end
201
+
202
+ # What a requester writing into a closed ticket does: :reopen_on_reply
203
+ # (the ticket comes back) or :locked (the composer is replaced by a
204
+ # notice).
205
+ def closed_tickets = read(:closed_tickets)
206
+
207
+ # Set it, validating on assignment (see the reader above).
208
+ def closed_tickets=(value)
209
+ @settings[:closed_tickets] = ensure_one_of(value, CLOSED_TICKET_MODES, "closed_tickets")
210
+ end
211
+
212
+ # The answer promise: the SLA breach threshold AND the "normalmente en
213
+ # menos de 24 h" line the requester is shown. One setting, one truth.
214
+ def reply_within = duration(read(:reply_within))
215
+
216
+ # Set it, validating on assignment (see the reader above).
217
+ def reply_within=(value)
218
+ @settings[:reply_within] = ensure_duration(value, "reply_within")
219
+ end
220
+
221
+ # When a waiting ticket starts showing as at risk, short of breach.
222
+ def at_risk_after = duration(read(:at_risk_after))
223
+
224
+ # Set it, validating on assignment (see the reader above).
225
+ def at_risk_after=(value)
226
+ @settings[:at_risk_after] = ensure_duration(value, "at_risk_after")
227
+ end
228
+
229
+ # `{ to: 5, within: 1.hour }` — how often one requester may open
230
+ # tickets. nil disables it.
231
+ def open_rate_limit = read(:open_rate_limit)
232
+
233
+ # Set it, validating on assignment (see the reader above).
234
+ def open_rate_limit=(value)
235
+ if value.nil?
236
+ @settings[:open_rate_limit] = nil
237
+ return
238
+ end
239
+
240
+ hash = value.to_h.symbolize_keys
241
+ unless hash[:to].is_a?(Integer) && hash[:to].positive? && hash[:within].respond_to?(:to_i)
242
+ raise ConfigurationError,
243
+ "open_rate_limit must be nil or { to: Integer, within: duration }, got #{value.inspect}"
244
+ end
245
+
246
+ @settings[:open_rate_limit] = hash
247
+ end
248
+
249
+ # How many tickets one requester may have open at once. nil for no
250
+ # cap. Advisory: checked before the insert, not under a lock, so a
251
+ # burst of concurrent opens can leave a requester one over.
252
+ def max_open_tickets = read(:max_open_tickets)
253
+
254
+ # Set it, validating on assignment (see the reader above).
255
+ def max_open_tickets=(value)
256
+ unless value.nil? || (value.is_a?(Integer) && value.positive?)
257
+ raise ConfigurationError, "max_open_tickets must be a positive Integer or nil, got #{value.inspect}"
258
+ end
259
+
260
+ @settings[:max_open_tickets] = value
261
+ end
262
+
263
+ # Whether the desk shows up in the requester's inbox before they have
264
+ # ever written: :always (a "¿Necesitas ayuda?" door), :when_tickets,
265
+ # or :never.
266
+ def inbox_entry = read(:inbox_entry)
267
+
268
+ # Set it, validating on assignment (see the reader above).
269
+ def inbox_entry=(value)
270
+ @settings[:inbox_entry] = ensure_one_of(value, INBOX_ENTRY_MODES, "inbox_entry")
271
+ end
272
+
273
+ # How new tickets find an agent. 0.1 ships :manual (unassigned, pool
274
+ # notified, first take wins) and procs; the load-aware strategies
275
+ # arrive with the duty table in 0.3.
276
+ def routing = read(:routing)
277
+
278
+ # Set it, validating on assignment (see the reader above).
279
+ def routing=(value)
280
+ if value.respond_to?(:call)
281
+ @settings[:routing] = value
282
+ return
283
+ end
284
+
285
+ strategy = ensure_one_of(value, ROUTING_STRATEGIES, "routing")
286
+ if UNRELEASED_ROUTING_STRATEGIES.include?(strategy)
287
+ raise ConfigurationError,
288
+ "routing #{strategy.inspect} needs the duty and capacity tables that ship in support_desk 0.3 — " \
289
+ "use :manual, or a ->(ticket) { agent } proc"
290
+ end
291
+
292
+ @settings[:routing] = strategy
293
+ end
294
+
295
+ # Whether agent replies are also emailed to the requester (0.2).
296
+ def mirror_replies_by_email = read(:mirror_replies_by_email)
297
+
298
+ # Set it, validating on assignment (see the reader above).
299
+ def mirror_replies_by_email=(value)
300
+ @settings[:mirror_replies_by_email] = ensure_one_of(value, MIRROR_MODES, "mirror_replies_by_email")
301
+ end
302
+
303
+ # Close a ticket that has been awaiting the requester this long (0.2).
304
+ def auto_close_after = duration(read(:auto_close_after))
305
+
306
+ # Set it, validating on assignment (see the reader above).
307
+ def auto_close_after=(value)
308
+ @settings[:auto_close_after] = ensure_duration(value, "auto_close_after")
309
+ end
310
+
311
+ # --- Internals ------------------------------------------------------------
312
+
313
+ def read(name) # :nodoc:
314
+ return @settings[name] if @settings.key?(name)
315
+ return fallback.read(name) if fallback
316
+
317
+ DEFAULTS[name]
318
+ end
319
+
320
+ # Whether THIS desk states the setting itself, rather than inheriting.
321
+ def own?(name) = @settings.key?(name) # :nodoc:
322
+
323
+ # Forget a setting so this desk inherits it again (tests).
324
+ def reset_setting(name) # :nodoc:
325
+ @settings.delete(name)
326
+ end
327
+
328
+ # The desk and what it calls itself.
329
+ def inspect
330
+ "#<SupportDesk::Configuration::DeskConfiguration #{key} #{name.inspect}>"
331
+ end
332
+
333
+ private
334
+
335
+ def duration(value)
336
+ return nil if value.nil?
337
+ return value if value.is_a?(ActiveSupport::Duration)
338
+
339
+ ActiveSupport::Duration.build(value.to_i)
340
+ end
341
+
342
+ def ensure_present_string(value, name)
343
+ string = value.to_s
344
+ raise ConfigurationError, "#{name} can't be blank" if string.strip.empty?
345
+
346
+ string
347
+ end
348
+
349
+ def ensure_one_of(value, allowed, name)
350
+ symbol = value.respond_to?(:to_sym) ? value.to_sym : value
351
+ unless allowed.include?(symbol)
352
+ raise ConfigurationError, "#{name} must be one of #{allowed.map(&:inspect).join(", ")}, got #{value.inspect}"
353
+ end
354
+
355
+ symbol
356
+ end
357
+
358
+ def ensure_duration(value, name)
359
+ return nil if value.nil?
360
+
361
+ unless value.is_a?(ActiveSupport::Duration) || value.is_a?(Numeric)
362
+ raise ConfigurationError, "#{name} must be a duration (e.g. 24.hours) or nil, got #{value.inspect}"
363
+ end
364
+
365
+ value
366
+ end
367
+
368
+ def ensure_callable(value, name)
369
+ unless value.respond_to?(:call)
370
+ raise ConfigurationError, "#{name} must respond to #call (a proc/lambda), got #{value.inspect}"
371
+ end
372
+
373
+ value
374
+ end
375
+ end
376
+
377
+ # Settings that belong to a desk rather than the installation. The
378
+ # top-level accessors forward to the `:default` desk, which is also what
379
+ # every other desk falls back to.
380
+ DESK_SETTINGS = %i[
381
+ name avatar email reply_policy announce_assignments closed_tickets reply_within at_risk_after
382
+ open_rate_limit max_open_tickets inbox_entry routing mirror_replies_by_email auto_close_after
383
+ ].freeze
384
+
385
+ delegate(*DESK_SETTINGS, *DESK_SETTINGS.map { |setting| :"#{setting}=" }, to: :default_desk)
386
+ delegate :agents, :agents=, :topics, to: :default_desk
387
+
388
+ # The model that asks for help — the one with `has_support_tickets`. It
389
+ # must also be a chats messager: a requester holds a seat in the
390
+ # conversation behind every one of their tickets.
391
+ attr_reader :requester_class
392
+
393
+ # The controller the requester-facing engine inherits from, so your
394
+ # layout, helpers, auth and locale apply to the support screens.
395
+ attr_reader :parent_controller
396
+
397
+ # The controller the console inherits from (the optional ConsoleEngine
398
+ # and the generated console) — usually your admin framework's base
399
+ # controller.
400
+ attr_reader :console_parent_controller
401
+
402
+ # How the engine finds the person asking for help.
403
+ attr_accessor :current_requester_method
404
+
405
+ # How the console finds the person answering.
406
+ attr_accessor :current_agent_method
407
+
408
+ # The host's own authentication filter, run before every requester-facing
409
+ # screen so a logged-out visitor meets the host's login flow rather than
410
+ # this gem's idea of one (`:authenticate_user!` is Devise's, and chats'
411
+ # default too).
412
+ attr_accessor :authenticate_method
413
+ # ->(agent) { … } returning the desks this agent may work, or nil for
414
+ # "every desk". Read through #desks_visible_to.
415
+ attr_reader :visible_desks_for
416
+
417
+ # ->(agent, ticket, action) { true/false } — the console asks this before
418
+ # every action, for hosts with Pundit, CanCan or a policy object of their
419
+ # own. Read through #console_authorized?.
420
+ attr_reader :authorize_console
421
+
422
+ # A fresh configuration: one `:default` desk, every setting at the
423
+ # documented default.
424
+ def initialize
425
+ @requester_class = "User"
426
+ @parent_controller = "::ApplicationController"
427
+ @console_parent_controller = "::ApplicationController"
428
+ @current_requester_method = :current_user
429
+ @current_agent_method = :current_user
430
+ @authenticate_method = :authenticate_user!
431
+ @visible_desks_for = nil
432
+ @authorize_console = nil
433
+
434
+ @desks = { default: DeskConfiguration.new(:default) }
435
+ @warnings = []
436
+ end
437
+
438
+ def requester_class=(value)
439
+ @requester_class = ensure_class_name(value, "requester_class")
440
+ end
441
+
442
+ def parent_controller=(value)
443
+ @parent_controller = ensure_class_name(value, "parent_controller")
444
+ end
445
+
446
+ def console_parent_controller=(value)
447
+ @console_parent_controller = ensure_class_name(value, "console_parent_controller")
448
+ end
449
+
450
+ # --- The console ------------------------------------------------------------
451
+
452
+ # Narrow what the console can reach:
453
+ #
454
+ # config.visible_desks_for = ->(agent) { agent.billing? ? [ SupportDesk.desk(:billing) ] : Desk.all }
455
+ #
456
+ # A ticket on a desk an agent can't see is a 404 in the console, not a
457
+ # 403: an agent who may not work the billing desk shouldn't learn that a
458
+ # billing case exists.
459
+ def visible_desks_for=(value)
460
+ @visible_desks_for = value.nil? ? nil : ensure_callable(value, "visible_desks_for")
461
+ end
462
+
463
+ # The desks +agent+ may work, always as Desk records. The hook may hand
464
+ # back records, a relation, or plain desk keys — all three read the same
465
+ # way in an initializer, so all three are accepted here.
466
+ def desks_visible_to(agent)
467
+ return Desk.all if visible_desks_for.nil?
468
+
469
+ Array(visible_desks_for.call(agent)).filter_map do |desk|
470
+ desk.is_a?(Desk) ? desk : SupportDesk.desk(desk)
471
+ end
472
+ end
473
+
474
+ # config.authorize_console = ->(agent, ticket, action) { AdminPolicy.new(agent).support?(action) }
475
+ #
476
+ # `ticket` is nil on collection actions (the index, "next"). Returning
477
+ # false is a 403.
478
+ def authorize_console=(value)
479
+ @authorize_console = value.nil? ? nil : ensure_callable(value, "authorize_console")
480
+ end
481
+
482
+ # Whether the host's policy allows +agent+ to do +action+ here. True when
483
+ # no hook is configured — the console's own agent check still applies.
484
+ #
485
+ # A hook that RAISES denies rather than taking the screen down with it.
486
+ # This is the one place in the gem where swallowing an exception is the
487
+ # right call: an authorization check that blew up has not said yes, and
488
+ # a 500 on the page that was guarding something is both a worse answer
489
+ # and a louder hint that something is there. The error still reaches the
490
+ # host through `Rails.error`, so nobody has to notice it from a flash.
491
+ def console_authorized?(agent, ticket, action)
492
+ return true if authorize_console.nil?
493
+
494
+ !!authorize_console.call(agent, ticket, action)
495
+ rescue StandardError => e
496
+ report_console_authorization_error(e, action)
497
+ false
498
+ end
499
+
500
+ # --- Desks ------------------------------------------------------------------
501
+
502
+ # Read or configure a desk:
503
+ #
504
+ # config.desk :billing do |desk|
505
+ # desk.name = "Facturación"
506
+ # end
507
+ #
508
+ # config.desk(:billing).reply_within # => 24 hours (inherited)
509
+ def desk(key = :default)
510
+ key = key.to_sym
511
+ configuration = @desks[key] ||= DeskConfiguration.new(key, fallback: default_desk)
512
+ yield configuration if block_given?
513
+ configuration
514
+ end
515
+
516
+ def default_desk = @desks[:default]
517
+
518
+ # Every configured desk, keyed by key.
519
+ def desks = @desks
520
+
521
+ def desk?(key) = @desks.key?(key.to_sym)
522
+
523
+ # --- Events -----------------------------------------------------------------
524
+
525
+ # Subscribe from inside the configure block — the same dispatcher as
526
+ # `SupportDesk.on`, spelled the way an initializer reads best.
527
+ def on(event, &block)
528
+ SupportDesk.on(event, &block)
529
+ end
530
+
531
+ # --- Validation -------------------------------------------------------------
532
+
533
+ # Cross-field validation, run at the end of `SupportDesk.configure`.
534
+ # Anything that needs the host's classes to be loaded is checked later,
535
+ # in the engine's to_prepare hook (see #validate_classes!).
536
+ def validate!
537
+ @desks.each_value do |desk|
538
+ next unless desk.at_risk_after && desk.reply_within && desk.at_risk_after > desk.reply_within
539
+
540
+ raise ConfigurationError,
541
+ "desk #{desk.key}: at_risk_after (#{desk.at_risk_after.inspect}) must come before " \
542
+ "reply_within (#{desk.reply_within.inspect}) — a ticket can't breach before it's at risk"
543
+ end
544
+
545
+ true
546
+ end
547
+
548
+ # Warnings raised by the last `validate!` — surfaced by `doctor`.
549
+ attr_reader :warnings
550
+
551
+ # Checks that need the host's classes loaded, so they run from the
552
+ # engine's to_prepare (every boot, and again after every reload).
553
+ def validate_classes!
554
+ requester = requester_class.safe_constantize
555
+ unless requester
556
+ raise ConfigurationError,
557
+ "config.requester_class is #{requester_class.inspect}, which doesn't exist. " \
558
+ "Point it at the model that asks for help."
559
+ end
560
+
561
+ unless requester.respond_to?(:support_desk_requester_options)
562
+ raise ConfigurationError,
563
+ "#{requester_class} must declare `has_support_tickets` (and `acts_as_messager`) to be the " \
564
+ "requester_class."
565
+ end
566
+
567
+ validate_requester_desks!
568
+
569
+ @warnings = []
570
+ @desks.each_value do |desk|
571
+ validate_agent_pool!(desk)
572
+ # Reading the tree is what BUILDS it, so a malformed topics block
573
+ # fails here — at boot, with the offending option named.
574
+ tree = desk.topics
575
+
576
+ tree.each do |topic|
577
+ topic.about_class_names.each { |name| validate_supportable!(name, topic, desk) }
578
+ end
579
+
580
+ next if tree.empty? || tree.free_form?
581
+
582
+ @warnings << "desk #{desk.key}: no free-form topic. Add `other` to the topics block — a taxonomy " \
583
+ "without an exit is how people pick the wrong topic."
584
+ end
585
+ @warnings.each { |warning| SupportDesk.logger&.warn("[support_desk] #{warning}") }
586
+
587
+ true
588
+ end
589
+
590
+ # The constantized requester class (resolved lazily — see class comment).
591
+ def requester_model = requester_class.constantize
592
+
593
+ def parent_controller_class = parent_controller.constantize
594
+
595
+ def console_parent_controller_class = console_parent_controller.constantize
596
+
597
+ private
598
+
599
+ # `config.agents { … }` has to hand back something a desk can iterate.
600
+ # Resolving it costs nothing at boot — a relation is lazy — and a block
601
+ # that returns 42, or raises, is a configuration mistake, not a 3am
602
+ # surprise the first time somebody opens a ticket.
603
+ def validate_agent_pool!(desk)
604
+ return if desk.agents.nil?
605
+
606
+ desk.agent_pool
607
+ rescue ConfigurationError
608
+ raise
609
+ rescue ActiveRecord::ActiveRecordError
610
+ # No database yet (asset precompile, a boot before migrating).
611
+ # `SupportDesk.doctor` asks the same question where there is one.
612
+ nil
613
+ rescue StandardError => e
614
+ raise ConfigurationError,
615
+ "config.agents for desk #{desk.key} raised #{e.class}: #{e.message}"
616
+ end
617
+
618
+ # A model that writes to a desk nobody configured would quietly land its
619
+ # tickets on the default desk instead.
620
+ def validate_requester_desks!
621
+ SupportDesk.requester_class_names.each do |name|
622
+ klass = name.safe_constantize
623
+ next unless klass.respond_to?(:support_desk_requester_options)
624
+
625
+ key = klass.support_desk_requester_options[:desk]
626
+ next if @desks.key?(key)
627
+
628
+ raise ConfigurationError,
629
+ "#{name} has `has_support_tickets desk: #{key.inspect}`, but no such desk is configured. " \
630
+ "Add `config.desk #{key.inspect} do |desk| … end`, or drop the desk: option."
631
+ end
632
+ end
633
+
634
+ def validate_supportable!(name, topic, desk)
635
+ klass = name.safe_constantize
636
+ unless klass
637
+ raise ConfigurationError,
638
+ "desk #{desk.key}, topic #{topic.path.inspect}: about: #{name} doesn't exist."
639
+ end
640
+
641
+ return if klass.respond_to?(:supportable?) && klass.supportable?
642
+
643
+ raise ConfigurationError,
644
+ "desk #{desk.key}, topic #{topic.path.inspect}: about: #{name} is not supportable. " \
645
+ "Add `supportable topic: :#{topic.path}` to #{name}."
646
+ end
647
+
648
+ def ensure_class_name(value, name)
649
+ string = value.is_a?(Class) ? value.name : value.to_s
650
+ raise ConfigurationError, "#{name} can't be blank" if string.strip.empty?
651
+
652
+ string
653
+ end
654
+
655
+ def ensure_callable(value, name)
656
+ unless value.respond_to?(:call)
657
+ raise ConfigurationError, "#{name} must respond to #call (a proc/lambda), got #{value.inspect}"
658
+ end
659
+
660
+ value
661
+ end
662
+
663
+ # The same reporting path the event dispatcher uses for a subscriber
664
+ # that raises: `Rails.error` when there is one, the log otherwise.
665
+ def report_console_authorization_error(error, action)
666
+ if defined?(Rails) && Rails.respond_to?(:error) && Rails.error
667
+ Rails.error.report(error, handled: true, source: "support_desk",
668
+ context: { hook: :authorize_console, action: action })
669
+ else
670
+ SupportDesk.logger&.error("[support_desk] authorize_console raised on #{action}: " \
671
+ "#{error.class}: #{error.message}")
672
+ end
673
+ end
674
+ end
675
+ end