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,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
|
data/lib/support_desk.rb
ADDED
|
@@ -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
|