support_desk 0.1.3 → 0.2.0

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