support_desk 0.2.0 → 0.3.2

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +197 -0
  3. data/README.md +1131 -14
  4. data/app/assets/stylesheets/support_desk.css +10 -0
  5. data/app/controllers/support_desk/tickets_controller.rb +23 -1
  6. data/app/helpers/support_desk/engine_helper.rb +16 -0
  7. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  8. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  9. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  10. data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
  11. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  12. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  13. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
  14. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  15. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  16. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  17. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  18. data/config/locales/support_desk.console.en.yml +63 -0
  19. data/config/locales/support_desk.console.es.yml +65 -0
  20. data/config/locales/support_desk.en.yml +15 -0
  21. data/config/locales/support_desk.es.yml +23 -0
  22. data/config/routes.rb +7 -1
  23. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  24. data/lib/generators/support_desk/install_generator.rb +21 -3
  25. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  26. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  27. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  28. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  29. data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
  30. data/lib/generators/support_desk/templates/initializer.rb +33 -0
  31. data/lib/generators/support_desk/upgrade_generator.rb +25 -7
  32. data/lib/support_desk/assistant_policy.rb +213 -0
  33. data/lib/support_desk/brief.rb +283 -0
  34. data/lib/support_desk/configuration.rb +552 -2
  35. data/lib/support_desk/console.rb +290 -8
  36. data/lib/support_desk/context_card.rb +10 -1
  37. data/lib/support_desk/doctor.rb +171 -1
  38. data/lib/support_desk/engine.rb +16 -1
  39. data/lib/support_desk/errors.rb +37 -0
  40. data/lib/support_desk/events.rb +12 -5
  41. data/lib/support_desk/macros.rb +14 -1
  42. data/lib/support_desk/message_signatures.rb +20 -0
  43. data/lib/support_desk/models/assistant.rb +188 -0
  44. data/lib/support_desk/models/concerns/requester.rb +18 -0
  45. data/lib/support_desk/models/desk.rb +26 -3
  46. data/lib/support_desk/models/draft.rb +275 -0
  47. data/lib/support_desk/models/event.rb +15 -1
  48. data/lib/support_desk/models/message_registration.rb +12 -0
  49. data/lib/support_desk/models/ticket/assistance.rb +899 -0
  50. data/lib/support_desk/models/ticket.rb +406 -58
  51. data/lib/support_desk/outcome.rb +38 -0
  52. data/lib/support_desk/queue.rb +41 -5
  53. data/lib/support_desk/test_helpers.rb +152 -0
  54. data/lib/support_desk/timeline.rb +40 -11
  55. data/lib/support_desk/topic.rb +22 -0
  56. data/lib/support_desk/topic_tree.rb +7 -1
  57. data/lib/support_desk/transcript.rb +234 -0
  58. data/lib/support_desk/version.rb +1 -1
  59. data/lib/support_desk.rb +151 -0
  60. data/lib/tasks/support_desk.rake +57 -0
  61. metadata +33 -8
@@ -34,6 +34,17 @@ module SupportDesk
34
34
  ticket_topic_changed: "ticket, from:, to:, by:",
35
35
  subject_attached: "ticket, subject, by:",
36
36
  note_added: "ticket, event",
37
+ # The assistants (0.3). `assistant_turn` is the one a harness
38
+ # subscribes to: everything else is something to notify a human about.
39
+ assistant_turn: "ticket, assistant, message, turn:",
40
+ draft_proposed: "ticket, draft",
41
+ draft_sent: "ticket, draft, message, by:",
42
+ draft_rejected: "ticket, draft, by:, reason:",
43
+ assistant_withheld: "ticket, assistant, reason:, policy:",
44
+ ticket_escalated: "ticket, from:, reason:, by:",
45
+ human_requested: "ticket, by:, reason:",
46
+ assistant_paused: "ticket, by:",
47
+ assistant_resumed: "ticket, by:",
37
48
  ticket_transitioned: "ticket, kind, by:, request:, payload:"
38
49
  }.freeze
39
50
 
@@ -112,11 +123,7 @@ module SupportDesk
112
123
  end
113
124
 
114
125
  def report_subscriber_error(error, event)
