support_desk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +32 -0
- data/.simplecov +53 -0
- data/Appraisals +18 -0
- data/CHANGELOG.md +135 -0
- data/LICENSE.txt +21 -0
- data/README.md +469 -0
- data/Rakefile +55 -0
- data/app/assets/stylesheets/support_desk.css +242 -0
- data/app/controllers/support_desk/application_controller.rb +92 -0
- data/app/controllers/support_desk/console/application_controller.rb +18 -0
- data/app/controllers/support_desk/console/tickets_controller.rb +26 -0
- data/app/controllers/support_desk/tickets_controller.rb +119 -0
- data/app/helpers/support_desk/engine_helper.rb +196 -0
- data/app/views/chats/slots/_inbox_top.html.erb +31 -0
- data/app/views/chats/slots/_locked_composer.html.erb +18 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +29 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +60 -0
- data/app/views/support_desk/console/tickets/_composer.html.erb +71 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +53 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +48 -0
- data/app/views/support_desk/console/tickets/_nav_badge.html.erb +19 -0
- data/app/views/support_desk/console/tickets/_tabs.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +56 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +37 -0
- data/app/views/support_desk/console/tickets/_transcript.html.erb +26 -0
- data/app/views/support_desk/console/tickets/index.html.erb +37 -0
- data/app/views/support_desk/console/tickets/show.html.erb +45 -0
- data/app/views/support_desk/tickets/_context_card.html.erb +14 -0
- data/app/views/support_desk/tickets/_door.html.erb +12 -0
- data/app/views/support_desk/tickets/_pick_thing.html.erb +55 -0
- data/app/views/support_desk/tickets/_pick_topic.html.erb +30 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +31 -0
- data/app/views/support_desk/tickets/_wizard_header.html.erb +23 -0
- data/app/views/support_desk/tickets/_write.html.erb +56 -0
- data/app/views/support_desk/tickets/index.html.erb +53 -0
- data/app/views/support_desk/tickets/new.html.erb +14 -0
- data/app/views/support_desk/tickets/rate_limited.html.erb +40 -0
- data/config/console_routes.rb +16 -0
- data/config/locales/support_desk.console.en.yml +93 -0
- data/config/locales/support_desk.console.es.yml +93 -0
- data/config/locales/support_desk.en.yml +79 -0
- data/config/locales/support_desk.es.yml +84 -0
- data/config/routes.rb +24 -0
- data/context7.json +4 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/gemfiles/rails_8.0.gemfile +34 -0
- data/gemfiles/rails_8.1.gemfile +34 -0
- data/lib/generators/support_desk/console_generator.rb +94 -0
- data/lib/generators/support_desk/install_generator.rb +86 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +43 -0
- data/lib/generators/support_desk/templates/console/resource.rb.erb +44 -0
- data/lib/generators/support_desk/templates/create_support_desk_tables.rb.erb +224 -0
- data/lib/generators/support_desk/templates/initializer.rb +186 -0
- data/lib/generators/support_desk/views_generator.rb +50 -0
- data/lib/support_desk/configuration.rb +675 -0
- data/lib/support_desk/console.rb +487 -0
- data/lib/support_desk/console_engine.rb +63 -0
- data/lib/support_desk/console_routes.rb +107 -0
- data/lib/support_desk/context_card.rb +90 -0
- data/lib/support_desk/current.rb +26 -0
- data/lib/support_desk/doctor.rb +220 -0
- data/lib/support_desk/engine.rb +141 -0
- data/lib/support_desk/errors.rb +49 -0
- data/lib/support_desk/events.rb +122 -0
- data/lib/support_desk/macros.rb +73 -0
- data/lib/support_desk/models/application_record.rb +11 -0
- data/lib/support_desk/models/assignment.rb +83 -0
- data/lib/support_desk/models/concerns/agent.rb +79 -0
- data/lib/support_desk/models/concerns/requester.rb +71 -0
- data/lib/support_desk/models/concerns/supportable.rb +88 -0
- data/lib/support_desk/models/desk.rb +101 -0
- data/lib/support_desk/models/event.rb +72 -0
- data/lib/support_desk/models/ticket.rb +1124 -0
- data/lib/support_desk/queue.rb +171 -0
- data/lib/support_desk/summary.rb +70 -0
- data/lib/support_desk/test_helpers.rb +137 -0
- data/lib/support_desk/timeline.rb +104 -0
- data/lib/support_desk/topic.rb +290 -0
- data/lib/support_desk/topic_tree.rb +214 -0
- data/lib/support_desk/version.rb +5 -0
- data/lib/support_desk/wizard.rb +392 -0
- data/lib/support_desk.rb +288 -0
- metadata +229 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* support_desk — bundled default styles for the requester-facing screens.
|
|
3
|
+
*
|
|
4
|
+
* Self-contained for the same reason chats' are: an engine's views can't
|
|
5
|
+
* lean on the host's CSS framework, because a Tailwind host never scans gem
|
|
6
|
+
* view files and the utility classes used here would simply not exist in its
|
|
7
|
+
* build. Semantic support-desk-* classes + plain modern CSS work in ANY host
|
|
8
|
+
* with zero build integration.
|
|
9
|
+
*
|
|
10
|
+
* The list and its rows deliberately reuse chats' own classes (.chats-row,
|
|
11
|
+
* .chats-badge, .chats-empty) — a support case IS a conversation, and it
|
|
12
|
+
* should look like one. This file only adds what chats has no opinion about:
|
|
13
|
+
* the wizard, the choices, the composer and the doors.
|
|
14
|
+
*
|
|
15
|
+
* Theme with CSS variables (chats' are inherited where they overlap):
|
|
16
|
+
*
|
|
17
|
+
* :root {
|
|
18
|
+
* --support-desk-accent: #facc15;
|
|
19
|
+
* --support-desk-accent-contrast: #111827;
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* `rails g support_desk:views` ejects the templates into your app — restyle
|
|
23
|
+
* them with your own framework and skip this file (it's linked by the views
|
|
24
|
+
* through the support_desk_styles helper).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
:root {
|
|
28
|
+
--support-desk-card-bg: #ffffff;
|
|
29
|
+
--support-desk-accent: var(--chats-accent, #111827);
|
|
30
|
+
--support-desk-accent-contrast: var(--chats-accent-contrast, #ffffff);
|
|
31
|
+
--support-desk-surface: var(--chats-surface, #f3f4f6);
|
|
32
|
+
--support-desk-border: var(--chats-border, rgba(0, 0, 0, 0.08));
|
|
33
|
+
--support-desk-text: var(--chats-text, #111827);
|
|
34
|
+
--support-desk-text-muted: var(--chats-text-muted, #6b7280);
|
|
35
|
+
--support-desk-radius: var(--chats-radius, 1.1rem);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.support-desk { color: var(--support-desk-text); }
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* Every box this file draws sizes itself INCLUDING its padding and border.
|
|
42
|
+
* A gem cannot assume the host ships a `* { box-sizing: border-box }` reset,
|
|
43
|
+
* and without it `width: 100%` plus 1rem of padding is 2rem wider than the
|
|
44
|
+
* screen — which on the composer, the one screen this gem exists for, means
|
|
45
|
+
* the page scrolls sideways on a phone.
|
|
46
|
+
*/
|
|
47
|
+
.support-desk,
|
|
48
|
+
.support-desk *,
|
|
49
|
+
.support-desk *::before,
|
|
50
|
+
.support-desk *::after,
|
|
51
|
+
.support-desk-door,
|
|
52
|
+
.support-desk-choice,
|
|
53
|
+
.support-desk-card,
|
|
54
|
+
.support-desk-notice,
|
|
55
|
+
.support-desk-form__input,
|
|
56
|
+
.support-desk-button { box-sizing: border-box; }
|
|
57
|
+
|
|
58
|
+
/* --- Doors ------------------------------------------------------------------ */
|
|
59
|
+
|
|
60
|
+
.support-desk-door {
|
|
61
|
+
display: flex;
|
|
62
|
+
gap: 0.85rem;
|
|
63
|
+
align-items: center;
|
|
64
|
+
margin: 0.5rem 1.25rem;
|
|
65
|
+
padding: 0.85rem 1rem;
|
|
66
|
+
border: 1px dashed var(--support-desk-border);
|
|
67
|
+
border-radius: var(--support-desk-radius);
|
|
68
|
+
text-decoration: none;
|
|
69
|
+
color: inherit;
|
|
70
|
+
}
|
|
71
|
+
.support-desk-door:hover { background: var(--support-desk-surface); }
|
|
72
|
+
.support-desk-door__icon {
|
|
73
|
+
flex: none;
|
|
74
|
+
width: 2.25rem;
|
|
75
|
+
height: 2.25rem;
|
|
76
|
+
display: grid;
|
|
77
|
+
place-items: center;
|
|
78
|
+
border-radius: 50%;
|
|
79
|
+
background: var(--support-desk-surface);
|
|
80
|
+
font-size: 1.1rem;
|
|
81
|
+
}
|
|
82
|
+
.support-desk-door__body { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
|
|
83
|
+
.support-desk-door__title { font-weight: 650; }
|
|
84
|
+
.support-desk-door__hint { font-size: 0.85rem; color: var(--support-desk-text-muted); }
|
|
85
|
+
|
|
86
|
+
.support-desk-inbox-door { padding-bottom: 0; }
|
|
87
|
+
|
|
88
|
+
/* --- Closed section --------------------------------------------------------- */
|
|
89
|
+
|
|
90
|
+
.support-desk-closed { margin: 0.5rem 0 2rem; }
|
|
91
|
+
.support-desk-closed__summary {
|
|
92
|
+
cursor: pointer;
|
|
93
|
+
padding: 0.75rem 1.25rem;
|
|
94
|
+
font-size: 0.9rem;
|
|
95
|
+
color: var(--support-desk-text-muted);
|
|
96
|
+
}
|
|
97
|
+
.support-desk-closed[open] .support-desk-closed__summary { color: var(--support-desk-text); }
|
|
98
|
+
.support-desk-closed__capped {
|
|
99
|
+
margin: 0 1.25rem 0.5rem;
|
|
100
|
+
font-size: 0.8rem;
|
|
101
|
+
color: var(--support-desk-text-muted);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* --- Wizard ----------------------------------------------------------------- */
|
|
105
|
+
|
|
106
|
+
.support-desk-wizard__header { padding: 1rem 1.25rem 0.25rem; }
|
|
107
|
+
.support-desk-wizard__back {
|
|
108
|
+
display: inline-block;
|
|
109
|
+
margin-bottom: 0.5rem;
|
|
110
|
+
font-size: 0.9rem;
|
|
111
|
+
color: var(--support-desk-text-muted);
|
|
112
|
+
text-decoration: none;
|
|
113
|
+
}
|
|
114
|
+
.support-desk-wizard__back::before { content: "‹ "; }
|
|
115
|
+
.support-desk-wizard__ask { font-size: 1.35rem; font-weight: 750; margin: 0 0 0.5rem; }
|
|
116
|
+
|
|
117
|
+
.support-desk-error {
|
|
118
|
+
margin: 0 1.25rem 0.75rem;
|
|
119
|
+
padding: 0.7rem 0.9rem;
|
|
120
|
+
border-radius: 0.75rem;
|
|
121
|
+
background: #fef2f2;
|
|
122
|
+
color: #991b1b;
|
|
123
|
+
font-size: 0.9rem;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.support-desk-empty { padding: 1.5rem 1.25rem; color: var(--support-desk-text-muted); }
|
|
127
|
+
|
|
128
|
+
/* --- Choices ---------------------------------------------------------------- */
|
|
129
|
+
|
|
130
|
+
.support-desk-choices { list-style: none; margin: 0; padding: 0.25rem 0 1rem; }
|
|
131
|
+
|
|
132
|
+
.support-desk-choice {
|
|
133
|
+
display: flex;
|
|
134
|
+
gap: 0.75rem;
|
|
135
|
+
align-items: center;
|
|
136
|
+
margin: 0.4rem 1.25rem;
|
|
137
|
+
padding: 0.9rem 1rem;
|
|
138
|
+
border: 1px solid var(--support-desk-border);
|
|
139
|
+
border-radius: var(--support-desk-radius);
|
|
140
|
+
background: var(--support-desk-card-bg);
|
|
141
|
+
text-decoration: none;
|
|
142
|
+
color: inherit;
|
|
143
|
+
}
|
|
144
|
+
.support-desk-choice:hover { background: var(--support-desk-surface); }
|
|
145
|
+
.support-desk-choice__icon { flex: none; font-size: 1.2rem; }
|
|
146
|
+
.support-desk-choice__label { flex: 1; min-width: 0; font-weight: 600; }
|
|
147
|
+
.support-desk-choice__status {
|
|
148
|
+
flex: none;
|
|
149
|
+
font-size: 0.75rem;
|
|
150
|
+
padding: 0.15rem 0.5rem;
|
|
151
|
+
border-radius: 999px;
|
|
152
|
+
background: var(--support-desk-surface);
|
|
153
|
+
color: var(--support-desk-text-muted);
|
|
154
|
+
}
|
|
155
|
+
.support-desk-choice__chevron { flex: none; color: var(--support-desk-text-muted); }
|
|
156
|
+
.support-desk-choice__hint { flex: none; font-size: 0.8rem; color: var(--support-desk-text-muted); }
|
|
157
|
+
.support-desk-choice--taken { background: var(--support-desk-surface); }
|
|
158
|
+
.support-desk-choice--none { justify-content: center; border-style: dashed; font-weight: 500; }
|
|
159
|
+
|
|
160
|
+
/* --- Context card ----------------------------------------------------------- */
|
|
161
|
+
|
|
162
|
+
.support-desk-card {
|
|
163
|
+
display: flex;
|
|
164
|
+
gap: 0.6rem;
|
|
165
|
+
align-items: center;
|
|
166
|
+
margin: 0 1.25rem 0.75rem;
|
|
167
|
+
padding: 0.8rem 1rem;
|
|
168
|
+
border-radius: var(--support-desk-radius);
|
|
169
|
+
background: var(--support-desk-surface);
|
|
170
|
+
}
|
|
171
|
+
.support-desk-card__label { flex: 1; min-width: 0; font-weight: 600; }
|
|
172
|
+
.support-desk-card__status {
|
|
173
|
+
font-size: 0.75rem;
|
|
174
|
+
padding: 0.15rem 0.5rem;
|
|
175
|
+
border-radius: 999px;
|
|
176
|
+
background: var(--support-desk-card-bg);
|
|
177
|
+
color: var(--support-desk-text-muted);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* --- Composer --------------------------------------------------------------- */
|
|
181
|
+
|
|
182
|
+
.support-desk-form { display: flex; flex-direction: column; gap: 0.75rem; padding: 0 1.25rem 2rem; }
|
|
183
|
+
.support-desk-form__input {
|
|
184
|
+
width: 100%;
|
|
185
|
+
padding: 0.85rem 1rem;
|
|
186
|
+
border: 1px solid var(--support-desk-border);
|
|
187
|
+
border-radius: var(--support-desk-radius);
|
|
188
|
+
background: var(--support-desk-card-bg);
|
|
189
|
+
font: inherit;
|
|
190
|
+
resize: vertical;
|
|
191
|
+
}
|
|
192
|
+
.support-desk-form__attach { font-size: 0.9rem; color: var(--support-desk-text-muted); }
|
|
193
|
+
.support-desk-form__promise { margin: 0; font-size: 0.85rem; color: var(--support-desk-text-muted); }
|
|
194
|
+
|
|
195
|
+
.support-desk-button {
|
|
196
|
+
display: inline-block;
|
|
197
|
+
align-self: flex-start;
|
|
198
|
+
padding: 0.75rem 1.4rem;
|
|
199
|
+
border: 0;
|
|
200
|
+
border-radius: 999px;
|
|
201
|
+
background: var(--support-desk-accent);
|
|
202
|
+
color: var(--support-desk-accent-contrast);
|
|
203
|
+
font: inherit;
|
|
204
|
+
font-weight: 650;
|
|
205
|
+
text-decoration: none;
|
|
206
|
+
cursor: pointer;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* --- Notices ---------------------------------------------------------------- */
|
|
210
|
+
|
|
211
|
+
.support-desk-notice {
|
|
212
|
+
display: flex;
|
|
213
|
+
flex-direction: column;
|
|
214
|
+
gap: 0.75rem;
|
|
215
|
+
align-items: flex-start;
|
|
216
|
+
margin: 0 1.25rem 1.5rem;
|
|
217
|
+
padding: 1rem;
|
|
218
|
+
border-radius: var(--support-desk-radius);
|
|
219
|
+
background: var(--support-desk-surface);
|
|
220
|
+
}
|
|
221
|
+
.support-desk-notice__title { margin: 0 1.25rem 0.75rem; font-weight: 600; }
|
|
222
|
+
.support-desk-notice .support-desk-notice__title { margin: 0; }
|
|
223
|
+
|
|
224
|
+
/* --- Dark ------------------------------------------------------------------- *
|
|
225
|
+
*
|
|
226
|
+
* Only the surfaces this file hardcodes: everything else already comes from
|
|
227
|
+
* a token that chats' own dark handling moves. A host with its own dark
|
|
228
|
+
* palette overrides the variables and never reaches this block.
|
|
229
|
+
*/
|
|
230
|
+
@media (prefers-color-scheme: dark) {
|
|
231
|
+
:root {
|
|
232
|
+
--support-desk-surface: var(--chats-surface, #1f2430);
|
|
233
|
+
--support-desk-border: var(--chats-border, rgba(255, 255, 255, 0.12));
|
|
234
|
+
--support-desk-text: var(--chats-text, #f3f4f6);
|
|
235
|
+
--support-desk-text-muted: var(--chats-text-muted, #9ca3af);
|
|
236
|
+
--support-desk-card-bg: #111827;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* The cards, the composer and the status pills all read --card-bg above;
|
|
240
|
+
only the error notice carries colours of its own. */
|
|
241
|
+
.support-desk-error { background: #3f1d1d; color: #fecaca; }
|
|
242
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# Base controller for every requester-facing screen. It inherits from the
|
|
5
|
+
# HOST's controller (`config.parent_controller`, "::ApplicationController"
|
|
6
|
+
# by default) so the host's layout, helpers, authentication, locale
|
|
7
|
+
# switching and exception handling all apply to the support screens for
|
|
8
|
+
# free — the same integration style as chats.
|
|
9
|
+
#
|
|
10
|
+
# NOTE: the superclass is resolved when this class is autoloaded, which in
|
|
11
|
+
# a booted app happens AFTER initializers — so `config.parent_controller`
|
|
12
|
+
# set in config/initializers/support_desk.rb is honoured. In development
|
|
13
|
+
# the class is reloaded on every change, picking up config changes too.
|
|
14
|
+
class ApplicationController < SupportDesk.config.parent_controller.constantize
|
|
15
|
+
before_action :support_desk_authenticate!
|
|
16
|
+
before_action :ensure_requester!
|
|
17
|
+
before_action :set_support_desk_current
|
|
18
|
+
|
|
19
|
+
helper SupportDesk::EngineHelper
|
|
20
|
+
helper_method :current_requester
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# The person asking for help, via the host-configured method
|
|
25
|
+
# (`current_user` by default — Devise-compatible out of the box).
|
|
26
|
+
def current_requester
|
|
27
|
+
return @current_requester if defined?(@current_requester)
|
|
28
|
+
|
|
29
|
+
method_name = SupportDesk.config.current_requester_method
|
|
30
|
+
unless respond_to?(method_name, true)
|
|
31
|
+
raise SupportDesk::ConfigurationError,
|
|
32
|
+
"support_desk can't find ##{method_name} on #{self.class.superclass.name}. " \
|
|
33
|
+
"Set config.current_requester_method in config/initializers/support_desk.rb " \
|
|
34
|
+
"to the controller method that returns the logged-in #{SupportDesk.config.requester_class}."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@current_requester = send(method_name)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The host's own authentication filter, so a logged-out visitor meets the
|
|
41
|
+
# host's login flow and never this gem's idea of one.
|
|
42
|
+
def support_desk_authenticate!
|
|
43
|
+
method_name = SupportDesk.config.authenticate_method
|
|
44
|
+
unless respond_to?(method_name, true)
|
|
45
|
+
raise SupportDesk::ConfigurationError,
|
|
46
|
+
"support_desk can't find ##{method_name} on #{self.class.superclass.name}. " \
|
|
47
|
+
"Set config.authenticate_method in config/initializers/support_desk.rb " \
|
|
48
|
+
"to your authentication filter (e.g. :authenticate_user! with Devise)."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
send(method_name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A host whose authentication filter lets anonymous requests through gets
|
|
55
|
+
# a 401 rather than a NoMethodError three frames deeper; a host whose
|
|
56
|
+
# current_requester_method returns the wrong model gets the fix in the
|
|
57
|
+
# message, because that is a configuration mistake, not a request one.
|
|
58
|
+
def ensure_requester!
|
|
59
|
+
return head :unauthorized if current_requester.nil?
|
|
60
|
+
return if current_requester.respond_to?(:ask_support!)
|
|
61
|
+
|
|
62
|
+
raise SupportDesk::ConfigurationError,
|
|
63
|
+
"config.current_requester_method (##{SupportDesk.config.current_requester_method}) returned a " \
|
|
64
|
+
"#{current_requester.class}, which doesn't declare `has_support_tickets`. " \
|
|
65
|
+
"Point it at your #{SupportDesk.config.requester_class}, or add the macro to #{current_requester.class}."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Every transition takes `by:`; this is what it falls back to, so a
|
|
69
|
+
# requester's own actions are attributed to them without the views
|
|
70
|
+
# passing an actor around.
|
|
71
|
+
def set_support_desk_current
|
|
72
|
+
SupportDesk::Current.actor = current_requester
|
|
73
|
+
SupportDesk::Current.request = request
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Engine URL helpers for chats, from a controller. The mounted proxy
|
|
77
|
+
# carries the host's mount prefix ("/messages"); the engine's own
|
|
78
|
+
# url_helpers are the prefix-less fallback for a host that hasn't
|
|
79
|
+
# mounted chats under its default name.
|
|
80
|
+
def chats_routes
|
|
81
|
+
respond_to?(:chats) ? chats : Chats::Engine.routes.url_helpers
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Where a case is read and answered: its chats conversation, never a
|
|
85
|
+
# second thread UI of our own.
|
|
86
|
+
def conversation_path_for(ticket)
|
|
87
|
+
return root_path if ticket.conversation.nil?
|
|
88
|
+
|
|
89
|
+
chats_routes.conversation_path(ticket.conversation)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
module Console
|
|
5
|
+
# Base controller for the turnkey console. It inherits from the HOST's
|
|
6
|
+
# controller (`config.console_parent_controller`, "::ApplicationController"
|
|
7
|
+
# by default), so the host's layout, helpers, authentication filters,
|
|
8
|
+
# locale switching and exception handling all apply to the console
|
|
9
|
+
# screens — the same integration style as the requester engine.
|
|
10
|
+
#
|
|
11
|
+
# NOTE: the superclass is resolved when this class is autoloaded, which in
|
|
12
|
+
# a booted app happens AFTER initializers — so a
|
|
13
|
+
# `config.console_parent_controller` set in config/initializers is
|
|
14
|
+
# honoured. In development the class reloads with the rest of the app.
|
|
15
|
+
class ApplicationController < SupportDesk.config.console_parent_controller.constantize
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
module Console
|
|
5
|
+
# The whole turnkey console, and proof the public API is enough: the
|
|
6
|
+
# queue, one case, and the verbs — all of it from the same two concerns
|
|
7
|
+
# a host includes in their own controller, with nothing private.
|
|
8
|
+
#
|
|
9
|
+
# `rails g support_desk:console madmin` writes a controller that reads
|
|
10
|
+
# exactly like this one into the host app, and copies these views next to
|
|
11
|
+
# it. If you find yourself wanting something this controller can't do,
|
|
12
|
+
# that is a gap in Layer 1 or Layer 2 — not a reason to reach in here.
|
|
13
|
+
class TicketsController < ApplicationController
|
|
14
|
+
include SupportDesk::Console
|
|
15
|
+
include SupportDesk::Console::Index
|
|
16
|
+
|
|
17
|
+
# One case: the context card, the transcript, the timeline, and exactly
|
|
18
|
+
# the buttons `actions_for` says this agent may press. `@ticket` is set
|
|
19
|
+
# by the concern, scoped to the desks this agent may work.
|
|
20
|
+
def show
|
|
21
|
+
@context_card = @ticket.context_card
|
|
22
|
+
@actions = @ticket.actions_for(current_agent)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# The four screens a person who needs help ever sees: their open cases, the
|
|
5
|
+
# three-step wizard that opens a new one, and the door into the
|
|
6
|
+
# conversation behind a case.
|
|
7
|
+
#
|
|
8
|
+
# The step machine is SupportDesk::Wizard, not this controller: a host that
|
|
9
|
+
# ejects the views, a native app or a JSON API all drive the same steps, so
|
|
10
|
+
# nothing here decides anything the wizard could decide.
|
|
11
|
+
class TicketsController < ApplicationController
|
|
12
|
+
# How many closed cases the collapsed section lists. Support histories
|
|
13
|
+
# only grow, and nobody scrolls a year of them on a phone.
|
|
14
|
+
CLOSED_TICKETS_SHOWN = 20
|
|
15
|
+
|
|
16
|
+
before_action :set_wizard, only: %i[new create]
|
|
17
|
+
|
|
18
|
+
# Their cases: open ones as rows, closed ones folded away, and the door
|
|
19
|
+
# to open another.
|
|
20
|
+
def index
|
|
21
|
+
tickets = current_requester.support_tickets.includes(:desk, :subject, conversation: { last_message: :sender })
|
|
22
|
+
@open_tickets = tickets.not_closed.recent_activity_first.to_a
|
|
23
|
+
@closed_tickets = tickets.closed.newest_first.limit(CLOSED_TICKETS_SHOWN).to_a
|
|
24
|
+
@closed_count = tickets.closed.count
|
|
25
|
+
@unread_counts = unread_counts_for(@open_tickets + @closed_tickets)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The wizard. One URL, three frames — which one renders is the wizard's
|
|
29
|
+
# answer, never a param we trust.
|
|
30
|
+
def new
|
|
31
|
+
render :new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Open the case and hand the requester straight to the conversation.
|
|
35
|
+
#
|
|
36
|
+
# Two submits land in the same TICKET, because `Ticket.open!` treats a
|
|
37
|
+
# unique-index collision as "you already opened this one" — but they
|
|
38
|
+
# post two opening messages into it, so the form also disables its own
|
|
39
|
+
# button for the length of the submit (`turbo_submits_with`).
|
|
40
|
+
def create
|
|
41
|
+
if message.blank? && files.empty?
|
|
42
|
+
@error = t("support_desk.wizard.message_required")
|
|
43
|
+
return render :new, status: :unprocessable_entity
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
ticket = @wizard.open!(message, files: files)
|
|
47
|
+
redirect_to conversation_path_for(ticket)
|
|
48
|
+
rescue SupportDesk::RateLimited, SupportDesk::TooManyOpenTickets => e
|
|
49
|
+
render_limit_wall(e)
|
|
50
|
+
rescue SupportDesk::InvalidTransition
|
|
51
|
+
# The form was submitted from a step that isn't the composer (a stale
|
|
52
|
+
# tab, a hand-rolled POST): re-render the step they are actually on.
|
|
53
|
+
render :new, status: :unprocessable_entity
|
|
54
|
+
rescue SupportDesk::NotAllowed, SupportDesk::NotSupportable
|
|
55
|
+
# The subject passed the wizard's check and failed the model's. Same
|
|
56
|
+
# answer as a forged token: 404.
|
|
57
|
+
raise ActiveRecord::RecordNotFound
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A stable URL for a case — what an email or a push notification links
|
|
61
|
+
# to. The case is READ in its conversation, so this is a redirect and
|
|
62
|
+
# nothing else.
|
|
63
|
+
def show
|
|
64
|
+
redirect_to conversation_path_for(find_ticket)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def set_wizard
|
|
70
|
+
@wizard = SupportDesk::Wizard.new(current_requester, params)
|
|
71
|
+
# A token that is forged, expired, or points at somebody else's record
|
|
72
|
+
# is a 404, never a 403 with a hint: "not yours" and "not there" must
|
|
73
|
+
# look the same from outside.
|
|
74
|
+
raise ActiveRecord::RecordNotFound if @wizard.subject_rejected?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# By id, or by the reference people read down a phone line ("T-AB12CD").
|
|
78
|
+
# Always through the requester's OWN tickets, so somebody else's case is
|
|
79
|
+
# a 404 rather than a redirect into a conversation they can't read.
|
|
80
|
+
def find_ticket
|
|
81
|
+
scope = current_requester.support_tickets
|
|
82
|
+
reference = SupportDesk::Ticket.normalize_reference(params[:id])
|
|
83
|
+
|
|
84
|
+
if params[:id].to_s.upcase.start_with?(SupportDesk::Ticket::REFERENCE_PREFIX)
|
|
85
|
+
scope.find_by!(reference: reference)
|
|
86
|
+
else
|
|
87
|
+
scope.find(params[:id])
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def message
|
|
92
|
+
params[:message].to_s
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def files
|
|
96
|
+
Array(params[:files]).reject(&:blank?)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# "Ya tienes N conversaciones abiertas" — a wall with the way out on it
|
|
100
|
+
# (their open cases), never a 500 and never a silent failure.
|
|
101
|
+
def render_limit_wall(error)
|
|
102
|
+
@limit_reason = error.is_a?(SupportDesk::TooManyOpenTickets) ? "too_many_open" : "too_fast"
|
|
103
|
+
@open_tickets = current_requester.support_tickets.not_closed
|
|
104
|
+
.includes(:desk, :subject, conversation: { last_message: :sender })
|
|
105
|
+
.recent_activity_first.to_a
|
|
106
|
+
@unread_counts = unread_counts_for(@open_tickets)
|
|
107
|
+
render :rate_limited, status: :too_many_requests
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# One grouped query for every row's badge (see
|
|
111
|
+
# Chats::Conversation.unread_counts_for).
|
|
112
|
+
def unread_counts_for(tickets)
|
|
113
|
+
ids = tickets.filter_map(&:conversation_id)
|
|
114
|
+
return {} if ids.empty?
|
|
115
|
+
|
|
116
|
+
Chats::Conversation.unread_counts_for(current_requester, ids)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|