support_desk 0.5.0 → 0.6.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +151 -6
  4. data/app/controllers/support_desk/tickets_controller.rb +6 -4
  5. data/app/helpers/support_desk/engine_helper.rb +17 -7
  6. data/app/views/chats/slots/_inbox_top.html.erb +1 -1
  7. data/app/views/support_desk/launcher/_badge.html.erb +11 -3
  8. data/app/views/support_desk/launcher/_launcher.html.erb +2 -2
  9. data/lib/generators/support_desk/install_generator.rb +12 -0
  10. data/lib/generators/support_desk/templates/add_owners_to_support_desk.rb.erb +73 -0
  11. data/lib/generators/support_desk/templates/add_replay_to_support_desk.rb.erb +73 -0
  12. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +6 -1
  13. data/lib/generators/support_desk/templates/initializer.rb +12 -0
  14. data/lib/generators/support_desk/upgrade_generator.rb +22 -2
  15. data/lib/support_desk/brief.rb +1 -1
  16. data/lib/support_desk/configuration.rb +106 -6
  17. data/lib/support_desk/context_card.rb +1 -1
  18. data/lib/support_desk/current.rb +22 -0
  19. data/lib/support_desk/events.rb +41 -0
  20. data/lib/support_desk/launcher.rb +14 -4
  21. data/lib/support_desk/models/assistant.rb +37 -9
  22. data/lib/support_desk/models/concerns/requester.rb +3 -2
  23. data/lib/support_desk/models/desk.rb +40 -7
  24. data/lib/support_desk/models/event.rb +15 -1
  25. data/lib/support_desk/models/ticket/assistance.rb +36 -7
  26. data/lib/support_desk/models/ticket.rb +80 -25
  27. data/lib/support_desk/queue.rb +3 -1
  28. data/lib/support_desk/replay.rb +109 -0
  29. data/lib/support_desk/timeline.rb +1 -1
  30. data/lib/support_desk/version.rb +1 -1
  31. data/lib/support_desk.rb +58 -23
  32. metadata +6 -6
@@ -374,7 +374,7 @@ module SupportDesk
374
374
  end
375
375
  ensure_agent!(actor)
376
376
 
377
- event = write_transition!(:assistant_resumed, actor: actor, request: request) do
377
+ event = write_transition!(:assistant_resumed, actor: actor, request: request, offers_turn: true) do
378
378
  next false unless assistant_paused?
379
379
 
380
380
  update!(assistant_paused_at: nil, assistant_paused_reason: nil)
@@ -383,7 +383,7 @@ module SupportDesk
383
383
  return self unless event
384
384
 
385
385
  SupportDesk.emit_after_commit(:assistant_resumed, self, by: actor)
386
- emit_assistant_turn if awaiting_reply?
386
+ emit_assistant_turn(record: false) if awaiting_reply?
387
387
  self
388
388
  end
389
389
 
@@ -710,6 +710,8 @@ module SupportDesk
710
710
  # would make every draft stale the moment it was written.
711
711
  candidate.proposed_turn = assistant_turn
712
712
  candidate.save!
713
+ Event.record!(ticket: self, kind: :draft_proposed, actor: assistant,
714
+ payload: { "draft" => candidate.id.to_s, "turn" => candidate.proposed_turn })
713
715
  reset_draft_associations!
714
716
  stamp_assistant_action!
715
717
  broadcast_change
@@ -841,16 +843,43 @@ module SupportDesk
841
843
  # It carries the turn, so a job can check `ticket.assistant_turn ==
842
844
  # turn` before spending money. A hook that raises is reported: a
843
845
  # broken subscriber must not roll back the message that triggered it.
844
- def emit_assistant_turn(message = nil)
845
- return unless open? && awaiting_reply?
846
-
847
- assistant = self.assistant
846
+ #
847
+ # It also writes the turn down — an `assistant_turn` event row, in the
848
+ # transaction that produced the turn — so a durable reader
849
+ # (SupportDesk::Events.replay) hears about it even when this process
850
+ # dies before the in-memory event fires. `record: false` is the
851
+ # re-emission sweep, which repeats an old turn and owes no new row.
852
+ def emit_assistant_turn(message = nil, record: true)
853
+ assistant = assistant_owed_turn
848
854
  return if assistant.nil?
849
- return unless assistant_policy(assistant).may_observe?
850
855
 
856
+ record_assistant_turn!(assistant, message) if record
851
857
  SupportDesk.emit_after_commit(:assistant_turn, self, assistant, message, turn: assistant_turn)