115
- if defined?(Rails) && Rails.respond_to?(:error) && Rails.error
116
- Rails.error.report(error, handled: true, source: "support_desk", context: { event: event })
117
- else
118
- logger&.error("[support_desk] subscriber raised on #{event}: #{error.class}: #{error.message}")
119
- end
126
+ report_error(error, context: { event: event, hook: :subscriber })
120
127
  end
121
128
  end
122
129
  end
@@ -57,13 +57,26 @@ module SupportDesk
57
57
  # subject of the sentence (`ticket.assign!(to: lucia)`).
58
58
  def acts_as_support_agent(kind: :human, **options)
59
59
  condition = Macros.condition!(options, macro: "acts_as_support_agent", known: ":if, :kind")
60
+ kind = Macros.kind!(kind)
60
61
 
61
62
  include SupportDesk::Agent
62
63
 
63
- self.support_desk_agent_options = { if: condition, kind: kind.to_sym }.freeze
64
+ self.support_desk_agent_options = { if: condition, kind: kind }.freeze
64
65
  SupportDesk.register_agent(self)
65
66
  end
66
67
 
68
+ # Who the agent IS: a person, or a machine. There are two kinds because
69
+ # they are answerable to different rules, and an unknown third would be
70
+ # treated as a human by every check that isn't looking for :ai — so it
71
+ # fails at class definition instead.
72
+ def self.kind!(kind)
73
+ kind = kind.to_sym if kind.respond_to?(:to_sym)
74
+ return kind if %i[human ai].include?(kind)
75
+
76
+ raise ConfigurationError,
77
+ "acts_as_support_agent kind: must be :human or :ai, got #{kind.inspect}"
78
+ end
79
+
67
80
  # The `if:` both macros take: a method name or a callable, and the only
68
81
  # option either of them accepts beyond its own keywords. Written once
