support_desk 0.1.2 → 0.2.0

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