858
+ end
859
+
860
+ # The `assistant_turn` row, on its own: for a transition that hands the
861
+ # case to her, which writes it under its lock and announces it after.
862
+ def record_assistant_turn!(assistant = assistant_owed_turn, message = nil) # :nodoc:
863
+ return if assistant.nil?
864
+
865
+ Event.record!(ticket: self, kind: :assistant_turn, actor: :system,
866
+ payload: { "turn" => assistant_turn, "assistant" => assistant.key,
867
+ "message" => message&.id&.to_s }.compact)
868
+ end
869
+
870
+ # Whom the case owes a turn right now, or nil. A hook that raises (a
871
+ # host's `cap`) is reported and owes nothing: a broken subscriber or
872
+ # policy must not roll back the message that triggered it.
873
+ def assistant_owed_turn
874
+ return nil unless open? && awaiting_reply?
875
+
876
+ assistant = self.assistant
877
+ return nil if assistant.nil?
878
+
879
+ assistant if assistant_policy(assistant).may_observe?
852
880
  rescue StandardError => e
853
881
  SupportDesk.report_error(e, context: { hook: :assistant_turn, ticket: id })
882
+ nil
854
883
  end
855
884
 
856
885
  # A human transition that lowers the effective level below :reply while
@@ -140,6 +140,12 @@ module SupportDesk
140
140
  }
141
141
 
142
142
  scope :for_desk, ->(key) { where(desk: SupportDesk.desk(key)) }
143
+ # Cases on +owner+'s desks (nil: the desks with no owner) — and, as
144
+ # `on_current_desks`, on SupportDesk::Current.owner's. Every screen and
145
+ # count a requester sees reads through the second: one person can be a
146
+ # customer of two owners, and neither may show the other's cases.
147
+ scope :owned_by, ->(owner) { where(desk_id: Desk.owned_by(owner).select(:id)) }
148
+ scope :on_current_desks, -> { owned_by(SupportDesk::Current.owner) }
143
149
  scope :opened_via, ->(channel) { where(opened_via: channel.to_s) }
144
150
  scope :opened_between, ->(range) { where(opened_at: range) }
145
151
  scope :closed_between, ->(range) { where(closed_at: range) }
@@ -160,10 +166,12 @@ module SupportDesk
160
166
  # Measured per DESK, not against whichever desk happens to be the
161
167
  # default: two desks can promise different things, and a billing desk
162
168
  # that answers in an hour must not be judged by a 24 hour promise.
163
- # Pass a key to ask about one desk, nothing to ask about all of them.
169
+ # Pass a key to ask about one desk, nothing to ask about all of them —
170
+ # all of SupportDesk::Current.owner's, each judged by its own settings.
164
171
  def past_sla(threshold, desk_key: nil, but_not: nil)
165
172
  keys = desk_key ? [ desk_key.to_sym ] : SupportDesk.config.desks.keys
166
- clauses = keys.filter_map { |key| sla_clause(key, threshold, but_not) }
173
+ desks = Desk.owned_by(SupportDesk::Current.owner).where(key: keys.map(&:to_s))
174
+ clauses = desks.filter_map { |desk| sla_clause(desk, threshold, but_not) }
167
175
  return none if clauses.empty?
168
176
 
169
177
  awaiting_reply.not_closed.where.not(waiting_since: nil).where(clauses.reduce(:or))
@@ -192,7 +200,8 @@ module SupportDesk
192
200
  find_by(conversation_id: conversation.id)
193
201
  end
194
202
 
195
- # The conversations behind +requester+'s own cases, while they still
203
+ # The conversations behind +requester+'s own cases on the current
204
+ # owner's desks (SupportDesk::Current.owner), while they still
196
205
  # hold its seat: chats' participation and block rules (`inbox_for`),
197
206
  # without the host's `inbox_scope`. Participation is what makes a
198
207
  # conversation theirs to read; the inbox is only where it is listed.
@@ -208,7 +217,8 @@ module SupportDesk
208
217
 
209
218
  tickets = arel_table
210
219
  conversations = Chats::Conversation.arel_table
211
- own_cases = requester.support_tickets.where(tickets[:id].eq(conversations[:subject_id])).select(:conversation_id)
220
+ own_cases = requester.support_tickets.on_current_desks
221
+ .where(tickets[:id].eq(conversations[:subject_id])).select(:conversation_id)
212
222
  Chats::Conversation.inbox_for(requester).direct
