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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +105 -10
  4. data/Rakefile +16 -0
  5. data/app/controllers/support_desk/tickets_controller.rb +5 -3
  6. data/app/helpers/support_desk/engine_helper.rb +7 -1
  7. data/app/views/support_desk/console/tickets/_context_card.html.erb +5 -0
  8. data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
  9. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +8 -0
  10. data/app/views/support_desk/console/tickets/index.html.erb +14 -2
  11. data/app/views/support_desk/console/tickets/new.html.erb +25 -0
  12. data/config/console_routes.rb +1 -1
  13. data/config/locales/support_desk.console.en.yml +26 -0
  14. data/config/locales/support_desk.console.es.yml +26 -0
  15. data/config/locales/support_desk.en.yml +3 -0
  16. data/config/locales/support_desk.es.yml +6 -0
  17. data/lib/generators/support_desk/console_generator.rb +1 -1
  18. data/lib/generators/support_desk/install_generator.rb +14 -0
  19. data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
  20. data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
  21. data/lib/generators/support_desk/templates/initializer.rb +30 -0
  22. data/lib/generators/support_desk/upgrade_generator.rb +56 -0
  23. data/lib/support_desk/configuration.rb +148 -2
  24. data/lib/support_desk/console.rb +347 -25
  25. data/lib/support_desk/console_engine.rb +2 -0
  26. data/lib/support_desk/console_routes.rb +20 -8
  27. data/lib/support_desk/context_card.rb +23 -0
  28. data/lib/support_desk/doctor.rb +51 -1
  29. data/lib/support_desk/errors.rb +5 -0
  30. data/lib/support_desk/macros.rb +28 -12
  31. data/lib/support_desk/models/assignment.rb +3 -1
  32. data/lib/support_desk/models/concerns/agent.rb +39 -4
  33. data/lib/support_desk/models/concerns/requester.rb +27 -12
  34. data/lib/support_desk/models/ticket.rb +464 -100
  35. data/lib/support_desk/summary.rb +1 -1
  36. data/lib/support_desk/test_helpers.rb +8 -3
  37. data/lib/support_desk/version.rb +1 -1
  38. data/lib/support_desk/wizard.rb +4 -12
  39. data/lib/support_desk.rb +45 -0
  40. data/lib/tasks/support_desk.rake +27 -0
  41. metadata +7 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 587ddd67c318b1c6b4cbe7b496a65ec2bfb70b39818556a5d90ad81415db17c8
4
- data.tar.gz: 3ab9972eca4fc07680e1b33d139400d7f1c6ef9116e3778967f070d05f5111e1
3
+ metadata.gz: 74ee479ece9f12d0c47800369c872e8d15f7535a40665592618dc95fc88b7f10
4
+ data.tar.gz: 66cba52c0c8ffdba13d921d831a804b170887cbc25e3048d9b748404a80573db
5
5
  SHA512:
6
- metadata.gz: 27e60ca1419b9fd778e6d57ac35dea358b7f422829b92c36f00d123153b967a0171c8f7edfeeef8715e597b2e169fa48629325d4314aa2fdc2593f8c27662924
7
- data.tar.gz: f04e5ba138fd6e847c0aad98ef5f42eeaf1018900eccd4f3b72af15a9ba82a00e9506bf2412e900145918a2eb97299b5b0fbb2d1b0bcabba0c23b890ae027fc3
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 migration + an annotated initializer):
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 exactly four methods to whoever asks for help:
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 `support_queue`, and **no verbs**: the ticket is the subject of every sentence.
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 inside a `resources`
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. Same
60
- # answer as a forged token: 404.
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::Wizard.humanize_duration(within))
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
- <%= link_to t("support_desk.console.actions.next"), console_tickets_path(:next),
21
- class: "inline-flex items-center rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-700" %>
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>
@@ -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?"