support_desk 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +113 -12
- data/Rakefile +16 -0
- data/app/controllers/support_desk/tickets_controller.rb +11 -3
- data/app/helpers/support_desk/engine_helper.rb +7 -1
- data/app/views/support_desk/console/tickets/_context_card.html.erb +5 -0
- data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +8 -0
- data/app/views/support_desk/console/tickets/index.html.erb +14 -2
- data/app/views/support_desk/console/tickets/new.html.erb +25 -0
- data/app/views/support_desk/tickets/_write.html.erb +1 -0
- data/config/console_routes.rb +1 -1
- data/config/locales/support_desk.console.en.yml +26 -0
- data/config/locales/support_desk.console.es.yml +26 -0
- data/config/locales/support_desk.en.yml +3 -0
- data/config/locales/support_desk.es.yml +6 -0
- data/lib/generators/support_desk/console_generator.rb +1 -1
- data/lib/generators/support_desk/install_generator.rb +14 -0
- data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
- data/lib/generators/support_desk/templates/initializer.rb +30 -0
- data/lib/generators/support_desk/upgrade_generator.rb +56 -0
- data/lib/support_desk/configuration.rb +148 -2
- data/lib/support_desk/console.rb +358 -26
- data/lib/support_desk/console_engine.rb +2 -0
- data/lib/support_desk/console_routes.rb +20 -8
- data/lib/support_desk/context_card.rb +23 -0
- data/lib/support_desk/doctor.rb +51 -1
- data/lib/support_desk/errors.rb +5 -0
- data/lib/support_desk/macros.rb +28 -12
- data/lib/support_desk/models/assignment.rb +3 -1
- data/lib/support_desk/models/concerns/agent.rb +39 -4
- data/lib/support_desk/models/concerns/requester.rb +27 -12
- data/lib/support_desk/models/ticket.rb +509 -126
- data/lib/support_desk/summary.rb +1 -1
- data/lib/support_desk/test_helpers.rb +8 -3
- data/lib/support_desk/topic.rb +8 -29
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +10 -27
- data/lib/support_desk.rb +45 -0
- data/lib/tasks/support_desk.rake +27 -0
- metadata +13 -2
|
@@ -47,6 +47,12 @@ module SupportDesk
|
|
|
47
47
|
belongs_to :assignee, polymorphic: true, optional: true
|
|
48
48
|
belongs_to :conversation, class_name: "Chats::Conversation", optional: true
|
|
49
49
|
belongs_to :closed_by, polymorphic: true, optional: true
|
|
50
|
+
# Who opened the case: the requester when they asked, the agent when the
|
|
51
|
+
# desk wrote first. A record, exactly like `closed_by`. Nullable only for
|
|
52
|
+
# rows written by 0.1, which had no way to open a case as anybody else —
|
|
53
|
+
# the upgrade migration backfills them to their requester and `doctor`
|
|
54
|
+
# reports any that are left.
|
|
55
|
+
belongs_to :opened_by, polymorphic: true, optional: true
|
|
50
56
|
|
|
51
57
|
has_many :assignments, class_name: "SupportDesk::Assignment", inverse_of: :ticket, dependent: :destroy
|
|
52
58
|
# :delete_all, not :destroy — an Event is readonly once written, and a
|
|
@@ -55,15 +61,23 @@ module SupportDesk
|
|
|
55
61
|
has_many :events, class_name: "SupportDesk::Event", inverse_of: :ticket, dependent: :delete_all
|
|
56
62
|
has_many :messages, through: :conversation, source: :messages
|
|
57
63
|
|
|
58
|
-
#
|
|
59
|
-
#
|
|
64
|
+
# Persist only the path. Resolving behavior needs this ticket's desk;
|
|
65
|
+
# an attribute caster has no record context and cannot choose the tree.
|
|
60
66
|
attribute :topic, Topic::Type.new
|
|
61
67
|
attribute :metadata, default: -> { {} }
|
|
62
68
|
|
|
69
|
+
def topic
|
|
70
|
+
path = self[:topic]
|
|
71
|
+
return if path.nil?
|
|
72
|
+
|
|
73
|
+
desk_config.topics.find(path) || Topic::Unknown.new(path)
|
|
74
|
+
end
|
|
75
|
+
|
|
63
76
|
validates :status, inclusion: { in: STATUSES }
|
|
64
77
|
validates :awaiting, inclusion: { in: AWAITING_STATES }
|
|
65
78
|
validates :opened_via, inclusion: { in: CHANNELS }
|
|
66
79
|
validates :reference, presence: true
|
|
80
|
+
validate :opened_by_must_be_a_whole_record
|
|
67
81
|
|
|
68
82
|
# --- Scopes ---------------------------------------------------------------
|
|
69
83
|
|
|
@@ -81,6 +95,26 @@ module SupportDesk
|
|
|
81
95
|
scope :awaiting_reply, -> { where(awaiting: "agent") }
|
|
82
96
|
scope :awaiting_requester, -> { where(awaiting: "requester") }
|
|
83
97
|
|
|
98
|
+
# The two halves of every case: the requester asked, or the desk wrote
|
|
99
|
+
# first. They partition the table, NULL provenance included — a 0.1 row
|
|
100
|
+
# nobody backfilled reads as "not the requester", which is exactly what
|
|
101
|
+
# `doctor` wants somebody to come and look at.
|
|
102
|
+
#
|
|
103
|
+
# Built lazily, in a method rather than a constant: an Arel comparison
|
|
104
|
+
# evaluated at class definition would consult the schema before
|
|
105
|
+
# `db:migrate` had added the columns it names.
|
|
106
|
+
def self.opened_by_requester_condition # :nodoc:
|
|
107
|
+
identity = arel_table[:opened_by_type].eq(arel_table[:requester_type])
|
|
108
|
+
.and(arel_table[:opened_by_id].eq(arel_table[:requester_id]))
|
|
109
|
+
# Only 0.1 writes a NULL pair: automation openers are not supported.
|
|
110
|
+
# Keep those inbound cases correct while the catch-up backfill runs.
|
|
111
|
+
legacy = arel_table[:opened_by_type].eq(nil).and(arel_table[:opened_by_id].eq(nil))
|
|
112
|
+
identity.or(legacy)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
scope :opened_by_requester, -> { where(opened_by_requester_condition) }
|
|
116
|
+
scope :opened_by_support, -> { where.not(opened_by_requester_condition) }
|
|
117
|
+
|
|
84
118
|
# Waiting longer than +duration+ for whoever owes the next word.
|
|
85
119
|
scope :waiting_over, lambda { |duration|
|
|
86
120
|
not_closed.where.not(waiting_since: nil).where(waiting_since: ..duration.ago)
|
|
@@ -152,38 +186,83 @@ module SupportDesk
|
|
|
152
186
|
end
|
|
153
187
|
|
|
154
188
|
# Open a ticket and post its first message. Usually called as
|
|
155
|
-
# `requester.ask_support!(…)
|
|
189
|
+
# `requester.ask_support!(…)` — or, when the desk writes first, as
|
|
190
|
+
# `agent.open_support_conversation_with!(…)`. This is the seam under
|
|
191
|
+
# both, and the one channels and jobs use.
|
|
192
|
+
#
|
|
193
|
+
# `by:` is whoever is opening it and defaults to the requester; an
|
|
194
|
+
# explicit nil means the same thing. An AGENT there is the desk writing
|
|
195
|
+
# first: the message is the desk's, signed by them, they hold the case
|
|
196
|
+
# from its first committed state, nobody is told "se ocupa de tu
|
|
197
|
+
# consulta", and the requester's asking limits don't apply — those
|
|
198
|
+
# limit asking, not being asked.
|
|
156
199
|
#
|
|
157
200
|
# Returns the existing open ticket when one already covers the same
|
|
158
201
|
# subject (or, for free-form tickets, the same topic) — posting the
|
|
159
|
-
# message into it, because somebody just typed it.
|
|
202
|
+
# message into it as an ordinary reply, because somebody just typed it.
|
|
160
203
|
def open!(requester:, message: nil, about: nil, topic: nil, files: [], via: :in_app,
|
|
161
|
-
desk: nil, requester_role: nil, title: nil, metadata: {})
|
|
204
|
+
desk: nil, requester_role: nil, title: nil, metadata: {}, by: nil, request: nil)
|
|
205
|
+
open_or_reply!(requester: requester, message: message, about: about, topic: topic, files: files,
|
|
206
|
+
via: via, desk: desk, requester_role: requester_role, title: title,
|
|
207
|
+
metadata: metadata, by: by, request: request)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# `open!`, plus the console's authorization seam. `authorize_reuse` is
|
|
211
|
+
# called with the case this turned out to be a reply INTO — under its
|
|
212
|
+
# row lock, before any policy side effect — so a host that allows
|
|
213
|
+
# writing to somebody but not answering that particular case refuses
|
|
214
|
+
# before anything is written, and a raise there takes the whole
|
|
215
|
+
# operation with it. Everything else is `open!`; that is the method to
|
|
216
|
+
# call.
|
|
217
|
+
def open_or_reply!(requester:, message: nil, about: nil, topic: nil, files: [], via: :in_app,
|
|
218
|
+
desk: nil, requester_role: nil, title: nil, metadata: {}, by: nil, request: nil,
|
|
219
|
+
authorize_reuse: nil) # :nodoc:
|
|
220
|
+
ensure_requester!(requester)
|
|
162
221
|
desk ||= SupportDesk.desk
|
|
222
|
+
opener = by.nil? ? requester : by
|
|
223
|
+
# By persisted identity, never by ambient state: who asked is not
|
|
224
|
+
# something to infer from Current.actor, and an agent passed as their
|
|
225
|
+
# own requester is asking for help, not writing to themselves.
|
|
226
|
+
by_support = !same_actor?(opener, requester)
|
|
227
|
+
ensure_opener!(opener) if by_support
|
|
228
|
+
|
|
163
229
|
# The subject is checked BEFORE the topic: "this isn't supportable" is
|
|
164
230
|
# the useful error, and an unsupportable record has no topic to find.
|
|
165
231
|
validate_subject!(about, requester)
|
|
166
|
-
node = resolve_topic!(topic, about, desk, requester, about)
|
|
232
|
+
node = resolve_topic!(topic, about, desk, requester, about, by_support: by_support)
|
|
167
233
|
|
|
168
234
|
cardinality = cardinality_key_for(requester: requester, subject: about, topic: node)
|
|
169
|
-
existing =
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
235
|
+
existing = existing_for(requester: requester, desk: desk, subject: about, topic: node)
|
|
236
|
+
enforce_asking_limits!(requester, desk) unless existing || by_support
|
|
237
|
+
|
|
238
|
+
# ONE savepoint around the WHOLE operation, reuse included. A joined
|
|
239
|
+
# transaction is not enough: a caller who rescues our failure and
|
|
240
|
+
# commits its own would keep the ticket, the seat and the conversation
|
|
241
|
+
# and lose only the message that was supposed to justify them.
|
|
242
|
+
transaction(requires_new: true) do
|
|
243
|
+
next reply_into!(existing, message, files: files, by: opener, by_support: by_support,
|
|
244
|
+
request: request, authorize_reuse: authorize_reuse) if existing
|
|
245
|
+
|
|
246
|
+
created, inserted = insert_ticket!(
|
|
247
|
+
requester: requester, desk: desk, node: node, about: about, via: via, opened_by: opener,
|
|
248
|
+
by_support: by_support, requester_role: requester_role, title: title, metadata: metadata,
|
|
249
|
+
cardinality_key: cardinality, request: request
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# `inserted` is the only thing that knows whether THIS call opened
|
|
253
|
+
# the case. Not the assignment, not the message count, not
|
|
254
|
+
# `previously_new_record?` — the update two lines down resets it.
|
|
255
|
+
if inserted
|
|
256
|
+
post_opening!(created, message, files: files, by: opener, by_support: by_support)
|
|
257
|
+
SupportDesk.emit_after_commit(:ticket_opened, created)
|
|
258
|
+
created
|
|
259
|
+
else
|
|
260
|
+
# Somebody else's INSERT won by a millisecond. Theirs is the case
|
|
261
|
+
# that exists, so this is a reply into it, policy and all.
|
|
262
|
+
reply_into!(created, message, files: files, by: opener, by_support: by_support,
|
|
263
|
+
request: request, authorize_reuse: authorize_reuse)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
187
266
|
end
|
|
188
267
|
|
|
189
268
|
# A human-friendly, unguessable-enough reference: "T-AB12CD".
|
|
@@ -218,8 +297,88 @@ module SupportDesk
|
|
|
218
297
|
end
|
|
219
298
|
end
|
|
220
299
|
|
|
300
|
+
# New submissions reuse an open case, including a reopened history.
|
|
301
|
+
# Reopening itself never merges or discards a different conversation.
|
|
302
|
+
def existing_for(requester:, desk:, subject:, topic:)
|
|
303
|
+
return if subject && !subject.class.one_open_support_ticket?
|
|
304
|
+
|
|
305
|
+
scope = not_closed.where(requester: requester, desk: desk, subject: subject)
|
|
306
|
+
scope = scope.where(topic: topic.to_s) unless subject
|
|
307
|
+
scope.newest_first.first
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# The record on the requester side of every operation: saved, declared
|
|
311
|
+
# with `has_support_tickets`, and eligible RIGHT NOW.
|
|
312
|
+
#
|
|
313
|
+
# Eligibility is re-read rather than taken from the record in hand: the
|
|
314
|
+
# instance a caller is holding may have been loaded before the account
|
|
315
|
+
# was closed, and "not yours" and "not eligible" must look the same from
|
|
316
|
+
# outside. (It does not lock the host's closure transaction — see
|
|
317
|
+
# #requester_unavailable?.)
|
|
318
|
+
def ensure_requester!(record) # :nodoc:
|
|
319
|
+
unless record.respond_to?(:support_requester?)
|
|
320
|
+
raise NotARequester, "#{describe_record(record)} can't ask for support or be written to — " \
|
|
321
|
+
"declare `has_support_tickets` on #{record.class}"
|
|
322
|
+
end
|
|
323
|
+
unless record.persisted?
|
|
324
|
+
raise NotARequester, "an unsaved #{record.class} can't ask for support — save it first"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
current = record.class.uncached { record.class.find_by(id: record.id) }
|
|
328
|
+
return if current&.support_requester?
|
|
329
|
+
|
|
330
|
+
raise NotARequester, "#{record.class}##{record.id} can't ask for support or be written to right now " \
|
|
331
|
+
"(`has_support_tickets if:` says no, or the record is gone)"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# A loaded instance may predate revocation or deletion. Check the row,
|
|
335
|
+
# just as requester eligibility does; this does not serialize revocation.
|
|
336
|
+
def ensure_agent_record!(record) # :nodoc:
|
|
337
|
+
if record.respond_to?(:persisted?) && record.persisted? && record.respond_to?(:support_agent?)
|
|
338
|
+
current = record.class.uncached { record.class.find_by(id: record.id) }
|
|
339
|
+
return if current&.support_agent?
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
raise NotAnAgent, "#{record.class} is not a currently eligible, persisted support agent — " \
|
|
343
|
+
"declare `acts_as_support_agent` and check its if: condition"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Whether two actors are the same record. Not `==`: either side can be
|
|
347
|
+
# nil, a Symbol or an unsaved record, and all of those must answer "no"
|
|
348
|
+
# rather than raise or match on a nil id. STI subclasses compare by
|
|
349
|
+
# their base name, because that is the identity the polymorphic columns
|
|
350
|
+
# store — and two rows in one table can't share an id anyway.
|
|
351
|
+
def same_actor?(one, other) # :nodoc:
|
|
352
|
+
return false if one.nil? || other.nil? || one.is_a?(Symbol) || other.is_a?(Symbol)
|
|
353
|
+
return false unless one.respond_to?(:persisted?) && other.respond_to?(:persisted?)
|
|
354
|
+
return false unless one.persisted? && other.persisted?
|
|
355
|
+
|
|
356
|
+
one.class.polymorphic_name == other.class.polymorphic_name && one.id.to_s == other.id.to_s
|
|
357
|
+
end
|
|
358
|
+
|
|
221
359
|
private
|
|
222
360
|
|
|
361
|
+
# Who may speak for the desk: a saved, currently eligible agent.
|
|
362
|
+
# Automation (`by: :system`) is refused by name rather than by
|
|
363
|
+
# NoMethodError three frames in — it is deferred work, not a typo (see
|
|
364
|
+
# docs/12-open-questions.md Q17).
|
|
365
|
+
def ensure_opener!(opener)
|
|
366
|
+
if opener.is_a?(Symbol)
|
|
367
|
+
raise NotAnAgent, "can't open a ticket as #{opener.inspect} — a case is opened by somebody who can " \
|
|
368
|
+
"answer it, and automation openers aren't supported yet; pass an agent record"
|
|
369
|
+
end
|
|
370
|
+
unless opener.respond_to?(:persisted?) && opener.persisted?
|
|
371
|
+
raise NotAnAgent, "can't open a ticket as an unsaved #{opener.class} — save the agent first"
|
|
372
|
+
end
|
|
373
|
+
ensure_agent_record!(opener)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def describe_record(record)
|
|
377
|
+
return record.inspect if record.nil? || record.is_a?(Symbol)
|
|
378
|
+
|
|
379
|
+
"#{record.class}##{record.id}"
|
|
380
|
+
end
|
|
381
|
+
|
|
223
382
|
# "on this desk, and waiting longer than its own threshold" — with an
|
|
224
383
|
# upper bound when the caller wants the band between two thresholds
|
|
225
384
|
# (at risk, but not yet breached).
|
|
@@ -234,9 +393,14 @@ module SupportDesk
|
|
|
234
393
|
ceiling ? clause.and(arel_table[:waiting_since].gt(ceiling.ago)) : clause
|
|
235
394
|
end
|
|
236
395
|
|
|
237
|
-
def resolve_topic!(topic, about, desk, requester, subject)
|
|
396
|
+
def resolve_topic!(topic, about, desk, requester, subject, by_support: false)
|
|
238
397
|
node = locate_topic!(topic, about, desk)
|
|
239
|
-
|
|
398
|
+
# An agent may file onto any topic in the tree, including ones no
|
|
399
|
+
# requester is offered (`only:`) — the same latitude `change_topic!`
|
|
400
|
+
# has, and the reason it exists: the desk knows what this is about.
|
|
401
|
+
# The tree's other rule stands for everybody: a topic that needs
|
|
402
|
+
# something to be about still needs it.
|
|
403
|
+
if !by_support && !node.visible_for?(requester)
|
|
240
404
|
raise NotAllowed, "#{requester.class}##{requester.id} may not open a ticket under topic " \
|
|
241
405
|
"#{node.path.inspect} (its only: condition says no)"
|
|
242
406
|
end
|
|
@@ -284,12 +448,20 @@ module SupportDesk
|
|
|
284
448
|
not_closed.find_by(requester: requester, desk: desk, cardinality_key: cardinality_key)
|
|
285
449
|
end
|
|
286
450
|
|
|
451
|
+
# The two walls a requester can hit, and only they can: both count the
|
|
452
|
+
# cases this person ASKED for. Five conversations the desk started must
|
|
453
|
+
# never be what stops somebody asking their first question.
|
|
454
|
+
def enforce_asking_limits!(requester, desk)
|
|
455
|
+
enforce_rate_limit!(requester, desk)
|
|
456
|
+
enforce_open_ticket_cap!(requester, desk)
|
|
457
|
+
end
|
|
458
|
+
|
|
287
459
|
def enforce_rate_limit!(requester, desk)
|
|
288
460
|
limit = desk.config.open_rate_limit
|
|
289
461
|
return if limit.nil?
|
|
290
462
|
|
|
291
463
|
window = Time.current - limit[:within].to_i
|
|
292
|
-
recent = where(requester: requester, desk: desk).where(opened_at: window..).count
|
|
464
|
+
recent = opened_by_requester.where(requester: requester, desk: desk).where(opened_at: window..).count
|
|
293
465
|
return if recent < limit[:to]
|
|
294
466
|
|
|
295
467
|
raise RateLimited, "#{requester.class}##{requester.id} has opened #{recent} tickets in the last " \
|
|
@@ -306,7 +478,7 @@ module SupportDesk
|
|
|
306
478
|
cap = desk.config.max_open_tickets
|
|
307
479
|
return if cap.nil?
|
|
308
480
|
|
|
309
|
-
current = not_closed.where(requester: requester, desk: desk).count
|
|
481
|
+
current = not_closed.opened_by_requester.where(requester: requester, desk: desk).count
|
|
310
482
|
return if current < cap
|
|
311
483
|
|
|
312
484
|
raise TooManyOpenTickets, "#{requester.class}##{requester.id} already has #{current} open tickets " \
|
|
@@ -321,28 +493,47 @@ module SupportDesk
|
|
|
321
493
|
# from "I found this": they are the same ticket, and a very different
|
|
322
494
|
# thing to announce.
|
|
323
495
|
def insert_ticket!(requester:, desk:, node:, about:, via:, requester_role:, title:, metadata:,
|
|
324
|
-
cardinality_key:)
|
|
496
|
+
cardinality_key:, opened_by:, by_support: false, request: nil)
|
|
325
497
|
attempts = 0
|
|
326
498
|
begin
|
|
327
499
|
attempts += 1
|
|
328
|
-
|
|
500
|
+
# A unique-index loser must roll back a savepoint before querying
|
|
501
|
+
# the winner; PostgreSQL forbids reads in an aborted transaction.
|
|
502
|
+
ticket, opened = transaction(requires_new: true) do
|
|
329
503
|
ticket = create!(
|
|
330
504
|
desk: desk, requester: requester, requester_role: requester_role, subject: about,
|
|
331
505
|
topic: node, title: title.presence, reference: unique_reference, status: "open",
|
|
332
|
-
awaiting: "agent", priority: node.priority, opened_via: via.to_s,
|
|
506
|
+
awaiting: "agent", priority: node.priority, opened_via: via.to_s, opened_by: opened_by,
|
|
333
507
|
opened_at: Time.current, cardinality_key: cardinality_key, metadata: metadata
|
|
334
508
|
)
|
|
335
|
-
|
|
509
|
+
# The pair is symmetric; the host's `can_message?` policy is not.
|
|
510
|
+
# A conversation the DESK opens has to be asked about in that
|
|
511
|
+
# direction, or a host that lets support write to anyone and
|
|
512
|
+
# strangers write to nobody would refuse its own outreach.
|
|
513
|
+
conversation = if by_support
|
|
514
|
+
Chats::Conversation.direct_between!(desk, requester, about: ticket)
|
|
515
|
+
else
|
|
516
|
+
Chats::Conversation.direct_between!(requester, desk, about: ticket)
|
|
517
|
+
end
|
|
336
518
|
ticket.update!(conversation_id: conversation.id, waiting_since: ticket.opened_at)
|
|
519
|
+
# The agent who wrote first holds the case from its first
|
|
520
|
+
# committed state — silently. No `assign!`, so no "Lucía se ocupa
|
|
521
|
+
# de tu consulta" in a thread the requester never started, and no
|
|
522
|
+
# :ticket_assigned to page a team about their own message.
|
|
523
|
+
if by_support
|
|
524
|
+
Assignment.open!(ticket: ticket, agent: opened_by, by: opened_by, reason: :opened)
|
|
525
|
+
ticket.update!(assignee: opened_by)
|
|
526
|
+
end
|
|
337
527
|
# Through the same writer every other transition uses (`send`
|
|
338
528
|
# because it is private and we are the class, not the record),
|
|
339
529
|
# so opening a ticket reaches `ticket_transitioned` too.
|
|
340
|
-
opened = ticket.send(:record_transition!, :opened, actor:
|
|
341
|
-
{ "topic" => node.path, "via" => via.to_s
|
|
530
|
+
opened = ticket.send(:record_transition!, :opened, actor: opened_by) do
|
|
531
|
+
{ "topic" => node.path, "via" => via.to_s,
|
|
532
|
+
"assignee" => (SupportDesk.actor_key(opened_by) if by_support) }
|
|
342
533
|
end
|
|
343
534
|
[ ticket, opened ]
|
|
344
535
|
end
|
|
345
|
-
ticket.send(:publish_transition, opened, :opened,
|
|
536
|
+
ticket.send(:publish_transition, opened, :opened, opened_by, request)
|
|
346
537
|
[ ticket, true ]
|
|
347
538
|
rescue ActiveRecord::RecordNotUnique
|
|
348
539
|
existing = open_ticket_for(requester: requester, desk: desk, cardinality_key: cardinality_key)
|
|
@@ -362,15 +553,90 @@ module SupportDesk
|
|
|
362
553
|
raise Error, "couldn't generate a free ticket reference in #{REFERENCE_ATTEMPTS} attempts"
|
|
363
554
|
end
|
|
364
555
|
|
|
365
|
-
|
|
366
|
-
|
|
556
|
+
# The first words of a brand new case, inside the transaction that
|
|
557
|
+
# created it: the desk's opening line, the actual message, and the
|
|
558
|
+
# clocks — folded in on THIS instance, so the row that commits is
|
|
559
|
+
# already true and the caller needs no reload. chats' after-commit
|
|
560
|
+
# subscriber then finds the message already registered and does
|
|
561
|
+
# nothing.
|
|
562
|
+
def post_opening!(ticket, message, files:, by:, by_support:)
|
|
563
|
+
notice = post_opening_line!(ticket)
|
|
564
|
+
|
|
565
|
+
posted = if by_support
|
|
566
|
+
# Never the inbound "no message, hand the ticket back" shortcut: a
|
|
567
|
+
# desk that writes first with nothing to say is a chats validation
|
|
568
|
+
# error, and this whole transaction goes with it.
|
|
569
|
+
ticket.post_agent_message!(message, files: files, by: by)
|
|
570
|
+
elsif message.present? || files.present?
|
|
571
|
+
ticket.post_requester_message!(message, files: files)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
ticket.send(:record_registration!, posted) if posted
|
|
575
|
+
pin_opening_line!(ticket, notice, posted)
|
|
576
|
+
ticket
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def post_opening_line!(ticket)
|
|
580
|
+
line = ticket.desk_config.opening_line_for(ticket)
|
|
581
|
+
return nil if line.blank?
|
|
582
|
+
|
|
583
|
+
ticket.conversation.post_system_message!(line)
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# chats orders a transcript by (created_at, id), and inserting one row
|
|
587
|
+
# after another does NOT guarantee two different timestamps: frozen
|
|
588
|
+
# time in a test, a coarse column, a clock that doesn't move between
|
|
589
|
+
# two very fast inserts. Where ids are UUIDs there is then nothing
|
|
590
|
+
# useful to break the tie with, and the notice can sort BELOW the
|
|
591
|
+
# message it introduces.
|
|
592
|
+
#
|
|
593
|
+
# So the notice is pinned one database tick before that message, inside
|
|
594
|
+
# the same transaction, using the timestamp the message actually got.
|
|
595
|
+
# Only this new notice moves, never anybody's real message, and the
|
|
596
|
+
# human message still owns the conversation's last-message pointer, so
|
|
597
|
+
# nothing has to be recomputed afterwards.
|
|
598
|
+
def pin_opening_line!(ticket, notice, posted)
|
|
599
|
+
return if notice.nil?
|
|
600
|
+
|
|
601
|
+
anchor = posted&.created_at || ticket.opened_at
|
|
602
|
+
notice.update_columns(created_at: anchor - ordering_tick)
|
|
603
|
+
# When there IS a first message it owns the conversation's
|
|
604
|
+
# last-message pointer and nothing needs repairing. When there isn't,
|
|
605
|
+
# the notice is that pointer, and chats denormalised its timestamp
|
|
606
|
+
# before we moved it — so the inbox would sort this conversation by a
|
|
607
|
+
# moment its only message doesn't have.
|
|
608
|
+
ticket.conversation.recompute_last_message! if posted.nil?
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# One tick of the messages table's own timestamp column: the smallest
|
|
612
|
+
# difference this database will still store.
|
|
613
|
+
def ordering_tick
|
|
614
|
+
precision = Chats::Message.columns_hash["created_at"]&.precision || 6
|
|
615
|
+
(10**-precision).seconds
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Reuse is a reply, never a second opening. Both ways in — the case the
|
|
619
|
+
# pre-check found and the one this call lost the insert race to — come
|
|
620
|
+
# through here, so an existing case is answered under its row lock,
|
|
621
|
+
# under the desk's reply policy, with the console's authorization hook
|
|
622
|
+
# running before any of it.
|
|
623
|
+
def reply_into!(ticket, message, files:, by:, by_support:, request:, authorize_reuse:)
|
|
624
|
+
if by_support
|
|
625
|
+
ticket.send(:reply_under_lock!, message, by: by, files: files, request: request,
|
|
626
|
+
authorize: authorize_reuse)
|
|
627
|
+
else
|
|
628
|
+
ticket.with_lock(requires_new: true) do
|
|
629
|
+
authorize_reuse&.call(ticket)
|
|
630
|
+
# A requester opening the same case again with nothing to say is
|
|
631
|
+
# the old API's "hand it back": supported, and it writes nothing.
|
|
632
|
+
next if message.blank? && files.blank?
|
|
633
|
+
|
|
634
|
+
posted = ticket.post_requester_message!(message, files: files)
|
|
635
|
+
ticket.send(:record_registration!, posted)
|
|
636
|
+
end
|
|
637
|
+
end
|
|
367
638
|
|
|
368
|
-
ticket
|
|
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
|
|
639
|
+
ticket
|
|
374
640
|
end
|
|
375
641
|
end
|
|
376
642
|
|
|
@@ -385,17 +651,49 @@ module SupportDesk
|
|
|
385
651
|
# chats' context line for the conversation behind the ticket.
|
|
386
652
|
def chat_subject_label = label
|
|
387
653
|
|
|
388
|
-
# Whether chats should refuse new messages in this conversation
|
|
389
|
-
#
|
|
390
|
-
# :locked`
|
|
654
|
+
# Whether chats should refuse new messages in this conversation: because
|
|
655
|
+
# there is nobody to write to any more, or because the case is closed on
|
|
656
|
+
# a desk configured `closed_tickets: :locked` (the default lets a
|
|
657
|
+
# requester's reply reopen it instead).
|
|
658
|
+
#
|
|
659
|
+
# The first reason is a WRITE rule, not a screen rule: the transcript
|
|
660
|
+
# stays readable, the case stays in the queue, and nothing is deleted —
|
|
661
|
+
# only new messages stop. `open!` alone could not do this, because the
|
|
662
|
+
# account can be closed long after the case was opened.
|
|
391
663
|
def chat_locked?
|
|
392
|
-
closed? && desk_config.closed_tickets == :locked
|
|
664
|
+
requester_unavailable? || (closed? && desk_config.closed_tickets == :locked)
|
|
393
665
|
end
|
|
394
666
|
|
|
667
|
+
# The reasons in the same order the refusal takes them, so the notice
|
|
668
|
+
# under a composer and the error behind it never tell different stories.
|
|
395
669
|
def chat_locked_notice
|
|
670
|
+
return I18n.t("support_desk.thread.unavailable_notice") if requester_unavailable?
|
|
671
|
+
|
|
396
672
|
I18n.t("support_desk.thread.closed_notice")
|
|
397
673
|
end
|
|
398
674
|
|
|
675
|
+
# Whether the person this case belongs to can be written to at all right
|
|
676
|
+
# now: their record is gone, or `has_support_tickets if:` says no (a
|
|
677
|
+
# closed account, a ban).
|
|
678
|
+
#
|
|
679
|
+
# Read FRESH from the database on purpose — the requester this instance
|
|
680
|
+
# is holding may have been loaded before they closed their account, and a
|
|
681
|
+
# cached association is not evidence about now. It still doesn't
|
|
682
|
+
# serialize against the host's own closure transaction: an account closed
|
|
683
|
+
# between this read and the write gets one more message in.
|
|
684
|
+
def requester_unavailable?
|
|
685
|
+
return true if requester_type.blank? || requester_id.blank?
|
|
686
|
+
|
|
687
|
+
model = requester_type.safe_constantize
|
|
688
|
+
current = model&.uncached { model.find_by(id: requester_id) }
|
|
689
|
+
return true if current.nil?
|
|
690
|
+
# A requester class that never declared the macro (an import, a legacy
|
|
691
|
+
# row) has no opinion to honour, so it isn't "unavailable".
|
|
692
|
+
return false unless current.respond_to?(:support_requester?)
|
|
693
|
+
|
|
694
|
+
!current.support_requester?
|
|
695
|
+
end
|
|
696
|
+
|
|
399
697
|
def open? = status == "open"
|
|
400
698
|
def closed? = status == "closed"
|
|
401
699
|
def snoozed? = status == "snoozed"
|
|
@@ -403,6 +701,18 @@ module SupportDesk
|
|
|
403
701
|
def unassigned? = !assigned?
|
|
404
702
|
def reopened? = reopen_count.to_i.positive?
|
|
405
703
|
|
|
704
|
+
# Who started this conversation. Read from the stored identity rather
|
|
705
|
+
# than the association, so neither predicate loads a record to answer —
|
|
706
|
+
# and so a case whose opener has since been deleted still answers.
|
|
707
|
+
def opened_by_requester?
|
|
708
|
+
return true if opened_by_id.nil? && opened_by_type.nil?
|
|
709
|
+
|
|
710
|
+
opened_by_id.present? && opened_by_type == requester_type && opened_by_id.to_s == requester_id.to_s
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# NULL provenance is a legacy requester, never an automation opener.
|
|
714
|
+
def opened_by_support? = !opened_by_requester?
|
|
715
|
+
|
|
406
716
|
# True when the desk owes the next word.
|
|
407
717
|
def awaiting_reply? = awaiting == "agent"
|
|
408
718
|
def awaiting_requester? = awaiting == "requester"
|
|
@@ -441,8 +751,13 @@ module SupportDesk
|
|
|
441
751
|
waiting_for >= desk_config.reply_within
|
|
442
752
|
end
|
|
443
753
|
|
|
444
|
-
# How long the requester waited for a first human answer.
|
|
754
|
+
# How long the requester waited for a first human answer. Nil for a case
|
|
755
|
+
# the desk opened: nobody was waiting for it, and "how long from our own
|
|
756
|
+
# first word to our own first word" is not a service level. Metrics (0.4)
|
|
757
|
+
# is where that case gets a number, measured from the requester's first
|
|
758
|
+
# message.
|
|
445
759
|
def time_to_first_reply
|
|
760
|
+
return nil if opened_by_support?
|
|
446
761
|
return nil if first_agent_reply_at.nil? || opened_at.nil?
|
|
447
762
|
|
|
448
763
|
ActiveSupport::Duration.build((first_agent_reply_at - opened_at).to_i)
|
|
@@ -491,17 +806,8 @@ module SupportDesk
|
|
|
491
806
|
def reply!(body = nil, by: nil, files: [], request: nil)
|
|
492
807
|
actor = resolve_actor(by)
|
|
493
808
|
ensure_agent!(actor)
|
|
494
|
-
|
|
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
|
|
809
|
+
|
|
810
|
+
reply_under_lock!(body, by: actor, files: files, request: request)
|
|
505
811
|
end
|
|
506
812
|
|
|
507
813
|
# An internal note: in the timeline and the console, never in the
|
|
@@ -521,13 +827,14 @@ module SupportDesk
|
|
|
521
827
|
# handed it. Repeating an assignment to the current holder does nothing.
|
|
522
828
|
def assign!(to:, by: nil, reason: nil, note: nil, request: nil)
|
|
523
829
|
actor = resolve_actor(by)
|
|
830
|
+
ensure_agent!(actor)
|
|
524
831
|
ensure_assignable!(to)
|
|
525
|
-
raise InvalidTransition, "can't assign a closed ticket — reopen it first" if closed?
|
|
526
832
|
|
|
527
833
|
reason ||= to == actor ? :taken : :assigned
|
|
528
834
|
assignment = nil
|
|
529
835
|
|
|
530
836
|
event = write_transition!(:assigned, actor: actor, request: request) do
|
|
837
|
+
raise InvalidTransition, "can't assign a closed ticket — reopen it first" if closed?
|
|
531
838
|
next false if assigned_to?(to)
|
|
532
839
|
|
|
533
840
|
assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: reason, note: note)
|
|
@@ -545,18 +852,19 @@ module SupportDesk
|
|
|
545
852
|
# whoever picks it up. Hand-off notes are always internal.
|
|
546
853
|
def hand_off!(to:, note: nil, by: nil, request: nil)
|
|
547
854
|
actor = resolve_actor(by)
|
|
548
|
-
|
|
549
|
-
raise NotTheAssignee, "#{describe_actor(actor)} doesn't hold ticket #{reference} " \
|
|
550
|
-
"(#{assignee ? describe_actor(assignee) : "nobody"} does) — use assign! to override"
|
|
551
|
-
end
|
|
855
|
+
ensure_agent!(actor)
|
|
552
856
|
ensure_assignable!(to)
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
from = assignee
|
|
857
|
+
from = nil
|
|
556
858
|
assignment = nil
|
|
557
859
|
event = write_transition!(:handed_off, actor: actor, request: request) do
|
|
860
|
+
unless assigned_to?(actor)
|
|
861
|
+
raise NotTheAssignee, "#{describe_actor(actor)} doesn't hold ticket #{reference} " \
|
|
862
|
+
"(#{assignee ? describe_actor(assignee) : "nobody"} does) — use assign! to override"
|
|
863
|
+
end
|
|
864
|
+
raise InvalidTransition, "can't hand off a closed ticket" if closed?
|
|
558
865
|
next false if assigned_to?(to)
|
|
559
866
|
|
|
867
|
+
from = assignee
|
|
560
868
|
assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: :handed_off, note: note,
|
|
561
869
|
release_reason: :handed_off)
|
|
562
870
|
update!(assignee: to)
|
|
@@ -573,12 +881,12 @@ module SupportDesk
|
|
|
573
881
|
def release!(by: nil, reason: :released, request: nil)
|
|
574
882
|
actor = resolve_actor(by)
|
|
575
883
|
ensure_agent!(actor)
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
from = assignee
|
|
884
|
+
from = nil
|
|
579
885
|
event = write_transition!(:released, actor: actor, request: request) do
|
|
886
|
+
raise InvalidTransition, "can't release a closed ticket" if closed?
|
|
580
887
|
next false if unassigned?
|
|
581
888
|
|
|
889
|
+
from = assignee
|
|
582
890
|
assignments.open.each { |assignment| assignment.release!(reason: reason) }
|
|
583
891
|
update!(assignee: nil)
|
|
584
892
|
{ "from" => SupportDesk.actor_key(from), "reason" => reason.to_s }
|
|
@@ -625,7 +933,8 @@ module SupportDesk
|
|
|
625
933
|
# one update! hash reads the closed ticket's "none" and stores nil —
|
|
626
934
|
# a reopened case that no SLA scope can see.
|
|
627
935
|
assign_attributes(status: "open", closed_at: nil, closed_by: nil,
|
|
628
|
-
reopen_count: reopen_count.to_i + 1, awaiting: awaiting_from_clocks
|
|
936
|
+
reopen_count: reopen_count.to_i + 1, awaiting: awaiting_from_clocks,
|
|
937
|
+
cardinality_key: "reopened:#{id}")
|
|
629
938
|
self.waiting_since = waiting_since_from_clocks
|
|
630
939
|
save!
|
|
631
940
|
restore_assignment!(by: actor)
|
|
@@ -691,54 +1000,14 @@ module SupportDesk
|
|
|
691
1000
|
# double-counts, and safe to call by hand after importing a transcript.
|
|
692
1001
|
def register!(message)
|
|
693
1002
|
return self if registered?(message)
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
with_lock
|
|
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
|
|
1003
|
+
# System messages move nothing, however they arrive. chats never
|
|
1004
|
+
# delivers them here (Chats::Message#notify_host returns early for
|
|
1005
|
+
# them), so this guards direct calls and replayed imports — including
|
|
1006
|
+
# the opening line, which is posted inside the opening transaction and
|
|
1007
|
+
# must never be mistaken for somebody's first word.
|
|
1008
|
+
return self if role_of(message) == :system
|
|
1009
|
+
|
|
1010
|
+
with_lock(requires_new: true) { record_registration!(message) }
|
|
742
1011
|
self
|
|
743
1012
|
end
|
|
744
1013
|
|
|
@@ -792,7 +1061,10 @@ module SupportDesk
|
|
|
792
1061
|
if closed?
|
|
793
1062
|
actions << :reopen
|
|
794
1063
|
else
|
|
795
|
-
|
|
1064
|
+
# Nobody to write to is not the same as nothing to do: the transcript,
|
|
1065
|
+
# the notes and closing the case are all still here, and only the
|
|
1066
|
+
# thing that would speak to the requester is taken away.
|
|
1067
|
+
actions << :reply if may_reply?(agent) && !requester_unavailable?
|
|
796
1068
|
actions << :assign
|
|
797
1069
|
actions << :hand_off if assigned_to?(agent)
|
|
798
1070
|
actions << :release if assigned?
|
|
@@ -825,7 +1097,7 @@ module SupportDesk
|
|
|
825
1097
|
I18n.t("support_desk.notifications.title", desk: desk.name)
|
|
826
1098
|
end
|
|
827
1099
|
|
|
828
|
-
#
|
|
1100
|
+
# Case detail for an authenticated feed. Do not use for push previews.
|
|
829
1101
|
def notification_body = label
|
|
830
1102
|
|
|
831
1103
|
# A GDPR-friendly dump of the case as the requester experienced it:
|
|
@@ -857,6 +1129,13 @@ module SupportDesk
|
|
|
857
1129
|
requester.message!(conversation, body, files: files)
|
|
858
1130
|
end
|
|
859
1131
|
|
|
1132
|
+
# Post a message as the DESK, signed by the agent who wrote it — every
|
|
1133
|
+
# answer, and the desk's first word when it writes first. One place knows
|
|
1134
|
+
# "the desk sends, the human signs".
|
|
1135
|
+
def post_agent_message!(body, files: [], by:) # :nodoc:
|
|
1136
|
+
desk.message!(conversation, body, files: files, author: by)
|
|
1137
|
+
end
|
|
1138
|
+
|
|
860
1139
|
def inspect
|
|
861
1140
|
"#<SupportDesk::Ticket #{reference} #{topic&.path} #{label.to_s.inspect} #{status}" \
|
|
862
1141
|
"#{" → #{describe_actor(assignee)}" if assigned?}#{waiting_description}>"
|
|
@@ -895,10 +1174,7 @@ module SupportDesk
|
|
|
895
1174
|
|
|
896
1175
|
def ensure_agent!(actor)
|
|
897
1176
|
return if actor.is_a?(Symbol)
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
raise NotAnAgent, "#{describe_actor(actor)} is not a support agent — declare " \
|
|
901
|
-
"`acts_as_support_agent` on #{actor.class}"
|
|
1177
|
+
self.class.ensure_agent_record!(actor)
|
|
902
1178
|
end
|
|
903
1179
|
|
|
904
1180
|
def describe_actor(actor)
|
|
@@ -909,12 +1185,43 @@ module SupportDesk
|
|
|
909
1185
|
|
|
910
1186
|
# --- Transition plumbing -------------------------------------------------------
|
|
911
1187
|
|
|
1188
|
+
# The two reasons a case takes no more messages, in the order the notice
|
|
1189
|
+
# under the composer gives them: there is nobody to write to, and only
|
|
1190
|
+
# then the closed-and-locked case.
|
|
912
1191
|
def ensure_writable!
|
|
1192
|
+
if requester_unavailable?
|
|
1193
|
+
raise Locked, "ticket #{reference} can't be written to: #{requester_type}##{requester_id} is no " \
|
|
1194
|
+
"longer an eligible support requester. The case stays readable."
|
|
1195
|
+
end
|
|
913
1196
|
return unless chat_locked?
|
|
914
1197
|
|
|
915
1198
|
raise Locked, "ticket #{reference} is closed and this desk locks closed tickets — reopen it first"
|
|
916
1199
|
end
|
|
917
1200
|
|
|
1201
|
+
# Everything `reply!` does, under ONE lock and ONE savepoint: check that
|
|
1202
|
+
# the conversation still takes messages, apply the desk's reply policy,
|
|
1203
|
+
# post, and fold the message into the clocks before anything commits.
|
|
1204
|
+
#
|
|
1205
|
+
# The savepoint is not belt and braces: taking the ticket and announcing
|
|
1206
|
+
# it are part of answering, so a reply that raises (an empty body, a
|
|
1207
|
+
# locked conversation, a host policy) must not leave the agent holding a
|
|
1208
|
+
# case they never answered — not even when the caller rescues the raise
|
|
1209
|
+
# and commits its own transaction.
|
|
1210
|
+
#
|
|
1211
|
+
# `authorize:` is the console's hook (see Ticket.open_or_reply!): it runs
|
|
1212
|
+
# under this lock, before any policy side effect, and a raise there rolls
|
|
1213
|
+
# the whole thing back.
|
|
1214
|
+
def reply_under_lock!(body, by:, files:, request:, authorize: nil)
|
|
1215
|
+
with_lock(requires_new: true) do
|
|
1216
|
+
authorize&.call(self)
|
|
1217
|
+
ensure_writable!
|
|
1218
|
+
apply_reply_policy!(by, request: request)
|
|
1219
|
+
posted = post_agent_message!(body, files: files, by: by)
|
|
1220
|
+
record_registration!(posted)
|
|
1221
|
+
posted
|
|
1222
|
+
end
|
|
1223
|
+
end
|
|
1224
|
+
|
|
918
1225
|
def apply_reply_policy!(actor, request: nil)
|
|
919
1226
|
# A closed case has no seat to take: an agent adding one last word
|
|
920
1227
|
# posts it and the case stays closed. (A REQUESTER writing is what
|
|
@@ -992,6 +1299,66 @@ module SupportDesk
|
|
|
992
1299
|
|
|
993
1300
|
# --- Registration plumbing ------------------------------------------------------
|
|
994
1301
|
|
|
1302
|
+
# The half that writes, for callers who ALREADY hold the row: `reply!`
|
|
1303
|
+
# under its lock, and `Ticket.open!` while it still owns the uncommitted
|
|
1304
|
+
# row it just created. That is what keeps ONE method in charge of the
|
|
1305
|
+
# clocks — the opening message is folded in on the same instance, before
|
|
1306
|
+
# commit, so the row that lands is already true and nothing has to
|
|
1307
|
+
# reload.
|
|
1308
|
+
#
|
|
1309
|
+
# Everything is re-checked here rather than in the caller, because the
|
|
1310
|
+
# caller is not always the lock holder it thinks it is.
|
|
1311
|
+
def record_registration!(message) # :nodoc:
|
|
1312
|
+
return self if role_of(message) == :system
|
|
1313
|
+
# Re-check under the lock: the same message can reach us twice (a
|
|
1314
|
+
# redelivered event, a hand-written replay), and an SLA clock that
|
|
1315
|
+
# moves twice for one message is a lie.
|
|
1316
|
+
return self if registered?(message)
|
|
1317
|
+
|
|
1318
|
+
role = role_of(message)
|
|
1319
|
+
opening = opening_message?
|
|
1320
|
+
reopened = false
|
|
1321
|
+
reopen_event = nil
|
|
1322
|
+
|
|
1323
|
+
attributes = { last_registered_message_id: message.id }
|
|
1324
|
+
case role
|
|
1325
|
+
when :requester
|
|
1326
|
+
attributes[:last_requester_message_at] = message.created_at
|
|
1327
|
+
if closed? && message.created_at > closed_at && desk_config.closed_tickets == :reopen_on_reply
|
|
1328
|
+
attributes.merge!(status: "open", closed_at: nil, closed_by: nil,
|
|
1329
|
+
reopen_count: reopen_count.to_i + 1, cardinality_key: "reopened:#{id}")
|
|
1330
|
+
reopened = true
|
|
1331
|
+
end
|
|
1332
|
+
when :agent
|
|
1333
|
+
attributes[:last_agent_message_at] = message.created_at
|
|
1334
|
+
# A first REPLY answers something. An agent message with no earlier
|
|
1335
|
+
# requester message is the desk opening the conversation, or a word
|
|
1336
|
+
# into an empty case — neither is a reply, and a requester clock that
|
|
1337
|
+
# is LATER (an out-of-order replay) is not evidence of one either.
|
|
1338
|
+
if first_agent_reply_at.nil? && last_requester_message_at.present? &&
|
|
1339
|
+
last_requester_message_at <= message.created_at
|
|
1340
|
+
attributes[:first_agent_reply_at] = message.created_at
|
|
1341
|
+
end
|
|
1342
|
+
# A closed case owes nobody anything. An agent adding one last
|
|
1343
|
+
# word keeps the clocks honest without putting the case back in a
|
|
1344
|
+
# queue that `close!` just took it out of.
|
|
1345
|
+
end
|
|
1346
|
+
|
|
1347
|
+
assign_attributes(attributes)
|
|
1348
|
+
self.awaiting = closed? ? "none" : awaiting_from_clocks
|
|
1349
|
+
self.waiting_since = waiting_since_from_clocks
|
|
1350
|
+
save!
|
|
1351
|
+
|
|
1352
|
+
if reopened
|
|
1353
|
+
restore_assignment!(by: :system)
|
|
1354
|
+
reopen_event = record_transition!(:reopened, actor: requester) { { "via" => "requester_reply" } }
|
|
1355
|
+
end
|
|
1356
|
+
|
|
1357
|
+
publish_transition(reopen_event, :reopened, requester, nil) if reopen_event
|
|
1358
|
+
announce_registration(message, role: role, opening: opening, reopened: reopened)
|
|
1359
|
+
self
|
|
1360
|
+
end
|
|
1361
|
+
|
|
995
1362
|
# Whether this message is already folded in. The last-id check catches
|
|
996
1363
|
# the common redelivery; the clock check catches the rest, because a
|
|
997
1364
|
# REPLAY can arrive in any order and an older message must never rewind
|
|
@@ -1032,6 +1399,16 @@ module SupportDesk
|
|
|
1032
1399
|
type == record.class.polymorphic_name && id.to_s == record.id.to_s
|
|
1033
1400
|
end
|
|
1034
1401
|
|
|
1402
|
+
# Provenance is a record or it is nothing: half a polymorphic pair points
|
|
1403
|
+
# at a class with no row, or a row with no class, and every predicate and
|
|
1404
|
+
# scope over it would have to guess. (Legacy rows have NEITHER, which is
|
|
1405
|
+
# a shape we can read and `doctor` can report.)
|
|
1406
|
+
def opened_by_must_be_a_whole_record
|
|
1407
|
+
return if opened_by_type.nil? == opened_by_id.nil?
|
|
1408
|
+
|
|
1409
|
+
errors.add(:opened_by, "needs both a type and an id, or neither")
|
|
1410
|
+
end
|
|
1411
|
+
|
|
1035
1412
|
# What "one open ticket about this" means for this ticket, recomputed
|
|
1036
1413
|
# because the case now says it is about something else. Refusing a
|
|
1037
1414
|
# collision here is the point: silently keeping the old key leaves the
|
|
@@ -1089,7 +1466,13 @@ module SupportDesk
|
|
|
1089
1466
|
when :requester
|
|
1090
1467
|
SupportDesk.emit_after_commit(:requester_replied, self, message) unless opening
|
|
1091
1468
|
when :agent
|
|
1092
|
-
|
|
1469
|
+
# The desk's OWN first word announces nothing: it is the start of a
|
|
1470
|
+
# conversation the requester never asked for, not an answer to one,
|
|
1471
|
+
# and `:ticket_opened` has already said it. An agent's first message
|
|
1472
|
+
# into an old empty case the REQUESTER opened is a reply, which is
|
|
1473
|
+
# why this asks who opened the case and not just whether the clocks
|
|
1474
|
+
# are empty.
|
|
1475
|
+
SupportDesk.emit_after_commit(:agent_replied, self, message) unless opening && opened_by_support?
|
|
1093
1476
|
end
|
|
1094
1477
|
end
|
|
1095
1478
|
|