213
223
  .where(subject_type: polymorphic_name)
214
224
  .where(id: own_cases)
@@ -279,7 +289,7 @@ module SupportDesk
279
289
  # and lose only the message that was supposed to justify them.
280
290
  transaction(requires_new: true) do
281
291
  next reply_into!(existing, message, files: files, by: opener, by_support: by_support,
282
- request: request, authorize_reuse: authorize_reuse) if existing
292
+ request: request, authorize_reuse: authorize_reuse, via: via) if existing
283
293
 
284
294
  created, inserted = insert_ticket!(
285
295
  requester: requester, desk: desk, node: node, about: about, via: via, opened_by: opener,
@@ -298,7 +308,7 @@ module SupportDesk
298
308
  # Somebody else's INSERT won by a millisecond. Theirs is the case
299
309
  # that exists, so this is a reply into it, policy and all.
300
310
  reply_into!(created, message, files: files, by: opener, by_support: by_support,
301
- request: request, authorize_reuse: authorize_reuse)
311
+ request: request, authorize_reuse: authorize_reuse, via: via)
302
312
  end
303
313
  end
304
314
  end
@@ -455,13 +465,12 @@ module SupportDesk
455
465
  # "on this desk, and waiting longer than its own threshold" — with an
456
466
  # upper bound when the caller wants the band between two thresholds
457
467
  # (at risk, but not yet breached).
458
- def sla_clause(key, threshold, but_not)
459
- config = SupportDesk.config.desk(key)
468
+ def sla_clause(desk, threshold, but_not)
469
+ config = desk.config
460
470
  duration = config.public_send(threshold)
461
471
  return nil if duration.nil?
462
472
 
463
- clause = arel_table[:desk_id].in(Desk.where(key: key.to_s).select(:id).arel)
464
- .and(arel_table[:waiting_since].lteq(duration.ago))
473
+ clause = arel_table[:desk_id].eq(desk.id).and(arel_table[:waiting_since].lteq(duration.ago))
465
474
  ceiling = but_not && config.public_send(but_not)
466
475
  ceiling ? clause.and(arel_table[:waiting_since].gt(ceiling.ago)) : clause
467
476
  end
@@ -644,7 +653,7 @@ module SupportDesk
644
653
  # error, and this whole transaction goes with it.
645
654
  ticket.post_agent_message!(message, files: files, by: by)
646
655
  elsif message.present? || files.present?
647
- ticket.post_requester_message!(message, files: files)
656
+ ticket.post_requester_message!(message, files: files, via: ticket.opened_via)
648
657
  end
649
658
 
650
659
  ticket.send(:record_registration!, posted) if posted
@@ -727,7 +736,7 @@ module SupportDesk
727
736
  # through here, so an existing case is answered under its row lock,
728
737
  # under the desk's reply policy, with the console's authorization hook
729
738
  # running before any of it.
730
- def reply_into!(ticket, message, files:, by:, by_support:, request:, authorize_reuse:)
739
+ def reply_into!(ticket, message, files:, by:, by_support:, request:, authorize_reuse:, via:)
731
740
  if by_support
732
741
  # Only this private outreach path supplies the current turn internally.
733
742
  # Public replies must present the token their harness observed.
@@ -740,7 +749,7 @@ module SupportDesk
740
749
  # the old API's "hand it back": supported, and it writes nothing.
741
750
  next if message.blank? && files.blank?
742
751
 
743
- posted = ticket.post_requester_message!(message, files: files)
752
+ posted = ticket.post_requester_message!(message, files: files, via: via)
744
753
  ticket.send(:record_registration!, posted)
745
754
  end
746
755
  end
@@ -914,9 +923,12 @@ module SupportDesk
914
923
  # it raises SupportDesk::NotAllowed. Returns the Chats::Message.
915
924
  #
916
925
  # `by:` the assistant needs `turn:` and her policy's permission — see
917
- # SupportDesk::Ticket::Assistance. `metadata:` rides on the message
918
- # (provenance for a draft a human sent; hosts nest their own under
919
- # "host").
926
+ # SupportDesk::Ticket::Assistance. A PERSON may pass `turn:` too, and
927
+ # then the reply is conditional: it is posted only if the case is still
928
+ # at the turn they read, and raises SupportDesk::StaleTurn otherwise —
929
+ # the `If-Match` of a reply sent through an API or a review screen.
930
+ # `metadata:` rides on the message (provenance for a draft a human sent;
931
+ # hosts nest their own under "host").
920
932
  def reply!(body = nil, by: nil, files: [], request: nil, metadata: {}, turn: nil)
