support_desk 0.1.3 → 0.3.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 +126 -0
- data/README.md +1101 -22
- data/Rakefile +16 -0
- data/app/assets/stylesheets/support_desk.css +10 -0
- data/app/controllers/support_desk/tickets_controller.rb +28 -4
- data/app/helpers/support_desk/engine_helper.rb +23 -1
- data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
- data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +33 -0
- data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
- 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 +28 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
- 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/console/tickets/show.html.erb +5 -0
- data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
- data/config/console_routes.rb +1 -1
- data/config/locales/support_desk.console.en.yml +89 -0
- data/config/locales/support_desk.console.es.yml +91 -0
- data/config/locales/support_desk.en.yml +18 -0
- data/config/locales/support_desk.es.yml +29 -0
- data/config/routes.rb +7 -1
- data/lib/generators/support_desk/assistant_generator.rb +193 -0
- data/lib/generators/support_desk/console_generator.rb +1 -1
- data/lib/generators/support_desk/install_generator.rb +26 -0
- data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
- data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
- data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
- data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
- data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
- data/lib/generators/support_desk/templates/initializer.rb +63 -0
- data/lib/generators/support_desk/upgrade_generator.rb +66 -0
- data/lib/support_desk/assistant_policy.rb +213 -0
- data/lib/support_desk/brief.rb +283 -0
- data/lib/support_desk/configuration.rb +699 -3
- data/lib/support_desk/console.rb +634 -30
- 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 +33 -1
- data/lib/support_desk/doctor.rb +195 -2
- data/lib/support_desk/engine.rb +10 -0
- data/lib/support_desk/errors.rb +42 -0
- data/lib/support_desk/events.rb +12 -5
- data/lib/support_desk/macros.rb +41 -12
- data/lib/support_desk/models/assignment.rb +3 -1
- data/lib/support_desk/models/assistant.rb +153 -0
- data/lib/support_desk/models/concerns/agent.rb +39 -4
- data/lib/support_desk/models/concerns/requester.rb +45 -12
- data/lib/support_desk/models/desk.rb +26 -3
- data/lib/support_desk/models/draft.rb +266 -0
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/ticket/assistance.rb +675 -0
- data/lib/support_desk/models/ticket.rb +781 -120
- data/lib/support_desk/outcome.rb +38 -0
- data/lib/support_desk/queue.rb +41 -5
- data/lib/support_desk/summary.rb +1 -1
- data/lib/support_desk/test_helpers.rb +160 -3
- data/lib/support_desk/timeline.rb +40 -11
- data/lib/support_desk/topic.rb +22 -0
- data/lib/support_desk/topic_tree.rb +7 -1
- data/lib/support_desk/transcript.rb +237 -0
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +4 -12
- data/lib/support_desk.rb +153 -0
- data/lib/tasks/support_desk.rake +73 -0
- metadata +35 -8
|
@@ -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,6 +61,12 @@ 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
|
|
|
64
|
+
# The assistant surface — the turn, the policy gates, her two verbs and
|
|
65
|
+
# the two exits — in its own file. The GATES inside the verbs below stay
|
|
66
|
+
# here, next to the verbs they guard: a reader looking at `close!` has to
|
|
67
|
+
# see what stops a machine closing a case.
|
|
68
|
+
include Assistance
|
|
69
|
+
|
|
58
70
|
# Persist only the path. Resolving behavior needs this ticket's desk;
|
|
59
71
|
# an attribute caster has no record context and cannot choose the tree.
|
|
60
72
|
attribute :topic, Topic::Type.new
|
|
@@ -71,6 +83,7 @@ module SupportDesk
|
|
|
71
83
|
validates :awaiting, inclusion: { in: AWAITING_STATES }
|
|
72
84
|
validates :opened_via, inclusion: { in: CHANNELS }
|
|
73
85
|
validates :reference, presence: true
|
|
86
|
+
validate :opened_by_must_be_a_whole_record
|
|
74
87
|
|
|
75
88
|
# --- Scopes ---------------------------------------------------------------
|
|
76
89
|
|
|
@@ -88,6 +101,26 @@ module SupportDesk
|
|
|
88
101
|
scope :awaiting_reply, -> { where(awaiting: "agent") }
|
|
89
102
|
scope :awaiting_requester, -> { where(awaiting: "requester") }
|
|
90
103
|
|
|
104
|
+
# The two halves of every case: the requester asked, or the desk wrote
|
|
105
|
+
# first. They partition the table, NULL provenance included — a 0.1 row
|
|
106
|
+
# nobody backfilled reads as "not the requester", which is exactly what
|
|
107
|
+
# `doctor` wants somebody to come and look at.
|
|
108
|
+
#
|
|
109
|
+
# Built lazily, in a method rather than a constant: an Arel comparison
|
|
110
|
+
# evaluated at class definition would consult the schema before
|
|
111
|
+
# `db:migrate` had added the columns it names.
|
|
112
|
+
def self.opened_by_requester_condition # :nodoc:
|
|
113
|
+
identity = arel_table[:opened_by_type].eq(arel_table[:requester_type])
|
|
114
|
+
.and(arel_table[:opened_by_id].eq(arel_table[:requester_id]))
|
|
115
|
+
# Only 0.1 writes a NULL pair: automation openers are not supported.
|
|
116
|
+
# Keep those inbound cases correct while the catch-up backfill runs.
|
|
117
|
+
legacy = arel_table[:opened_by_type].eq(nil).and(arel_table[:opened_by_id].eq(nil))
|
|
118
|
+
identity.or(legacy)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
scope :opened_by_requester, -> { where(opened_by_requester_condition) }
|
|
122
|
+
scope :opened_by_support, -> { where.not(opened_by_requester_condition) }
|
|
123
|
+
|
|
91
124
|
# Waiting longer than +duration+ for whoever owes the next word.
|
|
92
125
|
scope :waiting_over, lambda { |duration|
|
|
93
126
|
not_closed.where.not(waiting_since: nil).where(waiting_since: ..duration.ago)
|
|
@@ -159,36 +192,85 @@ module SupportDesk
|
|
|
159
192
|
end
|
|
160
193
|
|
|
161
194
|
# Open a ticket and post its first message. Usually called as
|
|
162
|
-
# `requester.ask_support!(…)
|
|
195
|
+
# `requester.ask_support!(…)` — or, when the desk writes first, as
|
|
196
|
+
# `agent.open_support_conversation_with!(…)`. This is the seam under
|
|
197
|
+
# both, and the one channels and jobs use.
|
|
198
|
+
#
|
|
199
|
+
# `by:` is whoever is opening it and defaults to the requester; an
|
|
200
|
+
# explicit nil means the same thing. An AGENT there is the desk writing
|
|
201
|
+
# first: the message is the desk's, signed by them, they hold the case
|
|
202
|
+
# from its first committed state, nobody is told "se ocupa de tu
|
|
203
|
+
# consulta", and the requester's asking limits don't apply — those
|
|
204
|
+
# limit asking, not being asked.
|
|
163
205
|
#
|
|
164
206
|
# Returns the existing open ticket when one already covers the same
|
|
165
207
|
# subject (or, for free-form tickets, the same topic) — posting the
|
|
166
|
-
# message into it, because somebody just typed it.
|
|
208
|
+
# message into it as an ordinary reply, because somebody just typed it.
|
|
167
209
|
def open!(requester:, message: nil, about: nil, topic: nil, files: [], via: :in_app,
|
|
168
|
-
desk: nil, requester_role: nil, title: nil, metadata: {})
|
|
210
|
+
desk: nil, requester_role: nil, title: nil, metadata: {}, by: nil, request: nil)
|
|
211
|
+
open_or_reply!(requester: requester, message: message, about: about, topic: topic, files: files,
|
|
212
|
+
via: via, desk: desk, requester_role: requester_role, title: title,
|
|
213
|
+
metadata: metadata, by: by, request: request)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# `open!`, plus the console's authorization seam. `authorize_reuse` is
|
|
217
|
+
# called with the case this turned out to be a reply INTO — under its
|
|
218
|
+
# row lock, before any policy side effect — so a host that allows
|
|
219
|
+
# writing to somebody but not answering that particular case refuses
|
|
220
|
+
# before anything is written, and a raise there takes the whole
|
|
221
|
+
# operation with it. Everything else is `open!`; that is the method to
|
|
222
|
+
# call.
|
|
223
|
+
def open_or_reply!(requester:, message: nil, about: nil, topic: nil, files: [], via: :in_app,
|
|
224
|
+
desk: nil, requester_role: nil, title: nil, metadata: {}, by: nil, request: nil,
|
|
225
|
+
authorize_reuse: nil) # :nodoc:
|
|
226
|
+
ensure_requester!(requester)
|
|
169
227
|
desk ||= SupportDesk.desk
|
|
228
|
+
opener = by.nil? ? requester : by
|
|
229
|
+
# By persisted identity, never by ambient state: who asked is not
|
|
230
|
+
# something to infer from Current.actor, and an agent passed as their
|
|
231
|
+
# own requester is asking for help, not writing to themselves.
|
|
232
|
+
by_support = !same_actor?(opener, requester)
|
|
233
|
+
ensure_opener!(opener, desk) if by_support
|
|
234
|
+
|
|
170
235
|
# The subject is checked BEFORE the topic: "this isn't supportable" is
|
|
171
236
|
# the useful error, and an unsupportable record has no topic to find.
|
|
172
237
|
validate_subject!(about, requester)
|
|
173
|
-
node = resolve_topic!(topic, about, desk, requester, about)
|
|
238
|
+
node = resolve_topic!(topic, about, desk, requester, about, by_support: by_support)
|
|
239
|
+
# The topic is what caps her, so the cap is checked once it is known.
|
|
240
|
+
ensure_assistant_may_open!(opener, requester, desk, node) if by_support && SupportDesk.ai_actor?(opener)
|
|
174
241
|
|
|
175
242
|
cardinality = cardinality_key_for(requester: requester, subject: about, topic: node)
|
|
176
243
|
existing = existing_for(requester: requester, desk: desk, subject: about, topic: node)
|
|
177
|
-
|
|
244
|
+
enforce_asking_limits!(requester, desk) unless existing || by_support
|
|
178
245
|
|
|
179
|
-
|
|
180
|
-
|
|
246
|
+
# ONE savepoint around the WHOLE operation, reuse included. A joined
|
|
247
|
+
# transaction is not enough: a caller who rescues our failure and
|
|
248
|
+
# commits its own would keep the ticket, the seat and the conversation
|
|
249
|
+
# and lose only the message that was supposed to justify them.
|
|
250
|
+
transaction(requires_new: true) do
|
|
251
|
+
next reply_into!(existing, message, files: files, by: opener, by_support: by_support,
|
|
252
|
+
request: request, authorize_reuse: authorize_reuse) if existing
|
|
181
253
|
|
|
182
|
-
ticket = transaction do
|
|
183
254
|
created, inserted = insert_ticket!(
|
|
184
|
-
requester: requester, desk: desk, node: node, about: about, via: via,
|
|
185
|
-
requester_role: requester_role, title: title, metadata: metadata,
|
|
255
|
+
requester: requester, desk: desk, node: node, about: about, via: via, opened_by: opener,
|
|
256
|
+
by_support: by_support, requester_role: requester_role, title: title, metadata: metadata,
|
|
257
|
+
cardinality_key: cardinality, request: request
|
|
186
258
|
)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
259
|
+
|
|
260
|
+
# `inserted` is the only thing that knows whether THIS call opened
|
|
261
|
+
# the case. Not the assignment, not the message count, not
|
|
262
|
+
# `previously_new_record?` — the update two lines down resets it.
|
|
263
|
+
if inserted
|
|
264
|
+
post_opening!(created, message, files: files, by: opener, by_support: by_support)
|
|
265
|
+
SupportDesk.emit_after_commit(:ticket_opened, created)
|
|
266
|
+
created
|
|
267
|
+
else
|
|
268
|
+
# Somebody else's INSERT won by a millisecond. Theirs is the case
|
|
269
|
+
# that exists, so this is a reply into it, policy and all.
|
|
270
|
+
reply_into!(created, message, files: files, by: opener, by_support: by_support,
|
|
271
|
+
request: request, authorize_reuse: authorize_reuse)
|
|
272
|
+
end
|
|
190
273
|
end
|
|
191
|
-
ticket.reload
|
|
192
274
|
end
|
|
193
275
|
|
|
194
276
|
# A human-friendly, unguessable-enough reference: "T-AB12CD".
|
|
@@ -233,8 +315,109 @@ module SupportDesk
|
|
|
233
315
|
scope.newest_first.first
|
|
234
316
|
end
|
|
235
317
|
|
|
318
|
+
# The record on the requester side of every operation: saved, declared
|
|
319
|
+
# with `has_support_tickets`, and eligible RIGHT NOW.
|
|
320
|
+
#
|
|
321
|
+
# Eligibility is re-read rather than taken from the record in hand: the
|
|
322
|
+
# instance a caller is holding may have been loaded before the account
|
|
323
|
+
# was closed, and "not yours" and "not eligible" must look the same from
|
|
324
|
+
# outside. (It does not lock the host's closure transaction — see
|
|
325
|
+
# #requester_unavailable?.)
|
|
326
|
+
def ensure_requester!(record) # :nodoc:
|
|
327
|
+
unless record.respond_to?(:support_requester?)
|
|
328
|
+
raise NotARequester, "#{describe_record(record)} can't ask for support or be written to — " \
|
|
329
|
+
"declare `has_support_tickets` on #{record.class}"
|
|
330
|
+
end
|
|
331
|
+
unless record.persisted?
|
|
332
|
+
raise NotARequester, "an unsaved #{record.class} can't ask for support — save it first"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
current = record.class.uncached { record.class.find_by(id: record.id) }
|
|
336
|
+
return if current&.support_requester?
|
|
337
|
+
|
|
338
|
+
raise NotARequester, "#{record.class}##{record.id} can't ask for support or be written to right now " \
|
|
339
|
+
"(`has_support_tickets if:` says no, or the record is gone)"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# A loaded instance may predate revocation or deletion. Check the row,
|
|
343
|
+
# just as requester eligibility does; this does not serialize revocation.
|
|
344
|
+
def ensure_agent_record!(record) # :nodoc:
|
|
345
|
+
if record.respond_to?(:persisted?) && record.persisted? && record.respond_to?(:support_agent?)
|
|
346
|
+
current = record.class.uncached { record.class.find_by(id: record.id) }
|
|
347
|
+
return if current&.support_agent?
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
raise NotAnAgent, "#{record.class} is not a currently eligible, persisted support agent — " \
|
|
351
|
+
"declare `acts_as_support_agent` and check its if: condition"
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Whether two actors are the same record. Not `==`: either side can be
|
|
355
|
+
# nil, a Symbol or an unsaved record, and all of those must answer "no"
|
|
356
|
+
# rather than raise or match on a nil id. STI subclasses compare by
|
|
357
|
+
# their base name, because that is the identity the polymorphic columns
|
|
358
|
+
# store — and two rows in one table can't share an id anyway.
|
|
359
|
+
def same_actor?(one, other) # :nodoc:
|
|
360
|
+
return false if one.nil? || other.nil? || one.is_a?(Symbol) || other.is_a?(Symbol)
|
|
361
|
+
return false unless one.respond_to?(:persisted?) && other.respond_to?(:persisted?)
|
|
362
|
+
return false unless one.persisted? && other.persisted?
|
|
363
|
+
|
|
364
|
+
one.class.polymorphic_name == other.class.polymorphic_name && one.id.to_s == other.id.to_s
|
|
365
|
+
end
|
|
366
|
+
|
|
236
367
|
private
|
|
237
368
|
|
|
369
|
+
# Who may speak for the desk: a saved, currently eligible agent.
|
|
370
|
+
# Automation (`by: :system`) is refused by name rather than by
|
|
371
|
+
# NoMethodError three frames in — it is deferred work, not a typo (see
|
|
372
|
+
# docs/12-open-questions.md Q17).
|
|
373
|
+
def ensure_opener!(opener, desk)
|
|
374
|
+
if opener.is_a?(Symbol)
|
|
375
|
+
raise NotAnAgent, "can't open a ticket as #{opener.inspect} — a case is opened by somebody who can " \
|
|
376
|
+
"answer it, and automation openers aren't supported yet; pass an agent record"
|
|
377
|
+
end
|
|
378
|
+
unless opener.respond_to?(:persisted?) && opener.persisted?
|
|
379
|
+
raise NotAnAgent, "can't open a ticket as an unsaved #{opener.class} — save the agent first"
|
|
380
|
+
end
|
|
381
|
+
ensure_agent_record!(opener)
|
|
382
|
+
return unless SupportDesk.ai_actor?(opener)
|
|
383
|
+
|
|
384
|
+
# Writing to somebody who never wrote to you is the one thing an
|
|
385
|
+
# assistant does that nobody asked for, so it takes its own
|
|
386
|
+
# permission on top of everything else.
|
|
387
|
+
unless opener.is_a?(SupportDesk::Assistant) && same_actor?(opener, desk.assistant)
|
|
388
|
+
raise NotAnAssistant,
|
|
389
|
+
"only desk #{desk.key}'s own assistant can open a case, and only when she may"
|
|
390
|
+
end
|
|
391
|
+
unless opener.may_open_conversations?
|
|
392
|
+
raise AssistantNotAllowed.new(nil, verb: :open,
|
|
393
|
+
message: "#{opener.key} may not open conversations — set " \
|
|
394
|
+
"`may_open_conversations = true` if that is the intent")
|
|
395
|
+
end
|
|
396
|
+
return if AssistantPolicy::RANK.fetch(opener.autonomy) >= AssistantPolicy::RANK.fetch(:reply)
|
|
397
|
+
|
|
398
|
+
raise AssistantNotAllowed.new(nil, verb: :open,
|
|
399
|
+
message: "#{opener.key} works at #{opener.autonomy}: opening a " \
|
|
400
|
+
"case means speaking first, which takes :reply")
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# The topic's cap and the host's `cap` block, asked of the case this is
|
|
404
|
+
# ABOUT to be. Her per-case budget is not checked here: a brand new
|
|
405
|
+
# case has spent none of it, so the only way it could refuse is
|
|
406
|
+
# `max_turns 0`, and the policy below covers her level anyway.
|
|
407
|
+
def ensure_assistant_may_open!(opener, requester, desk, node)
|
|
408
|
+
candidate = new(desk: desk, requester: requester, topic: node)
|
|
409
|
+
policy = AssistantPolicy.for(candidate, opener)
|
|
410
|
+
return if policy.may_reply?
|
|
411
|
+
|
|
412
|
+
raise AssistantNotAllowed.new(policy, verb: :open)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def describe_record(record)
|
|
416
|
+
return record.inspect if record.nil? || record.is_a?(Symbol)
|
|
417
|
+
|
|
418
|
+
"#{record.class}##{record.id}"
|
|
419
|
+
end
|
|
420
|
+
|
|
238
421
|
# "on this desk, and waiting longer than its own threshold" — with an
|
|
239
422
|
# upper bound when the caller wants the band between two thresholds
|
|
240
423
|
# (at risk, but not yet breached).
|
|
@@ -249,9 +432,14 @@ module SupportDesk
|
|
|
249
432
|
ceiling ? clause.and(arel_table[:waiting_since].gt(ceiling.ago)) : clause
|
|
250
433
|
end
|
|
251
434
|
|
|
252
|
-
def resolve_topic!(topic, about, desk, requester, subject)
|
|
435
|
+
def resolve_topic!(topic, about, desk, requester, subject, by_support: false)
|
|
253
436
|
node = locate_topic!(topic, about, desk)
|
|
254
|
-
|
|
437
|
+
# An agent may file onto any topic in the tree, including ones no
|
|
438
|
+
# requester is offered (`only:`) — the same latitude `change_topic!`
|
|
439
|
+
# has, and the reason it exists: the desk knows what this is about.
|
|
440
|
+
# The tree's other rule stands for everybody: a topic that needs
|
|
441
|
+
# something to be about still needs it.
|
|
442
|
+
if !by_support && !node.visible_for?(requester)
|
|
255
443
|
raise NotAllowed, "#{requester.class}##{requester.id} may not open a ticket under topic " \
|
|
256
444
|
"#{node.path.inspect} (its only: condition says no)"
|
|
257
445
|
end
|
|
@@ -299,12 +487,20 @@ module SupportDesk
|
|
|
299
487
|
not_closed.find_by(requester: requester, desk: desk, cardinality_key: cardinality_key)
|
|
300
488
|
end
|
|
301
489
|
|
|
490
|
+
# The two walls a requester can hit, and only they can: both count the
|
|
491
|
+
# cases this person ASKED for. Five conversations the desk started must
|
|
492
|
+
# never be what stops somebody asking their first question.
|
|
493
|
+
def enforce_asking_limits!(requester, desk)
|
|
494
|
+
enforce_rate_limit!(requester, desk)
|
|
495
|
+
enforce_open_ticket_cap!(requester, desk)
|
|
496
|
+
end
|
|
497
|
+
|
|
302
498
|
def enforce_rate_limit!(requester, desk)
|
|
303
499
|
limit = desk.config.open_rate_limit
|
|
304
500
|
return if limit.nil?
|
|
305
501
|
|
|
306
502
|
window = Time.current - limit[:within].to_i
|
|
307
|
-
recent = where(requester: requester, desk: desk).where(opened_at: window..).count
|
|
503
|
+
recent = opened_by_requester.where(requester: requester, desk: desk).where(opened_at: window..).count
|
|
308
504
|
return if recent < limit[:to]
|
|
309
505
|
|
|
310
506
|
raise RateLimited, "#{requester.class}##{requester.id} has opened #{recent} tickets in the last " \
|
|
@@ -321,7 +517,7 @@ module SupportDesk
|
|
|
321
517
|
cap = desk.config.max_open_tickets
|
|
322
518
|
return if cap.nil?
|
|
323
519
|
|
|
324
|
-
current = not_closed.where(requester: requester, desk: desk).count
|
|
520
|
+
current = not_closed.opened_by_requester.where(requester: requester, desk: desk).count
|
|
325
521
|
return if current < cap
|
|
326
522
|
|
|
327
523
|
raise TooManyOpenTickets, "#{requester.class}##{requester.id} already has #{current} open tickets " \
|
|
@@ -336,7 +532,7 @@ module SupportDesk
|
|
|
336
532
|
# from "I found this": they are the same ticket, and a very different
|
|
337
533
|
# thing to announce.
|
|
338
534
|
def insert_ticket!(requester:, desk:, node:, about:, via:, requester_role:, title:, metadata:,
|
|
339
|
-
cardinality_key:)
|
|
535
|
+
cardinality_key:, opened_by:, by_support: false, request: nil)
|
|
340
536
|
attempts = 0
|
|
341
537
|
begin
|
|
342
538
|
attempts += 1
|
|
@@ -346,20 +542,37 @@ module SupportDesk
|
|
|
346
542
|
ticket = create!(
|
|
347
543
|
desk: desk, requester: requester, requester_role: requester_role, subject: about,
|
|
348
544
|
topic: node, title: title.presence, reference: unique_reference, status: "open",
|
|
349
|
-
awaiting: "agent", priority: node.priority, opened_via: via.to_s,
|
|
545
|
+
awaiting: "agent", priority: node.priority, opened_via: via.to_s, opened_by: opened_by,
|
|
350
546
|
opened_at: Time.current, cardinality_key: cardinality_key, metadata: metadata
|
|
351
547
|
)
|
|
352
|
-
|
|
548
|
+
# The pair is symmetric; the host's `can_message?` policy is not.
|
|
549
|
+
# A conversation the DESK opens has to be asked about in that
|
|
550
|
+
# direction, or a host that lets support write to anyone and
|
|
551
|
+
# strangers write to nobody would refuse its own outreach.
|
|
552
|
+
conversation = if by_support
|
|
553
|
+
Chats::Conversation.direct_between!(desk, requester, about: ticket)
|
|
554
|
+
else
|
|
555
|
+
Chats::Conversation.direct_between!(requester, desk, about: ticket)
|
|
556
|
+
end
|
|
353
557
|
ticket.update!(conversation_id: conversation.id, waiting_since: ticket.opened_at)
|
|
558
|
+
# The agent who wrote first holds the case from its first
|
|
559
|
+
# committed state — silently. No `assign!`, so no "Lucía se ocupa
|
|
560
|
+
# de tu consulta" in a thread the requester never started, and no
|
|
561
|
+
# :ticket_assigned to page a team about their own message.
|
|
562
|
+
if by_support
|
|
563
|
+
Assignment.open!(ticket: ticket, agent: opened_by, by: opened_by, reason: :opened)
|
|
564
|
+
ticket.update!(assignee: opened_by)
|
|
565
|
+
end
|
|
354
566
|
# Through the same writer every other transition uses (`send`
|
|
355
567
|
# because it is private and we are the class, not the record),
|
|
356
568
|
# so opening a ticket reaches `ticket_transitioned` too.
|
|
357
|
-
opened = ticket.send(:record_transition!, :opened, actor:
|
|
358
|
-
{ "topic" => node.path, "via" => via.to_s
|
|
569
|
+
opened = ticket.send(:record_transition!, :opened, actor: opened_by) do
|
|
570
|
+
{ "topic" => node.path, "via" => via.to_s,
|
|
571
|
+
"assignee" => (SupportDesk.actor_key(opened_by) if by_support) }
|
|
359
572
|
end
|
|
360
573
|
[ ticket, opened ]
|
|
361
574
|
end
|
|
362
|
-
ticket.send(:publish_transition, opened, :opened,
|
|
575
|
+
ticket.send(:publish_transition, opened, :opened, opened_by, request)
|
|
363
576
|
[ ticket, true ]
|
|
364
577
|
rescue ActiveRecord::RecordNotUnique
|
|
365
578
|
existing = open_ticket_for(requester: requester, desk: desk, cardinality_key: cardinality_key)
|
|
@@ -379,15 +592,103 @@ module SupportDesk
|
|
|
379
592
|
raise Error, "couldn't generate a free ticket reference in #{REFERENCE_ATTEMPTS} attempts"
|
|
380
593
|
end
|
|
381
594
|
|
|
382
|
-
|
|
383
|
-
|
|
595
|
+
# The first words of a brand new case, inside the transaction that
|
|
596
|
+
# created it: the desk's opening line, the actual message, and the
|
|
597
|
+
# clocks — folded in on THIS instance, so the row that commits is
|
|
598
|
+
# already true and the caller needs no reload. chats' after-commit
|
|
599
|
+
# subscriber then finds the message already registered and does
|
|
600
|
+
# nothing.
|
|
601
|
+
def post_opening!(ticket, message, files:, by:, by_support:)
|
|
602
|
+
notices = [ post_opening_line!(ticket) ]
|
|
603
|
+
|
|
604
|
+
posted = if by_support && SupportDesk.ai_actor?(by)
|
|
605
|
+
notices << ticket.send(:post_disclosure_notice!, by) if by.notice?
|
|
606
|
+
ticket.send(:post_assistant_opening!, message, files: files, assistant: by)
|
|
607
|
+
elsif by_support
|
|
608
|
+
# Never the inbound "no message, hand the ticket back" shortcut: a
|
|
609
|
+
# desk that writes first with nothing to say is a chats validation
|
|
610
|
+
# error, and this whole transaction goes with it.
|
|
611
|
+
ticket.post_agent_message!(message, files: files, by: by)
|
|
612
|
+
elsif message.present? || files.present?
|
|
613
|
+
ticket.post_requester_message!(message, files: files)
|
|
614
|
+
end
|
|
384
615
|
|
|
385
|
-
ticket.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
616
|
+
ticket.send(:record_registration!, posted) if posted
|
|
617
|
+
ticket.send(:stamp_assistant_action!) if by_support && SupportDesk.ai_actor?(by)
|
|
618
|
+
pin_opening_lines!(ticket, notices, posted)
|
|
619
|
+
ticket
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def post_opening_line!(ticket)
|
|
623
|
+
line = ticket.desk_config.opening_line_for(ticket)
|
|
624
|
+
return nil if line.blank?
|
|
625
|
+
|
|
626
|
+
ticket.conversation.post_system_message!(line)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
# chats orders a transcript by (created_at, id), and inserting one row
|
|
630
|
+
# after another does NOT guarantee two different timestamps: frozen
|
|
631
|
+
# time in a test, a coarse column, a clock that doesn't move between
|
|
632
|
+
# two very fast inserts. Where ids are UUIDs there is then nothing
|
|
633
|
+
# useful to break the tie with, and the notice can sort BELOW the
|
|
634
|
+
# message it introduces.
|
|
635
|
+
#
|
|
636
|
+
# So the notice is pinned one database tick before that message, inside
|
|
637
|
+
# the same transaction, using the timestamp the message actually got.
|
|
638
|
+
# Only this new notice moves, never anybody's real message, and the
|
|
639
|
+
# human message still owns the conversation's last-message pointer, so
|
|
640
|
+
# nothing has to be recomputed afterwards.
|
|
641
|
+
def pin_opening_lines!(ticket, notices, posted)
|
|
642
|
+
notices = Array(notices).compact
|
|
643
|
+
return if notices.empty?
|
|
644
|
+
|
|
645
|
+
anchor = posted&.created_at || ticket.opened_at
|
|
646
|
+
# Backwards, one tick each, so the last line written sits closest to
|
|
647
|
+
# the message and the first one opens the thread.
|
|
648
|
+
notices.reverse.each_with_index do |notice, index|
|
|
649
|
+
notice.update_columns(created_at: anchor - (ordering_tick * (index + 1)))
|
|
650
|
+
end
|
|
651
|
+
# When there IS a first message it owns the conversation's
|
|
652
|
+
# last-message pointer and nothing needs repairing. When there isn't,
|
|
653
|
+
# the notice is that pointer, and chats denormalised its timestamp
|
|
654
|
+
# before we moved it — so the inbox would sort this conversation by a
|
|
655
|
+
# moment its only message doesn't have.
|
|
656
|
+
ticket.conversation.recompute_last_message! if posted.nil?
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# One tick of the messages table's own timestamp column: the smallest
|
|
660
|
+
# difference this database will still store.
|
|
661
|
+
def ordering_tick
|
|
662
|
+
precision = Chats::Message.columns_hash["created_at"]&.precision || 6
|
|
663
|
+
(10**-precision).seconds
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Reuse is a reply, never a second opening. Both ways in — the case the
|
|
667
|
+
# pre-check found and the one this call lost the insert race to — come
|
|
668
|
+
# through here, so an existing case is answered under its row lock,
|
|
669
|
+
# under the desk's reply policy, with the console's authorization hook
|
|
670
|
+
# running before any of it.
|
|
671
|
+
def reply_into!(ticket, message, files:, by:, by_support:, request:, authorize_reuse:)
|
|
672
|
+
if by_support
|
|
673
|
+
# `turn: :current` is for the assistant: reuse turns outreach into
|
|
674
|
+
# an ordinary reply, with her full rules — and the turn she has to
|
|
675
|
+
# hold is the one this lock reads, because there was never an
|
|
676
|
+
# earlier one to give her.
|
|
677
|
+
ticket.send(:reply_under_lock!, message, by: by, files: files, request: request,
|
|
678
|
+
authorize: authorize_reuse, turn: :current)
|
|
679
|
+
else
|
|
680
|
+
ticket.with_lock(requires_new: true) do
|
|
681
|
+
authorize_reuse&.call(ticket)
|
|
682
|
+
# A requester opening the same case again with nothing to say is
|
|
683
|
+
# the old API's "hand it back": supported, and it writes nothing.
|
|
684
|
+
next if message.blank? && files.blank?
|
|
685
|
+
|
|
686
|
+
posted = ticket.post_requester_message!(message, files: files)
|
|
687
|
+
ticket.send(:record_registration!, posted)
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
ticket
|
|
391
692
|
end
|
|
392
693
|
end
|
|
393
694
|
|
|
@@ -402,17 +703,49 @@ module SupportDesk
|
|
|
402
703
|
# chats' context line for the conversation behind the ticket.
|
|
403
704
|
def chat_subject_label = label
|
|
404
705
|
|
|
405
|
-
# Whether chats should refuse new messages in this conversation
|
|
406
|
-
#
|
|
407
|
-
# :locked`
|
|
706
|
+
# Whether chats should refuse new messages in this conversation: because
|
|
707
|
+
# there is nobody to write to any more, or because the case is closed on
|
|
708
|
+
# a desk configured `closed_tickets: :locked` (the default lets a
|
|
709
|
+
# requester's reply reopen it instead).
|
|
710
|
+
#
|
|
711
|
+
# The first reason is a WRITE rule, not a screen rule: the transcript
|
|
712
|
+
# stays readable, the case stays in the queue, and nothing is deleted —
|
|
713
|
+
# only new messages stop. `open!` alone could not do this, because the
|
|
714
|
+
# account can be closed long after the case was opened.
|
|
408
715
|
def chat_locked?
|
|
409
|
-
closed? && desk_config.closed_tickets == :locked
|
|
716
|
+
requester_unavailable? || (closed? && desk_config.closed_tickets == :locked)
|
|
410
717
|
end
|
|
411
718
|
|
|
719
|
+
# The reasons in the same order the refusal takes them, so the notice
|
|
720
|
+
# under a composer and the error behind it never tell different stories.
|
|
412
721
|
def chat_locked_notice
|
|
722
|
+
return I18n.t("support_desk.thread.unavailable_notice") if requester_unavailable?
|
|
723
|
+
|
|
413
724
|
I18n.t("support_desk.thread.closed_notice")
|
|
414
725
|
end
|
|
415
726
|
|
|
727
|
+
# Whether the person this case belongs to can be written to at all right
|
|
728
|
+
# now: their record is gone, or `has_support_tickets if:` says no (a
|
|
729
|
+
# closed account, a ban).
|
|
730
|
+
#
|
|
731
|
+
# Read FRESH from the database on purpose — the requester this instance
|
|
732
|
+
# is holding may have been loaded before they closed their account, and a
|
|
733
|
+
# cached association is not evidence about now. It still doesn't
|
|
734
|
+
# serialize against the host's own closure transaction: an account closed
|
|
735
|
+
# between this read and the write gets one more message in.
|
|
736
|
+
def requester_unavailable?
|
|
737
|
+
return true if requester_type.blank? || requester_id.blank?
|
|
738
|
+
|
|
739
|
+
model = requester_type.safe_constantize
|
|
740
|
+
current = model&.uncached { model.find_by(id: requester_id) }
|
|
741
|
+
return true if current.nil?
|
|
742
|
+
# A requester class that never declared the macro (an import, a legacy
|
|
743
|
+
# row) has no opinion to honour, so it isn't "unavailable".
|
|
744
|
+
return false unless current.respond_to?(:support_requester?)
|
|
745
|
+
|
|
746
|
+
!current.support_requester?
|
|
747
|
+
end
|
|
748
|
+
|
|
416
749
|
def open? = status == "open"
|
|
417
750
|
def closed? = status == "closed"
|
|
418
751
|
def snoozed? = status == "snoozed"
|
|
@@ -420,6 +753,18 @@ module SupportDesk
|
|
|
420
753
|
def unassigned? = !assigned?
|
|
421
754
|
def reopened? = reopen_count.to_i.positive?
|
|
422
755
|
|
|
756
|
+
# Who started this conversation. Read from the stored identity rather
|
|
757
|
+
# than the association, so neither predicate loads a record to answer —
|
|
758
|
+
# and so a case whose opener has since been deleted still answers.
|
|
759
|
+
def opened_by_requester?
|
|
760
|
+
return true if opened_by_id.nil? && opened_by_type.nil?
|
|
761
|
+
|
|
762
|
+
opened_by_id.present? && opened_by_type == requester_type && opened_by_id.to_s == requester_id.to_s
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# NULL provenance is a legacy requester, never an automation opener.
|
|
766
|
+
def opened_by_support? = !opened_by_requester?
|
|
767
|
+
|
|
423
768
|
# True when the desk owes the next word.
|
|
424
769
|
def awaiting_reply? = awaiting == "agent"
|
|
425
770
|
def awaiting_requester? = awaiting == "requester"
|
|
@@ -458,8 +803,13 @@ module SupportDesk
|
|
|
458
803
|
waiting_for >= desk_config.reply_within
|
|
459
804
|
end
|
|
460
805
|
|
|
461
|
-
# How long the requester waited for a first human answer.
|
|
806
|
+
# How long the requester waited for a first human answer. Nil for a case
|
|
807
|
+
# the desk opened: nobody was waiting for it, and "how long from our own
|
|
808
|
+
# first word to our own first word" is not a service level. Metrics (0.4)
|
|
809
|
+
# is where that case gets a number, measured from the requester's first
|
|
810
|
+
# message.
|
|
462
811
|
def time_to_first_reply
|
|
812
|
+
return nil if opened_by_support?
|
|
463
813
|
return nil if first_agent_reply_at.nil? || opened_at.nil?
|
|
464
814
|
|
|
465
815
|
ActiveSupport::Duration.build((first_agent_reply_at - opened_at).to_i)
|
|
@@ -505,30 +855,35 @@ module SupportDesk
|
|
|
505
855
|
# by whoever answers first and a drop-in on somebody else's ticket is
|
|
506
856
|
# recorded; under :take_over the drop-in takes it; under :assignee_only
|
|
507
857
|
# it raises SupportDesk::NotAllowed. Returns the Chats::Message.
|
|
508
|
-
|
|
858
|
+
#
|
|
859
|
+
# `by:` the assistant needs `turn:` and her policy's permission — see
|
|
860
|
+
# SupportDesk::Ticket::Assistance. `metadata:` rides on the message
|
|
861
|
+
# (provenance for a draft a human sent; hosts nest their own under
|
|
862
|
+
# "host").
|
|
863
|
+
def reply!(body = nil, by: nil, files: [], request: nil, metadata: {}, turn: nil)
|
|
509
864
|
actor = resolve_actor(by)
|
|
510
865
|
ensure_agent!(actor)
|
|
511
866
|
|
|
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
|
|
867
|
+
reply_under_lock!(body, by: actor, files: files, request: request, metadata: metadata, turn: turn)
|
|
522
868
|
end
|
|
523
869
|
|
|
524
870
|
# An internal note: in the timeline and the console, never in the
|
|
525
871
|
# conversation, never mirrored to any channel. Returns the Event.
|
|
526
|
-
def note!(body, by: nil, request: nil)
|
|
872
|
+
def note!(body, by: nil, request: nil, turn: nil)
|
|
527
873
|
actor = resolve_actor(by)
|
|
528
874
|
ensure_agent!(actor)
|
|
875
|
+
assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
|
|
529
876
|
raise ArgumentError, "a note needs something to say" if body.blank?
|
|
530
877
|
|
|
531
|
-
event = write_transition!(:note, actor: actor, request: request)
|
|
878
|
+
event = write_transition!(:note, actor: actor, request: request) do
|
|
879
|
+
if assistant
|
|
880
|
+
ensure_current_turn!(turn)
|
|
881
|
+
policy = assistant_policy(assistant)
|
|
882
|
+
raise AssistantNotAllowed.new(policy, verb: :note) unless policy.may_observe?
|
|
883
|
+
end
|
|
884
|
+
{ "note" => body.to_s }
|
|
885
|
+
end
|
|
886
|
+
stamp_assistant_action! if assistant && event
|
|
532
887
|
SupportDesk.emit_after_commit(:note_added, self, event) if event
|
|
533
888
|
event
|
|
534
889
|
end
|
|
@@ -539,9 +894,14 @@ module SupportDesk
|
|
|
539
894
|
def assign!(to:, by: nil, reason: nil, note: nil, request: nil)
|
|
540
895
|
actor = resolve_actor(by)
|
|
541
896
|
ensure_agent!(actor)
|
|
897
|
+
ensure_assistant_may_assign!(actor, to)
|
|
542
898
|
ensure_assignable!(to)
|
|
543
899
|
|
|
544
900
|
reason ||= to == actor ? :taken : :assigned
|
|
901
|
+
# A PERSON giving the case back to the assistant is the one action that
|
|
902
|
+
# lifts the three human-side flags — and it says so out loud, in the
|
|
903
|
+
# payload, because nothing else may clear them (I9).
|
|
904
|
+
handing_back = SupportDesk.ai_actor?(to) && !SupportDesk.ai_actor?(actor)
|
|
545
905
|
assignment = nil
|
|
546
906
|
|
|
547
907
|
event = write_transition!(:assigned, actor: actor, request: request) do
|
|
@@ -549,13 +909,20 @@ module SupportDesk
|
|
|
549
909
|
next false if assigned_to?(to)
|
|
550
910
|
|
|
551
911
|
assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: reason, note: note)
|
|
552
|
-
|
|
553
|
-
|
|
912
|
+
attributes = { assignee: to }
|
|
913
|
+
if handing_back
|
|
914
|
+
attributes.merge!(human_required_at: nil, human_required_reason: nil, assistant_paused_at: nil,
|
|
915
|
+
assistant_paused_reason: nil, assistant_cap: nil)
|
|
916
|
+
end
|
|
917
|
+
update!(attributes)
|
|
918
|
+
{ "assignee" => SupportDesk.actor_key(to), "reason" => reason.to_s,
|
|
919
|
+
"handed_back" => (true if handing_back) }.compact
|
|
554
920
|
end
|
|
555
921
|
return self unless event
|
|
556
922
|
|
|
557
923
|
announce_assignment!(to, first: assignments.count <= 1)
|
|
558
924
|
SupportDesk.emit_after_commit(:ticket_assigned, self, assignment)
|
|
925
|
+
emit_assistant_turn if handing_back && awaiting_reply?
|
|
559
926
|
self
|
|
560
927
|
end
|
|
561
928
|
|
|
@@ -564,7 +931,13 @@ module SupportDesk
|
|
|
564
931
|
def hand_off!(to:, note: nil, by: nil, request: nil)
|
|
565
932
|
actor = resolve_actor(by)
|
|
566
933
|
ensure_agent!(actor)
|
|
934
|
+
if SupportDesk.ai_actor?(actor)
|
|
935
|
+
# She does not choose who picks a case up. `escalate!` is her way
|
|
936
|
+
# out: it asks for a person rather than naming one.
|
|
937
|
+
raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :hand_off)
|
|
938
|
+
end
|
|
567
939
|
ensure_assignable!(to)
|
|
940
|
+
handing_back = SupportDesk.ai_actor?(to)
|
|
568
941
|
from = nil
|
|
569
942
|
assignment = nil
|
|
570
943
|
event = write_transition!(:handed_off, actor: actor, request: request) do
|
|
@@ -578,13 +951,20 @@ module SupportDesk
|
|
|
578
951
|
from = assignee
|
|
579
952
|
assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: :handed_off, note: note,
|
|
580
953
|
release_reason: :handed_off)
|
|
581
|
-
|
|
582
|
-
|
|
954
|
+
attributes = { assignee: to }
|
|
955
|
+
if handing_back
|
|
956
|
+
attributes.merge!(human_required_at: nil, human_required_reason: nil, assistant_paused_at: nil,
|
|
957
|
+
assistant_paused_reason: nil, assistant_cap: nil)
|
|
958
|
+
end
|
|
959
|
+
update!(attributes)
|
|
960
|
+
{ "from" => SupportDesk.actor_key(from), "to" => SupportDesk.actor_key(to), "note" => note,
|
|
961
|
+
"handed_back" => (true if handing_back) }
|
|
583
962
|
end
|
|
584
963
|
return self unless event
|
|
585
964
|
|
|
586
965
|
announce_assignment!(to, first: false)
|
|
587
966
|
SupportDesk.emit_after_commit(:ticket_handed_off, self, assignment, from: from, note: note)
|
|
967
|
+
emit_assistant_turn if handing_back && awaiting_reply?
|
|
588
968
|
self
|
|
589
969
|
end
|
|
590
970
|
|
|
@@ -592,9 +972,17 @@ module SupportDesk
|
|
|
592
972
|
def release!(by: nil, reason: :released, request: nil)
|
|
593
973
|
actor = resolve_actor(by)
|
|
594
974
|
ensure_agent!(actor)
|
|
975
|
+
assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
|
|
595
976
|
from = nil
|
|
596
977
|
event = write_transition!(:released, actor: actor, request: request) do
|
|
597
978
|
raise InvalidTransition, "can't release a closed ticket" if closed?
|
|
979
|
+
if assistant
|
|
980
|
+
policy = assistant_policy(assistant)
|
|
981
|
+
raise AssistantNotAllowed.new(policy, verb: :release) unless policy.may_observe?
|
|
982
|
+
# Her own seat, and only hers: putting a PERSON's case back in the
|
|
983
|
+
# pile is not something a machine gets to decide.
|
|
984
|
+
raise AssistantNotAllowed.new(policy, verb: :release) unless assigned_to?(assistant)
|
|
985
|
+
end
|
|
598
986
|
next false if unassigned?
|
|
599
987
|
|
|
600
988
|
from = assignee
|
|
@@ -609,17 +997,32 @@ module SupportDesk
|
|
|
609
997
|
end
|
|
610
998
|
|
|
611
999
|
# Close the case. Closing a closed ticket is a no-op, not an error.
|
|
612
|
-
def close!(by: nil, request: nil)
|
|
1000
|
+
def close!(by: nil, request: nil, turn: nil)
|
|
613
1001
|
actor = resolve_actor(by)
|
|
614
1002
|
ensure_agent!(actor)
|
|
1003
|
+
assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
|
|
615
1004
|
|
|
616
1005
|
event = write_transition!(:closed, actor: actor, request: request) do
|
|
1006
|
+
if assistant
|
|
1007
|
+
reconcile_unregistered_messages!
|
|
1008
|
+
ensure_current_turn!(turn)
|
|
1009
|
+
policy = assistant_policy(assistant)
|
|
1010
|
+
# Four conditions, and every one of them is somebody else's word:
|
|
1011
|
+
# her level allows it, she is the one holding the case, the
|
|
1012
|
+
# customer has the last word (so nothing is waiting for an answer)
|
|
1013
|
+
# and nobody has asked for a person.
|
|
1014
|
+
raise AssistantNotAllowed.new(policy, verb: :close) unless policy.may_close?
|
|
1015
|
+
raise AssistantNotAllowed.new(policy, verb: :close) unless assigned_to?(assistant)
|
|
1016
|
+
raise AssistantNotAllowed.new(policy, verb: :close) unless awaiting_requester?
|
|
1017
|
+
raise AssistantNotAllowed.new(policy, verb: :close) if human_required?
|
|
1018
|
+
end
|
|
617
1019
|
next false if closed?
|
|
618
1020
|
|
|
1021
|
+
expired = expire_pending_drafts!
|
|
619
1022
|
assignments.open.each { |assignment| assignment.release!(reason: :closed) }
|
|
620
1023
|
update!(status: "closed", closed_at: Time.current, closed_by: record_actor(actor),
|
|
621
1024
|
awaiting: "none", waiting_since: nil)
|
|
622
|
-
{}
|
|
1025
|
+
{ "expired_drafts" => (expired if expired.positive?) }
|
|
623
1026
|
end
|
|
624
1027
|
return self unless event
|
|
625
1028
|
|
|
@@ -635,10 +1038,17 @@ module SupportDesk
|
|
|
635
1038
|
# the pool.
|
|
636
1039
|
def reopen!(by: nil, request: nil)
|
|
637
1040
|
actor = resolve_actor(by)
|
|
1041
|
+
if SupportDesk.ai_actor?(actor)
|
|
1042
|
+
raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :reopen)
|
|
1043
|
+
end
|
|
638
1044
|
|
|
639
1045
|
event = write_transition!(:reopened, actor: actor, request: request) do
|
|
640
1046
|
next false unless closed?
|
|
641
1047
|
|
|
1048
|
+
# Read BEFORE the update clears it: whether the case we are bringing
|
|
1049
|
+
# back is one the assistant closed is the whole question behind the
|
|
1050
|
+
# cap below, and `closed_by` is where it is written down.
|
|
1051
|
+
closed_by_assistant = closed_by_type == SupportDesk::Assistant.polymorphic_name
|
|
642
1052
|
# Order matters: waiting_since is DERIVED from awaiting, so awaiting
|
|
643
1053
|
# has to be the reopened value before it is read. Computing both in
|
|
644
1054
|
# one update! hash reads the closed ticket's "none" and stores nil —
|
|
@@ -648,8 +1058,8 @@ module SupportDesk
|
|
|
648
1058
|
cardinality_key: "reopened:#{id}")
|
|
649
1059
|
self.waiting_since = waiting_since_from_clocks
|
|
650
1060
|
save!
|
|
651
|
-
restore_assignment!(by: actor)
|
|
652
|
-
{ "reopen_count" => reopen_count }
|
|
1061
|
+
capped = restore_assignment!(by: actor, closed_by_assistant: closed_by_assistant)
|
|
1062
|
+
{ "reopen_count" => reopen_count, "assistant_capped" => (true if capped) }.compact
|
|
653
1063
|
end
|
|
654
1064
|
return self unless event
|
|
655
1065
|
|
|
@@ -664,6 +1074,12 @@ module SupportDesk
|
|
|
664
1074
|
# Agents may file onto any topic in the tree, including ones no
|
|
665
1075
|
# requester is offered (`only:`); requesters may not file at all.
|
|
666
1076
|
ensure_agent!(actor)
|
|
1077
|
+
# Triage is where authority comes from — a topic decides the cap she
|
|
1078
|
+
# works under, so refiling her own case would be widening her own
|
|
1079
|
+
# policy (I10). 0.3 refuses it outright.
|
|
1080
|
+
if SupportDesk.ai_actor?(actor)
|
|
1081
|
+
raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :triage)
|
|
1082
|
+
end
|
|
667
1083
|
node = desk_config.topics.find(to.to_s) ||
|
|
668
1084
|
raise(UnknownTopic, "no topic #{to.inspect} on desk #{desk.key}")
|
|
669
1085
|
|
|
@@ -673,7 +1089,11 @@ module SupportDesk
|
|
|
673
1089
|
|
|
674
1090
|
update!(topic: node, priority: [ priority.to_i, node.priority ].max,
|
|
675
1091
|
cardinality_key: recomputed_cardinality_key(subject: subject, topic: node))
|
|
676
|
-
|
|
1092
|
+
# A refile onto a capped topic is a refile onto a case she may no
|
|
1093
|
+
# longer answer, so her seat goes with it — in the same transition,
|
|
1094
|
+
# so the queue never shows a machine holding a case it can't work.
|
|
1095
|
+
released = release_assistant_if_unfit!
|
|
1096
|
+
{ "from" => from&.path, "to" => node.path, "assistant_released" => (true if released) }
|
|
677
1097
|
end
|
|
678
1098
|
return self unless event
|
|
679
1099
|
|
|
@@ -685,6 +1105,11 @@ module SupportDesk
|
|
|
685
1105
|
def attach_subject!(record, by: nil, request: nil)
|
|
686
1106
|
actor = resolve_actor(by)
|
|
687
1107
|
ensure_agent!(actor)
|
|
1108
|
+
# Same reason as `change_topic!`: what a case is ABOUT decides what may
|
|
1109
|
+
# be done on it, so a machine does not get to say.
|
|
1110
|
+
if SupportDesk.ai_actor?(actor)
|
|
1111
|
+
raise AssistantNotAllowed.new(assistant_policy(resolve_assistant!(actor)), verb: :triage)
|
|
1112
|
+
end
|
|
688
1113
|
unless record.respond_to?(:supportable?) && record.supportable?
|
|
689
1114
|
raise NotSupportable, "#{record.class} isn't supportable — add `supportable topic: :something` to it"
|
|
690
1115
|
end
|
|
@@ -711,53 +1136,14 @@ module SupportDesk
|
|
|
711
1136
|
# double-counts, and safe to call by hand after importing a transcript.
|
|
712
1137
|
def register!(message)
|
|
713
1138
|
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
|
|
1139
|
+
# System messages move nothing, however they arrive. chats never
|
|
1140
|
+
# delivers them here (Chats::Message#notify_host returns early for
|
|
1141
|
+
# them), so this guards direct calls and replayed imports — including
|
|
1142
|
+
# the opening line, which is posted inside the opening transaction and
|
|
1143
|
+
# must never be mistaken for somebody's first word.
|
|
1144
|
+
return self if role_of(message) == :system
|
|
1145
|
+
|
|
1146
|
+
with_lock(requires_new: true) { record_registration!(message) }
|
|
761
1147
|
self
|
|
762
1148
|
end
|
|
763
1149
|
|
|
@@ -802,6 +1188,9 @@ module SupportDesk
|
|
|
802
1188
|
# test instead of quietly moving the boundary.
|
|
803
1189
|
def actions_for(agent)
|
|
804
1190
|
return [] unless agent.respond_to?(:support_agent?) && agent.support_agent?
|
|
1191
|
+
# A machine's verbs are its policy's, filtered by the same state. Same
|
|
1192
|
+
# boundary, different vocabulary — see #assistant_actions_for.
|
|
1193
|
+
return assistant_actions_for(agent) if SupportDesk.ai_actor?(agent)
|
|
805
1194
|
# Off duty is a real answer: the console can still show the case, and
|
|
806
1195
|
# an agent passing by can still leave a note, but nothing that speaks
|
|
807
1196
|
# to the requester is offered to somebody who isn't working.
|
|
@@ -811,12 +1200,20 @@ module SupportDesk
|
|
|
811
1200
|
if closed?
|
|
812
1201
|
actions << :reopen
|
|
813
1202
|
else
|
|
814
|
-
|
|
1203
|
+
# Nobody to write to is not the same as nothing to do: the transcript,
|
|
1204
|
+
# the notes and closing the case are all still here, and only the
|
|
1205
|
+
# thing that would speak to the requester is taken away.
|
|
1206
|
+
actions << :reply if may_reply?(agent) && !requester_unavailable?
|
|
815
1207
|
actions << :assign
|
|
816
1208
|
actions << :hand_off if assigned_to?(agent)
|
|
817
1209
|
actions << :release if assigned?
|
|
818
1210
|
actions << :change_topic
|
|
819
1211
|
actions << :close
|
|
1212
|
+
if pending_draft
|
|
1213
|
+
actions << :send_draft if may_reply?(agent) && !requester_unavailable?
|
|
1214
|
+
actions << :reject_draft
|
|
1215
|
+
end
|
|
1216
|
+
actions << (assistant_paused? ? :resume_assistant : :pause_assistant) if assistant
|
|
820
1217
|
end
|
|
821
1218
|
actions
|
|
822
1219
|
end
|
|
@@ -824,6 +1221,10 @@ module SupportDesk
|
|
|
824
1221
|
# Whether +agent+ may answer right now under this desk's reply policy.
|
|
825
1222
|
def may_reply?(agent)
|
|
826
1223
|
return false unless agent.respond_to?(:support_agent?) && agent.support_agent?
|
|
1224
|
+
# A case a machine is holding is a case any person may answer,
|
|
1225
|
+
# whatever the desk says about assignees: `:assignee_only` exists so
|
|
1226
|
+
# two people don't answer at once, and she is not one (I8).
|
|
1227
|
+
return true if held_by_assistant? && !SupportDesk.ai_actor?(agent)
|
|
827
1228
|
return true unless desk_config.reply_policy == :assignee_only
|
|
828
1229
|
|
|
829
1230
|
assigned_to?(agent)
|
|
@@ -833,9 +1234,11 @@ module SupportDesk
|
|
|
833
1234
|
# the whole on-duty pool while it's unheld. The gem computes it; the
|
|
834
1235
|
# host delivers it.
|
|
835
1236
|
def agents_to_notify
|
|
836
|
-
|
|
1237
|
+
# A case the assistant holds is a case no person has seen, so the pool
|
|
1238
|
+
# hears about it: notifying a machine is notifying nobody.
|
|
1239
|
+
return [ assignee ].compact if assigned? && !held_by_assistant?
|
|
837
1240
|
|
|
838
|
-
desk.on_duty_agents.to_a
|
|
1241
|
+
desk.on_duty_agents.to_a.reject { |agent| SupportDesk.ai_actor?(agent) }
|
|
839
1242
|
end
|
|
840
1243
|
|
|
841
1244
|
# Generic on purpose: a lock screen shouldn't spell out what somebody's
|
|
@@ -876,6 +1279,20 @@ module SupportDesk
|
|
|
876
1279
|
requester.message!(conversation, body, files: files)
|
|
877
1280
|
end
|
|
878
1281
|
|
|
1282
|
+
# Post a message as the DESK, signed by the agent who wrote it — every
|
|
1283
|
+
# answer, and the desk's first word when it writes first. One place knows
|
|
1284
|
+
# "the desk sends, the human signs".
|
|
1285
|
+
# `metadata:` is the provenance a message carries: who drafted it, what
|
|
1286
|
+
# policy allowed it, what the machine declared about it. Posted through
|
|
1287
|
+
# `messages.create!` rather than `desk.message!` for one reason only —
|
|
1288
|
+
# chats' sugar takes no metadata, and the validations are the same.
|
|
1289
|
+
def post_agent_message!(body, files: [], by:, metadata: {}) # :nodoc:
|
|
1290
|
+
attributes = { sender: desk, body: body, author: by }
|
|
1291
|
+
attributes[:files] = files if files.present?
|
|
1292
|
+
attributes[:metadata] = metadata if metadata.present?
|
|
1293
|
+
conversation.messages.create!(**attributes)
|
|
1294
|
+
end
|
|
1295
|
+
|
|
879
1296
|
def inspect
|
|
880
1297
|
"#<SupportDesk::Ticket #{reference} #{topic&.path} #{label.to_s.inspect} #{status}" \
|
|
881
1298
|
"#{" → #{describe_actor(assignee)}" if assigned?}#{waiting_description}>"
|
|
@@ -910,14 +1327,45 @@ module SupportDesk
|
|
|
910
1327
|
end
|
|
911
1328
|
|
|
912
1329
|
ensure_agent!(agent)
|
|
1330
|
+
return unless SupportDesk.ai_actor?(agent)
|
|
1331
|
+
|
|
1332
|
+
# "Could she hold this case once the human-side flags were lifted?" —
|
|
1333
|
+
# the hand-back question, so the very flags a hand-back exists to
|
|
1334
|
+
# clear are not what refuses it. Her autonomy, the topic and the host's
|
|
1335
|
+
# cap block still decide.
|
|
1336
|
+
policy = assistant_policy(agent, hand_back: true)
|
|
1337
|
+
raise AssistantNotAllowed.new(policy, verb: :take) unless policy.may_hold?
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1340
|
+
# An assistant may take a case, and that is all: only herself, only when
|
|
1341
|
+
# nobody holds it, and only at a level that may answer.
|
|
1342
|
+
def ensure_assistant_may_assign!(actor, to)
|
|
1343
|
+
return unless SupportDesk.ai_actor?(actor)
|
|
1344
|
+
|
|
1345
|
+
assistant = resolve_assistant!(actor)
|
|
1346
|
+
policy = assistant_policy(assistant)
|
|
1347
|
+
unless self.class.same_actor?(actor, to)
|
|
1348
|
+
raise AssistantNotAllowed.new(policy, verb: :assign,
|
|
1349
|
+
message: "#{assistant.key} may not give #{reference} to " \
|
|
1350
|
+
"anybody — an assistant can only take a case herself")
|
|
1351
|
+
end
|
|
1352
|
+
raise AssistantNotAllowed.new(policy, verb: :take) unless unassigned?
|
|
1353
|
+
raise AssistantNotAllowed.new(policy, verb: :take) unless policy.may_hold?
|
|
913
1354
|
end
|
|
914
1355
|
|
|
915
1356
|
def ensure_agent!(actor)
|
|
916
1357
|
return if actor.is_a?(Symbol)
|
|
917
|
-
|
|
1358
|
+
# An AI-kind actor that is not a SupportDesk::Assistant is refused on
|
|
1359
|
+
# every write, by it or to it (I2). A host model declared `kind: :ai`
|
|
1360
|
+
# is a machine the gem knows nothing about, and treating it as a human
|
|
1361
|
+
# agent — which is what 0.2 did — hands it every human's authority.
|
|
1362
|
+
if SupportDesk.ai_actor?(actor) && !actor.is_a?(SupportDesk::Assistant)
|
|
1363
|
+
raise NotAnAssistant,
|
|
1364
|
+
"#{describe_actor(actor)} is declared `acts_as_support_agent kind: :ai` but isn't this gem's " \
|
|
1365
|
+
"assistant — configure one with `config.assistant` and act as SupportDesk.assistant(key)"
|
|
1366
|
+
end
|
|
918
1367
|
|
|
919
|
-
|
|
920
|
-
"`acts_as_support_agent` on #{actor.class}"
|
|
1368
|
+
self.class.ensure_agent_record!(actor)
|
|
921
1369
|
end
|
|
922
1370
|
|
|
923
1371
|
def describe_actor(actor)
|
|
@@ -928,12 +1376,74 @@ module SupportDesk
|
|
|
928
1376
|
|
|
929
1377
|
# --- Transition plumbing -------------------------------------------------------
|
|
930
1378
|
|
|
1379
|
+
# The two reasons a case takes no more messages, in the order the notice
|
|
1380
|
+
# under the composer gives them: there is nobody to write to, and only
|
|
1381
|
+
# then the closed-and-locked case.
|
|
931
1382
|
def ensure_writable!
|
|
1383
|
+
if requester_unavailable?
|
|
1384
|
+
raise Locked, "ticket #{reference} can't be written to: #{requester_type}##{requester_id} is no " \
|
|
1385
|
+
"longer an eligible support requester. The case stays readable."
|
|
1386
|
+
end
|
|
932
1387
|
return unless chat_locked?
|
|
933
1388
|
|
|
934
1389
|
raise Locked, "ticket #{reference} is closed and this desk locks closed tickets — reopen it first"
|
|
935
1390
|
end
|
|
936
1391
|
|
|
1392
|
+
# Everything `reply!` does, under ONE lock and ONE savepoint: check that
|
|
1393
|
+
# the conversation still takes messages, apply the desk's reply policy,
|
|
1394
|
+
# post, and fold the message into the clocks before anything commits.
|
|
1395
|
+
#
|
|
1396
|
+
# The savepoint is not belt and braces: taking the ticket and announcing
|
|
1397
|
+
# it are part of answering, so a reply that raises (an empty body, a
|
|
1398
|
+
# locked conversation, a host policy) must not leave the agent holding a
|
|
1399
|
+
# case they never answered — not even when the caller rescues the raise
|
|
1400
|
+
# and commits its own transaction.
|
|
1401
|
+
#
|
|
1402
|
+
# `authorize:` is the console's hook (see Ticket.open_or_reply!): it runs
|
|
1403
|
+
# under this lock, before any policy side effect, and a raise there rolls
|
|
1404
|
+
# the whole thing back.
|
|
1405
|
+
def reply_under_lock!(body, by:, files:, request:, authorize: nil, metadata: {}, turn: nil)
|
|
1406
|
+
with_lock(requires_new: true) do
|
|
1407
|
+
authorize&.call(self)
|
|
1408
|
+
next assistant_reply_under_lock!(body, assistant: by, files: files, request: request,
|
|
1409
|
+
metadata: metadata, turn: turn) if SupportDesk.ai_actor?(by)
|
|
1410
|
+
|
|
1411
|
+
ensure_writable!
|
|
1412
|
+
apply_reply_policy!(by, request: request)
|
|
1413
|
+
posted = post_agent_message!(body, files: files, by: by, metadata: metadata)
|
|
1414
|
+
# A person answering makes every machine proposal on this case out of
|
|
1415
|
+
# date — including one they are about to send, which is why the draft
|
|
1416
|
+
# being sent says so and is left alone.
|
|
1417
|
+
supersede_pending_drafts!(except: metadata.dig("support_desk", "draft_id"))
|
|
1418
|
+
record_registration!(posted)
|
|
1419
|
+
posted
|
|
1420
|
+
end
|
|
1421
|
+
end
|
|
1422
|
+
|
|
1423
|
+
# `reply!` by the assistant: her full rules, under the lock the caller
|
|
1424
|
+
# already holds. Everything here is also what `respond!` runs — this is
|
|
1425
|
+
# the path for a host that decided to answer rather than ask policy.
|
|
1426
|
+
def assistant_reply_under_lock!(body, assistant:, files:, request:, metadata:, turn:)
|
|
1427
|
+
assistant = resolve_assistant!(assistant)
|
|
1428
|
+
# `:current` means "the turn as this lock sees it" — the one place the
|
|
1429
|
+
# gem supplies a turn itself, because an outreach reply into an
|
|
1430
|
+
# existing case has no earlier turn for anybody to have held.
|
|
1431
|
+
turn = assistant_turn if turn == :current
|
|
1432
|
+
reconcile_unregistered_messages!
|
|
1433
|
+
ensure_current_turn!(turn)
|
|
1434
|
+
ensure_writable!
|
|
1435
|
+
|
|
1436
|
+
policy = assistant_policy(assistant)
|
|
1437
|
+
raise AssistantNotAllowed.new(policy, verb: :reply) unless policy.may_reply?
|
|
1438
|
+
raise AssistantNotAllowed.new(policy, verb: :not_your_turn) unless awaiting_reply?
|
|
1439
|
+
|
|
1440
|
+
left = assistant_turns_left
|
|
1441
|
+
raise AssistantNotAllowed.new(policy, verb: :max_turns) if left&.zero?
|
|
1442
|
+
|
|
1443
|
+
speak!(body, assistant, policy: policy, turn: turn, files: files, confidence: nil, sources: [],
|
|
1444
|
+
metadata: metadata, request: request)
|
|
1445
|
+
end
|
|
1446
|
+
|
|
937
1447
|
def apply_reply_policy!(actor, request: nil)
|
|
938
1448
|
# A closed case has no seat to take: an agent adding one last word
|
|
939
1449
|
# posts it and the case stays closed. (A REQUESTER writing is what
|
|
@@ -950,6 +1460,17 @@ module SupportDesk
|
|
|
950
1460
|
|
|
951
1461
|
assign!(to: actor, by: actor, request: request)
|
|
952
1462
|
elsif !assigned_to?(actor)
|
|
1463
|
+
# Humans outrank assistants, under EVERY reply policy (I8). A person
|
|
1464
|
+
# answering a case a machine is holding takes it over: there is
|
|
1465
|
+
# nothing to ask about "who owns this" when one of the two can't
|
|
1466
|
+
# want it, and leaving her seated would keep her answering next.
|
|
1467
|
+
if held_by_assistant? && !SupportDesk.ai_actor?(actor)
|
|
1468
|
+
return assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request)
|
|
1469
|
+
end
|
|
1470
|
+
# Belt: the `held_by_human` floor already turned this into a draft,
|
|
1471
|
+
# so an assistant reaching here is a bug, not a policy question.
|
|
1472
|
+
raise AssistantNotAllowed.new(assistant_policy(actor), verb: :reply) if SupportDesk.ai_actor?(actor)
|
|
1473
|
+
|
|
953
1474
|
case policy
|
|
954
1475
|
when :assignee_only
|
|
955
1476
|
raise NotAllowed, "ticket #{reference} is held by #{describe_actor(assignee)} and this desk only " \
|
|
@@ -982,7 +1503,11 @@ module SupportDesk
|
|
|
982
1503
|
payload = yield
|
|
983
1504
|
return nil if payload == false || payload.nil?
|
|
984
1505
|
|
|
985
|
-
Event.record!(ticket: self, kind: kind, actor: actor, payload: payload.compact)
|
|
1506
|
+
event = Event.record!(ticket: self, kind: kind, actor: actor, payload: payload.compact)
|
|
1507
|
+
# A real transition changed the case, so it moves the turn — a
|
|
1508
|
+
# no-op one wrote no event and moves nothing.
|
|
1509
|
+
bump_assistant_revision!
|
|
1510
|
+
event
|
|
986
1511
|
end
|
|
987
1512
|
|
|
988
1513
|
# The half that tells the world, once the write is durable. Every
|
|
@@ -1000,6 +1525,10 @@ module SupportDesk
|
|
|
1000
1525
|
end
|
|
1001
1526
|
|
|
1002
1527
|
def announce_assignment!(agent, first:)
|
|
1528
|
+
# "Rose se ocupa de tu consulta" is a promise about a person. Her
|
|
1529
|
+
# disclosure line is her announcement, and it is the only one.
|
|
1530
|
+
return if SupportDesk.ai_actor?(agent)
|
|
1531
|
+
|
|
1003
1532
|
mode = desk_config.announce_assignments
|
|
1004
1533
|
return if mode == :never
|
|
1005
1534
|
return if mode == :first_only && !first
|
|
@@ -1011,6 +1540,82 @@ module SupportDesk
|
|
|
1011
1540
|
|
|
1012
1541
|
# --- Registration plumbing ------------------------------------------------------
|
|
1013
1542
|
|
|
1543
|
+
# The half that writes, for callers who ALREADY hold the row: `reply!`
|
|
1544
|
+
# under its lock, and `Ticket.open!` while it still owns the uncommitted
|
|
1545
|
+
# row it just created. That is what keeps ONE method in charge of the
|
|
1546
|
+
# clocks — the opening message is folded in on the same instance, before
|
|
1547
|
+
# commit, so the row that lands is already true and nothing has to
|
|
1548
|
+
# reload.
|
|
1549
|
+
#
|
|
1550
|
+
# Everything is re-checked here rather than in the caller, because the
|
|
1551
|
+
# caller is not always the lock holder it thinks it is.
|
|
1552
|
+
def record_registration!(message) # :nodoc:
|
|
1553
|
+
return self if role_of(message) == :system
|
|
1554
|
+
# Re-check under the lock: the same message can reach us twice (a
|
|
1555
|
+
# redelivered event, a hand-written replay), and an SLA clock that
|
|
1556
|
+
# moves twice for one message is a lie.
|
|
1557
|
+
return self if registered?(message)
|
|
1558
|
+
|
|
1559
|
+
role = role_of(message)
|
|
1560
|
+
opening = opening_message?
|
|
1561
|
+
reopened = false
|
|
1562
|
+
reopen_event = nil
|
|
1563
|
+
|
|
1564
|
+
attributes = { last_registered_message_id: message.id }
|
|
1565
|
+
closed_by_assistant = false
|
|
1566
|
+
case role
|
|
1567
|
+
when :requester
|
|
1568
|
+
attributes[:last_requester_message_at] = message.created_at
|
|
1569
|
+
# The pointer the turn's reconciliation reads: which requester
|
|
1570
|
+
# message the clocks are standing on, by id and not only by time.
|
|
1571
|
+
attributes[:last_requester_message_id] = message.id
|
|
1572
|
+
if closed? && message.created_at > closed_at && desk_config.closed_tickets == :reopen_on_reply
|
|
1573
|
+
# Read before the merge below clears it — see #reopen!.
|
|
1574
|
+
closed_by_assistant = closed_by_type == SupportDesk::Assistant.polymorphic_name
|
|
1575
|
+
attributes.merge!(status: "open", closed_at: nil, closed_by: nil,
|
|
1576
|
+
reopen_count: reopen_count.to_i + 1, cardinality_key: "reopened:#{id}")
|
|
1577
|
+
reopened = true
|
|
1578
|
+
end
|
|
1579
|
+
when :agent
|
|
1580
|
+
attributes[:last_agent_message_at] = message.created_at
|
|
1581
|
+
# A first REPLY answers something. An agent message with no earlier
|
|
1582
|
+
# requester message is the desk opening the conversation, or a word
|
|
1583
|
+
# into an empty case — neither is a reply, and a requester clock that
|
|
1584
|
+
# is LATER (an out-of-order replay) is not evidence of one either.
|
|
1585
|
+
if first_agent_reply_at.nil? && last_requester_message_at.present? &&
|
|
1586
|
+
last_requester_message_at <= message.created_at
|
|
1587
|
+
attributes[:first_agent_reply_at] = message.created_at
|
|
1588
|
+
end
|
|
1589
|
+
# A closed case owes nobody anything. An agent adding one last
|
|
1590
|
+
# word keeps the clocks honest without putting the case back in a
|
|
1591
|
+
# queue that `close!` just took it out of.
|
|
1592
|
+
end
|
|
1593
|
+
|
|
1594
|
+
assign_attributes(attributes)
|
|
1595
|
+
self.awaiting = closed? ? "none" : awaiting_from_clocks
|
|
1596
|
+
self.waiting_since = waiting_since_from_clocks
|
|
1597
|
+
save!
|
|
1598
|
+
# Every registered message moves the case on, so every one of them
|
|
1599
|
+
# moves the turn: an assistant holding the turn she read a second ago
|
|
1600
|
+
# is holding a case that has not changed since (I4).
|
|
1601
|
+
bump_assistant_revision!
|
|
1602
|
+
|
|
1603
|
+
if reopened
|
|
1604
|
+
capped = restore_assignment!(by: :system, closed_by_assistant: closed_by_assistant)
|
|
1605
|
+
reopen_event = record_transition!(:reopened, actor: requester) do
|
|
1606
|
+
{ "via" => "requester_reply", "assistant_capped" => (true if capped) }.compact
|
|
1607
|
+
end
|
|
1608
|
+
end
|
|
1609
|
+
|
|
1610
|
+
# After the bump, and after any reopen: the hook decides about a case
|
|
1611
|
+
# in the state this message left it in.
|
|
1612
|
+
evaluate_hand_off_phrase!(message) if role == :requester
|
|
1613
|
+
|
|
1614
|
+
publish_transition(reopen_event, :reopened, requester, nil) if reopen_event
|
|
1615
|
+
announce_registration(message, role: role, opening: opening, reopened: reopened)
|
|
1616
|
+
self
|
|
1617
|
+
end
|
|
1618
|
+
|
|
1014
1619
|
# Whether this message is already folded in. The last-id check catches
|
|
1015
1620
|
# the common redelivery; the clock check catches the rest, because a
|
|
1016
1621
|
# REPLAY can arrive in any order and an older message must never rewind
|
|
@@ -1024,12 +1629,26 @@ module SupportDesk
|
|
|
1024
1629
|
def registered?(message)
|
|
1025
1630
|
return true if last_registered_message_id.present? && last_registered_message_id.to_s == message.id.to_s
|
|
1026
1631
|
|
|
1027
|
-
|
|
1632
|
+
role = role_of(message)
|
|
1633
|
+
clock = case role
|
|
1028
1634
|
when :requester then last_requester_message_at
|
|
1029
1635
|
when :agent then last_agent_message_at
|
|
1030
1636
|
end
|
|
1031
|
-
|
|
1032
|
-
|
|
1637
|
+
return false if clock.blank?
|
|
1638
|
+
return true if message.created_at < clock
|
|
1639
|
+
return false unless message.created_at == clock
|
|
1640
|
+
|
|
1641
|
+
# Equal timestamps are NOT the same message. A coarse column, a frozen
|
|
1642
|
+
# clock in a test, or two very fast inserts can put two different
|
|
1643
|
+
# messages on one instant, and a `<=` here folded the second one in
|
|
1644
|
+
# without moving anything — so the assistant answered a question the
|
|
1645
|
+
# case had never registered. The only equal-timestamp message that
|
|
1646
|
+
# counts as folded in is the one the clock was set FROM.
|
|
1647
|
+
case role
|
|
1648
|
+
when :requester then last_requester_message_id.present? &&
|
|
1649
|
+
last_requester_message_id.to_s == message.id.to_s
|
|
1650
|
+
else false
|
|
1651
|
+
end
|
|
1033
1652
|
end
|
|
1034
1653
|
|
|
1035
1654
|
def opening_message?
|
|
@@ -1051,6 +1670,16 @@ module SupportDesk
|
|
|
1051
1670
|
type == record.class.polymorphic_name && id.to_s == record.id.to_s
|
|
1052
1671
|
end
|
|
1053
1672
|
|
|
1673
|
+
# Provenance is a record or it is nothing: half a polymorphic pair points
|
|
1674
|
+
# at a class with no row, or a row with no class, and every predicate and
|
|
1675
|
+
# scope over it would have to guess. (Legacy rows have NEITHER, which is
|
|
1676
|
+
# a shape we can read and `doctor` can report.)
|
|
1677
|
+
def opened_by_must_be_a_whole_record
|
|
1678
|
+
return if opened_by_type.nil? == opened_by_id.nil?
|
|
1679
|
+
|
|
1680
|
+
errors.add(:opened_by, "needs both a type and an id, or neither")
|
|
1681
|
+
end
|
|
1682
|
+
|
|
1054
1683
|
# What "one open ticket about this" means for this ticket, recomputed
|
|
1055
1684
|
# because the case now says it is about something else. Refusing a
|
|
1056
1685
|
# collision here is the point: silently keeping the old key leaves the
|
|
@@ -1074,14 +1703,26 @@ module SupportDesk
|
|
|
1074
1703
|
# disagree on a live ticket. A closed ticket keeps its assignee as the
|
|
1075
1704
|
# record of who dealt with it, with no open row — that pair is the one
|
|
1076
1705
|
# shape `doctor` expects to see.
|
|
1077
|
-
def restore_assignment!(by:)
|
|
1078
|
-
return if unassigned?
|
|
1706
|
+
def restore_assignment!(by:, closed_by_assistant: false)
|
|
1707
|
+
return false if unassigned?
|
|
1708
|
+
|
|
1709
|
+
# A case the assistant closed and the customer reopened is a case she
|
|
1710
|
+
# got wrong (12 #21). It comes back to PEOPLE — unassigned, and capped
|
|
1711
|
+
# at :draft on this case for good, whatever her level is elsewhere. An
|
|
1712
|
+
# explicit hand-back is the only thing that lifts it, and the cap can
|
|
1713
|
+
# only tighten: an existing :observe stays :observe.
|
|
1714
|
+
if closed_by_assistant
|
|
1715
|
+
cap = [ assistant_cap&.to_sym, :draft ].compact.min_by { |level| AssistantPolicy::RANK.fetch(level) }
|
|
1716
|
+
update!(assignee: nil, assistant_cap: cap.to_s)
|
|
1717
|
+
return true
|
|
1718
|
+
end
|
|
1079
1719
|
|
|
1080
1720
|
if assignee.respond_to?(:support_agent?) && assignee.support_agent?
|
|
1081
1721
|
Assignment.open!(ticket: self, agent: assignee, by: by, reason: :reopened)
|
|
1082
1722
|
else
|
|
1083
1723
|
update!(assignee: nil)
|
|
1084
1724
|
end
|
|
1725
|
+
false
|
|
1085
1726
|
end
|
|
1086
1727
|
|
|
1087
1728
|
def awaiting_from_clocks
|
|
@@ -1106,9 +1747,19 @@ module SupportDesk
|
|
|
1106
1747
|
|
|
1107
1748
|
case role
|
|
1108
1749
|
when :requester
|
|
1750
|
+
# The opening message included: a case that starts with a question
|
|
1751
|
+
# is a case with something to answer, and the harness should hear
|
|
1752
|
+
# about it the same way it hears about every later message.
|
|
1753
|
+
emit_assistant_turn(message)
|
|
1109
1754
|
SupportDesk.emit_after_commit(:requester_replied, self, message) unless opening
|
|
1110
1755
|
when :agent
|
|
1111
|
-
|
|
1756
|
+
# The desk's OWN first word announces nothing: it is the start of a
|
|
1757
|
+
# conversation the requester never asked for, not an answer to one,
|
|
1758
|
+
# and `:ticket_opened` has already said it. An agent's first message
|
|
1759
|
+
# into an old empty case the REQUESTER opened is a reply, which is
|
|
1760
|
+
# why this asks who opened the case and not just whether the clocks
|
|
1761
|
+
# are empty.
|
|
1762
|
+
SupportDesk.emit_after_commit(:agent_replied, self, message) unless opening && opened_by_support?
|
|
1112
1763
|
end
|
|
1113
1764
|
end
|
|
1114
1765
|
|
|
@@ -1133,11 +1784,21 @@ module SupportDesk
|
|
|
1133
1784
|
conversation.messages.visible.oldest_first.map do |message|
|
|
1134
1785
|
{
|
|
1135
1786
|
at: message.created_at,
|
|
1136
|
-
|
|
1787
|
+
# "assistant" in every disclosure mode, silent ones included: what
|
|
1788
|
+
# a customer was told is a product decision, what an export says a
|
|
1789
|
+
# machine wrote is not.
|
|
1790
|
+
from: export_from(message),
|
|
1137
1791
|
body: message.visible_body,
|
|
1138
1792
|
attachments: message.try(:files)&.map { |file| file.try(:filename).to_s } || []
|
|
1139
1793
|
}
|
|
1140
1794
|
end
|
|
1141
1795
|
end
|
|
1796
|
+
|
|
1797
|
+
def export_from(message)
|
|
1798
|
+
return "you" if role_of(message) == :requester
|
|
1799
|
+
return "assistant" if assistant_message?(message)
|
|
1800
|
+
|
|
1801
|
+
"support"
|
|
1802
|
+
end
|
|
1142
1803
|
end
|
|
1143
1804
|
end
|