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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -0
  3. data/README.md +163 -8
  4. data/app/assets/stylesheets/support_desk.css +77 -0
  5. data/app/controllers/support_desk/application_controller.rb +21 -2
  6. data/app/controllers/support_desk/tickets_controller.rb +98 -9
  7. data/app/helpers/support_desk/engine_helper.rb +75 -10
  8. data/app/helpers/support_desk/thread_helper.rb +29 -0
  9. data/app/views/layouts/support_desk/panel.html.erb +6 -0
  10. data/app/views/support_desk/launcher/_badge.html.erb +6 -0
  11. data/app/views/support_desk/launcher/_icon.html.erb +6 -0
  12. data/app/views/support_desk/launcher/_launcher.html.erb +40 -0
  13. data/app/views/support_desk/launcher/_signed_out.html.erb +25 -0
  14. data/app/views/support_desk/panel/case.html.erb +36 -0
  15. data/app/views/support_desk/panel/home.html.erb +36 -0
  16. data/app/views/support_desk/panel/new.html.erb +8 -0
  17. data/app/views/support_desk/panel/rate_limited.html.erb +5 -0
  18. data/app/views/support_desk/tickets/_limit_wall.html.erb +33 -0
  19. data/app/views/support_desk/tickets/_pick_thing.html.erb +1 -1
  20. data/app/views/support_desk/tickets/_pick_topic.html.erb +1 -1
  21. data/app/views/support_desk/tickets/_wizard_header.html.erb +1 -1
  22. data/app/views/support_desk/tickets/_write.html.erb +4 -3
  23. data/app/views/support_desk/tickets/rate_limited.html.erb +1 -31
  24. data/app/views/support_desk/tickets/show.html.erb +8 -0
  25. data/config/locales/support_desk.en.yml +38 -0
  26. data/config/locales/support_desk.es.yml +38 -0
  27. data/config/routes.rb +4 -0
  28. data/lib/generators/support_desk/templates/initializer.rb +21 -1
  29. data/lib/generators/support_desk/views_generator.rb +9 -3
  30. data/lib/support_desk/brief.rb +1 -1
  31. data/lib/support_desk/configuration.rb +100 -19
  32. data/lib/support_desk/conversation_access.rb +42 -0
  33. data/lib/support_desk/engine.rb +7 -0
  34. data/lib/support_desk/launcher.rb +64 -0
  35. data/lib/support_desk/models/concerns/requester.rb +6 -2
  36. data/lib/support_desk/models/ticket.rb +55 -1
  37. data/lib/support_desk/version.rb +1 -1
  38. data/lib/support_desk/wizard.rb +1 -5
  39. data/lib/support_desk.rb +41 -6
  40. 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. `at_risk_after` is the earlier, softer warning for the queue.
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
@@ -109,7 +109,7 @@ module SupportDesk
109
109
  {
110
110
  key: desk&.key,
111
111
  name: desk&.name,
112
- reply_within: (SupportDesk.humanize_duration(reply_within) if reply_within)
112
+ reply_within: (SupportDesk.humanize_promise(reply_within) if reply_within)
113
113
  }
114
114
  end
115
115
 
@@ -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
- setting = ticket.opened_by_support? ? opening_line_from_support : opening_line
218
- return nil if setting.nil?
219
-
220
- line = case setting
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
- return nil if line.nil?
245
+ end
226
246
 
227
- unless line.is_a?(String)
228
- raise ConfigurationError,
229
- "an opening_line block must return a String or nil, got #{line.inspect}"
230
- end
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
- line
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
- def line_interpolations(ticket)
558
- { label: ticket.label, desk: ticket.desk.name,
559
- reply_within: (SupportDesk.humanize_duration(reply_within) if reply_within) }
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.humanize_duration(reply_within) if reply_within) }
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
@@ -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 = support_tickets.where.not(conversation_id: nil).distinct.pluck(:conversation_id)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SupportDesk
4
- VERSION = "0.3.2"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -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