921
933
  actor = resolve_actor(by)
922
934
  ensure_agent!(actor)
@@ -927,6 +939,9 @@ module SupportDesk
927
939
 
928
940
  # An internal note: in the timeline and the console, never in the
929
941
  # conversation, never mirrored to any channel. Returns the Event.
942
+ #
943
+ # `turn:` makes it conditional, for a person exactly as for the
944
+ # assistant: a note about a case that has moved on raises StaleTurn.
930
945
  def note!(body, by: nil, request: nil, turn: nil)
931
946
  actor = resolve_actor(by)
932
947
  ensure_agent!(actor)
@@ -935,6 +950,15 @@ module SupportDesk
935
950
 
936
951
  assistant = nil
937
952
  event = write_transition!(:note, actor: actor, request: request) do
953
+ if machine || turn
954
+ # Ticket, then conversation, then fold in what chats committed and
955
+ # the gem has not registered yet — exactly as a reply does — so a
956
+ # turn is compared against the case as it really is, not against
957
+ # one whose next question is a lost callback away. A stale turn
958
+ # rolls the folding back with everything else.
959
+ lock_conversation!
960
+ reconcile_unregistered_messages!
961
+ end
938
962
  if machine
939
963
  # Under the lock, from the row: a kill switch that commits while
940
964
  # this call waits for the case is a kill switch that stops it (R9).
@@ -942,6 +966,8 @@ module SupportDesk
942
966
  ensure_current_turn!(turn)
943
967
  policy = assistant_policy(assistant)
944
968
  raise AssistantNotAllowed.new(policy, verb: :note) unless policy.may_observe?
969
+ elsif turn
970
+ ensure_current_turn!(turn)
945
971
  end
946
972
  { "note" => body.to_s }
947
973
  end
@@ -965,7 +991,7 @@ module SupportDesk
965
991
  handing_back = SupportDesk.ai_actor?(to) && !SupportDesk.ai_actor?(actor)
966
992
  assignment = nil
967
993
 
968
- event = write_transition!(:assigned, actor: actor, request: request) do
994
+ event = write_transition!(:assigned, actor: actor, request: request, offers_turn: handing_back) do
969
995
  raise InvalidTransition, "can't assign a closed ticket — reopen it first" if closed?
970
996
  # Every mutable question about a seat is asked HERE, under the row
971
997
  # lock, from the reloaded row: who holds the case, what her policy
@@ -990,7 +1016,7 @@ module SupportDesk
990
1016
 
991
1017
  announce_assignment!(to, first: assignments.count <= 1)
992
1018
  SupportDesk.emit_after_commit(:ticket_assigned, self, assignment)
993
- emit_assistant_turn if handing_back && awaiting_reply?
1019
+ emit_assistant_turn(record: false) if handing_back && awaiting_reply?
994
1020
  self
995
1021
  end
996
1022
 
@@ -1008,7 +1034,7 @@ module SupportDesk
1008
1034
  handing_back = SupportDesk.ai_actor?(to)
1009
1035
  from = nil
1010
1036
  assignment = nil
1011
- event = write_transition!(:handed_off, actor: actor, request: request) do
1037
+ event = write_transition!(:handed_off, actor: actor, request: request, offers_turn: handing_back) do
1012
1038
  unless assigned_to?(actor)
1013
1039
  raise NotTheAssignee, "#{describe_actor(actor)} doesn't hold ticket #{reference} " \
1014
1040
  "(#{assignee ? describe_actor(assignee) : "nobody"} does) — use assign! to override"
@@ -1033,7 +1059,7 @@ module SupportDesk
1033
1059
 
1034
1060
  announce_assignment!(to, first: false)
1035
1061
  SupportDesk.emit_after_commit(:ticket_handed_off, self, assignment, from: from, note: note)
1036
- emit_assistant_turn if handing_back && awaiting_reply?
1062
+ emit_assistant_turn(record: false) if handing_back && awaiting_reply?
1037
1063
  self
1038
1064
  end
1039
1065
 
@@ -1352,9 +1378,27 @@ module SupportDesk
1352
1378
  # --- Internals -----------------------------------------------------------------
1353
1379
 
1354
1380
  # Post a message as the requester — what `ask_support!` says first, and
