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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +206 -0
- data/README.md +227 -16
- data/app/controllers/support_desk/application_controller.rb +5 -2
- data/app/controllers/support_desk/tickets_controller.rb +68 -3
- data/app/helpers/support_desk/engine_helper.rb +10 -10
- data/app/helpers/support_desk/thread_helper.rb +29 -0
- data/app/views/support_desk/tickets/show.html.erb +8 -0
- data/config/locales/support_desk.en.yml +28 -0
- data/config/locales/support_desk.es.yml +28 -0
- data/lib/generators/support_desk/install_generator.rb +9 -3
- data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
- data/lib/generators/support_desk/templates/initializer.rb +21 -1
- data/lib/generators/support_desk/upgrade_generator.rb +15 -7
- 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/doctor.rb +30 -3
- data/lib/support_desk/engine.rb +13 -1
- data/lib/support_desk/message_signatures.rb +20 -0
- data/lib/support_desk/models/assistant.rb +43 -8
- data/lib/support_desk/models/draft.rb +9 -0
- data/lib/support_desk/models/message_registration.rb +12 -0
- data/lib/support_desk/models/ticket/assistance.rb +254 -30
- data/lib/support_desk/models/ticket.rb +172 -67
- data/lib/support_desk/transcript.rb +9 -12
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +1 -5
- data/lib/support_desk.rb +82 -11
- data/lib/tasks/support_desk.rake +12 -1
- 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.
|
|
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
|
-
|
|
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
|
|
88
|
-
# desk promises nothing. The same `config.reply_within` the SLA
|
|
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
|
-
|
|
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
|
|
182
|
-
#
|
|
183
|
-
# the
|
|
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
|
|
94
|
-
say " nothing you own. Follow the
|
|
95
|
-
say "
|
|
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
|
|
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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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 (
|
|
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.
|
|
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
|
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:
|