69
82
  # because "may this person answer" and "may this person ask" are the same
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # The default Chats renderer uses the name/disclosure captured when an
5
+ # assistant spoke. Host signature overrides remain authoritative. Human
6
+ # replies (including approved drafts) still use Chats' normal signature.
7
+ module MessageSignatures
8
+ def message_signature_for(message)
9
+ return super if config.message_signature || !message&.signed?
10
+
11
+ stamp = message.metadata.is_a?(Hash) && message.metadata["support_desk"]
12
+ return super unless stamp.is_a?(Hash) && stamp["kind"] == "ai" && stamp["signed"] == true
13
+
14
+ name = stamp["display_name"]
15
+ return super unless name.is_a?(String) && name.present?
16
+
17
+ I18n.t("chats.message.signature", name: name)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # The assistant: an AI agent, as a record.
5
+ #
6
+ # rose = SupportDesk.assistant(:rose)
7
+ # rose.autonomy # => :draft (from configuration)
8
+ # rose.deactivate!(by: lucia) # the cross-process kill switch
9
+ #
10
+ # Two things live here and nothing else: WHO she is (a row a message can
11
+ # be authored by, an assignment can point at, and an audit log can name)
12
+ # and WHETHER she is on. Everything she may do lives in configuration, in
13
+ # code, so a policy change is a deploy and a diff — see
14
+ # SupportDesk::Configuration::AssistantConfiguration and
15
+ # SupportDesk::AssistantPolicy.
16
+ #
17
+ # `active` is the one runtime switch, and it is a DATABASE column on
18
+ # purpose: `rose.deactivate!(by: owner)` stops her everywhere within one
19
+ # transition, with no deploy, because `ensure_agent_record!` re-reads this
20
+ # row before every write. Rows are never destroyed — a case she answered in
21
+ # March must still be able to say who wrote it.
22
+ class Assistant < ApplicationRecord
23
+ self.table_name = "support_desk_assistants"
24
+
25
+ acts_as_support_agent kind: :ai, if: :active?
26
+
27
+ # Ruby-side default so settings is always a Hash even on MySQL, where a
28
+ # JSON column can't carry a DB default.
29
+ attribute :settings, default: -> { {} }
30
+
31
+ validates :key, presence: true
32
+
33
+ scope :active, -> { where(active: true) }
34
+
35
+ # The assistant for +key+, found or created. Never INSERT-first, exactly
36
+ # like Desk.for: read on every transition, written once.
37
+ def self.for(key)
38
+ key = key.to_s
39
+ find_by(key: key) || create_or_find_by!(key: key)
40
+ end
41
+
42
+ # --- Configuration ----------------------------------------------------------
43
+
44
+ # Her slice of the configuration. Raises ConfigurationError when nothing
45
+ # declares her any more — which is why everything below reads through
46
+ # #settings_config and falls back to the safest answer instead.
47
+ def config
48
+ SupportDesk.config.assistant(key)
49
+ end
50
+
51
+ # Whether this row still has configuration behind it. False for an
52
+ # assistant a host removed from the initializer and kept the history of.
53
+ def configured? = SupportDesk.config.assistant?(key)
54
+
55
+ # What she is called — from configuration while there is any, and from
56
+ # the snapshot below once there is not. See #snapshot_disclosure!.
57
+ def name = settings_config&.name.presence || settings["name"].presence || key.to_s.humanize
58
+
59
+ # Anything `image_tag` accepts, or nil. A callable is passed the record.
60
+ def avatar
61
+ value = settings_config&.avatar
62
+ value.respond_to?(:call) ? value.call(self) : value
63
+ end
64
+
65
+ # An unconfigured assistant is capped at :off by construction: there is
66
+ # no rule left saying what she may do, so she may do nothing.
67
+ def autonomy = settings_config&.autonomy || :off
68
+ def disclosure = settings_config&.disclosure || settings["disclosure"]&.to_sym
69
+ def max_turns = settings_config&.max_turns
70
+ def responds_within = settings_config&.responds_within
71
+ def may_open_conversations? = settings_config&.may_open_conversations? || false
72
+
73
+ # Read from configuration while there is any, and from the snapshot
74
+ # otherwise — never simply `false`, because these three decide what a
75
+ # requester is shown next to a message she already sent (R8).
76
+ def disclosed? = disclosure.present? && disclosure != :none
77
+ def signs? = %i[signature_and_notice signature].include?(disclosure)
78
+ def notice? = %i[signature_and_notice notice].include?(disclosure)
79
+
80
+ # Remember what she is called and what is disclosed about her, on her own
81
+ # row. Written whenever configuration resolves her and the two disagree —
82
+ # one UPDATE in the life of a setting, and none on the usual read.
83
+ #
84
+ # Chats asks the AUTHOR for a signature every time a message is rendered,
85
+ # so before this the requester-facing line was whatever configuration said
86
+ # right now: taking her out of the initializer turned "— Rose · virtual
87
+ # assistant" into "— Rose" on messages nobody had touched, and a host
88
+ # reading an old transcript could no longer tell that a machine had
89
+ # written it (R8).
90
+ #
91
+ # Signed messages render their own immutable provenance through the default
92
+ # Chats signature hook. This row snapshot is only a fallback for legacy
93
+ # messages with no provenance and other author displays.
94
+ def snapshot_disclosure! # :nodoc:
95
+ return self unless configured?
96
+
97
+ snapshot = { "name" => name, "disclosure" => disclosure&.to_s }.compact
98
+ return self if snapshot.all? { |field, value| settings[field] == value }
99
+
100
+ update_columns(settings: settings.merge(snapshot))
101
+ self
102
+ end
103
+
104
+ # --- Who she looks like -----------------------------------------------------
105
+
106
+ # The name a REQUESTER sees. Disclosure is not a badge somebody might
107
+ # miss: when she discloses, the name itself says what she is.
108
+ def disclosed_name
109
+ return name unless disclosed?
110
+
111
+ I18n.t("support_desk.assistant.disclosed_name", name: name)
112
+ end
113
+
114
+ # chats reads a display name through the host's own lambda, which
115
+ # usually tries these in order — so all three answer the disclosed name
116
+ # and no host has to special-case a support assistant.
117
+ def display_name = disclosed_name
118
+ def to_s = disclosed_name
119
+ def support_agent_name = disclosed_name
120
+ def support_agent_avatar = avatar
121
+
122
+ # --- Duty -------------------------------------------------------------------
123
+
124
+ # The kill switch, read fresh from the row on every transition.
125
+ def on_duty? = active?
126
+
127
+ # Unlimited by construction: her ceiling is `max_turns` per case and the
128
+ # policy, not a number of cases.
129
+ def support_capacity = nil
130
+
131
+ # Stop her everywhere, now. The cases she is SITTING on are given back by
132
+ # `SupportDesk.reclaim_assistant_seats!` (which
133
+ # `release_silent_assistants!` runs first, and
134
+ # `rake support_desk:reclaim_assistant_seats` runs alone), and it asks
135
+ # for a person on each of them. Run it, or wait for its schedule, before
136
+ # calling the desk quiet.
137
+ #
138
+ # It needs no `responds_within` and no overdue clock: a seat nobody can
139
+ # sit in any more is not a silence problem. In 0.3.0 it was treated as
140
+ # one, so an assistant with no promise kept her seats for ever (R6).
141
+ def deactivate!(by:, reason: nil)
142
+ update!(active: false)
143
+ SupportDesk.logger&.warn(
144
+ "[support_desk] assistant #{key} deactivated by #{SupportDesk.actor_key(by) || "somebody"}" \
145
+ "#{": #{reason}" if reason.present?}"
146
+ )
147
+ self
148
+ end
149
+
150
+ # Put her back on duty.
151
+ def activate!(by:)
152
+ update!(active: true)
153
+ SupportDesk.logger&.info(
154
+ "[support_desk] assistant #{key} activated by #{SupportDesk.actor_key(by) || "somebody"}"
155
+ )
156
+ self
157
+ end
158
+
159
+ # --- Where she works --------------------------------------------------------
160
+
161
+ # The desks whose configuration points at her.
162
+ def desks
163
+ SupportDesk.config.desks.each_key.filter_map do |desk_key|
164
+ SupportDesk.desk(desk_key) if SupportDesk.config.desk(desk_key).assistant_key == key.to_sym
165
+ end
166
+ end
167
+
168
+ # The open cases she is sitting on.
169
+ def held_tickets
170
+ Ticket.open.assigned_to(self)
171
+ end
172
+
173
+ # The assistant, in one line.
174
+ def inspect
175
+ "#<SupportDesk::Assistant #{key} #{name.inspect} #{autonomy} #{active? ? "active" : "inactive"}>"
176
+ end
177
+
178
+ private
179
+
180
+ # Her configuration, or nil when nothing declares her any more. Every
181
+ # reader goes through this so a historical row still renders.
182
+ def settings_config
183
+ return nil unless configured?
184
+
185
+ config
186
+ end
187
+ end
188
+ end
@@ -9,6 +9,12 @@ module SupportDesk
9
9
  # alice.support_desk
