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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +151 -6
- data/app/controllers/support_desk/tickets_controller.rb +6 -4
- data/app/helpers/support_desk/engine_helper.rb +17 -7
- data/app/views/chats/slots/_inbox_top.html.erb +1 -1
- data/app/views/support_desk/launcher/_badge.html.erb +11 -3
- data/app/views/support_desk/launcher/_launcher.html.erb +2 -2
- data/lib/generators/support_desk/install_generator.rb +12 -0
- data/lib/generators/support_desk/templates/add_owners_to_support_desk.rb.erb +73 -0
- data/lib/generators/support_desk/templates/add_replay_to_support_desk.rb.erb +73 -0
- data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +6 -1
- data/lib/generators/support_desk/templates/initializer.rb +12 -0
- data/lib/generators/support_desk/upgrade_generator.rb +22 -2
- data/lib/support_desk/brief.rb +1 -1
- data/lib/support_desk/configuration.rb +106 -6
- data/lib/support_desk/context_card.rb +1 -1
- data/lib/support_desk/current.rb +22 -0
- data/lib/support_desk/events.rb +41 -0
- data/lib/support_desk/launcher.rb +14 -4
- data/lib/support_desk/models/assistant.rb +37 -9
- data/lib/support_desk/models/concerns/requester.rb +3 -2
- data/lib/support_desk/models/desk.rb +40 -7
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/ticket/assistance.rb +36 -7
- data/lib/support_desk/models/ticket.rb +80 -25
- data/lib/support_desk/queue.rb +3 -1
- data/lib/support_desk/replay.rb +109 -0
- data/lib/support_desk/timeline.rb +1 -1
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +58 -23
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f59effae389db2716b2776132aa991881576422815776fe189e984ca95ce72ae
|
|
4
|
+
data.tar.gz: 2c8b33513576b69e28e787820f9059178c5f16a2f76cbe2b9b812e0bd1b5c835
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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)
|
|
1822
|
-
SupportDesk.assistant(key = nil)
|
|
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
|
|
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
|
-
|
|
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
|
|
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] ||=
|
|
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 =
|
|
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
|
-
<%=
|
|
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
|
-
`
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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)
|