support_desk 0.3.2 → 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 +77 -0
- data/README.md +83 -5
- 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/templates/initializer.rb +21 -1
- 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/engine.rb +7 -0
- data/lib/support_desk/models/ticket.rb +55 -1
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +1 -5
- data/lib/support_desk.rb +32 -4
- metadata +17 -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,83 @@ 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
|
+
|
|
7
84
|
## [0.3.2] - Unreleased
|
|
8
85
|
|
|
9
86
|
### Fixed
|
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:
|
|
@@ -1674,7 +1751,8 @@ SupportDesk.find_topic("billing/invoice")
|
|
|
1674
1751
|
SupportDesk.on(event, key: nil) { … } SupportDesk.off(event, key)
|
|
1675
1752
|
SupportDesk.doctor
|
|
1676
1753
|
SupportDesk.native_path_rules(mount:, title:) # Hotwire Native path rules for the requester screens
|
|
1677
|
-
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)
|
|
1678
1756
|
SupportDesk.actor_key(record) # a stable key for an actor (GlobalID param); what event payloads store
|
|
1679
1757
|
SupportDesk.requester_class?(klass) .supportable_class?(klass) .agent_class?(klass)
|
|
1680
1758
|
SupportDesk.eligible?(record, condition) # how both macros read their `if:`
|
|
@@ -1686,7 +1764,7 @@ SupportDesk::VERSION
|
|
|
1686
1764
|
|
|
1687
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.
|
|
1688
1766
|
|
|
1689
|
-
`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.
|
|
1690
1768
|
|
|
1691
1769
|
## Contributing
|
|
1692
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
|
|
@@ -16,6 +16,11 @@ module SupportDesk
|
|
|
16
16
|
before_action :set_wizard, only: %i[new create]
|
|
17
17
|
include Chats::SendRateLimited
|
|
18
18
|
|
|
19
|
+
# The ticket page renders chats' thread template, which names chats'
|
|
20
|
+
# routes bare (SupportDesk::ThreadHelper) and asks for these two.
|
|
21
|
+
helper SupportDesk::ThreadHelper
|
|
22
|
+
helper_method :chats_current_messager, :chats_counterpart
|
|
23
|
+
|
|
19
24
|
# Their cases: open ones as rows, closed ones folded away, and the door
|
|
20
25
|
# to open another.
|
|
21
26
|
def index
|
|
@@ -67,10 +72,15 @@ module SupportDesk
|
|
|
67
72
|
end
|
|
68
73
|
|
|
69
74
|
# A stable URL for a case — what an email or a push notification links
|
|
70
|
-
# to.
|
|
71
|
-
# nothing else.
|
|
75
|
+
# to. Under `thread_page = :chats` the case is read in the inbox's thread,
|
|
76
|
+
# so this is a redirect and nothing else. Under `:support` it is the page
|
|
77
|
+
# the case is read on: chats' own thread, with its back link leading
|
|
78
|
+
# here rather than to an inbox support is not part of.
|
|
72
79
|
def show
|
|
73
|
-
|
|
80
|
+
ticket = find_ticket
|
|
81
|
+
return redirect_to conversation_path_for(ticket) unless SupportDesk.config.support_thread_page? && ticket.conversation
|
|
82
|
+
|
|
83
|
+
render_thread(ticket)
|
|
74
84
|
end
|
|
75
85
|
|
|
76
86
|
# "Prefiero hablar con una persona." The one thing a requester can
|
|
@@ -96,6 +106,61 @@ module SupportDesk
|
|
|
96
106
|
|
|
97
107
|
def chat_rate_limit_messager = current_requester
|
|
98
108
|
|
|
109
|
+
# chats' thread page, rendered here: the same latest page of messages,
|
|
110
|
+
# the same «new messages» line and the same read on open as
|
|
111
|
+
# Chats::ConversationsController#show, for chats' own template. Older
|
|
112
|
+
# pages, the composer and the live updates are chats' endpoints, which
|
|
113
|
+
# reach this conversation through SupportDesk::ConversationAccess.
|
|
114
|
+
def render_thread(ticket)
|
|
115
|
+
@ticket = ticket
|
|
116
|
+
@conversation = chats_conversation(ticket)
|
|
117
|
+
@participant = @conversation.participant_for(current_requester)
|
|
118
|
+
|
|
119
|
+
scope = @conversation.messages.includes(:sender, :reactions)
|
|
120
|
+
scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
|
|
121
|
+
page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
|
|
122
|
+
@more_messages = page.size > Chats.config.messages_per_page
|
|
123
|
+
@messages = page.first(Chats.config.messages_per_page).reverse
|
|
124
|
+
|
|
125
|
+
if @participant&.unread?
|
|
126
|
+
@first_unread_id = @participant.unread_messages.where(id: @messages.map(&:id)).oldest_first.pick(:id) ||
|
|
127
|
+
@messages.first&.id
|
|
128
|
+
end
|
|
129
|
+
# A pointer resting on a link (Turbo's hover prefetch) is not somebody
|
|
130
|
+
# reading the case; the visit that follows reads it. Read through the
|
|
131
|
+
# last message on the page, not merely "now": an acknowledgement pinned
|
|
132
|
+
# one tick after the message it answers may sit just ahead of the clock.
|
|
133
|
+
@participant&.read!(at: [ Time.current, @messages.last&.created_at ].compact.max) unless prefetch_request?
|
|
134
|
+
|
|
135
|
+
render :show
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The case's conversation, as chats' own endpoints would find it: chats'
|
|
139
|
+
# `find_conversation` on a chats controller bound to this request, so
|
|
140
|
+
# every rule the host or a product gem added there (its lookup scopes, its
|
|
141
|
+
# own `find_conversation`) decides here exactly as at the chats URL. Then
|
|
142
|
+
# it must be this very case's conversation: direct, about this case (the
|
|
143
|
+
# rule Ticket.conversations_of holds the chats lookup to). A 404
|
|
144
|
+
# otherwise, never a hint.
|
|
145
|
+
def chats_conversation(ticket)
|
|
146
|
+
lookup = Chats::ApplicationController.new
|
|
147
|
+
lookup.set_request!(request)
|
|
148
|
+
conversation = lookup.send(:find_conversation, ticket.conversation_id)
|
|
149
|
+
raise ActiveRecord::RecordNotFound unless conversation.direct? &&
|
|
150
|
+
conversation.subject_type == ticket.class.polymorphic_name &&
|
|
151
|
+
conversation.subject_id.to_s == ticket.id.to_s
|
|
152
|
+
|
|
153
|
+
conversation
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def prefetch_request?
|
|
157
|
+
request.headers["X-Sec-Purpose"] == "prefetch" || request.headers["Sec-Purpose"].to_s.include?("prefetch")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# What chats' thread template asks its controller for.
|
|
161
|
+
def chats_current_messager = current_requester
|
|
162
|
+
def chats_counterpart = @conversation&.counterpart_for(current_requester)
|
|
163
|
+
|
|
99
164
|
def set_wizard
|
|
100
165
|
@wizard = SupportDesk::Wizard.new(current_requester, params)
|
|
101
166
|
# A token that is forged, expired, or points at somebody else's record
|
|
@@ -84,14 +84,11 @@ module SupportDesk
|
|
|
84
84
|
content_for(:head) { stylesheet_link_tag "support_desk", "data-turbo-track": "reload" }
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
-
# "Normalmente respondemos en menos de 24
|
|
88
|
-
# desk promises nothing. The same `config.reply_within` the SLA
|
|
89
|
-
# on — one setting, one truth.
|
|
87
|
+
# "Normalmente respondemos en menos de 24 horas" for a desk, or nil when
|
|
88
|
+
# the desk promises nothing. The same `config.reply_within` the SLA
|
|
89
|
+
# breaches on — one setting, one truth.
|
|
90
90
|
def support_reply_promise(desk_key = :default)
|
|
91
|
-
|
|
92
|
-
return nil if within.nil?
|
|
93
|
-
|
|
94
|
-
t("support_desk.thread.promise", time: SupportDesk.humanize_duration(within))
|
|
91
|
+
SupportDesk.config.desk(desk_key).reply_promise
|
|
95
92
|
end
|
|
96
93
|
|
|
97
94
|
# The status line under a case in the requester's list: who owes the next
|
|
@@ -178,10 +175,13 @@ module SupportDesk
|
|
|
178
175
|
chats_verified_badge(support_desk_record(desk_key))
|
|
179
176
|
end
|
|
180
177
|
|
|
181
|
-
# Where a case is read and answered: its chats conversation
|
|
182
|
-
#
|
|
183
|
-
# the
|
|
178
|
+
# Where a case is read and answered: its chats conversation, or the
|
|
179
|
+
# engine's own ticket page under `thread_page = :support` (by reference:
|
|
180
|
+
# the URL people read back from a screen). Falls back to the ticket URL
|
|
181
|
+
# (which redirects to the same place) for the one case that has no
|
|
182
|
+
# conversation yet, so a link is never dead.
|
|
184
183
|
def support_thread_path(ticket)
|
|
184
|
+
return support_desk_routes.ticket_path(ticket.reference) if SupportDesk.config.support_thread_page?
|
|
185
185
|
return support_desk_routes.ticket_path(ticket) if ticket.conversation.nil? || !respond_to?(:chats_routes)
|
|
186
186
|
|
|
187
187
|
chats_routes.conversation_path(ticket.conversation)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# The ticket page (`config.thread_page = :support`) renders chats' own
|
|
5
|
+
# thread template, and that template names chats' routes bare:
|
|
6
|
+
# `conversation_path`, `read_conversation_path`,
|
|
7
|
+
# `conversation_messages_path`… Inside chats' engine those are its own;
|
|
8
|
+
# rendered from this engine they would be looked up here and not exist.
|
|
9
|
+
#
|
|
10
|
+
# So on that page any route helper this engine does not have is chats',
|
|
11
|
+
# through its mounted proxy (`chats_routes`), and the one name both
|
|
12
|
+
# engines draw stays ours: `root_path` is the support list, which is
|
|
13
|
+
# exactly where the thread's way back should lead.
|
|
14
|
+
module ThreadHelper
|
|
15
|
+
def method_missing(name, ...)
|
|
16
|
+
chats_route?(name) ? chats_routes.public_send(name, ...) : super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def respond_to_missing?(name, include_private = false)
|
|
20
|
+
chats_route?(name) || super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def chats_route?(name)
|
|
26
|
+
name.end_with?("_path", "_url") && chats_routes.respond_to?(name)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%# One case, read where support lives (`config.thread_page = :support`).
|
|
2
|
+
|
|
3
|
+
It is chats' thread: the same template, so the same bubbles, composer,
|
|
4
|
+
read state and live updates as every conversation in the app, with the
|
|
5
|
+
desk's verified name in its header. Only the way back differs: its
|
|
6
|
+
`root_path` is this engine's, the support list (SupportDesk::ThreadHelper).
|
|
7
|
+
Eject this file to put anything of your own around it. %>
|
|
8
|
+
<%= render template: "chats/conversations/show" %>
|
|
@@ -14,12 +14,37 @@ en:
|
|
|
14
14
|
closed_notice: "This conversation is closed. Start a new one if you need anything else."
|
|
15
15
|
reopened_notice: "We reopened this conversation."
|
|
16
16
|
promise: "We usually reply in under %{time}"
|
|
17
|
+
acknowledgement: "Thanks, we got it."
|
|
18
|
+
acknowledgement_with_promise: "Thanks, we got it. We usually reply in under %{reply_within}."
|
|
17
19
|
opened_by_support: "%{desk} opened this conversation with you about “%{label}”. You can reply right here."
|
|
18
20
|
unavailable_notice: "This account can no longer send or receive messages. The conversation stays here to read."
|
|
19
21
|
human_door: "I’d rather talk to a person"
|
|
20
22
|
human_requested_notice: "Done. Somebody from the team will take it from here."
|
|
21
23
|
handled_by_assistant: "Handled by %{name}"
|
|
22
24
|
handled_by_humans: "Somebody from the team will take it from here"
|
|
25
|
+
# The answer promise, in the unit the desk wrote it in (SupportDesk.humanize_promise).
|
|
26
|
+
durations:
|
|
27
|
+
seconds:
|
|
28
|
+
one: "1 second"
|
|
29
|
+
other: "%{count} seconds"
|
|
30
|
+
minutes:
|
|
31
|
+
one: "1 minute"
|
|
32
|
+
other: "%{count} minutes"
|
|
33
|
+
hours:
|
|
34
|
+
one: "1 hour"
|
|
35
|
+
other: "%{count} hours"
|
|
36
|
+
days:
|
|
37
|
+
one: "1 day"
|
|
38
|
+
other: "%{count} days"
|
|
39
|
+
weeks:
|
|
40
|
+
one: "1 week"
|
|
41
|
+
other: "%{count} weeks"
|
|
42
|
+
months:
|
|
43
|
+
one: "1 month"
|
|
44
|
+
other: "%{count} months"
|
|
45
|
+
years:
|
|
46
|
+
one: "1 year"
|
|
47
|
+
other: "%{count} years"
|
|
23
48
|
transcript:
|
|
24
49
|
deleted: "[message deleted]"
|
|
25
50
|
notifications:
|
|
@@ -55,6 +80,9 @@ en:
|
|
|
55
80
|
closed_section:
|
|
56
81
|
one: "1 closed conversation"
|
|
57
82
|
other: "%{count} closed conversations"
|
|
83
|
+
closed_capped:
|
|
84
|
+
one: "Showing the most recent one."
|
|
85
|
+
other: "Showing the %{count} most recent."
|
|
58
86
|
state:
|
|
59
87
|
awaiting_reply: "We're on it"
|
|
60
88
|
answered: "We replied"
|
|
@@ -19,6 +19,8 @@ es:
|
|
|
19
19
|
closed_notice: "Esta conversación está cerrada. Abre una nueva si necesitas algo más."
|
|
20
20
|
reopened_notice: "Hemos vuelto a abrir esta conversación."
|
|
21
21
|
promise: "Normalmente respondemos en menos de %{time}"
|
|
22
|
+
acknowledgement: "Gracias, lo hemos recibido."
|
|
23
|
+
acknowledgement_with_promise: "Gracias, lo hemos recibido. Normalmente respondemos en menos de %{reply_within}."
|
|
22
24
|
# La línea con la que se abre un hilo que ha empezado el equipo: quien
|
|
23
25
|
# recibe un mensaje de un sitio al que nunca ha escrito necesita saber
|
|
24
26
|
# qué es esto.
|
|
@@ -28,6 +30,29 @@ es:
|
|
|
28
30
|
human_requested_notice: "Hecho. Una persona del equipo seguirá con tu consulta."
|
|
29
31
|
handled_by_assistant: "Atendida por %{name}"
|
|
30
32
|
handled_by_humans: "Una persona del equipo seguirá con tu consulta"
|
|
33
|
+
# La promesa de respuesta, en la unidad en que la escribió la mesa (SupportDesk.humanize_promise).
|
|
34
|
+
durations:
|
|
35
|
+
seconds:
|
|
36
|
+
one: "1 segundo"
|
|
37
|
+
other: "%{count} segundos"
|
|
38
|
+
minutes:
|
|
39
|
+
one: "1 minuto"
|
|
40
|
+
other: "%{count} minutos"
|
|
41
|
+
hours:
|
|
42
|
+
one: "1 hora"
|
|
43
|
+
other: "%{count} horas"
|
|
44
|
+
days:
|
|
45
|
+
one: "1 día"
|
|
46
|
+
other: "%{count} días"
|
|
47
|
+
weeks:
|
|
48
|
+
one: "1 semana"
|
|
49
|
+
other: "%{count} semanas"
|
|
50
|
+
months:
|
|
51
|
+
one: "1 mes"
|
|
52
|
+
other: "%{count} meses"
|
|
53
|
+
years:
|
|
54
|
+
one: "1 año"
|
|
55
|
+
other: "%{count} años"
|
|
31
56
|
transcript:
|
|
32
57
|
# Un mensaje borrado sigue siendo un turno. Quien lea la conversación
|
|
33
58
|
# después, una persona o el modelo al que el host se la pasa, tiene
|
|
@@ -70,6 +95,9 @@ es:
|
|
|
70
95
|
closed_section:
|
|
71
96
|
one: "1 conversación cerrada"
|
|
72
97
|
other: "%{count} conversaciones cerradas"
|
|
98
|
+
closed_capped:
|
|
99
|
+
one: "Se muestra la más reciente."
|
|
100
|
+
other: "Se muestran las %{count} más recientes."
|
|
73
101
|
state:
|
|
74
102
|
awaiting_reply: "Estamos con ello"
|
|
75
103
|
answered: "Te hemos contestado"
|
|
@@ -61,6 +61,18 @@ SupportDesk.configure do |config|
|
|
|
61
61
|
# screen, so a logged-out visitor meets YOUR login flow.
|
|
62
62
|
#
|
|
63
63
|
# config.authenticate_method = :authenticate_user!
|
|
64
|
+
#
|
|
65
|
+
# Where a case is read:
|
|
66
|
+
# :chats in the chats inbox's own thread (/messages/:id), with the
|
|
67
|
+
# desk as one grouped row there
|
|
68
|
+
# :support on this engine's own page (/support/tickets/T-AB12CD): a
|
|
69
|
+
# support area of its own, for a host that keeps support out
|
|
70
|
+
# of the inbox (`inbox_entry = :never`, and a chats
|
|
71
|
+
# `inbox_scope` that leaves SupportDesk::Ticket conversations
|
|
72
|
+
# out). The thread is still chats': its bubbles, composer and
|
|
73
|
+
# realtime, on a page whose way back leads here.
|
|
74
|
+
#
|
|
75
|
+
# config.thread_page = :chats
|
|
64
76
|
|
|
65
77
|
# ==========================================================================
|
|
66
78
|
# THE DESK
|
|
@@ -135,6 +147,13 @@ SupportDesk.configure do |config|
|
|
|
135
147
|
# config.opening_line = "Has abierto una conversación sobre «%{label}». Te contestamos aquí."
|
|
136
148
|
# config.opening_line_from_support = "%{desk} ha abierto esta conversación contigo sobre «%{label}»."
|
|
137
149
|
#
|
|
150
|
+
# The line posted right UNDER a requester's first message ("Thanks, we got
|
|
151
|
+
# it. We usually reply in under 72 hours."): the answer to "did that go
|
|
152
|
+
# through?", before anybody has read it. Same values and interpolations as
|
|
153
|
+
# the opening line; nil (the default) posts nothing. The gem ships one:
|
|
154
|
+
#
|
|
155
|
+
# config.acknowledgement = :"support_desk.thread.acknowledgement"
|
|
156
|
+
#
|
|
138
157
|
# What a requester writing into a closed ticket does:
|
|
139
158
|
# :reopen_on_reply the case comes back (no wall, no dead end)
|
|
140
159
|
# :locked the composer is replaced by a notice
|
|
@@ -143,7 +162,8 @@ SupportDesk.configure do |config|
|
|
|
143
162
|
#
|
|
144
163
|
# The answer promise: the SLA breach threshold AND the line requesters are
|
|
145
164
|
# shown when they write ("normalmente en menos de 24 h"). One setting, one
|
|
146
|
-
# truth
|
|
165
|
+
# truth, said in the unit you write it in (72.hours reads "72 hours").
|
|
166
|
+
# `at_risk_after` is the earlier, softer warning for the queue.
|
|
147
167
|
#
|
|
148
168
|
# config.reply_within = 24.hours
|
|
149
169
|
# config.at_risk_after = 4.hours
|
data/lib/support_desk/brief.rb
CHANGED
|
@@ -65,6 +65,7 @@ module SupportDesk
|
|
|
65
65
|
# this one has a default and `opening_line` does not: existing hosts'
|
|
66
66
|
# threads keep opening exactly as they do today.
|
|
67
67
|
opening_line_from_support: :"support_desk.thread.opened_by_support",
|
|
68
|
+
acknowledgement: nil,
|
|
68
69
|
find_requester: nil,
|
|
69
70
|
reply_policy: :anyone,
|
|
70
71
|
announce_assignments: :first_only,
|
|
@@ -166,6 +167,28 @@ module SupportDesk
|
|
|
166
167
|
@settings[:opening_line_from_support] = ensure_line(value, "opening_line_from_support")
|
|
167
168
|
end
|
|
168
169
|
|
|
170
|
+
# The system line posted right UNDER a requester's first message, in the
|
|
171
|
+
# same transaction: "Thanks, we got it. We usually reply in under 72
|
|
172
|
+
# hours." The answer to "did that go through?", said where they are
|
|
173
|
+
# looking, before anybody on the desk has read it. Only for a case the
|
|
174
|
+
# requester opened; a case the desk opens starts with
|
|
175
|
+
# `opening_line_from_support` instead.
|
|
176
|
+
#
|
|
177
|
+
# Same values as `opening_line`, and the same interpolations. nil (the
|
|
178
|
+
# default) posts nothing; the gem ships a line to opt into:
|
|
179
|
+
#
|
|
180
|
+
# config.acknowledgement = :"support_desk.thread.acknowledgement"
|
|
181
|
+
def acknowledgement(&block)
|
|
182
|
+
return @settings[:acknowledgement] = block if block
|
|
183
|
+
|
|
184
|
+
read(:acknowledgement)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Set it, validating on assignment (see the reader above).
|
|
188
|
+
def acknowledgement=(value)
|
|
189
|
+
@settings[:acknowledgement] = ensure_line(value, "acknowledgement")
|
|
190
|
+
end
|
|
191
|
+
|
|
169
192
|
# How a console finds the person an agent types: email, phone, handle,
|
|
170
193
|
# whatever this host lets staff search by. Given the typed string,
|
|
171
194
|
# returns a requester record or nil.
|
|
@@ -192,7 +215,7 @@ module SupportDesk
|
|
|
192
215
|
# it needs a ticket, and running a host's callback as a diagnostic is
|
|
193
216
|
# not a diagnostic.
|
|
194
217
|
def opening_line_problems # :nodoc:
|
|
195
|
-
%i[opening_line opening_line_from_support].filter_map do |setting|
|
|
218
|
+
%i[opening_line opening_line_from_support acknowledgement].filter_map do |setting|
|
|
196
219
|
value = public_send(setting)
|
|
197
220
|
next if value.nil? || value.respond_to?(:call)
|
|
198
221
|
|
|
@@ -214,22 +237,46 @@ module SupportDesk
|
|
|
214
237
|
# What to post for THIS ticket, resolved and interpolated in the
|
|
215
238
|
# current locale. nil or blank means post nothing.
|
|
216
239
|
def opening_line_for(ticket)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
when Symbol then I18n.t(setting, **line_interpolations(ticket), raise: true)
|
|
222
|
-
when String then interpolate_line(setting, line_interpolations(ticket), "opening_line")
|
|
223
|
-
else setting.call(ticket)
|
|
240
|
+
if ticket.opened_by_support?
|
|
241
|
+
line_for(ticket, opening_line_from_support, "opening_line_from_support")
|
|
242
|
+
else
|
|
243
|
+
line_for(ticket, opening_line, "opening_line")
|
|
224
244
|
end
|
|
225
|
-
|
|
245
|
+
end
|
|
226
246
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
247
|
+
# The acknowledgement for THIS ticket, the same way: nil for a case the
|
|
248
|
+
# desk opened, and nil or blank means post nothing.
|
|
249
|
+
def acknowledgement_for(ticket)
|
|
250
|
+
return nil if ticket.opened_by_support?
|
|
231
251
|
|
|
232
|
-
|
|
252
|
+
line_for(ticket, acknowledgement, "acknowledgement")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# "We usually reply in under 72 hours", in the current locale, or nil
|
|
256
|
+
# when this desk promises nothing. The line the requester reads above
|
|
257
|
+
# the composer and on the door, from the same `reply_within` the SLA
|
|
258
|
+
# breaches on.
|
|
259
|
+
def reply_promise
|
|
260
|
+
within = reply_within
|
|
261
|
+
return nil if within.nil?
|
|
262
|
+
|
|
263
|
+
I18n.t("support_desk.thread.promise", time: SupportDesk.humanize_promise(within))
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# What every line and notice of this desk can say about +ticket+:
|
|
267
|
+
# %{label}, %{desk} and %{reply_within} (the promise, in its own unit).
|
|
268
|
+
def line_interpolations(ticket)
|
|
269
|
+
{ label: ticket.label, desk: ticket.desk.name,
|
|
270
|
+
reply_within: (SupportDesk.humanize_promise(reply_within) if reply_within) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# The `_with_promise` variant of an I18n key, when this desk promises a
|
|
274
|
+
# time and the variant exists; the key as written otherwise.
|
|
275
|
+
def promised_key(symbol)
|
|
276
|
+
return symbol if reply_within.nil?
|
|
277
|
+
|
|
278
|
+
promised = :"#{symbol}_with_promise"
|
|
279
|
+
I18n.exists?(promised) ? promised : symbol
|
|
233
280
|
end
|
|
234
281
|
|
|
235
282
|
# --- Who answers ----------------------------------------------------------
|
|
@@ -554,9 +601,24 @@ module SupportDesk
|
|
|
554
601
|
"#{LINE_INTERPOLATIONS.keys.map { |key| "%{#{key}}" }.join(", ")}."
|
|
555
602
|
end
|
|
556
603
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
604
|
+
# One configured line, resolved and interpolated for +ticket+. An I18n
|
|
605
|
+
# key reads its `<key>_with_promise` variant when this desk promises a
|
|
606
|
+
# time and the variant exists, so a desk with `reply_within = nil` never
|
|
607
|
+
# says "we usually reply in under ." (the gem's assistant lines work the
|
|
608
|
+
# same way).
|
|
609
|
+
def line_for(ticket, setting, name)
|
|
610
|
+
return nil if setting.nil?
|
|
611
|
+
|
|
612
|
+
line = case setting
|
|
613
|
+
when Symbol then I18n.t(promised_key(setting), **line_interpolations(ticket), raise: true)
|
|
614
|
+
when String then interpolate_line(setting, line_interpolations(ticket), name)
|
|
615
|
+
else setting.call(ticket)
|
|
616
|
+
end
|
|
617
|
+
return nil if line.nil?
|
|
618
|
+
|
|
619
|
+
raise ConfigurationError, "a #{name} block must return a String or nil, got #{line.inspect}" unless line.is_a?(String)
|
|
620
|
+
|
|
621
|
+
line
|
|
560
622
|
end
|
|
561
623
|
end
|
|
562
624
|
|
|
@@ -949,7 +1011,7 @@ module SupportDesk
|
|
|
949
1011
|
def line_interpolations(ticket)
|
|
950
1012
|
reply_within = ticket.desk_config.reply_within
|
|
951
1013
|
{ name: name, desk: ticket.desk.name,
|
|
952
|
-
reply_within: (SupportDesk.
|
|
1014
|
+
reply_within: (SupportDesk.humanize_promise(reply_within) if reply_within) }
|
|
953
1015
|
end
|
|
954
1016
|
end
|
|
955
1017
|
|
|
@@ -957,7 +1019,7 @@ module SupportDesk
|
|
|
957
1019
|
# top-level accessors forward to the `:default` desk, which is also what
|
|
958
1020
|
# every other desk falls back to.
|
|
959
1021
|
DESK_SETTINGS = %i[
|
|
960
|
-
name avatar email opening_line opening_line_from_support find_requester reply_policy
|
|
1022
|
+
name avatar email opening_line opening_line_from_support acknowledgement find_requester reply_policy
|
|
961
1023
|
announce_assignments closed_tickets reply_within at_risk_after open_rate_limit max_open_tickets
|
|
962
1024
|
inbox_entry routing mirror_replies_by_email auto_close_after assistant
|
|
963
1025
|
].freeze
|
|
@@ -999,6 +1061,12 @@ module SupportDesk
|
|
|
999
1061
|
# own. Read through #console_authorized?.
|
|
1000
1062
|
attr_reader :authorize_console
|
|
1001
1063
|
|
|
1064
|
+
# Where a requester reads a case: :chats (the default) is its
|
|
1065
|
+
# conversation in the chats inbox; :support is this engine's own ticket
|
|
1066
|
+
# page, for a host whose support lives apart from its inbox.
|
|
1067
|
+
THREAD_PAGES = %i[chats support].freeze
|
|
1068
|
+
attr_reader :thread_page
|
|
1069
|
+
|
|
1002
1070
|
# A fresh configuration: one `:default` desk, every setting at the
|
|
1003
1071
|
# documented default.
|
|
1004
1072
|
def initialize
|
|
@@ -1010,6 +1078,7 @@ module SupportDesk
|
|
|
1010
1078
|
@authenticate_method = :authenticate_user!
|
|
1011
1079
|
@visible_desks_for = nil
|
|
1012
1080
|
@authorize_console = nil
|
|
1081
|
+
@thread_page = :chats
|
|
1013
1082
|
|
|
1014
1083
|
@desks = { default: DeskConfiguration.new(:default) }
|
|
1015
1084
|
@assistants = {}
|
|
@@ -1029,6 +1098,18 @@ module SupportDesk
|
|
|
1029
1098
|
@console_parent_controller = ensure_class_name(value, "console_parent_controller")
|
|
1030
1099
|
end
|
|
1031
1100
|
|
|
1101
|
+
def thread_page=(value)
|
|
1102
|
+
page = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
1103
|
+
unless THREAD_PAGES.include?(page)
|
|
1104
|
+
raise ConfigurationError, "thread_page must be one of #{THREAD_PAGES.map(&:inspect).join(", ")}, got #{value.inspect}"
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1107
|
+
@thread_page = page
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
# Whether a case is read on this engine's own page (`thread_page = :support`).
|
|
1111
|
+
def support_thread_page? = thread_page == :support
|
|
1112
|
+
|
|
1032
1113
|
# --- The console ------------------------------------------------------------
|
|
1033
1114
|
|
|
1034
1115
|
# Narrow what the console can reach:
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# chats finds every conversation a request names (the thread, a dock
|
|
5
|
+
# window, the composer, read, typing, refresh, reactions, edits) by asking,
|
|
6
|
+
# in order, the relations `Chats::ApplicationController#conversation_lookup_scopes`
|
|
7
|
+
# returns (chats >= 0.5.1). By default that is the viewer's inbox,
|
|
8
|
+
# `Messager#chats`, with the host's `config.inbox_scope` applied: the right
|
|
9
|
+
# rule for a list and the wrong one for a case. A host that keeps support
|
|
10
|
+
# apart from its inbox (`thread_page = :support`, and an inbox_scope that
|
|
11
|
+
# leaves SupportDesk::Ticket conversations out) would 404 its requesters
|
|
12
|
+
# inside their own cases.
|
|
13
|
+
#
|
|
14
|
+
# So this adds one relation after chats' own: the viewer's own cases
|
|
15
|
+
# (SupportDesk::Ticket.conversations_of), through the same participation
|
|
16
|
+
# and block rules as the inbox, without the host's listing scope. The union
|
|
17
|
+
# of the scopes is the authorization: nobody gains a conversation they are
|
|
18
|
+
# not seated in, a non-support conversation the host hides stays hidden, and
|
|
19
|
+
# a layer of the host's that refuses a conversation (its own
|
|
20
|
+
# `find_conversation`, or a scope it drops) still refuses it.
|
|
21
|
+
#
|
|
22
|
+
# == The contract with the host
|
|
23
|
+
#
|
|
24
|
+
# support_desk only ever adds a scope beside the host's. A host that must
|
|
25
|
+
# refuse a viewer entirely (a suspended account) returns no scopes, and that
|
|
26
|
+
# is final: with nothing to stand beside, support_desk adds nothing either.
|
|
27
|
+
# The ticket page asks the same question (SupportDesk::TicketsController
|
|
28
|
+
# resolves its conversation through chats' own `find_conversation`).
|
|
29
|
+
#
|
|
30
|
+
# Prepended to Chats::ApplicationController by the engine, in `to_prepare`
|
|
31
|
+
# so it survives a code reload.
|
|
32
|
+
module ConversationAccess
|
|
33
|
+
protected
|
|
34
|
+
|
|
35
|
+
def conversation_lookup_scopes
|
|
36
|
+
scopes = super
|
|
37
|
+
return scopes if scopes.empty?
|
|
38
|
+
|
|
39
|
+
scopes + [ SupportDesk::Ticket.conversations_of(chats_current_messager) ]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/support_desk/engine.rb
CHANGED
|
@@ -150,6 +150,13 @@ module SupportDesk
|
|
|
150
150
|
# needed to load the class.
|
|
151
151
|
_assistant = SupportDesk::Assistant
|
|
152
152
|
|
|
153
|
+
# A requester's own case opens through chats whatever the host's
|
|
154
|
+
# `inbox_scope` lists (SupportDesk::ConversationAccess). Here and not in
|
|
155
|
+
# an initializer because both classes reload: a reloaded chats
|
|
156
|
+
# controller is a new class, and it needs the seam again.
|
|
157
|
+
chats_controller = Chats::ApplicationController
|
|
158
|
+
chats_controller.prepend(SupportDesk::ConversationAccess) unless chats_controller < SupportDesk::ConversationAccess
|
|
159
|
+
|
|
153
160
|
SupportDesk.config.validate_classes! if SupportDesk.configured?
|
|
154
161
|
end
|
|
155
162
|
end
|
|
@@ -192,6 +192,35 @@ module SupportDesk
|
|
|
192
192
|
find_by(conversation_id: conversation.id)
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
+
# The conversations behind +requester+'s own cases, while they still
|
|
196
|
+
# hold its seat: chats' participation and block rules (`inbox_for`),
|
|
197
|
+
# without the host's `inbox_scope`. Participation is what makes a
|
|
198
|
+
# conversation theirs to read; the inbox is only where it is listed.
|
|
199
|
+
# What chats adds to its lookup (SupportDesk::ConversationAccess) and
|
|
200
|
+
# what the ticket page reads from.
|
|
201
|
+
#
|
|
202
|
+
# A case and its conversation have to point at each other: a direct
|
|
203
|
+
# conversation whose subject IS one of this requester's cases, that very
|
|
204
|
+
# case's conversation. A ticket mislinked to a group, or to a
|
|
205
|
+
# conversation about something else, opens nothing.
|
|
206
|
+
def conversations_of(requester)
|
|
207
|
+
return Chats::Conversation.none unless requester.respond_to?(:support_tickets)
|
|
208
|
+
|
|
209
|
+
tickets = arel_table
|
|
210
|
+
conversations = Chats::Conversation.arel_table
|
|
211
|
+
own_cases = requester.support_tickets.where(tickets[:id].eq(conversations[:subject_id])).select(:conversation_id)
|
|
212
|
+
Chats::Conversation.inbox_for(requester).direct
|
|
213
|
+
.where(subject_type: polymorphic_name)
|
|
214
|
+
.where(id: own_cases)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# One of them, by id, or nil.
|
|
218
|
+
def requester_conversation(requester, id)
|
|
219
|
+
return nil if id.blank?
|
|
220
|
+
|
|
221
|
+
conversations_of(requester).find_by(id: id)
|
|
222
|
+
end
|
|
223
|
+
|
|
195
224
|
# Open a ticket and post its first message. Usually called as
|
|
196
225
|
# `requester.ask_support!(…)` — or, when the desk writes first, as
|
|
197
226
|
# `agent.open_support_conversation_with!(…)`. This is the seam under
|
|
@@ -621,6 +650,7 @@ module SupportDesk
|
|
|
621
650
|
ticket.send(:record_registration!, posted) if posted
|
|
622
651
|
ticket.send(:stamp_assistant_action!) if by_support && SupportDesk.ai_actor?(by)
|
|
623
652
|
pin_opening_lines!(ticket, notices, posted)
|
|
653
|
+
post_acknowledgement!(ticket, posted) if posted && !by_support
|
|
624
654
|
ticket
|
|
625
655
|
end
|
|
626
656
|
|
|
@@ -631,6 +661,30 @@ module SupportDesk
|
|
|
631
661
|
ticket.conversation.post_system_message!(line)
|
|
632
662
|
end
|
|
633
663
|
|
|
664
|
+
# The desk's acknowledgement, right under the requester's first message
|
|
665
|
+
# and in the same transaction, so "did that go through?" is answered by
|
|
666
|
+
# the very commit that made it so. Pinned one tick AFTER the message
|
|
667
|
+
# whenever the clock did not move between the two inserts (the same tie
|
|
668
|
+
# `pin_opening_lines!` breaks the other way), and it is then the
|
|
669
|
+
# conversation's last message: what a list row previews.
|
|
670
|
+
#
|
|
671
|
+
# It is the desk's receipt for words the requester has just written, so
|
|
672
|
+
# it is never news to them: their read horizon moves through it. Without
|
|
673
|
+
# that, a tick dated after the clock (a frozen or coarse one) would stay
|
|
674
|
+
# unread on a page that already shows it, chats' own thread included,
|
|
675
|
+
# which reads at "now".
|
|
676
|
+
def post_acknowledgement!(ticket, posted)
|
|
677
|
+
line = ticket.desk_config.acknowledgement_for(ticket)
|
|
678
|
+
return if line.blank?
|
|
679
|
+
|
|
680
|
+
notice = ticket.conversation.post_system_message!(line)
|
|
681
|
+
if notice.created_at <= posted.created_at
|
|
682
|
+
notice.update_columns(created_at: posted.created_at + ordering_tick)
|
|
683
|
+
ticket.conversation.recompute_last_message!
|
|
684
|
+
end
|
|
685
|
+
ticket.conversation.participant_for(ticket.requester)&.read!(at: notice.created_at)
|
|
686
|
+
end
|
|
687
|
+
|
|
634
688
|
# chats orders a transcript by (created_at, id), and inserting one row
|
|
635
689
|
# after another does NOT guarantee two different timestamps: frozen
|
|
636
690
|
# time in a test, a coarse column, a clock that doesn't move between
|
|
@@ -724,7 +778,7 @@ module SupportDesk
|
|
|
724
778
|
def chat_locked_notice
|
|
725
779
|
return I18n.t("support_desk.thread.unavailable_notice") if requester_unavailable?
|
|
726
780
|
|
|
727
|
-
I18n.t("support_desk.thread.closed_notice")
|
|
781
|
+
I18n.t("support_desk.thread.closed_notice", **desk_config.line_interpolations(self))
|
|
728
782
|
end
|
|
729
783
|
|
|
730
784
|
# Whether the person this case belongs to can be written to at all right
|
data/lib/support_desk/version.rb
CHANGED
data/lib/support_desk/wizard.rb
CHANGED
|
@@ -179,11 +179,7 @@ module SupportDesk
|
|
|
179
179
|
# The promise shown next to the composer ("we usually reply in under a
|
|
180
180
|
# day"), built from `config.reply_within` — one setting, one truth: the
|
|
181
181
|
# same number the SLA breaches on.
|
|
182
|
-
def promise
|
|
183
|
-
return nil if promise_within.nil?
|
|
184
|
-
|
|
185
|
-
I18n.t("support_desk.thread.promise", time: self.class.humanize_duration(promise_within))
|
|
186
|
-
end
|
|
182
|
+
def promise = target_desk.config.reply_promise
|
|
187
183
|
|
|
188
184
|
# The promise as a Duration, for hosts that want to phrase it themselves.
|
|
189
185
|
def promise_within = target_desk.config.reply_within
|
data/lib/support_desk.rb
CHANGED
|
@@ -402,8 +402,10 @@ module SupportDesk
|
|
|
402
402
|
#
|
|
403
403
|
# Both surfaces are ordinary PUSHED screens (`context: "default"`), never
|
|
404
404
|
# modals: the wizard is three real URLs and a modal would break the back
|
|
405
|
-
# gesture between them. The thread itself is
|
|
406
|
-
# a chats conversation, and it stays under
|
|
405
|
+
# gesture between them. The thread itself is absent under the default
|
|
406
|
+
# `thread_page = :chats` — it is a chats conversation, and it stays under
|
|
407
|
+
# the host's chats rule. Under `:support` it is this engine's ticket page,
|
|
408
|
+
# and a third rule pushes it like the list.
|
|
407
409
|
#
|
|
408
410
|
# +mount+ defaults to wherever the engine is mounted and +title+ to the
|
|
409
411
|
# desk's name.
|
|
@@ -429,8 +431,16 @@ module SupportDesk
|
|
|
429
431
|
properties: { context: "default", title: title, pull_to_refresh_enabled: false },
|
|
430
432
|
comment: "The wizard: every step is a real URL, so it pushes and the back gesture works. " \
|
|
431
433
|
"Pull to refresh is off — it would throw away what the requester has typed."
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
+
},
|
|
435
|
+
(if config.support_thread_page?
|
|
436
|
+
{
|
|
437
|
+
patterns: [ "^#{prefix}/tickets/[^/?]+(?:\\?.*)?$" ],
|
|
438
|
+
properties: { context: "default", title: title, pull_to_refresh_enabled: false },
|
|
439
|
+
comment: "A case, read on the support page (thread_page = :support). Pull to refresh is off " \
|
|
440
|
+
"for the same reason as the wizard: the composer at its foot holds what is typed."
|
|
441
|
+
}
|
|
442
|
+
end)
|
|
443
|
+
].compact
|
|
434
444
|
end
|
|
435
445
|
|
|
436
446
|
# A duration in the reader's own language ("1 día", "4 hours") — the
|
|
@@ -449,6 +459,24 @@ module SupportDesk
|
|
|
449
459
|
words.start_with?("Translation missing") ? duration.inspect : words
|
|
450
460
|
end
|
|
451
461
|
|
|
462
|
+
# The answer promise, in the unit the desk made it in: `reply_within =
|
|
463
|
+
# 72.hours` reads "72 hours", `3.days` reads "3 days", `24.hours` reads
|
|
464
|
+
# "24 hours". A wait is approximate and `humanize_duration` rounds it
|
|
465
|
+
# ("about 8 hours", "3 days"); a promise is a number somebody chose, and
|
|
466
|
+
# rounding it says something they did not.
|
|
467
|
+
#
|
|
468
|
+
# The words are `support_desk.durations.<unit>`, pluralized by count. A
|
|
469
|
+
# duration of more than one unit (1.day + 2.hours), a fractional one, or
|
|
470
|
+
# a unit with no words in this locale is said the way a wait is.
|
|
471
|
+
def humanize_promise(duration)
|
|
472
|
+
parts = duration.respond_to?(:parts) ? duration.parts : {}
|
|
473
|
+
unit, count = parts.first if parts.size == 1
|
|
474
|
+
key = :"support_desk.durations.#{unit}"
|
|
475
|
+
return humanize_duration(duration) unless count.is_a?(Integer) && I18n.exists?(key)
|
|
476
|
+
|
|
477
|
+
I18n.t(key, count: count)
|
|
478
|
+
end
|
|
479
|
+
|
|
452
480
|
# A stable, URL-safe key for an actor (agent, requester, desk), used in
|
|
453
481
|
# cache keys and event payloads. GlobalID params already encode class +
|
|
454
482
|
# id, so two classes can never collide.
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: support_desk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: exe
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-18 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: activerecord
|
|
@@ -53,22 +54,22 @@ dependencies:
|
|
|
53
54
|
name: chats
|
|
54
55
|
requirement: !ruby/object:Gem::Requirement
|
|
55
56
|
requirements:
|
|
56
|
-
- - "~>"
|
|
57
|
-
- !ruby/object:Gem::Version
|
|
58
|
-
version: '0.3'
|
|
59
57
|
- - ">="
|
|
60
58
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: 0.
|
|
59
|
+
version: 0.5.1
|
|
60
|
+
- - "<"
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '1.0'
|
|
62
63
|
type: :runtime
|
|
63
64
|
prerelease: false
|
|
64
65
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
66
|
requirements:
|
|
66
|
-
- - "~>"
|
|
67
|
-
- !ruby/object:Gem::Version
|
|
68
|
-
version: '0.3'
|
|
69
67
|
- - ">="
|
|
70
68
|
- !ruby/object:Gem::Version
|
|
71
|
-
version: 0.
|
|
69
|
+
version: 0.5.1
|
|
70
|
+
- - "<"
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '1.0'
|
|
72
73
|
- !ruby/object:Gem::Dependency
|
|
73
74
|
name: globalid
|
|
74
75
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -143,6 +144,7 @@ files:
|
|
|
143
144
|
- app/controllers/support_desk/console/tickets_controller.rb
|
|
144
145
|
- app/controllers/support_desk/tickets_controller.rb
|
|
145
146
|
- app/helpers/support_desk/engine_helper.rb
|
|
147
|
+
- app/helpers/support_desk/thread_helper.rb
|
|
146
148
|
- app/views/chats/slots/_inbox_top.html.erb
|
|
147
149
|
- app/views/chats/slots/_locked_composer.html.erb
|
|
148
150
|
- app/views/support_desk/console/tickets/_actions.html.erb
|
|
@@ -171,6 +173,7 @@ files:
|
|
|
171
173
|
- app/views/support_desk/tickets/index.html.erb
|
|
172
174
|
- app/views/support_desk/tickets/new.html.erb
|
|
173
175
|
- app/views/support_desk/tickets/rate_limited.html.erb
|
|
176
|
+
- app/views/support_desk/tickets/show.html.erb
|
|
174
177
|
- config/console_routes.rb
|
|
175
178
|
- config/locales/support_desk.console.en.yml
|
|
176
179
|
- config/locales/support_desk.console.es.yml
|
|
@@ -204,6 +207,7 @@ files:
|
|
|
204
207
|
- lib/support_desk/console_engine.rb
|
|
205
208
|
- lib/support_desk/console_routes.rb
|
|
206
209
|
- lib/support_desk/context_card.rb
|
|
210
|
+
- lib/support_desk/conversation_access.rb
|
|
207
211
|
- lib/support_desk/current.rb
|
|
208
212
|
- lib/support_desk/doctor.rb
|
|
209
213
|
- lib/support_desk/engine.rb
|
|
@@ -244,6 +248,7 @@ metadata:
|
|
|
244
248
|
bug_tracker_uri: https://github.com/rameerez/support_desk/issues
|
|
245
249
|
documentation_uri: https://github.com/rameerez/support_desk#readme
|
|
246
250
|
rubygems_mfa_required: 'true'
|
|
251
|
+
post_install_message:
|
|
247
252
|
rdoc_options: []
|
|
248
253
|
require_paths:
|
|
249
254
|
- lib
|
|
@@ -258,7 +263,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
258
263
|
- !ruby/object:Gem::Version
|
|
259
264
|
version: '0'
|
|
260
265
|
requirements: []
|
|
261
|
-
rubygems_version: 3.
|
|
266
|
+
rubygems_version: 3.4.10
|
|
267
|
+
signing_key:
|
|
262
268
|
specification_version: 4
|
|
263
269
|
summary: 'Customer support for your Rails app: tickets that are conversations, worked
|
|
264
270
|
by your team and by AI agents with real guardrails'
|