1355
- # what an inbound channel replays.
1356
- def post_requester_message!(body, files: []) # :nodoc:
1357
- requester.message!(conversation, body, files: files)
1381
+ # what an inbound channel, an importer or a connector replays:
1382
+ #
1383
+ # ticket.post_requester_message!("Sigue sin llegar", via: :email, external_id: "<CAF…@mail.gmail.com>")
1384
+ #
1385
+ # `via:` and `external_id:` are provenance. They ride on the message as
1386
+ # `metadata["support_desk"]`, so a transcript can say which channel each
1387
+ # line came in through, and an importer can tell the message it already
1388
+ # brought over from the one it has not. `:in_app` is where every message
1389
+ # comes from unless somebody says otherwise, so it is never stamped.
1390
+ #
1391
+ # Folded into the clocks like any other requester message — by the chats
1392
+ # subscriber, after commit. Returns the Chats::Message.
1393
+ def post_requester_message!(body, files: [], via: nil, external_id: nil)
1394
+ provenance = { "via" => (via.to_s unless via.nil? || via.to_sym == :in_app),
1395
+ "external_id" => external_id&.to_s }.compact
1396
+ return requester.message!(conversation, body, files: files) if provenance.empty?
1397
+
1398
+ # chats' `message!` takes no metadata; the validations are the same.
1399
+ attributes = { sender: requester, body: body, metadata: { "support_desk" => provenance } }
1400
+ attributes[:files] = files if files.present?
1401
+ conversation.messages.create!(**attributes)
1358
1402
  end
1359
1403
 
1360
1404
  # Post a message as the DESK, signed by the agent who wrote it — every
@@ -1498,6 +1542,10 @@ module SupportDesk
1498
1542
  # must not later turn an already-answered question back into work.
1499
1543
  lock_conversation!
1500
1544
  reconcile_unregistered_messages!
1545
+ # Optional for a person, and checked only once the case is folded in:
1546
+ # a customer message that landed after they read the case is exactly
1547
+ # what makes their answer stale.
1548
+ ensure_current_turn!(turn) if turn
1501
1549
  ensure_writable!
1502
1550
  apply_reply_policy!(by, request: request)
1503
1551
  posted = post_agent_message!(body, files: files, by: by, metadata: metadata)
@@ -1580,9 +1628,16 @@ module SupportDesk
1580
1628
  # Run a transition under the row lock: yield, write exactly one event
1581
1629
  # row, and (once everything has committed) broadcast and emit. The block
1582
1630
  # returns the event payload, or false when there is nothing to do.
1583
- def write_transition!(kind, actor:, request: nil, &block)
1631
+ #
1632
+ # `offers_turn:` is for the transitions that hand the case (back) to the
1633
+ # assistant: the `assistant_turn` row they owe is written under the same
1634
+ # lock, so it commits with them or not at all.
1635
+ def write_transition!(kind, actor:, request: nil, offers_turn: false, &block)
1584
1636
  event = nil
1585
- with_lock { event = record_transition!(kind, actor: actor, &block) }
1637
+ with_lock do
1638
+ event = record_transition!(kind, actor: actor, &block)
1639
+ record_assistant_turn! if event && offers_turn
1640
+ end
1586
1641
  publish_transition(event, kind, actor, request) if event
1587
1642
  event
1588
1643
  end
@@ -200,8 +200,10 @@ module SupportDesk
200
200
  @agent_key ||= [ agent.class.polymorphic_name, agent.id.to_s ]
201
201
  end
202
202
 
203
+ # The desk's ROW, not only its key: every owner has a `:default` desk,
204
+ # and one agent working two owners' queues has two badges.
203
205
  def badge_cache_key
204
- [ "support_desk", "queue", desk.key, "badge", SupportDesk.actor_key(agent) ]
206
+ [ "support_desk", "queue", desk.key, desk.id, "badge", SupportDesk.actor_key(agent) ]
205
207
  end
206
208
  end
