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
data/README.md ADDED
@@ -0,0 +1,469 @@
1
+ # 🎫 `support_desk` - Customer support for your Rails app, as conversations
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/support_desk.svg)](https://badge.fury.io/rb/support_desk) [![Build Status](https://github.com/rameerez/support_desk/workflows/Tests/badge.svg)](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