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.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +32 -0
  3. data/.simplecov +53 -0
  4. data/Appraisals +18 -0
  5. data/CHANGELOG.md +135 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +469 -0
  8. data/Rakefile +55 -0
  9. data/app/assets/stylesheets/support_desk.css +242 -0
  10. data/app/controllers/support_desk/application_controller.rb +92 -0
  11. data/app/controllers/support_desk/console/application_controller.rb +18 -0
  12. data/app/controllers/support_desk/console/tickets_controller.rb +26 -0
  13. data/app/controllers/support_desk/tickets_controller.rb +119 -0
  14. data/app/helpers/support_desk/engine_helper.rb +196 -0
  15. data/app/views/chats/slots/_inbox_top.html.erb +31 -0
  16. data/app/views/chats/slots/_locked_composer.html.erb +18 -0
  17. data/app/views/support_desk/console/tickets/_actions.html.erb +29 -0
  18. data/app/views/support_desk/console/tickets/_assignment.html.erb +60 -0
  19. data/app/views/support_desk/console/tickets/_composer.html.erb +71 -0
  20. data/app/views/support_desk/console/tickets/_context_card.html.erb +53 -0
  21. data/app/views/support_desk/console/tickets/_message.html.erb +48 -0
  22. data/app/views/support_desk/console/tickets/_nav_badge.html.erb +19 -0
  23. data/app/views/support_desk/console/tickets/_tabs.html.erb +15 -0
  24. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +56 -0
  25. data/app/views/support_desk/console/tickets/_timeline.html.erb +37 -0
  26. data/app/views/support_desk/console/tickets/_transcript.html.erb +26 -0
  27. data/app/views/support_desk/console/tickets/index.html.erb +37 -0
  28. data/app/views/support_desk/console/tickets/show.html.erb +45 -0
  29. data/app/views/support_desk/tickets/_context_card.html.erb +14 -0
  30. data/app/views/support_desk/tickets/_door.html.erb +12 -0
  31. data/app/views/support_desk/tickets/_pick_thing.html.erb +55 -0
  32. data/app/views/support_desk/tickets/_pick_topic.html.erb +30 -0
  33. data/app/views/support_desk/tickets/_ticket_row.html.erb +31 -0
  34. data/app/views/support_desk/tickets/_wizard_header.html.erb +23 -0
  35. data/app/views/support_desk/tickets/_write.html.erb +56 -0
  36. data/app/views/support_desk/tickets/index.html.erb +53 -0
  37. data/app/views/support_desk/tickets/new.html.erb +14 -0
  38. data/app/views/support_desk/tickets/rate_limited.html.erb +40 -0
  39. data/config/console_routes.rb +16 -0
  40. data/config/locales/support_desk.console.en.yml +93 -0
  41. data/config/locales/support_desk.console.es.yml +93 -0
  42. data/config/locales/support_desk.en.yml +79 -0
  43. data/config/locales/support_desk.es.yml +84 -0
  44. data/config/routes.rb +24 -0
  45. data/context7.json +4 -0
  46. data/gemfiles/rails_7.2.gemfile +34 -0
  47. data/gemfiles/rails_8.0.gemfile +34 -0
  48. data/gemfiles/rails_8.1.gemfile +34 -0
  49. data/lib/generators/support_desk/console_generator.rb +94 -0
  50. data/lib/generators/support_desk/install_generator.rb +86 -0
  51. data/lib/generators/support_desk/templates/console/controller.rb.erb +43 -0
  52. data/lib/generators/support_desk/templates/console/resource.rb.erb +44 -0
  53. data/lib/generators/support_desk/templates/create_support_desk_tables.rb.erb +224 -0
  54. data/lib/generators/support_desk/templates/initializer.rb +186 -0
  55. data/lib/generators/support_desk/views_generator.rb +50 -0
  56. data/lib/support_desk/configuration.rb +675 -0
  57. data/lib/support_desk/console.rb +487 -0
  58. data/lib/support_desk/console_engine.rb +63 -0
  59. data/lib/support_desk/console_routes.rb +107 -0
  60. data/lib/support_desk/context_card.rb +90 -0
  61. data/lib/support_desk/current.rb +26 -0
  62. data/lib/support_desk/doctor.rb +220 -0
  63. data/lib/support_desk/engine.rb +141 -0
  64. data/lib/support_desk/errors.rb +49 -0
  65. data/lib/support_desk/events.rb +122 -0
  66. data/lib/support_desk/macros.rb +73 -0
  67. data/lib/support_desk/models/application_record.rb +11 -0
  68. data/lib/support_desk/models/assignment.rb +83 -0
  69. data/lib/support_desk/models/concerns/agent.rb +79 -0
  70. data/lib/support_desk/models/concerns/requester.rb +71 -0
  71. data/lib/support_desk/models/concerns/supportable.rb +88 -0
  72. data/lib/support_desk/models/desk.rb +101 -0
  73. data/lib/support_desk/models/event.rb +72 -0
  74. data/lib/support_desk/models/ticket.rb +1124 -0
  75. data/lib/support_desk/queue.rb +171 -0
  76. data/lib/support_desk/summary.rb +70 -0
  77. data/lib/support_desk/test_helpers.rb +137 -0
  78. data/lib/support_desk/timeline.rb +104 -0
  79. data/lib/support_desk/topic.rb +290 -0
  80. data/lib/support_desk/topic_tree.rb +214 -0
  81. data/lib/support_desk/version.rb +5 -0
  82. data/lib/support_desk/wizard.rb +392 -0
  83. data/lib/support_desk.rb +288 -0
  84. 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