support_desk 0.3.0 → 0.4.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +206 -0
  3. data/README.md +227 -16
  4. data/app/controllers/support_desk/application_controller.rb +5 -2
  5. data/app/controllers/support_desk/tickets_controller.rb +68 -3
  6. data/app/helpers/support_desk/engine_helper.rb +10 -10
  7. data/app/helpers/support_desk/thread_helper.rb +29 -0
  8. data/app/views/support_desk/tickets/show.html.erb +8 -0
  9. data/config/locales/support_desk.en.yml +28 -0
  10. data/config/locales/support_desk.es.yml +28 -0
  11. data/lib/generators/support_desk/install_generator.rb +9 -3
  12. data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
  13. data/lib/generators/support_desk/templates/initializer.rb +21 -1
  14. data/lib/generators/support_desk/upgrade_generator.rb +15 -7
  15. data/lib/support_desk/brief.rb +1 -1
  16. data/lib/support_desk/configuration.rb +100 -19
  17. data/lib/support_desk/conversation_access.rb +42 -0
  18. data/lib/support_desk/doctor.rb +30 -3
  19. data/lib/support_desk/engine.rb +13 -1
  20. data/lib/support_desk/message_signatures.rb +20 -0
  21. data/lib/support_desk/models/assistant.rb +43 -8
  22. data/lib/support_desk/models/draft.rb +9 -0
  23. data/lib/support_desk/models/message_registration.rb +12 -0
  24. data/lib/support_desk/models/ticket/assistance.rb +254 -30
  25. data/lib/support_desk/models/ticket.rb +172 -67
  26. data/lib/support_desk/transcript.rb +9 -12
  27. data/lib/support_desk/version.rb +1 -1
  28. data/lib/support_desk/wizard.rb +1 -5
  29. data/lib/support_desk.rb +82 -11
  30. data/lib/tasks/support_desk.rake +12 -1
  31. metadata +20 -11
@@ -16,6 +16,11 @@ module SupportDesk
16
16
  before_action :set_wizard, only: %i[new create]
17
17
  include Chats::SendRateLimited
18
18
 
19
+ # The ticket page renders chats' thread template, which names chats'
20
+ # routes bare (SupportDesk::ThreadHelper) and asks for these two.
21
+ helper SupportDesk::ThreadHelper
22
+ helper_method :chats_current_messager, :chats_counterpart
23
+
19
24
  # Their cases: open ones as rows, closed ones folded away, and the door
20
25
  # to open another.
21
26
  def index
@@ -67,10 +72,15 @@ module SupportDesk
67
72
  end
68
73
 
69
74
  # A stable URL for a case — what an email or a push notification links
70
- # to. The case is READ in its conversation, so this is a redirect and
71
- # nothing else.
75
+ # to. Under `thread_page = :chats` the case is read in the inbox's thread,
76
+ # so this is a redirect and nothing else. Under `:support` it is the page
77
+ # the case is read on: chats' own thread, with its back link leading
78
+ # here rather than to an inbox support is not part of.
72
79
  def show
73
- redirect_to conversation_path_for(find_ticket)
80
+ ticket = find_ticket
81
+ return redirect_to conversation_path_for(ticket) unless SupportDesk.config.support_thread_page? && ticket.conversation
82
+
83
+ render_thread(ticket)
74
84
  end
75
85
 
76
86
  # "Prefiero hablar con una persona." The one thing a requester can
@@ -96,6 +106,61 @@ module SupportDesk
96
106
 
97
107
  def chat_rate_limit_messager = current_requester
98
108
 
