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.
- checksums.yaml +7 -0
- data/.rubocop.yml +32 -0
- data/.simplecov +53 -0
- data/Appraisals +18 -0
- data/CHANGELOG.md +135 -0
- data/LICENSE.txt +21 -0
- data/README.md +469 -0
- data/Rakefile +55 -0
- data/app/assets/stylesheets/support_desk.css +242 -0
- data/app/controllers/support_desk/application_controller.rb +92 -0
- data/app/controllers/support_desk/console/application_controller.rb +18 -0
- data/app/controllers/support_desk/console/tickets_controller.rb +26 -0
- data/app/controllers/support_desk/tickets_controller.rb +119 -0
- data/app/helpers/support_desk/engine_helper.rb +196 -0
- data/app/views/chats/slots/_inbox_top.html.erb +31 -0
- data/app/views/chats/slots/_locked_composer.html.erb +18 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +29 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +60 -0
- data/app/views/support_desk/console/tickets/_composer.html.erb +71 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +53 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +48 -0
- data/app/views/support_desk/console/tickets/_nav_badge.html.erb +19 -0
- data/app/views/support_desk/console/tickets/_tabs.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +56 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +37 -0
- data/app/views/support_desk/console/tickets/_transcript.html.erb +26 -0
- data/app/views/support_desk/console/tickets/index.html.erb +37 -0
- data/app/views/support_desk/console/tickets/show.html.erb +45 -0
- data/app/views/support_desk/tickets/_context_card.html.erb +14 -0
- data/app/views/support_desk/tickets/_door.html.erb +12 -0
- data/app/views/support_desk/tickets/_pick_thing.html.erb +55 -0
- data/app/views/support_desk/tickets/_pick_topic.html.erb +30 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +31 -0
- data/app/views/support_desk/tickets/_wizard_header.html.erb +23 -0
- data/app/views/support_desk/tickets/_write.html.erb +56 -0
- data/app/views/support_desk/tickets/index.html.erb +53 -0
- data/app/views/support_desk/tickets/new.html.erb +14 -0
- data/app/views/support_desk/tickets/rate_limited.html.erb +40 -0
- data/config/console_routes.rb +16 -0
- data/config/locales/support_desk.console.en.yml +93 -0
- data/config/locales/support_desk.console.es.yml +93 -0
- data/config/locales/support_desk.en.yml +79 -0
- data/config/locales/support_desk.es.yml +84 -0
- data/config/routes.rb +24 -0
- data/context7.json +4 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/gemfiles/rails_8.0.gemfile +34 -0
- data/gemfiles/rails_8.1.gemfile +34 -0
- data/lib/generators/support_desk/console_generator.rb +94 -0
- data/lib/generators/support_desk/install_generator.rb +86 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +43 -0
- data/lib/generators/support_desk/templates/console/resource.rb.erb +44 -0
- data/lib/generators/support_desk/templates/create_support_desk_tables.rb.erb +224 -0
- data/lib/generators/support_desk/templates/initializer.rb +186 -0
- data/lib/generators/support_desk/views_generator.rb +50 -0
- data/lib/support_desk/configuration.rb +675 -0
- data/lib/support_desk/console.rb +487 -0
- data/lib/support_desk/console_engine.rb +63 -0
- data/lib/support_desk/console_routes.rb +107 -0
- data/lib/support_desk/context_card.rb +90 -0
- data/lib/support_desk/current.rb +26 -0
- data/lib/support_desk/doctor.rb +220 -0
- data/lib/support_desk/engine.rb +141 -0
- data/lib/support_desk/errors.rb +49 -0
- data/lib/support_desk/events.rb +122 -0
- data/lib/support_desk/macros.rb +73 -0
- data/lib/support_desk/models/application_record.rb +11 -0
- data/lib/support_desk/models/assignment.rb +83 -0
- data/lib/support_desk/models/concerns/agent.rb +79 -0
- data/lib/support_desk/models/concerns/requester.rb +71 -0
- data/lib/support_desk/models/concerns/supportable.rb +88 -0
- data/lib/support_desk/models/desk.rb +101 -0
- data/lib/support_desk/models/event.rb +72 -0
- data/lib/support_desk/models/ticket.rb +1124 -0
- data/lib/support_desk/queue.rb +171 -0
- data/lib/support_desk/summary.rb +70 -0
- data/lib/support_desk/test_helpers.rb +137 -0
- data/lib/support_desk/timeline.rb +104 -0
- data/lib/support_desk/topic.rb +290 -0
- data/lib/support_desk/topic_tree.rb +214 -0
- data/lib/support_desk/version.rb +5 -0
- data/lib/support_desk/wizard.rb +392 -0
- data/lib/support_desk.rb +288 -0
- 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
|