support_desk 0.1.3 → 0.2.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 +58 -0
- data/README.md +105 -10
- data/Rakefile +16 -0
- data/app/controllers/support_desk/tickets_controller.rb +5 -3
- data/app/helpers/support_desk/engine_helper.rb +7 -1
- data/app/views/support_desk/console/tickets/_context_card.html.erb +5 -0
- data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +8 -0
- data/app/views/support_desk/console/tickets/index.html.erb +14 -2
- data/app/views/support_desk/console/tickets/new.html.erb +25 -0
- data/config/console_routes.rb +1 -1
- data/config/locales/support_desk.console.en.yml +26 -0
- data/config/locales/support_desk.console.es.yml +26 -0
- data/config/locales/support_desk.en.yml +3 -0
- data/config/locales/support_desk.es.yml +6 -0
- data/lib/generators/support_desk/console_generator.rb +1 -1
- data/lib/generators/support_desk/install_generator.rb +14 -0
- data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
- data/lib/generators/support_desk/templates/initializer.rb +30 -0
- data/lib/generators/support_desk/upgrade_generator.rb +56 -0
- data/lib/support_desk/configuration.rb +148 -2
- data/lib/support_desk/console.rb +347 -25
- data/lib/support_desk/console_engine.rb +2 -0
- data/lib/support_desk/console_routes.rb +20 -8
- data/lib/support_desk/context_card.rb +23 -0
- data/lib/support_desk/doctor.rb +51 -1
- data/lib/support_desk/errors.rb +5 -0
- data/lib/support_desk/macros.rb +28 -12
- data/lib/support_desk/models/assignment.rb +3 -1
- data/lib/support_desk/models/concerns/agent.rb +39 -4
- data/lib/support_desk/models/concerns/requester.rb +27 -12
- data/lib/support_desk/models/ticket.rb +464 -100
- data/lib/support_desk/summary.rb +1 -1
- data/lib/support_desk/test_helpers.rb +8 -3
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +4 -12
- data/lib/support_desk.rb +45 -0
- data/lib/tasks/support_desk.rake +27 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74ee479ece9f12d0c47800369c872e8d15f7535a40665592618dc95fc88b7f10
|
|
4
|
+
data.tar.gz: 66cba52c0c8ffdba13d921d831a804b170887cbc25e3048d9b748404a80573db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 305686f2b7f606e94fdb78230991992407408d4603677935d44b7e366792692d99f79f74ddb8e631bed6633a678bb35241d9955cd73c0c52fd7fdaf6985da687
|
|
7
|
+
data.tar.gz: 6d4e3a9e84e9e14979bea658e686b29f6df00064bf1f18f80f14a1c75911f9c2a9f86ab2b6be90aa005040b6503acf485e3bc6beae5add62447918621f8fe285
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,64 @@ 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.2.0] - 2026-09-17
|
|
8
|
+
|
|
9
|
+
**The desk can write first.** Until now a case could only start with somebody
|
|
10
|
+
asking; now it can start with you, through the same seam and the same
|
|
11
|
+
algorithm.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `agent.open_support_conversation_with!(requester, message, about:, topic:, files:, via:, desk:)` — write first, as the desk: the message is the desk's, signed by the agent, and it lands in the requester's inbox as the desk. The only agent-side verb in the gem, because it is the only action with no ticket yet.
|
|
16
|
+
- `SupportDesk::Ticket.open!(…, by: an_agent)` — the seam under it. `by:` defaults to the requester; an agent there is the desk writing first. A Symbol (`by: :system`) is refused with `NotAnAgent`: automation is deferred (docs 12, Q17).
|
|
17
|
+
- `opened_by` on every case — a polymorphic record, exactly like `closed_by` — with `opened_by_requester?` / `opened_by_support?` and the `opened_by_requester` / `opened_by_support` scopes, which partition the table.
|
|
18
|
+
- `config.opening_line` and `config.opening_line_from_support`: the system line a thread opens with, posted inside the opening transaction. A String (with `%{label}`, `%{desk}`, `%{reply_within}`), an I18n key, a block, or nil. The first defaults to nil; the second to the gem's own copy, because a message from a desk somebody never wrote to has to explain itself.
|
|
19
|
+
- `config.find_requester`: how a console turns something an agent typed into the person they meant. Without it the console accepts only a GlobalID from one of your own pages.
|
|
20
|
+
- `has_support_tickets if:` — who may ask, and who may be written to — with `support_requester?` and `support_desk` on the requester model.
|
|
21
|
+
- Console: `new` (the form) and `open_conversation` (the send) as collection actions, with the "Write to someone" door in the mounted console and the generated one. `SupportDesk::Console::COLLECTION_VERBS` is the table the router reads.
|
|
22
|
+
- Generators: `rails g support_desk:upgrade` copies the additive `opened_by` migration (the same file a fresh install runs), and `rake support_desk:backfill_opened_by` runs its catch-up after draining old processes and before admitting 0.2 traffic.
|
|
23
|
+
- `SupportDesk::NotARequester`, and `SupportDesk.humanize_duration` (moved off `Wizard`, which keeps a delegation).
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
Behaviour changes, not refactors. Read them before upgrading:
|
|
28
|
+
|
|
29
|
+
- **System messages are never folded into the clocks.** They never reached the clocks through chats anyway; now a hand-written `register!` ignores them too, and so does a message from somebody who is neither the requester nor the desk. Neither moves `awaiting`, an SLA clock or the last-registered marker.
|
|
30
|
+
- **`first_agent_reply_at` is only set when a requester message came first.** An agent writing into a case with nothing in it is not answering anything, and an out-of-order replay whose requester clock is later is not evidence either.
|
|
31
|
+
- **`:agent_replied` is not emitted for the desk's own opening message** on a case the desk opened. An agent's first message into an old empty case the REQUESTER opened is still a reply, and still emits.
|
|
32
|
+
- **The asking limits count only requester-opened cases.** `open_rate_limit` and `max_open_tickets` limit asking, not being asked, so cases you opened no longer spend somebody's allowance.
|
|
33
|
+
- **Requester eligibility is checked at every message write.** `chat_locked?` is now "the requester can't be written to, or the case is closed on a desk that locks closed cases", re-read from the database rather than taken from a cached association. The thread, the queue row, the notes and the close are all unchanged; only new messages stop, and `actions_for` stops offering `:reply`.
|
|
34
|
+
- **`reply!` registers its message inside its own lock**, in a savepoint that owns the whole operation — so a caller who rescues its failure and commits their own transaction commits none of ours. `open!` does the same for opening and for the reply a reused case turns into.
|
|
35
|
+
- **The opening line is the gem's, and it is posted inside the opening transaction**, pinned one database tick above the message it introduces. Hosts that posted their own line after commit can delete that code.
|
|
36
|
+
- **`Console::ERROR_KEYS` is gone**, replaced by the error's own name (`NotTheAssignee` → `not_the_assignee`) with a generic fallback. Every key the table listed is a name it derives to, so a host that overrode one keeps its wording.
|
|
37
|
+
- `Console::TRANSITIONS` and `ConsoleRoutes::MEMBER_VERBS` are aliases of `Console::MEMBER_VERBS` for one release.
|
|
38
|
+
- `Chats::Error` is rescued into a console flash; `Chats::ConfigurationError` is deliberately not.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- Stale revoked/deleted agents are refused using an uncached eligibility read, shared by opening and ordinary ticket transitions.
|
|
43
|
+
- Malformed attachment inputs return the compose form with 422; signed blob failures do not discard the draft. Invalid explicit desks cannot silently change the sender, and host finder errors are not hidden as bad GlobalIDs.
|
|
44
|
+
- Legacy NULL provenance remains requester-originated for quotas and reply metrics. The documented upgrade drains old web/workers before the catch-up and new traffic.
|
|
45
|
+
- Rake tasks are discovered once across the two engines; migration collision guidance preserves existing provenance.
|
|
46
|
+
- Valid draft fields survive malformed sibling fields, and unavailable recipients are explained before offering send.
|
|
47
|
+
- `Ticket.open!` no longer reloads the case it just opened. The clocks are folded in on the same instance, inside the transaction, so the row that commits is already true.
|
|
48
|
+
|
|
49
|
+
### Upgrading from 0.1
|
|
50
|
+
|
|
51
|
+
**This upgrade requires a drained cutover, not a rolling deployment.** Old
|
|
52
|
+
processes cannot release assignments with the new `opened` reason.
|
|
53
|
+
|
|
54
|
+
1. Copy the upgrade migration with `rails generate support_desk:upgrade` and migrate before 0.2 serves traffic. The additive, nullable columns are compatible with 0.1.
|
|
55
|
+
2. Pause incoming support writes and background producers. Stop and drain **all old web requests and workers**, including jobs already running; verify none remain. Keep support traffic paused. Do not start serving 0.2 alongside 0.1.
|
|
56
|
+
3. With the 0.2 artifact available but traffic still paused, run `rake support_desk:backfill_opened_by`. Verify `SupportDesk::Ticket.where(opened_by_id: nil).count` is zero and inspect `SupportDesk.doctor`. The task is idempotent; repeating it is safe in this release because automation openers are not supported.
|
|
57
|
+
4. Start only 0.2 web/workers, then resume support traffic. Writing-first may now be used. Add `new` to console routes and regenerate/customize views as needed.
|
|
58
|
+
|
|
59
|
+
NULL-provenance cases are treated as requester-opened even before the backfill,
|
|
60
|
+
so quotas, labels and reply metrics stay correct. This read compatibility does
|
|
61
|
+
**not** make old assignment writers compatible with 0.2; the drain is still required.
|
|
62
|
+
|
|
63
|
+
Rolling back is a host code rollback, not a Gemfile pin: 0.1 does not know the new settings, routes or predicates. Keep the columns and the provenance already written — but note that 0.1's `Assignment#release!` revalidates `reason` and rejects `opened`, so closing, releasing or handing off a case the desk opened will fail under 0.1 until a compatibility patch accepts that reason. Prefer disabling the entry point (drop `new`/`open_conversation` from your routes) over downgrading with active desk-opened cases.
|
|
64
|
+
|
|
7
65
|
## [0.1.3] - 2026-09-17
|
|
8
66
|
|
|
9
67
|
### Fixed
|
data/README.md
CHANGED
|
@@ -36,10 +36,12 @@ class Order < ApplicationRecord
|
|
|
36
36
|
supportable topic: :order # can be asked about
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
ticket = alice.ask_support!("My order never arrived", about: order)
|
|
39
|
+
ticket = alice.ask_support!("My order never arrived", about: order) # she asks
|
|
40
40
|
ticket.assign!(to: lucia, by: lucia)
|
|
41
|
-
ticket.reply!("We're on it", by: lucia)
|
|
41
|
+
ticket.reply!("We're on it", by: lucia) # you answer
|
|
42
42
|
ticket.close!(by: lucia)
|
|
43
|
+
|
|
44
|
+
lucia.open_support_conversation_with!(alice, "We saw your refund bounced", about: order) # you write first
|
|
43
45
|
```
|
|
44
46
|
|
|
45
47
|
That's a ticket, a conversation, an assignment history, an append-only audit trail and four events your app can subscribe to.
|
|
@@ -52,7 +54,7 @@ Add the gem:
|
|
|
52
54
|
gem "support_desk"
|
|
53
55
|
```
|
|
54
56
|
|
|
55
|
-
Install it (creates the
|
|
57
|
+
Install it (creates the migrations + an annotated initializer):
|
|
56
58
|
|
|
57
59
|
```bash
|
|
58
60
|
bundle install
|
|
@@ -60,6 +62,13 @@ rails generate support_desk:install
|
|
|
60
62
|
rails db:migrate
|
|
61
63
|
```
|
|
62
64
|
|
|
65
|
+
Already on 0.1? `rails generate support_desk:upgrade` copies only the
|
|
66
|
+
migrations a version bump needs (0.2.0: who opened each case) and nothing
|
|
67
|
+
you own. Migrate first, pause support traffic, drain all old web requests and
|
|
68
|
+
workers, and backfill before starting 0.2 traffic. This is not a rolling
|
|
69
|
+
upgrade: old assignment writers cannot handle new support-opened cases.
|
|
70
|
+
See the CHANGELOG for the complete cutover and rollback procedure.
|
|
71
|
+
|
|
63
72
|
Three model lines and one route line:
|
|
64
73
|
|
|
65
74
|
```ruby
|
|
@@ -103,17 +112,21 @@ Desk records are memoised for the life of the process, so anything that has to c
|
|
|
103
112
|
|
|
104
113
|
## The model macros
|
|
105
114
|
|
|
106
|
-
### `has_support_tickets(desk: :default, as: nil)`
|
|
115
|
+
### `has_support_tickets(desk: :default, as: nil, if: nil)`
|
|
107
116
|
|
|
108
|
-
Adds
|
|
117
|
+
Adds six methods to whoever asks for help:
|
|
109
118
|
|
|
110
119
|
| method | what it does |
|
|
111
120
|
|---|---|
|
|
112
121
|
| `support_tickets` | `has_many`, newest first. Chain the scopes: `alice.support_tickets.open.about(order)` |
|
|
113
122
|
| `ask_support!(message, about:, topic:, files:, via:)` | opens the ticket, posts the first message, emits `ticket_opened`, and hands back the `Ticket` — or the open one they already have about the same thing |
|
|
123
|
+
| `support_requester?` | may this person ask for help, and be written to, right now? |
|
|
124
|
+
| `support_desk` | the desk record their tickets go to |
|
|
114
125
|
| `awaiting_support_reply?` | is the desk holding any of their questions? |
|
|
115
126
|
| `unread_support_count` | for a nav badge, counted against the chats read horizon |
|
|
116
127
|
|
|
128
|
+
`if:` is a method name or a callable, and it is a **write** rule rather than a screen rule. `has_support_tickets if: :kept?` means a closed account can neither ask nor be written to, on every path — while its history stays readable, its cases stay in the queue, and agents can still take notes on them and close them.
|
|
129
|
+
|
|
117
130
|
### `supportable(topic:, candidates: nil, one_open_ticket: true)`
|
|
118
131
|
|
|
119
132
|
Makes a domain record something people can ask about. Every method has a working default; override the ones that matter:
|
|
@@ -129,7 +142,7 @@ Makes a domain record something people can ask about. Every method has a working
|
|
|
129
142
|
|
|
130
143
|
### `acts_as_support_agent(if: nil, kind: :human)`
|
|
131
144
|
|
|
132
|
-
Makes someone able to answer. Agents are never chats participants — the desk sends, the agent *authors* — so this needs no messaging setup at all. It adds `support_agent?`, `support_agent_name`, `support_agent_avatar`, `on_duty?`, `support_capacity` and `
|
|
145
|
+
Makes someone able to answer. Agents are never chats participants — the desk sends, the agent *authors* — so this needs no messaging setup at all. It adds `support_agent?`, `support_agent_name`, `support_agent_avatar`, `on_duty?`, `support_capacity`, `support_queue` and exactly one verb — `open_support_conversation_with!`, the only agent action with no ticket yet (see [Writing first](#writing-first)). Everywhere else the ticket is the subject of the sentence.
|
|
133
146
|
|
|
134
147
|
## Tickets
|
|
135
148
|
|
|
@@ -280,13 +293,15 @@ One word in your routes file draws every verb an agent needs:
|
|
|
280
293
|
```ruby
|
|
281
294
|
# config/routes.rb
|
|
282
295
|
namespace :madmin do
|
|
283
|
-
resources :support_tickets, only: %i[index show], concerns: :support_console
|
|
296
|
+
resources :support_tickets, only: %i[index show new], concerns: :support_console
|
|
284
297
|
end
|
|
285
298
|
```
|
|
286
299
|
|
|
287
300
|
That adds member `reply take assign hand_off release close reopen note
|
|
288
|
-
change_topic` and collection `next`. It has to sit
|
|
289
|
-
block, since that is what those routes hang off.
|
|
301
|
+
change_topic` and collection `next` and `open_conversation`. It has to sit
|
|
302
|
+
inside a `resources` block, since that is what those routes hang off.
|
|
303
|
+
`new` stays yours: add it to `only:` when you render the form behind
|
|
304
|
+
`open_conversation` ("Write to someone").
|
|
290
305
|
|
|
291
306
|
The concern is seeded into every route set by a small prepend on Rails'
|
|
292
307
|
routing mapper, because routing concerns live in a Hash built per `draw`
|
|
@@ -298,7 +313,7 @@ Rails.application.routes.draw do
|
|
|
298
313
|
SupportDesk::ConsoleRoutes.register(self)
|
|
299
314
|
|
|
300
315
|
namespace :madmin do
|
|
301
|
-
resources :support_tickets, only: %i[index show], concerns: :support_console
|
|
316
|
+
resources :support_tickets, only: %i[index show new], concerns: :support_console
|
|
302
317
|
end
|
|
303
318
|
end
|
|
304
319
|
```
|
|
@@ -396,6 +411,86 @@ route. It takes its layout and authentication from
|
|
|
396
411
|
Generate when you have an admin to put this inside and want the files;
|
|
397
412
|
mount when you don't. They render the same templates either way.
|
|
398
413
|
|
|
414
|
+
## Writing first
|
|
415
|
+
|
|
416
|
+
Most cases start with somebody asking. Some start with you:
|
|
417
|
+
|
|
418
|
+
```ruby
|
|
419
|
+
lucia.open_support_conversation_with!(alice, "We saw your refund bounced", about: withdrawal)
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
That is not a personal message from Lucía. It speaks as the desk, signs the
|
|
423
|
+
message with her name, lands in Alice's inbox as "Support", and seats Lucía
|
|
424
|
+
on the case from its first committed state — silently, because "Lucía is
|
|
425
|
+
taking care of your request" in a thread Alice never opened answers a
|
|
426
|
+
question nobody asked. It is the same seam `ask_support!` uses, so there is
|
|
427
|
+
one algorithm for cardinality, topics, subjects, conversations, events and
|
|
428
|
+
clocks:
|
|
429
|
+
|
|
430
|
+
```ruby
|
|
431
|
+
SupportDesk::Ticket.open!(requester: alice, message: "…", by: lucia) # what the sugar calls
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
If Alice already has this conversation open, the message joins it as an
|
|
435
|
+
ordinary **reply** — under your desk's reply policy, which may well leave
|
|
436
|
+
the case with whoever holds it. Nothing about that is guessed from a count:
|
|
437
|
+
the opener knows whether it inserted, and the console says one true thing
|
|
438
|
+
either way.
|
|
439
|
+
|
|
440
|
+
Every case now records who opened it:
|
|
441
|
+
|
|
442
|
+
```ruby
|
|
443
|
+
ticket.opened_by # => alice · lucia — a record, like closed_by
|
|
444
|
+
ticket.opened_by_requester? # she asked
|
|
445
|
+
ticket.opened_by_support? # we wrote first
|
|
446
|
+
|
|
447
|
+
SupportDesk::Ticket.opened_by_support.awaiting_requester
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Her abuse limits stay hers: `open_rate_limit` and `max_open_tickets` count
|
|
451
|
+
only the cases she opened, so five conversations you started can never stop
|
|
452
|
+
her asking her first question. And `time_to_first_reply` is nil for a case
|
|
453
|
+
you opened — nobody was waiting for it.
|
|
454
|
+
|
|
455
|
+
### The line a thread opens with
|
|
456
|
+
|
|
457
|
+
```ruby
|
|
458
|
+
config.opening_line = "You opened a conversation about “%{label}”. We usually reply within %{reply_within}."
|
|
459
|
+
config.opening_line_from_support = "%{desk} started this conversation with you about “%{label}”."
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Both are posted inside the opening transaction, one database tick above the
|
|
463
|
+
message they introduce — so the line can never arrive after it, or not at
|
|
464
|
+
all. A String (with `%{label}`, `%{desk}`, `%{reply_within}`), an I18n key,
|
|
465
|
+
a block given the ticket, or nil. `opening_line` defaults to nil, so
|
|
466
|
+
existing threads open exactly as they do today; `opening_line_from_support`
|
|
467
|
+
has the gem's own copy as its default, because a message from a desk
|
|
468
|
+
somebody never wrote to has to explain itself.
|
|
469
|
+
|
|
470
|
+
### From the console
|
|
471
|
+
|
|
472
|
+
Add `new` to your routes and the queue grows a "Write to someone" button:
|
|
473
|
+
|
|
474
|
+
```ruby
|
|
475
|
+
resources :support_tickets, only: %i[index show new], concerns: :support_console
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Tell the console how to find people and it grows a search box too;
|
|
479
|
+
otherwise it takes a GlobalID from one of your own pages (a user's admin
|
|
480
|
+
screen, an order) and says so:
|
|
481
|
+
|
|
482
|
+
```ruby
|
|
483
|
+
config.find_requester { |query| User.find_by(email: query.to_s.strip.downcase) }
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### Who can be written to
|
|
487
|
+
|
|
488
|
+
`has_support_tickets if: :kept?` is the whole policy. When it turns false
|
|
489
|
+
the person can neither ask nor be written to, on every path — the console,
|
|
490
|
+
the model, a direct chats write — and nothing is hidden or deleted: the
|
|
491
|
+
transcript stays readable, the case stays in the queue, and agents can
|
|
492
|
+
still take notes and close it.
|
|
493
|
+
|
|
399
494
|
## The wizard
|
|
400
495
|
|
|
401
496
|
"What do you need help with?" is a plain object, not a controller, so a host
|
data/Rakefile
CHANGED
|
@@ -21,6 +21,22 @@ end
|
|
|
21
21
|
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
22
22
|
load "rails/tasks/engine.rake"
|
|
23
23
|
|
|
24
|
+
# `db:migrate:reset` — the command the README and CI give for the PostgreSQL
|
|
25
|
+
# leg — is `["db:drop", "db:create", "db:schema:dump", "db:migrate"]` in
|
|
26
|
+
# ActiveRecord's own databases.rake. That third step is hardwired: it ignores
|
|
27
|
+
# the dummy's `config.active_record.dump_schema_after_migration = false`,
|
|
28
|
+
# which is why plain `rake db:migrate` leaves no dump and this task does.
|
|
29
|
+
#
|
|
30
|
+
# The dump is stamped with the Rails version that wrote it
|
|
31
|
+
# (`ActiveRecord::Schema[8.1]`), and every later leg's `db:test:load_schema`
|
|
32
|
+
# prefers a schema.rb over the migrations — so one PostgreSQL run made the
|
|
33
|
+
# 7.2 Appraisal die with "Unknown migration version 8.1" until the file was
|
|
34
|
+
# deleted by hand. The dummy migrates from db/migrate and never from a dump,
|
|
35
|
+
# so the artifact has no reader: throw it away where it is made.
|
|
36
|
+
if Rake::Task.task_defined?("db:migrate:reset")
|
|
37
|
+
Rake::Task["db:migrate:reset"].enhance { rm_f "test/dummy/db/schema.rb" }
|
|
38
|
+
end
|
|
39
|
+
|
|
24
40
|
require "rake/testtask"
|
|
25
41
|
|
|
26
42
|
Rake::TestTask.new(:test) do |t|
|
|
@@ -55,9 +55,11 @@ module SupportDesk
|
|
|
55
55
|
# The form was submitted from a step that isn't the composer (a stale
|
|
56
56
|
# tab, a hand-rolled POST): re-render the step they are actually on.
|
|
57
57
|
render :new, status: :unprocessable_entity
|
|
58
|
-
rescue SupportDesk::NotAllowed, SupportDesk::NotSupportable
|
|
59
|
-
# The subject passed the wizard's check and failed the model's
|
|
60
|
-
#
|
|
58
|
+
rescue SupportDesk::NotAllowed, SupportDesk::NotSupportable, SupportDesk::NotARequester
|
|
59
|
+
# The subject passed the wizard's check and failed the model's, or this
|
|
60
|
+
# account stopped being able to write between the form and the submit.
|
|
61
|
+
# Same answer as a forged token: 404. "Not yours", "not there" and "not
|
|
62
|
+
# eligible" must look the same from outside.
|
|
61
63
|
raise ActiveRecord::RecordNotFound
|
|
62
64
|
end
|
|
63
65
|
|
|
@@ -75,13 +75,19 @@ module SupportDesk
|
|
|
75
75
|
within = SupportDesk.config.desk(desk_key).reply_within
|
|
76
76
|
return nil if within.nil?
|
|
77
77
|
|
|
78
|
-
t("support_desk.thread.promise", time: SupportDesk
|
|
78
|
+
t("support_desk.thread.promise", time: SupportDesk.humanize_duration(within))
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
# The status line under a case in the requester's list: who owes the next
|
|
82
82
|
# word, or that it's over.
|
|
83
83
|
def support_ticket_state(ticket)
|
|
84
84
|
return t("support_desk.tickets.state.closed") if ticket.closed?
|
|
85
|
+
# A conversation the desk started, before they have said anything back:
|
|
86
|
+
# "we replied" would be a lie about a thread they never opened, and
|
|
87
|
+
# "we're on it" is worse — there is nothing of theirs to be on.
|
|
88
|
+
if ticket.opened_by_support? && ticket.last_requester_message_at.nil?
|
|
89
|
+
return t("support_desk.tickets.state.opened_by_support")
|
|
90
|
+
end
|
|
85
91
|
|
|
86
92
|
ticket.awaiting_reply? ? t("support_desk.tickets.state.awaiting_reply") : t("support_desk.tickets.state.answered")
|
|
87
93
|
end
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
</div>
|
|
27
27
|
<% end %>
|
|
28
28
|
|
|
29
|
+
<div class="flex justify-between gap-3">
|
|
30
|
+
<dt class="text-slate-500"><%= t("support_desk.console.context.opened_by") %></dt>
|
|
31
|
+
<dd class="text-right"><%= card.opened_by_label %></dd>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
29
34
|
<div class="flex justify-between gap-3">
|
|
30
35
|
<dt class="text-slate-500"><%= t("support_desk.console.context.topic") %></dt>
|
|
31
36
|
<dd class="text-right"><%= card.topic_label %></dd>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<%# The compose form. Everything it renders is a draft the controller set
|
|
2
|
+
BEFORE it looked anything up, so a refusal comes back with the message
|
|
3
|
+
still in the box.
|
|
4
|
+
|
|
5
|
+
Three shapes for "who is this for", in order of how sure we are:
|
|
6
|
+
|
|
7
|
+
@requester a card — the page that opened this form knew
|
|
8
|
+
find_requester a text field — the host said how to look people up
|
|
9
|
+
neither a hint naming the setting, and no send button,
|
|
10
|
+
because a form that can't reach anybody is worse
|
|
11
|
+
than one that says so
|
|
12
|
+
|
|
13
|
+
Deliberately NOT promised here: who ends up holding the case. If this
|
|
14
|
+
person already has this conversation open, the message joins it as an
|
|
15
|
+
ordinary reply and the desk's reply policy decides the seat. %>
|
|
16
|
+
<%= form_with url: console_tickets_path(:open_conversation, desk: support_desk_record.key),
|
|
17
|
+
method: :post, html: { multipart: true }, class: "space-y-4" do %>
|
|
18
|
+
<section class="rounded-lg border border-slate-200 bg-white p-4">
|
|
19
|
+
<h2 class="text-sm font-semibold"><%= t("support_desk.console.new_conversation.requester") %></h2>
|
|
20
|
+
|
|
21
|
+
<% if @requester %>
|
|
22
|
+
<div class="mt-2 flex items-center justify-between gap-3">
|
|
23
|
+
<p class="text-sm font-medium"><%= Chats.display_name_for(@requester) %></p>
|
|
24
|
+
<%= hidden_field_tag :requester, @requester.to_global_id.to_s %>
|
|
25
|
+
</div>
|
|
26
|
+
<% elsif support_desk_record.config.find_requester %>
|
|
27
|
+
<%# Text, not an email field: the host's callback may look people up by
|
|
28
|
+
phone, handle or anything else they chose. %>
|
|
29
|
+
<%= text_field_tag :requester_query, @requester_query,
|
|
30
|
+
placeholder: t("support_desk.console.new_conversation.requester_query"),
|
|
31
|
+
class: "mt-2 w-full rounded-md border border-slate-300 p-2 text-sm" %>
|
|
32
|
+
<% else %>
|
|
33
|
+
<p class="mt-2 text-sm text-slate-500"><%= t("support_desk.console.new_conversation.requester_hint") %></p>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<% if @about %>
|
|
37
|
+
<p class="mt-3 text-sm text-slate-500">
|
|
38
|
+
<%= t("support_desk.console.new_conversation.about", subject: @about.support_label) %>
|
|
39
|
+
</p>
|
|
40
|
+
<%= hidden_field_tag :about, @about.to_global_id.to_s %>
|
|
41
|
+
<% end %>
|
|
42
|
+
</section>
|
|
43
|
+
|
|
44
|
+
<section class="rounded-lg border border-slate-200 bg-white p-4">
|
|
45
|
+
<%# A subject brings its own topic with it, so the picker is only for
|
|
46
|
+
cases that are about nothing in particular. The supplied topic is
|
|
47
|
+
preserved either way. %>
|
|
48
|
+
<% if @about %>
|
|
49
|
+
<%= hidden_field_tag :topic, @topic %>
|
|
50
|
+
<% else %>
|
|
51
|
+
<label class="text-sm font-semibold" for="topic"><%= t("support_desk.console.new_conversation.topic") %></label>
|
|
52
|
+
<%= select_tag :topic,
|
|
53
|
+
options_for_select(support_conversation_topics.map { |topic| [ topic.full_label, topic.path ] }, @topic),
|
|
54
|
+
class: "mt-2 w-full rounded-md border border-slate-300 p-2 text-sm" %>
|
|
55
|
+
<% end %>
|
|
56
|
+
|
|
57
|
+
<label class="mt-4 block text-sm font-semibold" for="body">
|
|
58
|
+
<%= t("support_desk.console.new_conversation.message") %>
|
|
59
|
+
</label>
|
|
60
|
+
<%= text_area_tag :body, @body, rows: 5, maxlength: Chats.config.max_message_length,
|
|
61
|
+
class: "mt-2 w-full rounded-md border border-slate-300 p-2 text-sm" %>
|
|
62
|
+
|
|
63
|
+
<p class="mt-2 text-xs text-slate-500">
|
|
64
|
+
<%= t("support_desk.console.new_conversation.signed_as", agent: current_agent.support_agent_name) %>
|
|
65
|
+
</p>
|
|
66
|
+
|
|
67
|
+
<% if Chats.config.attachments %>
|
|
68
|
+
<div class="mt-3">
|
|
69
|
+
<%= file_field_tag "files[]", multiple: true, class: "text-xs text-slate-500" %>
|
|
70
|
+
<%# Browsers can't be handed a file input back. Say so rather than
|
|
71
|
+
letting somebody assume their attachment survived the 422. %>
|
|
72
|
+
<p class="mt-1 text-xs text-slate-500"><%= t("support_desk.console.errors.attachments_again") %></p>
|
|
73
|
+
</div>
|
|
74
|
+
<% end %>
|
|
75
|
+
</section>
|
|
76
|
+
|
|
77
|
+
<div class="flex items-center justify-between gap-3">
|
|
78
|
+
<%= link_to t("support_desk.console.new_conversation.cancel"), console_tickets_path,
|
|
79
|
+
class: "text-sm text-slate-500 underline underline-offset-2" %>
|
|
80
|
+
|
|
81
|
+
<% if support_conversation_sendable? && (@requester || support_desk_record.config.find_requester) %>
|
|
82
|
+
<%= submit_tag t("support_desk.console.new_conversation.send", desk: support_desk_record.name),
|
|
83
|
+
class: "rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-700" %>
|
|
84
|
+
<% elsif flash[:alert].blank? %>
|
|
85
|
+
<p class="text-sm text-slate-500">
|
|
86
|
+
<%= support_conversation_available? ? t("support_desk.console.new_conversation.requester_hint") :
|
|
87
|
+
t("support_desk.console.errors.off_duty") %>
|
|
88
|
+
</p>
|
|
89
|
+
<% end %>
|
|
90
|
+
</div>
|
|
91
|
+
<% end %>
|
|
@@ -20,6 +20,14 @@
|
|
|
20
20
|
</span>
|
|
21
21
|
|
|
22
22
|
<span class="font-mono text-xs text-slate-400"><%= ticket.reference %></span>
|
|
23
|
+
|
|
24
|
+
<%# A case we started reads differently from one somebody asked for:
|
|
25
|
+
nobody is waiting on us in it until they answer. %>
|
|
26
|
+
<% if ticket.opened_by_support? %>
|
|
27
|
+
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
|
|
28
|
+
<%= t("support_desk.console.row.opened_by_support") %>
|
|
29
|
+
</span>
|
|
30
|
+
<% end %>
|
|
23
31
|
</div>
|
|
24
32
|
|
|
25
33
|
<p class="mt-0.5 text-sm text-slate-500">
|
|
@@ -17,8 +17,20 @@
|
|
|
17
17
|
<p class="text-sm text-slate-500"><%= support_desk_record.name %></p>
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
<div class="flex items-center gap-2">
|
|
21
|
+
<%# The door to writing first. Through console_tickets_path so it
|
|
22
|
+
resolves under an isolated admin engine as well as a host
|
|
23
|
+
namespace, and carrying the desk so a multi-desk console composes
|
|
24
|
+
for the desk being worked. %>
|
|
25
|
+
<% if support_conversation_offered? %>
|
|
26
|
+
<%= link_to t("support_desk.console.actions.new_conversation"),
|
|
27
|
+
console_tickets_path(:new, desk: support_desk_record.key),
|
|
28
|
+
class: "inline-flex items-center rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-slate-900 hover:bg-slate-50" %>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<%= link_to t("support_desk.console.actions.next"), console_tickets_path(:next),
|
|
32
|
+
class: "inline-flex items-center rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-700" %>
|
|
33
|
+
</div>
|
|
22
34
|
</header>
|
|
23
35
|
|
|
24
36
|
<%= render "tabs", queue: @queue, scope: @scope %>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%# Writing first: the desk starting a conversation with somebody who hasn't
|
|
2
|
+
written to us.
|
|
3
|
+
|
|
4
|
+
It is a whole page rather than a modal on purpose — the 422 that a bad
|
|
5
|
+
email or a refused message comes back as has to render the form again
|
|
6
|
+
with everything still typed into it, and a stream refresh would throw
|
|
7
|
+
that away. %>
|
|
8
|
+
<div class="mx-auto max-w-2xl px-4 py-6 text-slate-900">
|
|
9
|
+
<header class="mb-5">
|
|
10
|
+
<h1 class="text-xl font-semibold"><%= t("support_desk.console.new_conversation.title") %></h1>
|
|
11
|
+
<p class="text-sm text-slate-500"><%= support_desk_record.name %></p>
|
|
12
|
+
</header>
|
|
13
|
+
|
|
14
|
+
<%# The refusal is rendered HERE rather than left to your layout: this
|
|
15
|
+
screen answers 422 with itself, and an agent has to read why on the
|
|
16
|
+
page that came back. If your layout already prints flashes, delete
|
|
17
|
+
this block when you eject the views. %>
|
|
18
|
+
<% if flash[:alert] %>
|
|
19
|
+
<p class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
|
|
20
|
+
<%= flash[:alert] %>
|
|
21
|
+
</p>
|
|
22
|
+
<% end %>
|
|
23
|
+
|
|
24
|
+
<%= render "new_conversation_form" %>
|
|
25
|
+
</div>
|
data/config/console_routes.rb
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
# SAME `:support_console` concern a host writes in their own routes file —
|
|
11
11
|
# the turnkey console gets no private API.
|
|
12
12
|
SupportDesk::ConsoleEngine.routes.draw do
|
|
13
|
-
resources :tickets, path: "", only: %i[index show], concerns: :support_console
|
|
13
|
+
resources :tickets, path: "", only: %i[index show new], concerns: :support_console
|
|
14
14
|
|
|
15
15
|
root to: "tickets#index"
|
|
16
16
|
end
|
|
@@ -8,10 +8,14 @@ en:
|
|
|
8
8
|
tabs_label: "Queue tabs"
|
|
9
9
|
empty: "Nothing here right now."
|
|
10
10
|
row:
|
|
11
|
+
opened_by_support: "Written by support"
|
|
11
12
|
waiting: "%{duration}"
|
|
12
13
|
unassigned: "Unassigned"
|
|
13
14
|
context:
|
|
14
15
|
title: "Context"
|
|
16
|
+
opened_by: "Opened by"
|
|
17
|
+
not_recorded: "not recorded"
|
|
18
|
+
unavailable: "account unavailable"
|
|
15
19
|
requester: "Requester"
|
|
16
20
|
open_tickets: "Open cases"
|
|
17
21
|
member_since: "Member since"
|
|
@@ -21,6 +25,17 @@ en:
|
|
|
21
25
|
transcript:
|
|
22
26
|
title: "Conversation"
|
|
23
27
|
empty: "Nothing has been said yet."
|
|
28
|
+
new_conversation:
|
|
29
|
+
title: "Write as support"
|
|
30
|
+
requester: "To"
|
|
31
|
+
requester_query: "Their email"
|
|
32
|
+
requester_hint: "Set `config.find_requester` to look people up from here"
|
|
33
|
+
about: "About %{subject}"
|
|
34
|
+
topic: "Topic"
|
|
35
|
+
message: "Message"
|
|
36
|
+
send: "Send as %{desk}"
|
|
37
|
+
cancel: "Cancel"
|
|
38
|
+
signed_as: "Signed “%{agent}”."
|
|
24
39
|
composer:
|
|
25
40
|
reply: "Reply"
|
|
26
41
|
note: "Internal note"
|
|
@@ -53,6 +68,7 @@ en:
|
|
|
53
68
|
note: "Internal note"
|
|
54
69
|
actions:
|
|
55
70
|
back: "Back to the queue"
|
|
71
|
+
new_conversation: "Write to someone"
|
|
56
72
|
next: "Next case"
|
|
57
73
|
reply: "Send"
|
|
58
74
|
note: "Save note"
|
|
@@ -64,6 +80,7 @@ en:
|
|
|
64
80
|
reopen: "Reopen"
|
|
65
81
|
change_topic: "Refile"
|
|
66
82
|
flashes:
|
|
83
|
+
message_sent: "Message sent to %{requester}."
|
|
67
84
|
replied: "Answer sent."
|
|
68
85
|
taken: "This case is yours."
|
|
69
86
|
assigned: "Assigned to %{agent}."
|
|
@@ -90,4 +107,13 @@ en:
|
|
|
90
107
|
take_it_first: "Take this case first — this desk only lets the assignee reply."
|
|
91
108
|
held_by_somebody_else: "%{holder} is handling this case."
|
|
92
109
|
unavailable_action: "You can't do that on this case right now."
|
|
110
|
+
unknown_requester: "We can't find that person."
|
|
111
|
+
not_a_requester: "That account can't receive support messages."
|
|
112
|
+
invalid_requester: "That link doesn't point at anybody we can write to."
|
|
113
|
+
invalid_subject: "That link doesn't point at anything we can open a conversation about."
|
|
114
|
+
invalid_input: "Something in the form didn't come through. Try again."
|
|
115
|
+
no_requester_lookup: "Open this form from the person's own page: there's no way to look them up from here."
|
|
116
|
+
writing_to_yourself: "You can't open a support conversation with yourself. Ask for help as a customer instead."
|
|
117
|
+
off_duty: "You're off duty, so you can't write to anybody right now."
|
|
118
|
+
attachments_again: "If you attach files and something goes wrong, you'll have to pick them again."
|
|
93
119
|
generic: "That didn't work."
|
|
@@ -8,10 +8,14 @@ es:
|
|
|
8
8
|
tabs_label: "Pestañas de la cola"
|
|
9
9
|
empty: "Aquí no hay nada ahora mismo."
|
|
10
10
|
row:
|
|
11
|
+
opened_by_support: "Escrita por soporte"
|
|
11
12
|
waiting: "%{duration}"
|
|
12
13
|
unassigned: "Sin asignar"
|
|
13
14
|
context:
|
|
14
15
|
title: "Contexto"
|
|
16
|
+
opened_by: "Abierta por"
|
|
17
|
+
not_recorded: "sin registrar"
|
|
18
|
+
unavailable: "cuenta no disponible"
|
|
15
19
|
requester: "Cliente"
|
|
16
20
|
open_tickets: "Casos abiertos"
|
|
17
21
|
member_since: "Cliente desde"
|
|
@@ -21,6 +25,17 @@ es:
|
|
|
21
25
|
transcript:
|
|
22
26
|
title: "Conversación"
|
|
23
27
|
empty: "Todavía no se ha dicho nada."
|
|
28
|
+
new_conversation:
|
|
29
|
+
title: "Escribir por soporte"
|
|
30
|
+
requester: "Para"
|
|
31
|
+
requester_query: "Email de la persona"
|
|
32
|
+
requester_hint: "Configura `config.find_requester` para buscar a alguien desde aquí"
|
|
33
|
+
about: "Sobre %{subject}"
|
|
34
|
+
topic: "Tema"
|
|
35
|
+
message: "Mensaje"
|
|
36
|
+
send: "Enviar como %{desk}"
|
|
37
|
+
cancel: "Cancelar"
|
|
38
|
+
signed_as: "Firmado «%{agent}»."
|
|
24
39
|
composer:
|
|
25
40
|
reply: "Responder"
|
|
26
41
|
note: "Nota interna"
|
|
@@ -53,6 +68,7 @@ es:
|
|
|
53
68
|
note: "Nota interna"
|
|
54
69
|
actions:
|
|
55
70
|
back: "Volver a la cola"
|
|
71
|
+
new_conversation: "Escribir a alguien"
|
|
56
72
|
next: "Siguiente caso"
|
|
57
73
|
reply: "Enviar"
|
|
58
74
|
note: "Guardar nota"
|
|
@@ -64,6 +80,7 @@ es:
|
|
|
64
80
|
reopen: "Reabrir"
|
|
65
81
|
change_topic: "Cambiar tema"
|
|
66
82
|
flashes:
|
|
83
|
+
message_sent: "Mensaje enviado a %{requester}."
|
|
67
84
|
replied: "Respuesta enviada."
|
|
68
85
|
taken: "El caso es tuyo."
|
|
69
86
|
assigned: "Asignado a %{agent}."
|
|
@@ -90,4 +107,13 @@ es:
|
|
|
90
107
|
take_it_first: "Toma el caso antes de responder: en esta mesa solo responde quien lo lleva."
|
|
91
108
|
held_by_somebody_else: "Lo lleva %{holder}."
|
|
92
109
|
unavailable_action: "Ahora mismo no puedes hacer eso en este caso."
|
|
110
|
+
unknown_requester: "No encontramos a esa persona."
|
|
111
|
+
not_a_requester: "Esa cuenta no puede recibir mensajes de soporte."
|
|
112
|
+
invalid_requester: "Ese enlace no apunta a nadie a quien podamos escribir."
|
|
113
|
+
invalid_subject: "Ese enlace no apunta a nada sobre lo que podamos abrir la conversación."
|
|
114
|
+
invalid_input: "Algo en el formulario no se ha enviado bien. Vuelve a intentarlo."
|
|
115
|
+
no_requester_lookup: "Abre este formulario desde la ficha de la persona: aquí no podemos buscarla."
|
|
116
|
+
writing_to_yourself: "No puedes abrir una conversación de soporte contigo. Si necesitas ayuda, pídela como cliente."
|
|
117
|
+
off_duty: "No estás de guardia, así que no puedes escribir a nadie ahora mismo."
|
|
118
|
+
attachments_again: "Si adjuntas archivos y algo falla, tendrás que volver a seleccionarlos."
|
|
93
119
|
generic: "No ha funcionado."
|
|
@@ -7,6 +7,8 @@ en:
|
|
|
7
7
|
closed_notice: "This conversation is closed. Start a new one if you need anything else."
|
|
8
8
|
reopened_notice: "We reopened this conversation."
|
|
9
9
|
promise: "We usually reply in under %{time}"
|
|
10
|
+
opened_by_support: "%{desk} opened this conversation with you about “%{label}”. You can reply right here."
|
|
11
|
+
unavailable_notice: "This account can no longer send or receive messages. The conversation stays here to read."
|
|
10
12
|
notifications:
|
|
11
13
|
title: "%{desk} · new message"
|
|
12
14
|
queue:
|
|
@@ -41,6 +43,7 @@ en:
|
|
|
41
43
|
state:
|
|
42
44
|
awaiting_reply: "We're on it"
|
|
43
45
|
answered: "We replied"
|
|
46
|
+
opened_by_support: "We wrote to you"
|
|
44
47
|
closed: "Closed"
|
|
45
48
|
doors:
|
|
46
49
|
new: "Need help?"
|