109
+ # chats' thread page, rendered here: the same latest page of messages,
110
+ # the same «new messages» line and the same read on open as
111
+ # Chats::ConversationsController#show, for chats' own template. Older
112
+ # pages, the composer and the live updates are chats' endpoints, which
113
+ # reach this conversation through SupportDesk::ConversationAccess.
114
+ def render_thread(ticket)
115
+ @ticket = ticket
116
+ @conversation = chats_conversation(ticket)
117
+ @participant = @conversation.participant_for(current_requester)
118
+
119
+ scope = @conversation.messages.includes(:sender, :reactions)
120
+ scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
121
+ page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
122
+ @more_messages = page.size > Chats.config.messages_per_page
123
+ @messages = page.first(Chats.config.messages_per_page).reverse
124
+
125
+ if @participant&.unread?
126
+ @first_unread_id = @participant.unread_messages.where(id: @messages.map(&:id)).oldest_first.pick(:id) ||
127
+ @messages.first&.id
128
+ end
129
+ # A pointer resting on a link (Turbo's hover prefetch) is not somebody
130
+ # reading the case; the visit that follows reads it. Read through the
131
+ # last message on the page, not merely "now": an acknowledgement pinned
132
+ # one tick after the message it answers may sit just ahead of the clock.
133
+ @participant&.read!(at: [ Time.current, @messages.last&.created_at ].compact.max) unless prefetch_request?
134
+
135
+ render :show
136
+ end
137
+
138
+ # The case's conversation, as chats' own endpoints would find it: chats'
139
+ # `find_conversation` on a chats controller bound to this request, so
140
+ # every rule the host or a product gem added there (its lookup scopes, its
141
+ # own `find_conversation`) decides here exactly as at the chats URL. Then
142
+ # it must be this very case's conversation: direct, about this case (the
143
+ # rule Ticket.conversations_of holds the chats lookup to). A 404
144
+ # otherwise, never a hint.
145
+ def chats_conversation(ticket)
146
+ lookup = Chats::ApplicationController.new
147
+ lookup.set_request!(request)
148
+ conversation = lookup.send(:find_conversation, ticket.conversation_id)
149
+ raise ActiveRecord::RecordNotFound unless conversation.direct? &&
150
+ conversation.subject_type == ticket.class.polymorphic_name &&
151
+ conversation.subject_id.to_s == ticket.id.to_s
152
+
153
+ conversation
154
+ end
155
+
156
+ def prefetch_request?
157
+ request.headers["X-Sec-Purpose"] == "prefetch" || request.headers["Sec-Purpose"].to_s.include?("prefetch")
158
+ end
159
+
160
+ # What chats' thread template asks its controller for.
161
+ def chats_current_messager = current_requester
162
+ def chats_counterpart = @conversation&.counterpart_for(current_requester)
163
+
99
164
  def set_wizard
100
165
  @wizard = SupportDesk::Wizard.new(current_requester, params)
101
166
  # A token that is forged, expired, or points at somebody else's record
@@ -84,14 +84,11 @@ module SupportDesk
84
84
  content_for(:head) { stylesheet_link_tag "support_desk", "data-turbo-track": "reload" }
85
85
  end
86
86
 
87
- # "Normalmente respondemos en menos de 24 h" for a desk, or nil when the
88
- # desk promises nothing. The same `config.reply_within` the SLA breaches
89
- # on — one setting, one truth.
87
+ # "Normalmente respondemos en menos de 24 horas" for a desk, or nil when
88
+ # the desk promises nothing. The same `config.reply_within` the SLA
89
+ # breaches on — one setting, one truth.
90
90
  def support_reply_promise(desk_key = :default)
91
- within = SupportDesk.config.desk(desk_key).reply_within
92
- return nil if within.nil?
93
-
94
- t("support_desk.thread.promise", time: SupportDesk.humanize_duration(within))
91
+ SupportDesk.config.desk(desk_key).reply_promise
95
92
  end
96
93
 
97
94
  # The status line under a case in the requester's list: who owes the next
@@ -178,10 +175,13 @@ module SupportDesk
178
175
  chats_verified_badge(support_desk_record(desk_key))
179
176
  end
180
177
 
181
- # Where a case is read and answered: its chats conversation. Falls back
182
- # to the engine's own ticket URL (which redirects to the same place) for
183
- # the one case that has no conversation yet, so a link is never dead.
178
+ # Where a case is read and answered: its chats conversation, or the
179
+ # engine's own ticket page under `thread_page = :support` (by reference:
180
+ # the URL people read back from a screen). Falls back to the ticket URL
181
+ # (which redirects to the same place) for the one case that has no
182
+ # conversation yet, so a link is never dead.
184
183
  def support_thread_path(ticket)