207
209
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # Everything the desk wrote down, in an order a reader can checkpoint
5
+ # without ever skipping a row. Reached through SupportDesk::Events.replay;
6
+ # see there for the contract and the README for why it is shaped this way.
7
+ #
8
+ # Two tables are the durable record: `support_desk_events` (every
9
+ # transition, plus the `assistant_turn` and `draft_proposed` signals) and
10
+ # `support_desk_message_registrations` (every message folded into a case).
11
+ # Both carry `xact_id`, the 64-bit id of the transaction that wrote the row
12
+ # (`pg_current_xact_id()`), and `position`, drawn from ONE sequence, so
13
+ # `(xact_id, position)` is a single order across both and `position` alone
14
+ # identifies a row.
15
+ class Replay # :nodoc:
16
+ # The cursor a reader keeps between pages of one range, as JSON-friendly
17
+ # strings: the range's fixed end, and the last row handled.
18
+ RESUME_KEYS = %w[until xact_id position].freeze
19
+
20
+ def initialize(horizon:, resume: nil, batch_size: 500)
21
+ @horizon = Integer(horizon || 0)
22
+ @resume = resume&.to_h&.transform_keys(&:to_s)
23
+ @batch_size = Integer(batch_size)
24
+ raise ArgumentError, "batch_size must be positive" unless @batch_size.positive?
25
+ end
26
+
27
+ # Yield every row of the range, oldest first, then return the range's
28
+ # end — the next horizon. Nothing is returned for a range that did not
29
+ # drain: a raise leaves the caller's checkpoint where it was.
30
+ #
31
+ # Uncached, always: the snapshot is the same SQL on every poll, and a
32
+ # query cache — on in every request and job — would hand back the first
33
+ # answer and pin the horizon for as long as the executor lives.
34
+ def each_row(&block)
35
+ ensure_supported!
36
+ Event.uncached { read_range(&block) }
37
+ end
38
+
39
+ private
40
+
41
+ def read_range
42
+ upper, after = start
43
+
44
+ loop do
45
+ page = next_page(upper, after)
46
+ break if page.empty?
47
+
48
+ page.each do |row|
49
+ after = [ row.xact_id, row.position ]
50
+ yield row, { "until" => upper, "xact_id" => after[0], "position" => after[1] }
51
+ end
52
+ end
53
+
54
+ upper
55
+ end
56
+
57
+ attr_reader :horizon, :resume, :batch_size
58
+
59
+ # A resumed range keeps the end it was started with. Recomputing it
60
+ # would move the goalposts mid-range — harmless for completeness, but
61
+ # the sub-cursor would then describe a range nobody checkpointed.
62
+ def start
63
+ return [ snapshot_horizon, nil ] if resume.nil?
64
+
65
+ missing = RESUME_KEYS - resume.keys
66
+ raise ArgumentError, "resume: is missing #{missing.join(", ")} — pass back what replay yielded" if missing.any?
67
+
68
+ [ Integer(resume["until"]), [ Integer(resume["xact_id"]), Integer(resume["position"]) ] ]
69
+ end
70
+
71
+ # The oldest transaction still running. Every transaction below it has
72
+ # committed or aborted, so the rows below it are final: present now, or
73
+ # never going to exist.
74
+ def snapshot_horizon
75
+ Event.connection.select_value("SELECT pg_snapshot_xmin(pg_current_snapshot())::text::bigint").to_i
76
+ end
77
+
78
+ # The next page across both tables. Each table gives its own first
79
+ # `batch_size` rows past the cursor, and the merged first `batch_size`
80
+ # are exactly the first rows of the merged order.
81
+ def next_page(upper, after)
82
+ sources.flat_map { |model| page_of(model, upper, after) }
83
+ .sort_by { |row| [ row.xact_id, row.position ] }
84
+ .first(batch_size)
85
+ end
86
+
87
+ def page_of(model, upper, after)
88
+ table = model.quoted_table_name
89
+ scope = model.where("#{table}.xact_id >= ? AND #{table}.xact_id < ?", horizon, upper)
90
+ scope = scope.where("(#{table}.xact_id, #{table}.position) > (?, ?)", *after) if after
91
+ scope.order(:xact_id, :position).limit(batch_size).to_a
92
+ end
93
+
94
+ def sources = [ Event, MessageRegistration ]
95
+
96
+ def ensure_supported!
97
+ unless Event.connection.adapter_name.match?(/\Apostg/i)
98
+ raise ConfigurationError,
99
+ "SupportDesk::Events.replay needs PostgreSQL 13 or later: it reads transaction ids " \
100
+ "(pg_current_xact_id), which #{Event.connection.adapter_name} does not have."
101
+ end
102
+ return if sources.all? { |model| model.column_names.include?("xact_id") }
103
+
104
+ raise ConfigurationError,
105
+ "support_desk's replay columns are missing — run `rails g support_desk:upgrade` and " \
106
+ "`rails db:migrate` (support_desk 0.6.0 adds position and xact_id)."
107
+ end
108
+ end
109
+ end
@@ -118,7 +118,7 @@ module SupportDesk
118
118
  end
