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
data/README.md
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# 🎫 `support_desk` - Customer support for your Rails app, as conversations
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/support_desk) [](https://github.com/rameerez/support_desk/actions)
|
|
4
|
+
|
|
5
|
+
> [!TIP]
|
|
6
|
+
> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=support_desk)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=support_desk)!
|
|
7
|
+
|
|
8
|
+
`support_desk` gives your Rails app a **support desk**: tickets that are real conversations. Somebody asks for help about something in your app (a ride, an order, a withdrawal) or about nothing in particular, your desk answers, humans sign the answers, and your team works a queue.
|
|
9
|
+
|
|
10
|
+
It is a product gem on the [`chats`](https://github.com/rameerez/chats) kernel: chats owns the transcript, realtime, attachments, read state and moderation; `support_desk` owns the case — topics, assignment, SLA clocks, events and the console API.
|
|
11
|
+
|
|
12
|
+
Every app eventually needs a support inbox, and everyone rebuilds the same ticket table, the same "assigned to me" tab, the same "which order is this about?" picker and the same email bridge. `support_desk` is that whole rebuild, done once, done right, on top of the messaging you already have.
|
|
13
|
+
|
|
14
|
+
## 👨💻 Example
|
|
15
|
+
|
|
16
|
+
`support_desk` reads like plain English:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
class User < ApplicationRecord
|
|
20
|
+
acts_as_messager # chats
|
|
21
|
+
has_support_tickets # can ask for help
|
|
22
|
+
acts_as_support_agent if: :admin? # can answer
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Order < ApplicationRecord
|
|
26
|
+
supportable topic: :order # can be asked about
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
ticket = alice.ask_support!("My order never arrived", about: order)
|
|
30
|
+
ticket.assign!(to: lucia, by: lucia)
|
|
31
|
+
ticket.reply!("We're on it", by: lucia)
|
|
32
|
+
ticket.close!(by: lucia)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
That's a ticket, a conversation, an assignment history, an append-only audit trail and four events your app can subscribe to.
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
Add the gem:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
gem "support_desk"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Install it (creates the migration + an annotated initializer):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
bundle install
|
|
49
|
+
rails generate support_desk:install
|
|
50
|
+
rails db:migrate
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Three model lines and one route line:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# app/models/user.rb
|
|
57
|
+
class User < ApplicationRecord
|
|
58
|
+
acts_as_messager
|
|
59
|
+
has_support_tickets
|
|
60
|
+
acts_as_support_agent if: :admin?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# app/models/order.rb
|
|
64
|
+
class Order < ApplicationRecord
|
|
65
|
+
supportable topic: :order
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# config/routes.rb
|
|
69
|
+
mount SupportDesk::Engine => "/support"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Tell the desk who it is:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# config/initializers/support_desk.rb
|
|
76
|
+
SupportDesk.configure do |config|
|
|
77
|
+
config.name = "Support"
|
|
78
|
+
config.agents { User.where(admin: true) }
|
|
79
|
+
|
|
80
|
+
config.topics do
|
|
81
|
+
topic :order, about: Order
|
|
82
|
+
topic :billing do
|
|
83
|
+
topic :invoice, about: Invoice
|
|
84
|
+
end
|
|
85
|
+
other
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Check your work any time with `SupportDesk.doctor.print`.
|
|
91
|
+
|
|
92
|
+
Desk records are memoised for the life of the process, so anything that has to change everywhere at once belongs in this initializer rather than in a desk's `settings` column.
|
|
93
|
+
|
|
94
|
+
## The model macros
|
|
95
|
+
|
|
96
|
+
### `has_support_tickets(desk: :default, as: nil)`
|
|
97
|
+
|
|
98
|
+
Adds exactly four methods to whoever asks for help:
|
|
99
|
+
|
|
100
|
+
| method | what it does |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `support_tickets` | `has_many`, newest first. Chain the scopes: `alice.support_tickets.open.about(order)` |
|
|
103
|
+
| `ask_support!(message, about:, topic:, files:, via:)` | opens the ticket, posts the first message, emits `ticket_opened`, and hands back the `Ticket` — or the open one they already have about the same thing |
|
|
104
|
+
| `awaiting_support_reply?` | is the desk holding any of their questions? |
|
|
105
|
+
| `unread_support_count` | for a nav badge, counted against the chats read horizon |
|
|
106
|
+
|
|
107
|
+
### `supportable(topic:, candidates: nil, one_open_ticket: true)`
|
|
108
|
+
|
|
109
|
+
Makes a domain record something people can ask about. Every method has a working default; override the ones that matter:
|
|
110
|
+
|
|
111
|
+
| method | default |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `support_label` | `"Order 42"` — the ticket's label and the card's title |
|
|
114
|
+
| `support_status` | `nil` — a status pill under the label |
|
|
115
|
+
| `support_context` | `{}` — key/value pairs agents see in the console |
|
|
116
|
+
| `support_url` | `nil` — "open in admin" |
|
|
117
|
+
| `supportable_by?(requester)` | `user == requester` |
|
|
118
|
+
| `.support_candidates_for(requester)` | the requester's own association, for the "which one?" picker |
|
|
119
|
+
|
|
120
|
+
### `acts_as_support_agent(if: nil, kind: :human)`
|
|
121
|
+
|
|
122
|
+
Makes someone able to answer. Agents are never chats participants — the desk sends, the agent *authors* — so this needs no messaging setup at all. It adds `support_agent?`, `support_agent_name`, `support_agent_avatar`, `on_duty?`, `support_capacity` and `support_queue`, and **no verbs**: the ticket is the subject of every sentence.
|
|
123
|
+
|
|
124
|
+
## Tickets
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
ticket.reference # "T-AB12CD", for email subjects and phone calls
|
|
128
|
+
ticket.label # "Order SO1"
|
|
129
|
+
ticket.topic # a Topic value object: .label, .path, .under?(:billing)
|
|
130
|
+
ticket.subject # the Order (or nil)
|
|
131
|
+
ticket.status # "open" | "snoozed" | "closed"
|
|
132
|
+
ticket.awaiting_reply? # does the desk owe the next word?
|
|
133
|
+
ticket.waiting_for # a Duration
|
|
134
|
+
ticket.overdue? ticket.at_risk?
|
|
135
|
+
ticket.time_to_first_reply ticket.time_to_close
|
|
136
|
+
|
|
137
|
+
ticket.reply!("…", by: lucia) # sent by the desk, signed by Lucía
|
|
138
|
+
ticket.note!("VIP customer", by: lucia) # internal; never in the conversation
|
|
139
|
+
ticket.assign!(to: lucia, by: lucia) # "take"
|
|
140
|
+
ticket.hand_off!(to: pedro, note: "…", by: lucia)
|
|
141
|
+
ticket.release!(by: lucia, reason: :shift_end)
|
|
142
|
+
ticket.close!(by: lucia) ticket.reopen!(by: alice)
|
|
143
|
+
ticket.change_topic!(to: "billing/invoice", by: lucia)
|
|
144
|
+
ticket.attach_subject!(order, by: lucia)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Every transition takes `by:` (falling back to `SupportDesk::Current.actor`), runs under the ticket's row lock, writes exactly one event row, and emits its events after the transaction commits. Repeating one that already happened returns `self` and writes nothing.
|
|
148
|
+
|
|
149
|
+
### Scopes
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
SupportDesk::Ticket
|
|
153
|
+
.open .closed .not_closed .assigned .unassigned .assigned_to(lucia)
|
|
154
|
+
.awaiting_reply .awaiting_requester
|
|
155
|
+
.waiting_over(4.hours) .at_risk .overdue
|
|
156
|
+
.about(order) .about_any(Order) .on_topic(:billing)
|
|
157
|
+
.for_desk(:billing) .opened_via(:email) .opened_between(range)
|
|
158
|
+
.most_urgent_first .recent_activity_first .newest_first
|
|
159
|
+
.find_by_reference("T-AB12CD")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Queues and presenters
|
|
163
|
+
|
|
164
|
+
Bring your own UI on the agent side. Everything the console needs is plain Ruby:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
q = lucia.support_queue
|
|
168
|
+
q.mine q.unassigned q.awaiting q.open q.closed # relations
|
|
169
|
+
q.counts # { awaiting: 4, mine: 2, … } in ONE query
|
|
170
|
+
q.badge # the nav number, cached 30s per agent
|
|
171
|
+
q.next # the most urgent thing this agent could pick up
|
|
172
|
+
q.tabs # [[:awaiting, "Needs a reply", 4], …]
|
|
173
|
+
|
|
174
|
+
ticket.context_card # title, status, the host's own context pairs, the requester
|
|
175
|
+
ticket.summary # one line for a list row, Slack, or a digest
|
|
176
|
+
ticket.timeline # messages ⨉ events merged by time; .print in a console
|
|
177
|
+
ticket.actions_for(lucia) # exactly the buttons to render
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## The requester experience
|
|
181
|
+
|
|
182
|
+
Mounting the engine is the whole user side: four screens, ejectable views,
|
|
183
|
+
and two helpers you drop anywhere in your app.
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
mount SupportDesk::Engine => "/support" # CarHey mounts it at "/messages/support"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
| route | what |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `GET /support` | their cases — open ones as chats rows, closed ones folded away, and the door into a new one |
|
|
192
|
+
| `GET /support/new` | the wizard: pick a topic, pick the thing, write. One URL, three Turbo frames |
|
|
193
|
+
| `POST /support/tickets` | `ask_support!`, then straight into the conversation |
|
|
194
|
+
| `GET /support/tickets/:id` | a stable URL for a case (`/support/tickets/T-AB12CD` works too), redirecting to its thread |
|
|
195
|
+
|
|
196
|
+
The screens inherit `config.parent_controller`, so your layout, your
|
|
197
|
+
authentication (`config.authenticate_method`) and your locale switching all
|
|
198
|
+
apply. `config.current_requester_method` names the person asking.
|
|
199
|
+
|
|
200
|
+
Doors go anywhere, including in partials shared with pages that have nothing
|
|
201
|
+
to do with support:
|
|
202
|
+
|
|
203
|
+
```erb
|
|
204
|
+
<%= link_to_support about: @order %> <%# "Need help with Order SO1?" %>
|
|
205
|
+
<%= link_to_support about: @withdrawal, text: "Report a problem", class: "btn" %>
|
|
206
|
+
<%= link_to_support %> <%# no subject: the wizard, step 1 %>
|
|
207
|
+
<%= support_unread_badge %>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`link_to_support` renders **nothing** when there is no requester, when the
|
|
211
|
+
record isn't `supportable`, or when it isn't theirs to ask about — and when
|
|
212
|
+
they already have a case open about it, it leads to that conversation instead
|
|
213
|
+
of opening a second one. Subjects travel as signed GlobalIDs (purpose
|
|
214
|
+
`:support_subject`, one hour) and are re-checked against `supportable_by?`
|
|
215
|
+
anyway; a token that is forged, expired or somebody else's is a 404, never a
|
|
216
|
+
403 with a hint.
|
|
217
|
+
|
|
218
|
+
In the chats inbox the desk appears **once**, as a grouped row. Before the
|
|
219
|
+
requester has ever written there is no row to group, so the engine puts a
|
|
220
|
+
door in its place (`config.inbox_entry = :always | :when_tickets | :never`) —
|
|
221
|
+
a support entry that only exists once you already have a ticket is
|
|
222
|
+
undiscoverable.
|
|
223
|
+
|
|
224
|
+
Hotwire Native hosts merge the engine's path rules into their own, AFTER any
|
|
225
|
+
rule that could swallow them:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
rules: [ *my_own_rules, *SupportDesk.native_path_rules ]
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Order matters and the later rule wins: a host whose chats thread rule is
|
|
232
|
+
`^/messages/[^/]+$` already matches `/messages/support`, so rules placed
|
|
233
|
+
first would lose to it.
|
|
234
|
+
|
|
235
|
+
Both surfaces are pushed screens, never modals: every wizard step is a real
|
|
236
|
+
URL, so the back gesture and cold-boot deep links work.
|
|
237
|
+
|
|
238
|
+
### Restyling
|
|
239
|
+
|
|
240
|
+
The views ship with a small bundled stylesheet and semantic classes (the list
|
|
241
|
+
reuses chats' own row classes, because a case *is* a conversation). The
|
|
242
|
+
stylesheet goes into your layout's `<head>`, so that layout needs a
|
|
243
|
+
`<%= yield :head %>` — every Rails app generated this decade has one. To make
|
|
244
|
+
the screens yours:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
rails generate support_desk:views
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
That copies `app/views/support_desk/tickets/**` and the two rows this engine
|
|
251
|
+
contributes to chats' screens (`app/views/chats/slots/**`) into your app,
|
|
252
|
+
where they shadow the gem's copies — the Devise move. Delete your copy and
|
|
253
|
+
the default comes back; upgrade the gem and your copy is untouched. Every
|
|
254
|
+
view helper the templates use stays available afterwards, so an ejected copy
|
|
255
|
+
keeps working.
|
|
256
|
+
## The agent console
|
|
257
|
+
|
|
258
|
+
Three layers. Stop at whichever one you like — they are the same code, with
|
|
259
|
+
more of it written for you each time.
|
|
260
|
+
|
|
261
|
+
### Layer 1 — objects
|
|
262
|
+
|
|
263
|
+
The queue and presenters above. They have no view dependency at all, so a
|
|
264
|
+
console, a rake task, a Slack command and a JSON API all render from them.
|
|
265
|
+
|
|
266
|
+
### Layer 2 — two concerns
|
|
267
|
+
|
|
268
|
+
One word in your routes file draws every verb an agent needs:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
# config/routes.rb
|
|
272
|
+
namespace :madmin do
|
|
273
|
+
resources :support_tickets, only: %i[index show], concerns: :support_console
|
|
274
|
+
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
That adds member `reply take assign hand_off release close reopen note
|
|
278
|
+
change_topic` and collection `next`. It has to sit inside a `resources`
|
|
279
|
+
block, since that is what those routes hang off.
|
|
280
|
+
|
|
281
|
+
The concern is seeded into every route set by a small prepend on Rails'
|
|
282
|
+
routing mapper, because routing concerns live in a Hash built per `draw`
|
|
283
|
+
and there is no registry a gem can add to. If you would rather not have
|
|
284
|
+
that, register it yourself and the patch stays out of your way:
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
Rails.application.routes.draw do
|
|
288
|
+
SupportDesk::ConsoleRoutes.register(self)
|
|
289
|
+
|
|
290
|
+
namespace :madmin do
|
|
291
|
+
resources :support_tickets, only: %i[index show], concerns: :support_console
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Either way, a concern you define yourself under the same name wins.
|
|
297
|
+
|
|
298
|
+
Then:
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
class Madmin::SupportTicketsController < Madmin::ApplicationController
|
|
302
|
+
include SupportDesk::Console # the verbs
|
|
303
|
+
include SupportDesk::Console::Index # optional: @queue, @scope, @tickets from params
|
|
304
|
+
|
|
305
|
+
def current_agent = current_user # or rely on config.current_agent_method
|
|
306
|
+
end
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`index` and `show` stay yours — those are the UI, and Layer 1 is everything
|
|
310
|
+
they need. What the concern owns is the half that is easy to get wrong:
|
|
311
|
+
|
|
312
|
+
- `current_agent` has to be an eligible agent, or it's a **403**.
|
|
313
|
+
- `config.visible_desks_for` scopes *everything*, not just the ticket: the
|
|
314
|
+
queue, the tab counts, the badge and `next` all read the same list, and
|
|
315
|
+
`?desk=` can only name a desk that is already on it. A case on a desk this
|
|
316
|
+
agent may not work is a plain **404** — never a 403 that confirms it
|
|
317
|
+
exists. An agent with no desks at all gets a **403**, because that is a
|
|
318
|
+
different sentence: there is no case in the question yet.
|
|
319
|
+
- `config.authorize_console` is consulted before every action, `index`
|
|
320
|
+
included, for hosts with Pundit or CanCan. A hook that raises **denies**;
|
|
321
|
+
the exception goes to `Rails.error`, not to the screen it was guarding.
|
|
322
|
+
- The console never accepts what it wouldn't offer. Every verb checks
|
|
323
|
+
`ticket.actions_for(agent)` first, so a POST from a stale tab — replying
|
|
324
|
+
to a case somebody closed while you were reading it — is refused with a
|
|
325
|
+
reason rather than half-applied.
|
|
326
|
+
- Every refusal the domain can raise — a drop-in under `:assignee_only`, a
|
|
327
|
+
hand-off by somebody who doesn't hold the ticket, a reply into a locked
|
|
328
|
+
case — becomes a translated `flash[:alert]`. A console that 500s on a
|
|
329
|
+
policy is a console nobody trusts.
|
|
330
|
+
- Each verb answers an HTML redirect or a Turbo Stream page refresh.
|
|
331
|
+
Override `after_transition_path(ticket)` to land somewhere else.
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
SupportDesk.configure do |config|
|
|
335
|
+
config.current_agent_method = :current_user
|
|
336
|
+
config.visible_desks_for = ->(agent) { agent.billing? ? [ :billing ] : SupportDesk::Desk.all }
|
|
337
|
+
config.authorize_console = ->(agent, ticket, action) { AdminPolicy.new(agent).support?(action) }
|
|
338
|
+
end
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Realtime is two lines, and the gem broadcasts to both on every message and
|
|
342
|
+
every transition:
|
|
343
|
+
|
|
344
|
+
```erb
|
|
345
|
+
<%= turbo_stream_from @ticket, :console %> <%# the case %>
|
|
346
|
+
<%= turbo_stream_from SupportDesk.desk, :queue %> <%# the queue %>
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Layer 3 — a generated console
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
rails generate support_desk:console madmin
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Writes a controller that includes both concerns, a madmin resource so the
|
|
356
|
+
nav and search know tickets exist, and the whole view set (Tailwind, all
|
|
357
|
+
copy from locales) into `app/views/madmin/support_tickets/`. Everything it
|
|
358
|
+
writes is yours to edit; re-running it leaves your edits alone unless you
|
|
359
|
+
pass `--force`.
|
|
360
|
+
|
|
361
|
+
The views are copied out of `SupportDesk::ConsoleEngine` — the same
|
|
362
|
+
templates the mounted console renders, so there is one source of truth
|
|
363
|
+
rather than two sets that drift. They reference nothing private: tabs come
|
|
364
|
+
from `queue.tabs`, buttons from `actions_for`, and paths from the concern's
|
|
365
|
+
`console_ticket_path`, which reads *your* controller's route. That is why
|
|
366
|
+
the same file renders under `/admin/support` and under `/madmin`.
|
|
367
|
+
|
|
368
|
+
Add the badge to your admin nav:
|
|
369
|
+
|
|
370
|
+
```erb
|
|
371
|
+
<%= render "madmin/support_tickets/nav_badge", agent: current_user %>
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
#### No admin framework at all? Mount it instead
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
mount SupportDesk::ConsoleEngine => "/admin/support"
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
The same Layer 3 views, already wired — nothing to generate and nothing to
|
|
381
|
+
route. It takes its layout and authentication from
|
|
382
|
+
`config.console_parent_controller`, the way the requester engine takes
|
|
383
|
+
`config.parent_controller`. Mounting it grants nothing: the agent check and
|
|
384
|
+
`authorize_console` still run.
|
|
385
|
+
|
|
386
|
+
Generate when you have an admin to put this inside and want the files;
|
|
387
|
+
mount when you don't. They render the same templates either way.
|
|
388
|
+
|
|
389
|
+
## The wizard
|
|
390
|
+
|
|
391
|
+
"What do you need help with?" is a plain object, not a controller, so a host
|
|
392
|
+
that ejects the views, a native app or a JSON API can all drive the same
|
|
393
|
+
three steps: pick a topic, pick the thing it's about, write.
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
wizard = SupportDesk::Wizard.new(current_user, params)
|
|
397
|
+
wizard.step # :topic | :subject | :compose
|
|
398
|
+
wizard.choices # the topics to offer, or the records to pick from
|
|
399
|
+
wizard.ask # the prompt above them
|
|
400
|
+
wizard.existing_ticket # "you already have a conversation open about this"
|
|
401
|
+
wizard.open!(params[:message])
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Subjects travel as **signed GlobalIDs** and are re-checked against
|
|
405
|
+
`supportable_by?` anyway — a wizard that trusted a raw id would let anybody
|
|
406
|
+
open a ticket about anybody's order.
|
|
407
|
+
|
|
408
|
+
## Events
|
|
409
|
+
|
|
410
|
+
The gem emits; your app delivers. Multi-subscriber, error-isolated, after commit, and mirrored on `ActiveSupport::Notifications` as `"<event>.support_desk"`:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
SupportDesk.on(:ticket_opened) { |ticket| TicketNotifier.deliver(ticket.agents_to_notify) }
|
|
414
|
+
SupportDesk.on(:requester_replied) { |ticket, msg| TicketNotifier.deliver(ticket.agents_to_notify) }
|
|
415
|
+
SupportDesk.on(:ticket_transitioned) do |ticket, kind, by:, request:, payload:|
|
|
416
|
+
AuditLog.log("support_ticket_#{kind}", actor: by, request: request, subject: ticket, **payload)
|
|
417
|
+
end
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Pass `key:` from anywhere that runs more than once (a `to_prepare` block, an engine initializer) and re-registering replaces that subscriber instead of stacking a copy on every reload.
|
|
421
|
+
|
|
422
|
+
Keep notification titles generic — `ticket.notification_title` is — and put the detail in the body. A lock screen shouldn't spell out what somebody's support case is about.
|
|
423
|
+
|
|
424
|
+
## Compatibility
|
|
425
|
+
|
|
426
|
+
Rails 7.2, 8.0 and 8.1; Ruby >= 3.2; PostgreSQL, SQLite and MySQL; bigint or UUID primary keys (the migration follows your app's `primary_key_type`).
|
|
427
|
+
|
|
428
|
+
PostgreSQL and SQLite additionally hold the two cardinality rules — one open ticket about one thing, one open assignment per ticket — as **partial unique indexes**, so a race loses at the database and not merely at the model. MySQL has no partial indexes, so there those two rules are model-only; that is why the suite's own matrix is SQLite and PostgreSQL.
|
|
429
|
+
|
|
430
|
+
## Testing
|
|
431
|
+
|
|
432
|
+
The gem is tested with Minitest against a real dummy host app: models and transitions, full request cycles through both the requester engine and the console, the generators, every authorization negative, and the wizard's three Turbo Frames driven in a real browser — a frame is only a frame in one.
|
|
433
|
+
|
|
434
|
+
```bash
|
|
435
|
+
bundle exec rake ci # everything a pull request has to pass
|
|
436
|
+
bundle exec rake test # just the suite
|
|
437
|
+
bundle exec appraisal install # then test across Rails versions:
|
|
438
|
+
bundle exec appraisal rails-7.2 rake test
|
|
439
|
+
bundle exec appraisal rails-8.1 rake test
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
`rake ci` is `rake test`, `rake rubocop` and `rake brakeman`. The suite runs against SQLite by default, and against PostgreSQL with `DATABASE_URL` set.
|
|
443
|
+
|
|
444
|
+
**Testing your own app** — the gem ships the helpers its own suite uses, so your acceptance tests and ours describe the same behaviour:
|
|
445
|
+
|
|
446
|
+
```ruby
|
|
447
|
+
include SupportDesk::TestHelpers
|
|
448
|
+
|
|
449
|
+
ticket = open_support_ticket(for: users(:alice), about: orders(:one), message: "…")
|
|
450
|
+
reply_as users(:lucia), ticket, "…"
|
|
451
|
+
assert_awaiting_requester ticket
|
|
452
|
+
assert_ticket_event ticket, :handed_off, from: users(:lucia), to: users(:pedro)
|
|
453
|
+
|
|
454
|
+
with_support_config(reply_policy: :assignee_only) { … }
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
## Development
|
|
458
|
+
|
|
459
|
+
After checking out the repo, run `bundle install`, then `bundle exec rake ci`. The dummy app lives in `test/dummy` and mounts all three surfaces the way a real host does: the requester engine at `/messages/support`, `chats` at `/messages`, and the turnkey console at `/admin/support` — plus the same console again inside a host-owned `madmin` namespace, because "the console uses only the public API" is a claim that needs a second implementation to be worth anything.
|
|
460
|
+
|
|
461
|
+
`chats` is the kernel this gem is a product on and the two are developed in lockstep, so the Gemfile points at a sibling checkout (`../chats`) until `chats` 0.2.0 is on rubygems.
|
|
462
|
+
|
|
463
|
+
## Contributing
|
|
464
|
+
|
|
465
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/support_desk. Our code of conduct is: just be nice and make your mom proud of what you do and post online.
|
|
466
|
+
|
|
467
|
+
## License
|
|
468
|
+
|
|
469
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
puts "You must `gem install bundler` and `bundle install` to run rake tasks"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require "bundler/gem_tasks"
|
|
10
|
+
|
|
11
|
+
require "rdoc/task"
|
|
12
|
+
|
|
13
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
|
14
|
+
rdoc.rdoc_dir = "rdoc"
|
|
15
|
+
rdoc.title = "SupportDesk"
|
|
16
|
+
rdoc.options << "--line-numbers"
|
|
17
|
+
rdoc.rdoc_files.include("README.md")
|
|
18
|
+
rdoc.rdoc_files.include("lib/**/*.rb")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
22
|
+
load "rails/tasks/engine.rake"
|
|
23
|
+
|
|
24
|
+
require "rake/testtask"
|
|
25
|
+
|
|
26
|
+
Rake::TestTask.new(:test) do |t|
|
|
27
|
+
t.libs << "test"
|
|
28
|
+
t.pattern = "test/**/*_test.rb"
|
|
29
|
+
t.verbose = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
desc "Lint with rubocop-rails-omakase"
|
|
33
|
+
task :rubocop do
|
|
34
|
+
sh "bundle exec rubocop"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The engine has controllers and a generated console, so it gets scanned
|
|
38
|
+
# like an app. `--force-scan` because a gem is not a Rails app root.
|
|
39
|
+
desc "Scan for security problems with brakeman"
|
|
40
|
+
task :brakeman do
|
|
41
|
+
sh "bundle exec brakeman --no-pager --quiet --force-scan ."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Clear SimpleCov's merged resultset first. It merges results across runs, so
|
|
45
|
+
# a stale one from an earlier single-file run drags the total under the
|
|
46
|
+
# coverage floor and fails the gate for a regression that does not exist.
|
|
47
|
+
desc "Delete the merged coverage resultset so the next run starts clean"
|
|
48
|
+
task :clear_coverage do
|
|
49
|
+
rm_rf "coverage"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
desc "Everything a pull request has to pass: tests, linter, security scan"
|
|
53
|
+
task ci: %i[clear_coverage test rubocop brakeman]
|
|
54
|
+
|
|
55
|
+
task default: :test
|