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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +206 -0
  3. data/README.md +227 -16
  4. data/app/controllers/support_desk/application_controller.rb +5 -2
  5. data/app/controllers/support_desk/tickets_controller.rb +68 -3
  6. data/app/helpers/support_desk/engine_helper.rb +10 -10
  7. data/app/helpers/support_desk/thread_helper.rb +29 -0
  8. data/app/views/support_desk/tickets/show.html.erb +8 -0
  9. data/config/locales/support_desk.en.yml +28 -0
  10. data/config/locales/support_desk.es.yml +28 -0
  11. data/lib/generators/support_desk/install_generator.rb +9 -3
  12. data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
  13. data/lib/generators/support_desk/templates/initializer.rb +21 -1
  14. data/lib/generators/support_desk/upgrade_generator.rb +15 -7
  15. data/lib/support_desk/brief.rb +1 -1
  16. data/lib/support_desk/configuration.rb +100 -19
  17. data/lib/support_desk/conversation_access.rb +42 -0
  18. data/lib/support_desk/doctor.rb +30 -3
  19. data/lib/support_desk/engine.rb +13 -1
  20. data/lib/support_desk/message_signatures.rb +20 -0
  21. data/lib/support_desk/models/assistant.rb +43 -8
  22. data/lib/support_desk/models/draft.rb +9 -0
  23. data/lib/support_desk/models/message_registration.rb +12 -0
  24. data/lib/support_desk/models/ticket/assistance.rb +254 -30
  25. data/lib/support_desk/models/ticket.rb +172 -67
  26. data/lib/support_desk/transcript.rb +9 -12
  27. data/lib/support_desk/version.rb +1 -1
  28. data/lib/support_desk/wizard.rb +1 -5
  29. data/lib/support_desk.rb +82 -11
  30. data/lib/tasks/support_desk.rake +12 -1
  31. 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
- # `turn: :current` is for the assistant: reuse turns outreach into
674
- # an ordinary reply, with her full rules — and the turn she has to
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, turn: :current)
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
- assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
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 assistant
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
- assistant = (resolve_assistant!(actor) if SupportDesk.ai_actor?(actor))
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 assistant
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, and only at a level that may answer.
1342
- def ensure_assistant_may_assign!(actor, to)
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
- # `:current` means "the turn as this lock sees it" — the one place the
1429
- # gem supplies a turn itself, because an outreach reply into an
1430
- # existing case has no earlier turn for anybody to have held.
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
- assign!(to: actor, by: actor, request: request)
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? ? "none" : awaiting_from_clocks
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
- # Whether this message is already folded in. The last-id check catches
1620
- # the common redelivery; the clock check catches the rest, because a
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
- return true if last_registered_message_id.present? && last_registered_message_id.to_s == message.id.to_s
1631
-
1632
- role = role_of(message)
1633
- clock = case role
1634
- when :requester then last_requester_message_at
1635
- when :agent then last_agent_message_at
1636
- end
1637
- return false if clock.blank?
1638
- return true if message.created_at < clock
1639
- return false unless message.created_at == clock
1640
-
1641
- # Equal timestamps are NOT the same message. A coarse column, a frozen
1642
- # clock in a test, or two very fast inserts can put two different
1643
- # messages on one instant, and a `<=` here folded the second one in
1644
- # without moving anything — so the assistant answered a question the
1645
- # case had never registered. The only equal-timestamp message that
1646
- # counts as folded in is the one the clock was set FROM.
1647
- case role
1648
- when :requester then last_requester_message_id.present? &&
1649
- last_requester_message_id.to_s == message.id.to_s
1650
- else false
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
- # An assistant's name is read from the message's own provenance stamp
33
- # first (`metadata["support_desk"]["assistant"]`, the KEY), resolved
34
- # through the configuration when she is still declared, and only then
35
- # falls back to the `display_name` the message was written with. So a
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
- # The key the message was stamped with, resolved to the name that key
208
- # has NOW — and the stored display name when nothing declares her any
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SupportDesk
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -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