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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +151 -6
- data/app/controllers/support_desk/tickets_controller.rb +6 -4
- data/app/helpers/support_desk/engine_helper.rb +17 -7
- data/app/views/chats/slots/_inbox_top.html.erb +1 -1
- data/app/views/support_desk/launcher/_badge.html.erb +11 -3
- data/app/views/support_desk/launcher/_launcher.html.erb +2 -2
- data/lib/generators/support_desk/install_generator.rb +12 -0
- data/lib/generators/support_desk/templates/add_owners_to_support_desk.rb.erb +73 -0
- data/lib/generators/support_desk/templates/add_replay_to_support_desk.rb.erb +73 -0
- data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +6 -1
- data/lib/generators/support_desk/templates/initializer.rb +12 -0
- data/lib/generators/support_desk/upgrade_generator.rb +22 -2
- data/lib/support_desk/brief.rb +1 -1
- data/lib/support_desk/configuration.rb +106 -6
- data/lib/support_desk/context_card.rb +1 -1
- data/lib/support_desk/current.rb +22 -0
- data/lib/support_desk/events.rb +41 -0
- data/lib/support_desk/launcher.rb +14 -4
- data/lib/support_desk/models/assistant.rb +37 -9
- data/lib/support_desk/models/concerns/requester.rb +3 -2
- data/lib/support_desk/models/desk.rb +40 -7
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/ticket/assistance.rb +36 -7
- data/lib/support_desk/models/ticket.rb +80 -25
- data/lib/support_desk/queue.rb +3 -1
- data/lib/support_desk/replay.rb +109 -0
- data/lib/support_desk/timeline.rb +1 -1
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +58 -23
- 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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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(
|
|
459
|
-
config =
|
|
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].
|
|
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. `
|
|
918
|
-
#
|
|
919
|
-
#
|
|
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
|
-
|
|
1357
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/support_desk/queue.rb
CHANGED
|
@@ -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.
|
data/lib/support_desk/version.rb
CHANGED
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
|
|
111
|
-
#
|
|
112
|
-
# assistant at all, which is
|
|
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,
|
|
115
|
-
#
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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+,
|
|
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
|
-
|
|
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
|
|
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
|
|
267
|
-
#
|
|
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
|
-
|
|
273
|
-
node =
|
|
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
|