support_desk 0.5.0 → 0.6.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +151 -6
  4. data/app/controllers/support_desk/tickets_controller.rb +6 -4
  5. data/app/helpers/support_desk/engine_helper.rb +17 -7
  6. data/app/views/chats/slots/_inbox_top.html.erb +1 -1
  7. data/app/views/support_desk/launcher/_badge.html.erb +11 -3
  8. data/app/views/support_desk/launcher/_launcher.html.erb +2 -2
  9. data/lib/generators/support_desk/install_generator.rb +12 -0
  10. data/lib/generators/support_desk/templates/add_owners_to_support_desk.rb.erb +73 -0
  11. data/lib/generators/support_desk/templates/add_replay_to_support_desk.rb.erb +73 -0
  12. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +6 -1
  13. data/lib/generators/support_desk/templates/initializer.rb +12 -0
  14. data/lib/generators/support_desk/upgrade_generator.rb +22 -2
  15. data/lib/support_desk/brief.rb +1 -1
  16. data/lib/support_desk/configuration.rb +106 -6
  17. data/lib/support_desk/context_card.rb +1 -1
  18. data/lib/support_desk/current.rb +22 -0
  19. data/lib/support_desk/events.rb +41 -0
  20. data/lib/support_desk/launcher.rb +14 -4
  21. data/lib/support_desk/models/assistant.rb +37 -9
  22. data/lib/support_desk/models/concerns/requester.rb +3 -2
  23. data/lib/support_desk/models/desk.rb +40 -7
  24. data/lib/support_desk/models/event.rb +15 -1
  25. data/lib/support_desk/models/ticket/assistance.rb +36 -7
  26. data/lib/support_desk/models/ticket.rb +80 -25
  27. data/lib/support_desk/queue.rb +3 -1
  28. data/lib/support_desk/replay.rb +109 -0
  29. data/lib/support_desk/timeline.rb +1 -1
  30. data/lib/support_desk/version.rb +1 -1
  31. data/lib/support_desk.rb +58 -23
  32. metadata +6 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f20ef59f51139224d4dc822bc4d96eced0d5517e6b308399f707d43eef11d229
4
- data.tar.gz: fde3321433991bb1c7aa89a944e57a5fa4cd7cf19da1ba3444f8a81289b962ab
3
+ metadata.gz: f59effae389db2716b2776132aa991881576422815776fe189e984ca95ce72ae
4
+ data.tar.gz: 2c8b33513576b69e28e787820f9059178c5f16a2f76cbe2b9b812e0bd1b5c835
5
5
  SHA512:
6
- metadata.gz: 0fcd4009a005176029c7f2c07dc5f4a4dd7c6bee5a46428025d9ccc56d30f9b2a76170713d9362a3f5aab658f34c0f9c0ad151d048969fc7b7a0ea3b641ec724
7
- data.tar.gz: ae545aeec1cac66db40a3df1777fd2c7747dda0103c99d7424b55d06cbbd6dd67cb9cb8b1685e642d97f82153afc2ea149257967957da63b2e1524a7c437bf52
6
+ metadata.gz: 61e07986793fef354b19954ca00d97b162c452a16244f7db3969e5e76d04aabd70610a5a98279f260c712f15043246000a2918b2f573af497f95f2a3dd7380f5
7
+ data.tar.gz: 3b426605c9c9395efedc6ef2d77fb2e3a135e5f36e2fa6415f3925591a7d99f42014a7dccdac95c0064adccaa0454aecebad7e9eef757adab5a10557a890df55
data/CHANGELOG.md CHANGED
@@ -4,6 +4,85 @@ All notable changes to this project are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.0] - 2026-10-25
8
+
9
+ A desk per customer, a record you can replay, and two kinds of provenance.
10
+ Two additive migrations (`rails g support_desk:upgrade`); without an owner,
11
+ nothing about an existing install changes.
12
+
13
+ **Turning owners on is staged**: migrate, deploy 0.6 to every process and
14
+ retire every 0.5 one, and only then set `SupportDesk::Current.owner`. A 0.5
15
+ process looks desks and assistants up by key alone and, once owned rows
16
+ exist, can pick another owner's. See "Upgrading to 0.6" in the README.
17
+
18
+ ### Added
19
+
20
+ - **Owners.** Desks and assistants gain an optional polymorphic `owner`
21
+ (migration `add_owners_to_support_desk`): keys are unique per owner, and
22
+ the old unique key stays, as a partial index, for rows with no owner.
23
+ `SupportDesk::Current.owner` says whose desks a request or job means;
24
+ `SupportDesk.desk`, `SupportDesk.assistant`, `Ticket.for_desk`, the SLA
25
+ scopes (`overdue`, `at_risk`, `past_sla`), `find_topic` and the console's
26
+ default desk list read that owner's rows. Both readers take `owner:` to
27
+ name one explicitly. A desk's assistant is its owner's assistant, whatever
28
+ `Current.owner` says, so another owner's assistant is a `NotAnAssistant`.
29
+ Owned records are cached per request or job (in `Current`); ownerless ones
30
+ stay memoised per process, as before.
31
+ - **Settings from rows.** `config.desk_settings = ->(desk) { … }` and
32
+ `config.assistant_settings = ->(assistant) { … }` return a Hash overlaid on
33
+ the initializer, through the same validating setters (`topics:` takes a
34
+ block). `Desk#config` and `Assistant#config` are the overlaid settings.
35
+ - **Durable replay** (PostgreSQL 13+, migration `add_replay_to_support_desk`).
36
+ Read uncached: under the query cache every request and job runs with, a
37
+ repeated snapshot query would freeze the horizon.
38
+ `support_desk_events` and `support_desk_message_registrations` gain
39
+ `xact_id` (the writing transaction, `pg_current_xact_id()`, stored as
40
+ bigint so `schema.rb` can dump it) and `position` (one sequence for both
41
+ tables). `SupportDesk::Events.replay(horizon:, resume: nil, batch_size: 500)`
42
+ yields every row below the oldest running transaction in `(xact_id,
43
+ position)` order and returns the next horizon once the range is drained; a
44
+ cursor yielded with each row resumes a range that raised. See "Replaying
45
+ what was written down" in the README for the schedule a position
46
+ checkpoint loses.
47
+ - **`assistant_turn` and `draft_proposed` event rows.** Both signals are now
48
+ written down in the transaction that produced them (new `Event::KINDS`,
49
+ internal, and `Event::SIGNAL_KINDS`, kept out of the timeline by the new
50
+ `Event.history` scope). `redispatch_assistant_turns!` re-emits without
51
+ writing another row.
52
+ - **Conditional human replies.** `reply!(…, turn:)` and `note!(…, turn:)` by
53
+ a person raise `StaleTurn` and write nothing when the case moved on since
54
+ the turn was read. Without `turn:` they are unchanged.
55
+ - **Message provenance.** `post_requester_message!(body, files:, via:,
56
+ external_id:)` is public and stamps `metadata["support_desk"]` with `via`
57
+ (never for `:in_app`) and `external_id`; `open!(via:)` stamps the opening
58
+ message.
59
+
60
+ ### Changed
61
+
62
+ - **Everything a requester sees is the current owner's.** The engine's list,
63
+ case lookup (by reference and id) and limit wall, `conversations_of` /
64
+ `requester_conversation` (and so chats' lookup through
65
+ `SupportDesk::ConversationAccess`), `awaiting_support_reply?`,
66
+ `unread_support_count`, the inbox door and `open_support_ticket_about` read
67
+ `support_tickets.on_current_desks` (new scopes `Ticket.owned_by(owner)` and
68
+ `Ticket.on_current_desks`). The brief's and context card's open-case counts
69
+ are the case owner's.
70
+ - **The launcher badge streams per owner** (`SupportDesk::Launcher.stream`,
71
+ `[requester, :support_desk, owner]` when there is one) and the broadcast job
72
+ counts under the case's owner.
73
+ - The console's queue badge is cached per desk ROW, not per key.
74
+ - The generated turn job resolves the case's own assistant
75
+ (`ticket.assistant`, its desk owner's) and acts only if her key is the one
76
+ queued. A worker has no `Current.owner`; a lookup by key named the ownerless
77
+ assistant. Regenerate, or make the same two-line change in yours.
78
+ - A note with a `turn:` locks the conversation and folds in unregistered
79
+ requester messages before comparing it, as a reply does.
80
+ - `release_silent_assistants!` and `redispatch_assistant_turns!` iterate
81
+ assistant and desk rows rather than configured keys, so one run covers
82
+ every owner.
83
+ - The requester views' desk name, promise and inbox door read the current
84
+ owner's desk (`support_desk_config`), still without creating it.
85
+
7
86
  ## [0.5.0] - 2026-10-19
8
87
 
9
88
  Intended as **0.5.0**: "Need help?" in the corner. **Requires chats >= 0.7.1**
data/README.md CHANGED
@@ -152,12 +152,14 @@ configuration mistake is a boot failure, never a 3 a.m. `NoMethodError`.
152
152
  | `current_requester_method` | `:current_user` | how the engine finds the person asking |
153
153
  | `current_agent_method` | `:current_user` | how the console finds the person answering (or define `current_agent` in your controller) |
154
154
  | `authenticate_method` | `:authenticate_user!` | your own filter, run before every requester screen |
155
- | `visible_desks_for` | `nil` (every desk) | `->(agent) { … }` returning the desks this agent may work; scopes the whole console |
155
+ | `visible_desks_for` | `nil` (every desk of `Current.owner`) | `->(agent) { … }` returning the desks this agent may work; scopes the whole console |
156
156
  | `thread_page` | `:chats` | where a requester reads a case: `:chats` (its thread in the chats inbox) or `:support` (this engine's own ticket page, for support kept apart from the inbox; see [Support apart from the inbox](#support-apart-from-the-inbox)) |
157
157
  | `authorize_console` | `nil` (allow) | `->(agent, ticket, action) { … }`; asked before every console action, `ticket` is nil for `index`, `new`, `next` and `open_conversation`; a hook that raises **denies** |
158
158
  | `assistant(key) { … }` | — | declare an assistant (see [Assistants](#-assistants)); `config.assistants`, `config.assistant?(key)` read them back |
159
159
  | `default_assistant` | the only one declared | which assistant the `:default` desk gets when more than one exists |
160
160
  | `on(event) { … }` | — | subscribe to an event (see [Events](#events)); pass `key:` from code that reloads |
161
+ | `desk_settings` | `nil` | `->(desk) { … }` returning a Hash of desk settings for that row, overlaid on the initializer (see [A desk per customer](#a-desk-per-customer)) |
162
+ | `assistant_settings` | `nil` | `->(assistant) { … }`, the same for an assistant |
161
163
 
162
164
  ### Desk settings
163
165
 
@@ -202,6 +204,76 @@ SupportDesk.desk(:billing) # another one (nil if nobody configured it)
202
204
  `SupportDesk.doctor.print` checks all of it against a running app (see
203
205
  [Doctor](#doctor)).
204
206
 
207
+ ### A desk per customer
208
+
209
+ One app, many customers, each with its own desk. Give every desk an
210
+ **owner** — any record: an account, a workspace, a product — by saying whose
211
+ request or job this is:
212
+
213
+ ```ruby
214
+ # app/controllers/application_controller.rb
215
+ before_action { SupportDesk::Current.owner = current_workspace }
216
+
217
+ # a job
218
+ SupportDesk::Current.set(owner: workspace) { workspace.support_digest! }
219
+ ```
220
+
221
+ Now `SupportDesk.desk` is **that workspace's** `:default` desk, created the
222
+ first time it is asked for, and everything that names a desk by key follows:
223
+ `ask_support!`, `Ticket.for_desk`, `overdue` and `at_risk`, the console's desk
224
+ list, `find_topic`. Keys are still declared in code, so `:billing` means the
225
+ same thing for every owner; owners multiply rows, not keys. Everything a
226
+ **requester** sees is that owner's too — their list, a case by reference,
227
+ chats' lookup of their support conversations, `unread_support_count`, the
228
+ launcher's badge (on its own stream per owner) — so one person who is a
229
+ customer of two owners never sees one owner's case from the other's app. Anything reached
230
+ from a ticket ignores `Current.owner` — a case knows its desk, a desk knows
231
+ its owner, and its assistant is that owner's assistant. One owner's assistant
232
+ asking to answer another owner's case is a `NotAnAssistant`.
233
+
234
+ Each owner can have its own settings, from its own rows:
235
+
236
+ ```ruby
237
+ config.desk_settings = ->(desk) { desk.owner&.support_settings } # { name: "Acme Help", reply_within: 4.hours }
238
+ config.assistant_settings = ->(assistant) { assistant.owner&.assistant_settings } # { autonomy: :reply, max_turns: 3 }
239
+ ```
240
+
241
+ A hook returns a Hash or nil. Every value goes through its own setter, so a
242
+ bad row fails exactly like a bad initializer (`ConfigurationError`), and
243
+ whatever the Hash leaves out comes from the initializer. `topics:` takes the
244
+ same block `config.topics` does.
245
+
246
+ Without an owner nothing changes: the desk with no owner is the one every
247
+ install had before 0.6, memoised for the process. An owned desk is cached for
248
+ one request or job instead — owners are many and change their settings at
249
+ runtime, so a process-wide memo would grow with every customer and serve
250
+ yesterday's name. `SupportDesk.desk(:default, owner: acme)` and
251
+ `SupportDesk.assistant(:rose, owner: acme)` name one explicitly. The sweeps
252
+ (`release_silent_assistants!`, `redispatch_assistant_turns!`) run once for
253
+ everybody.
254
+
255
+ **Upgrading to 0.6 — in three stages.** The owners migration adds a nullable
256
+ polymorphic `owner` to desks and assistants, makes keys unique per owner, and
257
+ keeps the old unique key for rows with no owner (a partial index; MySQL has
258
+ none, so there an ownerless key is kept unique by the lookup only). `owner_id`
259
+ follows your generator's primary key type.
260
+
261
+ 1. **Migrate** (`rails g support_desk:upgrade && rails db:migrate`). 0.5
262
+ processes keep working: nothing has an owner yet.
263
+ 2. **Deploy 0.6 to every process** — web, jobs, consoles, anything that loads
264
+ the gem — and retire the last 0.5 one.
265
+ 3. **Only then turn owners on**: set `SupportDesk::Current.owner`, and with it
266
+ create the first owned desk or assistant.
267
+
268
+ The order is not a formality. 0.5 finds a desk with `find_by(key:)`, which
269
+ knew nothing of owners, and from the first owned row on a key no longer names
270
+ one row: a 0.5 process still running could hand a case to another owner's
271
+ desk, or act as another owner's assistant. Until stage 3 there is nothing for
272
+ it to pick wrongly; 0.6 never picks an owned row for a request with no owner.
273
+
274
+ The replay migration (PostgreSQL only) rewrites the events table: run it in a
275
+ quiet window.
276
+
205
277
  ## Topics
206
278
 
207
279
  Topics are a **tree defined in code** and stored on the ticket as a stable
@@ -307,6 +379,7 @@ ticket.overdue? ticket.at_risk?
307
379
  ticket.time_to_first_reply ticket.time_to_close
308
380
 
309
381
  ticket.reply!("…", by: lucia) # sent by the desk, signed by Lucía
382
+ ticket.reply!("…", by: lucia, turn: seen) # …only if the case is still at the turn Lucía read
310
383
  ticket.note!("VIP customer", by: lucia) # internal; never in the conversation
311
384
  ticket.assign!(to: lucia, by: lucia) # "take"
312
385
  ticket.hand_off!(to: pedro, note: "…", by: lucia)
@@ -335,6 +408,7 @@ ticket.export # a GDPR-friendly Hash: the requester's transcript an
335
408
  ticket.notification_title # "Support · new message" — safe for a lock screen
336
409
  ticket.notification_body # the label — for an authenticated feed
337
410
  ticket.register!(message) # fold a chats message into the clocks by hand (imports); idempotent, and what the chats subscriber calls
411
+ ticket.post_requester_message!("…", via: :email, external_id: "<id@mail>") # as the requester, with provenance
338
412
  ticket.desk_config # this desk's slice of the configuration
339
413
 
340
414
  SupportDesk::Ticket.find_by_reference!("t-ab12cd") # forgives case, the prefix and Crockford lookalikes (O→0, I/L→1)
@@ -350,7 +424,22 @@ has a `kind` (`opened` `assigned` `handed_off` `released` `drop_in` `closed`
350
424
  `reopened` `topic_changed` `subject_attached` `note` `escalated`
351
425
  `human_requested` `assistant_paused` `assistant_resumed` `draft_sent`
352
426
  `draft_rejected` `assistant_withheld` — plus kinds reserved for later
353
- releases), an `actor` and a `payload`, and is read-only once written.
427
+ releases), an `actor` and a `payload`, and is read-only once written. Two
428
+ more kinds are signals rather than history — `assistant_turn` (there is
429
+ something to answer, at this turn) and `draft_proposed` — written down so a
430
+ [durable reader](#replaying-what-was-written-down) hears about them; they are
431
+ not in the timeline or the export.
432
+
433
+ A person's `reply!` and `note!` take an optional `turn:`. With one, they are
434
+ **conditional**: a case that moved on since the turn was read (a customer
435
+ wrote again, somebody else answered) raises `SupportDesk::StaleTurn` and
436
+ nothing is written — the `If-Match` of an answer composed in a review screen
437
+ or sent through an API. Without one, they are what they always were.
438
+
439
+ `post_requester_message!` posts as the requester — what an importer or a
440
+ channel connector calls. `via:` and `external_id:` ride on the message as
441
+ `metadata["support_desk"]` (`:in_app` is the default and is never stamped);
442
+ a case opened with `open!(via: :email)` stamps its first message too.
354
443
 
355
444
  ### Scopes
356
445
 
@@ -1101,7 +1190,8 @@ seat: `assign!(to: rose, by: rose, turn:)` and `release!(by: rose, turn:)`
1101
1190
  require it when `by:` is an assistant (an omitted turn is an
1102
1191
  `ArgumentError`, a stale one a `StaleTurn`), because a run that finished
1103
1192
  after the case moved on must not release the seat a newer one took. A
1104
- **person's** calls are unchanged and take no turn.
1193
+ **person's** calls need no turn; `reply!` and `note!` accept one and are
1194
+ then conditional too (see [Tickets](#tickets)).
1105
1195
 
1106
1196
  Two consequences worth knowing:
1107
1197
 
@@ -1698,6 +1788,58 @@ nobody holds the case — the gem computes it so every host gets "assignee or
1698
1788
  everyone" right. System messages (opening lines, closure notices) never emit
1699
1789
  anything and never move the clocks.
1700
1790
 
1791
+ ### Replaying what was written down
1792
+
1793
+ Events fire after commit, in memory: a process that dies between the commit
1794
+ and the subscriber loses them. What it cannot lose is what the transaction
1795
+ wrote — an event row per transition (the `assistant_turn` and
1796
+ `draft_proposed` signals included) and a registration row per message. On
1797
+ PostgreSQL 13+ you can read those back, in order, without ever skipping one:
1798
+
1799
+ ```ruby
1800
+ horizon = SupportDesk::Events.replay(horizon: checkpoint.horizon) do |row|
1801
+ Bridge.ingest(row) # a SupportDesk::Event or a SupportDesk::MessageRegistration
1802
+ end
1803
+ checkpoint.update!(horizon: horizon)
1804
+ ```
1805
+
1806
+ Every row stores `xact_id`, the transaction that wrote it, and `position`,
1807
+ from one sequence for both tables. `replay` captures the oldest transaction
1808
+ still running, `h = pg_snapshot_xmin(pg_current_snapshot())`, yields every
1809
+ row with `horizon <= xact_id < h` in `(xact_id, position)` order, and returns
1810
+ `h` only once that whole range is read. Below `h` every transaction has
1811
+ committed or aborted, so the range is final.
1812
+
1813
+ Why not "the last position I saw"? Because positions are handed out when a
1814
+ row is **inserted**, and transactions commit in any order:
1815
+
1816
+ 1. B starts a transaction; A starts a later one and inserts position 1.
1817
+ 2. B inserts position 2 and commits.
1818
+ 3. A reader polls, sees position 2, and checkpoints it.
1819
+ 4. A commits position 1 — behind the checkpoint, never read.
1820
+
1821
+ The horizon reader polls at step 3 too, but stops below A's transaction,
1822
+ which is still open; it reads position 1 on the next poll. The cost is that
1823
+ one long-running transaction holds the horizon back for as long as it lives,
1824
+ so keep transactions short and watch their age. Transaction ids belong to
1825
+ the whole PostgreSQL **server**, so that means any transaction on it — in
1826
+ another database too, another app's, a console left in `BEGIN`.
1827
+
1828
+ A raise returns nothing and the range is read again, so readers deduplicate
1829
+ — `position` identifies a row across both tables. A long range can be
1830
+ resumed: the block's second argument is a cursor (JSON-friendly) to save once
1831
+ a row's effects are durable, and to pass back as `resume:`:
1832
+
1833
+ ```ruby
1834
+ SupportDesk::Events.replay(horizon: c.horizon, resume: c.resume, batch_size: 500) do |row, resume|
1835
+ Bridge.ingest(row)
1836
+ c.update!(resume: resume)
1837
+ end
1838
+ ```
1839
+
1840
+ Call it outside a transaction. On SQLite and MySQL it raises
1841
+ `ConfigurationError`; the columns are PostgreSQL only.
1842
+
1701
1843
  ## Errors
1702
1844
 
1703
1845
  Every error inherits `SupportDesk::Error`, so `rescue SupportDesk::Error`
@@ -1765,6 +1907,7 @@ historical cases is deliberately exempt from new-case deduplication: if a newer
1765
1907
  already exists, both histories remain open and support is notified of the reply. No
1766
1908
  conversation is silently merged, closed or discarded. MySQL has no partial indexes,
1767
1909
  so these guarantees are model-only there; the suite's matrix is SQLite and PostgreSQL.
1910
+ [Durable replay](#replaying-what-was-written-down) needs PostgreSQL 13 or later.
1768
1911
 
1769
1912
  ## Testing
1770
1913
 
@@ -1818,8 +1961,10 @@ desk records.
1818
1961
 
1819
1962
  ```ruby
1820
1963
  SupportDesk.configure { |config| … } SupportDesk.config SupportDesk.configured?
1821
- SupportDesk.desk(key = :default) # the Desk record, found or created, memoised
1822
- SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
1964
+ SupportDesk.desk(key = :default, owner: Current.owner) # the Desk record, found or created, memoised
1965
+ SupportDesk.assistant(key = nil, owner: Current.owner) # the Assistant record, memoised; nil when none is configured
1966
+ SupportDesk::Current.owner = workspace # whose desks these are, per request or job
1967
+ SupportDesk::Events.replay(horizon:, resume: nil, batch_size: 500) { |row, resume| … } # PostgreSQL 13+
1823
1968
  SupportDesk.reset_assistants! SupportDesk.ai_actor?(record)
1824
1969
  SupportDesk.release_silent_assistants! # the net under a dead harness (schedule it)
1825
1970
  SupportDesk.reclaim_assistant_seats! # seats an assistant may no longer sit in
@@ -1841,7 +1986,7 @@ SupportDesk::VERSION
1841
1986
 
1842
1987
  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.
1843
1988
 
1844
- `chats` is the kernel this gem is a product on and the two are developed in lockstep. support_desk requires `chats >= 0.7.1, < 1.0`. Until that is on rubygems, the bundle takes the reviewed chats package from `vendor/cache` (provenance, checksum and the step that adds it in `vendor/cache/README.md`), so `bundle exec rake ci` needs no network and no sibling checkout.
1989
+ `chats` is the kernel this gem is a product on and the two are developed in lockstep. support_desk requires `chats >= 0.7.1, < 1.0`, from rubygems; CI asserts the exact chats it resolved (`CHATS_VERSION_UNDER_TEST` in `.github/workflows/test.yml`), so a new chats release is adopted by a deliberate bump, never silently.
1845
1990
 
1846
1991
  ## Contributing
1847
1992
 
@@ -26,7 +26,7 @@ module SupportDesk
26
26
  def index
27
27
  # `:assignee` because a row says who is on the case when that is a
28
28
  # machine, and a name per row is a query per row.
29
- tickets = current_requester.support_tickets
29
+ tickets = current_requester.support_tickets.on_current_desks
30
30
  .includes(:desk, :subject, :assignee, conversation: { last_message: :sender })
31
31
  @open_tickets = tickets.not_closed.recent_activity_first.to_a
32
32
  @closed_tickets = tickets.closed.newest_first.limit(CLOSED_TICKETS_SHOWN).to_a
@@ -195,9 +195,11 @@ module SupportDesk
195
195
 
196
196
  # By id, or by the reference people read down a phone line ("T-AB12CD").
197
197
  # Always through the requester's OWN tickets, so somebody else's case is
198
- # a 404 rather than a redirect into a conversation they can't read.
198
+ # a 404 rather than a redirect into a conversation they can't read —
199
+ # and only on the current owner's desks, so another owner's case is a
200
+ # 404 too.
199
201
  def find_ticket
200
- scope = current_requester.support_tickets
202
+ scope = current_requester.support_tickets.on_current_desks
201
203
  reference = SupportDesk::Ticket.normalize_reference(params[:id])
202
204
 
203
205
  if params[:id].to_s.upcase.start_with?(SupportDesk::Ticket::REFERENCE_PREFIX)
@@ -219,7 +221,7 @@ module SupportDesk
219
221
  # (their open cases), never a 500 and never a silent failure.
220
222
  def render_limit_wall(error)
221
223
  @limit_reason = error.is_a?(SupportDesk::TooManyOpenTickets) ? "too_many_open" : "too_fast"
222
- @open_tickets = current_requester.support_tickets.not_closed
224
+ @open_tickets = current_requester.support_tickets.on_current_desks.not_closed
223
225
  .includes(:desk, :subject, conversation: { last_message: :sender })
224
226
  .recent_activity_first.to_a
225
227
  @unread_counts = unread_counts_for(@open_tickets)
@@ -152,7 +152,7 @@ module SupportDesk
152
152
  # the desk promises nothing. The same `config.reply_within` the SLA
153
153
  # breaches on — one setting, one truth.
154
154
  def support_reply_promise(desk_key = :default)
155
- SupportDesk.config.desk(desk_key).reply_promise
155
+ support_desk_config(desk_key).reply_promise
156
156
  end
157
157
 
158
158
  # The status line under a case in the requester's list: who owes the next
@@ -183,9 +183,9 @@ module SupportDesk
183
183
  return false unless viewer.respond_to?(:ask_support!)
184
184
 
185
185
  key = viewer.class.try(:support_desk_key) || :default
186
- return false unless SupportDesk.config.desk(key).inbox_entry == :always
186
+ return false unless support_desk_config(key).inbox_entry == :always
187
187
 
188
- !viewer.support_tickets.exists?
188
+ !viewer.support_tickets.on_current_desks.exists?
189
189
  end
190
190
 
191
191
  # The desk RECORD for +desk_key+, resolved WITHOUT ever creating one.
@@ -205,11 +205,21 @@ module SupportDesk
205
205
  # never a Class.
206
206
  #
207
207
  # Memoised per view instance, so the door's avatar and its badge cost ONE
208
- # query between them rather than one each.
208
+ # query between them rather than one each. SupportDesk::Current.owner's
209
+ # desk, like everything else that names a desk by key.
209
210
  def support_desk_record(desk_key = :default)
210
211
  key = desk_key.to_s
211
212
  @support_desk_records ||= {}
212
- @support_desk_records[key] ||= SupportDesk::Desk.find_by(key: key) || SupportDesk::Desk.new(key: key)
213
+ @support_desk_records[key] ||= begin
214
+ desks = SupportDesk::Desk.owned_by(SupportDesk::Current.owner)
215
+ desks.find_by(key: key) || desks.new(key: key)
216
+ end
217
+ end
218
+
219
+ # The settings that desk works under — its own, when
220
+ # `config.desk_settings` gives it some — without creating it either.
221
+ def support_desk_config(desk_key = :default)
222
+ support_desk_record(desk_key).config
213
223
  end
214
224
 
215
225
  # The desk's face. Once the desk has a row it is just another chats
@@ -221,7 +231,7 @@ module SupportDesk
221
231
  desk = support_desk_record(desk_key)
222
232
  return chats_messager_avatar(desk, css_class: css_class) if desk.persisted?
223
233
 
224
- name = SupportDesk.config.desk(desk_key).name
234
+ name = support_desk_config(desk_key).name
225
235
  initials = name.to_s.split.first(2).filter_map { |word| word[0] }.join.upcase
226
236
  tag.span(initials.presence || "?", class: "#{css_class} chats-avatar--initials", "aria-hidden": true)
227
237
  end
@@ -278,7 +288,7 @@ module SupportDesk
278
288
  # Memoised on the view instance, which is the render's own scope — the
279
289
  # same place chats caches its slot lookups.
280
290
  def open_support_ticket_about(record, requester)
281
- @support_open_tickets ||= SupportDesk::Ticket.not_closed
291
+ @support_open_tickets ||= SupportDesk::Ticket.on_current_desks.not_closed
282
292
  .where(requester: requester)
283
293
  .where.not(subject_id: nil)
284
294
  .index_by { |t| [ t.subject_type, t.subject_id.to_s ] }
@@ -22,7 +22,7 @@
22
22
  official-account badge beside it always shows. %>
23
23
  <span class="chats-row__name">
24
24
  <span class="chats-row__title">
25
- <%= SupportDesk.config.desk(desk_key).name %>
25
+ <%= support_desk_config(desk_key).name %>
26
26
  </span>
27
27
  <%= support_desk_badge(desk_key) %>
28
28
  </span>
@@ -1,6 +1,14 @@
1
1
  <%# The launcher's badge: unread messages across the requester's cases
2
2
  (`unread_support_count`), hidden at zero. What their
3
- `[requester, :support_desk]` stream replaces (SupportDesk::Launcher, which
3
+ `SupportDesk::Launcher.stream` replaces (SupportDesk::Launcher, which
4
4
  hands it the requester and lets it count as it renders) and GET
5
- tickets/badge answers. Locals: count, or requester. %>
6
- <%= chats_tray_badge local_assigns.fetch(:count) { requester.unread_support_count }, id: SupportDesk::Launcher::BADGE_ID %>
5
+ tickets/badge answers. Locals: count, or requester and the owner whose
6
+ cases it counts (a broadcast job has no SupportDesk::Current of its own). %>
7
+ <% count = local_assigns.fetch(:count) do
8
+ if local_assigns.key?(:owner)
9
+ SupportDesk::Current.set(owner: local_assigns[:owner]) { requester.unread_support_count }
10
+ else
11
+ requester.unread_support_count
12
+ end
13
+ end %>
14
+ <%= chats_tray_badge count, id: SupportDesk::Launcher::BADGE_ID %>
@@ -13,13 +13,13 @@
13
13
 
14
14
  Locals: requester (nil when signed out), help_url. %>
15
15
  <% desk_key = requester ? requester.class.support_desk_key : :default %>
16
- <% desk = SupportDesk.config.desk(desk_key) %>
16
+ <% desk = support_desk_config(desk_key) %>
17
17
  <%= stylesheet_link_tag "support_desk" %>
18
18
  <%= chats_tray_item :support,
19
19
  label: t("support_desk.launcher.label"),
20
20
  title: safe_join([ tag.span(desk.name, class: "support-desk-launcher__name"), support_desk_badge(desk_key) ]),
21
21
  icon: render("support_desk/launcher/icon"),
22
- badge: (safe_join([ turbo_stream_from(requester, :support_desk),
22
+ badge: (safe_join([ turbo_stream_from(*SupportDesk::Launcher.stream(requester)),
23
23
  render("support_desk/launcher/badge", count: requester.unread_support_count) ]) if requester),
24
24
  badge_url: (support_desk_routes.badge_tickets_path if requester) do %>
25
25
  <% if requester %>
@@ -49,6 +49,18 @@ module SupportDesk
49
49
  File.join(db_migrate_path, "create_support_desk_message_registrations.rb")
50
50
  end
51
51
 
52
+ # 0.6: owners on desks and assistants, and the replay columns. The same
53
+ # files on a fresh install and an upgrade.
54
+ def create_owners_migration
55
+ migration_template "add_owners_to_support_desk.rb.erb",
56
+ File.join(db_migrate_path, "add_owners_to_support_desk.rb")
57
+ end
58
+
59
+ def create_replay_migration
60
+ migration_template "add_replay_to_support_desk.rb.erb",
61
+ File.join(db_migrate_path, "add_replay_to_support_desk.rb")
62
+ end
63
+
52
64
 
53
65
  # The annotated initializer — every setting the gem has, with what it
54
66
  # means and what it defaults to.
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # support_desk 0.6.0 — OWNERS.
4
+ #
5
+ # A polymorphic, nullable `owner` on desks and assistants, for an app that
6
+ # runs a support desk per customer (an account, a workspace, a product):
7
+ # every owner gets its own `:default` desk, and SupportDesk::Current.owner
8
+ # says whose is meant. Rows with no owner are the single-desk app every
9
+ # install was before, and nothing about them changes.
10
+ #
11
+ # A key is unique PER OWNER: `(owner_type, owner_id, key)`. The old unique
12
+ # index on `key` alone becomes a partial one over the rows with no owner,
13
+ # where partial indexes exist (PostgreSQL, SQLite). MySQL has none, so there
14
+ # an ownerless key is kept unique by the find-first lookup only.
15
+ #
16
+ # `owner_id` follows your generator's primary key type (uuid or bigint).
17
+ # If your owners are keyed differently, change `owner_id_type` before you
18
+ # migrate. Every statement is guarded.
19
+ #
20
+ # The migration itself is safe under 0.5: until a row has an owner, a key
21
+ # still names one row. TURNING OWNERS ON is not: 0.5 finds desks and
22
+ # assistants with `find_by(key:)`, so the first owned row can be picked by a
23
+ # 0.5 process. Migrate, deploy 0.6 to every process and retire every 0.5
24
+ # one, and only then set SupportDesk::Current.owner.
25
+ class AddOwnersToSupportDesk < ActiveRecord::Migration<%= migration_version %>
26
+ TABLES = { support_desk_desks: "desks", support_desk_assistants: "assistants" }.freeze
27
+
28
+ def up
29
+ TABLES.each do |table, name|
30
+ unless column_exists?(table, :owner_id)
31
+ add_reference table, :owner, polymorphic: true, null: true, type: owner_id_type, index: false
32
+ end
33
+
34
+ owned = "index_support_desk_#{name}_on_owner_and_key"
35
+ add_index table, %i[owner_type owner_id key], unique: true, name: owned unless index_name_exists?(table, owned)
36
+
37
+ global = "index_support_desk_#{name}_on_key"
38
+ remove_index table, name: global if index_name_exists?(table, global)
39
+
40
+ ownerless = "index_support_desk_#{name}_on_key_without_owner"
41
+ next unless partial_indexes?
42
+ next if index_name_exists?(table, ownerless)
43
+
44
+ add_index table, :key, unique: true, name: ownerless, where: "owner_type IS NULL AND owner_id IS NULL"
45
+ end
46
+ end
47
+
48
+ # Exactly what `up` took away comes back. It fails — on purpose — once two
49
+ # owners share a key: the old schema has no way to hold them.
50
+ def down
51
+ TABLES.each do |table, name|
52
+ %W[index_support_desk_#{name}_on_owner_and_key index_support_desk_#{name}_on_key_without_owner].each do |index|
53
+ remove_index table, name: index if index_name_exists?(table, index)
54
+ end
55
+ global = "index_support_desk_#{name}_on_key"
56
+ add_index table, :key, unique: true, name: global unless index_name_exists?(table, global)
57
+ remove_reference table, :owner, polymorphic: true if column_exists?(table, :owner_id)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def owner_id_type
64
+ generators = Rails.configuration.generators
65
+ generators.options[generators.orm][:primary_key_type] == :uuid ? :uuid : :bigint
66
+ end
67
+
68
+ # Whether this adapter can enforce a rule over SOME rows. Everything but
69
+ # MySQL can — and Trilogy is MySQL under a different ADAPTER_NAME.
70
+ def partial_indexes?
71
+ !connection.adapter_name.match?(/mysql|trilogy/i)
72
+ end
73
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # support_desk 0.6.0 — DURABLE REPLAY (PostgreSQL 13+; a no-op elsewhere).
4
+ #
5
+ # Two columns on the two tables that are the desk's durable record,
6
+ # `support_desk_events` and `support_desk_message_registrations`:
7
+ #
8
+ # - `xact_id`: the transaction that wrote the row, `pg_current_xact_id()`.
9
+ # Stored as bigint — an xid8 IS a 64-bit integer — because Rails cannot
10
+ # dump an xid8 column: schema.rb would silently lose the whole table.
11
+ # - `position`: from ONE sequence for both tables, so `(xact_id, position)`
12
+ # orders every row of both, and `position` alone identifies one.
13
+ #
14
+ # SupportDesk::Events.replay reads them. Adding a column with a sequence
15
+ # default rewrites the table under an exclusive lock: on a large events
16
+ # table, run it in a quiet window. Rows that already exist get the
17
+ # migration's own transaction id, so a reader starting from zero reads them
18
+ # all. Every statement is guarded, so it is safe to run twice.
19
+ class AddReplayToSupportDesk < ActiveRecord::Migration<%= migration_version %>
20
+ SEQUENCE = "support_desk_events_position_seq"
21
+ XACT_ID = "(pg_current_xact_id()::text::bigint)"
22
+ TABLES = {
23
+ support_desk_events: "index_support_desk_events_on_replay",
24
+ support_desk_message_registrations: "index_sd_registrations_on_replay"
25
+ }.freeze
26
+
27
+ def up
28
+ return unless postgresql?
29
+
30
+ if connection.database_version < 130_000
31
+ raise ActiveRecord::MigrationError,
32
+ "support_desk's durable replay needs PostgreSQL 13 or later (pg_current_xact_id)."
33
+ end
34
+
35
+ # The events column OWNS the sequence (bigserial); the registrations
36
+ # column draws from it — the same order, for both tables.
37
+ unless column_exists?(:support_desk_events, :position)
38
+ execute "ALTER TABLE support_desk_events ADD COLUMN position bigserial NOT NULL"
39
+ end
40
+ unless column_exists?(:support_desk_message_registrations, :position)
41
+ execute "ALTER TABLE support_desk_message_registrations " \
42
+ "ADD COLUMN position bigint NOT NULL DEFAULT nextval('#{SEQUENCE}'::regclass)"
43
+ end
44
+
45
+ TABLES.each do |table, index|
46
+ unless column_exists?(table, :xact_id)
47
+ execute "ALTER TABLE #{table} ADD COLUMN xact_id bigint NOT NULL DEFAULT #{XACT_ID}"
48
+ end
49
+ add_index table, [ :xact_id, :position ], name: index unless index_exists?(table, [ :xact_id, :position ], name: index)
50
+ end
51
+ end
52
+
53
+ # Exactly what `up` added. The registrations column goes first: its
54
+ # default draws from the sequence the events column owns.
55
+ def down
56
+ return unless postgresql?
57
+
58
+ TABLES.each do |table, index|
59
+ remove_index table, name: index if index_exists?(table, [ :xact_id, :position ], name: index)
60
+ end
61
+ %i[support_desk_message_registrations support_desk_events].each do |table|
62
+ remove_column table, :xact_id if column_exists?(table, :xact_id)
63
+ remove_column table, :position if column_exists?(table, :position)
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ # Matched by prefix, because PostGIS answers "PostGIS".
70
+ def postgresql?
71
+ connection.adapter_name.match?(/\Apostg/i)
72
+ end
73
+ end
@@ -41,7 +41,12 @@ module Support
41
41
  ticket = SupportDesk::Ticket.find(ticket_id)
42
42
  return unless ticket.assistant_turn == turn # the case moved on while we waited
43
43
 
44
- <%= key %> = SupportDesk.assistant(assistant_key)
44
+ # The CASE's assistant — its desk's, and its desk's owner's — never one
45
+ # looked up by key here: a worker has no request, so a key alone would
46
+ # name the assistant with no owner. And only if she is still the one
47
+ # this turn was queued for.
48
+ <%= key %> = ticket.assistant
49
+ return unless <%= key %> && <%= key %>.key == assistant_key.to_s
45
50
  return unless ticket.assistant_policy(<%= key %>).may_observe?
46
51
 
47
52
  answer = <%= service_class %>.answer(ticket.brief, ticket.transcript)