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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # Everything an agent needs next to the transcript, as plain Ruby: what
5
+ # the case is about, what state that thing is in, who is asking, and
6
+ # where to go to do something about it.
7
+ #
8
+ # card = ticket.context_card
9
+ # card.title # "Viaje Sevilla → Granada · 20 sep"
10
+ # card.status # "Completado"
11
+ # card.pairs # { "Conductor" => "Lucía G.", "Plazas" => 3 }
12
+ # card.subject_url # "/madmin/rides/…"
13
+ # card.requester_name # "Alice"
14
+ #
15
+ # No view dependency at all — render it in ERB, a JSON API, or a Telegram
16
+ # message.
17
+ class ContextCard
18
+ attr_reader :ticket
19
+
20
+ # The card for one ticket.
21
+ def initialize(ticket)
22
+ @ticket = ticket
23
+ end
24
+
25
+ # What the case is about, and what to call it.
26
+ def subject = ticket.subject
27
+
28
+ # What to put at the top of the card.
29
+ def title = ticket.label
30
+
31
+ # The subject's own status pill, when it has one.
32
+ def status = subject&.support_status
33
+
34
+ # Key/value pairs the host chose to show agents.
35
+ def pairs
36
+ subject&.support_context || {}
37
+ end
38
+
39
+ # Where to open the subject in the host's admin, or nil.
40
+ def subject_url = subject&.support_url
41
+
42
+ # The topic, and the whole branch spelled out.
43
+ def topic = ticket.topic
44
+
45
+ # The whole branch spelled out ("Billing › Invoice").
46
+ def topic_label = ticket.topic&.full_label
47
+
48
+ # Who is asking, as the console should show them.
49
+ def requester = ticket.requester
50
+
51
+ # The requester as the console should show them: a display name, and
52
+ # an avatar if the host has one.
53
+ def requester_name
54
+ Chats.display_name_for(requester)
55
+ end
56
+
57
+ # Anything image_tag accepts, or nil.
58
+ def requester_avatar
59
+ Chats.avatar_for(requester)
60
+ end
61
+
62
+ # When this requester joined — context for "is this a new user?".
63
+ def requester_since = requester.try(:created_at)
64
+
65
+ # How many open cases this requester has right now, this one included.
66
+ def requester_open_tickets
67
+ Ticket.not_closed.where(requester: requester).count
68
+ end
69
+
70
+ # The same card as a Hash, for a JSON console.
71
+ def to_h
72
+ {
73
+ title: title,
74
+ status: status,
75
+ topic: topic&.path,
76
+ topic_label: topic_label,
77
+ pairs: pairs,
78
+ subject_url: subject_url,
79
+ requester: {
80
+ name: requester_name,
81
+ since: requester_since,
82
+ open_tickets: requester_open_tickets
83
+ }
84
+ }
85
+ end
86
+
87
+ # The card, in one line.
88
+ def inspect = "#<SupportDesk::ContextCard #{title.inspect}>"
89
+ end
90
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/current_attributes"
4
+
5
+ module SupportDesk
6
+ # The ambient actor for the current request or job. Every ticket transition
7
+ # takes `by:`; when it's omitted, this is where it falls back to — the user
8
+ # engine sets it from `current_requester`, the console concern from
9
+ # `current_agent`:
10
+ #
11
+ # SupportDesk::Current.actor = current_user
12
+ # ticket.close! # => by: current_user
13
+ #
14
+ # Nothing is set for you. When both `by:` and this are empty, transitions
15
+ # raise SupportDesk::ActorMissing rather than writing an unattributed row —
16
+ # a support timeline nobody signed is worth less than no timeline.
17
+ class Current < ActiveSupport::CurrentAttributes
18
+ # The person (or bot) acting: an agent in the console, a requester in the
19
+ # engine. `:system` is a legitimate value for jobs and sweeps.
20
+ attribute :actor
21
+
22
+ # The current ActionDispatch::Request, when there is one. Only ever used
23
+ # to enrich event payloads (IP, user agent) — never to authorize.
24
+ attribute :request
25
+ end
26
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # Everything that can only be checked against a running app: the
5
+ # configuration the initializer wrote, the chats seams the gem is built
6
+ # on, and the invariants the data is supposed to keep.
7
+ #
8
+ # SupportDesk.doctor.print # in a console
9
+ # exit 1 unless SupportDesk.doctor.ok? # in CI
10
+ #
11
+ # Checks never raise: a doctor that blows up is a doctor nobody runs.
12
+ class Doctor
13
+ # One finding. :ok, :warn (worth fixing, nothing is broken) or :fail
14
+ # (support is not working right now).
15
+ Check = Struct.new(:name, :status, :message, keyword_init: true) do
16
+ def ok? = status == :ok
17
+ # Worth fixing, and actually broken.
18
+ def warn? = status == :warn
19
+ def fail? = status == :fail
20
+
21
+ # "✓ name message" — one finding, printable.
22
+ def to_s
23
+ icon = { ok: "✓", warn: "!", fail: "✗" }.fetch(status)
24
+ [ icon, name, message ].compact.join(" ")
25
+ end
26
+ end
27
+
28
+ # What `SupportDesk.doctor` hands back.
29
+ class Report
30
+ attr_reader :checks
31
+
32
+ # A report over the checks that were run.
33
+ def initialize(checks)
34
+ @checks = checks
35
+ end
36
+
37
+ # True when nothing failed. Warnings don't fail a build.
38
+ def ok? = failures.empty?
39
+
40
+ # The checks worth acting on: failures break support, warnings don't.
41
+ def failures = checks.select(&:fail?)
42
+ def warnings = checks.select(&:warn?)
43
+
44
+ # Every check, one per line, with the verdict last.
45
+ def to_s
46
+ lines = checks.map(&:to_s)
47
+ lines << (ok? ? "support_desk is healthy (#{warnings.size} warning(s))" : "#{failures.size} check(s) failed")
48
+ lines.join("\n")
49
+ end
50
+
51
+ # Print the report and return whether it passed — the one line to
52
+ # put in a CI step.
53
+ def print(io = $stdout)
54
+ io.puts(to_s)
55
+ ok?
56
+ end
57
+
58
+ # The verdict, in one line.
59
+ def inspect = "#<SupportDesk::Doctor::Report #{ok? ? "ok" : "#{failures.size} failed"}>"
60
+ end
61
+
62
+ # Run every check and hand back the report.
63
+ def self.run = new.run
64
+
65
+ # Run every check and hand back the report.
66
+ def run
67
+ checks = []
68
+ checks.concat(configuration_checks)
69
+ checks.concat(seam_checks)
70
+ checks.concat(invariant_checks)
71
+ Report.new(checks)
72
+ end
73
+
74
+ private
75
+
76
+ def configuration_checks
77
+ checks = []
78
+
79
+ checks << check("requester_class") do
80
+ klass = SupportDesk.config.requester_class.safe_constantize
81
+ next fail_with("#{SupportDesk.config.requester_class} doesn't exist") unless klass
82
+ unless klass.respond_to?(:support_desk_requester_options)
83
+ next fail_with("#{klass} is missing `has_support_tickets`")
84
+ end
85
+ next fail_with("#{klass} is missing `acts_as_messager` (chats)") unless klass.include?(Chats::Messager)
86
+
87
+ ok_with("#{klass} asks for support")
88
+ end
89
+
90
+ checks << check("agents") do
91
+ pool = SupportDesk.config.default_desk.agent_pool
92
+ next warn_with("no agent pool configured — set `config.agents { User.admin }`") if pool.nil?
93
+
94
+ ok_with("#{pool.respond_to?(:count) ? pool.count : pool.size} agent(s)")
95
+ end
96
+
97
+ SupportDesk.config.desks.each_value do |desk|
98
+ checks << check("topics (#{desk.key})") do
99
+ tree = desk.topics
100
+ next warn_with("no topics configured — every ticket lands on the free-form leaf") if tree.empty?
101
+ next warn_with("no free-form topic: add `other` to the topics block") unless tree.free_form?
102
+
103
+ ok_with("#{tree.count} topic(s), #{tree.leaves.size} leaf/leaves")
104
+ end
105
+
106
+ checks << check("supportables (#{desk.key})") do
107
+ missing = desk.topics.about_class_names.reject do |name|
108
+ klass = name.safe_constantize
109
+ klass.respond_to?(:supportable?) && klass.supportable?
110
+ end
111
+ next fail_with("not supportable: #{missing.join(", ")}") if missing.any?
112
+
113
+ ok_with("every about: class is supportable")
114
+ end
115
+ end
116
+
117
+ checks << check("engine mount") do
118
+ path = SupportDesk.root_path
119
+ next warn_with("SupportDesk::Engine isn't mounted — requesters have nowhere to write") if path.nil?
120
+
121
+ ok_with("mounted at #{path}")
122
+ end
123
+
124
+ checks << check("parent controllers") do
125
+ missing = [ SupportDesk.config.parent_controller, SupportDesk.config.console_parent_controller ].reject do |name|
126
+ name.safe_constantize
127
+ end
128
+ next fail_with("#{missing.join(", ")} doesn't exist") if missing.any?
129
+
130
+ ok_with("resolved")
131
+ end
132
+
133
+ checks
134
+ end
135
+
136
+ # The chats seams this gem is built on. A host on the wrong chats
137
+ # version should hear it from `doctor`, not from a NoMethodError in
138
+ # production.
139
+ def seam_checks
140
+ [
141
+ check("chats subscribers") do
142
+ next fail_with("this chats doesn't expose `Chats.on` — support_desk needs chats ~> 0.2") unless
143
+ Chats.respond_to?(:on)
144
+
145
+ ok_with("Chats.on available")
146
+ end,
147
+ check("chats authorship") do
148
+ next fail_with("chats_messages has no author column — run `rails g chats:upgrade`") unless
149
+ Chats::Message.column_names.include?("author_id")
150
+
151
+ ok_with("messages can be signed")
152
+ end,
153
+ check("desk messager") do
154
+ next fail_with("SupportDesk::Desk isn't a chats messager") unless Desk.include?(Chats::Messager)
155
+
156
+ ok_with("the desk converses")
157
+ end
158
+ ]
159
+ end
160
+
161
+ def invariant_checks
162
+ return [ check("database") { warn_with("support_desk tables are missing — run rails db:migrate") } ] unless
163
+ tables?
164
+
165
+ [
166
+ check("conversations") do
167
+ orphans = Ticket.where(conversation_id: nil).count
168
+ next fail_with("#{orphans} ticket(s) without a conversation") if orphans.positive?
169
+
170
+ ok_with("every ticket has one")
171
+ end,
172
+ check("assignments") do
173
+ duplicated = Assignment.open.group(:ticket_id).having("COUNT(*) > 1").count.size
174
+ next fail_with("#{duplicated} ticket(s) with more than one open assignment") if duplicated.positive?
175
+
176
+ ok_with("at most one open assignment per ticket")
177
+ end,
178
+ check("assignee pointers") do
179
+ # A CLOSED ticket keeps its assignee with no open assignment row:
180
+ # that is the record of who dealt with it, not a live seat.
181
+ mismatched = Ticket.not_closed.assigned.where.not(
182
+ id: Assignment.open.select(:ticket_id)
183
+ ).count
184
+ next fail_with("#{mismatched} ticket(s) whose assignee has no open assignment") if mismatched.positive?
185
+
186
+ ok_with("assignee matches the open assignment")
187
+ end,
188
+ check("awaiting") do
189
+ stale = Ticket.awaiting_reply.where("last_agent_message_at > last_requester_message_at").count
190
+ next fail_with("#{stale} ticket(s) waiting on the desk after the desk already answered") if stale.positive?
191
+
192
+ ok_with("awaiting agrees with the transcript")
193
+ end,
194
+ check("references") do
195
+ duplicated = Ticket.group(:reference).having("COUNT(*) > 1").count.size
196
+ next fail_with("#{duplicated} duplicated reference(s)") if duplicated.positive?
197
+
198
+ ok_with("references are unique")
199
+ end
200
+ ]
201
+ end
202
+
203
+ def tables?
204
+ ActiveRecord::Base.connection.table_exists?(Ticket.table_name)
205
+ rescue StandardError
206
+ false
207
+ end
208
+
209
+ def check(name)
210
+ status, message = yield
211
+ Check.new(name: name, status: status, message: message)
212
+ rescue StandardError => e
213
+ Check.new(name: name, status: :fail, message: "#{e.class}: #{e.message}")
214
+ end
215
+
216
+ def ok_with(message) = [ :ok, message ]
217
+ def warn_with(message) = [ :warn, message ]
218
+ def fail_with(message) = [ :fail, message ]
219
+ end
220
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module SupportDesk
6
+ # The mountable engine: wires autoloading, migrations, locales, the
7
+ # ActiveRecord macros, and boot-time configuration into the host app.
8
+ #
9
+ # mount SupportDesk::Engine => "/support"
10
+ #
11
+ # The requester-facing screens live here; the agent console is BYOUI
12
+ # (query objects, presenters and a generator — see the README).
13
+ class Engine < ::Rails::Engine
14
+ isolate_namespace SupportDesk
15
+
16
+ # -------------------------------------------------------------------------
17
+ # Zeitwerk: the gem keeps its ActiveRecord models under
18
+ # lib/support_desk/models (the same layout as chats and moderate) so the
19
+ # whole domain ships in lib/ and the engine's app/ tree only holds the
20
+ # web layer. For that to autoload we manage the loader by hand:
21
+ #
22
+ # - push_dir(lib/support_desk, namespace: SupportDesk) makes
23
+ # lib/support_desk/models/... autoloadable under the SupportDesk
24
+ # namespace.
25
+ # - collapse(models) + collapse(models/concerns) mean those files
26
+ # define SupportDesk::Ticket, not SupportDesk::Models::Ticket.
27
+ # - The SPINE files are required explicitly by lib/support_desk.rb at
28
+ # boot (the configuration DSL has to exist before any initializer
29
+ # runs), so they must be IGNORED by the loader or Zeitwerk would
30
+ # complain about unmanaged constants.
31
+ # -------------------------------------------------------------------------
32
+ LIB_ROOT = File.expand_path("..", __dir__)
33
+ SUPPORT_DESK_LIB = File.expand_path("support_desk", LIB_ROOT)
34
+
35
+ ZEITWERK_IGNORED = %w[
36
+ version.rb errors.rb events.rb topic.rb topic_tree.rb configuration.rb current.rb macros.rb engine.rb
37
+ console.rb console_routes.rb console_engine.rb
38
+ ].freeze
39
+
40
+ initializer "support_desk.autoload", before: :set_autoload_paths do
41
+ loader = Rails.autoloaders.main
42
+
43
+ ZEITWERK_IGNORED.each do |file|
44
+ path = File.join(SUPPORT_DESK_LIB, file)
45
+ loader.ignore(path) if File.exist?(path)
46
+ end
47
+
48
+ %w[models models/concerns].each do |dir|
49
+ path = File.join(SUPPORT_DESK_LIB, dir)
50
+ loader.collapse(path) if File.directory?(path)
51
+ end
52
+
53
+ loader.push_dir(SUPPORT_DESK_LIB, namespace: SupportDesk)
54
+ end
55
+
56
+ config.eager_load_paths << SUPPORT_DESK_LIB
57
+
58
+ # Make the gem's migrations runnable from the host without copying. The
59
+ # install generator still copies a host-owned migration, which is the
60
+ # recommended path; this mainly serves the dummy app.
61
+ initializer "support_desk.migrations" do |app|
62
+ unless app.root.to_s == root.to_s
63
+ config.paths["db/migrate"].expanded.each do |path|
64
+ app.config.paths["db/migrate"] << path
65
+ end
66
+ end
67
+ end
68
+
69
+ # Expose `has_support_tickets` / `supportable` / `acts_as_support_agent`
70
+ # on every AR model.
71
+ initializer "support_desk.active_record" do
72
+ ActiveSupport.on_load(:active_record) do
73
+ extend SupportDesk::Macros
74
+ end
75
+ end
76
+
77
+ # Serve the bundled stylesheet the requester-facing views link (propshaft
78
+ # or sprockets — both honour config.assets.paths). A host that ejects and
79
+ # restyles the views simply stops rendering `support_desk_styles`.
80
+ initializer "support_desk.assets" do |app|
81
+ if app.config.respond_to?(:assets)
82
+ app.config.assets.paths << root.join("app/assets/stylesheets")
83
+ # Sprockets compiles only what is declared; without this the host
84
+ # 404s the stylesheet in production while Propshaft (which serves
85
+ # everything on the path) works fine, so the gap only shows up on
86
+ # somebody else's deploy.
87
+ app.config.assets.precompile << "support_desk.css" if app.config.assets.respond_to?(:precompile)
88
+ end
89
+ end
90
+
91
+ # The gem's locale files (en, es) ship through Rails::Engine's own
92
+ # :add_locales initializer, which picks up every engine's config/locales
93
+ # automatically — and deliberately NOT through a manual
94
+ # `app.config.i18n.load_path +=` on top of it.
95
+ #
96
+ # That manual append is not merely redundant, it INVERTS the contract.
97
+ # Railtie paths are unshifted ahead of everything in load_path, so an
98
+ # appended copy of these files lands AFTER the host's own locales and
99
+ # silently overrides them: a host that rewords `support_desk.queue.tabs
100
+ # .awaiting` in its own es.yml would keep reading ours. Measured before
101
+ # this was removed: the gem's file sat in load_path 14 times and the
102
+ # host's override lost.
103
+ #
104
+ # Gem first, host last. `clickwrap` learned this the same way and its
105
+ # engine carries the same note.
106
+
107
+ # Keep `awaiting`, the SLA clocks and reopen-on-reply true by listening
108
+ # to chats. One subscriber, registered once, for every channel a message
109
+ # can arrive through.
110
+ initializer "support_desk.chats_subscribers" do
111
+ SupportDesk.subscribe_to_chats!
112
+ end
113
+
114
+ # The checks that need the host's own classes loaded — the requester
115
+ # class, and every `about:` class named in a topic tree. In to_prepare
116
+ # (not an initializer) so they re-run after every code reload, which is
117
+ # exactly when a model stops being `supportable`.
118
+ config.to_prepare do
119
+ # Touch the helper so its bottom-of-file on_load(:action_view) hook
120
+ # registers even when no engine code has been referenced yet. Without
121
+ # this, `link_to_support` is undefined in every host that does not
122
+ # eager load — which is every host in development.
123
+ # (Assigned to appease Lint/Void — the constant REFERENCE is the point.)
124
+ _loaded = SupportDesk::EngineHelper
125
+
126
+ # Touch the desk for the same reason, on the CHATS side. `acts_as_messager`
127
+ # registers a class with chats when that class LOADS, and `Chats::Inbox`
128
+ # reads that registry (`Chats.grouped_messager_types`) to decide which
129
+ # messager types fold into one inbox row. Under lazy autoloading nothing
130
+ # has referenced SupportDesk::Desk by the time a requester opens their
131
+ # inbox, so the registry is empty, the stacking prefilter matches nothing,
132
+ # and every support conversation renders as its own row — the exact noise
133
+ # the grouped row exists to prevent. Eager-loading hosts (production)
134
+ # never see it; development does, and "works in prod, wrong locally" is
135
+ # the worst shape for a bug. Found by the CarHey integration (#2).
136
+ _desk = SupportDesk::Desk
137
+
138
+ SupportDesk.config.validate_classes! if SupportDesk.configured?
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # Base class for every error this gem raises, so hosts can
5
+ # `rescue SupportDesk::Error` to catch anything support-specific.
6
+ class Error < StandardError; end
7
+
8
+ # Raised by `SupportDesk.configure`, by the validating setters, and by the
9
+ # boot-time checks when the configuration can't work as written.
10
+ class ConfigurationError < Error; end
11
+
12
+ # Raised when a transition was given no `by:` and nothing set
13
+ # `SupportDesk::Current.actor`. Pass `by: :system` from jobs.
14
+ class ActorMissing < Error; end
15
+
16
+ # Raised when the record handed to an agent-side operation isn't an eligible
17
+ # agent (no `acts_as_support_agent`, or its `if:` said no).
18
+ class NotAnAgent < Error; end
19
+
20
+ # Raised by `hand_off!` when the actor doesn't currently hold the ticket.
21
+ class NotTheAssignee < Error; end
22
+
23
+ # Raised when policy forbids the attempted action — a drop-in reply under
24
+ # `reply_policy = :assignee_only`, a requester acting on someone else's
25
+ # ticket, a subject the requester may not talk about.
26
+ class NotAllowed < Error; end
27
+
28
+ # Raised when a transition can't happen from the ticket's current state
29
+ # (releasing a closed ticket, assigning one).
30
+ class InvalidTransition < Error; end
31
+
32
+ # Raised when the conversation behind a ticket is locked for writing —
33
+ # a closed ticket on a desk configured `closed_tickets: :locked`.
34
+ # A subclass of InvalidTransition, so `rescue InvalidTransition` still
35
+ # catches it and the specific name is there when you want it.
36
+ class Locked < InvalidTransition; end
37
+
38
+ # Raised when a topic path isn't in the desk's tree.
39
+ class UnknownTopic < Error; end
40
+
41
+ # Raised when a record was offered as a ticket subject without `supportable`.
42
+ class NotSupportable < Error; end
43
+
44
+ # Raised when a requester trips `config.open_rate_limit`.
45
+ class RateLimited < Error; end
46
+
47
+ # Raised when a requester is already at `config.max_open_tickets`.
48
+ class TooManyOpenTickets < Error; end
49
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # The gem emits; the host delivers. Every domain moment goes through one
5
+ # multi-subscriber, error-isolated dispatcher:
6
+ #
7
+ # SupportDesk.on(:ticket_opened) { |ticket| Support::TicketNotifier.deliver(ticket.agents_to_notify) }
8
+ # SupportDesk.on(:requester_replied) { |ticket, m| … }
9
+ # SupportDesk.on(:ticket_transitioned) { |ticket, kind, by:, request:, payload:| AuditLog.log(…) }
10
+ #
11
+ # Three guarantees worth knowing:
12
+ #
13
+ # 1. **After commit.** Events fire once the transition they describe is
14
+ # durable, so a subscriber never reads uncommitted state and never
15
+ # enqueues a job that races the write.
16
+ # 2. **Isolated.** A subscriber that raises is reported through
17
+ # `Rails.error` and the next subscriber still runs. A broken notifier
18
+ # can't roll back a ticket.
19
+ # 3. **Mirrored.** Every event is also published on ActiveSupport
20
+ # ::Notifications as `"<event>.support_desk"` for APM and hosts that
21
+ # prefer that bus.
22
+ module Events
23
+ # The catalogue. Keys are event names; values document the arguments
24
+ # subscribers receive (see 09-events-and-notifications).
25
+ CATALOGUE = {
26
+ ticket_opened: "ticket",
27
+ requester_replied: "ticket, message",
28
+ agent_replied: "ticket, message",
29
+ ticket_assigned: "ticket, assignment",
30
+ ticket_handed_off: "ticket, assignment, from:, note:",
31
+ ticket_released: "ticket, from:, reason:",
32
+ ticket_closed: "ticket, by:",
33
+ ticket_reopened: "ticket, by:",
34
+ ticket_topic_changed: "ticket, from:, to:, by:",
35
+ subject_attached: "ticket, subject, by:",
36
+ note_added: "ticket, event",
37
+ ticket_transitioned: "ticket, kind, by:, request:, payload:"
38
+ }.freeze
39
+
40
+ # One registered callable, and the key it can be replaced under.
41
+ Subscriber = Struct.new(:key, :callable) do
42
+ def call(*args, **kwargs) = callable.call(*args, **kwargs)
43
+ end
44
+
45
+ # Subscribe to an event. Multiple subscribers per event are the point;
46
+ # they run in registration order and never see each other's exceptions.
47
+ # Returns the block, so a host can keep the handle.
48
+ #
49
+ # Pass `key:` from anywhere that runs more than once — a `to_prepare`
50
+ # block, an engine initializer — and re-registering REPLACES that
51
+ # subscriber in place instead of stacking a second copy on every code
52
+ # reload.
53
+ def on(event, key: nil, &block)
54
+ event = event.to_sym
55
+ unless CATALOGUE.key?(event)
56
+ raise ConfigurationError,
57
+ "unknown event #{event.inspect} — support_desk emits #{CATALOGUE.keys.map(&:inspect).join(", ")}"
58
+ end
59
+ raise ConfigurationError, "SupportDesk.on(#{event.inspect}) needs a block" unless block
60
+
61
+ entry = Subscriber.new(key, block)
62
+ list = subscribers[event]
63
+ existing = key && list.index { |subscriber| subscriber.key == key }
64
+ existing ? list[existing] = entry : list << entry
65
+ block
66
+ end
67
+
68
+ # Drop a keyed subscriber. Mostly for tests and for hosts that wire
69
+ # support up and down around a block.
70
+ def off(event, key)
71
+ subscribers[event.to_sym].reject! { |subscriber| subscriber.key == key }
72
+ self
73
+ end
74
+
75
+ # Everything registered, as { event => [block, …] }. Mutable on purpose:
76
+ # `reset!` empties it between tests.
77
+ def subscribers
78
+ @subscribers ||= Hash.new { |hash, key| hash[key] = [] }
79
+ end
80
+
81
+ # Fire +event+ now. Internal — the models call this; hosts subscribe.
82
+ def emit(event, *args, **kwargs) # :nodoc:
83
+ event = event.to_sym
84
+
85
+ instrument(event, *args, **kwargs)
86
+
87
+ subscribers[event].each do |subscriber|
88
+ subscriber.call(*args, **kwargs)
89
+ rescue StandardError => e
90
+ report_subscriber_error(e, event)
91
+ end
92
+
93
+ nil
94
+ end
95
+
96
+ # Fire +event+ once the surrounding transaction commits (immediately when
97
+ # there is none). Every transition emits through here.
98
+ def emit_after_commit(event, *args, **kwargs) # :nodoc:
99
+ if defined?(ActiveRecord) && ActiveRecord.respond_to?(:after_all_transactions_commit)
100
+ ActiveRecord.after_all_transactions_commit { emit(event, *args, **kwargs) }
101
+ else
102
+ emit(event, *args, **kwargs)
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def instrument(event, *args, **kwargs)
109
+ return unless defined?(ActiveSupport::Notifications)
110
+
111
+ ActiveSupport::Notifications.instrument("#{event}.support_desk", args: args, **kwargs)
112
+ end
113
+
114
+ def report_subscriber_error(error, event)
115
+ if defined?(Rails) && Rails.respond_to?(:error) && Rails.error
116
+ Rails.error.report(error, handled: true, source: "support_desk", context: { event: event })
117
+ else
118
+ logger&.error("[support_desk] subscriber raised on #{event}: #{error.class}: #{error.message}")
119
+ end
120
+ end
121
+ end
122
+ end