support_desk 0.1.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 (84) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +32 -0
  3. data/.simplecov +53 -0
  4. data/Appraisals +18 -0
  5. data/CHANGELOG.md +135 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +469 -0
  8. data/Rakefile +55 -0
  9. data/app/assets/stylesheets/support_desk.css +242 -0
  10. data/app/controllers/support_desk/application_controller.rb +92 -0
  11. data/app/controllers/support_desk/console/application_controller.rb +18 -0
  12. data/app/controllers/support_desk/console/tickets_controller.rb +26 -0
  13. data/app/controllers/support_desk/tickets_controller.rb +119 -0
  14. data/app/helpers/support_desk/engine_helper.rb +196 -0
  15. data/app/views/chats/slots/_inbox_top.html.erb +31 -0
  16. data/app/views/chats/slots/_locked_composer.html.erb +18 -0
  17. data/app/views/support_desk/console/tickets/_actions.html.erb +29 -0
  18. data/app/views/support_desk/console/tickets/_assignment.html.erb +60 -0
  19. data/app/views/support_desk/console/tickets/_composer.html.erb +71 -0
  20. data/app/views/support_desk/console/tickets/_context_card.html.erb +53 -0
  21. data/app/views/support_desk/console/tickets/_message.html.erb +48 -0
  22. data/app/views/support_desk/console/tickets/_nav_badge.html.erb +19 -0
  23. data/app/views/support_desk/console/tickets/_tabs.html.erb +15 -0
  24. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +56 -0
  25. data/app/views/support_desk/console/tickets/_timeline.html.erb +37 -0
  26. data/app/views/support_desk/console/tickets/_transcript.html.erb +26 -0
  27. data/app/views/support_desk/console/tickets/index.html.erb +37 -0
  28. data/app/views/support_desk/console/tickets/show.html.erb +45 -0
  29. data/app/views/support_desk/tickets/_context_card.html.erb +14 -0
  30. data/app/views/support_desk/tickets/_door.html.erb +12 -0
  31. data/app/views/support_desk/tickets/_pick_thing.html.erb +55 -0
  32. data/app/views/support_desk/tickets/_pick_topic.html.erb +30 -0
  33. data/app/views/support_desk/tickets/_ticket_row.html.erb +31 -0
  34. data/app/views/support_desk/tickets/_wizard_header.html.erb +23 -0
  35. data/app/views/support_desk/tickets/_write.html.erb +56 -0
  36. data/app/views/support_desk/tickets/index.html.erb +53 -0
  37. data/app/views/support_desk/tickets/new.html.erb +14 -0
  38. data/app/views/support_desk/tickets/rate_limited.html.erb +40 -0
  39. data/config/console_routes.rb +16 -0
  40. data/config/locales/support_desk.console.en.yml +93 -0
  41. data/config/locales/support_desk.console.es.yml +93 -0
  42. data/config/locales/support_desk.en.yml +79 -0
  43. data/config/locales/support_desk.es.yml +84 -0
  44. data/config/routes.rb +24 -0
  45. data/context7.json +4 -0
  46. data/gemfiles/rails_7.2.gemfile +34 -0
  47. data/gemfiles/rails_8.0.gemfile +34 -0
  48. data/gemfiles/rails_8.1.gemfile +34 -0
  49. data/lib/generators/support_desk/console_generator.rb +94 -0
  50. data/lib/generators/support_desk/install_generator.rb +86 -0
  51. data/lib/generators/support_desk/templates/console/controller.rb.erb +43 -0
  52. data/lib/generators/support_desk/templates/console/resource.rb.erb +44 -0
  53. data/lib/generators/support_desk/templates/create_support_desk_tables.rb.erb +224 -0
  54. data/lib/generators/support_desk/templates/initializer.rb +186 -0
  55. data/lib/generators/support_desk/views_generator.rb +50 -0
  56. data/lib/support_desk/configuration.rb +675 -0
  57. data/lib/support_desk/console.rb +487 -0
  58. data/lib/support_desk/console_engine.rb +63 -0
  59. data/lib/support_desk/console_routes.rb +107 -0
  60. data/lib/support_desk/context_card.rb +90 -0
  61. data/lib/support_desk/current.rb +26 -0
  62. data/lib/support_desk/doctor.rb +220 -0
  63. data/lib/support_desk/engine.rb +141 -0
  64. data/lib/support_desk/errors.rb +49 -0
  65. data/lib/support_desk/events.rb +122 -0
  66. data/lib/support_desk/macros.rb +73 -0
  67. data/lib/support_desk/models/application_record.rb +11 -0
  68. data/lib/support_desk/models/assignment.rb +83 -0
  69. data/lib/support_desk/models/concerns/agent.rb +79 -0
  70. data/lib/support_desk/models/concerns/requester.rb +71 -0
  71. data/lib/support_desk/models/concerns/supportable.rb +88 -0
  72. data/lib/support_desk/models/desk.rb +101 -0
  73. data/lib/support_desk/models/event.rb +72 -0
  74. data/lib/support_desk/models/ticket.rb +1124 -0
  75. data/lib/support_desk/queue.rb +171 -0
  76. data/lib/support_desk/summary.rb +70 -0
  77. data/lib/support_desk/test_helpers.rb +137 -0
  78. data/lib/support_desk/timeline.rb +104 -0
  79. data/lib/support_desk/topic.rb +290 -0
  80. data/lib/support_desk/topic_tree.rb +214 -0
  81. data/lib/support_desk/version.rb +5 -0
  82. data/lib/support_desk/wizard.rb +392 -0
  83. data/lib/support_desk.rb +288 -0
  84. metadata +229 -0
