support_desk 0.3.0 → 0.4.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 +206 -0
- data/README.md +227 -16
- data/app/controllers/support_desk/application_controller.rb +5 -2
- data/app/controllers/support_desk/tickets_controller.rb +68 -3
- data/app/helpers/support_desk/engine_helper.rb +10 -10
- data/app/helpers/support_desk/thread_helper.rb +29 -0
- data/app/views/support_desk/tickets/show.html.erb +8 -0
- data/config/locales/support_desk.en.yml +28 -0
- data/config/locales/support_desk.es.yml +28 -0
- data/lib/generators/support_desk/install_generator.rb +9 -3
- data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
- data/lib/generators/support_desk/templates/initializer.rb +21 -1
- data/lib/generators/support_desk/upgrade_generator.rb +15 -7
- data/lib/support_desk/brief.rb +1 -1
- data/lib/support_desk/configuration.rb +100 -19
- data/lib/support_desk/conversation_access.rb +42 -0
- data/lib/support_desk/doctor.rb +30 -3
- data/lib/support_desk/engine.rb +13 -1
- data/lib/support_desk/message_signatures.rb +20 -0
- data/lib/support_desk/models/assistant.rb +43 -8
- data/lib/support_desk/models/draft.rb +9 -0
- data/lib/support_desk/models/message_registration.rb +12 -0
- data/lib/support_desk/models/ticket/assistance.rb +254 -30
- data/lib/support_desk/models/ticket.rb +172 -67
- data/lib/support_desk/transcript.rb +9 -12
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +1 -5
- data/lib/support_desk.rb +82 -11
- data/lib/tasks/support_desk.rake +12 -1
- metadata +20 -11
|
@@ -59,6 +59,7 @@ module SupportDesk
|
|
|
59
59
|
# readonly record refuses to be destroyed. Deleting a ticket is the one
|
|
60
60
|
# thing that takes its timeline with it, and it needs no callbacks.
|
|
61
61
|
has_many :events, class_name: "SupportDesk::Event", inverse_of: :ticket, dependent: :delete_all
|
|
62
|
+
has_many :message_registrations, class_name: "SupportDesk::MessageRegistration", dependent: :delete_all
|
|
62
63
|
has_many :messages, through: :conversation, source: :messages
|
|
63
64
|
|
|
64
65
|
# The assistant surface — the turn, the policy gates, her two verbs and
|
|
@@ -191,6 +192,35 @@ module SupportDesk
|
|
|
191
192
|
find_by(conversation_id: conversation.id)
|
|
192
193
|
end
|
|
193
194
|
|
|
195
|
+
# The conversations behind +requester+'s own cases, while they still
|
|
196
|
+
# hold its seat: chats' participation and block rules (`inbox_for`),
|
|
197
|
+
# without the host's `inbox_scope`. Participation is what makes a
|
|
198
|
+
# conversation theirs to read; the inbox is only where it is listed.
|
|
199
|
+
# What chats adds to its lookup (SupportDesk::ConversationAccess) and
|
|
200
|
+
# what the ticket page reads from.
|
|
201
|
+
#
|
|
202
|
+
# A case and its conversation have to point at each other: a direct
|
|
203
|
+
# conversation whose subject IS one of this requester's cases, that very
|
|
204
|
+
# case's conversation. A ticket mislinked to a group, or to a
|
|
205
|
+
# conversation about something else, opens nothing.
|
|
206
|
+
def conversations_of(requester)
|
|
207
|
+
return Chats::Conversation.none unless requester.respond_to?(:support_tickets)
|
|
208
|
+
|
|
209
|
+
tickets = arel_table
|
|
210
|
+
conversations = Chats::Conversation.arel_table
|
|
211
|
+
own_cases = requester.support_tickets.where(tickets[:id].eq(conversations[:subject_id])).select(:conversation_id)
|
|
212
|
+
Chats::Conversation.inbox_for(requester).direct
|
|
213
|
+
.where(subject_type: polymorphic_name)
|
|
214
|
+
.where(id: own_cases)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# One of them, by id, or nil.
|
|
218
|
+
def requester_conversation(requester, id)
|
|
219
|
+
return nil if id.blank?
|
|
220
|
+
|
|
221
|
+
conversations_of(requester).find_by(id: id)
|
|
222
|
+
end
|
|
223
|
+
|
|
194
224
|
# Open a ticket and post its first message. Usually called as
|
|
195
225
|
# `requester.ask_support!(…)` — or, when the desk writes first, as
|
|
196
226
|
# `agent.open_support_conversation_with!(…)`. This is the seam under
|
|
@@ -341,10 +371,14 @@ module SupportDesk
|
|
|
341
371
|
|
|
342
372
|
# A loaded instance may predate revocation or deletion. Check the row,
|
|
343
373
|
# just as requester eligibility does; this does not serialize revocation.
|
|
374
|
+
#
|
|
375
|
+
# Returns the FRESH record, so a caller that goes on to read a runtime
|
|
376
|
+
# flag off it (the assistant's `active?`, which the policy reads) is
|
|
377
|
+
# reading the row and not the copy it was handed (R9).
|
|
344
378
|
def ensure_agent_record!(record) # :nodoc:
|
|
345
379
|
if record.respond_to?(:persisted?) && record.persisted? && record.respond_to?(:support_agent?)
|
|
346
380
|
current = record.class.uncached { record.class.find_by(id: record.id) }
|
|
347
|
-
return if current&.support_agent?
|
|
381
|
+
return current if current&.support_agent?
|
|
348
382
|
end
|
|
349
383
|
|
|
350
384
|
raise NotAnAgent, "#{record.class} is not a currently eligible, persisted support agent — " \
|
|
@@ -616,6 +650,7 @@ module SupportDesk
|
|
|
616
650
|
ticket.send(:record_registration!, posted) if posted
|
|
617
651
|
ticket.send(:stamp_assistant_action!) if by_support && SupportDesk.ai_actor?(by)
|
|
618
652
|
pin_opening_lines!(ticket, notices, posted)
|
|
653
|
+
post_acknowledgement!(ticket, posted) if posted && !by_support
|
|
619
654
|
ticket
|
|
620
655
|
end
|
|
621
656
|
|
|
@@ -626,6 +661,30 @@ module SupportDesk
|
|
|
626
661
|
ticket.conversation.post_system_message!(line)
|
|
627
662
|
end
|
|
628
663
|
|
|
664
|
+
# The desk's acknowledgement, right under the requester's first message
|
|
665
|
+
# and in the same transaction, so "did that go through?" is answered by
|
|
666
|
+
# the very commit that made it so. Pinned one tick AFTER the message
|
|
667
|
+
# whenever the clock did not move between the two inserts (the same tie
|
|
668
|
+
# `pin_opening_lines!` breaks the other way), and it is then the
|
|
669
|
+
# conversation's last message: what a list row previews.
|
|
670
|
+
#
|
|
671
|
+
# It is the desk's receipt for words the requester has just written, so
|
|
672
|
+
# it is never news to them: their read horizon moves through it. Without
|
|
673
|
+
# that, a tick dated after the clock (a frozen or coarse one) would stay
|
|
674
|
+
# unread on a page that already shows it, chats' own thread included,
|
|
675
|
+
# which reads at "now".
|
|
676
|
+
def post_acknowledgement!(ticket, posted)
|
|
677
|
+
line = ticket.desk_config.acknowledgement_for(ticket)
|
|
678
|
+
return if line.blank?
|
|
679
|
+
|
|
680
|
+
notice = ticket.conversation.post_system_message!(line)
|
|
681
|
+
if notice.created_at <= posted.created_at
|
|
682
|
+
notice.update_columns(created_at: posted.created_at + ordering_tick)
|
|
683
|
+
ticket.conversation.recompute_last_message!
|
|
684
|
+
end
|
|
685
|
+
ticket.conversation.participant_for(ticket.requester)&.read!(at: notice.created_at)
|
|
686
|
+
end
|
|
687
|
+
|
|
629
688
|
# chats orders a transcript by (created_at, id), and inserting one row
|
|
630
689
|
# after another does NOT guarantee two different timestamps: frozen
|
|
631
690
|
# time in a test, a coarse column, a clock that doesn't move between
|
|
@@ -670,12 +729,10 @@ module SupportDesk
|
|
|
670
729
|
# running before any of it.
|
|
671
730
|
def reply_into!(ticket, message, files:, by:, by_support:, request:, authorize_reuse:)
|
|
672
731
|
if by_support
|
|
673
|
-
#
|
|
674
|
-
#
|
|
675
|
-
# hold is the one this lock reads, because there was never an
|
|
676
|
-
# earlier one to give her.
|
|
732
|
+
# Only this private outreach path supplies the current turn internally.
|
|
733
|
+
# Public replies must present the token their harness observed.
|
|
677
734
|
ticket.send(:reply_under_lock!, message, by: by, files: files, request: request,
|
|
678
|
-
authorize: authorize_reuse,
|
|
735
|
+
authorize: authorize_reuse, outreach: true)
|
|
679
736
|
else
|
|
680
737
|
ticket.with_lock(requires_new: true) do
|
|
681
738
|
authorize_reuse&.call(ticket)
|
|
@@ -721,7 +778,7 @@ module SupportDesk
|
|
|
721
778
|
def chat_locked_notice
|
|
722
779
|
return I18n.t("support_desk.thread.unavailable_notice") if requester_unavailable?
|
|
723
780
|
|
|
724
|
-
I18n.t("support_desk.thread.closed_notice")
|
|
781
|
+
I18n.t("support_desk.thread.closed_notice", **desk_config.line_interpolations(self))
|
|
725
782
|
end
|
|
726
783
|
|
|
727
784
|
# Whether the person this case belongs to can be written to at all right
|
|
@@ -863,6 +920,7 @@ module SupportDesk
|
|
|
863
920
|
def reply!(body = nil, by: nil, files: [], request: nil, metadata: {}, turn: nil)
|
|
864
921
|
actor = resolve_actor(by)
|
|
865
922
|
ensure_agent!(actor)
|
|
923
|
+
reconcile_and_commit! if SupportDesk.ai_actor?(actor)
|
|
866
924
|
|
|
867
925
|
reply_under_lock!(body, by: actor, files: files, request: request, metadata: metadata, turn: turn)
|
|
868
926
|
end
|
|
@@ -872,11 +930,15 @@ module SupportDesk
|
|
|
872
930
|
def note!(body, by: nil, request: nil, turn: nil)
|
|
873
931
|
actor = resolve_actor(by)
|
|
874
932
|
ensure_agent!(actor)
|
|
875
|
-
|
|
933
|
+
machine = SupportDesk.ai_actor?(actor)
|
|
876
934
|
raise ArgumentError, "a note needs something to say" if body.blank?
|
|
877
935
|
|
|
936
|
+
assistant = nil
|
|
878
937
|
event = write_transition!(:note, actor: actor, request: request) do
|
|
879
|
-
if
|
|
938
|
+
if machine
|
|
939
|
+
# Under the lock, from the row: a kill switch that commits while
|
|
940
|
+
# this call waits for the case is a kill switch that stops it (R9).
|
|
941
|
+
assistant = resolve_assistant!(actor)
|
|
880
942
|
ensure_current_turn!(turn)
|
|
881
943
|
policy = assistant_policy(assistant)
|
|
882
944
|
raise AssistantNotAllowed.new(policy, verb: :note) unless policy.may_observe?
|
|
@@ -891,10 +953,9 @@ module SupportDesk
|
|
|
891
953
|
# Give the ticket to an agent. `assign!(to: lucia, by: lucia)` is
|
|
892
954
|
# somebody taking it; `assign!(to: pedro, by: admin)` is somebody being
|
|
893
955
|
# handed it. Repeating an assignment to the current holder does nothing.
|
|
894
|
-
def assign!(to:, by: nil, reason: nil, note: nil, request: nil)
|
|
956
|
+
def assign!(to:, by: nil, reason: nil, note: nil, request: nil, turn: nil)
|
|
895
957
|
actor = resolve_actor(by)
|
|
896
958
|
ensure_agent!(actor)
|
|
897
|
-
ensure_assistant_may_assign!(actor, to)
|
|
898
959
|
ensure_assignable!(to)
|
|
899
960
|
|
|
900
961
|
reason ||= to == actor ? :taken : :assigned
|
|
@@ -906,6 +967,13 @@ module SupportDesk
|
|
|
906
967
|
|
|
907
968
|
event = write_transition!(:assigned, actor: actor, request: request) do
|
|
908
969
|
raise InvalidTransition, "can't assign a closed ticket — reopen it first" if closed?
|
|
970
|
+
# Every mutable question about a seat is asked HERE, under the row
|
|
971
|
+
# lock, from the reloaded row: who holds the case, what her policy
|
|
972
|
+
# says, which turn she is on. Asked before the lock (0.3.0) they
|
|
973
|
+
# were answers about a case that has since moved, and a stale
|
|
974
|
+
# instance could take a seat a person had already been given (R2).
|
|
975
|
+
ensure_assistant_may_assign!(actor, to, turn: turn)
|
|
976
|
+
ensure_assistant_may_hold!(to)
|
|
909
977
|
next false if assigned_to?(to)
|
|
910
978
|
|
|
911
979
|
assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: reason, note: note)
|
|
@@ -946,6 +1014,7 @@ module SupportDesk
|
|
|
946
1014
|
"(#{assignee ? describe_actor(assignee) : "nobody"} does) — use assign! to override"
|
|
947
1015
|
end
|
|
948
1016
|
raise InvalidTransition, "can't hand off a closed ticket" if closed?
|
|
1017
|
+
ensure_assistant_may_hold!(to)
|
|
949
1018
|
next false if assigned_to?(to)
|
|
950
1019
|
|
|
951
1020
|
from = assignee
|
|
@@ -969,14 +1038,18 @@ module SupportDesk
|
|
|
969
1038
|
end
|
|
970
1039
|
|
|
971
1040
|
# Put the ticket back in the unassigned pile.
|
|
972
|
-
def release!(by: nil, reason: :released, request: nil)
|
|
1041
|
+
def release!(by: nil, reason: :released, request: nil, turn: nil)
|
|
973
1042
|
actor = resolve_actor(by)
|
|
974
1043
|
ensure_agent!(actor)
|
|
975
|
-
assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
|
|
976
1044
|
from = nil
|
|
977
1045
|
event = write_transition!(:released, actor: actor, request: request) do
|
|
978
1046
|
raise InvalidTransition, "can't release a closed ticket" if closed?
|
|
1047
|
+
assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
|
|
979
1048
|
if assistant
|
|
1049
|
+
# Giving a seat back is an action like any other, so it holds the
|
|
1050
|
+
# turn it read: a run that finished after the case moved on must
|
|
1051
|
+
# not release the seat a newer one took (R2).
|
|
1052
|
+
ensure_current_turn!(turn)
|
|
980
1053
|
policy = assistant_policy(assistant)
|
|
981
1054
|
raise AssistantNotAllowed.new(policy, verb: :release) unless policy.may_observe?
|
|
982
1055
|
# Her own seat, and only hers: putting a PERSON's case back in the
|
|
@@ -1000,10 +1073,15 @@ module SupportDesk
|
|
|
1000
1073
|
def close!(by: nil, request: nil, turn: nil)
|
|
1001
1074
|
actor = resolve_actor(by)
|
|
1002
1075
|
ensure_agent!(actor)
|
|
1003
|
-
|
|
1076
|
+
machine = SupportDesk.ai_actor?(actor)
|
|
1077
|
+
reconcile_and_commit! if machine
|
|
1004
1078
|
|
|
1005
1079
|
event = write_transition!(:closed, actor: actor, request: request) do
|
|
1006
|
-
if
|
|
1080
|
+
if machine
|
|
1081
|
+
# Resolved under the lock, so the kill switch is read after the
|
|
1082
|
+
# wait rather than before it (R9).
|
|
1083
|
+
assistant = resolve_assistant!(actor)
|
|
1084
|
+
lock_conversation!
|
|
1007
1085
|
reconcile_unregistered_messages!
|
|
1008
1086
|
ensure_current_turn!(turn)
|
|
1009
1087
|
policy = assistant_policy(assistant)
|
|
@@ -1327,22 +1405,30 @@ module SupportDesk
|
|
|
1327
1405
|
end
|
|
1328
1406
|
|
|
1329
1407
|
ensure_agent!(agent)
|
|
1408
|
+
end
|
|
1409
|
+
|
|
1410
|
+
# "Could she hold this case once the human-side flags were lifted?" —
|
|
1411
|
+
# the hand-back question, so the very flags a hand-back exists to clear
|
|
1412
|
+
# are not what refuses it. Her autonomy, the topic and the host's cap
|
|
1413
|
+
# block still decide.
|
|
1414
|
+
#
|
|
1415
|
+
# Asked UNDER THE LOCK (R2): a cap that lands while a take waits for the
|
|
1416
|
+
# row is the cap that applies to it.
|
|
1417
|
+
def ensure_assistant_may_hold!(agent)
|
|
1330
1418
|
return unless SupportDesk.ai_actor?(agent)
|
|
1331
1419
|
|
|
1332
|
-
# "Could she hold this case once the human-side flags were lifted?" —
|
|
1333
|
-
# the hand-back question, so the very flags a hand-back exists to
|
|
1334
|
-
# clear are not what refuses it. Her autonomy, the topic and the host's
|
|
1335
|
-
# cap block still decide.
|
|
1336
1420
|
policy = assistant_policy(agent, hand_back: true)
|
|
1337
1421
|
raise AssistantNotAllowed.new(policy, verb: :take) unless policy.may_hold?
|
|
1338
1422
|
end
|
|
1339
1423
|
|
|
1340
1424
|
# An assistant may take a case, and that is all: only herself, only when
|
|
1341
|
-
# nobody holds it,
|
|
1342
|
-
|
|
1425
|
+
# nobody holds it, only at a level that may answer, and only holding the
|
|
1426
|
+
# turn she read. Called under the row lock, from the reloaded row.
|
|
1427
|
+
def ensure_assistant_may_assign!(actor, to, turn:)
|
|
1343
1428
|
return unless SupportDesk.ai_actor?(actor)
|
|
1344
1429
|
|
|
1345
1430
|
assistant = resolve_assistant!(actor)
|
|
1431
|
+
ensure_current_turn!(turn)
|
|
1346
1432
|
policy = assistant_policy(assistant)
|
|
1347
1433
|
unless self.class.same_actor?(actor, to)
|
|
1348
1434
|
raise AssistantNotAllowed.new(policy, verb: :assign,
|
|
@@ -1402,12 +1488,16 @@ module SupportDesk
|
|
|
1402
1488
|
# `authorize:` is the console's hook (see Ticket.open_or_reply!): it runs
|
|
1403
1489
|
# under this lock, before any policy side effect, and a raise there rolls
|
|
1404
1490
|
# the whole thing back.
|
|
1405
|
-
def reply_under_lock!(body, by:, files:, request:, authorize: nil, metadata: {}, turn: nil)
|
|
1491
|
+
def reply_under_lock!(body, by:, files:, request:, authorize: nil, metadata: {}, turn: nil, outreach: false)
|
|
1406
1492
|
with_lock(requires_new: true) do
|
|
1407
1493
|
authorize&.call(self)
|
|
1408
1494
|
next assistant_reply_under_lock!(body, assistant: by, files: files, request: request,
|
|
1409
|
-
metadata: metadata, turn: turn) if SupportDesk.ai_actor?(by)
|
|
1495
|
+
metadata: metadata, turn: turn, outreach: outreach) if SupportDesk.ai_actor?(by)
|
|
1410
1496
|
|
|
1497
|
+
# Fold committed inputs before a human answer too. A delayed callback
|
|
1498
|
+
# must not later turn an already-answered question back into work.
|
|
1499
|
+
lock_conversation!
|
|
1500
|
+
reconcile_unregistered_messages!
|
|
1411
1501
|
ensure_writable!
|
|
1412
1502
|
apply_reply_policy!(by, request: request)
|
|
1413
1503
|
posted = post_agent_message!(body, files: files, by: by, metadata: metadata)
|
|
@@ -1423,13 +1513,13 @@ module SupportDesk
|
|
|
1423
1513
|
# `reply!` by the assistant: her full rules, under the lock the caller
|
|
1424
1514
|
# already holds. Everything here is also what `respond!` runs — this is
|
|
1425
1515
|
# the path for a host that decided to answer rather than ask policy.
|
|
1426
|
-
def assistant_reply_under_lock!(body, assistant:, files:, request:, metadata:, turn:)
|
|
1516
|
+
def assistant_reply_under_lock!(body, assistant:, files:, request:, metadata:, turn:, outreach: false)
|
|
1427
1517
|
assistant = resolve_assistant!(assistant)
|
|
1428
|
-
#
|
|
1429
|
-
#
|
|
1430
|
-
|
|
1431
|
-
turn = assistant_turn if turn == :current
|
|
1518
|
+
# Private outreach supplies a token only after acquiring both locks;
|
|
1519
|
+
# public replies compare the caller's actual observed token.
|
|
1520
|
+
lock_conversation!
|
|
1432
1521
|
reconcile_unregistered_messages!
|
|
1522
|
+
turn = assistant_turn if outreach
|
|
1433
1523
|
ensure_current_turn!(turn)
|
|
1434
1524
|
ensure_writable!
|
|
1435
1525
|
|
|
@@ -1458,14 +1548,16 @@ module SupportDesk
|
|
|
1458
1548
|
"take it first"
|
|
1459
1549
|
end
|
|
1460
1550
|
|
|
1461
|
-
|
|
1551
|
+
# This private path already validated the reply while holding the lock.
|
|
1552
|
+
# Seat-taking consumes the real current token, not a public sentinel.
|
|
1553
|
+
assign!(to: actor, by: actor, request: request, turn: assistant_turn)
|
|
1462
1554
|
elsif !assigned_to?(actor)
|
|
1463
1555
|
# Humans outrank assistants, under EVERY reply policy (I8). A person
|
|
1464
1556
|
# answering a case a machine is holding takes it over: there is
|
|
1465
1557
|
# nothing to ask about "who owns this" when one of the two can't
|
|
1466
1558
|
# want it, and leaving her seated would keep her answering next.
|
|
1467
1559
|
if held_by_assistant? && !SupportDesk.ai_actor?(actor)
|
|
1468
|
-
return assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request)
|
|
1560
|
+
return assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request, turn: assistant_turn)
|
|
1469
1561
|
end
|
|
1470
1562
|
# Belt: the `held_by_human` floor already turned this into a draft,
|
|
1471
1563
|
# so an assistant reaching here is a bug, not a policy question.
|
|
@@ -1476,7 +1568,7 @@ module SupportDesk
|
|
|
1476
1568
|
raise NotAllowed, "ticket #{reference} is held by #{describe_actor(assignee)} and this desk only " \
|
|
1477
1569
|
"lets the assignee reply"
|
|
1478
1570
|
when :take_over
|
|
1479
|
-
assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request)
|
|
1571
|
+
assign!(to: actor, by: actor, reason: :drop_in_takeover, request: request, turn: assistant_turn)
|
|
1480
1572
|
else
|
|
1481
1573
|
write_transition!(:drop_in, actor: actor, request: request) do
|
|
1482
1574
|
{ "assignee" => SupportDesk.actor_key(assignee) }
|
|
@@ -1551,11 +1643,15 @@ module SupportDesk
|
|
|
1551
1643
|
# caller is not always the lock holder it thinks it is.
|
|
1552
1644
|
def record_registration!(message) # :nodoc:
|
|
1553
1645
|
return self if role_of(message) == :system
|
|
1646
|
+
unless message.persisted? && message.conversation_id == conversation_id
|
|
1647
|
+
raise ArgumentError, "message must belong to this ticket's conversation"
|
|
1648
|
+
end
|
|
1554
1649
|
# Re-check under the lock: the same message can reach us twice (a
|
|
1555
1650
|
# redelivered event, a hand-written replay), and an SLA clock that
|
|
1556
1651
|
# moves twice for one message is a lie.
|
|
1557
1652
|
return self if registered?(message)
|
|
1558
1653
|
|
|
1654
|
+
message_registrations.create!(message_id: message.id)
|
|
1559
1655
|
role = role_of(message)
|
|
1560
1656
|
opening = opening_message?
|
|
1561
1657
|
reopened = false
|
|
@@ -1565,10 +1661,10 @@ module SupportDesk
|
|
|
1565
1661
|
closed_by_assistant = false
|
|
1566
1662
|
case role
|
|
1567
1663
|
when :requester
|
|
1568
|
-
attributes[:last_requester_message_at] = message.created_at
|
|
1664
|
+
attributes[:last_requester_message_at] = [ last_requester_message_at, message.created_at ].compact.max
|
|
1569
1665
|
# The pointer the turn's reconciliation reads: which requester
|
|
1570
1666
|
# message the clocks are standing on, by id and not only by time.
|
|
1571
|
-
attributes[:last_requester_message_id] = message.id
|
|
1667
|
+
attributes[:last_requester_message_id] = message.id if after_requester_watermark?(message)
|
|
1572
1668
|
if closed? && message.created_at > closed_at && desk_config.closed_tickets == :reopen_on_reply
|
|
1573
1669
|
# Read before the merge below clears it — see #reopen!.
|
|
1574
1670
|
closed_by_assistant = closed_by_type == SupportDesk::Assistant.polymorphic_name
|
|
@@ -1577,7 +1673,7 @@ module SupportDesk
|
|
|
1577
1673
|
reopened = true
|
|
1578
1674
|
end
|
|
1579
1675
|
when :agent
|
|
1580
|
-
attributes[:last_agent_message_at] = message.created_at
|
|
1676
|
+
attributes[:last_agent_message_at] = [ last_agent_message_at, message.created_at ].compact.max
|
|
1581
1677
|
# A first REPLY answers something. An agent message with no earlier
|
|
1582
1678
|
# requester message is the desk opening the conversation, or a word
|
|
1583
1679
|
# into an empty case — neither is a reply, and a requester clock that
|
|
@@ -1592,7 +1688,13 @@ module SupportDesk
|
|
|
1592
1688
|
end
|
|
1593
1689
|
|
|
1594
1690
|
assign_attributes(attributes)
|
|
1595
|
-
self.awaiting = closed?
|
|
1691
|
+
self.awaiting = if closed?
|
|
1692
|
+
"none"
|
|
1693
|
+
elsif role == :requester
|
|
1694
|
+
"agent"
|
|
1695
|
+
else
|
|
1696
|
+
awaiting_from_clocks
|
|
1697
|
+
end
|
|
1596
1698
|
self.waiting_since = waiting_since_from_clocks
|
|
1597
1699
|
save!
|
|
1598
1700
|
# Every registered message moves the case on, so every one of them
|
|
@@ -1609,48 +1711,51 @@ module SupportDesk
|
|
|
1609
1711
|
|
|
1610
1712
|
# After the bump, and after any reopen: the hook decides about a case
|
|
1611
1713
|
# in the state this message left it in.
|
|
1612
|
-
evaluate_hand_off_phrase!(message) if role == :requester
|
|
1714
|
+
evaluate_hand_off_phrase!(message) if role == :requester && !closed?
|
|
1613
1715
|
|
|
1614
1716
|
publish_transition(reopen_event, :reopened, requester, nil) if reopen_event
|
|
1615
1717
|
announce_registration(message, role: role, opening: opening, reopened: reopened)
|
|
1616
1718
|
self
|
|
1617
1719
|
end
|
|
1618
1720
|
|
|
1619
|
-
#
|
|
1620
|
-
#
|
|
1621
|
-
# REPLAY can arrive in any order and an older message must never rewind
|
|
1622
|
-
# `awaiting`, restart an SLA clock, or reopen a case that was closed
|
|
1623
|
-
# after it.
|
|
1624
|
-
#
|
|
1625
|
-
# The comparison is `<=`, so a message whose timestamp already sits on
|
|
1626
|
-
# the clock counts as folded in: idempotency is the documented promise,
|
|
1627
|
-
# and the cost of the rare tie is one clock that doesn't advance until
|
|
1628
|
-
# the next message.
|
|
1721
|
+
# Receipt identity, not transcript order, decides whether a callback is a
|
|
1722
|
+
# replay. A later commit may have an earlier timestamp or a smaller UUID.
|
|
1629
1723
|
def registered?(message)
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
return true if
|
|
1639
|
-
return
|
|
1640
|
-
|
|
1641
|
-
#
|
|
1642
|
-
#
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1724
|
+
message_registrations.exists?(message_id: message.id)
|
|
1725
|
+
end
|
|
1726
|
+
|
|
1727
|
+
# Advance the requester clock pointer only in transcript order. Receipt
|
|
1728
|
+
# identity decides registration separately, so late arrivals cannot rewind
|
|
1729
|
+
# clocks or be discarded as replays.
|
|
1730
|
+
def after_requester_watermark?(message)
|
|
1731
|
+
watermark = last_requester_message_at
|
|
1732
|
+
return true if watermark.blank?
|
|
1733
|
+
return true if message.created_at > watermark
|
|
1734
|
+
return false if message.created_at < watermark
|
|
1735
|
+
# A tie with no pointer to break it (a 0.2 row whose backfill found
|
|
1736
|
+
# nothing) is new: the alternative silently drops it.
|
|
1737
|
+
return true if last_requester_message_id.blank?
|
|
1738
|
+
|
|
1739
|
+
compare_message_ids(message.id, last_requester_message_id).positive?
|
|
1740
|
+
end
|
|
1741
|
+
|
|
1742
|
+
# Order two chats message ids the way the DATABASE orders that column,
|
|
1743
|
+
# because the reconciliation query compares them in SQL and this one
|
|
1744
|
+
# compares them in Ruby. Integers compare numerically (10 is after 9);
|
|
1745
|
+
# anything else — a uuid, a ULID — compares as a string, which is how
|
|
1746
|
+
# every adapter orders those columns too.
|
|
1747
|
+
def compare_message_ids(one, other)
|
|
1748
|
+
if one.is_a?(Integer) && other.is_a?(Integer)
|
|
1749
|
+
one <=> other
|
|
1750
|
+
elsif integerish?(one) && integerish?(other)
|
|
1751
|
+
one.to_i <=> other.to_i
|
|
1752
|
+
else
|
|
1753
|
+
one.to_s <=> other.to_s
|
|
1651
1754
|
end
|
|
1652
1755
|
end
|
|
1653
1756
|
|
|
1757
|
+
def integerish?(value) = value.to_s.match?(/\A-?\d+\z/)
|
|
1758
|
+
|
|
1654
1759
|
def opening_message?
|
|
1655
1760
|
last_registered_message_id.blank? && last_requester_message_at.nil? && last_agent_message_at.nil?
|
|
1656
1761
|
end
|
|
@@ -29,14 +29,10 @@ module SupportDesk
|
|
|
29
29
|
#
|
|
30
30
|
# == Names survive renames
|
|
31
31
|
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
# transcript reads "Rose" while Rose is configured, keeps saying what the
|
|
37
|
-
# customer was actually shown for an assistant nobody declares any more,
|
|
38
|
-
# and never invents a name for a message written under a nameless
|
|
39
|
-
# disclosure mode.
|
|
32
|
+
# The assistant's original name is stored on new messages. Older assistant
|
|
33
|
+
# messages fall back to their stored display name, then to configuration or
|
|
34
|
+
# the author record only when there is no message snapshot. Exported turns
|
|
35
|
+
# retain AI provenance even when their requester-facing mode is nameless.
|
|
40
36
|
#
|
|
41
37
|
# == Deleted messages are turns too
|
|
42
38
|
#
|
|
@@ -204,12 +200,13 @@ module SupportDesk
|
|
|
204
200
|
end
|
|
205
201
|
end
|
|
206
202
|
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
# more. A message written by the record rather than by `speak!` (an
|
|
210
|
-
# import, a host that posts its own) has neither, so the record answers.
|
|
203
|
+
# Message snapshots survive configuration changes. Imports without a
|
|
204
|
+
# snapshot use the configured key or author as a legacy fallback.
|
|
211
205
|
def assistant_name(message)
|
|
212
206
|
stamp = provenance(message)
|
|
207
|
+
return stamp["name"] if stamp["name"].is_a?(String) && stamp["name"].present?
|
|
208
|
+
return stamp["display_name"] if stamp["display_name"].is_a?(String) && stamp["display_name"].present?
|
|
209
|
+
|
|
213
210
|
key = stamp["assistant"]
|
|
214
211
|
if key.present? && SupportDesk.config.assistant?(key)
|
|
215
212
|
return SupportDesk.config.assistant(key).name
|
data/lib/support_desk/version.rb
CHANGED
data/lib/support_desk/wizard.rb
CHANGED
|
@@ -179,11 +179,7 @@ module SupportDesk
|
|
|
179
179
|
# The promise shown next to the composer ("we usually reply in under a
|
|
180
180
|
# day"), built from `config.reply_within` — one setting, one truth: the
|
|
181
181
|
# same number the SLA breaches on.
|
|
182
|
-
def promise
|
|
183
|
-
return nil if promise_within.nil?
|
|
184
|
-
|
|
185
|
-
I18n.t("support_desk.thread.promise", time: self.class.humanize_duration(promise_within))
|
|
186
|
-
end
|
|
182
|
+
def promise = target_desk.config.reply_promise
|
|
187
183
|
|
|
188
184
|
# The promise as a Duration, for hosts that want to phrase it themselves.
|
|
189
185
|
def promise_within = target_desk.config.reply_within
|