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,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # "What do you need help with?" as a plain object.
5
+ #
6
+ # The wizard is the step machine behind the requester-facing screens, and
7
+ # it is deliberately NOT a controller: a host that ejects the views, a
8
+ # native app, or a JSON API can all drive the same three steps.
9
+ #
10
+ # wizard = SupportDesk::Wizard.new(alice, params)
11
+ # wizard.step # :topic | :subject | :compose
12
+ # wizard.choices # the topics to offer, or the records to pick from
13
+ # wizard.open!("My order never arrived")
14
+ #
15
+ # The three steps:
16
+ #
17
+ # 1. **Pick a topic.** One level at a time; a branch shows its children,
18
+ # a leaf ends the step. Deep-linking a path skips ahead.
19
+ # 2. **Pick a thing** — only when the leaf attaches something. Candidates
20
+ # come from the topic or from the class's own picker, and the ones
21
+ # the requester already has an open case about are marked, not hidden.
22
+ # 3. **Write.** The context card, the prefill, and the composer.
23
+ #
24
+ # Subjects arrive as SIGNED GlobalIDs and are re-checked against
25
+ # `supportable_by?` anyway: a wizard that trusted a raw id would let
26
+ # anybody open a ticket about anybody's order.
27
+ class Wizard
28
+ # What a subject token is signed for, so a token minted for one purpose
29
+ # can't be replayed at another.
30
+ SUBJECT_PURPOSE = :support_subject
31
+
32
+ # How long a token minted for a DOOR stays good. Doors sit on long-lived
33
+ # pages and get copied into chats and bug reports, so the link somebody
34
+ # pastes tomorrow should not still open a wizard.
35
+ SUBJECT_TOKEN_TTL = 1.hour
36
+
37
+ # How long the token the COMPOSER round-trips stays good. It is a hidden
38
+ # field in a form somebody is typing into, not a link: at one hour, a
39
+ # person who writes a long message, takes a call and hits Enviar loses
40
+ # the lot to a 404. Nothing is trusted on the strength of the token
41
+ # anyway — `supportable_by?` is re-checked on the way in.
42
+ FORM_TOKEN_TTL = 24.hours
43
+
44
+ STEPS = %i[topic subject compose].freeze
45
+
46
+ attr_reader :requester, :params
47
+
48
+ # Sign a record for a door or a picker link.
49
+ def self.sign_subject(record, expires_in: SUBJECT_TOKEN_TTL)
50
+ record.to_sgid(expires_in: expires_in, for: SUBJECT_PURPOSE).to_s
51
+ end
52
+
53
+ # Resolve a signed subject token, or nil when it's missing, expired,
54
+ # forged, or points at something that has since been deleted.
55
+ def self.find_signed_subject(token)
56
+ return nil if token.blank?
57
+
58
+ GlobalID::Locator.locate_signed(token.to_s, for: SUBJECT_PURPOSE)
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ # +params+ accepts `topic:` (a path), `subject:` (a signed GlobalID) and
64
+ # `about:` (a record, for hosts driving the wizard in Ruby).
65
+ def initialize(requester, params = {}, desk: nil)
66
+ @requester = requester
67
+ @params = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h.symbolize_keys : params.symbolize_keys
68
+ @desk = desk
69
+ end
70
+
71
+ # The desk this requester writes to.
72
+ def desk
73
+ @desk ||= SupportDesk.desk(requester.class.try(:support_desk_key) || :default)
74
+ end
75
+
76
+ # That desk's topic tree.
77
+ def tree = desk.config.topics
78
+
79
+ # Where the ticket will actually be FILED. A topic may hand its subtree
80
+ # to another desk (`topic :invoice, desk: :billing`), and a ticket
81
+ # belongs where its topic says, not where the requester's class does.
82
+ #
83
+ # Deliberately not folded into #desk: the tree is read from the
84
+ # requester's desk, and a #desk that depended on the topic would ask the
85
+ # tree for the topic to find out which tree to ask.
86
+ def target_desk
87
+ key = topic&.desk_key
88
+ return desk if key.nil? || key == desk.key
89
+
90
+ SupportDesk.desk(key) || desk
91
+ end
92
+
93
+ # Which step the requester is on, given what they've chosen so far.
94
+ def step
95
+ return :topic if topic.nil? || topic.branch?
96
+ return :subject if needs_subject?
97
+
98
+ :compose
99
+ end
100
+
101
+ # Which step this is, for a view that would rather ask than compare.
102
+ def topic_step? = step == :topic
103
+ # Picking the thing it's about.
104
+ def subject_step? = step == :subject
105
+ # Writing the message.
106
+ def compose_step? = step == :compose
107
+
108
+ # The chosen topic, resolved from the `topic:` param or from the chosen
109
+ # subject. nil until they've picked one.
110
+ def topic
111
+ return @topic if defined?(@topic)
112
+
113
+ @topic = resolve_topic
114
+ end
115
+
116
+ # The chosen subject, if any — always re-checked against
117
+ # `supportable_by?`, never trusted from the params.
118
+ def subject
119
+ return @subject if defined?(@subject)
120
+
121
+ @subject = resolve_subject
122
+ end
123
+
124
+ # What to render on this step: the topics to offer, or the records to
125
+ # pick from. Always a collection, never nil.
126
+ def choices
127
+ case step
128
+ when :topic then tree.visible_for(requester, under: topic&.path)
129
+ when :subject then candidates
130
+ else []
131
+ end
132
+ end
133
+
134
+ # The records the picker would offer, resolved once per wizard: the
135
+ # step machine asks whether there are any, and then the view asks for
136
+ # them, and a `candidates:` proc that runs a query shouldn't run it twice.
137
+ def candidates
138
+ @candidates ||= Array(topic&.candidates_for(requester))
139
+ end
140
+
141
+ # The declared way out of the tree ("Otra cosa"), when this requester may
142
+ # use it — what a screen with nothing else to offer links to, so a dead
143
+ # end always has a door in it.
144
+ def free_form_exit
145
+ exit_leaf = tree.free_form_leaf
146
+ exit_leaf if exit_leaf&.visible_for?(requester)
147
+ end
148
+
149
+ # The state this step carries into the next request — what the composer
150
+ # round-trips as hidden fields, so a POST lands on exactly the step the
151
+ # GET rendered.
152
+ def state_params
153
+ { topic: topic&.path, subject: subject_token, no_subject: (1 if declined_subject?) }.compact
154
+ end
155
+
156
+ # The prompt above the choices.
157
+ def ask
158
+ case step
159
+ when :topic then topic&.ask || I18n.t("support_desk.wizard.ask_topic")
160
+ when :subject then topic.ask || I18n.t("support_desk.wizard.ask_subject")
161
+ end
162
+ end
163
+
164
+ # Whether the picker should offer "none of these".
165
+ def subject_optional?
166
+ subject_step? && topic.subject_mode == :optional
167
+ end
168
+
169
+ # Text to drop into the composer, from the topic's `prefill:`.
170
+ def prefill
171
+ topic&.prefill(subject)
172
+ end
173
+
174
+ # The composer's placeholder for this topic.
175
+ def placeholder
176
+ topic&.placeholder || I18n.t("support_desk.wizard.placeholder")
177
+ end
178
+
179
+ # The promise shown next to the composer ("we usually reply in under a
180
+ # day"), built from `config.reply_within` — one setting, one truth: the
181
+ # same number the SLA breaches on.
182
+ def promise
183
+ return nil if promise_within.nil?
184
+
185
+ I18n.t("support_desk.thread.promise", time: self.class.humanize_duration(promise_within))
186
+ end
187
+
188
+ # The promise as a Duration, for hosts that want to phrase it themselves.
189
+ def promise_within = desk.config.reply_within
190
+
191
+ # "1 day", "4 horas" — through ActionView's date helper so it speaks the
192
+ # requester's language, falling back to Duration#inspect in the (rare)
193
+ # app that has no ActionView.
194
+ def self.humanize_duration(duration)
195
+ return duration.inspect unless defined?(ActionView::Helpers::DateHelper)
196
+
197
+ @duration_words ||= Object.new.extend(ActionView::Helpers::DateHelper)
198
+ words = @duration_words.distance_of_time_in_words(duration.to_i).to_s
199
+ # An app whose locale has no date translations (no rails-i18n) would
200
+ # otherwise show "Translation missing" to a customer.
201
+ words.start_with?("Translation missing") ? duration.inspect : words
202
+ end
203
+
204
+ # The open case this would land in, when the requester already has one
205
+ # about this thing — so the wizard can say "you already have a
206
+ # conversation open" and link to it instead of opening a second.
207
+ def existing_ticket
208
+ return nil if topic.nil? || topic.branch?
209
+
210
+ key = Ticket.cardinality_key_for(requester: requester, subject: subject, topic: topic)
211
+ return nil if key.start_with?("free:")
212
+
213
+ Ticket.not_closed.find_by(requester: requester, desk: target_desk, cardinality_key: key)
214
+ end
215
+
216
+ # Which records in +choices+ the requester already has an open case
217
+ # about, so the picker can mark them. One query per wizard, not one per
218
+ # row.
219
+ def open_tickets_by_subject
220
+ return @open_tickets_by_subject if defined?(@open_tickets_by_subject)
221
+
222
+ @open_tickets_by_subject =
223
+ if subject_step?
224
+ Ticket.not_closed.where(requester: requester, desk: target_desk)
225
+ .where.not(subject_id: nil)
226
+ .index_by { |ticket| [ ticket.subject_type, ticket.subject_id.to_s ] }
227
+ else
228
+ {}
229
+ end
230
+ end
231
+
232
+ # The open case this requester already has about +record+, or nil — what
233
+ # the picker marks with "Ya tienes una conversación abierta" and links to
234
+ # instead of offering as a choice.
235
+ def open_ticket_about(record)
236
+ return nil if record.nil?
237
+
238
+ open_tickets_by_subject[[ record.class.polymorphic_name, record.id.to_s ]]
239
+ end
240
+
241
+ # True when the params named a subject that didn't resolve: a forged or
242
+ # expired token, a record that has since been deleted, one that isn't
243
+ # supportable, or somebody else's. Callers turn this into a 404 — "not
244
+ # yours" and "not there" must look the same from outside.
245
+ def subject_rejected?
246
+ subject_named? && subject.nil?
247
+ end
248
+
249
+ # The params that take the requester one step back, or nil when this is
250
+ # the first screen. An empty Hash means the top of the topic tree, so
251
+ # `new_ticket_path(wizard.back)` is always the right link — the wizard
252
+ # never leans on `history.back()`, because every step is a real URL.
253
+ def back
254
+ case step
255
+ when :topic then topic && level_above(topic)
256
+ when :subject then level_above(topic)
257
+ when :compose
258
+ # A door dropped them straight here from a host page. "Back" to a
259
+ # picker they never saw would be a place they have never been.
260
+ return nil if subject_named? && params[:topic].blank?
261
+
262
+ picker_step? ? { topic: topic.path } : level_above(topic)
263
+ end
264
+ end
265
+
266
+ # Submit. Raises SupportDesk::InvalidTransition when the wizard isn't
267
+ # finished — a host that renders its own form can't half-submit one.
268
+ def open!(message, files: [])
269
+ unless compose_step?
270
+ raise InvalidTransition, "the wizard is still on the #{step} step — pick one before submitting"
271
+ end
272
+
273
+ # `ask_support!` files under the REQUESTER's desk, which is right
274
+ # until a topic says otherwise; then the redirect is spelled out.
275
+ if target_desk == desk
276
+ requester.ask_support!(message, about: subject, topic: topic.path, files: files)
277
+ else
278
+ Ticket.open!(requester: requester, message: message, about: subject, topic: topic.path,
279
+ files: files, desk: target_desk,
280
+ requester_role: requester.class.support_desk_requester_options[:as])
281
+ end
282
+ end
283
+
284
+ # A signed token for the currently chosen subject, to round-trip through
285
+ # the next form — on the form's clock, not a door's (see FORM_TOKEN_TTL).
286
+ def subject_token
287
+ subject && self.class.sign_subject(subject, expires_in: FORM_TOKEN_TTL)
288
+ end
289
+
290
+ # Where the requester has got to, in one line.
291
+ def inspect
292
+ "#<SupportDesk::Wizard step=#{step} topic=#{topic&.path.inspect} subject=#{subject.inspect}>"
293
+ end
294
+
295
+ private
296
+
297
+ def needs_subject?
298
+ return false if topic.nil? || topic.free_form?
299
+ return false if subject
300
+ return false if topic.about.empty?
301
+
302
+ return false if topic.subject_mode == :none
303
+ # "None of these" is only on offer when the topic said it was optional.
304
+ return false if declined_subject?
305
+ # Nothing to pick from and nothing insisting we pick: asking "which
306
+ # one?" above an empty list is a dead end, so skip straight to writing.
307
+ return false if candidates.empty? && topic.subject_mode != :required
308
+
309
+ true
310
+ end
311
+
312
+ # Whether they answered "ninguno de estos" — only an answer at all when
313
+ # the topic offered it.
314
+ def declined_subject?
315
+ params.key?(:no_subject) && topic&.subject_mode == :optional
316
+ end
317
+
318
+ # True when the params tried to name a subject at all — used to tell
319
+ # "they haven't picked one yet" apart from "the one they named is not
320
+ # theirs".
321
+ def subject_named?
322
+ params[:about].present? || params[:subject].present?
323
+ end
324
+
325
+ # Whether this topic has a picker that would actually RENDER — which is
326
+ # what decides where "back" from the composer goes. A picker skipped for
327
+ # having nothing in it must not be the place back leads to, or back
328
+ # forwards straight to where it came from.
329
+ def picker_step?
330
+ return false if topic.nil? || topic.free_form? || topic.about.empty?
331
+ return true if topic.subject_mode == :required
332
+
333
+ candidates.any?
334
+ end
335
+
336
+ # The params for the level of the tree +node+ was chosen from: its
337
+ # parent branch, or the top level ({}).
338
+ def level_above(node)
339
+ node&.parent ? { topic: node.parent.path } : {}
340
+ end
341
+
342
+ def resolve_topic
343
+ path = params[:topic].presence
344
+ chosen = path ? nil : resolve_subject
345
+ node = if path
346
+ tree.find(path.to_s)
347
+ elsif chosen
348
+ tree.find(chosen.support_topic.to_s)
349
+ end
350
+
351
+ # A deep link is a list of one: a topic this requester would never be
352
+ # offered is not one they may walk into by typing its path — nor by
353
+ # pointing at a record whose own `support_topic` is hidden from them,
354
+ # which is the same bypass wearing a different hat.
355
+ return node&.visible_for?(requester) ? node : nil if path || chosen
356
+
357
+ # Nothing on offer at all: every branch behind an `only:`, or a desk
358
+ # with no tree. The wizard must not open on a question with no
359
+ # answers, so it starts at the composer under the DECLARED way out
360
+ # (`other`, or `free_form: true`) — and only when that leaf is one
361
+ # this requester may use, because `Ticket.open!` refuses a hidden
362
+ # topic and a composer that 404s on submit is worse than a refusal.
363
+ #
364
+ # nil when the tree declares no exit, or hides the one it declares:
365
+ # the requester stays on the topic step with nothing to choose, and
366
+ # the screen says so.
367
+ return nil unless tree.visible_for(requester).empty?
368
+
369
+ free_form_exit
370
+ end
371
+
372
+ def resolve_subject
373
+ record = subject_from_params
374
+ return nil if record.nil?
375
+ return nil unless record.respond_to?(:supportable?) && record.supportable?
376
+ return nil unless record.supportable_by?(requester)
377
+
378
+ record
379
+ end
380
+
381
+ # `about:` is the one param a door, a deep link and a host driving the
382
+ # wizard in Ruby all use, so it accepts BOTH: a signed GlobalID off the
383
+ # query string, or the record itself. `subject:` is the same token under
384
+ # the name the wizard's own forms round-trip it as.
385
+ def subject_from_params
386
+ given = params[:about]
387
+ return given unless given.nil? || given.is_a?(String)
388
+
389
+ self.class.find_signed_subject(given.presence || params[:subject])
390
+ end
391
+ end
392
+ end
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/string/inflections"
5
+ require "active_support/core_ext/hash/keys"
6
+ require "active_support/core_ext/object/blank"
7
+ require "global_id"
8
+
9
+ require "chats"
10
+
11
+ require_relative "support_desk/version"
12
+ require_relative "support_desk/errors"
13
+ require_relative "support_desk/events"
14
+ require_relative "support_desk/topic"
15
+ require_relative "support_desk/topic_tree"
16
+ require_relative "support_desk/configuration"
17
+ require_relative "support_desk/current"
18
+ require_relative "support_desk/macros"
19
+ # The console's Layer 2. Spine rather than autoloaded, because the engine
20
+ # isolates SupportDesk::Console as its namespace and a namespace has to
21
+ # exist before an engine can isolate it.
22
+ require_relative "support_desk/console"
23
+ require_relative "support_desk/console_routes"
24
+
25
+ if defined?(::Rails::Engine)
26
+ require_relative "support_desk/engine"
27
+ require_relative "support_desk/console_engine"
28
+ end
29
+
30
+ # == SupportDesk
31
+ #
32
+ # Customer support for Rails apps: tickets that are conversations. A product
33
+ # gem on the `chats` kernel — chats owns the transcript, support_desk owns
34
+ # the case.
35
+ #
36
+ # The public surface is small on purpose:
37
+ #
38
+ # SupportDesk.configure { |config| ... } # one block, in an initializer
39
+ # has_support_tickets # on whoever asks for help
40
+ # supportable topic: :ride # on whatever they ask about
41
+ # acts_as_support_agent if: :admin? # on whoever answers
42
+ #
43
+ # ticket = alice.ask_support!("No me han pagado", about: withdrawal)
44
+ # ticket.assign!(to: lucia)
45
+ # ticket.reply!("Lo estamos revisando", by: lucia)
46
+ # ticket.close!(by: lucia)
47
+ #
48
+ # Everything else is queues, presenters and events — see the README.
49
+ module SupportDesk
50
+ # Assertions and builders for host test suites. Autoloaded, the way
51
+ # organizations and clickwrap expose theirs, so a host writes
52
+ # `include SupportDesk::TestHelpers` without a require and a production
53
+ # boot never loads the file.
54
+ autoload :TestHelpers, "support_desk/test_helpers"
55
+
56
+ class << self
57
+ include Events
58
+
59
+ # --- Configuration --------------------------------------------------------
60
+
61
+ def config
62
+ @config ||= Configuration.new
63
+ end
64
+
65
+ alias configuration config
66
+
67
+ # The one block a host writes, in an initializer. Validates what it
68
+ # can right away; the checks that need the app's own classes run at
69
+ # the first prepare.
70
+ def configure
71
+ yield config if block_given?
72
+ config.validate!
73
+ @configured = true
74
+ config
75
+ end
76
+
77
+ # Whether a host has actually run `SupportDesk.configure`. Boot-time
78
+ # class validation only bites once they have: a fresh `bundle add
79
+ # support_desk` must still boot so you can run the install generator.
80
+ def configured?
81
+ !!@configured
82
+ end
83
+
84
+ # Reset the global state a test can dirty: configuration, subscribers,
85
+ # and the memoised desks. Handy in a console too.
86
+ #
87
+ # The class registries are deliberately NOT cleared: they are a property
88
+ # of the code that is loaded (the macros register at class definition
89
+ # time), not of the configuration, and a host test suite shouldn't have
90
+ # to re-declare its own models between examples.
91
+ def reset!
92
+ @config = Configuration.new
93
+ @configured = false
94
+ @subscribers = nil
95
+ @desks = nil
96
+ self
97
+ end
98
+
99
+ # Forget the memoised Desk records without touching configuration — for
100
+ # tests that truncate tables between examples.
101
+ def reset_desks!
102
+ @desks = nil
103
+ self
104
+ end
105
+
106
+ # --- Desks ------------------------------------------------------------------
107
+
108
+ # The desk record for +key+, memoised per process.
109
+ #
110
+ # Lazily created with `find_by || create_or_find_by!` and NEVER
111
+ # INSERT-first: a desk is read thousands of times and written once, and
112
+ # an INSERT that fails its unique index on every page view is noise in
113
+ # the log and a wasted round trip.
114
+ def desk(key = :default)
115
+ key = (key || :default).to_sym
116
+ return nil unless config.desk?(key) || key == :default
117
+
118
+ desks[key] ||= Desk.for(key)
119
+ end
120
+
121
+ # Every desk record this process has resolved, keyed by key.
122
+ def desks # :nodoc:
123
+ @desks ||= {}
124
+ end
125
+
126
+ # --- Topics -----------------------------------------------------------------
127
+
128
+ # The Topic at +path+, looked up across every configured desk's tree, or
129
+ # a Topic::Unknown that still renders (never raises in a view).
130
+ def find_topic(path)
131
+ return nil if path.nil?
132
+
133
+ path = path.to_s
134
+ config.desks.each_value do |desk|
135
+ node = desk.topics.find(path)
136
+ return node if node
137
+ end
138
+
139
+ Topic::Unknown.new(path)
140
+ end
141
+
142
+ # --- Registries ---------------------------------------------------------------
143
+ #
144
+ # The macros self-register the calling class here. We store class NAMES
145
+ # (strings), not Class objects, so the registry survives Zeitwerk code
146
+ # reloading in development (a reloaded class is a brand new object; its
147
+ # name is stable).
148
+
149
+ # Called by the macros. Returns the class, so it composes.
150
+ def register_requester(klass) = register(requester_class_names, klass)
151
+ def register_supportable(klass) = register(supportable_class_names, klass)
152
+ # Called by `acts_as_support_agent`. Returns the class.
153
+ def register_agent(klass) = register(agent_class_names, klass)
154
+
155
+ # The registered class names, as Sets of Strings.
156
+ def requester_class_names = @requester_class_names ||= Set.new
157
+ def supportable_class_names = @supportable_class_names ||= Set.new
158
+ # Every class that has declared itself able to answer.
159
+ def agent_class_names = @agent_class_names ||= Set.new
160
+
161
+ # Whether +klass+ (a Class, an instance, or a class name) is supportable.
162
+ def supportable_class?(klass) = registered?(supportable_class_names, klass)
163
+ # Whether +klass+ asks for support / answers it. Ancestor-aware, so an
164
+ # STI subclass of a registered class counts.
165
+ def requester_class?(klass) = registered?(requester_class_names, klass)
166
+ def agent_class?(klass) = registered?(agent_class_names, klass)
167
+
168
+ # --- The chats seam -------------------------------------------------------------
169
+
170
+ # Subscribe the gem's own `:message_created` listener, which is what
171
+ # keeps `awaiting`, the SLA clocks and reopen-on-reply true without
172
+ # anybody remembering to call anything.
173
+ #
174
+ # Safe to call as often as you like: chats replaces a subscriber
175
+ # registered under the same `key:` rather than stacking another one.
176
+ # There is deliberately NO "already subscribed" flag here — one would
177
+ # make re-subscribing after a `Chats.reset!` a silent no-op, and the
178
+ # first sign of that is a desk whose tickets stop knowing whose turn it
179
+ # is, with nothing in the log.
180
+ def subscribe_to_chats!
181
+ Chats.on(:message_created, key: :support_desk) do |message|
182
+ # The constant is resolved on every call on purpose: in development
183
+ # the Ticket class is a new object after each reload.
184
+ SupportDesk::Ticket.for_conversation(message.conversation)&.register!(message)
185
+ end
186
+ self
187
+ end
188
+
189
+ # --- Health -------------------------------------------------------------------
190
+
191
+ # Everything that can only be checked against a running app:
192
+ # configuration, the chats seams, and the data invariants.
193
+ # `SupportDesk.doctor.ok?` is the one line to put in CI.
194
+ def doctor = Doctor.run
195
+
196
+ # --- Internals ----------------------------------------------------------------
197
+
198
+ def logger
199
+ defined?(::Rails) ? ::Rails.logger : nil
200
+ end
201
+
202
+ # Where the host mounted the requester-facing engine
203
+ # ("/messages/support"), or nil when they haven't. Used for the grouped
204
+ # inbox row's link and by `doctor`.
205
+ #
206
+ # Read from the route set rather than from a URL helper on purpose: the
207
+ # answer has to exist before the engine has drawn a single route.
208
+ def root_path
209
+ return nil unless defined?(::Rails) && ::Rails.application
210
+
211
+ route = ::Rails.application.routes.routes.find do |candidate|
212
+ candidate.app.respond_to?(:app) && candidate.app.app == SupportDesk::Engine
213
+ end
214
+ return nil unless route
215
+
216
+ path = route.path.spec.to_s.sub("(.:format)", "")
217
+ path.empty? ? "/" : path
218
+ rescue StandardError
219
+ nil
220
+ end
221
+
222
+ # The Hotwire Native path-configuration rules for the requester-facing
223
+ # engine, ready to splat into a host's own `rules` array:
224
+ #
225
+ # rules: [ *SupportDesk.native_path_rules, *my_own_rules ]
226
+ #
227
+ # Both surfaces are ordinary PUSHED screens (`context: "default"`), never
228
+ # modals: the wizard is three real URLs and a modal would break the back
229
+ # gesture between them. The thread itself is deliberately absent — it is
230
+ # a chats conversation, and it stays under the host's chats rule.
231
+ #
232
+ # +mount+ defaults to wherever the engine is mounted and +title+ to the
233
+ # desk's name.
234
+ def native_path_rules(mount: nil, title: nil)
235
+ mount = (mount || root_path)&.to_s&.chomp("/")
236
+ if mount.blank?
237
+ raise ConfigurationError,
238
+ "SupportDesk.native_path_rules can't tell where the engine is mounted. " \
239
+ "Mount it (`mount SupportDesk::Engine => \"/support\"`) or pass mount: \"/support\"."
240
+ end
241
+
242
+ title ||= config.name
243
+ prefix = Regexp.escape(mount)
244
+
245
+ [
246
+ {
247
+ patterns: [ "^#{prefix}/?(?:\\?.*)?$" ],
248
+ properties: { context: "default", title: title, pull_to_refresh_enabled: true },
249
+ comment: "The requester's support list: a pushed screen, pull to refresh like any other list."
250
+ },
251
+ {
252
+ patterns: [ "^#{prefix}/new(?:\\?.*)?$" ],
253
+ properties: { context: "default", title: title, pull_to_refresh_enabled: false },
254
+ comment: "The wizard: every step is a real URL, so it pushes and the back gesture works. " \
255
+ "Pull to refresh is off — it would throw away what the requester has typed."
256
+ }
257
+ ]
258
+ end
259
+
260
+ # A stable, URL-safe key for an actor (agent, requester, desk), used in
261
+ # cache keys and event payloads. GlobalID params already encode class +
262
+ # id, so two classes can never collide.
263
+ def actor_key(record)
264
+ return nil if record.nil?
265
+ return record.to_s if record.is_a?(Symbol)
266
+
267
+ record.to_global_id.to_param
268
+ end
269
+
270
+ private
271
+
272
+ def register(registry, klass)
273
+ registry << klass.name if klass.name
274
+ klass
275
+ end
276
+
277
+ def registered?(registry, klass)
278
+ klass = klass.class unless klass.is_a?(Class) || klass.is_a?(String)
279
+ name = klass.is_a?(String) ? klass : klass.name
280
+ return true if registry.include?(name)
281
+
282
+ constant = klass.is_a?(String) ? name.safe_constantize : klass
283
+ return false unless constant.respond_to?(:ancestors)
284
+
285
+ constant.ancestors.any? { |ancestor| registry.include?(ancestor.name) }
286
+ end
287
+ end
288
+ end