support_desk 0.1.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 +7 -0
- data/.rubocop.yml +32 -0
- data/.simplecov +53 -0
- data/Appraisals +18 -0
- data/CHANGELOG.md +135 -0
- data/LICENSE.txt +21 -0
- data/README.md +469 -0
- data/Rakefile +55 -0
- data/app/assets/stylesheets/support_desk.css +242 -0
- data/app/controllers/support_desk/application_controller.rb +92 -0
- data/app/controllers/support_desk/console/application_controller.rb +18 -0
- data/app/controllers/support_desk/console/tickets_controller.rb +26 -0
- data/app/controllers/support_desk/tickets_controller.rb +119 -0
- data/app/helpers/support_desk/engine_helper.rb +196 -0
- data/app/views/chats/slots/_inbox_top.html.erb +31 -0
- data/app/views/chats/slots/_locked_composer.html.erb +18 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +29 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +60 -0
- data/app/views/support_desk/console/tickets/_composer.html.erb +71 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +53 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +48 -0
- data/app/views/support_desk/console/tickets/_nav_badge.html.erb +19 -0
- data/app/views/support_desk/console/tickets/_tabs.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +56 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +37 -0
- data/app/views/support_desk/console/tickets/_transcript.html.erb +26 -0
- data/app/views/support_desk/console/tickets/index.html.erb +37 -0
- data/app/views/support_desk/console/tickets/show.html.erb +45 -0
- data/app/views/support_desk/tickets/_context_card.html.erb +14 -0
- data/app/views/support_desk/tickets/_door.html.erb +12 -0
- data/app/views/support_desk/tickets/_pick_thing.html.erb +55 -0
- data/app/views/support_desk/tickets/_pick_topic.html.erb +30 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +31 -0
- data/app/views/support_desk/tickets/_wizard_header.html.erb +23 -0
- data/app/views/support_desk/tickets/_write.html.erb +56 -0
- data/app/views/support_desk/tickets/index.html.erb +53 -0
- data/app/views/support_desk/tickets/new.html.erb +14 -0
- data/app/views/support_desk/tickets/rate_limited.html.erb +40 -0
- data/config/console_routes.rb +16 -0
- data/config/locales/support_desk.console.en.yml +93 -0
- data/config/locales/support_desk.console.es.yml +93 -0
- data/config/locales/support_desk.en.yml +79 -0
- data/config/locales/support_desk.es.yml +84 -0
- data/config/routes.rb +24 -0
- data/context7.json +4 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/gemfiles/rails_8.0.gemfile +34 -0
- data/gemfiles/rails_8.1.gemfile +34 -0
- data/lib/generators/support_desk/console_generator.rb +94 -0
- data/lib/generators/support_desk/install_generator.rb +86 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +43 -0
- data/lib/generators/support_desk/templates/console/resource.rb.erb +44 -0
- data/lib/generators/support_desk/templates/create_support_desk_tables.rb.erb +224 -0
- data/lib/generators/support_desk/templates/initializer.rb +186 -0
- data/lib/generators/support_desk/views_generator.rb +50 -0
- data/lib/support_desk/configuration.rb +675 -0
- data/lib/support_desk/console.rb +487 -0
- data/lib/support_desk/console_engine.rb +63 -0
- data/lib/support_desk/console_routes.rb +107 -0
- data/lib/support_desk/context_card.rb +90 -0
- data/lib/support_desk/current.rb +26 -0
- data/lib/support_desk/doctor.rb +220 -0
- data/lib/support_desk/engine.rb +141 -0
- data/lib/support_desk/errors.rb +49 -0
- data/lib/support_desk/events.rb +122 -0
- data/lib/support_desk/macros.rb +73 -0
- data/lib/support_desk/models/application_record.rb +11 -0
- data/lib/support_desk/models/assignment.rb +83 -0
- data/lib/support_desk/models/concerns/agent.rb +79 -0
- data/lib/support_desk/models/concerns/requester.rb +71 -0
- data/lib/support_desk/models/concerns/supportable.rb +88 -0
- data/lib/support_desk/models/desk.rb +101 -0
- data/lib/support_desk/models/event.rb +72 -0
- data/lib/support_desk/models/ticket.rb +1124 -0
- data/lib/support_desk/queue.rb +171 -0
- data/lib/support_desk/summary.rb +70 -0
- data/lib/support_desk/test_helpers.rb +137 -0
- data/lib/support_desk/timeline.rb +104 -0
- data/lib/support_desk/topic.rb +290 -0
- data/lib/support_desk/topic_tree.rb +214 -0
- data/lib/support_desk/version.rb +5 -0
- data/lib/support_desk/wizard.rb +392 -0
- data/lib/support_desk.rb +288 -0
- metadata +229 -0
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# A case — and a case is one conversation.
|
|
5
|
+
#
|
|
6
|
+
# ticket = alice.ask_support!("El viaje no aparece verificado", about: ride)
|
|
7
|
+
# ticket.reference # => "T-AB12CD"
|
|
8
|
+
# ticket.assign!(to: lucia, by: lucia)
|
|
9
|
+
# ticket.reply!("Hola, lo estamos revisando", by: lucia)
|
|
10
|
+
# ticket.close!(by: lucia)
|
|
11
|
+
#
|
|
12
|
+
# == The transitions
|
|
13
|
+
#
|
|
14
|
+
# Every transition takes `by:` (falling back to SupportDesk::Current.actor,
|
|
15
|
+
# raising ActorMissing when there is nobody), runs under the ticket's row
|
|
16
|
+
# lock, writes the ticket, its assignment row and exactly ONE event row in
|
|
17
|
+
# one transaction, and emits its events only once that transaction has
|
|
18
|
+
# committed. Repeating a transition that has already happened returns
|
|
19
|
+
# `self` and writes nothing; a transition that can't happen from here
|
|
20
|
+
# raises SupportDesk::InvalidTransition.
|
|
21
|
+
#
|
|
22
|
+
# == Awaiting, and the clocks
|
|
23
|
+
#
|
|
24
|
+
# `awaiting` says who owes the next word. It is maintained by #register!,
|
|
25
|
+
# which the gem subscribes to chats' `:message_created` — so a message
|
|
26
|
+
# typed in the app, mirrored in by email, or posted by a bot all move the
|
|
27
|
+
# same clock, and nothing has to remember to call anything.
|
|
28
|
+
class Ticket < ApplicationRecord
|
|
29
|
+
self.table_name = "support_desk_tickets"
|
|
30
|
+
|
|
31
|
+
STATUSES = %w[open snoozed closed].freeze
|
|
32
|
+
AWAITING_STATES = %w[agent requester none].freeze
|
|
33
|
+
CHANNELS = %i[in_app email intercom api].freeze
|
|
34
|
+
|
|
35
|
+
# Crockford base32: no I, L, O or U, so a reference read aloud down a
|
|
36
|
+
# phone line or typed from a screenshot can't come back wrong.
|
|
37
|
+
REFERENCE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
38
|
+
REFERENCE_PREFIX = "T-"
|
|
39
|
+
REFERENCE_LENGTH = 6
|
|
40
|
+
REFERENCE_ATTEMPTS = 10
|
|
41
|
+
|
|
42
|
+
acts_as_chat_subject
|
|
43
|
+
|
|
44
|
+
belongs_to :desk, class_name: "SupportDesk::Desk", inverse_of: :tickets
|
|
45
|
+
belongs_to :requester, polymorphic: true
|
|
46
|
+
belongs_to :subject, polymorphic: true, optional: true
|
|
47
|
+
belongs_to :assignee, polymorphic: true, optional: true
|
|
48
|
+
belongs_to :conversation, class_name: "Chats::Conversation", optional: true
|
|
49
|
+
belongs_to :closed_by, polymorphic: true, optional: true
|
|
50
|
+
|
|
51
|
+
has_many :assignments, class_name: "SupportDesk::Assignment", inverse_of: :ticket, dependent: :destroy
|
|
52
|
+
# :delete_all, not :destroy — an Event is readonly once written, and a
|
|
53
|
+
# readonly record refuses to be destroyed. Deleting a ticket is the one
|
|
54
|
+
# thing that takes its timeline with it, and it needs no callbacks.
|
|
55
|
+
has_many :events, class_name: "SupportDesk::Event", inverse_of: :ticket, dependent: :delete_all
|
|
56
|
+
has_many :messages, through: :conversation, source: :messages
|
|
57
|
+
|
|
58
|
+
# `ticket.topic` is a Topic, never a String — the tree is the thing that
|
|
59
|
+
# carries behaviour, so the column casts both ways.
|
|
60
|
+
attribute :topic, Topic::Type.new
|
|
61
|
+
attribute :metadata, default: -> { {} }
|
|
62
|
+
|
|
63
|
+
validates :status, inclusion: { in: STATUSES }
|
|
64
|
+
validates :awaiting, inclusion: { in: AWAITING_STATES }
|
|
65
|
+
validates :opened_via, inclusion: { in: CHANNELS }
|
|
66
|
+
validates :reference, presence: true
|
|
67
|
+
|
|
68
|
+
# --- Scopes ---------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
scope :open, -> { where(status: "open") }
|
|
71
|
+
scope :closed, -> { where(status: "closed") }
|
|
72
|
+
scope :snoozed, -> { where(status: "snoozed") }
|
|
73
|
+
scope :not_closed, -> { where.not(status: "closed") }
|
|
74
|
+
|
|
75
|
+
scope :assigned, -> { where.not(assignee_id: nil) }
|
|
76
|
+
scope :unassigned, -> { where(assignee_id: nil) }
|
|
77
|
+
scope :assigned_to, lambda { |agent|
|
|
78
|
+
where(assignee_type: agent.class.polymorphic_name, assignee_id: agent.id)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
scope :awaiting_reply, -> { where(awaiting: "agent") }
|
|
82
|
+
scope :awaiting_requester, -> { where(awaiting: "requester") }
|
|
83
|
+
|
|
84
|
+
# Waiting longer than +duration+ for whoever owes the next word.
|
|
85
|
+
scope :waiting_over, lambda { |duration|
|
|
86
|
+
not_closed.where.not(waiting_since: nil).where(waiting_since: ..duration.ago)
|
|
87
|
+
}
|
|
88
|
+
# Waiting long enough to warn about, but not yet past the promise.
|
|
89
|
+
scope :at_risk, ->(desk_key = nil) { past_sla(:at_risk_after, desk_key: desk_key, but_not: :reply_within) }
|
|
90
|
+
# Past the promise (`config.reply_within`).
|
|
91
|
+
scope :overdue, ->(desk_key = nil) { past_sla(:reply_within, desk_key: desk_key) }
|
|
92
|
+
|
|
93
|
+
scope :about, ->(record) { where(subject_type: record.class.polymorphic_name, subject_id: record.id) }
|
|
94
|
+
scope :about_any, ->(klass) { where(subject_type: klass.polymorphic_name) }
|
|
95
|
+
# Includes descendants: `on_topic(:payments)` finds payments/withdrawal.
|
|
96
|
+
scope :on_topic, lambda { |path|
|
|
97
|
+
path = path.to_s
|
|
98
|
+
where(topic: path).or(where(arel_table[:topic].matches("#{path}/%", nil, true)))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
scope :for_desk, ->(key) { where(desk: SupportDesk.desk(key)) }
|
|
102
|
+
scope :opened_via, ->(channel) { where(opened_via: channel.to_s) }
|
|
103
|
+
scope :opened_between, ->(range) { where(opened_at: range) }
|
|
104
|
+
scope :closed_between, ->(range) { where(closed_at: range) }
|
|
105
|
+
|
|
106
|
+
scope :most_urgent_first, lambda {
|
|
107
|
+
order(Arel.sql("#{table_name}.priority DESC, " \
|
|
108
|
+
"COALESCE(#{table_name}.waiting_since, #{table_name}.opened_at) ASC"))
|
|
109
|
+
}
|
|
110
|
+
scope :recent_activity_first, -> { order(updated_at: :desc) }
|
|
111
|
+
scope :oldest_first, -> { order(opened_at: :asc, id: :asc) }
|
|
112
|
+
scope :newest_first, -> { order(opened_at: :desc, id: :desc) }
|
|
113
|
+
|
|
114
|
+
# --- Finding & opening ------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
class << self
|
|
117
|
+
# Tickets whose wait has passed one of the desk's thresholds.
|
|
118
|
+
#
|
|
119
|
+
# Measured per DESK, not against whichever desk happens to be the
|
|
120
|
+
# default: two desks can promise different things, and a billing desk
|
|
121
|
+
# that answers in an hour must not be judged by a 24 hour promise.
|
|
122
|
+
# Pass a key to ask about one desk, nothing to ask about all of them.
|
|
123
|
+
def past_sla(threshold, desk_key: nil, but_not: nil)
|
|
124
|
+
keys = desk_key ? [ desk_key.to_sym ] : SupportDesk.config.desks.keys
|
|
125
|
+
clauses = keys.filter_map { |key| sla_clause(key, threshold, but_not) }
|
|
126
|
+
return none if clauses.empty?
|
|
127
|
+
|
|
128
|
+
awaiting_reply.not_closed.where.not(waiting_since: nil).where(clauses.reduce(:or))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The ticket with this reference, case- and prefix-insensitive
|
|
132
|
+
# ("t-ab12cd", "AB12CD" and "T-AB12CD" all find it), or nil.
|
|
133
|
+
def find_by_reference(reference)
|
|
134
|
+
normalized = normalize_reference(reference)
|
|
135
|
+
return nil if normalized.nil?
|
|
136
|
+
|
|
137
|
+
find_by(reference: normalized)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Same, raising ActiveRecord::RecordNotFound.
|
|
141
|
+
def find_by_reference!(reference)
|
|
142
|
+
find_by_reference(reference) ||
|
|
143
|
+
raise(ActiveRecord::RecordNotFound, "no ticket with reference #{reference.inspect}")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The ticket behind a chats conversation, or nil — the hook the
|
|
147
|
+
# `:message_created` subscriber comes in through.
|
|
148
|
+
def for_conversation(conversation)
|
|
149
|
+
return nil if conversation.nil?
|
|
150
|
+
|
|
151
|
+
find_by(conversation_id: conversation.id)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Open a ticket and post its first message. Usually called as
|
|
155
|
+
# `requester.ask_support!(…)`; this is the seam channels and jobs use.
|
|
156
|
+
#
|
|
157
|
+
# Returns the existing open ticket when one already covers the same
|
|
158
|
+
# subject (or, for free-form tickets, the same topic) — posting the
|
|
159
|
+
# message into it, because somebody just typed it.
|
|
160
|
+
def open!(requester:, message: nil, about: nil, topic: nil, files: [], via: :in_app,
|
|
161
|
+
desk: nil, requester_role: nil, title: nil, metadata: {})
|
|
162
|
+
desk ||= SupportDesk.desk
|
|
163
|
+
# The subject is checked BEFORE the topic: "this isn't supportable" is
|
|
164
|
+
# the useful error, and an unsupportable record has no topic to find.
|
|
165
|
+
validate_subject!(about, requester)
|
|
166
|
+
node = resolve_topic!(topic, about, desk, requester, about)
|
|
167
|
+
|
|
168
|
+
cardinality = cardinality_key_for(requester: requester, subject: about, topic: node)
|
|
169
|
+
existing = open_ticket_for(requester: requester, desk: desk, cardinality_key: cardinality)
|
|
170
|
+
return post_opening_message(existing, message, files) if existing
|
|
171
|
+
|
|
172
|
+
enforce_rate_limit!(requester, desk)
|
|
173
|
+
enforce_open_ticket_cap!(requester, desk)
|
|
174
|
+
|
|
175
|
+
ticket, inserted = insert_ticket!(
|
|
176
|
+
requester: requester, desk: desk, node: node, about: about, via: via,
|
|
177
|
+
requester_role: requester_role, title: title, metadata: metadata, cardinality_key: cardinality
|
|
178
|
+
)
|
|
179
|
+
ticket = post_opening_message(ticket, message, files)
|
|
180
|
+
|
|
181
|
+
# ONLY the request that actually inserted announces the ticket. Under
|
|
182
|
+
# the insert race the loser is holding somebody else's ticket, and a
|
|
183
|
+
# subscriber that pages every agent, or writes a row into a
|
|
184
|
+
# hash-chained audit log, must not do it twice for one case.
|
|
185
|
+
SupportDesk.emit_after_commit(:ticket_opened, ticket) if inserted
|
|
186
|
+
ticket
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# A human-friendly, unguessable-enough reference: "T-AB12CD".
|
|
190
|
+
def generate_reference
|
|
191
|
+
REFERENCE_PREFIX + Array.new(REFERENCE_LENGTH) { REFERENCE_ALPHABET[SecureRandom.random_number(32)] }.join
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A reference as it is stored: upper case, prefixed, and with
|
|
195
|
+
# Crockford's lookalikes folded in.
|
|
196
|
+
def normalize_reference(reference) # :nodoc:
|
|
197
|
+
return nil if reference.nil?
|
|
198
|
+
|
|
199
|
+
# Crockford's whole point: O reads as 0, I and L read as 1, so a
|
|
200
|
+
# reference read down a phone line or typed off a screenshot still
|
|
201
|
+
# finds its ticket.
|
|
202
|
+
body = reference.to_s.strip.upcase.delete_prefix(REFERENCE_PREFIX).tr("OIL", "011")
|
|
203
|
+
return nil if body.empty?
|
|
204
|
+
|
|
205
|
+
REFERENCE_PREFIX + body
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The value the "one open ticket about this" index is built on: the
|
|
209
|
+
# subject when there is one, the topic when there isn't, and a unique
|
|
210
|
+
# value when the supportable said `one_open_ticket: false`.
|
|
211
|
+
def cardinality_key_for(requester:, subject:, topic:) # :nodoc:
|
|
212
|
+
if subject
|
|
213
|
+
return "free:#{SecureRandom.uuid}" unless subject.class.one_open_support_ticket?
|
|
214
|
+
|
|
215
|
+
"subject:#{SupportDesk.actor_key(subject)}"
|
|
216
|
+
else
|
|
217
|
+
"topic:#{topic.path}"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
# "on this desk, and waiting longer than its own threshold" — with an
|
|
224
|
+
# upper bound when the caller wants the band between two thresholds
|
|
225
|
+
# (at risk, but not yet breached).
|
|
226
|
+
def sla_clause(key, threshold, but_not)
|
|
227
|
+
config = SupportDesk.config.desk(key)
|
|
228
|
+
duration = config.public_send(threshold)
|
|
229
|
+
return nil if duration.nil?
|
|
230
|
+
|
|
231
|
+
clause = arel_table[:desk_id].in(Desk.where(key: key.to_s).select(:id).arel)
|
|
232
|
+
.and(arel_table[:waiting_since].lteq(duration.ago))
|
|
233
|
+
ceiling = but_not && config.public_send(but_not)
|
|
234
|
+
ceiling ? clause.and(arel_table[:waiting_since].gt(ceiling.ago)) : clause
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def resolve_topic!(topic, about, desk, requester, subject)
|
|
238
|
+
node = locate_topic!(topic, about, desk)
|
|
239
|
+
unless node.visible_for?(requester)
|
|
240
|
+
raise NotAllowed, "#{requester.class}##{requester.id} may not open a ticket under topic " \
|
|
241
|
+
"#{node.path.inspect} (its only: condition says no)"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
if node.subject_required? && subject.nil?
|
|
245
|
+
raise NotAllowed, "topic #{node.path.inspect} needs something to be about — pass about:"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
node
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def locate_topic!(topic, about, desk)
|
|
252
|
+
tree = desk.config.topics
|
|
253
|
+
|
|
254
|
+
if topic
|
|
255
|
+
tree.find(topic.to_s) ||
|
|
256
|
+
raise(UnknownTopic, "no topic #{topic.inspect} on desk #{desk.key} " \
|
|
257
|
+
"(known: #{tree.map(&:path).sort.join(", ")})")
|
|
258
|
+
elsif about
|
|
259
|
+
path = about.respond_to?(:support_topic) ? about.support_topic : nil
|
|
260
|
+
tree.find(path.to_s) ||
|
|
261
|
+
raise(UnknownTopic, "#{about.class} is supportable under topic #{path.inspect}, which isn't in " \
|
|
262
|
+
"desk #{desk.key}'s topic tree")
|
|
263
|
+
else
|
|
264
|
+
tree.free_form_leaf ||
|
|
265
|
+
raise(UnknownTopic, "desk #{desk.key} has no free-form topic to open a subject-less ticket under. " \
|
|
266
|
+
"Add `other` to its topics block, or pass topic:")
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def validate_subject!(about, requester)
|
|
271
|
+
return if about.nil?
|
|
272
|
+
|
|
273
|
+
unless about.respond_to?(:supportable?) && about.supportable?
|
|
274
|
+
raise NotSupportable, "#{about.class} isn't supportable — add `supportable topic: :something` to it"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
return if about.supportable_by?(requester)
|
|
278
|
+
|
|
279
|
+
raise NotAllowed, "#{requester.class}##{requester.id} may not open a ticket about " \
|
|
280
|
+
"#{about.class}##{about.id}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def open_ticket_for(requester:, desk:, cardinality_key:)
|
|
284
|
+
not_closed.find_by(requester: requester, desk: desk, cardinality_key: cardinality_key)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def enforce_rate_limit!(requester, desk)
|
|
288
|
+
limit = desk.config.open_rate_limit
|
|
289
|
+
return if limit.nil?
|
|
290
|
+
|
|
291
|
+
window = Time.current - limit[:within].to_i
|
|
292
|
+
recent = where(requester: requester, desk: desk).where(opened_at: window..).count
|
|
293
|
+
return if recent < limit[:to]
|
|
294
|
+
|
|
295
|
+
raise RateLimited, "#{requester.class}##{requester.id} has opened #{recent} tickets in the last " \
|
|
296
|
+
"#{limit[:within].inspect} (limit #{limit[:to]})"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The wall from 10 §Abuse. Checked before the insert and NOT under a
|
|
300
|
+
# lock, so it is advisory by design: two requests racing about two
|
|
301
|
+
# different things can both pass it and leave a requester one over.
|
|
302
|
+
# The alternative is locking the host's own requester row on every
|
|
303
|
+
# open, which trades a real contention risk for an imaginary
|
|
304
|
+
# correctness one — nobody is harmed by a sixth open ticket.
|
|
305
|
+
def enforce_open_ticket_cap!(requester, desk)
|
|
306
|
+
cap = desk.config.max_open_tickets
|
|
307
|
+
return if cap.nil?
|
|
308
|
+
|
|
309
|
+
current = not_closed.where(requester: requester, desk: desk).count
|
|
310
|
+
return if current < cap
|
|
311
|
+
|
|
312
|
+
raise TooManyOpenTickets, "#{requester.class}##{requester.id} already has #{current} open tickets " \
|
|
313
|
+
"(max_open_tickets is #{cap})"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Create the ticket, its conversation and its `opened` event in one
|
|
317
|
+
# transaction. A collision on the cardinality index means somebody
|
|
318
|
+
# else opened the same ticket a millisecond ago — we hand back theirs.
|
|
319
|
+
#
|
|
320
|
+
# Returns [ticket, inserted?] so the caller can tell "I opened this"
|
|
321
|
+
# from "I found this": they are the same ticket, and a very different
|
|
322
|
+
# thing to announce.
|
|
323
|
+
def insert_ticket!(requester:, desk:, node:, about:, via:, requester_role:, title:, metadata:,
|
|
324
|
+
cardinality_key:)
|
|
325
|
+
attempts = 0
|
|
326
|
+
begin
|
|
327
|
+
attempts += 1
|
|
328
|
+
ticket, opened = transaction do
|
|
329
|
+
ticket = create!(
|
|
330
|
+
desk: desk, requester: requester, requester_role: requester_role, subject: about,
|
|
331
|
+
topic: node, title: title.presence, reference: unique_reference, status: "open",
|
|
332
|
+
awaiting: "agent", priority: node.priority, opened_via: via.to_s,
|
|
333
|
+
opened_at: Time.current, cardinality_key: cardinality_key, metadata: metadata
|
|
334
|
+
)
|
|
335
|
+
conversation = Chats::Conversation.direct_between!(requester, desk, about: ticket)
|
|
336
|
+
ticket.update!(conversation_id: conversation.id, waiting_since: ticket.opened_at)
|
|
337
|
+
# Through the same writer every other transition uses (`send`
|
|
338
|
+
# because it is private and we are the class, not the record),
|
|
339
|
+
# so opening a ticket reaches `ticket_transitioned` too.
|
|
340
|
+
opened = ticket.send(:record_transition!, :opened, actor: requester) do
|
|
341
|
+
{ "topic" => node.path, "via" => via.to_s }
|
|
342
|
+
end
|
|
343
|
+
[ ticket, opened ]
|
|
344
|
+
end
|
|
345
|
+
ticket.send(:publish_transition, opened, :opened, requester, nil)
|
|
346
|
+
[ ticket, true ]
|
|
347
|
+
rescue ActiveRecord::RecordNotUnique
|
|
348
|
+
existing = open_ticket_for(requester: requester, desk: desk, cardinality_key: cardinality_key)
|
|
349
|
+
return [ existing, false ] if existing
|
|
350
|
+
raise if attempts >= 2
|
|
351
|
+
|
|
352
|
+
retry
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def unique_reference
|
|
357
|
+
REFERENCE_ATTEMPTS.times do
|
|
358
|
+
reference = generate_reference
|
|
359
|
+
return reference unless exists?(reference: reference)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
raise Error, "couldn't generate a free ticket reference in #{REFERENCE_ATTEMPTS} attempts"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def post_opening_message(ticket, message, files)
|
|
366
|
+
return ticket if message.blank? && files.blank?
|
|
367
|
+
|
|
368
|
+
ticket.post_requester_message!(message, files: files)
|
|
369
|
+
# Posting the first message is what starts the clocks, and it does
|
|
370
|
+
# that through chats' after-commit subscriber — on a DIFFERENT
|
|
371
|
+
# instance of this row. Without the reload the caller gets a ticket
|
|
372
|
+
# whose `waiting_since` is nil while the database's is not.
|
|
373
|
+
ticket.reload
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# --- Readers ----------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
# What this ticket is called: its own title when a channel gave it one,
|
|
380
|
+
# else the subject's label, else the topic's.
|
|
381
|
+
def label
|
|
382
|
+
title.presence || subject&.support_label || topic&.label || reference
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# chats' context line for the conversation behind the ticket.
|
|
386
|
+
def chat_subject_label = label
|
|
387
|
+
|
|
388
|
+
# Whether chats should refuse new messages in this conversation. Only
|
|
389
|
+
# true for closed tickets on a desk configured `closed_tickets:
|
|
390
|
+
# :locked` — the default lets a requester's reply reopen the case.
|
|
391
|
+
def chat_locked?
|
|
392
|
+
closed? && desk_config.closed_tickets == :locked
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def chat_locked_notice
|
|
396
|
+
I18n.t("support_desk.thread.closed_notice")
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def open? = status == "open"
|
|
400
|
+
def closed? = status == "closed"
|
|
401
|
+
def snoozed? = status == "snoozed"
|
|
402
|
+
def assigned? = assignee_id.present?
|
|
403
|
+
def unassigned? = !assigned?
|
|
404
|
+
def reopened? = reopen_count.to_i.positive?
|
|
405
|
+
|
|
406
|
+
# True when the desk owes the next word.
|
|
407
|
+
def awaiting_reply? = awaiting == "agent"
|
|
408
|
+
def awaiting_requester? = awaiting == "requester"
|
|
409
|
+
|
|
410
|
+
def about?(record)
|
|
411
|
+
return false if record.nil?
|
|
412
|
+
|
|
413
|
+
subject_type == record.class.polymorphic_name && subject_id.to_s == record.id.to_s
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def assigned_to?(agent)
|
|
417
|
+
return false if agent.nil? || agent.is_a?(Symbol) || unassigned?
|
|
418
|
+
|
|
419
|
+
assignee_type == agent.class.polymorphic_name && assignee_id.to_s == agent.id.to_s
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# How long the current wait has been going on, or nil when nobody owes
|
|
423
|
+
# anything.
|
|
424
|
+
def waiting_for
|
|
425
|
+
return nil if waiting_since.nil?
|
|
426
|
+
|
|
427
|
+
ActiveSupport::Duration.build((Time.current - waiting_since).to_i)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Waiting long enough to warn about, short of the promise.
|
|
431
|
+
def at_risk?
|
|
432
|
+
return false unless awaiting_reply? && waiting_for && desk_config.at_risk_after
|
|
433
|
+
|
|
434
|
+
waiting_for >= desk_config.at_risk_after && !overdue?
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Past `config.reply_within`.
|
|
438
|
+
def overdue?
|
|
439
|
+
return false unless awaiting_reply? && waiting_for && desk_config.reply_within
|
|
440
|
+
|
|
441
|
+
waiting_for >= desk_config.reply_within
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# How long the requester waited for a first human answer.
|
|
445
|
+
def time_to_first_reply
|
|
446
|
+
return nil if first_agent_reply_at.nil? || opened_at.nil?
|
|
447
|
+
|
|
448
|
+
ActiveSupport::Duration.build((first_agent_reply_at - opened_at).to_i)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# How long the case stayed open.
|
|
452
|
+
def time_to_close
|
|
453
|
+
return nil if closed_at.nil? || opened_at.nil?
|
|
454
|
+
|
|
455
|
+
ActiveSupport::Duration.build((closed_at - opened_at).to_i)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Which channel this ticket was opened through, as a Symbol.
|
|
459
|
+
def opened_via
|
|
460
|
+
super&.to_sym
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Every channel the case can be answered through. 0.1 ships in-app
|
|
464
|
+
# only; the email channel adds to this list in 0.2.
|
|
465
|
+
def channels = [ opened_via ].compact
|
|
466
|
+
|
|
467
|
+
# "in app · email" — the channels, in the reader's language.
|
|
468
|
+
def channels_summary
|
|
469
|
+
channels.map { |channel| I18n.t("support_desk.channels.#{channel}") }.join(" · ")
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# The internal notes agents left, newest last.
|
|
473
|
+
def notes = events.notes.chronological
|
|
474
|
+
|
|
475
|
+
# This desk's slice of the configuration — the thresholds and policies
|
|
476
|
+
# that decide what this ticket's predicates mean.
|
|
477
|
+
def desk_config
|
|
478
|
+
desk&.config || SupportDesk.config.default_desk
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# --- Transitions ------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
# Answer the requester. The message is sent BY the desk and AUTHORED by
|
|
484
|
+
# the agent, so the requester sees one counterpart with a signature and
|
|
485
|
+
# the console knows who wrote it.
|
|
486
|
+
#
|
|
487
|
+
# Honours `config.reply_policy`: under :anyone an unheld ticket is taken
|
|
488
|
+
# by whoever answers first and a drop-in on somebody else's ticket is
|
|
489
|
+
# recorded; under :take_over the drop-in takes it; under :assignee_only
|
|
490
|
+
# it raises SupportDesk::NotAllowed. Returns the Chats::Message.
|
|
491
|
+
def reply!(body = nil, by: nil, files: [], request: nil)
|
|
492
|
+
actor = resolve_actor(by)
|
|
493
|
+
ensure_agent!(actor)
|
|
494
|
+
ensure_writable!
|
|
495
|
+
|
|
496
|
+
# One transaction, because taking the ticket and announcing it are
|
|
497
|
+
# part of answering: a reply that raises (an empty body, a locked
|
|
498
|
+
# conversation, a rate limit) must not leave the agent holding a case
|
|
499
|
+
# they never answered, or "Lucía se ocupa de tu consulta" sitting in
|
|
500
|
+
# the requester's thread with no reply under it.
|
|
501
|
+
transaction do
|
|
502
|
+
apply_reply_policy!(actor, request: request)
|
|
503
|
+
desk.message!(conversation, body, files: files, author: actor)
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# An internal note: in the timeline and the console, never in the
|
|
508
|
+
# conversation, never mirrored to any channel. Returns the Event.
|
|
509
|
+
def note!(body, by: nil, request: nil)
|
|
510
|
+
actor = resolve_actor(by)
|
|
511
|
+
ensure_agent!(actor)
|
|
512
|
+
raise ArgumentError, "a note needs something to say" if body.blank?
|
|
513
|
+
|
|
514
|
+
event = write_transition!(:note, actor: actor, request: request) { { "note" => body.to_s } }
|
|
515
|
+
SupportDesk.emit_after_commit(:note_added, self, event) if event
|
|
516
|
+
event
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Give the ticket to an agent. `assign!(to: lucia, by: lucia)` is
|
|
520
|
+
# somebody taking it; `assign!(to: pedro, by: admin)` is somebody being
|
|
521
|
+
# handed it. Repeating an assignment to the current holder does nothing.
|
|
522
|
+
def assign!(to:, by: nil, reason: nil, note: nil, request: nil)
|
|
523
|
+
actor = resolve_actor(by)
|
|
524
|
+
ensure_assignable!(to)
|
|
525
|
+
raise InvalidTransition, "can't assign a closed ticket — reopen it first" if closed?
|
|
526
|
+
|
|
527
|
+
reason ||= to == actor ? :taken : :assigned
|
|
528
|
+
assignment = nil
|
|
529
|
+
|
|
530
|
+
event = write_transition!(:assigned, actor: actor, request: request) do
|
|
531
|
+
next false if assigned_to?(to)
|
|
532
|
+
|
|
533
|
+
assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: reason, note: note)
|
|
534
|
+
update!(assignee: to)
|
|
535
|
+
{ "assignee" => SupportDesk.actor_key(to), "reason" => reason.to_s }
|
|
536
|
+
end
|
|
537
|
+
return self unless event
|
|
538
|
+
|
|
539
|
+
announce_assignment!(to, first: assignments.count <= 1)
|
|
540
|
+
SupportDesk.emit_after_commit(:ticket_assigned, self, assignment)
|
|
541
|
+
self
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# `assign!` said by the person holding the ticket — with a note for
|
|
545
|
+
# whoever picks it up. Hand-off notes are always internal.
|
|
546
|
+
def hand_off!(to:, note: nil, by: nil, request: nil)
|
|
547
|
+
actor = resolve_actor(by)
|
|
548
|
+
unless assigned_to?(actor)
|
|
549
|
+
raise NotTheAssignee, "#{describe_actor(actor)} doesn't hold ticket #{reference} " \
|
|
550
|
+
"(#{assignee ? describe_actor(assignee) : "nobody"} does) — use assign! to override"
|
|
551
|
+
end
|
|
552
|
+
ensure_assignable!(to)
|
|
553
|
+
raise InvalidTransition, "can't hand off a closed ticket" if closed?
|
|
554
|
+
|
|
555
|
+
from = assignee
|
|
556
|
+
assignment = nil
|
|
557
|
+
event = write_transition!(:handed_off, actor: actor, request: request) do
|
|
558
|
+
next false if assigned_to?(to)
|
|
559
|
+
|
|
560
|
+
assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: :handed_off, note: note,
|
|
561
|
+
release_reason: :handed_off)
|
|
562
|
+
update!(assignee: to)
|
|
563
|
+
{ "from" => SupportDesk.actor_key(from), "to" => SupportDesk.actor_key(to), "note" => note }
|
|
564
|
+
end
|
|
565
|
+
return self unless event
|
|
566
|
+
|
|
567
|
+
announce_assignment!(to, first: false)
|
|
568
|
+
SupportDesk.emit_after_commit(:ticket_handed_off, self, assignment, from: from, note: note)
|
|
569
|
+
self
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Put the ticket back in the unassigned pile.
|
|
573
|
+
def release!(by: nil, reason: :released, request: nil)
|
|
574
|
+
actor = resolve_actor(by)
|
|
575
|
+
ensure_agent!(actor)
|
|
576
|
+
raise InvalidTransition, "can't release a closed ticket" if closed?
|
|
577
|
+
|
|
578
|
+
from = assignee
|
|
579
|
+
event = write_transition!(:released, actor: actor, request: request) do
|
|
580
|
+
next false if unassigned?
|
|
581
|
+
|
|
582
|
+
assignments.open.each { |assignment| assignment.release!(reason: reason) }
|
|
583
|
+
update!(assignee: nil)
|
|
584
|
+
{ "from" => SupportDesk.actor_key(from), "reason" => reason.to_s }
|
|
585
|
+
end
|
|
586
|
+
return self unless event
|
|
587
|
+
|
|
588
|
+
SupportDesk.emit_after_commit(:ticket_released, self, from: from, reason: reason.to_sym)
|
|
589
|
+
self
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Close the case. Closing a closed ticket is a no-op, not an error.
|
|
593
|
+
def close!(by: nil, request: nil)
|
|
594
|
+
actor = resolve_actor(by)
|
|
595
|
+
ensure_agent!(actor)
|
|
596
|
+
|
|
597
|
+
event = write_transition!(:closed, actor: actor, request: request) do
|
|
598
|
+
next false if closed?
|
|
599
|
+
|
|
600
|
+
assignments.open.each { |assignment| assignment.release!(reason: :closed) }
|
|
601
|
+
update!(status: "closed", closed_at: Time.current, closed_by: record_actor(actor),
|
|
602
|
+
awaiting: "none", waiting_since: nil)
|
|
603
|
+
{}
|
|
604
|
+
end
|
|
605
|
+
return self unless event
|
|
606
|
+
|
|
607
|
+
SupportDesk.emit_after_commit(:ticket_closed, self, by: actor)
|
|
608
|
+
self
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Bring a closed case back. Also what a requester's reply does on a desk
|
|
612
|
+
# configured `closed_tickets: :reopen_on_reply` (the default).
|
|
613
|
+
#
|
|
614
|
+
# A reopened case goes back to whoever handled it, when they can still
|
|
615
|
+
# take it: they already know the story. When they can't, it returns to
|
|
616
|
+
# the pool.
|
|
617
|
+
def reopen!(by: nil, request: nil)
|
|
618
|
+
actor = resolve_actor(by)
|
|
619
|
+
|
|
620
|
+
event = write_transition!(:reopened, actor: actor, request: request) do
|
|
621
|
+
next false unless closed?
|
|
622
|
+
|
|
623
|
+
# Order matters: waiting_since is DERIVED from awaiting, so awaiting
|
|
624
|
+
# has to be the reopened value before it is read. Computing both in
|
|
625
|
+
# one update! hash reads the closed ticket's "none" and stores nil —
|
|
626
|
+
# a reopened case that no SLA scope can see.
|
|
627
|
+
assign_attributes(status: "open", closed_at: nil, closed_by: nil,
|
|
628
|
+
reopen_count: reopen_count.to_i + 1, awaiting: awaiting_from_clocks)
|
|
629
|
+
self.waiting_since = waiting_since_from_clocks
|
|
630
|
+
save!
|
|
631
|
+
restore_assignment!(by: actor)
|
|
632
|
+
{ "reopen_count" => reopen_count }
|
|
633
|
+
end
|
|
634
|
+
return self unless event
|
|
635
|
+
|
|
636
|
+
SupportDesk.emit_after_commit(:ticket_reopened, self, by: actor)
|
|
637
|
+
self
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# Refile the case. Misfiling is normal — the wizard can only offer the
|
|
641
|
+
# tree, and people describe problems in their own words.
|
|
642
|
+
def change_topic!(to:, by: nil, request: nil)
|
|
643
|
+
actor = resolve_actor(by)
|
|
644
|
+
# Agents may file onto any topic in the tree, including ones no
|
|
645
|
+
# requester is offered (`only:`); requesters may not file at all.
|
|
646
|
+
ensure_agent!(actor)
|
|
647
|
+
node = desk_config.topics.find(to.to_s) ||
|
|
648
|
+
raise(UnknownTopic, "no topic #{to.inspect} on desk #{desk.key}")
|
|
649
|
+
|
|
650
|
+
from = topic
|
|
651
|
+
event = write_transition!(:topic_changed, actor: actor, request: request) do
|
|
652
|
+
next false if topic == node
|
|
653
|
+
|
|
654
|
+
update!(topic: node, priority: [ priority.to_i, node.priority ].max,
|
|
655
|
+
cardinality_key: recomputed_cardinality_key(subject: subject, topic: node))
|
|
656
|
+
{ "from" => from&.path, "to" => node.path }
|
|
657
|
+
end
|
|
658
|
+
return self unless event
|
|
659
|
+
|
|
660
|
+
SupportDesk.emit_after_commit(:ticket_topic_changed, self, from: from, to: node, by: actor)
|
|
661
|
+
self
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Point a free-form ticket at the record it turned out to be about.
|
|
665
|
+
def attach_subject!(record, by: nil, request: nil)
|
|
666
|
+
actor = resolve_actor(by)
|
|
667
|
+
ensure_agent!(actor)
|
|
668
|
+
unless record.respond_to?(:supportable?) && record.supportable?
|
|
669
|
+
raise NotSupportable, "#{record.class} isn't supportable — add `supportable topic: :something` to it"
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
event = write_transition!(:subject_attached, actor: actor, request: request) do
|
|
673
|
+
next false if about?(record)
|
|
674
|
+
|
|
675
|
+
update!(subject: record,
|
|
676
|
+
cardinality_key: recomputed_cardinality_key(subject: record, topic: topic))
|
|
677
|
+
{ "subject" => SupportDesk.actor_key(record) }
|
|
678
|
+
end
|
|
679
|
+
return self unless event
|
|
680
|
+
|
|
681
|
+
SupportDesk.emit_after_commit(:subject_attached, self, record, by: actor)
|
|
682
|
+
self
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# --- Message registration -----------------------------------------------------
|
|
686
|
+
|
|
687
|
+
# Fold a chats message into the case: who owes the next word, the SLA
|
|
688
|
+
# clocks, and a reopen when a requester writes into a closed ticket.
|
|
689
|
+
#
|
|
690
|
+
# Idempotent on the message id, so a redelivered event never
|
|
691
|
+
# double-counts, and safe to call by hand after importing a transcript.
|
|
692
|
+
def register!(message)
|
|
693
|
+
return self if registered?(message)
|
|
694
|
+
|
|
695
|
+
role = nil
|
|
696
|
+
opening = false
|
|
697
|
+
reopened = false
|
|
698
|
+
applied = false
|
|
699
|
+
reopen_event = nil
|
|
700
|
+
|
|
701
|
+
with_lock do
|
|
702
|
+
# Re-check under the lock: the same message can reach us twice (a
|
|
703
|
+
# redelivered event, a hand-written replay), and an SLA clock that
|
|
704
|
+
# moves twice for one message is a lie.
|
|
705
|
+
next if registered?(message)
|
|
706
|
+
|
|
707
|
+
role = role_of(message)
|
|
708
|
+
opening = opening_message?
|
|
709
|
+
applied = true
|
|
710
|
+
|
|
711
|
+
attributes = { last_registered_message_id: message.id }
|
|
712
|
+
case role
|
|
713
|
+
when :requester
|
|
714
|
+
attributes[:awaiting] = "agent"
|
|
715
|
+
attributes[:last_requester_message_at] = message.created_at
|
|
716
|
+
if closed? && desk_config.closed_tickets == :reopen_on_reply
|
|
717
|
+
attributes.merge!(status: "open", closed_at: nil, closed_by: nil,
|
|
718
|
+
reopen_count: reopen_count.to_i + 1)
|
|
719
|
+
reopened = true
|
|
720
|
+
end
|
|
721
|
+
when :agent
|
|
722
|
+
attributes[:last_agent_message_at] = message.created_at
|
|
723
|
+
attributes[:first_agent_reply_at] = message.created_at if first_agent_reply_at.nil?
|
|
724
|
+
# A closed case owes nobody anything. An agent adding one last
|
|
725
|
+
# word keeps the clocks honest without putting the case back in a
|
|
726
|
+
# queue that `close!` just took it out of.
|
|
727
|
+
attributes[:awaiting] = "requester" unless closed?
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
assign_attributes(attributes)
|
|
731
|
+
self.waiting_since = waiting_since_from_clocks
|
|
732
|
+
save!
|
|
733
|
+
|
|
734
|
+
if reopened
|
|
735
|
+
restore_assignment!(by: :system)
|
|
736
|
+
reopen_event = record_transition!(:reopened, actor: requester) { { "via" => "requester_reply" } }
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
publish_transition(reopen_event, :reopened, requester, nil) if reopen_event
|
|
741
|
+
announce_registration(message, role: role, opening: opening, reopened: reopened) if applied
|
|
742
|
+
self
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# --- Presenters ---------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
# Everything an agent needs to see next to the transcript.
|
|
748
|
+
def context_card = ContextCard.new(self)
|
|
749
|
+
|
|
750
|
+
# One line for a list row, a Telegram message, a Slack block.
|
|
751
|
+
def summary = Summary.new(self)
|
|
752
|
+
|
|
753
|
+
# Messages and events merged by time — the unified activity view.
|
|
754
|
+
def timeline = Timeline.new(self)
|
|
755
|
+
|
|
756
|
+
# Who held this ticket, in order.
|
|
757
|
+
def assignment_history = assignments.chronological
|
|
758
|
+
|
|
759
|
+
# Exactly the verbs +agent+ may use on this case right now, filtered by
|
|
760
|
+
# policy, status and duty.
|
|
761
|
+
#
|
|
762
|
+
# == This is authorization, not decoration
|
|
763
|
+
#
|
|
764
|
+
# It reads like a list of buttons and it is one, but `SupportDesk
|
|
765
|
+
# ::Console` also REFUSES any verb this method doesn't return — a
|
|
766
|
+
# console that renders one set of affordances and accepts a wider one
|
|
767
|
+
# has a UI that lies, and two agents working the same queue hit that
|
|
768
|
+
# every day: the second presses a button the first already made
|
|
769
|
+
# impossible. So treat this as a security boundary:
|
|
770
|
+
#
|
|
771
|
+
# * Narrowing it takes a verb away from every console, silently. The
|
|
772
|
+
# button disappears AND the endpoint starts refusing.
|
|
773
|
+
# * Widening it hands one out. Nothing else re-checks; the transitions
|
|
774
|
+
# raise for their own reasons, but "may this agent press this" is
|
|
775
|
+
# answered here and nowhere else.
|
|
776
|
+
# * It must agree with `may_reply?` and with what the transitions
|
|
777
|
+
# actually allow. Where they disagree, an agent sees a button that
|
|
778
|
+
# only ever errors, or no button for something they may do.
|
|
779
|
+
#
|
|
780
|
+
# `test/console/console_offered_actions_test.rb` writes the expected set
|
|
781
|
+
# down state by state and checks it twice — against this method, and
|
|
782
|
+
# against what the console accepts over HTTP — so a change here fails a
|
|
783
|
+
# test instead of quietly moving the boundary.
|
|
784
|
+
def actions_for(agent)
|
|
785
|
+
return [] unless agent.respond_to?(:support_agent?) && agent.support_agent?
|
|
786
|
+
# Off duty is a real answer: the console can still show the case, and
|
|
787
|
+
# an agent passing by can still leave a note, but nothing that speaks
|
|
788
|
+
# to the requester is offered to somebody who isn't working.
|
|
789
|
+
return [ :note ] if agent.respond_to?(:on_duty?) && !agent.on_duty?
|
|
790
|
+
|
|
791
|
+
actions = [ :note ]
|
|
792
|
+
if closed?
|
|
793
|
+
actions << :reopen
|
|
794
|
+
else
|
|
795
|
+
actions << :reply if may_reply?(agent)
|
|
796
|
+
actions << :assign
|
|
797
|
+
actions << :hand_off if assigned_to?(agent)
|
|
798
|
+
actions << :release if assigned?
|
|
799
|
+
actions << :change_topic
|
|
800
|
+
actions << :close
|
|
801
|
+
end
|
|
802
|
+
actions
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# Whether +agent+ may answer right now under this desk's reply policy.
|
|
806
|
+
def may_reply?(agent)
|
|
807
|
+
return false unless agent.respond_to?(:support_agent?) && agent.support_agent?
|
|
808
|
+
return true unless desk_config.reply_policy == :assignee_only
|
|
809
|
+
|
|
810
|
+
assigned_to?(agent)
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
# Who should hear about activity on this ticket: whoever holds it, or
|
|
814
|
+
# the whole on-duty pool while it's unheld. The gem computes it; the
|
|
815
|
+
# host delivers it.
|
|
816
|
+
def agents_to_notify
|
|
817
|
+
return [ assignee ].compact if assigned?
|
|
818
|
+
|
|
819
|
+
desk.on_duty_agents.to_a
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# Generic on purpose: a lock screen shouldn't spell out what somebody's
|
|
823
|
+
# support case is about.
|
|
824
|
+
def notification_title
|
|
825
|
+
I18n.t("support_desk.notifications.title", desk: desk.name)
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# The detail, for the body — visible after unlock.
|
|
829
|
+
def notification_body = label
|
|
830
|
+
|
|
831
|
+
# A GDPR-friendly dump of the case as the requester experienced it:
|
|
832
|
+
# their transcript and the status changes they saw, never internal
|
|
833
|
+
# notes or hand-off reasoning.
|
|
834
|
+
def export
|
|
835
|
+
{
|
|
836
|
+
reference: reference,
|
|
837
|
+
label: label,
|
|
838
|
+
topic: topic&.path,
|
|
839
|
+
topic_label: topic&.full_label,
|
|
840
|
+
subject: subject&.support_label,
|
|
841
|
+
status: status,
|
|
842
|
+
channels: channels.map(&:to_s),
|
|
843
|
+
opened_at: opened_at,
|
|
844
|
+
closed_at: closed_at,
|
|
845
|
+
messages: export_messages,
|
|
846
|
+
events: events.requester_visible.chronological.map do |event|
|
|
847
|
+
{ kind: event.kind, at: event.created_at }
|
|
848
|
+
end
|
|
849
|
+
}
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# --- Internals -----------------------------------------------------------------
|
|
853
|
+
|
|
854
|
+
# Post a message as the requester — what `ask_support!` says first, and
|
|
855
|
+
# what an inbound channel replays.
|
|
856
|
+
def post_requester_message!(body, files: []) # :nodoc:
|
|
857
|
+
requester.message!(conversation, body, files: files)
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
def inspect
|
|
861
|
+
"#<SupportDesk::Ticket #{reference} #{topic&.path} #{label.to_s.inspect} #{status}" \
|
|
862
|
+
"#{" → #{describe_actor(assignee)}" if assigned?}#{waiting_description}>"
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
private
|
|
866
|
+
|
|
867
|
+
def waiting_description
|
|
868
|
+
return "" unless waiting_for
|
|
869
|
+
|
|
870
|
+
state = awaiting_reply? ? "awaiting reply" : "awaiting requester"
|
|
871
|
+
" (#{state} #{ActiveSupport::Duration.build(waiting_for.to_i).inspect})"
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
# --- Actors -------------------------------------------------------------------
|
|
875
|
+
|
|
876
|
+
def resolve_actor(by)
|
|
877
|
+
actor = by || Current.actor
|
|
878
|
+
return actor if actor
|
|
879
|
+
|
|
880
|
+
raise ActorMissing, "no actor: pass by: (an agent, a requester, or :system) or set " \
|
|
881
|
+
"SupportDesk::Current.actor"
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
def record_actor(actor) = actor.is_a?(Symbol) ? nil : actor
|
|
885
|
+
|
|
886
|
+
# The ACTOR of a transition may be `:system` (a job, a sweep); the agent
|
|
887
|
+
# a ticket is handed TO may not — somebody has to be able to answer it.
|
|
888
|
+
def ensure_assignable!(agent)
|
|
889
|
+
if agent.nil? || agent.is_a?(Symbol)
|
|
890
|
+
raise NotAnAgent, "can't assign a ticket to #{agent.inspect} — pass an agent record"
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
ensure_agent!(agent)
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
def ensure_agent!(actor)
|
|
897
|
+
return if actor.is_a?(Symbol)
|
|
898
|
+
return if actor.respond_to?(:support_agent?) && actor.support_agent?
|
|
899
|
+
|
|
900
|
+
raise NotAnAgent, "#{describe_actor(actor)} is not a support agent — declare " \
|
|
901
|
+
"`acts_as_support_agent` on #{actor.class}"
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
def describe_actor(actor)
|
|
905
|
+
return actor.to_s if actor.nil? || actor.is_a?(Symbol)
|
|
906
|
+
|
|
907
|
+
actor.try(:support_agent_name) || "#{actor.class}##{actor.id}"
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
# --- Transition plumbing -------------------------------------------------------
|
|
911
|
+
|
|
912
|
+
def ensure_writable!
|
|
913
|
+
return unless chat_locked?
|
|
914
|
+
|
|
915
|
+
raise Locked, "ticket #{reference} is closed and this desk locks closed tickets — reopen it first"
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
def apply_reply_policy!(actor, request: nil)
|
|
919
|
+
# A closed case has no seat to take: an agent adding one last word
|
|
920
|
+
# posts it and the case stays closed. (A REQUESTER writing is what
|
|
921
|
+
# reopens it — see #register!.)
|
|
922
|
+
return if closed?
|
|
923
|
+
|
|
924
|
+
policy = desk_config.reply_policy
|
|
925
|
+
|
|
926
|
+
if unassigned?
|
|
927
|
+
if policy == :assignee_only
|
|
928
|
+
raise NotAllowed, "ticket #{reference} is unassigned and this desk only lets the assignee reply — " \
|
|
929
|
+
"take it first"
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
assign!(to: actor, by: actor, request: request)
|
|
933
|
+
elsif !assigned_to?(actor)
|
|
934
|
+
case policy
|
|
935
|
+
when :assignee_only
|
|
936
|
+
raise NotAllowed, "ticket #{reference} is held by #{describe_actor(assignee)} and this desk only " \
|
|
937
|
+
"lets the assignee reply"
|
|
938
|
+
when :take_over
|
|
939
|
+
assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request)
|
|
940
|
+
else
|
|
941
|
+
write_transition!(:drop_in, actor: actor, request: request) do
|
|
942
|
+
{ "assignee" => SupportDesk.actor_key(assignee) }
|
|
943
|
+
end
|
|
944
|
+
end
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# Run a transition under the row lock: yield, write exactly one event
|
|
949
|
+
# row, and (once everything has committed) broadcast and emit. The block
|
|
950
|
+
# returns the event payload, or false when there is nothing to do.
|
|
951
|
+
def write_transition!(kind, actor:, request: nil, &block)
|
|
952
|
+
event = nil
|
|
953
|
+
with_lock { event = record_transition!(kind, actor: actor, &block) }
|
|
954
|
+
publish_transition(event, kind, actor, request) if event
|
|
955
|
+
event
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
# The half that writes, for callers who ALREADY hold the row (the
|
|
959
|
+
# reopen inside #register!) or who are still inside the transaction that
|
|
960
|
+
# created the ticket (the `opened` event). Returns the Event, or nil
|
|
961
|
+
# when the block says there was nothing to do.
|
|
962
|
+
def record_transition!(kind, actor:) # :nodoc:
|
|
963
|
+
payload = yield
|
|
964
|
+
return nil if payload == false || payload.nil?
|
|
965
|
+
|
|
966
|
+
Event.record!(ticket: self, kind: kind, actor: actor, payload: payload.compact)
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
# The half that tells the world, once the write is durable. Every
|
|
970
|
+
# transition goes through here — `ticket_transitioned` is the audit-log
|
|
971
|
+
# hook, so a transition that skipped it would be a hole in the host's
|
|
972
|
+
# audit trail, not a missing nicety.
|
|
973
|
+
def publish_transition(event, kind, actor, request) # :nodoc:
|
|
974
|
+
return nil if event.nil?
|
|
975
|
+
|
|
976
|
+
broadcast_change
|
|
977
|
+
SupportDesk.emit_after_commit(:ticket_transitioned, self, kind.to_sym, by: actor,
|
|
978
|
+
request: request || Current.request,
|
|
979
|
+
payload: event.payload)
|
|
980
|
+
event
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
def announce_assignment!(agent, first:)
|
|
984
|
+
mode = desk_config.announce_assignments
|
|
985
|
+
return if mode == :never
|
|
986
|
+
return if mode == :first_only && !first
|
|
987
|
+
return unless agent.respond_to?(:support_agent_name)
|
|
988
|
+
|
|
989
|
+
key = first ? "support_desk.system.assigned" : "support_desk.system.reassigned"
|
|
990
|
+
conversation&.post_system_message!(I18n.t(key, agent: agent.support_agent_name))
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
# --- Registration plumbing ------------------------------------------------------
|
|
994
|
+
|
|
995
|
+
# Whether this message is already folded in. The last-id check catches
|
|
996
|
+
# the common redelivery; the clock check catches the rest, because a
|
|
997
|
+
# REPLAY can arrive in any order and an older message must never rewind
|
|
998
|
+
# `awaiting`, restart an SLA clock, or reopen a case that was closed
|
|
999
|
+
# after it.
|
|
1000
|
+
#
|
|
1001
|
+
# The comparison is `<=`, so a message whose timestamp already sits on
|
|
1002
|
+
# the clock counts as folded in: idempotency is the documented promise,
|
|
1003
|
+
# and the cost of the rare tie is one clock that doesn't advance until
|
|
1004
|
+
# the next message.
|
|
1005
|
+
def registered?(message)
|
|
1006
|
+
return true if last_registered_message_id.present? && last_registered_message_id.to_s == message.id.to_s
|
|
1007
|
+
|
|
1008
|
+
clock = case role_of(message)
|
|
1009
|
+
when :requester then last_requester_message_at
|
|
1010
|
+
when :agent then last_agent_message_at
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
clock.present? && message.created_at <= clock
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
def opening_message?
|
|
1017
|
+
last_registered_message_id.blank? && last_requester_message_at.nil? && last_agent_message_at.nil?
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
# Who sent this message, in the only terms the case cares about.
|
|
1021
|
+
def role_of(message)
|
|
1022
|
+
return :system if message.respond_to?(:system?) && message.system?
|
|
1023
|
+
return :agent if same_record?(message.sender_type, message.sender_id, desk)
|
|
1024
|
+
return :requester if same_record?(message.sender_type, message.sender_id, requester)
|
|
1025
|
+
|
|
1026
|
+
:system
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
def same_record?(type, id, record)
|
|
1030
|
+
return false if record.nil? || type.nil?
|
|
1031
|
+
|
|
1032
|
+
type == record.class.polymorphic_name && id.to_s == record.id.to_s
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# What "one open ticket about this" means for this ticket, recomputed
|
|
1036
|
+
# because the case now says it is about something else. Refusing a
|
|
1037
|
+
# collision here is the point: silently keeping the old key leaves the
|
|
1038
|
+
# thing it used to be about blocked, and the thing it IS about free for
|
|
1039
|
+
# a second ticket.
|
|
1040
|
+
def recomputed_cardinality_key(subject:, topic:)
|
|
1041
|
+
key = self.class.cardinality_key_for(requester: requester, subject: subject, topic: topic)
|
|
1042
|
+
conflict = self.class.not_closed.where(requester: requester, desk: desk, cardinality_key: key)
|
|
1043
|
+
.where.not(id: id).first
|
|
1044
|
+
if conflict
|
|
1045
|
+
raise InvalidTransition,
|
|
1046
|
+
"#{requester.class}##{requester.id} already has an open ticket about that " \
|
|
1047
|
+
"(#{conflict.reference}) — close or merge it first"
|
|
1048
|
+
end
|
|
1049
|
+
|
|
1050
|
+
key
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
# Reopening restores the last holder's seat (reason "reopened") when
|
|
1054
|
+
# they can still answer, so `assignee` and the open assignment row never
|
|
1055
|
+
# disagree on a live ticket. A closed ticket keeps its assignee as the
|
|
1056
|
+
# record of who dealt with it, with no open row — that pair is the one
|
|
1057
|
+
# shape `doctor` expects to see.
|
|
1058
|
+
def restore_assignment!(by:)
|
|
1059
|
+
return if unassigned?
|
|
1060
|
+
|
|
1061
|
+
if assignee.respond_to?(:support_agent?) && assignee.support_agent?
|
|
1062
|
+
Assignment.open!(ticket: self, agent: assignee, by: by, reason: :reopened)
|
|
1063
|
+
else
|
|
1064
|
+
update!(assignee: nil)
|
|
1065
|
+
end
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
def awaiting_from_clocks
|
|
1069
|
+
return "agent" if last_requester_message_at && (last_agent_message_at.nil? ||
|
|
1070
|
+
last_requester_message_at > last_agent_message_at)
|
|
1071
|
+
return "requester" if last_agent_message_at
|
|
1072
|
+
|
|
1073
|
+
"agent"
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
def waiting_since_from_clocks
|
|
1077
|
+
case awaiting
|
|
1078
|
+
when "agent" then last_requester_message_at || opened_at
|
|
1079
|
+
when "requester" then last_agent_message_at
|
|
1080
|
+
end
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
def announce_registration(message, role:, opening:, reopened:)
|
|
1084
|
+
broadcast_change
|
|
1085
|
+
|
|
1086
|
+
SupportDesk.emit_after_commit(:ticket_reopened, self, by: requester) if reopened
|
|
1087
|
+
|
|
1088
|
+
case role
|
|
1089
|
+
when :requester
|
|
1090
|
+
SupportDesk.emit_after_commit(:requester_replied, self, message) unless opening
|
|
1091
|
+
when :agent
|
|
1092
|
+
SupportDesk.emit_after_commit(:agent_replied, self, message)
|
|
1093
|
+
end
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
# --- Realtime --------------------------------------------------------------------
|
|
1097
|
+
|
|
1098
|
+
# Turbo 8 refreshes for the two console surfaces: the ticket page and
|
|
1099
|
+
# the queue. Requester-side realtime is chats'.
|
|
1100
|
+
def broadcast_change
|
|
1101
|
+
return unless respond_to?(:broadcast_refresh_later_to)
|
|
1102
|
+
|
|
1103
|
+
ActiveRecord.after_all_transactions_commit do
|
|
1104
|
+
broadcast_refresh_later_to(self, :console)
|
|
1105
|
+
broadcast_refresh_later_to(desk, :queue)
|
|
1106
|
+
end
|
|
1107
|
+
rescue StandardError => e
|
|
1108
|
+
SupportDesk.logger&.warn("[support_desk] broadcast failed for ticket #{id}: #{e.class}: #{e.message}")
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
def export_messages
|
|
1112
|
+
return [] if conversation.nil?
|
|
1113
|
+
|
|
1114
|
+
conversation.messages.visible.oldest_first.map do |message|
|
|
1115
|
+
{
|
|
1116
|
+
at: message.created_at,
|
|
1117
|
+
from: role_of(message) == :requester ? "you" : "support",
|
|
1118
|
+
body: message.visible_body,
|
|
1119
|
+
attachments: message.try(:files)&.map { |file| file.try(:filename).to_s } || []
|
|
1120
|
+
}
|
|
1121
|
+
end
|
|
1122
|
+
end
|
|
1123
|
+
end
|
|
1124
|
+
end
|