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,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/type"
4
+
5
+ module SupportDesk
6
+ # What a ticket is about, as a value object.
7
+ #
8
+ # Topics are a tree defined in code (see SupportDesk::TopicTree), frozen at
9
+ # boot, and stored on the ticket as a stable path — `"payments/withdrawal"`
10
+ # — so seeds, exports and metrics stay readable and re-ordering the tree
11
+ # never rewrites a single row.
12
+ #
13
+ # ticket.topic # => #<SupportDesk::Topic payments/withdrawal>
14
+ # ticket.topic == :ride # compares against the path
15
+ # ticket.topic.under?(:payments) # self or any descendant
16
+ # ticket.topic.label # "Una retirada" (i18n)
17
+ # ticket.topic.to_s # "payments/withdrawal"
18
+ #
19
+ # Immutable: every node is frozen once the tree is built.
20
+ class Topic
21
+ # Options a branch passes down to its descendants unless they say
22
+ # otherwise. Presentation (label, ask, prefill) is never inherited — only
23
+ # the options that carry behaviour.
24
+ INHERITED_OPTIONS = %i[about candidates desk route_to priority only retired].freeze
25
+
26
+ SUBJECT_MODES = %i[required optional none].freeze
27
+ PRIORITIES = { normal: 0, high: 1, urgent: 2 }.freeze
28
+
29
+ attr_reader :key, :path, :parent, :children, :options
30
+
31
+ # Nodes are built by the topics DSL, never by hand.
32
+ def initialize(key:, parent: nil, **options)
33
+ @key = key.to_sym
34
+ @parent = parent
35
+ @path = [ parent&.path, @key ].compact.join("/")
36
+ @options = options.freeze
37
+ @children = []
38
+ end
39
+
40
+ # --- Tree shape -----------------------------------------------------------
41
+
42
+ # Top level, nothing below it, or something below it.
43
+ def root? = parent.nil?
44
+ # Nothing below it.
45
+ def leaf? = children.empty?
46
+ # Something below it.
47
+ def branch? = !leaf?
48
+
49
+ # Every ancestor, closest first.
50
+ def ancestors
51
+ parent ? [ parent, *parent.ancestors ] : []
52
+ end
53
+
54
+ # This node and every node below it, depth first.
55
+ def self_and_descendants
56
+ [ self, *children.flat_map(&:self_and_descendants) ]
57
+ end
58
+
59
+ # --- Identity -------------------------------------------------------------
60
+
61
+ def to_s = path
62
+ # The path, for URLs.
63
+ def to_param = path
64
+
65
+ # True for the null object returned for paths that aren't in the tree.
66
+ def unknown? = false
67
+
68
+ # Compares against a Topic, a path String, or a Symbol key/path, so
69
+ # `ticket.topic == :ride` reads the way you'd say it.
70
+ def ==(other)
71
+ case other
72
+ when Topic then path == other.path
73
+ when String, Symbol then path == other.to_s
74
+ else false
75
+ end
76
+ end
77
+ alias eql? ==
78
+
79
+ # Hashes by path, so topics work as Hash keys.
80
+ def hash = path.hash
81
+
82
+ # True when this topic is +other+ or lives under it:
83
+ # `topic("payments/withdrawal").under?(:payments)`.
84
+ def under?(other)
85
+ other = other.to_s
86
+ path == other || path.start_with?("#{other}/")
87
+ end
88
+
89
+ # --- Presentation ---------------------------------------------------------
90
+
91
+ # The human name of this node, from `label:` or i18n
92
+ # (`support_desk.topics.<dotted path>.label`), falling back to a
93
+ # humanized key so a missing translation is never a missing screen.
94
+ def label
95
+ case options[:label]
96
+ when String then options[:label]
97
+ when Symbol then translate(i18n_key_for(options[:label], :label)) || key.to_s.humanize
98
+ else translate(i18n_key(:label)) || key.to_s.humanize
99
+ end
100
+ end
101
+
102
+ # The whole branch spelled out: "Pagos › Una retirada".
103
+ def full_label
104
+ [ *ancestors.reverse, self ].map(&:label).join(" › ")
105
+ end
106
+
107
+ # The picker prompt ("¿Con qué viaje necesitas ayuda?").
108
+ def ask
109
+ options[:ask].is_a?(String) ? options[:ask] : translate(i18n_key(:ask))
110
+ end
111
+
112
+ # The composer placeholder for tickets opened under this topic.
113
+ def placeholder
114
+ options[:placeholder].is_a?(String) ? options[:placeholder] : translate(i18n_key(:placeholder))
115
+ end
116
+
117
+ # Deterministic text dropped into the composer. `prefill:` may be a String
118
+ # or a callable taking the chosen subject.
119
+ def prefill(subject = nil)
120
+ value = options[:prefill]
121
+ value.respond_to?(:call) ? value.call(subject) : value
122
+ end
123
+
124
+ # An optional icon key for hosts that render one; the gem never does.
125
+ def icon = options[:icon]
126
+
127
+ # --- Behaviour ------------------------------------------------------------
128
+
129
+ # The `supportable` classes a ticket under this topic may attach, as
130
+ # Classes. Resolved lazily from stored names so the tree survives
131
+ # Zeitwerk reloads.
132
+ def about
133
+ Array(inherited_or_own(:about)).map { |klass| klass.is_a?(String) ? klass.constantize : klass }
134
+ end
135
+
136
+ # Whether this topic attaches +klass+ (or an instance of it).
137
+ def about?(klass)
138
+ about.any? { |candidate| klass.is_a?(Class) ? klass <= candidate : klass.class <= candidate }
139
+ end
140
+
141
+ # Stored (unconstantized) `about:` class names — what boot validation and
142
+ # `doctor` check, without forcing the classes to load.
143
+ def about_class_names
144
+ Array(inherited_or_own(:about)).map { |klass| klass.is_a?(String) ? klass : klass.name }
145
+ end
146
+
147
+ # :required — the wizard insists on a subject; :optional — it offers
148
+ # "none of these"; :none — free form. Defaults to :optional when the
149
+ # topic attaches anything at all, :none when it doesn't.
150
+ def subject_mode
151
+ options.fetch(:subject) { about.any? ? :optional : :none }.to_sym
152
+ end
153
+
154
+ # Whether the wizard insists on a subject, or asks for none at all.
155
+ def subject_required? = subject_mode == :required
156
+ def free_form? = subject_mode == :none
157
+
158
+ # Whether this node is the tree's declared way out — what `other`
159
+ # builds, or any leaf a host declared `free_form: true`.
160
+ #
161
+ # Deliberately NOT the same question as #free_form?: plenty of topics
162
+ # take no subject ("app feedback", "safety") without being the place a
163
+ # ticket should land when nobody picked anything.
164
+ def catch_all? = !!options[:free_form]
165
+
166
+ # The picker's candidate records for +requester+: the topic's
167
+ # `candidates:` proc, else each `about:` class's own
168
+ # `.support_candidates_for`.
169
+ def candidates_for(requester)
170
+ proc = inherited_or_own(:candidates)
171
+ return proc.call(requester) if proc.respond_to?(:call)
172
+
173
+ about.first&.support_candidates_for(requester)
174
+ end
175
+
176
+ # Whether this node shows in the wizard for +requester+ (`only:` proc,
177
+ # and retired nodes never do).
178
+ def visible_for?(requester)
179
+ return false if retired?
180
+
181
+ only = inherited_or_own(:only)
182
+ only.respond_to?(:call) ? !!only.call(requester) : true
183
+ end
184
+
185
+ # 0 normal, 1 high, 2 urgent — the integer a ticket is opened with.
186
+ def priority
187
+ PRIORITIES.fetch((inherited_or_own(:priority) || :normal).to_sym, 0)
188
+ end
189
+
190
+ # An agent scope or proc that wins over desk routing for this subtree.
191
+ def route_to = inherited_or_own(:route_to)
192
+
193
+ # The desk key tickets under this topic belong to.
194
+ def desk_key
195
+ (inherited_or_own(:desk) || :default).to_sym
196
+ end
197
+
198
+ # Hidden from the wizard; the label still resolves for historic tickets.
199
+ def retired? = !!inherited_or_own(:retired)
200
+
201
+ # The topic, in one line.
202
+ def inspect = "#<SupportDesk::Topic #{path}>"
203
+
204
+ # Freeze this node and everything under it — the tree is built once, at
205
+ # boot, and then it is a value.
206
+ def deep_freeze # :nodoc:
207
+ children.each(&:deep_freeze)
208
+ children.freeze
209
+ freeze
210
+ end
211
+
212
+ # Used by the DSL while the tree is being built.
213
+ def add_child(child) # :nodoc:
214
+ children << child
215
+ child
216
+ end
217
+
218
+ private
219
+
220
+ def inherited_or_own(option)
221
+ return options[option] if options.key?(option)
222
+ return nil unless INHERITED_OPTIONS.include?(option)
223
+
224
+ parent&.send(:inherited_or_own, option)
225
+ end
226
+
227
+ def i18n_key(suffix)
228
+ "support_desk.topics.#{path.tr("/", ".")}.#{suffix}"
229
+ end
230
+
231
+ def i18n_key_for(symbol, suffix)
232
+ symbol.to_s.include?(".") ? symbol.to_s : "support_desk.topics.#{symbol}.#{suffix}"
233
+ end
234
+
235
+ def translate(key)
236
+ return nil unless defined?(I18n)
237
+
238
+ I18n.t(key, default: nil)
239
+ end
240
+
241
+ # The null object for a path no tree knows: a retired node whose label
242
+ # still reads well, so a historic ticket never blows up a view.
243
+ #
244
+ # ticket.topic.unknown? # => true
245
+ # ticket.topic.label # => "Payments/withdrawal"
246
+ class Unknown < Topic
247
+ def initialize(path)
248
+ super(key: path.to_s.split("/").last || path.to_s)
249
+ @path = path.to_s
250
+ end
251
+
252
+ def unknown? = true
253
+ def retired? = true
254
+ def label = translate(i18n_key(:label)) || @path.tr("/", " ").humanize
255
+ def about = []
256
+ def subject_mode = :none
257
+ def inspect = "#<SupportDesk::Topic::Unknown #{path}>"
258
+ end
259
+
260
+ # The ActiveModel attribute type behind `attribute :topic`: casts
261
+ # Symbol | String | Topic on the way in, hands back a Topic on the way
262
+ # out, and serializes to the path — so `where(topic: :ride)` works and a
263
+ # view never sees a bare string.
264
+ class Type < ActiveModel::Type::Value
265
+ def type = :string
266
+
267
+ def cast(value)
268
+ case value
269
+ when nil then nil
270
+ when Topic then value
271
+ else SupportDesk.find_topic(value.to_s)
272
+ end
273
+ end
274
+
275
+ def serialize(value)
276
+ case value
277
+ when nil then nil
278
+ when Topic then value.path
279
+ else value.to_s
280
+ end
281
+ end
282
+
283
+ def deserialize(value) = cast(value)
284
+
285
+ def changed_in_place?(raw_old_value, new_value)
286
+ raw_old_value != serialize(new_value)
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "topic"
4
+
5
+ module SupportDesk
6
+ # The tree of topics a desk offers, built once at boot from the `topics`
7
+ # block and frozen:
8
+ #
9
+ # config.topics do
10
+ # topic :ride, about: Ride
11
+ # topic :payments do
12
+ # topic :withdrawal, about: Payouts::Withdrawal
13
+ # end
14
+ # other
15
+ # end
16
+ #
17
+ # It is the one object the wizard, the console picker, routing and metrics
18
+ # all read: `tree.find("payments/withdrawal")`, `tree.visible_for(user)`,
19
+ # `tree.leaves`.
20
+ class TopicTree
21
+ include Enumerable
22
+
23
+ # The free-form leaf every tree should have — a taxonomy without an exit
24
+ # is how people pick the wrong topic.
25
+ OTHER_KEY = :other
26
+
27
+ attr_reader :roots
28
+
29
+ # Build a tree from a `topics do … end` block, and freeze it.
30
+ def self.build(&block)
31
+ new.tap { |tree| Builder.new(tree).instance_eval(&block) if block }.freeze!
32
+ end
33
+
34
+ # An empty tree. Prefer `TopicTree.build`.
35
+ def initialize
36
+ @roots = []
37
+ @index = {}
38
+ end
39
+
40
+ # Depth-first over every node.
41
+ def each(&block)
42
+ return to_enum(:each) unless block
43
+
44
+ @roots.each { |root| root.self_and_descendants.each(&block) }
45
+ self
46
+ end
47
+
48
+ # The node at +path+ ("payments/withdrawal"), or nil.
49
+ def find(path)
50
+ return nil if path.nil?
51
+
52
+ @index[path.to_s]
53
+ end
54
+ alias [] find
55
+
56
+ # The node at +path+, or raise SupportDesk::UnknownTopic.
57
+ def find!(path)
58
+ find(path) || raise(UnknownTopic, "no topic #{path.inspect} in this desk's tree " \
59
+ "(known: #{@index.keys.sort.join(", ")})")
60
+ end
61
+
62
+ # Whether the tree has a node at +path+.
63
+ def include?(path) = !find(path).nil?
64
+
65
+ # Every node a ticket can actually be filed under.
66
+ def leaves = select(&:leaf?)
67
+
68
+ # A desk with no topics at all: every ticket lands free-form.
69
+ def empty? = @roots.empty?
70
+
71
+ # How many nodes, branches included.
72
+ def size = count
73
+
74
+ # The nodes a requester may see at a given level: pass nothing for the
75
+ # top level, a node (or its path) to walk into a branch.
76
+ def visible_for(requester, under: nil)
77
+ nodes = under ? Array(find(under.to_s)&.children) : @roots
78
+ nodes.select { |node| node.visible_for?(requester) }
79
+ end
80
+
81
+ # True when the tree has somewhere to put "something else": a leaf
82
+ # DECLARED as the way out, with `other` or `free_form: true`.
83
+ #
84
+ # Not "any leaf that takes no subject" — a tree can be full of
85
+ # subject-less topics ("safety", "feedback") and still have no honest
86
+ # answer to "none of the above", which is the failure this guards.
87
+ def free_form?
88
+ !free_form_leaf.nil?
89
+ end
90
+
91
+ # The leaf a ticket lands on when nobody picked a topic.
92
+ def free_form_leaf
93
+ candidates = leaves.select { |leaf| leaf.catch_all? && !leaf.retired? }
94
+ candidates.find { |leaf| leaf.key == OTHER_KEY } || candidates.first
95
+ end
96
+
97
+ # Every `about:` class name mentioned anywhere in the tree — what boot
98
+ # validation and `doctor` check against the supportable registry.
99
+ def about_class_names
100
+ flat_map(&:about_class_names).uniq
101
+ end
102
+
103
+ # Used by the DSL while the tree is being built.
104
+ def add(node, parent: nil) # :nodoc:
105
+ parent ? parent.add_child(node) : @roots << node
106
+ @index[node.path] = node
107
+ node
108
+ end
109
+
110
+ # Used by `other false` to take the way out back out.
111
+ def remove(path) # :nodoc:
112
+ node = @index.delete(path.to_s)
113
+ return nil unless node
114
+
115
+ (node.parent ? node.parent.children : @roots).delete(node)
116
+ node
117
+ end
118
+
119
+ # Freeze the tree and every node in it: built once, at boot.
120
+ def freeze! # :nodoc:
121
+ @roots.each(&:deep_freeze)
122
+ @roots.freeze
123
+ @index.freeze
124
+ freeze
125
+ end
126
+
127
+ # Every path in the tree, in one line.
128
+ def inspect = "#<SupportDesk::TopicTree #{map(&:path).join(" ")}>"
129
+
130
+ # The `topic` / `other` DSL. It runs once, at boot, inside
131
+ # `config.topics do … end` — no metaprogramming, nothing to grep for
132
+ # later: every node you can see in the initializer is every node there is.
133
+ class Builder
134
+ def initialize(tree, parent: nil)
135
+ @tree = tree
136
+ @parent = parent
137
+ end
138
+
139
+ # Declare a node. With a block it's a branch and the block declares its
140
+ # children; without one it's a leaf. See SupportDesk::Topic for every
141
+ # option (`about:`, `ask:`, `candidates:`, `subject:`, `prefill:`,
142
+ # `placeholder:`, `only:`, `priority:`, `route_to:`, `desk:`,
143
+ # `retired:`, `icon:`, `label:`).
144
+ def topic(key, **options, &block)
145
+ validate!(key, options)
146
+
147
+ node = Topic.new(key: key, parent: @parent, **normalize(options))
148
+ @tree.add(node, parent: @parent)
149
+ Builder.new(@tree, parent: node).instance_eval(&block) if block
150
+ node
151
+ end
152
+
153
+ # The free-form leaf ("Otra cosa"). `other false` removes it — the gem
154
+ # warns at boot when a tree ends up without one. A host that wants a
155
+ # differently named way out declares that leaf `free_form: true`.
156
+ def other(enabled = true, **options)
157
+ return @tree.remove(TopicTree::OTHER_KEY.to_s) unless enabled
158
+
159
+ topic(TopicTree::OTHER_KEY, subject: :none, free_form: true, **options)
160
+ end
161
+
162
+ private
163
+
164
+ KNOWN_OPTIONS = %i[
165
+ label about ask candidates subject prefill placeholder only priority route_to desk retired icon
166
+ free_form
167
+ ].freeze
168
+
169
+ def validate!(key, options)
170
+ unless key.is_a?(Symbol) || key.is_a?(String)
171
+ raise ConfigurationError, "topic keys must be symbols, got #{key.inspect}"
172
+ end
173
+
174
+ unknown = options.keys - KNOWN_OPTIONS
175
+ if unknown.any?
176
+ raise ConfigurationError,
177
+ "unknown topic option#{"s" if unknown.size > 1} #{unknown.map(&:inspect).join(", ")} " \
178
+ "on topic #{key.inspect} — known options are #{KNOWN_OPTIONS.map(&:inspect).join(", ")}"
179
+ end
180
+
181
+ if options.key?(:subject) && !Topic::SUBJECT_MODES.include?(options[:subject].to_sym)
182
+ raise ConfigurationError,
183
+ "topic #{key.inspect}: subject must be one of #{Topic::SUBJECT_MODES.inspect}, " \
184
+ "got #{options[:subject].inspect}"
185
+ end
186
+
187
+ if options.key?(:priority) && !Topic::PRIORITIES.key?(options[:priority].to_sym)
188
+ raise ConfigurationError,
189
+ "topic #{key.inspect}: priority must be one of #{Topic::PRIORITIES.keys.inspect}, " \
190
+ "got #{options[:priority].inspect}"
191
+ end
192
+
193
+ %i[candidates only prefill].each do |option|
194
+ next unless options.key?(option)
195
+ next if option == :prefill && options[option].is_a?(String)
196
+ next if options[option].respond_to?(:call)
197
+
198
+ raise ConfigurationError,
199
+ "topic #{key.inspect}: #{option} must respond to #call, got #{options[option].inspect}"
200
+ end
201
+ end
202
+
203
+ # Store `about:` as class NAMES so the tree survives Zeitwerk reloads
204
+ # and the initializer can name classes that haven't loaded yet.
205
+ def normalize(options)
206
+ options = options.dup
207
+ if options.key?(:about)
208
+ options[:about] = Array(options[:about]).map { |klass| klass.is_a?(Class) ? klass.name : klass.to_s }
209
+ end
210
+ options
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ VERSION = "0.1.0"
5
+ end