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,675 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/module/delegation"
|
|
4
|
+
require_relative "topic_tree"
|
|
5
|
+
|
|
6
|
+
module SupportDesk
|
|
7
|
+
# Everything a desk decides, with defaults that already work.
|
|
8
|
+
#
|
|
9
|
+
# SupportDesk.configure do |config|
|
|
10
|
+
# config.name = "Soporte CarHey"
|
|
11
|
+
# config.agents { User.admin }
|
|
12
|
+
# config.topics do
|
|
13
|
+
# topic :ride, about: Ride
|
|
14
|
+
# other
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Two rules shared with the rest of the gem ecosystem:
|
|
19
|
+
#
|
|
20
|
+
# 1. Class names are stored as STRINGS and constantized lazily, so the
|
|
21
|
+
# initializer can name app classes before they load and everything
|
|
22
|
+
# survives Zeitwerk reloads.
|
|
23
|
+
# 2. Setters validate ON ASSIGNMENT and raise SupportDesk::ConfigurationError
|
|
24
|
+
# with the fix in the message — a configuration mistake is a boot
|
|
25
|
+
# failure, never a 3am NoMethodError.
|
|
26
|
+
#
|
|
27
|
+
# == Desks
|
|
28
|
+
#
|
|
29
|
+
# Most apps have one desk and never think about it: the top-level setters
|
|
30
|
+
# configure the `:default` desk. Apps with more say so explicitly, and any
|
|
31
|
+
# setting a desk doesn't state falls back to the default desk's:
|
|
32
|
+
#
|
|
33
|
+
# config.desk :billing do |desk|
|
|
34
|
+
# desk.name = "Facturación"
|
|
35
|
+
# desk.reply_within = 8.hours
|
|
36
|
+
# end
|
|
37
|
+
class Configuration
|
|
38
|
+
# Everything a single desk decides. A desk reads its own value when it
|
|
39
|
+
# has one and the default desk's otherwise, so `config.reply_within =
|
|
40
|
+
# 24.hours` at the top level really does mean "every desk, unless it
|
|
41
|
+
# says otherwise".
|
|
42
|
+
class DeskConfiguration
|
|
43
|
+
REPLY_POLICIES = %i[anyone take_over assignee_only].freeze
|
|
44
|
+
ANNOUNCE_MODES = %i[always first_only never].freeze
|
|
45
|
+
CLOSED_TICKET_MODES = %i[reopen_on_reply locked].freeze
|
|
46
|
+
INBOX_ENTRY_MODES = %i[always when_tickets never].freeze
|
|
47
|
+
MIRROR_MODES = %i[always when_away never].freeze
|
|
48
|
+
ROUTING_STRATEGIES = %i[manual round_robin least_loaded].freeze
|
|
49
|
+
# Strategies that need the 0.3 duty/capacity tables; naming one now
|
|
50
|
+
# fails at boot instead of silently leaving tickets unassigned.
|
|
51
|
+
UNRELEASED_ROUTING_STRATEGIES = %i[round_robin least_loaded].freeze
|
|
52
|
+
|
|
53
|
+
DEFAULTS = {
|
|
54
|
+
name: nil,
|
|
55
|
+
avatar: nil,
|
|
56
|
+
email: nil,
|
|
57
|
+
reply_policy: :anyone,
|
|
58
|
+
announce_assignments: :first_only,
|
|
59
|
+
closed_tickets: :reopen_on_reply,
|
|
60
|
+
reply_within: 24 * 60 * 60,
|
|
61
|
+
at_risk_after: 4 * 60 * 60,
|
|
62
|
+
open_rate_limit: { to: 5, within: 60 * 60 },
|
|
63
|
+
max_open_tickets: 5,
|
|
64
|
+
inbox_entry: :always,
|
|
65
|
+
routing: :manual,
|
|
66
|
+
mirror_replies_by_email: :when_away,
|
|
67
|
+
auto_close_after: nil
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
70
|
+
attr_reader :key, :fallback
|
|
71
|
+
|
|
72
|
+
# A desk's settings, falling back to +fallback+ for anything it
|
|
73
|
+
# doesn't state (the default desk, for every desk but itself).
|
|
74
|
+
def initialize(key, fallback: nil)
|
|
75
|
+
@key = key.to_sym
|
|
76
|
+
@fallback = fallback
|
|
77
|
+
@settings = {}
|
|
78
|
+
@topics_block = nil
|
|
79
|
+
@topics = nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# --- The desk's identity --------------------------------------------------
|
|
83
|
+
|
|
84
|
+
# What requesters see as the counterpart in their inbox.
|
|
85
|
+
def name = read(:name) || key.to_s.humanize
|
|
86
|
+
|
|
87
|
+
# nil un-sets the name, so the desk goes back to inheriting it (or to
|
|
88
|
+
# its humanized key). A blank string is a mistake, not an intention.
|
|
89
|
+
def name=(value)
|
|
90
|
+
return @settings.delete(:name) if value.nil?
|
|
91
|
+
|
|
92
|
+
@settings[:name] = ensure_present_string(value, "name")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# An asset path, a URL, or ->(desk) { … }. Anything `image_tag` accepts.
|
|
96
|
+
def avatar = read(:avatar)
|
|
97
|
+
|
|
98
|
+
# Set it, validating on assignment (see the reader above).
|
|
99
|
+
def avatar=(value)
|
|
100
|
+
unless value.nil? || value.is_a?(String) || value.respond_to?(:call)
|
|
101
|
+
raise ConfigurationError, "avatar must be a String, a callable, or nil, got #{value.inspect}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@settings[:avatar] = value
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The address the email channel answers from (0.2).
|
|
108
|
+
def email = read(:email)
|
|
109
|
+
|
|
110
|
+
# Set it, validating on assignment (see the reader above).
|
|
111
|
+
def email=(value)
|
|
112
|
+
if value && !value.to_s.include?("@")
|
|
113
|
+
raise ConfigurationError, "email must be an email address, got #{value.inspect}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
@settings[:email] = value&.to_s
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# --- Who answers ----------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# The agent pool: notified while a ticket is unassigned, offered in the
|
|
122
|
+
# "assign to" picker, and what routing chooses from.
|
|
123
|
+
#
|
|
124
|
+
# config.agents { User.admin }
|
|
125
|
+
# config.agents = -> { User.where(support: true) }
|
|
126
|
+
def agents(&block)
|
|
127
|
+
return @settings[:agents] = block if block
|
|
128
|
+
|
|
129
|
+
read(:agents)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Set it, validating on assignment (see the reader above).
|
|
133
|
+
def agents=(value)
|
|
134
|
+
@settings[:agents] = ensure_callable(value, "agents")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The pool, resolved. Raises ConfigurationError when the host's block
|
|
138
|
+
# hands back something that isn't a collection of records.
|
|
139
|
+
def agent_pool
|
|
140
|
+
callable = agents
|
|
141
|
+
return [] unless callable
|
|
142
|
+
|
|
143
|
+
result = callable.call
|
|
144
|
+
unless result.respond_to?(:each) || result.respond_to?(:to_a)
|
|
145
|
+
raise ConfigurationError,
|
|
146
|
+
"config.agents must return a relation or an array of agents, got #{result.inspect}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
result
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# --- Topics ---------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
# The desk's topic tree. With a block, remembers it; with none, builds
|
|
155
|
+
# it (once) and hands it back.
|
|
156
|
+
#
|
|
157
|
+
# The block is kept rather than run immediately ON PURPOSE: an
|
|
158
|
+
# initializer runs before the host's own classes are autoloadable, and
|
|
159
|
+
# `topic :ride, about: Ride` has to keep reading like that. The tree is
|
|
160
|
+
# built at the first prepare — still boot, so a malformed tree is still
|
|
161
|
+
# a boot failure — and it stores class NAMES, so it survives reloads.
|
|
162
|
+
def topics(&block)
|
|
163
|
+
if block
|
|
164
|
+
@topics_block = block
|
|
165
|
+
@topics = nil
|
|
166
|
+
return block
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
@topics ||= if @topics_block
|
|
170
|
+
TopicTree.build(&@topics_block)
|
|
171
|
+
elsif fallback
|
|
172
|
+
fallback.topics
|
|
173
|
+
else
|
|
174
|
+
TopicTree.build
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Whether this desk declared a tree of its own.
|
|
179
|
+
def own_topics? = !@topics_block.nil?
|
|
180
|
+
|
|
181
|
+
# --- Behaviour ------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
# Who may reply to a ticket somebody else holds: :anyone (the reply
|
|
184
|
+
# posts, signed by the drop-in), :take_over (replying reassigns), or
|
|
185
|
+
# :assignee_only (raises NotAllowed).
|
|
186
|
+
def reply_policy = read(:reply_policy)
|
|
187
|
+
|
|
188
|
+
# Set it, validating on assignment (see the reader above).
|
|
189
|
+
def reply_policy=(value)
|
|
190
|
+
@settings[:reply_policy] = ensure_one_of(value, REPLY_POLICIES, "reply_policy")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Whether the requester is told who picked up their ticket: :first_only
|
|
194
|
+
# (the first human to take it), :always (hand-offs too), or :never.
|
|
195
|
+
def announce_assignments = read(:announce_assignments)
|
|
196
|
+
|
|
197
|
+
# Set it, validating on assignment (see the reader above).
|
|
198
|
+
def announce_assignments=(value)
|
|
199
|
+
@settings[:announce_assignments] = ensure_one_of(value, ANNOUNCE_MODES, "announce_assignments")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# What a requester writing into a closed ticket does: :reopen_on_reply
|
|
203
|
+
# (the ticket comes back) or :locked (the composer is replaced by a
|
|
204
|
+
# notice).
|
|
205
|
+
def closed_tickets = read(:closed_tickets)
|
|
206
|
+
|
|
207
|
+
# Set it, validating on assignment (see the reader above).
|
|
208
|
+
def closed_tickets=(value)
|
|
209
|
+
@settings[:closed_tickets] = ensure_one_of(value, CLOSED_TICKET_MODES, "closed_tickets")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The answer promise: the SLA breach threshold AND the "normalmente en
|
|
213
|
+
# menos de 24 h" line the requester is shown. One setting, one truth.
|
|
214
|
+
def reply_within = duration(read(:reply_within))
|
|
215
|
+
|
|
216
|
+
# Set it, validating on assignment (see the reader above).
|
|
217
|
+
def reply_within=(value)
|
|
218
|
+
@settings[:reply_within] = ensure_duration(value, "reply_within")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# When a waiting ticket starts showing as at risk, short of breach.
|
|
222
|
+
def at_risk_after = duration(read(:at_risk_after))
|
|
223
|
+
|
|
224
|
+
# Set it, validating on assignment (see the reader above).
|
|
225
|
+
def at_risk_after=(value)
|
|
226
|
+
@settings[:at_risk_after] = ensure_duration(value, "at_risk_after")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# `{ to: 5, within: 1.hour }` — how often one requester may open
|
|
230
|
+
# tickets. nil disables it.
|
|
231
|
+
def open_rate_limit = read(:open_rate_limit)
|
|
232
|
+
|
|
233
|
+
# Set it, validating on assignment (see the reader above).
|
|
234
|
+
def open_rate_limit=(value)
|
|
235
|
+
if value.nil?
|
|
236
|
+
@settings[:open_rate_limit] = nil
|
|
237
|
+
return
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
hash = value.to_h.symbolize_keys
|
|
241
|
+
unless hash[:to].is_a?(Integer) && hash[:to].positive? && hash[:within].respond_to?(:to_i)
|
|
242
|
+
raise ConfigurationError,
|
|
243
|
+
"open_rate_limit must be nil or { to: Integer, within: duration }, got #{value.inspect}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
@settings[:open_rate_limit] = hash
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# How many tickets one requester may have open at once. nil for no
|
|
250
|
+
# cap. Advisory: checked before the insert, not under a lock, so a
|
|
251
|
+
# burst of concurrent opens can leave a requester one over.
|
|
252
|
+
def max_open_tickets = read(:max_open_tickets)
|
|
253
|
+
|
|
254
|
+
# Set it, validating on assignment (see the reader above).
|
|
255
|
+
def max_open_tickets=(value)
|
|
256
|
+
unless value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
257
|
+
raise ConfigurationError, "max_open_tickets must be a positive Integer or nil, got #{value.inspect}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
@settings[:max_open_tickets] = value
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Whether the desk shows up in the requester's inbox before they have
|
|
264
|
+
# ever written: :always (a "¿Necesitas ayuda?" door), :when_tickets,
|
|
265
|
+
# or :never.
|
|
266
|
+
def inbox_entry = read(:inbox_entry)
|
|
267
|
+
|
|
268
|
+
# Set it, validating on assignment (see the reader above).
|
|
269
|
+
def inbox_entry=(value)
|
|
270
|
+
@settings[:inbox_entry] = ensure_one_of(value, INBOX_ENTRY_MODES, "inbox_entry")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# How new tickets find an agent. 0.1 ships :manual (unassigned, pool
|
|
274
|
+
# notified, first take wins) and procs; the load-aware strategies
|
|
275
|
+
# arrive with the duty table in 0.3.
|
|
276
|
+
def routing = read(:routing)
|
|
277
|
+
|
|
278
|
+
# Set it, validating on assignment (see the reader above).
|
|
279
|
+
def routing=(value)
|
|
280
|
+
if value.respond_to?(:call)
|
|
281
|
+
@settings[:routing] = value
|
|
282
|
+
return
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
strategy = ensure_one_of(value, ROUTING_STRATEGIES, "routing")
|
|
286
|
+
if UNRELEASED_ROUTING_STRATEGIES.include?(strategy)
|
|
287
|
+
raise ConfigurationError,
|
|
288
|
+
"routing #{strategy.inspect} needs the duty and capacity tables that ship in support_desk 0.3 — " \
|
|
289
|
+
"use :manual, or a ->(ticket) { agent } proc"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
@settings[:routing] = strategy
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Whether agent replies are also emailed to the requester (0.2).
|
|
296
|
+
def mirror_replies_by_email = read(:mirror_replies_by_email)
|
|
297
|
+
|
|
298
|
+
# Set it, validating on assignment (see the reader above).
|
|
299
|
+
def mirror_replies_by_email=(value)
|
|
300
|
+
@settings[:mirror_replies_by_email] = ensure_one_of(value, MIRROR_MODES, "mirror_replies_by_email")
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Close a ticket that has been awaiting the requester this long (0.2).
|
|
304
|
+
def auto_close_after = duration(read(:auto_close_after))
|
|
305
|
+
|
|
306
|
+
# Set it, validating on assignment (see the reader above).
|
|
307
|
+
def auto_close_after=(value)
|
|
308
|
+
@settings[:auto_close_after] = ensure_duration(value, "auto_close_after")
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# --- Internals ------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
def read(name) # :nodoc:
|
|
314
|
+
return @settings[name] if @settings.key?(name)
|
|
315
|
+
return fallback.read(name) if fallback
|
|
316
|
+
|
|
317
|
+
DEFAULTS[name]
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Whether THIS desk states the setting itself, rather than inheriting.
|
|
321
|
+
def own?(name) = @settings.key?(name) # :nodoc:
|
|
322
|
+
|
|
323
|
+
# Forget a setting so this desk inherits it again (tests).
|
|
324
|
+
def reset_setting(name) # :nodoc:
|
|
325
|
+
@settings.delete(name)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# The desk and what it calls itself.
|
|
329
|
+
def inspect
|
|
330
|
+
"#<SupportDesk::Configuration::DeskConfiguration #{key} #{name.inspect}>"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
private
|
|
334
|
+
|
|
335
|
+
def duration(value)
|
|
336
|
+
return nil if value.nil?
|
|
337
|
+
return value if value.is_a?(ActiveSupport::Duration)
|
|
338
|
+
|
|
339
|
+
ActiveSupport::Duration.build(value.to_i)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def ensure_present_string(value, name)
|
|
343
|
+
string = value.to_s
|
|
344
|
+
raise ConfigurationError, "#{name} can't be blank" if string.strip.empty?
|
|
345
|
+
|
|
346
|
+
string
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def ensure_one_of(value, allowed, name)
|
|
350
|
+
symbol = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
351
|
+
unless allowed.include?(symbol)
|
|
352
|
+
raise ConfigurationError, "#{name} must be one of #{allowed.map(&:inspect).join(", ")}, got #{value.inspect}"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
symbol
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def ensure_duration(value, name)
|
|
359
|
+
return nil if value.nil?
|
|
360
|
+
|
|
361
|
+
unless value.is_a?(ActiveSupport::Duration) || value.is_a?(Numeric)
|
|
362
|
+
raise ConfigurationError, "#{name} must be a duration (e.g. 24.hours) or nil, got #{value.inspect}"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
value
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def ensure_callable(value, name)
|
|
369
|
+
unless value.respond_to?(:call)
|
|
370
|
+
raise ConfigurationError, "#{name} must respond to #call (a proc/lambda), got #{value.inspect}"
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
value
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Settings that belong to a desk rather than the installation. The
|
|
378
|
+
# top-level accessors forward to the `:default` desk, which is also what
|
|
379
|
+
# every other desk falls back to.
|
|
380
|
+
DESK_SETTINGS = %i[
|
|
381
|
+
name avatar email reply_policy announce_assignments closed_tickets reply_within at_risk_after
|
|
382
|
+
open_rate_limit max_open_tickets inbox_entry routing mirror_replies_by_email auto_close_after
|
|
383
|
+
].freeze
|
|
384
|
+
|
|
385
|
+
delegate(*DESK_SETTINGS, *DESK_SETTINGS.map { |setting| :"#{setting}=" }, to: :default_desk)
|
|
386
|
+
delegate :agents, :agents=, :topics, to: :default_desk
|
|
387
|
+
|
|
388
|
+
# The model that asks for help — the one with `has_support_tickets`. It
|
|
389
|
+
# must also be a chats messager: a requester holds a seat in the
|
|
390
|
+
# conversation behind every one of their tickets.
|
|
391
|
+
attr_reader :requester_class
|
|
392
|
+
|
|
393
|
+
# The controller the requester-facing engine inherits from, so your
|
|
394
|
+
# layout, helpers, auth and locale apply to the support screens.
|
|
395
|
+
attr_reader :parent_controller
|
|
396
|
+
|
|
397
|
+
# The controller the console inherits from (the optional ConsoleEngine
|
|
398
|
+
# and the generated console) — usually your admin framework's base
|
|
399
|
+
# controller.
|
|
400
|
+
attr_reader :console_parent_controller
|
|
401
|
+
|
|
402
|
+
# How the engine finds the person asking for help.
|
|
403
|
+
attr_accessor :current_requester_method
|
|
404
|
+
|
|
405
|
+
# How the console finds the person answering.
|
|
406
|
+
attr_accessor :current_agent_method
|
|
407
|
+
|
|
408
|
+
# The host's own authentication filter, run before every requester-facing
|
|
409
|
+
# screen so a logged-out visitor meets the host's login flow rather than
|
|
410
|
+
# this gem's idea of one (`:authenticate_user!` is Devise's, and chats'
|
|
411
|
+
# default too).
|
|
412
|
+
attr_accessor :authenticate_method
|
|
413
|
+
# ->(agent) { … } returning the desks this agent may work, or nil for
|
|
414
|
+
# "every desk". Read through #desks_visible_to.
|
|
415
|
+
attr_reader :visible_desks_for
|
|
416
|
+
|
|
417
|
+
# ->(agent, ticket, action) { true/false } — the console asks this before
|
|
418
|
+
# every action, for hosts with Pundit, CanCan or a policy object of their
|
|
419
|
+
# own. Read through #console_authorized?.
|
|
420
|
+
attr_reader :authorize_console
|
|
421
|
+
|
|
422
|
+
# A fresh configuration: one `:default` desk, every setting at the
|
|
423
|
+
# documented default.
|
|
424
|
+
def initialize
|
|
425
|
+
@requester_class = "User"
|
|
426
|
+
@parent_controller = "::ApplicationController"
|
|
427
|
+
@console_parent_controller = "::ApplicationController"
|
|
428
|
+
@current_requester_method = :current_user
|
|
429
|
+
@current_agent_method = :current_user
|
|
430
|
+
@authenticate_method = :authenticate_user!
|
|
431
|
+
@visible_desks_for = nil
|
|
432
|
+
@authorize_console = nil
|
|
433
|
+
|
|
434
|
+
@desks = { default: DeskConfiguration.new(:default) }
|
|
435
|
+
@warnings = []
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def requester_class=(value)
|
|
439
|
+
@requester_class = ensure_class_name(value, "requester_class")
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def parent_controller=(value)
|
|
443
|
+
@parent_controller = ensure_class_name(value, "parent_controller")
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def console_parent_controller=(value)
|
|
447
|
+
@console_parent_controller = ensure_class_name(value, "console_parent_controller")
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# --- The console ------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
# Narrow what the console can reach:
|
|
453
|
+
#
|
|
454
|
+
# config.visible_desks_for = ->(agent) { agent.billing? ? [ SupportDesk.desk(:billing) ] : Desk.all }
|
|
455
|
+
#
|
|
456
|
+
# A ticket on a desk an agent can't see is a 404 in the console, not a
|
|
457
|
+
# 403: an agent who may not work the billing desk shouldn't learn that a
|
|
458
|
+
# billing case exists.
|
|
459
|
+
def visible_desks_for=(value)
|
|
460
|
+
@visible_desks_for = value.nil? ? nil : ensure_callable(value, "visible_desks_for")
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# The desks +agent+ may work, always as Desk records. The hook may hand
|
|
464
|
+
# back records, a relation, or plain desk keys — all three read the same
|
|
465
|
+
# way in an initializer, so all three are accepted here.
|
|
466
|
+
def desks_visible_to(agent)
|
|
467
|
+
return Desk.all if visible_desks_for.nil?
|
|
468
|
+
|
|
469
|
+
Array(visible_desks_for.call(agent)).filter_map do |desk|
|
|
470
|
+
desk.is_a?(Desk) ? desk : SupportDesk.desk(desk)
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# config.authorize_console = ->(agent, ticket, action) { AdminPolicy.new(agent).support?(action) }
|
|
475
|
+
#
|
|
476
|
+
# `ticket` is nil on collection actions (the index, "next"). Returning
|
|
477
|
+
# false is a 403.
|
|
478
|
+
def authorize_console=(value)
|
|
479
|
+
@authorize_console = value.nil? ? nil : ensure_callable(value, "authorize_console")
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Whether the host's policy allows +agent+ to do +action+ here. True when
|
|
483
|
+
# no hook is configured — the console's own agent check still applies.
|
|
484
|
+
#
|
|
485
|
+
# A hook that RAISES denies rather than taking the screen down with it.
|
|
486
|
+
# This is the one place in the gem where swallowing an exception is the
|
|
487
|
+
# right call: an authorization check that blew up has not said yes, and
|
|
488
|
+
# a 500 on the page that was guarding something is both a worse answer
|
|
489
|
+
# and a louder hint that something is there. The error still reaches the
|
|
490
|
+
# host through `Rails.error`, so nobody has to notice it from a flash.
|
|
491
|
+
def console_authorized?(agent, ticket, action)
|
|
492
|
+
return true if authorize_console.nil?
|
|
493
|
+
|
|
494
|
+
!!authorize_console.call(agent, ticket, action)
|
|
495
|
+
rescue StandardError => e
|
|
496
|
+
report_console_authorization_error(e, action)
|
|
497
|
+
false
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# --- Desks ------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
# Read or configure a desk:
|
|
503
|
+
#
|
|
504
|
+
# config.desk :billing do |desk|
|
|
505
|
+
# desk.name = "Facturación"
|
|
506
|
+
# end
|
|
507
|
+
#
|
|
508
|
+
# config.desk(:billing).reply_within # => 24 hours (inherited)
|
|
509
|
+
def desk(key = :default)
|
|
510
|
+
key = key.to_sym
|
|
511
|
+
configuration = @desks[key] ||= DeskConfiguration.new(key, fallback: default_desk)
|
|
512
|
+
yield configuration if block_given?
|
|
513
|
+
configuration
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def default_desk = @desks[:default]
|
|
517
|
+
|
|
518
|
+
# Every configured desk, keyed by key.
|
|
519
|
+
def desks = @desks
|
|
520
|
+
|
|
521
|
+
def desk?(key) = @desks.key?(key.to_sym)
|
|
522
|
+
|
|
523
|
+
# --- Events -----------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
# Subscribe from inside the configure block — the same dispatcher as
|
|
526
|
+
# `SupportDesk.on`, spelled the way an initializer reads best.
|
|
527
|
+
def on(event, &block)
|
|
528
|
+
SupportDesk.on(event, &block)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# --- Validation -------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
# Cross-field validation, run at the end of `SupportDesk.configure`.
|
|
534
|
+
# Anything that needs the host's classes to be loaded is checked later,
|
|
535
|
+
# in the engine's to_prepare hook (see #validate_classes!).
|
|
536
|
+
def validate!
|
|
537
|
+
@desks.each_value do |desk|
|
|
538
|
+
next unless desk.at_risk_after && desk.reply_within && desk.at_risk_after > desk.reply_within
|
|
539
|
+
|
|
540
|
+
raise ConfigurationError,
|
|
541
|
+
"desk #{desk.key}: at_risk_after (#{desk.at_risk_after.inspect}) must come before " \
|
|
542
|
+
"reply_within (#{desk.reply_within.inspect}) — a ticket can't breach before it's at risk"
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
true
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Warnings raised by the last `validate!` — surfaced by `doctor`.
|
|
549
|
+
attr_reader :warnings
|
|
550
|
+
|
|
551
|
+
# Checks that need the host's classes loaded, so they run from the
|
|
552
|
+
# engine's to_prepare (every boot, and again after every reload).
|
|
553
|
+
def validate_classes!
|
|
554
|
+
requester = requester_class.safe_constantize
|
|
555
|
+
unless requester
|
|
556
|
+
raise ConfigurationError,
|
|
557
|
+
"config.requester_class is #{requester_class.inspect}, which doesn't exist. " \
|
|
558
|
+
"Point it at the model that asks for help."
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
unless requester.respond_to?(:support_desk_requester_options)
|
|
562
|
+
raise ConfigurationError,
|
|
563
|
+
"#{requester_class} must declare `has_support_tickets` (and `acts_as_messager`) to be the " \
|
|
564
|
+
"requester_class."
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
validate_requester_desks!
|
|
568
|
+
|
|
569
|
+
@warnings = []
|
|
570
|
+
@desks.each_value do |desk|
|
|
571
|
+
validate_agent_pool!(desk)
|
|
572
|
+
# Reading the tree is what BUILDS it, so a malformed topics block
|
|
573
|
+
# fails here — at boot, with the offending option named.
|
|
574
|
+
tree = desk.topics
|
|
575
|
+
|
|
576
|
+
tree.each do |topic|
|
|
577
|
+
topic.about_class_names.each { |name| validate_supportable!(name, topic, desk) }
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
next if tree.empty? || tree.free_form?
|
|
581
|
+
|
|
582
|
+
@warnings << "desk #{desk.key}: no free-form topic. Add `other` to the topics block — a taxonomy " \
|
|
583
|
+
"without an exit is how people pick the wrong topic."
|
|
584
|
+
end
|
|
585
|
+
@warnings.each { |warning| SupportDesk.logger&.warn("[support_desk] #{warning}") }
|
|
586
|
+
|
|
587
|
+
true
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# The constantized requester class (resolved lazily — see class comment).
|
|
591
|
+
def requester_model = requester_class.constantize
|
|
592
|
+
|
|
593
|
+
def parent_controller_class = parent_controller.constantize
|
|
594
|
+
|
|
595
|
+
def console_parent_controller_class = console_parent_controller.constantize
|
|
596
|
+
|
|
597
|
+
private
|
|
598
|
+
|
|
599
|
+
# `config.agents { … }` has to hand back something a desk can iterate.
|
|
600
|
+
# Resolving it costs nothing at boot — a relation is lazy — and a block
|
|
601
|
+
# that returns 42, or raises, is a configuration mistake, not a 3am
|
|
602
|
+
# surprise the first time somebody opens a ticket.
|
|
603
|
+
def validate_agent_pool!(desk)
|
|
604
|
+
return if desk.agents.nil?
|
|
605
|
+
|
|
606
|
+
desk.agent_pool
|
|
607
|
+
rescue ConfigurationError
|
|
608
|
+
raise
|
|
609
|
+
rescue ActiveRecord::ActiveRecordError
|
|
610
|
+
# No database yet (asset precompile, a boot before migrating).
|
|
611
|
+
# `SupportDesk.doctor` asks the same question where there is one.
|
|
612
|
+
nil
|
|
613
|
+
rescue StandardError => e
|
|
614
|
+
raise ConfigurationError,
|
|
615
|
+
"config.agents for desk #{desk.key} raised #{e.class}: #{e.message}"
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# A model that writes to a desk nobody configured would quietly land its
|
|
619
|
+
# tickets on the default desk instead.
|
|
620
|
+
def validate_requester_desks!
|
|
621
|
+
SupportDesk.requester_class_names.each do |name|
|
|
622
|
+
klass = name.safe_constantize
|
|
623
|
+
next unless klass.respond_to?(:support_desk_requester_options)
|
|
624
|
+
|
|
625
|
+
key = klass.support_desk_requester_options[:desk]
|
|
626
|
+
next if @desks.key?(key)
|
|
627
|
+
|
|
628
|
+
raise ConfigurationError,
|
|
629
|
+
"#{name} has `has_support_tickets desk: #{key.inspect}`, but no such desk is configured. " \
|
|
630
|
+
"Add `config.desk #{key.inspect} do |desk| … end`, or drop the desk: option."
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def validate_supportable!(name, topic, desk)
|
|
635
|
+
klass = name.safe_constantize
|
|
636
|
+
unless klass
|
|
637
|
+
raise ConfigurationError,
|
|
638
|
+
"desk #{desk.key}, topic #{topic.path.inspect}: about: #{name} doesn't exist."
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
return if klass.respond_to?(:supportable?) && klass.supportable?
|
|
642
|
+
|
|
643
|
+
raise ConfigurationError,
|
|
644
|
+
"desk #{desk.key}, topic #{topic.path.inspect}: about: #{name} is not supportable. " \
|
|
645
|
+
"Add `supportable topic: :#{topic.path}` to #{name}."
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def ensure_class_name(value, name)
|
|
649
|
+
string = value.is_a?(Class) ? value.name : value.to_s
|
|
650
|
+
raise ConfigurationError, "#{name} can't be blank" if string.strip.empty?
|
|
651
|
+
|
|
652
|
+
string
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def ensure_callable(value, name)
|
|
656
|
+
unless value.respond_to?(:call)
|
|
657
|
+
raise ConfigurationError, "#{name} must respond to #call (a proc/lambda), got #{value.inspect}"
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
value
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
# The same reporting path the event dispatcher uses for a subscriber
|
|
664
|
+
# that raises: `Rails.error` when there is one, the log otherwise.
|
|
665
|
+
def report_console_authorization_error(error, action)
|
|
666
|
+
if defined?(Rails) && Rails.respond_to?(:error) && Rails.error
|
|
667
|
+
Rails.error.report(error, handled: true, source: "support_desk",
|
|
668
|
+
context: { hook: :authorize_console, action: action })
|
|
669
|
+
else
|
|
670
|
+
SupportDesk.logger&.error("[support_desk] authorize_console raised on #{action}: " \
|
|
671
|
+
"#{error.class}: #{error.message}")
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
end
|