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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -0
  3. data/README.md +1101 -22
  4. data/Rakefile +16 -0
  5. data/app/assets/stylesheets/support_desk.css +10 -0
  6. data/app/controllers/support_desk/tickets_controller.rb +28 -4
  7. data/app/helpers/support_desk/engine_helper.rb +23 -1
  8. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  9. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  10. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  11. data/app/views/support_desk/console/tickets/_context_card.html.erb +33 -0
  12. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  13. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  14. data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
  15. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +28 -0
  16. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  17. data/app/views/support_desk/console/tickets/index.html.erb +14 -2
  18. data/app/views/support_desk/console/tickets/new.html.erb +25 -0
  19. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  20. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  21. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  22. data/config/console_routes.rb +1 -1
  23. data/config/locales/support_desk.console.en.yml +89 -0
  24. data/config/locales/support_desk.console.es.yml +91 -0
  25. data/config/locales/support_desk.en.yml +18 -0
  26. data/config/locales/support_desk.es.yml +29 -0
  27. data/config/routes.rb +7 -1
  28. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  29. data/lib/generators/support_desk/console_generator.rb +1 -1
  30. data/lib/generators/support_desk/install_generator.rb +26 -0
  31. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  32. data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
  33. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  34. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  35. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  36. data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
  37. data/lib/generators/support_desk/templates/initializer.rb +63 -0
  38. data/lib/generators/support_desk/upgrade_generator.rb +66 -0
  39. data/lib/support_desk/assistant_policy.rb +213 -0
  40. data/lib/support_desk/brief.rb +283 -0
  41. data/lib/support_desk/configuration.rb +699 -3
  42. data/lib/support_desk/console.rb +634 -30
  43. data/lib/support_desk/console_engine.rb +2 -0
  44. data/lib/support_desk/console_routes.rb +20 -8
  45. data/lib/support_desk/context_card.rb +33 -1
  46. data/lib/support_desk/doctor.rb +195 -2
  47. data/lib/support_desk/engine.rb +10 -0
  48. data/lib/support_desk/errors.rb +42 -0
  49. data/lib/support_desk/events.rb +12 -5
  50. data/lib/support_desk/macros.rb +41 -12
  51. data/lib/support_desk/models/assignment.rb +3 -1
  52. data/lib/support_desk/models/assistant.rb +153 -0
  53. data/lib/support_desk/models/concerns/agent.rb +39 -4
  54. data/lib/support_desk/models/concerns/requester.rb +45 -12
  55. data/lib/support_desk/models/desk.rb +26 -3
  56. data/lib/support_desk/models/draft.rb +266 -0
  57. data/lib/support_desk/models/event.rb +15 -1
  58. data/lib/support_desk/models/ticket/assistance.rb +675 -0
  59. data/lib/support_desk/models/ticket.rb +781 -120
  60. data/lib/support_desk/outcome.rb +38 -0
  61. data/lib/support_desk/queue.rb +41 -5
  62. data/lib/support_desk/summary.rb +1 -1
  63. data/lib/support_desk/test_helpers.rb +160 -3
  64. data/lib/support_desk/timeline.rb +40 -11
  65. data/lib/support_desk/topic.rb +22 -0
  66. data/lib/support_desk/topic_tree.rb +7 -1
  67. data/lib/support_desk/transcript.rb +237 -0
  68. data/lib/support_desk/version.rb +1 -1
  69. data/lib/support_desk/wizard.rb +4 -12
  70. data/lib/support_desk.rb +153 -0
  71. data/lib/tasks/support_desk.rake +73 -0
  72. 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!(…)`; this is the seam channels and jobs use.
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
- return post_opening_message(existing, message, files) if existing
244
+ enforce_asking_limits!(requester, desk) unless existing || by_support
178
245
 
179
- enforce_rate_limit!(requester, desk)
180
- enforce_open_ticket_cap!(requester, desk)
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, cardinality_key: cardinality
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
- post_opening_message(created, message, files)
188
- SupportDesk.emit_after_commit(:ticket_opened, created) if inserted
189
- created
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
- unless node.visible_for?(requester)
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
- conversation = Chats::Conversation.direct_between!(requester, desk, about: ticket)
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: requester) do
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, requester, nil)
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
- def post_opening_message(ticket, message, files)
383
- return ticket if message.blank? && files.blank?
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.post_requester_message!(message, files: files)
386
- # Posting the first message is what starts the clocks, and it does
387
- # that through chats' after-commit subscriber — on a DIFFERENT
388
- # instance of this row. Without the reload the caller gets a ticket
389
- # whose `waiting_since` is nil while the database's is not.
390
- ticket.reload
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. Only
406
- # true for closed tickets on a desk configured `closed_tickets:
407
- # :locked` — the default lets a requester's reply reopen the case.
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
- def reply!(body = nil, by: nil, files: [], request: nil)
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
- # One transaction, because taking the ticket and announcing it are
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) { { "note" => body.to_s } }
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
- update!(assignee: to)
553
- { "assignee" => SupportDesk.actor_key(to), "reason" => reason.to_s }
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
- update!(assignee: to)
582
- { "from" => SupportDesk.actor_key(from), "to" => SupportDesk.actor_key(to), "note" => note }
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
- { "from" => from&.path, "to" => node.path }
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
- role = nil
716
- opening = false
717
- reopened = false
718
- applied = false
719
- reopen_event = nil
720
-
721
- with_lock do
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
- actions << :reply if may_reply?(agent)
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
- return [ assignee ].compact if assigned?
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
- return if actor.respond_to?(:support_agent?) && actor.support_agent?
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
- raise NotAnAgent, "#{describe_actor(actor)} is not a support agent — declare " \
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
- clock = case role_of(message)
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
- clock.present? && message.created_at <= clock
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
- SupportDesk.emit_after_commit(:agent_replied, self, message)
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
- from: role_of(message) == :requester ? "you" : "support",
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