119
119
 
120
120
  def event_entries
121
- ticket.events.chronological.map { |event| Entry.new(at: event.created_at, event: event) }
121
+ ticket.events.history.chronological.map { |event| Entry.new(at: event.created_at, event: event) }
122
122
  end
123
123
 
124
124
  # Proposals somebody decided about — sent, discarded, or overtaken.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SupportDesk
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/support_desk.rb CHANGED
@@ -95,6 +95,7 @@ module SupportDesk
95
95
  @subscribers = nil
96
96
  @desks = nil
97
97
  @assistants = nil
98
+ Current.resolved = nil
98
99
  self
99
100
  end
100
101
 
@@ -102,18 +103,20 @@ module SupportDesk
102
103
  # tests that truncate tables between examples.
103
104
  def reset_desks!
104
105
  @desks = nil
106
+ Current.resolved = nil
105
107
  self
106
108
  end
107
109
 
108
110
  # --- Assistants -----------------------------------------------------------
109
111
 
110
- # The assistant record for +key+, memoised per process — or the desk's
111
- # default when called with nothing. nil when this installation has no
112
- # assistant at all, which is the answer every existing host gets.
112
+ # The assistant record for +key+ — or the desk's default when called
113
+ # with nothing — for +owner+ (SupportDesk::Current.owner unless you
114
+ # say). nil when this installation has no assistant at all, which is
115
+ # the answer every existing host gets.
113
116
  #
114
- # Found or created exactly like a desk, and never INSERT-first: an
115
- # assistant is read on every transition and written once in her life.
116
- def assistant(key = nil)
117
+ # Found or created exactly like a desk, never INSERT-first, and cached
118
+ # the same way: per process without an owner, per request with one.
119
+ def assistant(key = nil, owner: Current.owner)
117
120
  key = (key || config.default_assistant_key)
118
121
  return nil if key.nil?
119
122
 
@@ -128,7 +131,7 @@ module SupportDesk
128
131
  # message she signed still says the same thing after somebody takes
129
132
  # her out of the initializer (R8). It writes only when configuration
130
133
  # and the row disagree.
131
- (assistants[key] ||= Assistant.for(key)).snapshot_disclosure!
134
+ resolve(assistants, :assistant, key, owner) { Assistant.for(key, owner: owner) }.snapshot_disclosure!
132
135
  end
133
136
 
134
137
  # Every assistant record this process has resolved, keyed by key.
@@ -186,9 +189,10 @@ module SupportDesk
186
189
  # A seat nobody can sit in any more is not a silence problem, and it
187
190
  # must not need a `responds_within` to be noticed (R6).
188
191
  moved = reclaim_assistant_seats!
189
- config.assistants.each_key do |key|
190
- agent = assistant(key)
191
- window = agent&.responds_within
192
+ # Every owner's assistants, not only the ones this process resolved:
193
+ # the sweep runs once for the whole installation.
194
+ Assistant.where(key: config.assistants.keys.map(&:to_s)).find_each do |agent|
195
+ window = agent.responds_within
192
196
  next if window.nil?
193
197
 
194
198
  Ticket.open.assigned_to(agent).awaiting_reply.waiting_over(window).find_each do |ticket|
@@ -214,8 +218,9 @@ module SupportDesk
214
218
  older_than = older_than.to_i.seconds unless older_than.respond_to?(:ago)
215
219
  emitted = 0
216
220
 
217
- config.desks.each_key do |key|
218
- agent = desk(key)&.assistant
221
+ # Every owner's desks, for the same reason as the sweep above.
222
+ Desk.where(key: config.desks.keys.map(&:to_s)).find_each do |desk|
223
+ agent = desk.assistant
219
224
  # Repair before deciding. A requester message whose registration was
220
225
  # lost after commit leaves the clocks describing a case that no longer
221
226
  # exists — and `assistant_idle_since` reads those clocks, so the case
@@ -223,7 +228,7 @@ module SupportDesk
223
228
  # in COMMITS on its own and emits the turn it produces, so a dead
224
229
  # process followed by nothing but this task still ends in an
225
230
  # actionable turn (R3).
226
- Ticket.for_desk(key).with_unregistered_requester_messages.find_each do |ticket|
231
+ desk.tickets.with_unregistered_requester_messages.find_each do |ticket|
227
232
  emitted += 1 if ticket.reconcile_and_commit!.positive?