10
10
  # alice.awaiting_support_reply?
11
11
  # alice.unread_support_count
12
+ #
13
+ # class User < ApplicationRecord
14
+ # has_support_tickets
15
+ #
16
+ # def support_context = { "Plan" => plan.name, "Viajes" => rides.count }
17
+ # end
12
18
  module Requester
13
19
  extend ActiveSupport::Concern
14
20
 
@@ -69,6 +75,18 @@ module SupportDesk
69
75
  )
70
76
  end
71
77
 
78
+ # Key/value pairs about the PERSON, the way `Supportable#support_context`
79
+ # is key/value pairs about the thing they are asking about: their plan,
80
+ # how long they have been here, whatever your agents always look up
81
+ # anyway. Rendered in the console's context card, and included in
82
+ # `Ticket#brief`.
83
+ #
84
+ # Empty by default, and it stays empty until you fill it — everything
85
+ # in here is read by whoever answers the case, and by whatever model a
86
+ # harness hands the brief to. Put what an agent needs to help; leave out
87
+ # what they don't.
88
+ def support_context = {}
89
+
72
90
  # True when any of this requester's open tickets is waiting on the desk.
73
91
  def awaiting_support_reply?
74
92
  support_tickets.not_closed.awaiting_reply.exists?
@@ -79,12 +79,35 @@ module SupportDesk
79
79
  config.read(:email) || settings["email"].presence
80
80
  end
81
81
 
