support_desk 0.3.0 → 0.4.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 +206 -0
- data/README.md +227 -16
- data/app/controllers/support_desk/application_controller.rb +5 -2
- data/app/controllers/support_desk/tickets_controller.rb +68 -3
- data/app/helpers/support_desk/engine_helper.rb +10 -10
- data/app/helpers/support_desk/thread_helper.rb +29 -0
- data/app/views/support_desk/tickets/show.html.erb +8 -0
- data/config/locales/support_desk.en.yml +28 -0
- data/config/locales/support_desk.es.yml +28 -0
- data/lib/generators/support_desk/install_generator.rb +9 -3
- data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
- data/lib/generators/support_desk/templates/initializer.rb +21 -1
- data/lib/generators/support_desk/upgrade_generator.rb +15 -7
- data/lib/support_desk/brief.rb +1 -1
- data/lib/support_desk/configuration.rb +100 -19
- data/lib/support_desk/conversation_access.rb +42 -0
- data/lib/support_desk/doctor.rb +30 -3
- data/lib/support_desk/engine.rb +13 -1
- data/lib/support_desk/message_signatures.rb +20 -0
- data/lib/support_desk/models/assistant.rb +43 -8
- data/lib/support_desk/models/draft.rb +9 -0
- data/lib/support_desk/models/message_registration.rb +12 -0
- data/lib/support_desk/models/ticket/assistance.rb +254 -30
- data/lib/support_desk/models/ticket.rb +172 -67
- data/lib/support_desk/transcript.rb +9 -12
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +1 -5
- data/lib/support_desk.rb +82 -11
- data/lib/tasks/support_desk.rake +12 -1
- metadata +20 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: afefc82399ac7abf6a331cc44fceb5fd160694fea8c83025af5e5888399a18d2
|
|
4
|
+
data.tar.gz: c209e7f207e89de6e4e0b48ecdd8b2dfd4e7011b352cf2eaf52c82dd9b13260f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 773a60b0710143ef412597a9789b2c37e5a41d99cbb965e5908d6035c11dcd5d97b0353c4f0777e5fd1e7644414ad01afa7c19379402a83e3cb9ef81353d0896
|
|
7
|
+
data.tar.gz: 120b9347b36642f5248ba4c6e95b1898886f666913729cedc28d02b0177e3a97c5136e12ab8052501921a2b812488d55359ed326ac361dd5cb9c5af76b1ff55f
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,212 @@ 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.4.0] - 2026-10-18
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Support apart from the inbox.** `config.thread_page = :support` reads a
|
|
12
|
+
case on the engine's own page, `/support/tickets/T-AB12CD`, which renders
|
|
13
|
+
chats' thread template (same bubbles, composer and realtime) with its way
|
|
14
|
+
back leading to the support list. `support_thread_path`, the list rows and
|
|
15
|
+
the redirect after opening a case follow the setting. The default,
|
|
16
|
+
`:chats`, is unchanged. A third Hotwire Native rule pushes the ticket page
|
|
17
|
+
under `:support`.
|
|
18
|
+
- **A requester's own case always opens through chats.** chats finds every
|
|
19
|
+
thread and composer request through `viewer.chats`, which applies the
|
|
20
|
+
host's `inbox_scope`; a host keeping support out of its inbox would 404 its
|
|
21
|
+
requesters inside their own cases. The engine adds the viewer's own cases
|
|
22
|
+
to chats' `conversation_lookup_scopes` (`SupportDesk::ConversationAccess`,
|
|
23
|
+
`SupportDesk::Ticket.conversations_of`): by participation and blocks,
|
|
24
|
+
without the listing scope, each a direct conversation whose subject is that
|
|
25
|
+
very case. Nothing else the scope hides becomes reachable.
|
|
26
|
+
- **`acknowledgement`**, a desk setting: the system line posted right under a
|
|
27
|
+
requester's first message, in the same transaction, and read for them as
|
|
28
|
+
it is posted (a receipt for their own words is never news). Same values and
|
|
29
|
+
interpolations as `opening_line`; nil by default, and the gem ships
|
|
30
|
+
`support_desk.thread.acknowledgement` to opt into.
|
|
31
|
+
- An I18n key given as a line reads its `<key>_with_promise` variant when the
|
|
32
|
+
desk promises a time and the variant exists (the assistant lines already
|
|
33
|
+
did): the shipped acknowledgement is "Thanks, we got it." on a desk with
|
|
34
|
+
`reply_within = nil`, and adds the promise otherwise.
|
|
35
|
+
- `SupportDesk.humanize_promise` and `DeskConfiguration#reply_promise`: the
|
|
36
|
+
promise in the unit the desk wrote it in, from the new
|
|
37
|
+
`support_desk.durations.*` keys.
|
|
38
|
+
- `support_desk.thread.closed_notice` can interpolate `%{reply_within}`,
|
|
39
|
+
`%{desk}` and `%{label}`.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- The answer promise (`support_reply_promise`, the wizard's line, and
|
|
44
|
+
`%{reply_within}` in every line) is now said in the unit `reply_within`
|
|
45
|
+
was configured in: `24.hours` reads "24 hours" where it read "1 day", and
|
|
46
|
+
`8.hours` reads "8 hours" where it read "about 8 hours". A duration of more
|
|
47
|
+
than one unit, or a fractional one, reads as before. Waits in the console
|
|
48
|
+
are unchanged.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- `SupportDesk::ConversationAccess` adds a requester's own cases as a lookup
|
|
53
|
+
scope (`conversation_lookup_scopes`, chats 0.5.1) instead of rescuing chats'
|
|
54
|
+
404: no exception is inspected, and a host's own `find_conversation` that
|
|
55
|
+
refuses a case is final. It only ever adds a scope beside the host's: a host
|
|
56
|
+
that returns no scopes for a viewer (a suspended account) opens nothing for
|
|
57
|
+
them, their cases included.
|
|
58
|
+
- The ticket page resolves its conversation through chats' own
|
|
59
|
+
`find_conversation` (a chats controller bound to the request), so whatever
|
|
60
|
+
refuses a case at the chats URL refuses it on the ticket page too.
|
|
61
|
+
- The access fallback and the ticket page require the case and its
|
|
62
|
+
conversation to point at each other (a direct conversation whose subject is
|
|
63
|
+
that case): a ticket mislinked to a group or to another conversation opens
|
|
64
|
+
nothing.
|
|
65
|
+
- The ticket page resolves its conversation through chats' participation and
|
|
66
|
+
block rules: owning the case is no longer enough once the seat is gone.
|
|
67
|
+
- The support list's "showing the N most recent" line under a long closed
|
|
68
|
+
section (`support_desk.tickets.closed_capped`) had no copy in English or
|
|
69
|
+
Spanish, so a requester with more than 20 closed cases read "translation
|
|
70
|
+
missing".
|
|
71
|
+
|
|
72
|
+
### Compatibility
|
|
73
|
+
|
|
74
|
+
- Requires chats >= 0.5.1; take both from GitHub by tag until they are on
|
|
75
|
+
rubygems. (The old `~> 0.3, >= 0.3.2` already admitted 0.4 and 0.5: `~> 0.3`
|
|
76
|
+
is `>= 0.3, < 1.0`.) 0.5.1 brings the lookup seam the access to a
|
|
77
|
+
requester's own case is built on. The whole suite runs on chats 0.5.1, the
|
|
78
|
+
reviewed package in `vendor/cache` built from tag v0.5.1 (6159ea1). The
|
|
79
|
+
chats 0.4.2 CI leg is gone: the constraint no longer admits it. Every CI job
|
|
80
|
+
asserts the chats it resolved (`CHATS_VERSION_UNDER_TEST`), so a later chats
|
|
81
|
+
release never silently becomes what CI tests. The dummy app's chats
|
|
82
|
+
migration is chats' current install template.
|
|
83
|
+
|
|
84
|
+
## [0.3.2] - Unreleased
|
|
85
|
+
|
|
86
|
+
### Fixed
|
|
87
|
+
|
|
88
|
+
- Persist message-registration receipts atomically with clocks and turn revisions.
|
|
89
|
+
A new message with an earlier timestamp or smaller UUID is no longer dropped
|
|
90
|
+
as a replay. It invalidates the turn and triggers requester notifications and
|
|
91
|
+
handoff detection once, without rewinding clocks. This also fixes human-only
|
|
92
|
+
desks; no assistant configuration is needed to encounter the old defect.
|
|
93
|
+
- Recovery discovers unregistered replies on closed cases and honors the desk's
|
|
94
|
+
reopen/locked policy. Repair also runs when the assistant is disabled; only
|
|
95
|
+
actual assistant dispatch requires one to be configured.
|
|
96
|
+
- Public assistant assignment, release and reply refuse `turn: :current`.
|
|
97
|
+
Internal outreach and seat-taking use private paths and actual tokens.
|
|
98
|
+
- Default Chats signatures render assistant messages using per-message
|
|
99
|
+
disclosure snapshots, including old 0.3.0 messages with provenance but no
|
|
100
|
+
assistant-row snapshot. Rename, mode change and flag-off do not erase old
|
|
101
|
+
disclosure. Human replies and approved drafts keep human signatures, and host
|
|
102
|
+
signature overrides remain authoritative. Transcript names are snapshotted
|
|
103
|
+
for new messages; older ones use their stored display name.
|
|
104
|
+
- Correct the README signature override to preserve the human fallback.
|
|
105
|
+
|
|
106
|
+
### Upgrade
|
|
107
|
+
|
|
108
|
+
Run `rails generate support_desk:upgrade` and migrate **with support writes paused
|
|
109
|
+
and all old web/jobs drained**. The new receipt table follows the existing ticket
|
|
110
|
+
and message ID types and seeds historical messages up to the old role clocks.
|
|
111
|
+
Old clocks cannot reveal lost callbacks behind that baseline: review suspect
|
|
112
|
+
historical cases before resuming. Only 0.3.2 processes may write after cutover.
|
|
113
|
+
See README for recovery, rollback limitations and the unchanged SQLite caveat.
|
|
114
|
+
No gem publication or deployment is performed by this change.
|
|
115
|
+
|
|
116
|
+
## [0.3.1] - 2026-09-19
|
|
117
|
+
|
|
118
|
+
**Hardening the assistant, after an adversarial review of 0.3.0.** Nine
|
|
119
|
+
defects, every one of them in the concurrency, recovery and kill-switch
|
|
120
|
+
guarantees the release advertised rather than in what a green suite
|
|
121
|
+
covered. Nothing here changes what an assistant may do; it changes what
|
|
122
|
+
can happen to a case while she is doing it. `assign!` and `release!` take
|
|
123
|
+
a `turn:` when `by:` is an assistant — the one API change — and a host
|
|
124
|
+
with no assistant is untouched.
|
|
125
|
+
|
|
126
|
+
### Fixed
|
|
127
|
+
|
|
128
|
+
- **A customer's next question can no longer be answered around (R1).**
|
|
129
|
+
Reconciliation is a `SELECT`, and a requester never takes the case's row
|
|
130
|
+
lock, so a question could commit between the answer reading the case and
|
|
131
|
+
the answer writing to it: she replied to the previous one, and a human
|
|
132
|
+
approved a proposal from a page that predated the new one — `seen_turn`
|
|
133
|
+
was not evidence that the reviewer had seen the last committed message.
|
|
134
|
+
Every speaking path now takes the ticket's row lock and then the
|
|
135
|
+
conversation row chats updates inside every message insert, before
|
|
136
|
+
reconciling. The lock order is ticket → conversation everywhere, and it
|
|
137
|
+
is written down. **The guarantee is a row lock, so it holds on PostgreSQL
|
|
138
|
+
and MySQL and not on SQLite**, which has no row locks and whose WAL
|
|
139
|
+
snapshot reads straight through an open write transaction: a desk with an
|
|
140
|
+
assistant belongs on PostgreSQL or MySQL, and `doctor` now warns when it
|
|
141
|
+
is not.
|
|
142
|
+
- **A seat is decided under the lock (R2).** `assign!` asked whether
|
|
143
|
+
anybody held the case, and whether she could hold it, *before* the
|
|
144
|
+
transition acquired the row. No threads were needed to break it: load an
|
|
145
|
+
unassigned case into one instance, give it to a person through another,
|
|
146
|
+
and the first instance took the seat from them. A topic cap or a pause
|
|
147
|
+
committed while a take waited for the lock was read too late in the same
|
|
148
|
+
way.
|
|
149
|
+
- **A lost registration is repairable (R3).** A requester message that
|
|
150
|
+
committed while the process that should have registered it died left a
|
|
151
|
+
turn nothing could ever consume: reconciliation happened inside the
|
|
152
|
+
candidate answer's savepoint, the bumped revision made that very answer
|
|
153
|
+
stale, and the `StaleTurn` rolled the repair back with it — for ever,
|
|
154
|
+
while the clocks said `awaiting_requester` so no idle query could see the
|
|
155
|
+
case. The fold is now its own committed step, and
|
|
156
|
+
`redispatch_assistant_turns!` looks for unregistered messages before it
|
|
157
|
+
looks at clocks.
|
|
158
|
+
- **Messages that share an instant no longer deadlock the turn (R4).** The
|
|
159
|
+
unseen-message test was "its id is not the one the clock was set from",
|
|
160
|
+
so every other message on that timestamp read as new: reconciliation
|
|
161
|
+
folded an already-registered one in again, bumped the revision, and made
|
|
162
|
+
the answer that discovered it stale. The retry did the same thing. "Seen"
|
|
163
|
+
is now the pair `(created_at, id)` compared in chats' own transcript
|
|
164
|
+
order, by the same rule in Ruby and in SQL.
|
|
165
|
+
- **The silent sweep rechecks before it escalates (R5).** Its predicates
|
|
166
|
+
were true when it SELECTED its candidates. A person who answered, took
|
|
167
|
+
the case or reset the clock in between lost anyway: the case was marked
|
|
168
|
+
human-required, its priority went up, and the customer was told a person
|
|
169
|
+
was coming — on a case that already had one.
|
|
170
|
+
- **Stranded seats come back (R6).** `deactivate!` promised the sweep would
|
|
171
|
+
release her cases; the sweep only ever visited configured assistants with
|
|
172
|
+
a `responds_within`, and only cases already overdue. An assistant with no
|
|
173
|
+
promise kept her seat for ever, and one removed by the documented kill
|
|
174
|
+
switch — which takes her configuration away — was not visited at all,
|
|
175
|
+
while the doctor's assistant checks returned early on exactly that state.
|
|
176
|
+
`SupportDesk.reclaim_assistant_seats!` (and `rake
|
|
177
|
+
support_desk:reclaim_assistant_seats`) reads the seats that exist.
|
|
178
|
+
- **Running out of turns tells the host (R7).** The budget branch wrote the
|
|
179
|
+
`escalated` event, released her seat and posted the public hand-off line,
|
|
180
|
+
but only the outer `escalate!` published `:ticket_escalated` — so a
|
|
181
|
+
host's "a person is needed here" notifier never heard about a
|
|
182
|
+
conversation that ran out of turns. One private method now publishes it
|
|
183
|
+
for every hand-off.
|
|
184
|
+
- **Disclosure on old messages stops moving (R8).** The requester-facing
|
|
185
|
+
signature resolved live configuration, so taking her out of the
|
|
186
|
+
initializer rewrote "— Rose · asistente virtual" into "— Rose" on
|
|
187
|
+
messages nobody had touched. Her name and mode are snapshotted onto her
|
|
188
|
+
own row and read back from it when nothing declares her any more. A
|
|
189
|
+
rename still renames history, deliberately; the README shows the
|
|
190
|
+
`Chats.config.message_signature` lambda for hosts that want each bubble
|
|
191
|
+
frozen.
|
|
192
|
+
- **The kill switch is read after the wait (R9).** Eligibility was checked
|
|
193
|
+
before the case's row lock, and the policy then read `active?` off that
|
|
194
|
+
same stale copy, so an already-seated assistant whose job queued behind a
|
|
195
|
+
busy case could still speak after `deactivate!` had committed. Every verb
|
|
196
|
+
resolves her under the lock now, from the row. The ordinary in-flight
|
|
197
|
+
window — a switch that commits after that read — is documented rather
|
|
198
|
+
than claimed away.
|
|
199
|
+
|
|
200
|
+
### Changed
|
|
201
|
+
|
|
202
|
+
- `Ticket#assign!` and `Ticket#release!` take `turn:`. It is REQUIRED when
|
|
203
|
+
`by:` is an assistant (`ArgumentError` when omitted, `StaleTurn` when
|
|
204
|
+
stale) and ignored for a person, whose calls are unchanged.
|
|
205
|
+
- `SupportDesk.release_silent_assistants!` runs
|
|
206
|
+
`reclaim_assistant_seats!` first, and its count includes what that
|
|
207
|
+
reclaimed.
|
|
208
|
+
- The doctor's assistant invariants run whenever an assistant ROW exists,
|
|
209
|
+
not only while one is configured.
|
|
210
|
+
- `Ticket.with_unregistered_requester_messages` — the scope behind the
|
|
211
|
+
repair half of redispatch.
|
|
212
|
+
|
|
7
213
|
## [0.3.0] - 2026-09-19
|
|
8
214
|
|
|
9
215
|
**A machine can answer, and a person still owns every word it sends.** An
|
data/README.md
CHANGED
|
@@ -153,6 +153,7 @@ configuration mistake is a boot failure, never a 3 a.m. `NoMethodError`.
|
|
|
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
155
|
| `visible_desks_for` | `nil` (every desk) | `->(agent) { … }` returning the desks this agent may work; scopes the whole console |
|
|
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)) |
|
|
156
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** |
|
|
157
158
|
| `assistant(key) { … }` | — | declare an assistant (see [Assistants](#-assistants)); `config.assistants`, `config.assistant?(key)` read them back |
|
|
158
159
|
| `default_assistant` | the only one declared | which assistant the `:default` desk gets when more than one exists |
|
|
@@ -185,7 +186,7 @@ SupportDesk.desk(:billing) # another one (nil if nobody configured it)
|
|
|
185
186
|
| `reply_policy` | `:anyone` | `:anyone` (a drop-in posts, signed; an unheld case is taken by whoever answers) · `:take_over` (replying reassigns) · `:assignee_only` (raises `NotAllowed`) |
|
|
186
187
|
| `announce_assignments` | `:first_only` | `:first_only` ("Lucía is taking care of your request" once) · `:always` (hand-offs too) · `:never` |
|
|
187
188
|
| `closed_tickets` | `:reopen_on_reply` | `:reopen_on_reply` (a requester writing reopens the case) · `:locked` (composer replaced by a notice) |
|
|
188
|
-
| `reply_within` | `24.hours` | the SLA breach threshold **and** the promise the requester reads |
|
|
189
|
+
| `reply_within` | `24.hours` | the SLA breach threshold **and** the promise the requester reads, said in the unit you write it in (`72.hours` reads "72 hours") |
|
|
189
190
|
| `at_risk_after` | `4.hours` | when a waiting case starts showing as at risk |
|
|
190
191
|
| `open_rate_limit` | `{ to: 5, within: 1.hour }` | how often one requester may **ask**; `nil` disables. Counts only cases the requester opened |
|
|
191
192
|
| `max_open_tickets` | `5` | how many cases one requester may have open; `nil` disables. Same rule |
|
|
@@ -195,6 +196,7 @@ SupportDesk.desk(:billing) # another one (nil if nobody configured it)
|
|
|
195
196
|
| `auto_close_after` | `nil` | reserved for the sweep job: a duration after which an answered case closes itself |
|
|
196
197
|
| `opening_line` | `nil` | the system line a requester-opened thread starts with (see [Writing first](#writing-first)) |
|
|
197
198
|
| `opening_line_from_support` | the gem's I18n copy | the same line for a case the desk opened |
|
|
199
|
+
| `acknowledgement` | `nil` | the system line posted right under a requester's first message ("Thanks, we got it…"; see [Writing first](#the-line-a-thread-opens-with)) |
|
|
198
200
|
| `find_requester { \|query\| … }` | `nil` | how a console turns what an agent typed into a requester record |
|
|
199
201
|
|
|
200
202
|
`SupportDesk.doctor.print` checks all of it against a running app (see
|
|
@@ -410,7 +412,7 @@ mount SupportDesk::Engine => "/support" # CarHey mounts it at "/messages/suppo
|
|
|
410
412
|
| `GET /support` | their cases — open ones as chats rows, closed ones folded away, and the door into a new one |
|
|
411
413
|
| `GET /support/new` | the wizard: pick a topic, pick the thing, write. One URL, three Turbo frames |
|
|
412
414
|
| `POST /support/tickets` | `ask_support!`, then straight into the conversation |
|
|
413
|
-
| `GET /support/tickets/:id` | a stable URL for a case (`/support/tickets/T-AB12CD` works too)
|
|
415
|
+
| `GET /support/tickets/:id` | a stable URL for a case (`/support/tickets/T-AB12CD` works too): a redirect to its thread, or the page it is read on under `thread_page = :support` |
|
|
414
416
|
|
|
415
417
|
The screens inherit `config.parent_controller`, so your layout, your
|
|
416
418
|
authentication (`config.authenticate_method`) and your locale switching all
|
|
@@ -440,6 +442,54 @@ door in its place (`config.inbox_entry = :always | :when_tickets | :never`) —
|
|
|
440
442
|
a support entry that only exists once you already have a ticket is
|
|
441
443
|
undiscoverable.
|
|
442
444
|
|
|
445
|
+
### Support apart from the inbox
|
|
446
|
+
|
|
447
|
+
Some products want support to be a place of its own: a Support button that
|
|
448
|
+
leads to the person's cases, rather than an official row among their
|
|
449
|
+
conversations. Three settings say so:
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
# config/initializers/support_desk.rb
|
|
453
|
+
config.thread_page = :support # a case is read at /support/tickets/T-AB12CD
|
|
454
|
+
config.inbox_entry = :never # no door in the chats inbox
|
|
455
|
+
|
|
456
|
+
# config/initializers/chats.rb: support conversations out of the inbox, the unread badge and everything else that lists
|
|
457
|
+
config.inbox_scope = ->(relation, _viewer) {
|
|
458
|
+
relation.where(subject_type: nil).or(relation.where.not(subject_type: "SupportDesk::Ticket"))
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
(`subject_type: nil` keeps the conversations that are about nothing, which is
|
|
463
|
+
most of them: `where.not` alone would hide those too.)
|
|
464
|
+
|
|
465
|
+
The ticket page **is chats' thread**: the engine renders chats' own template,
|
|
466
|
+
so the bubbles, the composer, read state, typing and realtime are the ones
|
|
467
|
+
every other conversation has, with the desk's verified name in the header.
|
|
468
|
+
Only the way back differs: it leads to the support list. Older pages, the
|
|
469
|
+
composer and the live updates still go to chats' endpoints.
|
|
470
|
+
|
|
471
|
+
Those endpoints find a conversation through the relations chats'
|
|
472
|
+
`conversation_lookup_scopes` returns (chats >= 0.5.1), by default the viewer's
|
|
473
|
+
inbox with the host's `inbox_scope` applied, so a scope that keeps support out
|
|
474
|
+
of the inbox would also keep a requester out of their own case. The engine
|
|
475
|
+
adds one relation after chats' own (`SupportDesk::ConversationAccess`): the
|
|
476
|
+
viewer's **own** cases (`SupportDesk::Ticket.conversations_of`), through the
|
|
477
|
+
same participation and block rules (`Chats::Conversation.inbox_for`) without
|
|
478
|
+
the listing scope, each a direct conversation whose subject is that very
|
|
479
|
+
case. Nobody gains a conversation they are not seated in, anything else the
|
|
480
|
+
scope hides stays a 404, and a host's own `find_conversation` that refuses a
|
|
481
|
+
case still refuses it. support_desk only ever adds a scope beside the host's:
|
|
482
|
+
a host that must refuse a viewer entirely returns no scopes, and then nothing
|
|
483
|
+
opens for them, their cases included. The ticket page asks chats the same
|
|
484
|
+
question (chats' own `find_conversation`), so it never shows what the chats
|
|
485
|
+
URL would refuse.
|
|
486
|
+
|
|
487
|
+
Links follow the setting: `support_thread_path`, the list's rows and the
|
|
488
|
+
redirect after a case is opened all lead to the ticket page, by reference.
|
|
489
|
+
Telling the requester an answer arrived is then yours, since the chats
|
|
490
|
+
badge no longer counts it: subscribe to `agent_replied` (see [Events](#events))
|
|
491
|
+
and link the notification to `support_desk.ticket_path(ticket.reference)`.
|
|
492
|
+
|
|
443
493
|
Hotwire Native hosts merge the engine's path rules into their own, AFTER any
|
|
444
494
|
rule that could swallow them:
|
|
445
495
|
|
|
@@ -462,7 +512,7 @@ URL, so the back gesture and cold-boot deep links work.
|
|
|
462
512
|
| `link_to_support(about:, text:, **html)` | the door into the wizard (nothing when there is nobody to ask, or nothing they may ask about) |
|
|
463
513
|
| `support_unread_badge` | unread support messages, counted against the chats read horizon |
|
|
464
514
|
| `support_desk_styles` | the bundled stylesheet tag for your `<head>` |
|
|
465
|
-
| `support_reply_promise` | "We usually reply in under 24 hours", from `reply_within` |
|
|
515
|
+
| `support_reply_promise` | "We usually reply in under 24 hours", from `reply_within` (also `SupportDesk.config.desk(key).reply_promise`) |
|
|
466
516
|
| `support_ticket_state(ticket)` | "We're on it" · "We replied" · "We wrote to you" · "Closed" |
|
|
467
517
|
| `support_inbox_door?(viewer)` | whether the inbox should show the door instead of a row (`inbox_entry`) |
|
|
468
518
|
| `support_desk_record(key)`, `support_desk_avatar`, `support_desk_badge` | the desk as a counterpart: record, avatar (initials fallback), verified badge |
|
|
@@ -682,6 +732,33 @@ existing threads open exactly as they do today; `opening_line_from_support`
|
|
|
682
732
|
has the gem's own copy as its default, because a message from a desk
|
|
683
733
|
somebody never wrote to has to explain itself.
|
|
684
734
|
|
|
735
|
+
And one line goes **under** the first message, for a case the requester
|
|
736
|
+
opened: the answer to "did that go through?", posted in the same
|
|
737
|
+
transaction, before anybody on the desk has read it.
|
|
738
|
+
|
|
739
|
+
```ruby
|
|
740
|
+
config.acknowledgement = :"support_desk.thread.acknowledgement"
|
|
741
|
+
# => "Thanks, we got it. We usually reply in under 72 hours."
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
An I18n key reads its `<key>_with_promise` variant when the desk promises a
|
|
745
|
+
time and the variant exists (the shipped key has one), so a desk with
|
|
746
|
+
`reply_within = nil` says "Thanks, we got it." and nothing it cannot keep.
|
|
747
|
+
The requester's read horizon moves through the acknowledgement as it is
|
|
748
|
+
posted: it is a receipt for what they just wrote, never news to them.
|
|
749
|
+
|
|
750
|
+
Same values and interpolations as the opening lines, nil by default. It
|
|
751
|
+
never moves the clocks (it is a system message), it is pinned one database
|
|
752
|
+
tick after the message when the clock did not move between the two inserts,
|
|
753
|
+
and it is then the conversation's last message, which is what a list row
|
|
754
|
+
previews. A reply into a case already open is not acknowledged again.
|
|
755
|
+
|
|
756
|
+
`%{reply_within}` is the promise in the unit it was configured in
|
|
757
|
+
(`SupportDesk.humanize_promise`: `72.hours` is "72 hours", never "3 days"),
|
|
758
|
+
from `support_desk.durations.*`. The closed notice
|
|
759
|
+
(`support_desk.thread.closed_notice`) takes the same three interpolations,
|
|
760
|
+
so a locked case can say how soon a new one will be answered.
|
|
761
|
+
|
|
685
762
|
### From the console
|
|
686
763
|
|
|
687
764
|
Add `new` to your routes and the queue grows a "Write to someone" button:
|
|
@@ -942,6 +1019,13 @@ one raises `SupportDesk::StaleTurn` and writes nothing. That one integer is
|
|
|
942
1019
|
also why this gem has no idempotency keys, no claim rows and no leases —
|
|
943
1020
|
"is this still the case you read?" is already answered.
|
|
944
1021
|
|
|
1022
|
+
Every verb of hers takes it, and that includes taking and giving back the
|
|
1023
|
+
seat: `assign!(to: rose, by: rose, turn:)` and `release!(by: rose, turn:)`
|
|
1024
|
+
require it when `by:` is an assistant (an omitted turn is an
|
|
1025
|
+
`ArgumentError`, a stale one a `StaleTurn`), because a run that finished
|
|
1026
|
+
after the case moved on must not release the seat a newer one took. A
|
|
1027
|
+
**person's** calls are unchanged and take no turn.
|
|
1028
|
+
|
|
945
1029
|
Two consequences worth knowing:
|
|
946
1030
|
|
|
947
1031
|
- Check `ticket.assistant_turn == turn` in your job **before you spend
|
|
@@ -949,6 +1033,34 @@ Two consequences worth knowing:
|
|
|
949
1033
|
- A retry after a committed action is a `StaleTurn`, and that is correct:
|
|
950
1034
|
the work was done. Generated jobs `discard_on` it.
|
|
951
1035
|
|
|
1036
|
+
#### Ticket, then conversation
|
|
1037
|
+
|
|
1038
|
+
A turn is only as good as the reconciliation behind it, and reconciliation
|
|
1039
|
+
is a `SELECT`: on its own it cannot exclude a message that commits a
|
|
1040
|
+
millisecond later. Your customer never takes the case's row lock — they
|
|
1041
|
+
press send, and chats writes a message.
|
|
1042
|
+
|
|
1043
|
+
What chats *does* take is the **conversation row**: every message insert
|
|
1044
|
+
updates `chats_conversations` inside its own transaction. So every path
|
|
1045
|
+
that speaks takes the ticket's row lock and then that row, before
|
|
1046
|
+
reconciling. A question already in flight holds it, so we wait for it and
|
|
1047
|
+
the turn goes stale; a question that starts after we hold it waits for us
|
|
1048
|
+
and raises a turn of its own. **The order is always ticket → conversation**,
|
|
1049
|
+
in this gem and in anything you add to it.
|
|
1050
|
+
|
|
1051
|
+
What that does not cover: a message whose `INSERT` was already stamped when
|
|
1052
|
+
we won the row is still stamped earlier than the answer, so a transcript can
|
|
1053
|
+
show a question above an answer that did not address it. That is a
|
|
1054
|
+
genuinely simultaneous send, and its own turn follows.
|
|
1055
|
+
|
|
1056
|
+
> [!IMPORTANT]
|
|
1057
|
+
> **This is a row lock, so it is PostgreSQL and MySQL.** SQLite has none:
|
|
1058
|
+
> it serializes writes, and in WAL mode a reconciliation `SELECT` reads the
|
|
1059
|
+
> last committed snapshot straight through a requester's open write
|
|
1060
|
+
> transaction, so a question committing behind an answer is still missed
|
|
1061
|
+
> there. Run PostgreSQL or MySQL for a production desk with an assistant —
|
|
1062
|
+
> `SupportDesk.doctor` warns when you haven't.
|
|
1063
|
+
|
|
952
1064
|
### Proposals in the console
|
|
953
1065
|
|
|
954
1066
|
A pending proposal renders above the composer with its confidence, its
|
|
@@ -1020,6 +1132,32 @@ the turn and the whole policy that allowed it, `ticket.export` labels it
|
|
|
1020
1132
|
marks it for staff. What a customer is told is a product decision; what your
|
|
1021
1133
|
records say a machine wrote is not.
|
|
1022
1134
|
|
|
1135
|
+
**Historical disclosure is captured per message.** The default Chats signature
|
|
1136
|
+
renderer uses the assistant name and disclosure stored when the message was
|
|
1137
|
+
sent. Renaming, changing mode or removing configuration does not rewrite old
|
|
1138
|
+
signed bubbles. Human replies and human-approved drafts retain their normal
|
|
1139
|
+
human signatures. The assistant row snapshot is a fallback for legacy messages
|
|
1140
|
+
without provenance; their original disclosure cannot be reconstructed if it
|
|
1141
|
+
was never stored.
|
|
1142
|
+
|
|
1143
|
+
A host's `Chats.config.message_signature` override remains authoritative. If
|
|
1144
|
+
you customize it, preserve the human fallback as well as the assistant snapshot:
|
|
1145
|
+
|
|
1146
|
+
```ruby
|
|
1147
|
+
Chats.configure do |config|
|
|
1148
|
+
config.message_signature = lambda do |message|
|
|
1149
|
+
stamp = message.metadata["support_desk"]
|
|
1150
|
+
name = if stamp.is_a?(Hash) && stamp["kind"] == "ai" && stamp["signed"] == true &&
|
|
1151
|
+
stamp["display_name"].is_a?(String) && stamp["display_name"].present?
|
|
1152
|
+
stamp["display_name"]
|
|
1153
|
+
else
|
|
1154
|
+
Chats.display_name_for(message.author)
|
|
1155
|
+
end
|
|
1156
|
+
I18n.t("chats.message.signature", name: name)
|
|
1157
|
+
end
|
|
1158
|
+
end
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1023
1161
|
### Humans outrank
|
|
1024
1162
|
|
|
1025
1163
|
- A person may answer a case she holds under **every** `reply_policy`.
|
|
@@ -1071,8 +1209,27 @@ whatever she would have said. `redispatch_assistant_turns` re-emits the turn
|
|
|
1071
1209
|
for cases nobody acted on, which is safe precisely because the turn is
|
|
1072
1210
|
consumed by the first action and every later one is a `StaleTurn`.
|
|
1073
1211
|
|
|
1074
|
-
`
|
|
1075
|
-
|
|
1212
|
+
`release_silent_assistants` runs `SupportDesk.reclaim_assistant_seats!`
|
|
1213
|
+
first, and you can run that on its own
|
|
1214
|
+
(`rake support_desk:reclaim_assistant_seats`). It is a different question
|
|
1215
|
+
from silence: it reads the **seats that exist** rather than the assistants
|
|
1216
|
+
this process happens to have configured, and gives back every one whose
|
|
1217
|
+
holder is switched off, no longer declared, or no longer allowed to hold a
|
|
1218
|
+
case — with no `responds_within` and no overdue clock anywhere in it. That
|
|
1219
|
+
is what makes `deactivate!` and a flag flipped off actually release her
|
|
1220
|
+
cases, and it is why the doctor's seat checks keep running when the
|
|
1221
|
+
configuration is gone.
|
|
1222
|
+
|
|
1223
|
+
`redispatch_assistant_turns` also repairs before it decides: a requester
|
|
1224
|
+
message whose registration was lost after its commit leaves the clocks
|
|
1225
|
+
describing a case that no longer exists, so the task looks for unregistered
|
|
1226
|
+
**messages** and not only for idle clocks. The repair commits on its own,
|
|
1227
|
+
which is what makes a dead process followed by nothing but this task end in
|
|
1228
|
+
an actionable turn.
|
|
1229
|
+
|
|
1230
|
+
`rake support_desk:assistant_status` reports current counts without changing
|
|
1231
|
+
tickets. Resolving configured assistants may create their identity rows or
|
|
1232
|
+
refresh their name/disclosure snapshots. `SupportDesk.doctor` covers the same ground
|
|
1076
1233
|
with verdicts.
|
|
1077
1234
|
|
|
1078
1235
|
### What the model sees
|
|
@@ -1166,7 +1323,8 @@ acts_as_support_agent kind: :ai # validated; see
|
|
|
1166
1323
|
SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
|
|
1167
1324
|
SupportDesk.reset_assistants!
|
|
1168
1325
|
SupportDesk.ai_actor?(record)
|
|
1169
|
-
SupportDesk.release_silent_assistants! # → Integer
|
|
1326
|
+
SupportDesk.release_silent_assistants! # → Integer (runs reclaim_assistant_seats! first)
|
|
1327
|
+
SupportDesk.reclaim_assistant_seats! # → Integer; seats she may no longer sit in
|
|
1170
1328
|
SupportDesk.redispatch_assistant_turns!(older_than: 1.minute) # → Integer
|
|
1171
1329
|
```
|
|
1172
1330
|
|
|
@@ -1176,7 +1334,7 @@ SupportDesk.redispatch_assistant_turns!(older_than: 1.minute) # → Integer
|
|
|
1176
1334
|
|---|---|
|
|
1177
1335
|
| `SupportDesk::Assistant.for(key)` / `.active` | found or created; the on-duty scope |
|
|
1178
1336
|
| `config` / `configured?` | her slice of the configuration; whether anything still declares her |
|
|
1179
|
-
| `name` `avatar` `autonomy` `disclosure` `max_turns` `responds_within` `may_open_conversations?` | read through the configuration,
|
|
1337
|
+
| `name` `avatar` `autonomy` `disclosure` `max_turns` `responds_within` `may_open_conversations?` | read through the configuration; `name` and `disclosure` fall back to the snapshot on her row, the rest to the safe answer |
|
|
1180
1338
|
| `disclosed?` `signs?` `notice?` | the mode, as predicates |
|
|
1181
1339
|
| `disclosed_name` `display_name` `to_s` `support_agent_name` `support_agent_avatar` | what a requester sees |
|
|
1182
1340
|
| `on_duty?` `support_capacity` | the agent contract |
|
|
@@ -1201,6 +1359,8 @@ assistant" it was).
|
|
|
1201
1359
|
respond!(body, by:, turn:, files:, confidence:, sources:, metadata:, request:) # → Outcome
|
|
1202
1360
|
draft!(body, by:, turn:, …) # → Draft
|
|
1203
1361
|
escalate!(by:, reason:, summary:, turn:, request:)
|
|
1362
|
+
assign!(to:, by:, reason:, note:, request:, turn:) # turn: required when by: is an assistant
|
|
1363
|
+
release!(by:, reason: :released, request:, turn:) # idem
|
|
1204
1364
|
request_human!(by:, request:) # by: must be the requester
|
|
1205
1365
|
pause_assistant!(by:, reason:) / resume_assistant!(by:)
|
|
1206
1366
|
|
|
@@ -1213,6 +1373,7 @@ drafts pending_draft notes
|
|
|
1213
1373
|
# scopes
|
|
1214
1374
|
held_by_assistants held_by_humans needs_human assistant_paused assistant_capped
|
|
1215
1375
|
resolved_by_assistant with_pending_draft assistant_idle_since(time)
|
|
1376
|
+
with_unregistered_requester_messages
|
|
1216
1377
|
```
|
|
1217
1378
|
|
|
1218
1379
|
Columns: `assistant_revision`, `last_requester_message_id`,
|
|
@@ -1288,12 +1449,13 @@ the count is non-zero.
|
|
|
1288
1449
|
`support_human_door(ticket)` helper.
|
|
1289
1450
|
|
|
1290
1451
|
**Rake** — `support_desk:release_silent_assistants` ·
|
|
1452
|
+
`support_desk:reclaim_assistant_seats` ·
|
|
1291
1453
|
`support_desk:redispatch_assistant_turns` (`OLDER_THAN=60`) ·
|
|
1292
1454
|
`support_desk:assistant_status`.
|
|
1293
1455
|
|
|
1294
1456
|
**Doctor** — `assistants (config)` · `assistant turn subscriber` ·
|
|
1295
1457
|
`assistant authorship` · `assistant silence` · `assistant seats` ·
|
|
1296
|
-
`assistant idle turns` · `drafts` · `ai agents without policy`.
|
|
1458
|
+
`assistant idle turns` · `drafts` · `ai agents without policy` · `message registrations`.
|
|
1297
1459
|
|
|
1298
1460
|
**Test helpers**
|
|
1299
1461
|
|
|
@@ -1310,10 +1472,54 @@ the count is non-zero.
|
|
|
1310
1472
|
| `with_assistant_config(key = nil, **overrides) { … }` | |
|
|
1311
1473
|
| `with_topic_assistant_cap(path, level) { … }` | rebuilds the frozen tree with one cap |
|
|
1312
1474
|
|
|
1313
|
-
### Upgrading to 0.3
|
|
1475
|
+
### Upgrading to 0.3.2 (including hosts without assistants)
|
|
1476
|
+
|
|
1477
|
+
0.3.2 adds `support_desk_message_registrations`, an internal receipt per text
|
|
1478
|
+
message. A receipt, written with the ticket clocks and revision in one
|
|
1479
|
+
transaction, distinguishes a replay from an unseen message. Timestamp and UUID
|
|
1480
|
+
order are not evidence of delivery. Every unseen requester message invalidates
|
|
1481
|
+
the turn, notifies the host and runs handoff detection once, even if its timestamp
|
|
1482
|
+
is older; the SLA timestamps themselves never move backwards.
|
|
1483
|
+
|
|
1484
|
+
**This upgrade requires a drained cutover; it is not rolling-safe.** The table
|
|
1485
|
+
is additive, but old processes cannot write receipts.
|
|
1486
|
+
|
|
1487
|
+
1. Pause support writes and drain old web requests and jobs. Keep the assistant
|
|
1488
|
+
disabled throughout the cutover.
|
|
1489
|
+
2. Run `rails generate support_desk:upgrade`, inspect the new migration, then
|
|
1490
|
+
`rails db:migrate` while writes remain paused. Fresh installs get the same
|
|
1491
|
+
migration from `support_desk:install`.
|
|
1492
|
+
3. The migration seeds existing text messages at or before each role's old
|
|
1493
|
+
clock as the historical baseline. Messages beyond those clocks remain
|
|
1494
|
+
discoverable by recovery. **Old data cannot prove whether a message behind
|
|
1495
|
+
the clock lost its callback.** Review suspect historical cases explicitly;
|
|
1496
|
+
the migration does not replay old notifications or reopen history en masse.
|
|
1497
|
+
4. Start only 0.3.2 processes. Run `SupportDesk.doctor` and
|
|
1498
|
+
`SupportDesk.redispatch_assistant_turns!` before resuming support traffic.
|
|
1499
|
+
Recovery checks closed cases as well: `:reopen_on_reply` reopens them, while
|
|
1500
|
+
`:locked` preserves closure and dispatches no model work. Registration repair
|
|
1501
|
+
also runs on desks with no assistant configured.
|
|
1502
|
+
5. Resume writes. An assistant rollout still requires its own staging checks
|
|
1503
|
+
and disclosure decision. The serialization guarantee still requires
|
|
1504
|
+
PostgreSQL/MySQL; SQLite does not acquire row locks.
|
|
1505
|
+
|
|
1506
|
+
Do not roll back to an old writer while serving traffic: it would leave missing
|
|
1507
|
+
receipts. If a rollback is necessary, pause and drain first, keep the receipt
|
|
1508
|
+
table, and re-establish the historical baseline before a later upgrade. Removing
|
|
1509
|
+
a receipt deliberately permits processing that message again; it is an internal
|
|
1510
|
+
recovery action, not a normal host API.
|
|
1511
|
+
|
|
1512
|
+
Public assistant `turn:` arguments must be the observed opaque token. Symbols
|
|
1513
|
+
such as `:current` are refused; private outreach and automatic seat-taking do
|
|
1514
|
+
not expose a public bypass. No new host-facing registration API is required.
|
|
1515
|
+
|
|
1516
|
+
### Historical upgrade from 0.2 to 0.3.0
|
|
1517
|
+
|
|
1518
|
+
The following describes the original assistants migration. When installing the
|
|
1519
|
+
current release, also follow the 0.3.2 cutover above.
|
|
1314
1520
|
|
|
1315
1521
|
```bash
|
|
1316
|
-
rails generate support_desk:upgrade #
|
|
1522
|
+
rails generate support_desk:upgrade # current releases also copy the receipt migration
|
|
1317
1523
|
rails db:migrate
|
|
1318
1524
|
```
|
|
1319
1525
|
|
|
@@ -1464,10 +1670,13 @@ supportable), `find_requester` (callable, one argument), `engine mount`,
|
|
|
1464
1670
|
(no half-NULL `opened_by`; warns on legacy NULL rows and names the backfill
|
|
1465
1671
|
task), `awaiting` (agrees with the transcript), `references` (unique).
|
|
1466
1672
|
Assistants (only where one is configured): `assistants (config)`,
|
|
1467
|
-
`assistant turn subscriber`, `assistant
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1673
|
+
`assistant turn subscriber`, `assistant serialization` — which warns when
|
|
1674
|
+
the adapter takes no row locks, because the turn rests on them —
|
|
1675
|
+
`assistant authorship`, `assistant silence`, `assistant seats`, `assistant
|
|
1676
|
+
idle turns`, `drafts`, and `ai agents without policy`, which warns about any
|
|
1677
|
+
host class declared `kind: :ai`, since every support write by it is refused.
|
|
1678
|
+
The seat and draft checks also run once she is no longer configured, since
|
|
1679
|
+
that is exactly when a seat gets stranded.
|
|
1471
1680
|
|
|
1472
1681
|
## Compatibility
|
|
1473
1682
|
|
|
@@ -1536,12 +1745,14 @@ SupportDesk.desk(key = :default) # the Desk record, found or created, memo
|
|
|
1536
1745
|
SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
|
|
1537
1746
|
SupportDesk.reset_assistants! SupportDesk.ai_actor?(record)
|
|
1538
1747
|
SupportDesk.release_silent_assistants! # the net under a dead harness (schedule it)
|
|
1748
|
+
SupportDesk.reclaim_assistant_seats! # seats an assistant may no longer sit in
|
|
1539
1749
|
SupportDesk.redispatch_assistant_turns!(older_than: 1.minute)
|
|
1540
1750
|
SupportDesk.find_topic("billing/invoice")
|
|
1541
1751
|
SupportDesk.on(event, key: nil) { … } SupportDesk.off(event, key)
|
|
1542
1752
|
SupportDesk.doctor
|
|
1543
1753
|
SupportDesk.native_path_rules(mount:, title:) # Hotwire Native path rules for the requester screens
|
|
1544
|
-
SupportDesk.humanize_duration(24.hours) # "1 day", in the reader's language
|
|
1754
|
+
SupportDesk.humanize_duration(24.hours) # "1 day", in the reader's language (a wait: rounded)
|
|
1755
|
+
SupportDesk.humanize_promise(72.hours) # "72 hours" (a promise: in the unit it was made in)
|
|
1545
1756
|
SupportDesk.actor_key(record) # a stable key for an actor (GlobalID param); what event payloads store
|
|
1546
1757
|
SupportDesk.requester_class?(klass) .supportable_class?(klass) .agent_class?(klass)
|
|
1547
1758
|
SupportDesk.eligible?(record, condition) # how both macros read their `if:`
|
|
@@ -1553,7 +1764,7 @@ SupportDesk::VERSION
|
|
|
1553
1764
|
|
|
1554
1765
|
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.
|
|
1555
1766
|
|
|
1556
|
-
`chats` is the kernel this gem is a product on and the two are developed in lockstep,
|
|
1767
|
+
`chats` is the kernel this gem is a product on and the two are developed in lockstep. support_desk requires `chats >= 0.5.1, < 1.0`. Until that is on rubygems, the bundle takes the reviewed chats package from `vendor/cache` (provenance and checksum in `vendor/cache/README.md`), so `bundle exec rake ci` needs no network and no sibling checkout.
|
|
1557
1768
|
|
|
1558
1769
|
## Contributing
|
|
1559
1770
|
|
|
@@ -81,10 +81,13 @@ module SupportDesk
|
|
|
81
81
|
respond_to?(:chats) ? chats : Chats::Engine.routes.url_helpers
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
# Where a case is read and answered: its chats conversation,
|
|
85
|
-
#
|
|
84
|
+
# Where a case is read and answered: its chats conversation, in the
|
|
85
|
+
# chats inbox (`thread_page = :chats`) or on this engine's ticket page
|
|
86
|
+
# (`:support`). Never a second thread UI of our own: that page renders
|
|
87
|
+
# chats' thread.
|
|
86
88
|
def conversation_path_for(ticket)
|
|
87
89
|
return root_path if ticket.conversation.nil?
|
|
90
|
+
return ticket_path(ticket.reference) if SupportDesk.config.support_thread_page?
|
|
88
91
|
|
|
89
92
|
chats_routes.conversation_path(ticket.conversation)
|
|
90
93
|
end
|