184
+ return support_desk_routes.ticket_path(ticket.reference) if SupportDesk.config.support_thread_page?
185
185
  return support_desk_routes.ticket_path(ticket) if ticket.conversation.nil? || !respond_to?(:chats_routes)
186
186
 
187
187
  chats_routes.conversation_path(ticket.conversation)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # The ticket page (`config.thread_page = :support`) renders chats' own
5
+ # thread template, and that template names chats' routes bare:
6
+ # `conversation_path`, `read_conversation_path`,
7
+ # `conversation_messages_path`… Inside chats' engine those are its own;
8
+ # rendered from this engine they would be looked up here and not exist.
9
+ #
10
+ # So on that page any route helper this engine does not have is chats',
11
+ # through its mounted proxy (`chats_routes`), and the one name both
12
+ # engines draw stays ours: `root_path` is the support list, which is
13
+ # exactly where the thread's way back should lead.
14
+ module ThreadHelper
15
+ def method_missing(name, ...)
16
+ chats_route?(name) ? chats_routes.public_send(name, ...) : super
17
+ end
18
+
19
+ def respond_to_missing?(name, include_private = false)
20
+ chats_route?(name) || super
21
+ end
22
+
23
+ private
24
+
25
+ def chats_route?(name)
26
+ name.end_with?("_path", "_url") && chats_routes.respond_to?(name)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,8 @@
1
+ <%# One case, read where support lives (`config.thread_page = :support`).
2
+
3
+ It is chats' thread: the same template, so the same bubbles, composer,
4
+ read state and live updates as every conversation in the app, with the
5
+ desk's verified name in its header. Only the way back differs: its
6
+ `root_path` is this engine's, the support list (SupportDesk::ThreadHelper).
7
+ Eject this file to put anything of your own around it. %>
8
+ <%= render template: "chats/conversations/show" %>
@@ -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"
@@ -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"
@@ -44,6 +44,12 @@ module SupportDesk
44
44
  File.join(db_migrate_path, "add_assistants_to_support_desk.rb")
45
45
  end
46
46
 
47
+ def create_message_registrations_migration
48
+ migration_template "create_support_desk_message_registrations.rb.erb",
49
+ File.join(db_migrate_path, "create_support_desk_message_registrations.rb")
50
+ end
51
+
52
+
47
53
  # The annotated initializer — every setting the gem has, with what it
48
54
  # means and what it defaults to.
49
55
  def create_initializer
@@ -90,9 +96,9 @@ module SupportDesk
90
96
  say " # routing(/^support@/i => :support_desk)"
91
97
 
92
98
  say "\n Already installed and bumping the version? 'rails g support_desk:upgrade' copies"
93
- say " only the migrations the new version needs (0.2.0: who opened the case), and"
94
- say " nothing you own. Follow the CHANGELOG's drained cutover: migrate, pause"
95
- say " support traffic, drain old web/workers, backfill, then serve only 0.2."
99
+ say " only the migrations the new version needs (including 0.3.2 receipts), and"
100
+ say " nothing you own. Follow the README drained cutover: pause support writes,"
101
+ say " drain old web/workers, migrate, then start only the new version."
96
102
 
97
103
  say "\n Assistants (0.3): when a machine should answer first, 'rails g support_desk:assistant"