82
- # The agent pool, resolved from `config.agents`.
82
+ # The assistant who works this desk, or nil. Resolved through
83
+ # `SupportDesk.assistant`, so it is the same memoised record everywhere.
84
+ def assistant
85
+ key = config.assistant_key
86
+ return nil if key.nil?
87
+
88
+ SupportDesk.assistant(key)
89
+ end
90
+
91
+ # Whether this desk has one at all.
92
+ def assistant? = !assistant.nil?
93
+
94
+ # The PEOPLE in the pool. What `agents_to_notify` pages, what a human
95
+ # picker offers, and the reason a host never has to reject machines in
96
+ # its own notifier.
97
+ def humans
98
+ config.agent_pool.to_a.reject { |agent| SupportDesk.ai_actor?(agent) }
99
+ end
100
+
101
+ # Everybody who can answer here, the assistant included — pickers and
102
+ # routing. An Array, not a relation: the assistant isn't in the host's
103
+ # scope, and a pool that is half a query and half a record is a pool
104
+ # that can't be either.
83
105
  def agents
84
- config.agent_pool
106
+ humans + [ assistant ].compact
85
107
  end
86
108
 
87
- # The pool, minus anyone who says they're off duty.
109
+ # The pool, minus anyone who says they're off duty (a deactivated
110
+ # assistant says so).
88
111
  def on_duty_agents
89
112
  agents.select { |agent| !agent.respond_to?(:on_duty?) || agent.on_duty? }
90
113
  end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # A reply the assistant proposes and a human sends — or doesn't.
