support_desk 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +105 -10
- data/Rakefile +16 -0
- data/app/controllers/support_desk/tickets_controller.rb +5 -3
- data/app/helpers/support_desk/engine_helper.rb +7 -1
- data/app/views/support_desk/console/tickets/_context_card.html.erb +5 -0
- data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +8 -0
- data/app/views/support_desk/console/tickets/index.html.erb +14 -2
- data/app/views/support_desk/console/tickets/new.html.erb +25 -0
- data/config/console_routes.rb +1 -1
- data/config/locales/support_desk.console.en.yml +26 -0
- data/config/locales/support_desk.console.es.yml +26 -0
- data/config/locales/support_desk.en.yml +3 -0
- data/config/locales/support_desk.es.yml +6 -0
- data/lib/generators/support_desk/console_generator.rb +1 -1
- data/lib/generators/support_desk/install_generator.rb +14 -0
- data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
- data/lib/generators/support_desk/templates/initializer.rb +30 -0
- data/lib/generators/support_desk/upgrade_generator.rb +56 -0
- data/lib/support_desk/configuration.rb +148 -2
- data/lib/support_desk/console.rb +347 -25
- data/lib/support_desk/console_engine.rb +2 -0
- data/lib/support_desk/console_routes.rb +20 -8
- data/lib/support_desk/context_card.rb +23 -0
- data/lib/support_desk/doctor.rb +51 -1
- data/lib/support_desk/errors.rb +5 -0
- data/lib/support_desk/macros.rb +28 -12
- data/lib/support_desk/models/assignment.rb +3 -1
- data/lib/support_desk/models/concerns/agent.rb +39 -4
- data/lib/support_desk/models/concerns/requester.rb +27 -12
- data/lib/support_desk/models/ticket.rb +464 -100
- data/lib/support_desk/summary.rb +1 -1
- data/lib/support_desk/test_helpers.rb +8 -3
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +4 -12
- data/lib/support_desk.rb +45 -0
- data/lib/tasks/support_desk.rake +27 -0
- metadata +7 -2
|
@@ -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
|
|
@@ -71,6 +77,7 @@ module SupportDesk
|
|
|
71
77
|
validates :awaiting, inclusion: { in: AWAITING_STATES }
|
|
72
78
|
validates :opened_via, inclusion: { in: CHANNELS }
|
|
73
79
|
validates :reference, presence: true
|
|
80
|
+
validate :opened_by_must_be_a_whole_record
|
|
74
81
|
|
|
75
82
|
# --- Scopes ---------------------------------------------------------------
|
|
76
83
|
|
|
@@ -88,6 +95,26 @@ module SupportDesk
|
|
|
88
95
|
scope :awaiting_reply, -> { where(awaiting: "agent") }
|
|
89
96
|
scope :awaiting_requester, -> { where(awaiting: "requester") }
|
|
90
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
|
+
|
|
91
118
|
# Waiting longer than +duration+ for whoever owes the next word.
|
|
92
119
|
scope :waiting_over, lambda { |duration|
|
|
93
120
|
not_closed.where.not(waiting_since: nil).where(waiting_since: ..duration.ago)
|
|
@@ -159,36 +186,83 @@ module SupportDesk
|
|
|
159
186
|
end
|
|
160
187
|
|
|
161
188
|
# Open a ticket and post its first message. Usually called as
|
|
162
|
-
# `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.
|
|
163
199
|
#
|
|
164
200
|
# Returns the existing open ticket when one already covers the same
|
|
165
201
|
# subject (or, for free-form tickets, the same topic) — posting the
|
|
166
|
-
# message into it, because somebody just typed it.
|
|
202
|
+
# message into it as an ordinary reply, because somebody just typed it.
|
|
167
203
|
def open!(requester:, message: nil, about: nil, topic: nil, files: [], via: :in_app,
|
|
168
|
-
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)
|
|
169
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
|
+
|
|
170
229
|
# The subject is checked BEFORE the topic: "this isn't supportable" is
|
|
171
230
|
# the useful error, and an unsupportable record has no topic to find.
|
|
172
231
|
validate_subject!(about, requester)
|
|
173
|
-
node = resolve_topic!(topic, about, desk, requester, about)
|
|
232
|
+
node = resolve_topic!(topic, about, desk, requester, about, by_support: by_support)
|
|
174
233
|
|
|
175
234
|
cardinality = cardinality_key_for(requester: requester, subject: about, topic: node)
|
|
176
235
|
existing = existing_for(requester: requester, desk: desk, subject: about, topic: node)
|
|
177
|
-
|
|
236
|
+
enforce_asking_limits!(requester, desk) unless existing || by_support
|
|
178
237
|
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
181
245
|
|
|
182
|
-
ticket = transaction do
|
|
183
246
|
created, inserted = insert_ticket!(
|
|
184
|
-
requester: requester, desk: desk, node: node, about: about, via: via,
|
|
185
|
-
requester_role: requester_role, title: title, metadata: metadata,
|
|
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
|
|
186
250
|
)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
190
265
|
end
|
|
191
|
-
ticket.reload
|
|
192
266
|
end
|
|
193
267
|
|
|
194
268
|
# A human-friendly, unguessable-enough reference: "T-AB12CD".
|
|
@@ -233,8 +307,78 @@ module SupportDesk
|
|
|
233
307
|
scope.newest_first.first
|
|
234
308
|
end
|
|
235
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
|
+
|
|
236
359
|
private
|
|
237
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
|
+
|
|
238
382
|
# "on this desk, and waiting longer than its own threshold" — with an
|
|
239
383
|
# upper bound when the caller wants the band between two thresholds
|
|
240
384
|
# (at risk, but not yet breached).
|
|
@@ -249,9 +393,14 @@ module SupportDesk
|
|
|
249
393
|
ceiling ? clause.and(arel_table[:waiting_since].gt(ceiling.ago)) : clause
|
|
250
394
|
end
|
|
251
395
|
|
|
252
|
-
def resolve_topic!(topic, about, desk, requester, subject)
|
|
396
|
+
def resolve_topic!(topic, about, desk, requester, subject, by_support: false)
|
|
253
397
|
node = locate_topic!(topic, about, desk)
|
|
254
|
-
|
|
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)
|
|
255
404
|
raise NotAllowed, "#{requester.class}##{requester.id} may not open a ticket under topic " \
|
|
256
405
|
"#{node.path.inspect} (its only: condition says no)"
|
|
257
406
|
end
|
|
@@ -299,12 +448,20 @@ module SupportDesk
|
|
|
299
448
|
not_closed.find_by(requester: requester, desk: desk, cardinality_key: cardinality_key)
|
|
300
449
|
end
|
|
301
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
|
+
|
|
302
459
|
def enforce_rate_limit!(requester, desk)
|
|
303
460
|
limit = desk.config.open_rate_limit
|
|
304
461
|
return if limit.nil?
|
|
305
462
|
|
|
306
463
|
window = Time.current - limit[:within].to_i
|
|
307
|
-
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
|
|
308
465
|
return if recent < limit[:to]
|
|
309
466
|
|
|
310
467
|
raise RateLimited, "#{requester.class}##{requester.id} has opened #{recent} tickets in the last " \
|
|
@@ -321,7 +478,7 @@ module SupportDesk
|
|
|
321
478
|
cap = desk.config.max_open_tickets
|
|
322
479
|
return if cap.nil?
|
|
323
480
|
|
|
324
|
-
current = not_closed.where(requester: requester, desk: desk).count
|
|
481
|
+
current = not_closed.opened_by_requester.where(requester: requester, desk: desk).count
|
|
325
482
|
return if current < cap
|
|
326
483
|
|
|
327
484
|
raise TooManyOpenTickets, "#{requester.class}##{requester.id} already has #{current} open tickets " \
|
|
@@ -336,7 +493,7 @@ module SupportDesk
|
|
|
336
493
|
# from "I found this": they are the same ticket, and a very different
|
|
337
494
|
# thing to announce.
|
|
338
495
|
def insert_ticket!(requester:, desk:, node:, about:, via:, requester_role:, title:, metadata:,
|
|
339
|
-
cardinality_key:)
|
|
496
|
+
cardinality_key:, opened_by:, by_support: false, request: nil)
|
|
340
497
|
attempts = 0
|
|
341
498
|
begin
|
|
342
499
|
attempts += 1
|
|
@@ -346,20 +503,37 @@ module SupportDesk
|
|
|
346
503
|
ticket = create!(
|
|
347
504
|
desk: desk, requester: requester, requester_role: requester_role, subject: about,
|
|
348
505
|
topic: node, title: title.presence, reference: unique_reference, status: "open",
|
|
349
|
-
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,
|
|
350
507
|
opened_at: Time.current, cardinality_key: cardinality_key, metadata: metadata
|
|
351
508
|
)
|
|
352
|
-
|
|
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
|
|
353
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
|
|
354
527
|
# Through the same writer every other transition uses (`send`
|
|
355
528
|
# because it is private and we are the class, not the record),
|
|
356
529
|
# so opening a ticket reaches `ticket_transitioned` too.
|
|
357
|
-
opened = ticket.send(:record_transition!, :opened, actor:
|
|
358
|
-
{ "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) }
|
|
359
533
|
end
|
|
360
534
|
[ ticket, opened ]
|
|
361
535
|
end
|
|
362
|
-
ticket.send(:publish_transition, opened, :opened,
|
|
536
|
+
ticket.send(:publish_transition, opened, :opened, opened_by, request)
|
|
363
537
|
[ ticket, true ]
|
|
364
538
|
rescue ActiveRecord::RecordNotUnique
|
|
365
539
|
existing = open_ticket_for(requester: requester, desk: desk, cardinality_key: cardinality_key)
|
|
@@ -379,15 +553,90 @@ module SupportDesk
|
|
|
379
553
|
raise Error, "couldn't generate a free ticket reference in #{REFERENCE_ATTEMPTS} attempts"
|
|
380
554
|
end
|
|
381
555
|
|
|
382
|
-
|
|
383
|
-
|
|
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?
|
|
384
582
|
|
|
385
|
-
ticket.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
638
|
+
|
|
639
|
+
ticket
|
|
391
640
|
end
|
|
392
641
|
end
|
|
393
642
|
|
|
@@ -402,17 +651,49 @@ module SupportDesk
|
|
|
402
651
|
# chats' context line for the conversation behind the ticket.
|
|
403
652
|
def chat_subject_label = label
|
|
404
653
|
|
|
405
|
-
# Whether chats should refuse new messages in this conversation
|
|
406
|
-
#
|
|
407
|
-
# :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.
|
|
408
663
|
def chat_locked?
|
|
409
|
-
closed? && desk_config.closed_tickets == :locked
|
|
664
|
+
requester_unavailable? || (closed? && desk_config.closed_tickets == :locked)
|
|
410
665
|
end
|
|
411
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.
|
|
412
669
|
def chat_locked_notice
|
|
670
|
+
return I18n.t("support_desk.thread.unavailable_notice") if requester_unavailable?
|
|
671
|
+
|
|
413
672
|
I18n.t("support_desk.thread.closed_notice")
|
|
414
673
|
end
|
|
415
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
|
+
|
|
416
697
|
def open? = status == "open"
|
|
417
698
|
def closed? = status == "closed"
|
|
418
699
|
def snoozed? = status == "snoozed"
|
|
@@ -420,6 +701,18 @@ module SupportDesk
|
|
|
420
701
|
def unassigned? = !assigned?
|
|
421
702
|
def reopened? = reopen_count.to_i.positive?
|
|
422
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
|
+
|
|
423
716
|
# True when the desk owes the next word.
|
|
424
717
|
def awaiting_reply? = awaiting == "agent"
|
|
425
718
|
def awaiting_requester? = awaiting == "requester"
|
|
@@ -458,8 +751,13 @@ module SupportDesk
|
|
|
458
751
|
waiting_for >= desk_config.reply_within
|
|
459
752
|
end
|
|
460
753
|
|
|
461
|
-
# 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.
|
|
462
759
|
def time_to_first_reply
|
|
760
|
+
return nil if opened_by_support?
|
|
463
761
|
return nil if first_agent_reply_at.nil? || opened_at.nil?
|
|
464
762
|
|
|
465
763
|
ActiveSupport::Duration.build((first_agent_reply_at - opened_at).to_i)
|
|
@@ -509,16 +807,7 @@ module SupportDesk
|
|
|
509
807
|
actor = resolve_actor(by)
|
|
510
808
|
ensure_agent!(actor)
|
|
511
809
|
|
|
512
|
-
|
|
513
|
-
# part of answering: a reply that raises (an empty body, a locked
|
|
514
|
-
# conversation, a rate limit) must not leave the agent holding a case
|
|
515
|
-
# they never answered, or "Lucía se ocupa de tu consulta" sitting in
|
|
516
|
-
# the requester's thread with no reply under it.
|
|
517
|
-
with_lock do
|
|
518
|
-
ensure_writable!
|
|
519
|
-
apply_reply_policy!(actor, request: request)
|
|
520
|
-
desk.message!(conversation, body, files: files, author: actor)
|
|
521
|
-
end
|
|
810
|
+
reply_under_lock!(body, by: actor, files: files, request: request)
|
|
522
811
|
end
|
|
523
812
|
|
|
524
813
|
# An internal note: in the timeline and the console, never in the
|
|
@@ -711,53 +1000,14 @@ module SupportDesk
|
|
|
711
1000
|
# double-counts, and safe to call by hand after importing a transcript.
|
|
712
1001
|
def register!(message)
|
|
713
1002
|
return self if registered?(message)
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
with_lock
|
|
722
|
-
# Re-check under the lock: the same message can reach us twice (a
|
|
723
|
-
# redelivered event, a hand-written replay), and an SLA clock that
|
|
724
|
-
# moves twice for one message is a lie.
|
|
725
|
-
next if registered?(message)
|
|
726
|
-
|
|
727
|
-
role = role_of(message)
|
|
728
|
-
opening = opening_message?
|
|
729
|
-
applied = true
|
|
730
|
-
|
|
731
|
-
attributes = { last_registered_message_id: message.id }
|
|
732
|
-
case role
|
|
733
|
-
when :requester
|
|
734
|
-
attributes[:last_requester_message_at] = message.created_at
|
|
735
|
-
if closed? && message.created_at > closed_at && desk_config.closed_tickets == :reopen_on_reply
|
|
736
|
-
attributes.merge!(status: "open", closed_at: nil, closed_by: nil,
|
|
737
|
-
reopen_count: reopen_count.to_i + 1, cardinality_key: "reopened:#{id}")
|
|
738
|
-
reopened = true
|
|
739
|
-
end
|
|
740
|
-
when :agent
|
|
741
|
-
attributes[:last_agent_message_at] = message.created_at
|
|
742
|
-
attributes[:first_agent_reply_at] = message.created_at if first_agent_reply_at.nil?
|
|
743
|
-
# A closed case owes nobody anything. An agent adding one last
|
|
744
|
-
# word keeps the clocks honest without putting the case back in a
|
|
745
|
-
# queue that `close!` just took it out of.
|
|
746
|
-
end
|
|
747
|
-
|
|
748
|
-
assign_attributes(attributes)
|
|
749
|
-
self.awaiting = closed? ? "none" : awaiting_from_clocks
|
|
750
|
-
self.waiting_since = waiting_since_from_clocks
|
|
751
|
-
save!
|
|
752
|
-
|
|
753
|
-
if reopened
|
|
754
|
-
restore_assignment!(by: :system)
|
|
755
|
-
reopen_event = record_transition!(:reopened, actor: requester) { { "via" => "requester_reply" } }
|
|
756
|
-
end
|
|
757
|
-
end
|
|
758
|
-
|
|
759
|
-
publish_transition(reopen_event, :reopened, requester, nil) if reopen_event
|
|
760
|
-
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) }
|
|
761
1011
|
self
|
|
762
1012
|
end
|
|
763
1013
|
|
|
@@ -811,7 +1061,10 @@ module SupportDesk
|
|
|
811
1061
|
if closed?
|
|
812
1062
|
actions << :reopen
|
|
813
1063
|
else
|
|
814
|
-
|
|
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?
|
|
815
1068
|
actions << :assign
|
|
816
1069
|
actions << :hand_off if assigned_to?(agent)
|
|
817
1070
|
actions << :release if assigned?
|
|
@@ -876,6 +1129,13 @@ module SupportDesk
|
|
|
876
1129
|
requester.message!(conversation, body, files: files)
|
|
877
1130
|
end
|
|
878
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
|
+
|
|
879
1139
|
def inspect
|
|
880
1140
|
"#<SupportDesk::Ticket #{reference} #{topic&.path} #{label.to_s.inspect} #{status}" \
|
|
881
1141
|
"#{" → #{describe_actor(assignee)}" if assigned?}#{waiting_description}>"
|
|
@@ -914,10 +1174,7 @@ module SupportDesk
|
|
|
914
1174
|
|
|
915
1175
|
def ensure_agent!(actor)
|
|
916
1176
|
return if actor.is_a?(Symbol)
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
raise NotAnAgent, "#{describe_actor(actor)} is not a support agent — declare " \
|
|
920
|
-
"`acts_as_support_agent` on #{actor.class}"
|
|
1177
|
+
self.class.ensure_agent_record!(actor)
|
|
921
1178
|
end
|
|
922
1179
|
|
|
923
1180
|
def describe_actor(actor)
|
|
@@ -928,12 +1185,43 @@ module SupportDesk
|
|
|
928
1185
|
|
|
929
1186
|
# --- Transition plumbing -------------------------------------------------------
|
|
930
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.
|
|
931
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
|
|
932
1196
|
return unless chat_locked?
|
|
933
1197
|
|
|
934
1198
|
raise Locked, "ticket #{reference} is closed and this desk locks closed tickets — reopen it first"
|
|
935
1199
|
end
|
|
936
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
|
+
|
|
937
1225
|
def apply_reply_policy!(actor, request: nil)
|
|
938
1226
|
# A closed case has no seat to take: an agent adding one last word
|
|
939
1227
|
# posts it and the case stays closed. (A REQUESTER writing is what
|
|
@@ -1011,6 +1299,66 @@ module SupportDesk
|
|
|
1011
1299
|
|
|
1012
1300
|
# --- Registration plumbing ------------------------------------------------------
|
|
1013
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
|
+
|
|
1014
1362
|
# Whether this message is already folded in. The last-id check catches
|
|
1015
1363
|
# the common redelivery; the clock check catches the rest, because a
|
|
1016
1364
|
# REPLAY can arrive in any order and an older message must never rewind
|
|
@@ -1051,6 +1399,16 @@ module SupportDesk
|
|
|
1051
1399
|
type == record.class.polymorphic_name && id.to_s == record.id.to_s
|
|
1052
1400
|
end
|
|
1053
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
|
+
|
|
1054
1412
|
# What "one open ticket about this" means for this ticket, recomputed
|
|
1055
1413
|
# because the case now says it is about something else. Refusing a
|
|
1056
1414
|
# collision here is the point: silently keeping the old key leaves the
|
|
@@ -1108,7 +1466,13 @@ module SupportDesk
|
|
|
1108
1466
|
when :requester
|
|
1109
1467
|
SupportDesk.emit_after_commit(:requester_replied, self, message) unless opening
|
|
1110
1468
|
when :agent
|
|
1111
|
-
|
|
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?
|
|
1112
1476
|
end
|
|
1113
1477
|
end
|
|
1114
1478
|
|