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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66ba1a32f86acca1b4068bbca4091fcb16c4e6840a9d57ae5dc44bea25572ea2
4
- data.tar.gz: 2433e492636e42eeffde1652a2c7d21915267c6f0a9180bea8bb010d902cdfad
3
+ metadata.gz: afefc82399ac7abf6a331cc44fceb5fd160694fea8c83025af5e5888399a18d2
4
+ data.tar.gz: c209e7f207e89de6e4e0b48ecdd8b2dfd4e7011b352cf2eaf52c82dd9b13260f
5
5
  SHA512:
6
- metadata.gz: ac1246120f4c2aa2763f81866006f054e0dfd14901d42120ddc25d754b08dad350b1627552faaa9e4fd62d37a50d785eeae9abe6dab1c5dc9adbb9f9bb44acd1
7
- data.tar.gz: 66fca7c4ddad8c9b41358448ec24beda2a3dcd9bade8bd373fe7433a565700d500c212f863c1755048faa7dd803c79c1fa92d3fb0b029a072322d1eff6672994
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), redirecting to its thread |
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, so the Gemfile points at a sibling checkout (`../chats`) whenever this gem needs a `chats` that is not on rubygems yet. It currently requires `chats` ~> 0.3 (the `verified:` messager option).
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, never a
85
- # second thread UI of our own.
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. The case is READ in its conversation, so this is a redirect and
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
- redirect_to conversation_path_for(find_ticket)
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 h" for a desk, or nil when the
88
- # desk promises nothing. The same `config.reply_within` the SLA breaches
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
- within = SupportDesk.config.desk(desk_key).reply_within
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. Falls back
182
- # to the engine's own ticket URL (which redirects to the same place) for
183
- # the one case that has no conversation yet, so a link is never dead.
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. `at_risk_after` is the earlier, softer warning for the queue.
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
@@ -109,7 +109,7 @@ module SupportDesk
109
109
  {
110
110
  key: desk&.key,
111
111
  name: desk&.name,
112
- reply_within: (SupportDesk.humanize_duration(reply_within) if reply_within)
112
+ reply_within: (SupportDesk.humanize_promise(reply_within) if reply_within)
113
113
  }
114
114
  end
115
115
 
@@ -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
- setting = ticket.opened_by_support? ? opening_line_from_support : opening_line
218
- return nil if setting.nil?
219
-
220
- line = case setting
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
- return nil if line.nil?
245
+ end
226
246
 
227
- unless line.is_a?(String)
228
- raise ConfigurationError,
229
- "an opening_line block must return a String or nil, got #{line.inspect}"
230
- end
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
- line
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
- def line_interpolations(ticket)
558
- { label: ticket.label, desk: ticket.desk.name,
559
- reply_within: (SupportDesk.humanize_duration(reply_within) if reply_within) }
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.humanize_duration(reply_within) if reply_within) }
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SupportDesk
4
- VERSION = "0.3.2"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -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 deliberately absent — it is
406
- # a chats conversation, and it stays under the host's chats rule.
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.3.2
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-09-24 00:00:00.000000000 Z
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.3.2
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.3.2
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.6.2
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'