5
+ #
6
+ # draft = ticket.pending_draft
7
+ # draft.send!(by: lucia, seen_turn: ticket.assistant_turn) # verbatim
8
+ # draft.send!(by: lucia, seen_turn: …, body: "Casi: …") # edited
9
+ # draft.reject!(by: lucia, reason: "no es eso")
10
+ #
11
+ # == Who wrote it
12
+ #
13
+ # A sent draft is a message from the HUMAN who sent it (12 #20): they read
14
+ # it, they own it, and the requester sees their signature. What the machine
15
+ # contributed is provenance — `support_desk.drafted_by`, `draft_id` and
16
+ # `edited` in the message's metadata, and this row, which keeps the
17
+ # original body even when the sent one was edited.
18
+ #
19
+ # == Staleness is not a warning, it is the contract
20
+ #
21
+ # `proposed_turn` is the ticket's turn when the draft was written.
22
+ # `seen_turn` is the turn the reviewer was LOOKING at. Sending compares
23
+ # `seen_turn` against the case's turn right now, under its row lock — so a
24
+ # draft approved from a page that predates the customer's next message is
25
+ # refused (SupportDesk::StaleTurn) rather than sent into a conversation
26
+ # that has moved on. There is no "send anyway" flag: re-read the case and
27
+ # submit again with the current turn.
28
+ class Draft < ApplicationRecord
29
+ self.table_name = "support_desk_drafts"
30
+
31
+ # pending → sent | rejected (a human decided), or superseded (a newer
32
+ # proposal, a takeover, a pause) | expired (the case closed).
33
+ STATUSES = %w[pending sent rejected superseded expired].freeze
34
+
35
+ # Enough for any honest answer, and a ceiling on what a model can stuff
36
+ # into a JSON column.
37
+ MAX_SOURCES = 20
38
+ MAX_SOURCE_TITLE = 500
39
+ MAX_SOURCE_URL = 2048
40
+
41
+ belongs_to :ticket, class_name: "SupportDesk::Ticket", inverse_of: :drafts
42
+ belongs_to :author, polymorphic: true
43
+ belongs_to :reviewed_by, polymorphic: true, optional: true
44
+ belongs_to :sent_message, class_name: "Chats::Message", optional: true
45
+
46
+ # Attachments ride on ActiveStorage when the host has it installed —
47
+ # the same condition chats' own messages use.
48
+ has_many_attached :files if defined?(ActiveStorage)
49
+
50
+ # Ruby-side defaults so both are always usable even on MySQL, where a
51
+ # JSON column can't carry a DB default.
52
+ attribute :sources, default: -> { [] }
53
+ attribute :metadata, default: -> { {} }
54
+
55
+ validates :status, inclusion: { in: STATUSES }
56
+ validates :proposed_turn, presence: true
57
+ validates :confidence, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 },
58
+ allow_nil: true
59
+ validate :body_or_files_present
60
+ validate :sources_well_formed
61
+
62
+ scope :pending, -> { where(status: "pending") }
63
+ scope :sent, -> { where(status: "sent") }
64
+ scope :rejected, -> { where(status: "rejected") }
65
+ scope :superseded, -> { where(status: "superseded") }
66
+ scope :expired, -> { where(status: "expired") }
67
+ # What a human actually decided about — the denominator every acceptance
68
+ # number in the README is measured against.
69
+ scope :reviewed, -> { where(status: %w[sent rejected]) }
70
+ scope :verbatim, -> { sent.where(sent_body: nil) }
71
+ scope :edited, -> { sent.where.not(sent_body: nil) }
72
+ scope :by, ->(author) { where(author_type: author.class.polymorphic_name, author_id: author.id) }
73
+ scope :chronological, -> { order(:created_at, :id) }
74
+ scope :newest_first, -> { order(created_at: :desc, id: :desc) }
75
+
76
+ def pending? = status == "pending"
77
+ def sent? = status == "sent"
78
+ def rejected? = status == "rejected"
79
+ def superseded? = status == "superseded"
80
+ def expired? = status == "expired"
81
+
82
+ # Whether the case has changed since this was proposed — a customer
83
+ # message, a human note, a takeover, anything. The turn is the whole
84
+ # answer: it moves for every one of them.
85
+ def stale?
86
+ pending? && proposed_turn.to_s != ticket.assistant_turn
87
+ end
88
+
89
+ # Whether the human rewrote it before sending.
90
+ def edited? = sent? && sent_body.present?
91
+
92
+ # What the requester got (or would get): the edit when there was one.
93
+ def final_body = sent_body.presence || body
94
+
95
+ # The declared confidence as a whole percentage, or nil.
96
+ def confidence_percent
97
+ return nil if confidence.nil?
98
+
99
+ (confidence.to_f * 100).round
100
+ end
101
+
102
+ # Who proposed it, as the stable key event payloads and metadata use.
103
+ def author_key = SupportDesk.actor_key(author)
104
+
105
+ # Whether this draft has attachments to carry to the message.
106
+ def files_attached? # :nodoc:
107
+ respond_to?(:files) && files.attached?
108
+ end
109
+
110
+ # --- The two decisions --------------------------------------------------------
111
+
112
+ # Send it: the message is the HUMAN's, the proposal is provenance.
113
+ # `seen_turn` is the case's turn as the reviewer's page rendered it, and
114
+ # a mismatch is a refusal — see the class comment. `body:` replaces the
115
+ # text (an edit); nil sends it verbatim. Returns the Chats::Message.
116
+ def send!(by:, seen_turn:, body: nil, request: nil)
117
+ human = ticket.send(:resolve_actor, by)
118
+ if human.is_a?(Symbol) || SupportDesk.ai_actor?(human)
119
+ raise NotAllowed,
120
+ "an assistant can't approve her own draft — a person sends it, and it is signed by them"
121
+ end
122
+ ticket.send(:ensure_agent!, human)
123
+ raise ArgumentError, "seen_turn: is required — send the turn the page was rendered with" if seen_turn.blank?
124
+ if !body.nil? && body.to_s.strip.empty? && !files_attached?
125
+ raise ArgumentError, "an edited draft can't be blank — reject it instead"
126
+ end
127
+
128
+ # The repair commits on its own, so a message whose registration
129
+ # callback was lost is folded in for good even when the approval this
130
+ # call was making is then refused as stale (R3).
131
+ ticket.reconcile_and_commit!
132
+
133
+ message = nil
134
+ ticket.with_lock(requires_new: true) do
135
+ reload
136
+ # Ticket, then conversation: an in-flight customer message holds the
137
+ # conversation row, so `seen_turn` cannot be compared against a case
138
+ # whose next question is one commit away (R1).
139
+ ticket.send(:lock_conversation!)
140
+ ticket.send(:reconcile_unregistered_messages!)
141
+ raise InvalidTransition, "draft #{id} is #{status}, not pending" unless pending?
142
+ unless seen_turn.to_s == ticket.assistant_turn
143
+ raise StaleTurn,
144
+ "the case changed since you read it (#{seen_turn} → #{ticket.assistant_turn}); read it again"
145
+ end
146
+
147
+ final = body.nil? ? self.body : body
148
+ edited = final != self.body
149
+ # BEFORE anything moves: `stale?` reads `pending?`, and the update
150
+ # below is what stops it being true.
151
+ was_stale = stale?
152
+
153
+ message = ticket.reply!(
154
+ final, by: human, files: (files_attached? ? files.blobs : []), request: request,
155
+ metadata: { "support_desk" => { "drafted_by" => author_key, "draft_id" => id.to_s, "edited" => edited } }
156
+ )
157
+ update!(status: "sent", reviewed_by: human, reviewed_at: Time.current, sent_message: message,
158
+ sent_body: (final if edited))
159
+ ticket.send(:reset_draft_associations!)
160
+ ticket.send(:write_transition!, :draft_sent, actor: human, request: request) do
161
+ { "draft" => id.to_s, "assistant" => author_key, "edited" => edited,
162
+ "was_stale" => was_stale, "message" => message.id.to_s }
163
+ end
164
+ end
165
+
166
+ SupportDesk.emit_after_commit(:draft_sent, ticket, self, message, by: human)
167
+ message
168
+ end
169
+
170
+ # Throw it away, with a reason worth reading later: the rejection reasons
171
+ # are what tell a host whether an assistant is ready for a higher level.
172
+ def reject!(by:, reason: nil, request: nil)
173
+ human = ticket.send(:resolve_actor, by)
174
+ if human.is_a?(Symbol) || SupportDesk.ai_actor?(human)
175
+ raise NotAllowed, "an assistant can't review her own draft"
176
+ end
177
+ ticket.send(:ensure_agent!, human)
178
+
179
+ ticket.with_lock(requires_new: true) do
180
+ reload
181
+ raise InvalidTransition, "draft #{id} is #{status}, not pending" unless pending?
182
+
183
+ update!(status: "rejected", reviewed_by: human, reviewed_at: Time.current,
184
+ rejection_reason: reason.presence&.to_s)
185
+ ticket.send(:reset_draft_associations!)
186
+ ticket.send(:write_transition!, :draft_rejected, actor: human, request: request) do
187
+ { "draft" => id.to_s, "assistant" => author_key, "reason" => reason.presence&.to_s }
188
+ end
189
+ end
190
+
191
+ SupportDesk.emit_after_commit(:draft_rejected, ticket, self, by: human, reason: reason)
192
+ self
193
+ end
194
+
195
+ # A newer proposal, a human reply, a takeover or a pause replaced it.
196
+ # Idempotent, and writes no event: nobody decided anything.
197
+ def supersede! # :nodoc:
198
+ return self unless pending?
199
+
200
+ update!(status: "superseded")
201
+ self
202
+ end
203
+
204
+ # The case closed under it.
205
+ def expire! # :nodoc:
206
+ return self unless pending?
207
+
208
+ update!(status: "expired")
209
+ self
210
+ end
211
+
212
+ # The draft, in one line.
213
+ def inspect
214
+ "#<SupportDesk::Draft #{id} #{status} ticket=#{ticket_id} #{author_key}>"
215
+ end
216
+
217
+ private
218
+
219
+ # A model wrote these, so the shape is whatever came back. `to_h` is the
220
+ # generous reading (a list of pairs, a hash-like object) and it RAISES on
221
+ # anything else — `["title", "x"]` is a TypeError, not a hash — so the
222
+ # coercion answers nil and the validation below says what it wanted,
223
+ # instead of taking the save down with an exception nobody can act on.
224
+ def coerce_source(entry)
225
+ return entry.stringify_keys if entry.is_a?(Hash)
226
+ return nil if entry.is_a?(String) || !entry.respond_to?(:to_h)
227
+
228
+ entry.to_h.stringify_keys
229
+ rescue TypeError, ArgumentError
230
+ nil
231
+ end
232
+
233
+ def body_or_files_present
234
+ return if body.present? || files_attached?
235
+
236
+ errors.add(:body, "can't be blank without an attachment")
237
+ end
238
+
239
+ # A model wrote these. They render as links in a console, so they are
240
+ # checked like anything a stranger typed: a list, of pairs, with http(s)
241
+ # URLs and nothing longer than a page can show.
242
+ def sources_well_formed
243
+ value = sources
244
+ return if value.blank?
245
+
246
+ unless value.is_a?(Array)
247
+ errors.add(:sources, "must be a list of { title:, url: } entries")
248
+ return
249
+ end
250
+ if value.size > MAX_SOURCES
251
+ errors.add(:sources, "can't have more than #{MAX_SOURCES} entries (got #{value.size})")
252
+ return
253
+ end
254
+
255
+ value.each do |original|
256
+ entry = coerce_source(original)
257
+ unless entry.is_a?(Hash)
258
+ errors.add(:sources, "entries must be { title:, url: } hashes, got #{original.class}")
259
+ next
260
+ end
261
+
262
+ title = entry["title"]
263
+ url = entry["url"]
264
+ errors.add(:sources, "every entry needs a title") if title.blank?
265
+ errors.add(:sources, "a title can't be longer than #{MAX_SOURCE_TITLE} characters") if
266
+ title.to_s.length > MAX_SOURCE_TITLE
267
+ next if url.blank?
268
+
269
+ errors.add(:sources, "a url can't be longer than #{MAX_SOURCE_URL} characters") if
270
+ url.to_s.length > MAX_SOURCE_URL
271
+ errors.add(:sources, "url #{url.inspect} must be http(s)") unless url.to_s.match?(%r{\Ahttps?://}i)
272
+ end
273
+ end
274
+ end
275
+ end
@@ -14,6 +14,16 @@ module SupportDesk
14
14
  KINDS = %w[
15
15
  opened assigned handed_off released drop_in closed reopened topic_changed subject_attached note
16
16
  snoozed woken escalated channel_added email_bounced email_unverified rated tagged
17
+ human_requested assistant_paused assistant_resumed draft_sent draft_rejected assistant_withheld
18
+ ].freeze
19
+
20
+ # The five kinds that describe how the desk works rather than what
21
+ # happened to the case. They join `note` and `drop_in` outside
22
+ # #requester_visible: a customer's export says a person was asked for
23
+ # (`escalated`, `human_requested`), never that a machine's proposal was
24
+ # discarded.
25
+ INTERNAL_KINDS = %w[
26
+ note drop_in assistant_paused assistant_resumed draft_sent draft_rejected assistant_withheld
17
27
  ].freeze
18
28
 
19
29
  belongs_to :ticket, class_name: "SupportDesk::Ticket", inverse_of: :events
@@ -28,7 +38,7 @@ module SupportDesk
28
38
  scope :notes, -> { where(kind: "note") }
29
39
  # Everything a requester may see in an export: their own case's story,
30
40
  # never the desk's internal reasoning.
31
- scope :requester_visible, -> { where.not(kind: %w[note drop_in]) }
41
+ scope :requester_visible, -> { where.not(kind: INTERNAL_KINDS) }
32
42
 
33
43
  # Write one event. `actor` may be a record or a Symbol (`:system`,
34
44
  # `:routing`) — symbols are kept in the payload, since there is no row
@@ -64,6 +74,10 @@ module SupportDesk
64
74
  # The text of an internal note, for kind "note".
65
75
  def note = payload["note"]
66
76
 
77
+ # The one-paragraph "here is where I got to" an assistant leaves when she
78
+ # hands a case over, for kinds "escalated" and "human_requested".
79
+ def summary = payload["summary"]
80
+
67
81
  # The event, in one line.
68
82
  def inspect
69
83
  "#<SupportDesk::Event #{kind} ticket=#{ticket_id} #{created_at&.iso8601}>"
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # Internal receipt written in the same transaction as the clocks and turn.
5
+ # Message identity establishes idempotency; timestamp/UUID order does not.
6
+ class MessageRegistration < ApplicationRecord
7
+ self.table_name = "support_desk_message_registrations"
8
+ self.primary_key = :message_id
9
+
10
+ belongs_to :ticket, class_name: "SupportDesk::Ticket"
11
+ end
12
+ end