@@ -0,0 +1,1124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # A case — and a case is one conversation.
5
+ #
6
+ # ticket = alice.ask_support!("El viaje no aparece verificado", about: ride)
7
+ # ticket.reference # => "T-AB12CD"
8
+ # ticket.assign!(to: lucia, by: lucia)
9
+ # ticket.reply!("Hola, lo estamos revisando", by: lucia)
10
+ # ticket.close!(by: lucia)
11
+ #
12
+ # == The transitions
13
+ #
14
+ # Every transition takes `by:` (falling back to SupportDesk::Current.actor,
15
+ # raising ActorMissing when there is nobody), runs under the ticket's row
16
+ # lock, writes the ticket, its assignment row and exactly ONE event row in
17
+ # one transaction, and emits its events only once that transaction has
18
+ # committed. Repeating a transition that has already happened returns
19
+ # `self` and writes nothing; a transition that can't happen from here
20
+ # raises SupportDesk::InvalidTransition.
21
+ #
22
+ # == Awaiting, and the clocks
23
+ #
24
+ # `awaiting` says who owes the next word. It is maintained by #register!,
25
+ # which the gem subscribes to chats' `:message_created` — so a message
26
+ # typed in the app, mirrored in by email, or posted by a bot all move the
27
+ # same clock, and nothing has to remember to call anything.
28
+ class Ticket < ApplicationRecord
29
+ self.table_name = "support_desk_tickets"
30
+
31
+ STATUSES = %w[open snoozed closed].freeze
32
+ AWAITING_STATES = %w[agent requester none].freeze
33
+ CHANNELS = %i[in_app email intercom api].freeze
34
+
35
+ # Crockford base32: no I, L, O or U, so a reference read aloud down a
36
+ # phone line or typed from a screenshot can't come back wrong.
37
+ REFERENCE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
38
+ REFERENCE_PREFIX = "T-"
39
+ REFERENCE_LENGTH = 6
40
+ REFERENCE_ATTEMPTS = 10
41
+
42
+ acts_as_chat_subject
43
+
44
+ belongs_to :desk, class_name: "SupportDesk::Desk", inverse_of: :tickets
45
+ belongs_to :requester, polymorphic: true
46
+ belongs_to :subject, polymorphic: true, optional: true
47
+ belongs_to :assignee, polymorphic: true, optional: true
48
+ belongs_to :conversation, class_name: "Chats::Conversation", optional: true
49
+ belongs_to :closed_by, polymorphic: true, optional: true
50
+
51
+ has_many :assignments, class_name: "SupportDesk::Assignment", inverse_of: :ticket, dependent: :destroy
52
+ # :delete_all, not :destroy — an Event is readonly once written, and a
53
+ # readonly record refuses to be destroyed. Deleting a ticket is the one
54
+ # thing that takes its timeline with it, and it needs no callbacks.
55
+ has_many :events, class_name: "SupportDesk::Event", inverse_of: :ticket, dependent: :delete_all
56
+ has_many :messages, through: :conversation, source: :messages
57
+
58
+ # `ticket.topic` is a Topic, never a String — the tree is the thing that
59
+ # carries behaviour, so the column casts both ways.
60
+ attribute :topic, Topic::Type.new
61
+ attribute :metadata, default: -> { {} }
62
+
63
+ validates :status, inclusion: { in: STATUSES }
64
+ validates :awaiting, inclusion: { in: AWAITING_STATES }
65
+ validates :opened_via, inclusion: { in: CHANNELS }
66
+ validates :reference, presence: true
67
+
68
+ # --- Scopes ---------------------------------------------------------------
69
+
70
+ scope :open, -> { where(status: "open") }
71
+ scope :closed, -> { where(status: "closed") }
72
+ scope :snoozed, -> { where(status: "snoozed") }
73
+ scope :not_closed, -> { where.not(status: "closed") }
74
+
75
+ scope :assigned, -> { where.not(assignee_id: nil) }
76
+ scope :unassigned, -> { where(assignee_id: nil) }
77
+ scope :assigned_to, lambda { |agent|
78
+ where(assignee_type: agent.class.polymorphic_name, assignee_id: agent.id)
79
+ }
80
+
81
+ scope :awaiting_reply, -> { where(awaiting: "agent") }
82
+ scope :awaiting_requester, -> { where(awaiting: "requester") }
83
+
84
+ # Waiting longer than +duration+ for whoever owes the next word.
85
+ scope :waiting_over, lambda { |duration|
86
+ not_closed.where.not(waiting_since: nil).where(waiting_since: ..duration.ago)
87
+ }
88
+ # Waiting long enough to warn about, but not yet past the promise.
89
+ scope :at_risk, ->(desk_key = nil) { past_sla(:at_risk_after, desk_key: desk_key, but_not: :reply_within) }
90
+ # Past the promise (`config.reply_within`).
91
+ scope :overdue, ->(desk_key = nil) { past_sla(:reply_within, desk_key: desk_key) }
92
+
93
+ scope :about, ->(record) { where(subject_type: record.class.polymorphic_name, subject_id: record.id) }
94
+ scope :about_any, ->(klass) { where(subject_type: klass.polymorphic_name) }
95
+ # Includes descendants: `on_topic(:payments)` finds payments/withdrawal.
96
+ scope :on_topic, lambda { |path|
97
+ path = path.to_s
98
+ where(topic: path).or(where(arel_table[:topic].matches("#{path}/%", nil, true)))
99
+ }
100
+
101
+ scope :for_desk, ->(key) { where(desk: SupportDesk.desk(key)) }
102
+ scope :opened_via, ->(channel) { where(opened_via: channel.to_s) }
103
+ scope :opened_between, ->(range) { where(opened_at: range) }
104
+ scope :closed_between, ->(range) { where(closed_at: range) }
105
+
106
+ scope :most_urgent_first, lambda {
107
+ order(Arel.sql("#{table_name}.priority DESC, " \
108
+ "COALESCE(#{table_name}.waiting_since, #{table_name}.opened_at) ASC"))
109
+ }
110
+ scope :recent_activity_first, -> { order(updated_at: :desc) }
111
+ scope :oldest_first, -> { order(opened_at: :asc, id: :asc) }
112
+ scope :newest_first, -> { order(opened_at: :desc, id: :desc) }
113
+
114
+ # --- Finding & opening ------------------------------------------------------
115
+
116
+ class << self
117
+ # Tickets whose wait has passed one of the desk's thresholds.
118
+ #
119
+ # Measured per DESK, not against whichever desk happens to be the
120
+ # default: two desks can promise different things, and a billing desk
121
+ # that answers in an hour must not be judged by a 24 hour promise.
122
+ # Pass a key to ask about one desk, nothing to ask about all of them.
123
+ def past_sla(threshold, desk_key: nil, but_not: nil)
124
+ keys = desk_key ? [ desk_key.to_sym ] : SupportDesk.config.desks.keys
125
+ clauses = keys.filter_map { |key| sla_clause(key, threshold, but_not) }
126
+ return none if clauses.empty?
127
+
128
+ awaiting_reply.not_closed.where.not(waiting_since: nil).where(clauses.reduce(:or))
129
+ end
130
+
131
+ # The ticket with this reference, case- and prefix-insensitive
132
+ # ("t-ab12cd", "AB12CD" and "T-AB12CD" all find it), or nil.
133
+ def find_by_reference(reference)
134
+ normalized = normalize_reference(reference)
135
+ return nil if normalized.nil?
136
+
137
+ find_by(reference: normalized)
138
+ end
139
+
140
+ # Same, raising ActiveRecord::RecordNotFound.
141
+ def find_by_reference!(reference)
142
+ find_by_reference(reference) ||
143
+ raise(ActiveRecord::RecordNotFound, "no ticket with reference #{reference.inspect}")
144
+ end
145
+
146
+ # The ticket behind a chats conversation, or nil — the hook the
147
+ # `:message_created` subscriber comes in through.
148
+ def for_conversation(conversation)
149
+ return nil if conversation.nil?
150
+
151
+ find_by(conversation_id: conversation.id)
152
+ end
153
+
154
+ # Open a ticket and post its first message. Usually called as
155
+ # `requester.ask_support!(…)`; this is the seam channels and jobs use.
156
+ #
157
+ # Returns the existing open ticket when one already covers the same
158
+ # subject (or, for free-form tickets, the same topic) — posting the
159
+ # message into it, because somebody just typed it.
160
+ def open!(requester:, message: nil, about: nil, topic: nil, files: [], via: :in_app,
161
+ desk: nil, requester_role: nil, title: nil, metadata: {})
162
+ desk ||= SupportDesk.desk
163
+ # The subject is checked BEFORE the topic: "this isn't supportable" is
164
+ # the useful error, and an unsupportable record has no topic to find.
165
+ validate_subject!(about, requester)
166
+ node = resolve_topic!(topic, about, desk, requester, about)
167
+
168
+ 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
187
+ end
188
+
189
+ # A human-friendly, unguessable-enough reference: "T-AB12CD".
190
+ def generate_reference
191
+ REFERENCE_PREFIX + Array.new(REFERENCE_LENGTH) { REFERENCE_ALPHABET[SecureRandom.random_number(32)] }.join
192
+ end
193
+
194
+ # A reference as it is stored: upper case, prefixed, and with
195
+ # Crockford's lookalikes folded in.
196
+ def normalize_reference(reference) # :nodoc:
197
+ return nil if reference.nil?
198
+
199
+ # Crockford's whole point: O reads as 0, I and L read as 1, so a
200
+ # reference read down a phone line or typed off a screenshot still
201
+ # finds its ticket.
202
+ body = reference.to_s.strip.upcase.delete_prefix(REFERENCE_PREFIX).tr("OIL", "011")
203
+ return nil if body.empty?
204
+
205
+ REFERENCE_PREFIX + body
206
+ end
207
+
208
+ # The value the "one open ticket about this" index is built on: the
209
+ # subject when there is one, the topic when there isn't, and a unique
210
+ # value when the supportable said `one_open_ticket: false`.
211
+ def cardinality_key_for(requester:, subject:, topic:) # :nodoc:
212
+ if subject
213
+ return "free:#{SecureRandom.uuid}" unless subject.class.one_open_support_ticket?
214
+
215
+ "subject:#{SupportDesk.actor_key(subject)}"
216
+ else
217
+ "topic:#{topic.path}"
218
+ end
219
+ end
220
+
221
+ private
222
+
223
+ # "on this desk, and waiting longer than its own threshold" — with an
224
+ # upper bound when the caller wants the band between two thresholds
225
+ # (at risk, but not yet breached).
226
+ def sla_clause(key, threshold, but_not)
227
+ config = SupportDesk.config.desk(key)
228
+ duration = config.public_send(threshold)
229
+ return nil if duration.nil?
230
+
231
+ clause = arel_table[:desk_id].in(Desk.where(key: key.to_s).select(:id).arel)
232
+ .and(arel_table[:waiting_since].lteq(duration.ago))
233
+ ceiling = but_not && config.public_send(but_not)
234
+ ceiling ? clause.and(arel_table[:waiting_since].gt(ceiling.ago)) : clause
235
+ end
236
+
237
+ def resolve_topic!(topic, about, desk, requester, subject)
238
+ node = locate_topic!(topic, about, desk)
239
+ unless node.visible_for?(requester)
240
+ raise NotAllowed, "#{requester.class}##{requester.id} may not open a ticket under topic " \
241
+ "#{node.path.inspect} (its only: condition says no)"
242
+ end
243
+
244
+ if node.subject_required? && subject.nil?
245
+ raise NotAllowed, "topic #{node.path.inspect} needs something to be about — pass about:"
246
+ end
247
+
248
+ node
249
+ end
250
+
251
+ def locate_topic!(topic, about, desk)
252
+ tree = desk.config.topics
253
+
254
+ if topic
255
+ tree.find(topic.to_s) ||
256
+ raise(UnknownTopic, "no topic #{topic.inspect} on desk #{desk.key} " \
257
+ "(known: #{tree.map(&:path).sort.join(", ")})")
258
+ elsif about
259
+ path = about.respond_to?(:support_topic) ? about.support_topic : nil
260
+ tree.find(path.to_s) ||
261
+ raise(UnknownTopic, "#{about.class} is supportable under topic #{path.inspect}, which isn't in " \
262
+ "desk #{desk.key}'s topic tree")
263
+ else
264
+ tree.free_form_leaf ||
265
+ raise(UnknownTopic, "desk #{desk.key} has no free-form topic to open a subject-less ticket under. " \
266
+ "Add `other` to its topics block, or pass topic:")
267
+ end
268
+ end
269
+
270
+ def validate_subject!(about, requester)
271
+ return if about.nil?
272
+
273
+ unless about.respond_to?(:supportable?) && about.supportable?
274
+ raise NotSupportable, "#{about.class} isn't supportable — add `supportable topic: :something` to it"
275
+ end
276
+
277
+ return if about.supportable_by?(requester)
278
+
279
+ raise NotAllowed, "#{requester.class}##{requester.id} may not open a ticket about " \
280
+ "#{about.class}##{about.id}"
281
+ end
282
+
283
+ def open_ticket_for(requester:, desk:, cardinality_key:)
284
+ not_closed.find_by(requester: requester, desk: desk, cardinality_key: cardinality_key)
285
+ end
286
+
287
+ def enforce_rate_limit!(requester, desk)
288
+ limit = desk.config.open_rate_limit
289
+ return if limit.nil?
290
+
291
+ window = Time.current - limit[:within].to_i
292
+ recent = where(requester: requester, desk: desk).where(opened_at: window..).count
293
+ return if recent < limit[:to]
294
+
295
+ raise RateLimited, "#{requester.class}##{requester.id} has opened #{recent} tickets in the last " \
296
+ "#{limit[:within].inspect} (limit #{limit[:to]})"
297
+ end
298
+
299
+ # The wall from 10 §Abuse. Checked before the insert and NOT under a
300
+ # lock, so it is advisory by design: two requests racing about two
301
+ # different things can both pass it and leave a requester one over.
302
+ # The alternative is locking the host's own requester row on every
303
+ # open, which trades a real contention risk for an imaginary
304
+ # correctness one — nobody is harmed by a sixth open ticket.
305
+ def enforce_open_ticket_cap!(requester, desk)
306
+ cap = desk.config.max_open_tickets
307
+ return if cap.nil?
308
+
309
+ current = not_closed.where(requester: requester, desk: desk).count
310
+ return if current < cap
311
+
312
+ raise TooManyOpenTickets, "#{requester.class}##{requester.id} already has #{current} open tickets " \
313
+ "(max_open_tickets is #{cap})"
314
+ end
315
+
316
+ # Create the ticket, its conversation and its `opened` event in one
317
+ # transaction. A collision on the cardinality index means somebody
318
+ # else opened the same ticket a millisecond ago — we hand back theirs.
319
+ #
320
+ # Returns [ticket, inserted?] so the caller can tell "I opened this"
321
+ # from "I found this": they are the same ticket, and a very different
322
+ # thing to announce.
323
+ def insert_ticket!(requester:, desk:, node:, about:, via:, requester_role:, title:, metadata:,
324
+ cardinality_key:)
325
+ attempts = 0
326
+ begin
327
+ attempts += 1
328
+ ticket, opened = transaction do
329
+ ticket = create!(
330
+ desk: desk, requester: requester, requester_role: requester_role, subject: about,
331
+ topic: node, title: title.presence, reference: unique_reference, status: "open",
332
+ awaiting: "agent", priority: node.priority, opened_via: via.to_s,
333
+ opened_at: Time.current, cardinality_key: cardinality_key, metadata: metadata
334
+ )
335
+ conversation = Chats::Conversation.direct_between!(requester, desk, about: ticket)
336
+ ticket.update!(conversation_id: conversation.id, waiting_since: ticket.opened_at)
337
+ # Through the same writer every other transition uses (`send`
338
+ # because it is private and we are the class, not the record),
339
+ # 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 }
342
+ end
343
+ [ ticket, opened ]
344
+ end
345
+ ticket.send(:publish_transition, opened, :opened, requester, nil)
346
+ [ ticket, true ]
347
+ rescue ActiveRecord::RecordNotUnique
348
+ existing = open_ticket_for(requester: requester, desk: desk, cardinality_key: cardinality_key)
349
+ return [ existing, false ] if existing
350
+ raise if attempts >= 2
351
+
352
+ retry
353
+ end
354
+ end
355
+
356
+ def unique_reference
357
+ REFERENCE_ATTEMPTS.times do
358
+ reference = generate_reference
359
+ return reference unless exists?(reference: reference)
360
+ end
361
+
362
+ raise Error, "couldn't generate a free ticket reference in #{REFERENCE_ATTEMPTS} attempts"
363
+ end
364
+
365
+ def post_opening_message(ticket, message, files)
366
+ return ticket if message.blank? && files.blank?
367
+
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
374
+ end
375
+ end
376
+
377
+ # --- Readers ----------------------------------------------------------------
378
+
379
+ # What this ticket is called: its own title when a channel gave it one,
380
+ # else the subject's label, else the topic's.
381
+ def label
382
+ title.presence || subject&.support_label || topic&.label || reference
383
+ end
384
+
385
+ # chats' context line for the conversation behind the ticket.
386
+ def chat_subject_label = label
387
+
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.
391
+ def chat_locked?
392
+ closed? && desk_config.closed_tickets == :locked
393
+ end
394
+
395
+ def chat_locked_notice
396
+ I18n.t("support_desk.thread.closed_notice")
397
+ end
398
+
399
+ def open? = status == "open"
400
+ def closed? = status == "closed"
401
+ def snoozed? = status == "snoozed"
402
+ def assigned? = assignee_id.present?
403
+ def unassigned? = !assigned?
404
+ def reopened? = reopen_count.to_i.positive?
405
+
406
+ # True when the desk owes the next word.
407
+ def awaiting_reply? = awaiting == "agent"
408
+ def awaiting_requester? = awaiting == "requester"
409
+
410
+ def about?(record)
411
+ return false if record.nil?
412
+
413
+ subject_type == record.class.polymorphic_name && subject_id.to_s == record.id.to_s
414
+ end
415
+
416
+ def assigned_to?(agent)
417
+ return false if agent.nil? || agent.is_a?(Symbol) || unassigned?
418
+
419
+ assignee_type == agent.class.polymorphic_name && assignee_id.to_s == agent.id.to_s
420
+ end
421
+
422
+ # How long the current wait has been going on, or nil when nobody owes
423
+ # anything.
424
+ def waiting_for
425
+ return nil if waiting_since.nil?
426
+
427
+ ActiveSupport::Duration.build((Time.current - waiting_since).to_i)
428
+ end
429
+
430
+ # Waiting long enough to warn about, short of the promise.
431
+ def at_risk?
432
+ return false unless awaiting_reply? && waiting_for && desk_config.at_risk_after
433
+
434
+ waiting_for >= desk_config.at_risk_after && !overdue?
435
+ end
436
+
437
+ # Past `config.reply_within`.
438
+ def overdue?
439
+ return false unless awaiting_reply? && waiting_for && desk_config.reply_within
440
+
441
+ waiting_for >= desk_config.reply_within
442
+ end
443
+
444
+ # How long the requester waited for a first human answer.
445
+ def time_to_first_reply
446
+ return nil if first_agent_reply_at.nil? || opened_at.nil?
447
+
448
+ ActiveSupport::Duration.build((first_agent_reply_at - opened_at).to_i)
449
+ end
450
+
451
+ # How long the case stayed open.
452
+ def time_to_close
453
+ return nil if closed_at.nil? || opened_at.nil?
454
+
455
+ ActiveSupport::Duration.build((closed_at - opened_at).to_i)
456
+ end
457
+
458
+ # Which channel this ticket was opened through, as a Symbol.
459
+ def opened_via
460
+ super&.to_sym
461
+ end
462
+
463
+ # Every channel the case can be answered through. 0.1 ships in-app
464
+ # only; the email channel adds to this list in 0.2.
465
+ def channels = [ opened_via ].compact
466
+
467
+ # "in app · email" — the channels, in the reader's language.
468
+ def channels_summary
469
+ channels.map { |channel| I18n.t("support_desk.channels.#{channel}") }.join(" · ")
470
+ end
471
+
472
+ # The internal notes agents left, newest last.
473
+ def notes = events.notes.chronological
474
+
475
+ # This desk's slice of the configuration — the thresholds and policies
476
+ # that decide what this ticket's predicates mean.
477
+ def desk_config
478
+ desk&.config || SupportDesk.config.default_desk
479
+ end
480
+
481
+ # --- Transitions ------------------------------------------------------------
482
+
483
+ # Answer the requester. The message is sent BY the desk and AUTHORED by
484
+ # the agent, so the requester sees one counterpart with a signature and
485
+ # the console knows who wrote it.
486
+ #
487
+ # Honours `config.reply_policy`: under :anyone an unheld ticket is taken
488
+ # by whoever answers first and a drop-in on somebody else's ticket is
489
+ # recorded; under :take_over the drop-in takes it; under :assignee_only
490
+ # it raises SupportDesk::NotAllowed. Returns the Chats::Message.
491
+ def reply!(body = nil, by: nil, files: [], request: nil)
492
+ actor = resolve_actor(by)
493
+ 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
505
+ end
506
+
507
+ # An internal note: in the timeline and the console, never in the
508
+ # conversation, never mirrored to any channel. Returns the Event.
509
+ def note!(body, by: nil, request: nil)
510
+ actor = resolve_actor(by)
511
+ ensure_agent!(actor)
512
+ raise ArgumentError, "a note needs something to say" if body.blank?
513
+
514
+ event = write_transition!(:note, actor: actor, request: request) { { "note" => body.to_s } }
515
+ SupportDesk.emit_after_commit(:note_added, self, event) if event
516
+ event
517
+ end
518
+
519
+ # Give the ticket to an agent. `assign!(to: lucia, by: lucia)` is
520
+ # somebody taking it; `assign!(to: pedro, by: admin)` is somebody being
521
+ # handed it. Repeating an assignment to the current holder does nothing.
522
+ def assign!(to:, by: nil, reason: nil, note: nil, request: nil)
523
+ actor = resolve_actor(by)
524
+ ensure_assignable!(to)
525
+ raise InvalidTransition, "can't assign a closed ticket — reopen it first" if closed?
526
+
527
+ reason ||= to == actor ? :taken : :assigned
528
+ assignment = nil
529
+
530
+ event = write_transition!(:assigned, actor: actor, request: request) do
531
+ next false if assigned_to?(to)
532
+
533
+ assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: reason, note: note)
534
+ update!(assignee: to)
535
+ { "assignee" => SupportDesk.actor_key(to), "reason" => reason.to_s }
536
+ end
537
+ return self unless event
538
+
539
+ announce_assignment!(to, first: assignments.count <= 1)
540
+ SupportDesk.emit_after_commit(:ticket_assigned, self, assignment)
541
+ self
542
+ end
543
+
544
+ # `assign!` said by the person holding the ticket — with a note for
545
+ # whoever picks it up. Hand-off notes are always internal.
546
+ def hand_off!(to:, note: nil, by: nil, request: nil)
547
+ 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
552
+ ensure_assignable!(to)
553
+ raise InvalidTransition, "can't hand off a closed ticket" if closed?
554
+
555
+ from = assignee
556
+ assignment = nil
557
+ event = write_transition!(:handed_off, actor: actor, request: request) do
558
+ next false if assigned_to?(to)
559
+
560
+ assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: :handed_off, note: note,
561
+ release_reason: :handed_off)
562
+ update!(assignee: to)
563
+ { "from" => SupportDesk.actor_key(from), "to" => SupportDesk.actor_key(to), "note" => note }
564
+ end
565
+ return self unless event
566
+
567
+ announce_assignment!(to, first: false)
568
+ SupportDesk.emit_after_commit(:ticket_handed_off, self, assignment, from: from, note: note)
569
+ self
570
+ end
571
+
572
+ # Put the ticket back in the unassigned pile.
573
+ def release!(by: nil, reason: :released, request: nil)
574
+ actor = resolve_actor(by)
575
+ ensure_agent!(actor)
576
+ raise InvalidTransition, "can't release a closed ticket" if closed?
577
+
578
+ from = assignee
579
+ event = write_transition!(:released, actor: actor, request: request) do
580
+ next false if unassigned?
581
+
582
+ assignments.open.each { |assignment| assignment.release!(reason: reason) }
583
+ update!(assignee: nil)
584
+ { "from" => SupportDesk.actor_key(from), "reason" => reason.to_s }
585
+ end
586
+ return self unless event
587
+
588
+ SupportDesk.emit_after_commit(:ticket_released, self, from: from, reason: reason.to_sym)
589
+ self
590
+ end
591
+
592
+ # Close the case. Closing a closed ticket is a no-op, not an error.
593
+ def close!(by: nil, request: nil)
594
+ actor = resolve_actor(by)
595
+ ensure_agent!(actor)
596
+
597
+ event = write_transition!(:closed, actor: actor, request: request) do
598
+ next false if closed?
599
+
600
+ assignments.open.each { |assignment| assignment.release!(reason: :closed) }
601
+ update!(status: "closed", closed_at: Time.current, closed_by: record_actor(actor),
602
+ awaiting: "none", waiting_since: nil)
603
+ {}
604
+ end
605
+ return self unless event
606
+
607
+ SupportDesk.emit_after_commit(:ticket_closed, self, by: actor)
608
+ self
609
+ end
610
+
611
+ # Bring a closed case back. Also what a requester's reply does on a desk
612
+ # configured `closed_tickets: :reopen_on_reply` (the default).
613
+ #
614
+ # A reopened case goes back to whoever handled it, when they can still
615
+ # take it: they already know the story. When they can't, it returns to
616
+ # the pool.
617
+ def reopen!(by: nil, request: nil)
618
+ actor = resolve_actor(by)
619
+
620
+ event = write_transition!(:reopened, actor: actor, request: request) do
621
+ next false unless closed?
622
+
623
+ # Order matters: waiting_since is DERIVED from awaiting, so awaiting
624
+ # has to be the reopened value before it is read. Computing both in
625
+ # one update! hash reads the closed ticket's "none" and stores nil —
626
+ # a reopened case that no SLA scope can see.
627
+ assign_attributes(status: "open", closed_at: nil, closed_by: nil,
628
+ reopen_count: reopen_count.to_i + 1, awaiting: awaiting_from_clocks)
629
+ self.waiting_since = waiting_since_from_clocks
630
+ save!
631
+ restore_assignment!(by: actor)
632
+ { "reopen_count" => reopen_count }
633
+ end
634
+ return self unless event
635
+
636
+ SupportDesk.emit_after_commit(:ticket_reopened, self, by: actor)
637
+ self
638
+ end
639
+
640
+ # Refile the case. Misfiling is normal — the wizard can only offer the
641
+ # tree, and people describe problems in their own words.
642
+ def change_topic!(to:, by: nil, request: nil)
643
+ actor = resolve_actor(by)
644
+ # Agents may file onto any topic in the tree, including ones no
645
+ # requester is offered (`only:`); requesters may not file at all.
646
+ ensure_agent!(actor)
647
+ node = desk_config.topics.find(to.to_s) ||
648
+ raise(UnknownTopic, "no topic #{to.inspect} on desk #{desk.key}")
649
+
650
+ from = topic
651
+ event = write_transition!(:topic_changed, actor: actor, request: request) do
652
+ next false if topic == node
653
+
654
+ update!(topic: node, priority: [ priority.to_i, node.priority ].max,
655
+ cardinality_key: recomputed_cardinality_key(subject: subject, topic: node))
656
+ { "from" => from&.path, "to" => node.path }
657
+ end
658
+ return self unless event
659
+
660
+ SupportDesk.emit_after_commit(:ticket_topic_changed, self, from: from, to: node, by: actor)
661
+ self
662
+ end
663
+
664
+ # Point a free-form ticket at the record it turned out to be about.
665
+ def attach_subject!(record, by: nil, request: nil)
666
+ actor = resolve_actor(by)
667
+ ensure_agent!(actor)
668
+ unless record.respond_to?(:supportable?) && record.supportable?
669
+ raise NotSupportable, "#{record.class} isn't supportable — add `supportable topic: :something` to it"
670
+ end
671
+
672
+ event = write_transition!(:subject_attached, actor: actor, request: request) do
673
+ next false if about?(record)
674
+
675
+ update!(subject: record,
676
+ cardinality_key: recomputed_cardinality_key(subject: record, topic: topic))
677
+ { "subject" => SupportDesk.actor_key(record) }
678
+ end
679
+ return self unless event
680
+
681
+ SupportDesk.emit_after_commit(:subject_attached, self, record, by: actor)
682
+ self
683
+ end
684
+
685
+ # --- Message registration -----------------------------------------------------
686
+
687
+ # Fold a chats message into the case: who owes the next word, the SLA
688
+ # clocks, and a reopen when a requester writes into a closed ticket.
689
+ #
690
+ # Idempotent on the message id, so a redelivered event never
691
+ # double-counts, and safe to call by hand after importing a transcript.
692
+ def register!(message)
693
+ 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
742
+ self
743
+ end
744
+
745
+ # --- Presenters ---------------------------------------------------------------
746
+
747
+ # Everything an agent needs to see next to the transcript.
748
+ def context_card = ContextCard.new(self)
749
+
750
+ # One line for a list row, a Telegram message, a Slack block.
751
+ def summary = Summary.new(self)
752
+
753
+ # Messages and events merged by time — the unified activity view.
754
+ def timeline = Timeline.new(self)
755
+
756
+ # Who held this ticket, in order.
757
+ def assignment_history = assignments.chronological
758
+
759
+ # Exactly the verbs +agent+ may use on this case right now, filtered by
760
+ # policy, status and duty.
761
+ #
762
+ # == This is authorization, not decoration
763
+ #
764
+ # It reads like a list of buttons and it is one, but `SupportDesk
765
+ # ::Console` also REFUSES any verb this method doesn't return — a
766
+ # console that renders one set of affordances and accepts a wider one
767
+ # has a UI that lies, and two agents working the same queue hit that
768
+ # every day: the second presses a button the first already made
769
+ # impossible. So treat this as a security boundary:
770
+ #
771
+ # * Narrowing it takes a verb away from every console, silently. The
772
+ # button disappears AND the endpoint starts refusing.
773
+ # * Widening it hands one out. Nothing else re-checks; the transitions
774
+ # raise for their own reasons, but "may this agent press this" is
775
+ # answered here and nowhere else.
776
+ # * It must agree with `may_reply?` and with what the transitions
777
+ # actually allow. Where they disagree, an agent sees a button that
778
+ # only ever errors, or no button for something they may do.
779
+ #
780
+ # `test/console/console_offered_actions_test.rb` writes the expected set
781
+ # down state by state and checks it twice — against this method, and
782
+ # against what the console accepts over HTTP — so a change here fails a
783
+ # test instead of quietly moving the boundary.
784
+ def actions_for(agent)
785
+ return [] unless agent.respond_to?(:support_agent?) && agent.support_agent?
786
+ # Off duty is a real answer: the console can still show the case, and
787
+ # an agent passing by can still leave a note, but nothing that speaks
788
+ # to the requester is offered to somebody who isn't working.
789
+ return [ :note ] if agent.respond_to?(:on_duty?) && !agent.on_duty?
790
+
791
+ actions = [ :note ]
792
+ if closed?
793
+ actions << :reopen
794
+ else
795
+ actions << :reply if may_reply?(agent)
796
+ actions << :assign
797
+ actions << :hand_off if assigned_to?(agent)
798
+ actions << :release if assigned?
799
+ actions << :change_topic
800
+ actions << :close
801
+ end
802
+ actions
803
+ end
804
+
805
+ # Whether +agent+ may answer right now under this desk's reply policy.
806
+ def may_reply?(agent)
807
+ return false unless agent.respond_to?(:support_agent?) && agent.support_agent?
808
+ return true unless desk_config.reply_policy == :assignee_only
809
+
810
+ assigned_to?(agent)
811
+ end
812
+
813
+ # Who should hear about activity on this ticket: whoever holds it, or
814
+ # the whole on-duty pool while it's unheld. The gem computes it; the
815
+ # host delivers it.
816
+ def agents_to_notify
817
+ return [ assignee ].compact if assigned?
818
+
819
+ desk.on_duty_agents.to_a
820
+ end
821
+
822
+ # Generic on purpose: a lock screen shouldn't spell out what somebody's
823
+ # support case is about.
824
+ def notification_title
825
+ I18n.t("support_desk.notifications.title", desk: desk.name)
826
+ end
827
+
828
+ # The detail, for the body — visible after unlock.
829
+ def notification_body = label
830
+
831
+ # A GDPR-friendly dump of the case as the requester experienced it:
832
+ # their transcript and the status changes they saw, never internal
833
+ # notes or hand-off reasoning.
834
+ def export
835
+ {
836
+ reference: reference,
837
+ label: label,
838
+ topic: topic&.path,
839
+ topic_label: topic&.full_label,
840
+ subject: subject&.support_label,
841
+ status: status,
842
+ channels: channels.map(&:to_s),
843
+ opened_at: opened_at,
844
+ closed_at: closed_at,
845
+ messages: export_messages,
846
+ events: events.requester_visible.chronological.map do |event|
847
+ { kind: event.kind, at: event.created_at }
848
+ end
849
+ }
850
+ end
851
+
852
+ # --- Internals -----------------------------------------------------------------
853
+
854
+ # Post a message as the requester — what `ask_support!` says first, and
855
+ # what an inbound channel replays.
856
+ def post_requester_message!(body, files: []) # :nodoc:
857
+ requester.message!(conversation, body, files: files)
858
+ end
859
+
860
+ def inspect
861
+ "#<SupportDesk::Ticket #{reference} #{topic&.path} #{label.to_s.inspect} #{status}" \
862
+ "#{" → #{describe_actor(assignee)}" if assigned?}#{waiting_description}>"
863
+ end
864
+
865
+ private
866
+
867
+ def waiting_description
868
+ return "" unless waiting_for
869
+
870
+ state = awaiting_reply? ? "awaiting reply" : "awaiting requester"
871
+ " (#{state} #{ActiveSupport::Duration.build(waiting_for.to_i).inspect})"
872
+ end
873
+
874
+ # --- Actors -------------------------------------------------------------------
875
+
876
+ def resolve_actor(by)
877
+ actor = by || Current.actor
878
+ return actor if actor
879
+
880
+ raise ActorMissing, "no actor: pass by: (an agent, a requester, or :system) or set " \
881
+ "SupportDesk::Current.actor"
882
+ end
883
+
884
+ def record_actor(actor) = actor.is_a?(Symbol) ? nil : actor
885
+
886
+ # The ACTOR of a transition may be `:system` (a job, a sweep); the agent
887
+ # a ticket is handed TO may not — somebody has to be able to answer it.
888
+ def ensure_assignable!(agent)
889
+ if agent.nil? || agent.is_a?(Symbol)
890
+ raise NotAnAgent, "can't assign a ticket to #{agent.inspect} — pass an agent record"
891
+ end
892
+
893
+ ensure_agent!(agent)
894
+ end
895
+
896
+ def ensure_agent!(actor)
897
+ 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}"
902
+ end
903
+
904
+ def describe_actor(actor)
905
+ return actor.to_s if actor.nil? || actor.is_a?(Symbol)
906
+
907
+ actor.try(:support_agent_name) || "#{actor.class}##{actor.id}"
908
+ end
909
+
910
+ # --- Transition plumbing -------------------------------------------------------
911
+
912
+ def ensure_writable!
913
+ return unless chat_locked?
914
+
915
+ raise Locked, "ticket #{reference} is closed and this desk locks closed tickets — reopen it first"
916
+ end
917
+
918
+ def apply_reply_policy!(actor, request: nil)
919
+ # A closed case has no seat to take: an agent adding one last word
920
+ # posts it and the case stays closed. (A REQUESTER writing is what
921
+ # reopens it — see #register!.)
922
+ return if closed?
923
+
924
+ policy = desk_config.reply_policy
925
+
926
+ if unassigned?
927
+ if policy == :assignee_only
928
+ raise NotAllowed, "ticket #{reference} is unassigned and this desk only lets the assignee reply — " \
929
+ "take it first"
930
+ end
931
+
932
+ assign!(to: actor, by: actor, request: request)
933
+ elsif !assigned_to?(actor)
934
+ case policy
935
+ when :assignee_only
936
+ raise NotAllowed, "ticket #{reference} is held by #{describe_actor(assignee)} and this desk only " \
937
+ "lets the assignee reply"
938
+ when :take_over
939
+ assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request)
940
+ else
941
+ write_transition!(:drop_in, actor: actor, request: request) do
942
+ { "assignee" => SupportDesk.actor_key(assignee) }
943
+ end
944
+ end
945
+ end
946
+ end
947
+
948
+ # Run a transition under the row lock: yield, write exactly one event
949
+ # row, and (once everything has committed) broadcast and emit. The block
950
+ # returns the event payload, or false when there is nothing to do.
951
+ def write_transition!(kind, actor:, request: nil, &block)
952
+ event = nil
953
+ with_lock { event = record_transition!(kind, actor: actor, &block) }
954
+ publish_transition(event, kind, actor, request) if event
955
+ event
956
+ end
957
+
958
+ # The half that writes, for callers who ALREADY hold the row (the
959
+ # reopen inside #register!) or who are still inside the transaction that
960
+ # created the ticket (the `opened` event). Returns the Event, or nil
961
+ # when the block says there was nothing to do.
962
+ def record_transition!(kind, actor:) # :nodoc:
963
+ payload = yield
964
+ return nil if payload == false || payload.nil?
965
+
966
+ Event.record!(ticket: self, kind: kind, actor: actor, payload: payload.compact)
967
+ end
968
+
969
+ # The half that tells the world, once the write is durable. Every
970
+ # transition goes through here — `ticket_transitioned` is the audit-log
971
+ # hook, so a transition that skipped it would be a hole in the host's
972
+ # audit trail, not a missing nicety.
973
+ def publish_transition(event, kind, actor, request) # :nodoc:
974
+ return nil if event.nil?
975
+
976
+ broadcast_change
977
+ SupportDesk.emit_after_commit(:ticket_transitioned, self, kind.to_sym, by: actor,
978
+ request: request || Current.request,
979
+ payload: event.payload)
980
+ event
981
+ end
982
+
983
+ def announce_assignment!(agent, first:)
984
+ mode = desk_config.announce_assignments
985
+ return if mode == :never
986
+ return if mode == :first_only && !first
987
+ return unless agent.respond_to?(:support_agent_name)
988
+
989
+ key = first ? "support_desk.system.assigned" : "support_desk.system.reassigned"
990
+ conversation&.post_system_message!(I18n.t(key, agent: agent.support_agent_name))
991
+ end
992
+
993
+ # --- Registration plumbing ------------------------------------------------------
994
+
995
+ # Whether this message is already folded in. The last-id check catches
996
+ # the common redelivery; the clock check catches the rest, because a
997
+ # REPLAY can arrive in any order and an older message must never rewind
998
+ # `awaiting`, restart an SLA clock, or reopen a case that was closed
999
+ # after it.
1000
+ #
1001
+ # The comparison is `<=`, so a message whose timestamp already sits on
1002
+ # the clock counts as folded in: idempotency is the documented promise,
1003
+ # and the cost of the rare tie is one clock that doesn't advance until
1004
+ # the next message.
1005
+ def registered?(message)
1006
+ return true if last_registered_message_id.present? && last_registered_message_id.to_s == message.id.to_s
1007
+
1008
+ clock = case role_of(message)
1009
+ when :requester then last_requester_message_at
1010
+ when :agent then last_agent_message_at
1011
+ end
1012
+
1013
+ clock.present? && message.created_at <= clock
1014
+ end
1015
+
1016
+ def opening_message?
1017
+ last_registered_message_id.blank? && last_requester_message_at.nil? && last_agent_message_at.nil?
1018
+ end
1019
+
1020
+ # Who sent this message, in the only terms the case cares about.
1021
+ def role_of(message)
1022
+ return :system if message.respond_to?(:system?) && message.system?
1023
+ return :agent if same_record?(message.sender_type, message.sender_id, desk)
1024
+ return :requester if same_record?(message.sender_type, message.sender_id, requester)
1025
+
1026
+ :system
1027
+ end
1028
+
1029
+ def same_record?(type, id, record)
1030
+ return false if record.nil? || type.nil?
1031
+
1032
+ type == record.class.polymorphic_name && id.to_s == record.id.to_s
1033
+ end
1034
+
1035
+ # What "one open ticket about this" means for this ticket, recomputed
1036
+ # because the case now says it is about something else. Refusing a
1037
+ # collision here is the point: silently keeping the old key leaves the
1038
+ # thing it used to be about blocked, and the thing it IS about free for
1039
+ # a second ticket.
1040
+ def recomputed_cardinality_key(subject:, topic:)
1041
+ key = self.class.cardinality_key_for(requester: requester, subject: subject, topic: topic)
1042
+ conflict = self.class.not_closed.where(requester: requester, desk: desk, cardinality_key: key)
1043
+ .where.not(id: id).first
1044
+ if conflict
1045
+ raise InvalidTransition,
1046
+ "#{requester.class}##{requester.id} already has an open ticket about that " \
1047
+ "(#{conflict.reference}) — close or merge it first"
1048
+ end
1049
+
1050
+ key
1051
+ end
1052
+
1053
+ # Reopening restores the last holder's seat (reason "reopened") when
1054
+ # they can still answer, so `assignee` and the open assignment row never
1055
+ # disagree on a live ticket. A closed ticket keeps its assignee as the
1056
+ # record of who dealt with it, with no open row — that pair is the one
1057
+ # shape `doctor` expects to see.
1058
+ def restore_assignment!(by:)
1059
+ return if unassigned?
1060
+
1061
+ if assignee.respond_to?(:support_agent?) && assignee.support_agent?
1062
+ Assignment.open!(ticket: self, agent: assignee, by: by, reason: :reopened)
1063
+ else
1064
+ update!(assignee: nil)
1065
+ end
1066
+ end
1067
+
1068
+ def awaiting_from_clocks
1069
+ return "agent" if last_requester_message_at && (last_agent_message_at.nil? ||
1070
+ last_requester_message_at > last_agent_message_at)
1071
+ return "requester" if last_agent_message_at
1072
+
1073
+ "agent"
1074
+ end
1075
+
1076
+ def waiting_since_from_clocks
1077
+ case awaiting
1078
+ when "agent" then last_requester_message_at || opened_at
1079
+ when "requester" then last_agent_message_at
1080
+ end
1081
+ end
1082
+
1083
+ def announce_registration(message, role:, opening:, reopened:)
1084
+ broadcast_change
1085
+
1086
+ SupportDesk.emit_after_commit(:ticket_reopened, self, by: requester) if reopened
1087
+
1088
+ case role
1089
+ when :requester
1090
+ SupportDesk.emit_after_commit(:requester_replied, self, message) unless opening
1091
+ when :agent
1092
+ SupportDesk.emit_after_commit(:agent_replied, self, message)
1093
+ end
1094
+ end
1095
+
1096
+ # --- Realtime --------------------------------------------------------------------
1097
+
1098
+ # Turbo 8 refreshes for the two console surfaces: the ticket page and
1099
+ # the queue. Requester-side realtime is chats'.
1100
+ def broadcast_change
1101
+ return unless respond_to?(:broadcast_refresh_later_to)
1102
+
1103
+ ActiveRecord.after_all_transactions_commit do
1104
+ broadcast_refresh_later_to(self, :console)
1105
+ broadcast_refresh_later_to(desk, :queue)
1106
+ end
1107
+ rescue StandardError => e
1108
+ SupportDesk.logger&.warn("[support_desk] broadcast failed for ticket #{id}: #{e.class}: #{e.message}")
1109
+ end
1110
+
1111
+ def export_messages
1112
+ return [] if conversation.nil?
1113
+
1114
+ conversation.messages.visible.oldest_first.map do |message|
1115
+ {
1116
+ at: message.created_at,
1117
+ from: role_of(message) == :requester ? "you" : "support",
1118
+ body: message.visible_body,
1119
+ attachments: message.try(:files)&.map { |file| file.try(:filename).to_s } || []
1120
+ }
1121
+ end
1122
+ end
1123
+ end
1124
+ end