228
233
  rescue StandardError => e
229
234
  report_error(e, context: { hook: :redispatch_assistant_turns, ticket: ticket.id })
@@ -231,10 +236,10 @@ module SupportDesk
231
236
 
232
237
  next if agent.nil?
233
238
 
234
- Ticket.open.for_desk(key).assistant_idle_since(older_than.ago).find_each do |ticket|
239
+ desk.tickets.open.assistant_idle_since(older_than.ago).find_each do |ticket|
235
240
  next unless ticket.assistant_policy(agent).may_observe?
236
241
 
237
- ticket.send(:emit_assistant_turn)
242
+ ticket.send(:emit_assistant_turn, record: false)
238
243
  emitted += 1
239
244
  end
240
245
  end
@@ -243,34 +248,46 @@ module SupportDesk
243
248
 
244
249
  # --- Desks ------------------------------------------------------------------
245
250
 
246
- # The desk record for +key+, memoised per process.
251
+ # The desk record for +key+, for +owner+ (SupportDesk::Current.owner
252
+ # unless you say). Keys are declared in code; owners multiply ROWS, so
253
+ # every owner has its own `:default` desk and `:billing` means the same
254
+ # thing for all of them.
247
255
  #
248
256
  # Lazily created with `find_by || create_or_find_by!` and NEVER
249
257
  # INSERT-first: a desk is read thousands of times and written once, and
250
258
  # an INSERT that fails its unique index on every page view is noise in
251
259
  # the log and a wasted round trip.
252
- def desk(key = :default)
260
+ #
261
+ # Cached in two places, on purpose. A desk with no owner is memoised for
262
+ # the life of the process, as it always was: there is one of each, and
263
+ # its settings change with a deploy. An owned desk is cached for the
264
+ # request or job (in SupportDesk::Current), because owners are many,
265
+ # come and go, and change their settings at runtime — a process-wide
266
+ # memo would grow with every customer and serve yesterday's name.
267
+ def desk(key = :default, owner: Current.owner)
253
268
  key = (key || :default).to_sym
254
269
  return nil unless config.desk?(key) || key == :default
255
270
 
256
- desks[key] ||= Desk.for(key)
271
+ resolve(desks, :desk, key, owner) { Desk.for(key, owner: owner) }
257
272
  end
258
273
 
259
- # Every desk record this process has resolved, keyed by key.
274
+ # Every ownerless desk record this process has resolved, keyed by key.
260
275
  def desks # :nodoc:
261
276
  @desks ||= {}
262
277
  end
263
278
 
264
279
  # --- Topics -----------------------------------------------------------------
265
280
 
266
- # The Topic at +path+, looked up across every configured desk's tree, or
267
- # a Topic::Unknown that still renders (never raises in a view).
281
+ # The Topic at +path+, looked up across every configured desk's tree —
282
+ # the current owner's own trees first, when `config.desk_settings` hands
283
+ # them one — or a Topic::Unknown that still renders (never raises in a
284
+ # view).
268
285
  def find_topic(path)
269
286
  return nil if path.nil?
270
287
 
271
288
  path = path.to_s
272
- config.desks.each_value do |desk|
273
- node = desk.topics.find(path)
289
+ topic_trees.each do |tree|
290
+ node = tree.find(path)
274
291
  return node if node
275
292
  end
276
293
 
@@ -496,6 +513,24 @@ module SupportDesk
496
513
 
497
514
  private
498
515
 
516
+ # The memo behind `desk` and `assistant`: the process-wide +memo+ for
517
+ # ownerless rows, the request's SupportDesk::Current for owned ones.
518
+ def resolve(memo, kind, key, owner)
519
+ return memo[key] ||= yield if owner.nil?
520
+ unless owner.respond_to?(:persisted?) && owner.persisted?
521
+ raise ArgumentError, "a support desk owner must be a saved record, got #{owner.inspect}"
522
+ end
523
+
524
+ Current.resolved_records[[ kind, owner.class.polymorphic_name, owner.id, key ]] ||= yield
525
+ end
526
+
527
+ def topic_trees
528
+ trees = config.desks.values.map(&:topics)
529
+ return trees if config.desk_settings.nil?
530
+
531
+ Desk.owned_by(Current.owner).where(key: config.desks.keys.map(&:to_s)).map { |desk| desk.config.topics } + trees
532
+ end
533
+
499
534
  def register(registry, klass)
500
535
  registry << klass.name if klass.name
501
536
  klass