98
104
  say " Rose --disclosure signature' writes the harness and prints the stanza. Read the"
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stop support writes and drain old web/jobs before migrating. Old processes
4
+ # cannot write receipts. Existing messages at/before the legacy clocks form
5
+ # the historical baseline; messages ahead of those clocks remain repairable.
6
+ # A pre-upgrade lost callback behind a clock cannot be inferred from old data:
7
+ # review historical suspect cases before resuming writes (see README).
8
+ class CreateSupportDeskMessageRegistrations < ActiveRecord::Migration<%= migration_version %>
9
+ def up
10
+ return if table_exists?(:support_desk_message_registrations)
11
+
12
+ create_table :support_desk_message_registrations, id: false do |t|
13
+ t.column :message_id, column_type(:chats_messages, "id"), null: false
14
+ t.column :ticket_id, column_type(:support_desk_tickets, "id"), null: false
15
+ end
16
+ add_index :support_desk_message_registrations, :message_id, unique: true, name: "index_sd_registrations_message"
17
+ add_index :support_desk_message_registrations, :ticket_id, name: "index_sd_registrations_ticket"
18
+ add_foreign_key :support_desk_message_registrations, :support_desk_tickets, column: :ticket_id, on_delete: :cascade
19
+ add_foreign_key :support_desk_message_registrations, :chats_messages, column: :message_id, on_delete: :cascade
20
+
21
+ execute <<~SQL
22
+ INSERT INTO support_desk_message_registrations (message_id, ticket_id)
23
+ SELECT m.id, t.id FROM chats_messages m
24
+ JOIN support_desk_tickets t ON t.conversation_id = m.conversation_id
25
+ WHERE m.kind = 'text' AND (
26
+ (m.sender_type = t.requester_type AND m.sender_id = t.requester_id
27
+ AND m.created_at <= t.last_requester_message_at)
28
+ OR (m.sender_type = 'SupportDesk::Desk' AND m.sender_id = t.desk_id
29
+ AND m.created_at <= t.last_agent_message_at))
30
+ SQL
31
+ end
32
+
33
+ def down
34
+ drop_table :support_desk_message_registrations, if_exists: true
35
+ end
36
+
37
+ private
38
+
39
+ def column_type(table, name)
40
+ connection.columns(table).find { |column| column.name == name }.sql_type
41
+ end
42
+ end
@@ -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
@@ -9,16 +9,14 @@ module SupportDesk
9
9
  # bump needs into an EXISTING install. Nothing else: the initializer, the
10
10
  # views, the console and the routes you already own stay untouched.
11
11
  #
12
- # Currently writes the 0.2.0 migration (who opened the case), which is the
13
- # SAME template a fresh install runs — one file owns those columns, so a
14
- # fresh install and an upgraded one end up with the same schema and the
15
- # same rollback. Running this twice writes nothing the second time: the
16
- # migration already sitting in db/migrate is identical, and Rails skips it.
12
+ # Install and upgrade use the same templates for opened_by, assistants,
13
+ # and message receipts. See the README for the drained receipt cutover;
14
+ # old writers do not know how to record message identities.
17
15
  class UpgradeGenerator < Rails::Generators::Base
18
16
  include ActiveRecord::Generators::Migration
19
17
 
20
18
  source_root File.expand_path("templates", __dir__)
21
- desc "Add the migrations a support_desk version bump needs (0.2.0: opened_by, 0.3.0: assistants)"
19
+ desc "Add the migrations a support_desk version bump needs (opened_by, assistants, message registration receipts)"
22
20
 
23
21
  # Rails' migration numbering, borrowed from ActiveRecord's generators.
24
22
  def self.next_migration_number(dir)
@@ -35,6 +33,12 @@ module SupportDesk
35
33
  File.join(db_migrate_path, "add_assistants_to_support_desk.rb")
36
34
  end
37
35
 
36
+ def create_message_registrations_migration
37
+ migration_template "create_support_desk_message_registrations.rb.erb",
38
+ File.join(db_migrate_path, "create_support_desk_message_registrations.rb")
39
+ end
40
+
41
+
38
42
  def display_post_upgrade_message
39
43
  say "\n🎫 support_desk upgrade migrations copied.", :green
40
44
  say "\n 1. Run 'rails db:migrate'. It adds `opened_by` and points every existing"
@@ -53,7 +57,11 @@ module SupportDesk
53
57
  say " rails g support_desk:assistant Rose --disclosure signature"
54
58
  say " writes the harness and prints the stanza, the subscription and the two"
55
59
  say " scheduled tasks. It never edits your initializer."
56
- say " 5. See the CHANGELOG for the full list.\n", :green
60
+ say " 5. 0.3.2 receipts require a drained cutover: pause support writes and stop"
61
+ say " old web/jobs, migrate, then start only the new version. Review historical"
62
+ say " suspect callbacks before resuming; old clocks cannot prove delivery."
63
+ say " Do not roll code back to a writer that cannot record receipts."
64
+ say " 6. See the CHANGELOG for the full list.\n", :green
57
65
  end
58
66
 
59
67
  private
@@ -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: