support_desk 0.3.2 → 0.5.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 +4 -4
- data/CHANGELOG.md +134 -0
- data/README.md +163 -8
- data/app/assets/stylesheets/support_desk.css +77 -0
- data/app/controllers/support_desk/application_controller.rb +21 -2
- data/app/controllers/support_desk/tickets_controller.rb +98 -9
- data/app/helpers/support_desk/engine_helper.rb +75 -10
- data/app/helpers/support_desk/thread_helper.rb +29 -0
- data/app/views/layouts/support_desk/panel.html.erb +6 -0
- data/app/views/support_desk/launcher/_badge.html.erb +6 -0
- data/app/views/support_desk/launcher/_icon.html.erb +6 -0
- data/app/views/support_desk/launcher/_launcher.html.erb +40 -0
- data/app/views/support_desk/launcher/_signed_out.html.erb +25 -0
- data/app/views/support_desk/panel/case.html.erb +36 -0
- data/app/views/support_desk/panel/home.html.erb +36 -0
- data/app/views/support_desk/panel/new.html.erb +8 -0
- data/app/views/support_desk/panel/rate_limited.html.erb +5 -0
- data/app/views/support_desk/tickets/_limit_wall.html.erb +33 -0
- data/app/views/support_desk/tickets/_pick_thing.html.erb +1 -1
- data/app/views/support_desk/tickets/_pick_topic.html.erb +1 -1
- data/app/views/support_desk/tickets/_wizard_header.html.erb +1 -1
- data/app/views/support_desk/tickets/_write.html.erb +4 -3
- data/app/views/support_desk/tickets/rate_limited.html.erb +1 -31
- data/app/views/support_desk/tickets/show.html.erb +8 -0
- data/config/locales/support_desk.en.yml +38 -0
- data/config/locales/support_desk.es.yml +38 -0
- data/config/routes.rb +4 -0
- data/lib/generators/support_desk/templates/initializer.rb +21 -1
- data/lib/generators/support_desk/views_generator.rb +9 -3
- data/lib/support_desk/brief.rb +1 -1
- data/lib/support_desk/configuration.rb +100 -19
- data/lib/support_desk/conversation_access.rb +42 -0
- data/lib/support_desk/engine.rb +7 -0
- data/lib/support_desk/launcher.rb +64 -0
- data/lib/support_desk/models/concerns/requester.rb +6 -2
- data/lib/support_desk/models/ticket.rb +55 -1
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +1 -5
- data/lib/support_desk.rb +41 -6
- metadata +28 -11
|
@@ -14,12 +14,37 @@ en:
|
|
|
14
14
|
closed_notice: "This conversation is closed. Start a new one if you need anything else."
|
|
15
15
|
reopened_notice: "We reopened this conversation."
|
|
16
16
|
promise: "We usually reply in under %{time}"
|
|
17
|
+
acknowledgement: "Thanks, we got it."
|
|
18
|
+
acknowledgement_with_promise: "Thanks, we got it. We usually reply in under %{reply_within}."
|
|
17
19
|
opened_by_support: "%{desk} opened this conversation with you about “%{label}”. You can reply right here."
|
|
18
20
|
unavailable_notice: "This account can no longer send or receive messages. The conversation stays here to read."
|
|
19
21
|
human_door: "I’d rather talk to a person"
|
|
20
22
|
human_requested_notice: "Done. Somebody from the team will take it from here."
|
|
21
23
|
handled_by_assistant: "Handled by %{name}"
|
|
22
24
|
handled_by_humans: "Somebody from the team will take it from here"
|
|
25
|
+
# The answer promise, in the unit the desk wrote it in (SupportDesk.humanize_promise).
|
|
26
|
+
durations:
|
|
27
|
+
seconds:
|
|
28
|
+
one: "1 second"
|
|
29
|
+
other: "%{count} seconds"
|
|
30
|
+
minutes:
|
|
31
|
+
one: "1 minute"
|
|
32
|
+
other: "%{count} minutes"
|
|
33
|
+
hours:
|
|
34
|
+
one: "1 hour"
|
|
35
|
+
other: "%{count} hours"
|
|
36
|
+
days:
|
|
37
|
+
one: "1 day"
|
|
38
|
+
other: "%{count} days"
|
|
39
|
+
weeks:
|
|
40
|
+
one: "1 week"
|
|
41
|
+
other: "%{count} weeks"
|
|
42
|
+
months:
|
|
43
|
+
one: "1 month"
|
|
44
|
+
other: "%{count} months"
|
|
45
|
+
years:
|
|
46
|
+
one: "1 year"
|
|
47
|
+
other: "%{count} years"
|
|
23
48
|
transcript:
|
|
24
49
|
deleted: "[message deleted]"
|
|
25
50
|
notifications:
|
|
@@ -55,6 +80,9 @@ en:
|
|
|
55
80
|
closed_section:
|
|
56
81
|
one: "1 closed conversation"
|
|
57
82
|
other: "%{count} closed conversations"
|
|
83
|
+
closed_capped:
|
|
84
|
+
one: "Showing the most recent one."
|
|
85
|
+
other: "Showing the %{count} most recent."
|
|
58
86
|
state:
|
|
59
87
|
awaiting_reply: "We're on it"
|
|
60
88
|
answered: "We replied"
|
|
@@ -65,6 +93,16 @@ en:
|
|
|
65
93
|
about: "Need help with %{subject}?"
|
|
66
94
|
existing: "See your support conversation"
|
|
67
95
|
new_conversation: "Start a new conversation"
|
|
96
|
+
launcher:
|
|
97
|
+
label: "Need help?"
|
|
98
|
+
open: "Open support"
|
|
99
|
+
all: "All your conversations"
|
|
100
|
+
open_page: "Open in a page"
|
|
101
|
+
signed_out:
|
|
102
|
+
lead: "Sign in and write to us: your conversations with us are kept in your account."
|
|
103
|
+
sign_in: "Sign in to write to us"
|
|
104
|
+
email_html: "Can't sign in? Write to %{email}."
|
|
105
|
+
help: "Visit the help centre"
|
|
68
106
|
inbox:
|
|
69
107
|
empty_hint: "Need help? Write to us"
|
|
70
108
|
limits:
|
|
@@ -19,6 +19,8 @@ es:
|
|
|
19
19
|
closed_notice: "Esta conversación está cerrada. Abre una nueva si necesitas algo más."
|
|
20
20
|
reopened_notice: "Hemos vuelto a abrir esta conversación."
|
|
21
21
|
promise: "Normalmente respondemos en menos de %{time}"
|
|
22
|
+
acknowledgement: "Gracias, lo hemos recibido."
|
|
23
|
+
acknowledgement_with_promise: "Gracias, lo hemos recibido. Normalmente respondemos en menos de %{reply_within}."
|
|
22
24
|
# La línea con la que se abre un hilo que ha empezado el equipo: quien
|
|
23
25
|
# recibe un mensaje de un sitio al que nunca ha escrito necesita saber
|
|
24
26
|
# qué es esto.
|
|
@@ -28,6 +30,29 @@ es:
|
|
|
28
30
|
human_requested_notice: "Hecho. Una persona del equipo seguirá con tu consulta."
|
|
29
31
|
handled_by_assistant: "Atendida por %{name}"
|
|
30
32
|
handled_by_humans: "Una persona del equipo seguirá con tu consulta"
|
|
33
|
+
# La promesa de respuesta, en la unidad en que la escribió la mesa (SupportDesk.humanize_promise).
|
|
34
|
+
durations:
|
|
35
|
+
seconds:
|
|
36
|
+
one: "1 segundo"
|
|
37
|
+
other: "%{count} segundos"
|
|
38
|
+
minutes:
|
|
39
|
+
one: "1 minuto"
|
|
40
|
+
other: "%{count} minutos"
|
|
41
|
+
hours:
|
|
42
|
+
one: "1 hora"
|
|
43
|
+
other: "%{count} horas"
|
|
44
|
+
days:
|
|
45
|
+
one: "1 día"
|
|
46
|
+
other: "%{count} días"
|
|
47
|
+
weeks:
|
|
48
|
+
one: "1 semana"
|
|
49
|
+
other: "%{count} semanas"
|
|
50
|
+
months:
|
|
51
|
+
one: "1 mes"
|
|
52
|
+
other: "%{count} meses"
|
|
53
|
+
years:
|
|
54
|
+
one: "1 año"
|
|
55
|
+
other: "%{count} años"
|
|
31
56
|
transcript:
|
|
32
57
|
# Un mensaje borrado sigue siendo un turno. Quien lea la conversación
|
|
33
58
|
# después, una persona o el modelo al que el host se la pasa, tiene
|
|
@@ -70,6 +95,9 @@ es:
|
|
|
70
95
|
closed_section:
|
|
71
96
|
one: "1 conversación cerrada"
|
|
72
97
|
other: "%{count} conversaciones cerradas"
|
|
98
|
+
closed_capped:
|
|
99
|
+
one: "Se muestra la más reciente."
|
|
100
|
+
other: "Se muestran las %{count} más recientes."
|
|
73
101
|
state:
|
|
74
102
|
awaiting_reply: "Estamos con ello"
|
|
75
103
|
answered: "Te hemos contestado"
|
|
@@ -81,6 +109,16 @@ es:
|
|
|
81
109
|
about: "¿Necesitas ayuda con %{subject}?"
|
|
82
110
|
existing: "Ver tu conversación de soporte"
|
|
83
111
|
new_conversation: "Abrir una nueva conversación"
|
|
112
|
+
launcher:
|
|
113
|
+
label: "¿Necesitas ayuda?"
|
|
114
|
+
open: "Abrir soporte"
|
|
115
|
+
all: "Todas tus conversaciones"
|
|
116
|
+
open_page: "Abrir en una página"
|
|
117
|
+
signed_out:
|
|
118
|
+
lead: "Inicia sesión y escríbenos: tus conversaciones con nosotros se guardan en tu cuenta."
|
|
119
|
+
sign_in: "Inicia sesión para escribirnos"
|
|
120
|
+
email_html: "¿No puedes iniciar sesión? Escribe a %{email}."
|
|
121
|
+
help: "Visita el centro de ayuda"
|
|
84
122
|
inbox:
|
|
85
123
|
empty_hint: "¿Necesitas ayuda? Escríbenos"
|
|
86
124
|
limits:
|
data/config/routes.rb
CHANGED
|
@@ -19,6 +19,10 @@ SupportDesk::Engine.routes.draw do
|
|
|
19
19
|
# for emails and notifications, that never has to know where chats is
|
|
20
20
|
# mounted.
|
|
21
21
|
resources :tickets, only: %i[create show] do
|
|
22
|
+
# The launcher's badge, as a Turbo Stream: what its stream missed
|
|
23
|
+
# (a reconnect, a return to the tab), asked again by chats' tray.
|
|
24
|
+
get :badge, on: :collection
|
|
25
|
+
|
|
22
26
|
# The door out of a machine. It is a POST on the CASE rather than
|
|
23
27
|
# anything to do with chats, because it is a fact about the case: from
|
|
24
28
|
# here on a person is expected, whatever the assistant would have done
|
|
@@ -61,6 +61,18 @@ SupportDesk.configure do |config|
|
|
|
61
61
|
# screen, so a logged-out visitor meets YOUR login flow.
|
|
62
62
|
#
|
|
63
63
|
# config.authenticate_method = :authenticate_user!
|
|
64
|
+
#
|
|
65
|
+
# Where a case is read:
|
|
66
|
+
# :chats in the chats inbox's own thread (/messages/:id), with the
|
|
67
|
+
# desk as one grouped row there
|
|
68
|
+
# :support on this engine's own page (/support/tickets/T-AB12CD): a
|
|
69
|
+
# support area of its own, for a host that keeps support out
|
|
70
|
+
# of the inbox (`inbox_entry = :never`, and a chats
|
|
71
|
+
# `inbox_scope` that leaves SupportDesk::Ticket conversations
|
|
72
|
+
# out). The thread is still chats': its bubbles, composer and
|
|
73
|
+
# realtime, on a page whose way back leads here.
|
|
74
|
+
#
|
|
75
|
+
# config.thread_page = :chats
|
|
64
76
|
|
|
65
77
|
# ==========================================================================
|
|
66
78
|
# THE DESK
|
|
@@ -135,6 +147,13 @@ SupportDesk.configure do |config|
|
|
|
135
147
|
# config.opening_line = "Has abierto una conversación sobre «%{label}». Te contestamos aquí."
|
|
136
148
|
# config.opening_line_from_support = "%{desk} ha abierto esta conversación contigo sobre «%{label}»."
|
|
137
149
|
#
|
|
150
|
+
# The line posted right UNDER a requester's first message ("Thanks, we got
|
|
151
|
+
# it. We usually reply in under 72 hours."): the answer to "did that go
|
|
152
|
+
# through?", before anybody has read it. Same values and interpolations as
|
|
153
|
+
# the opening line; nil (the default) posts nothing. The gem ships one:
|
|
154
|
+
#
|
|
155
|
+
# config.acknowledgement = :"support_desk.thread.acknowledgement"
|
|
156
|
+
#
|
|
138
157
|
# What a requester writing into a closed ticket does:
|
|
139
158
|
# :reopen_on_reply the case comes back (no wall, no dead end)
|
|
140
159
|
# :locked the composer is replaced by a notice
|
|
@@ -143,7 +162,8 @@ SupportDesk.configure do |config|
|
|
|
143
162
|
#
|
|
144
163
|
# The answer promise: the SLA breach threshold AND the line requesters are
|
|
145
164
|
# shown when they write ("normalmente en menos de 24 h"). One setting, one
|
|
146
|
-
# truth
|
|
165
|
+
# truth, said in the unit you write it in (72.hours reads "72 hours").
|
|
166
|
+
# `at_risk_after` is the earlier, softer warning for the queue.
|
|
147
167
|
#
|
|
148
168
|
# config.reply_within = 24.hours
|
|
149
169
|
# config.at_risk_after = 4.hours
|
|
@@ -23,15 +23,21 @@ module SupportDesk
|
|
|
23
23
|
|
|
24
24
|
# Which groups to eject. `tickets` is every requester screen (the list,
|
|
25
25
|
# the wizard's three frames, the rows and the doors); `slots` is the
|
|
26
|
-
# row this engine contributes to chats' inbox
|
|
26
|
+
# row this engine contributes to chats' inbox; `launcher` is the
|
|
27
|
+
# "Need help?" launcher (its tab, badge, icon and signed-out panel) and
|
|
28
|
+
# the screens its panel shows.
|
|
27
29
|
class_option :views,
|
|
28
30
|
type: :array,
|
|
29
|
-
default: %w[tickets slots],
|
|
30
|
-
desc: "Which view groups to copy (tickets, slots)"
|
|
31
|
+
default: %w[tickets slots launcher],
|
|
32
|
+
desc: "Which view groups to copy (tickets, slots, launcher)"
|
|
31
33
|
|
|
32
34
|
def copy_views
|
|
33
35
|
directory "support_desk/tickets", "app/views/support_desk/tickets" if include?("tickets")
|
|
34
36
|
directory "chats/slots", "app/views/chats/slots" if include?("slots")
|
|
37
|
+
return unless include?("launcher")
|
|
38
|
+
|
|
39
|
+
directory "support_desk/launcher", "app/views/support_desk/launcher"
|
|
40
|
+
directory "support_desk/panel", "app/views/support_desk/panel"
|
|
35
41
|
end
|
|
36
42
|
|
|
37
43
|
def show_styling_tip
|
data/lib/support_desk/brief.rb
CHANGED
|
@@ -65,6 +65,7 @@ module SupportDesk
|
|
|
65
65
|
# this one has a default and `opening_line` does not: existing hosts'
|
|
66
66
|
# threads keep opening exactly as they do today.
|
|
67
67
|
opening_line_from_support: :"support_desk.thread.opened_by_support",
|
|
68
|
+
acknowledgement: nil,
|
|
68
69
|
find_requester: nil,
|
|
69
70
|
reply_policy: :anyone,
|
|
70
71
|
announce_assignments: :first_only,
|
|
@@ -166,6 +167,28 @@ module SupportDesk
|
|
|
166
167
|
@settings[:opening_line_from_support] = ensure_line(value, "opening_line_from_support")
|
|
167
168
|
end
|
|
168
169
|
|
|
170
|
+
# The system line posted right UNDER a requester's first message, in the
|
|
171
|
+
# same transaction: "Thanks, we got it. We usually reply in under 72
|
|
172
|
+
# hours." The answer to "did that go through?", said where they are
|
|
173
|
+
# looking, before anybody on the desk has read it. Only for a case the
|
|
174
|
+
# requester opened; a case the desk opens starts with
|
|
175
|
+
# `opening_line_from_support` instead.
|
|
176
|
+
#
|
|
177
|
+
# Same values as `opening_line`, and the same interpolations. nil (the
|
|
178
|
+
# default) posts nothing; the gem ships a line to opt into:
|
|
179
|
+
#
|
|
180
|
+
# config.acknowledgement = :"support_desk.thread.acknowledgement"
|
|
181
|
+
def acknowledgement(&block)
|
|
182
|
+
return @settings[:acknowledgement] = block if block
|
|
183
|
+
|
|
184
|
+
read(:acknowledgement)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Set it, validating on assignment (see the reader above).
|
|
188
|
+
def acknowledgement=(value)
|
|
189
|
+
@settings[:acknowledgement] = ensure_line(value, "acknowledgement")
|
|
190
|
+
end
|
|
191
|
+
|
|
169
192
|
# How a console finds the person an agent types: email, phone, handle,
|
|
170
193
|
# whatever this host lets staff search by. Given the typed string,
|
|
171
194
|
# returns a requester record or nil.
|
|
@@ -192,7 +215,7 @@ module SupportDesk
|
|
|
192
215
|
# it needs a ticket, and running a host's callback as a diagnostic is
|
|
193
216
|
# not a diagnostic.
|
|
194
217
|
def opening_line_problems # :nodoc:
|
|
195
|
-
%i[opening_line opening_line_from_support].filter_map do |setting|
|
|
218
|
+
%i[opening_line opening_line_from_support acknowledgement].filter_map do |setting|
|
|
196
219
|
value = public_send(setting)
|
|
197
220
|
next if value.nil? || value.respond_to?(:call)
|
|
198
221
|
|
|
@@ -214,22 +237,46 @@ module SupportDesk
|
|
|
214
237
|
# What to post for THIS ticket, resolved and interpolated in the
|
|
215
238
|
# current locale. nil or blank means post nothing.
|
|
216
239
|
def opening_line_for(ticket)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
when Symbol then I18n.t(setting, **line_interpolations(ticket), raise: true)
|
|
222
|
-
when String then interpolate_line(setting, line_interpolations(ticket), "opening_line")
|
|
223
|
-
else setting.call(ticket)
|
|
240
|
+
if ticket.opened_by_support?
|
|
241
|
+
line_for(ticket, opening_line_from_support, "opening_line_from_support")
|
|
242
|
+
else
|
|
243
|
+
line_for(ticket, opening_line, "opening_line")
|
|
224
244
|
end
|
|
225
|
-
|
|
245
|
+
end
|
|
226
246
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
247
|
+
# The acknowledgement for THIS ticket, the same way: nil for a case the
|
|
248
|
+
# desk opened, and nil or blank means post nothing.
|
|
249
|
+
def acknowledgement_for(ticket)
|
|
250
|
+
return nil if ticket.opened_by_support?
|
|
231
251
|
|
|
232
|
-
|
|
252
|
+
line_for(ticket, acknowledgement, "acknowledgement")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# "We usually reply in under 72 hours", in the current locale, or nil
|
|
256
|
+
# when this desk promises nothing. The line the requester reads above
|
|
257
|
+
# the composer and on the door, from the same `reply_within` the SLA
|
|
258
|
+
# breaches on.
|
|
259
|
+
def reply_promise
|
|
260
|
+
within = reply_within
|
|
261
|
+
return nil if within.nil?
|
|
262
|
+
|
|
263
|
+
I18n.t("support_desk.thread.promise", time: SupportDesk.humanize_promise(within))
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# What every line and notice of this desk can say about +ticket+:
|
|
267
|
+
# %{label}, %{desk} and %{reply_within} (the promise, in its own unit).
|
|
268
|
+
def line_interpolations(ticket)
|
|
269
|
+
{ label: ticket.label, desk: ticket.desk.name,
|
|
270
|
+
reply_within: (SupportDesk.humanize_promise(reply_within) if reply_within) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# The `_with_promise` variant of an I18n key, when this desk promises a
|
|
274
|
+
# time and the variant exists; the key as written otherwise.
|
|
275
|
+
def promised_key(symbol)
|
|
276
|
+
return symbol if reply_within.nil?
|
|
277
|
+
|
|
278
|
+
promised = :"#{symbol}_with_promise"
|
|
279
|
+
I18n.exists?(promised) ? promised : symbol
|
|
233
280
|
end
|
|
234
281
|
|
|
235
282
|
# --- Who answers ----------------------------------------------------------
|
|
@@ -554,9 +601,24 @@ module SupportDesk
|
|
|
554
601
|
"#{LINE_INTERPOLATIONS.keys.map { |key| "%{#{key}}" }.join(", ")}."
|
|
555
602
|
end
|
|
556
603
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
604
|
+
# One configured line, resolved and interpolated for +ticket+. An I18n
|
|
605
|
+
# key reads its `<key>_with_promise` variant when this desk promises a
|
|
606
|
+
# time and the variant exists, so a desk with `reply_within = nil` never
|
|
607
|
+
# says "we usually reply in under ." (the gem's assistant lines work the
|
|
608
|
+
# same way).
|
|
609
|
+
def line_for(ticket, setting, name)
|
|
610
|
+
return nil if setting.nil?
|
|
611
|
+
|
|
612
|
+
line = case setting
|
|
613
|
+
when Symbol then I18n.t(promised_key(setting), **line_interpolations(ticket), raise: true)
|
|
614
|
+
when String then interpolate_line(setting, line_interpolations(ticket), name)
|
|
615
|
+
else setting.call(ticket)
|
|
616
|
+
end
|
|
617
|
+
return nil if line.nil?
|
|
618
|
+
|
|
619
|
+
raise ConfigurationError, "a #{name} block must return a String or nil, got #{line.inspect}" unless line.is_a?(String)
|
|
620
|
+
|
|
621
|
+
line
|
|
560
622
|
end
|
|
561
623
|
end
|
|
562
624
|
|
|
@@ -949,7 +1011,7 @@ module SupportDesk
|
|
|
949
1011
|
def line_interpolations(ticket)
|
|
950
1012
|
reply_within = ticket.desk_config.reply_within
|
|
951
1013
|
{ name: name, desk: ticket.desk.name,
|
|
952
|
-
reply_within: (SupportDesk.
|
|
1014
|
+
reply_within: (SupportDesk.humanize_promise(reply_within) if reply_within) }
|
|
953
1015
|
end
|
|
954
1016
|
end
|
|
955
1017
|
|
|
@@ -957,7 +1019,7 @@ module SupportDesk
|
|
|
957
1019
|
# top-level accessors forward to the `:default` desk, which is also what
|
|
958
1020
|
# every other desk falls back to.
|
|
959
1021
|
DESK_SETTINGS = %i[
|
|
960
|
-
name avatar email opening_line opening_line_from_support find_requester reply_policy
|
|
1022
|
+
name avatar email opening_line opening_line_from_support acknowledgement find_requester reply_policy
|
|
961
1023
|
announce_assignments closed_tickets reply_within at_risk_after open_rate_limit max_open_tickets
|
|
962
1024
|
inbox_entry routing mirror_replies_by_email auto_close_after assistant
|
|
963
1025
|
].freeze
|
|
@@ -999,6 +1061,12 @@ module SupportDesk
|
|
|
999
1061
|
# own. Read through #console_authorized?.
|
|
1000
1062
|
attr_reader :authorize_console
|
|
1001
1063
|
|
|
1064
|
+
# Where a requester reads a case: :chats (the default) is its
|
|
1065
|
+
# conversation in the chats inbox; :support is this engine's own ticket
|
|
1066
|
+
# page, for a host whose support lives apart from its inbox.
|
|
1067
|
+
THREAD_PAGES = %i[chats support].freeze
|
|
1068
|
+
attr_reader :thread_page
|
|
1069
|
+
|
|
1002
1070
|
# A fresh configuration: one `:default` desk, every setting at the
|
|
1003
1071
|
# documented default.
|
|
1004
1072
|
def initialize
|
|
@@ -1010,6 +1078,7 @@ module SupportDesk
|
|
|
1010
1078
|
@authenticate_method = :authenticate_user!
|
|
1011
1079
|
@visible_desks_for = nil
|
|
1012
1080
|
@authorize_console = nil
|
|
1081
|
+
@thread_page = :chats
|
|
1013
1082
|
|
|
1014
1083
|
@desks = { default: DeskConfiguration.new(:default) }
|
|
1015
1084
|
@assistants = {}
|
|
@@ -1029,6 +1098,18 @@ module SupportDesk
|
|
|
1029
1098
|
@console_parent_controller = ensure_class_name(value, "console_parent_controller")
|
|
1030
1099
|
end
|
|
1031
1100
|
|
|
1101
|
+
def thread_page=(value)
|
|
1102
|
+
page = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
1103
|
+
unless THREAD_PAGES.include?(page)
|
|
1104
|
+
raise ConfigurationError, "thread_page must be one of #{THREAD_PAGES.map(&:inspect).join(", ")}, got #{value.inspect}"
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1107
|
+
@thread_page = page
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
# Whether a case is read on this engine's own page (`thread_page = :support`).
|
|
1111
|
+
def support_thread_page? = thread_page == :support
|
|
1112
|
+
|
|
1032
1113
|
# --- The console ------------------------------------------------------------
|
|
1033
1114
|
|
|
1034
1115
|
# Narrow what the console can reach:
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# chats finds every conversation a request names (the thread, a dock
|
|
5
|
+
# window, the composer, read, typing, refresh, reactions, edits) by asking,
|
|
6
|
+
# in order, the relations `Chats::ApplicationController#conversation_lookup_scopes`
|
|
7
|
+
# returns (chats >= 0.5.1). By default that is the viewer's inbox,
|
|
8
|
+
# `Messager#chats`, with the host's `config.inbox_scope` applied: the right
|
|
9
|
+
# rule for a list and the wrong one for a case. A host that keeps support
|
|
10
|
+
# apart from its inbox (`thread_page = :support`, and an inbox_scope that
|
|
11
|
+
# leaves SupportDesk::Ticket conversations out) would 404 its requesters
|
|
12
|
+
# inside their own cases.
|
|
13
|
+
#
|
|
14
|
+
# So this adds one relation after chats' own: the viewer's own cases
|
|
15
|
+
# (SupportDesk::Ticket.conversations_of), through the same participation
|
|
16
|
+
# and block rules as the inbox, without the host's listing scope. The union
|
|
17
|
+
# of the scopes is the authorization: nobody gains a conversation they are
|
|
18
|
+
# not seated in, a non-support conversation the host hides stays hidden, and
|
|
19
|
+
# a layer of the host's that refuses a conversation (its own
|
|
20
|
+
# `find_conversation`, or a scope it drops) still refuses it.
|
|
21
|
+
#
|
|
22
|
+
# == The contract with the host
|
|
23
|
+
#
|
|
24
|
+
# support_desk only ever adds a scope beside the host's. A host that must
|
|
25
|
+
# refuse a viewer entirely (a suspended account) returns no scopes, and that
|
|
26
|
+
# is final: with nothing to stand beside, support_desk adds nothing either.
|
|
27
|
+
# The ticket page asks the same question (SupportDesk::TicketsController
|
|
28
|
+
# resolves its conversation through chats' own `find_conversation`).
|
|
29
|
+
#
|
|
30
|
+
# Prepended to Chats::ApplicationController by the engine, in `to_prepare`
|
|
31
|
+
# so it survives a code reload.
|
|
32
|
+
module ConversationAccess
|
|
33
|
+
protected
|
|
34
|
+
|
|
35
|
+
def conversation_lookup_scopes
|
|
36
|
+
scopes = super
|
|
37
|
+
return scopes if scopes.empty?
|
|
38
|
+
|
|
39
|
+
scopes + [ SupportDesk::Ticket.conversations_of(chats_current_messager) ]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/support_desk/engine.rb
CHANGED
|
@@ -150,6 +150,13 @@ module SupportDesk
|
|
|
150
150
|
# needed to load the class.
|
|
151
151
|
_assistant = SupportDesk::Assistant
|
|
152
152
|
|
|
153
|
+
# A requester's own case opens through chats whatever the host's
|
|
154
|
+
# `inbox_scope` lists (SupportDesk::ConversationAccess). Here and not in
|
|
155
|
+
# an initializer because both classes reload: a reloaded chats
|
|
156
|
+
# controller is a new class, and it needs the seam again.
|
|
157
|
+
chats_controller = Chats::ApplicationController
|
|
158
|
+
chats_controller.prepend(SupportDesk::ConversationAccess) unless chats_controller < SupportDesk::ConversationAccess
|
|
159
|
+
|
|
153
160
|
SupportDesk.config.validate_classes! if SupportDesk.configured?
|
|
154
161
|
end
|
|
155
162
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# The "Need help?" launcher (`support_launcher`): an item of chats' tray
|
|
5
|
+
# (chats >= 0.7.0), beside the messaging dock's bar or in a corner of its
|
|
6
|
+
# own, whose panel is this engine's own pages in a frame.
|
|
7
|
+
#
|
|
8
|
+
# This module is the launcher's two names and its live badge:
|
|
9
|
+
#
|
|
10
|
+
# FRAME the panel's turbo frame. A request that carries it
|
|
11
|
+
# (`Turbo-Frame: support_desk_panel`) is answered with the
|
|
12
|
+
# panel's templates (SupportDesk::TicketsController).
|
|
13
|
+
# BADGE_ID the badge the requester's `[requester, :support_desk]`
|
|
14
|
+
# stream replaces.
|
|
15
|
+
#
|
|
16
|
+
# The badge is `requester.unread_support_count`: unread messages across
|
|
17
|
+
# their own cases, against chats' read horizon, with no listing scope. It
|
|
18
|
+
# never touches the Messages badge, which counts `messager.chats` (the
|
|
19
|
+
# host's inbox_scope applied). It moves wherever chats says an unread count
|
|
20
|
+
# may have moved (`Chats.on(:unread_changed)`: every message, the case's
|
|
21
|
+
# own notices included, and a read), for a conversation that is a case's.
|
|
22
|
+
module Launcher
|
|
23
|
+
FRAME = "support_desk_panel"
|
|
24
|
+
BADGE_ID = "support_desk_launcher_badge"
|
|
25
|
+
BADGE_PARTIAL = "support_desk/launcher/badge"
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Whether +request+ is the panel's: its frame asked for it. Then the
|
|
29
|
+
# requester's screens answer with the panel's templates, every link
|
|
30
|
+
# stays inside it, and a new case opens in it.
|
|
31
|
+
def panel_request?(request)
|
|
32
|
+
request&.headers&.[]("Turbo-Frame") == FRAME
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# chats' `:unread_changed`, filtered to support: a case's conversation
|
|
36
|
+
# (its subject is the ticket, no query needed) and a seat that asks
|
|
37
|
+
# for help (a requester; a desk or an agent's console has no launcher).
|
|
38
|
+
def unread_changed(messager, conversation)
|
|
39
|
+
return unless conversation&.subject_type == SupportDesk::Ticket.polymorphic_name
|
|
40
|
+
return unless messager.respond_to?(:unread_support_count)
|
|
41
|
+
|
|
42
|
+
broadcast_badge_of(messager)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Replace +requester+'s launcher badge, wherever it is on screen: one
|
|
46
|
+
# job, which counts when it renders (nothing is counted in the request
|
|
47
|
+
# that wrote the message). A missed one heals on the launcher's
|
|
48
|
+
# catch-up (GET tickets/badge).
|
|
49
|
+
#
|
|
50
|
+
# It runs whether or not this host renders a launcher: there is no
|
|
51
|
+
# telling, from a job, whether anybody is listening, and a switch for it
|
|
52
|
+
# would let a launcher's badge silently never move. Support is
|
|
53
|
+
# human-paced, so this is one small job per message in a case.
|
|
54
|
+
def broadcast_badge_of(requester)
|
|
55
|
+
Turbo::StreamsChannel.broadcast_replace_later_to(
|
|
56
|
+
requester, :support_desk,
|
|
57
|
+
target: BADGE_ID,
|
|
58
|
+
partial: BADGE_PARTIAL,
|
|
59
|
+
locals: { requester: requester }
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -93,9 +93,13 @@ module SupportDesk
|
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
# Unread messages across every support conversation this requester has,
|
|
96
|
-
# counted against the chats read horizon — the number for a nav badge
|
|
96
|
+
# counted against the chats read horizon — the number for a nav badge
|
|
97
|
+
# and the launcher's. Counted through the cases they can open
|
|
98
|
+
# (Ticket.conversations_of: their seat still there, the same scope the
|
|
99
|
+
# case page and chats' lookup use), so the badge never counts a case that
|
|
100
|
+
# would 404.
|
|
97
101
|
def unread_support_count
|
|
98
|
-
conversation_ids =
|
|
102
|
+
conversation_ids = SupportDesk::Ticket.conversations_of(self).reorder(nil).distinct.pluck(:id)
|
|
99
103
|
return 0 if conversation_ids.empty?
|
|
100
104
|
|
|
101
105
|
Chats::Conversation.unread_counts_for(self, conversation_ids).values.sum
|
|
@@ -192,6 +192,35 @@ module SupportDesk
|
|
|
192
192
|
find_by(conversation_id: conversation.id)
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
+
# The conversations behind +requester+'s own cases, while they still
|
|
196
|
+
# hold its seat: chats' participation and block rules (`inbox_for`),
|
|
197
|
+
# without the host's `inbox_scope`. Participation is what makes a
|
|
198
|
+
# conversation theirs to read; the inbox is only where it is listed.
|
|
199
|
+
# What chats adds to its lookup (SupportDesk::ConversationAccess) and
|
|
200
|
+
# what the ticket page reads from.
|
|
201
|
+
#
|
|
202
|
+
# A case and its conversation have to point at each other: a direct
|
|
203
|
+
# conversation whose subject IS one of this requester's cases, that very
|
|
204
|
+
# case's conversation. A ticket mislinked to a group, or to a
|
|
205
|
+
# conversation about something else, opens nothing.
|
|
206
|
+
def conversations_of(requester)
|
|
207
|
+
return Chats::Conversation.none unless requester.respond_to?(:support_tickets)
|
|
208
|
+
|
|
209
|
+
tickets = arel_table
|
|
210
|
+
conversations = Chats::Conversation.arel_table
|
|
211
|
+
own_cases = requester.support_tickets.where(tickets[:id].eq(conversations[:subject_id])).select(:conversation_id)
|
|
212
|
+
Chats::Conversation.inbox_for(requester).direct
|
|
213
|
+
.where(subject_type: polymorphic_name)
|
|
214
|
+
.where(id: own_cases)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# One of them, by id, or nil.
|
|
218
|
+
def requester_conversation(requester, id)
|
|
219
|
+
return nil if id.blank?
|
|
220
|
+
|
|
221
|
+
conversations_of(requester).find_by(id: id)
|
|
222
|
+
end
|
|
223
|
+
|
|
195
224
|
# Open a ticket and post its first message. Usually called as
|
|
196
225
|
# `requester.ask_support!(…)` — or, when the desk writes first, as
|
|
197
226
|
# `agent.open_support_conversation_with!(…)`. This is the seam under
|
|
@@ -621,6 +650,7 @@ module SupportDesk
|
|
|
621
650
|
ticket.send(:record_registration!, posted) if posted
|
|
622
651
|
ticket.send(:stamp_assistant_action!) if by_support && SupportDesk.ai_actor?(by)
|
|
623
652
|
pin_opening_lines!(ticket, notices, posted)
|
|
653
|
+
post_acknowledgement!(ticket, posted) if posted && !by_support
|
|
624
654
|
ticket
|
|
625
655
|
end
|
|
626
656
|
|
|
@@ -631,6 +661,30 @@ module SupportDesk
|
|
|
631
661
|
ticket.conversation.post_system_message!(line)
|
|
632
662
|
end
|
|
633
663
|
|
|
664
|
+
# The desk's acknowledgement, right under the requester's first message
|
|
665
|
+
# and in the same transaction, so "did that go through?" is answered by
|
|
666
|
+
# the very commit that made it so. Pinned one tick AFTER the message
|
|
667
|
+
# whenever the clock did not move between the two inserts (the same tie
|
|
668
|
+
# `pin_opening_lines!` breaks the other way), and it is then the
|
|
669
|
+
# conversation's last message: what a list row previews.
|
|
670
|
+
#
|
|
671
|
+
# It is the desk's receipt for words the requester has just written, so
|
|
672
|
+
# it is never news to them: their read horizon moves through it. Without
|
|
673
|
+
# that, a tick dated after the clock (a frozen or coarse one) would stay
|
|
674
|
+
# unread on a page that already shows it, chats' own thread included,
|
|
675
|
+
# which reads at "now".
|
|
676
|
+
def post_acknowledgement!(ticket, posted)
|
|
677
|
+
line = ticket.desk_config.acknowledgement_for(ticket)
|
|
678
|
+
return if line.blank?
|
|
679
|
+
|
|
680
|
+
notice = ticket.conversation.post_system_message!(line)
|
|
681
|
+
if notice.created_at <= posted.created_at
|
|
682
|
+
notice.update_columns(created_at: posted.created_at + ordering_tick)
|
|
683
|
+
ticket.conversation.recompute_last_message!
|
|
684
|
+
end
|
|
685
|
+
ticket.conversation.participant_for(ticket.requester)&.read!(at: notice.created_at)
|
|
686
|
+
end
|
|
687
|
+
|
|
634
688
|
# chats orders a transcript by (created_at, id), and inserting one row
|
|
635
689
|
# after another does NOT guarantee two different timestamps: frozen
|
|
636
690
|
# time in a test, a coarse column, a clock that doesn't move between
|
|
@@ -724,7 +778,7 @@ module SupportDesk
|
|
|
724
778
|
def chat_locked_notice
|
|
725
779
|
return I18n.t("support_desk.thread.unavailable_notice") if requester_unavailable?
|
|
726
780
|
|
|
727
|
-
I18n.t("support_desk.thread.closed_notice")
|
|
781
|
+
I18n.t("support_desk.thread.closed_notice", **desk_config.line_interpolations(self))
|
|
728
782
|
end
|
|
729
783
|
|
|
730
784
|
# Whether the person this case belongs to can be written to at all right
|
data/lib/support_desk/version.rb
CHANGED
data/lib/support_desk/wizard.rb
CHANGED
|
@@ -179,11 +179,7 @@ module SupportDesk
|
|
|
179
179
|
# The promise shown next to the composer ("we usually reply in under a
|
|
180
180
|
# day"), built from `config.reply_within` — one setting, one truth: the
|
|
181
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
|
|
182
|
+
def promise = target_desk.config.reply_promise
|
|
187
183
|
|
|
188
184
|
# The promise as a Duration, for hosts that want to phrase it themselves.
|
|
189
185
|
def promise_within = target_desk.config.reply_within
|