support_desk 0.2.0 → 0.3.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +998 -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 +12 -0
  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/initializer.rb +33 -0
  30. data/lib/generators/support_desk/upgrade_generator.rb +12 -2
  31. data/lib/support_desk/assistant_policy.rb +213 -0
  32. data/lib/support_desk/brief.rb +283 -0
  33. data/lib/support_desk/configuration.rb +552 -2
  34. data/lib/support_desk/console.rb +290 -8
  35. data/lib/support_desk/context_card.rb +10 -1
  36. data/lib/support_desk/doctor.rb +144 -1
  37. data/lib/support_desk/engine.rb +10 -0
  38. data/lib/support_desk/errors.rb +37 -0
  39. data/lib/support_desk/events.rb +12 -5
  40. data/lib/support_desk/macros.rb +14 -1
  41. data/lib/support_desk/models/assistant.rb +153 -0
  42. data/lib/support_desk/models/concerns/requester.rb +18 -0
  43. data/lib/support_desk/models/desk.rb +26 -3
  44. data/lib/support_desk/models/draft.rb +266 -0
  45. data/lib/support_desk/models/event.rb +15 -1
  46. data/lib/support_desk/models/ticket/assistance.rb +675 -0
  47. data/lib/support_desk/models/ticket.rb +334 -37
  48. data/lib/support_desk/outcome.rb +38 -0
  49. data/lib/support_desk/queue.rb +41 -5
  50. data/lib/support_desk/test_helpers.rb +152 -0
  51. data/lib/support_desk/timeline.rb +40 -11
  52. data/lib/support_desk/topic.rb +22 -0
  53. data/lib/support_desk/topic_tree.rb +7 -1
  54. data/lib/support_desk/transcript.rb +237 -0
  55. data/lib/support_desk/version.rb +1 -1
  56. data/lib/support_desk.rb +108 -0
  57. data/lib/tasks/support_desk.rake +46 -0
  58. metadata +30 -8
@@ -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,153 @@
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
+ def name = settings_config&.name || key.to_s.humanize
56
+
57
+ # Anything `image_tag` accepts, or nil. A callable is passed the record.
58
+ def avatar
59
+ value = settings_config&.avatar
60
+ value.respond_to?(:call) ? value.call(self) : value
61
+ end
62
+
63
+ # An unconfigured assistant is capped at :off by construction: there is
64
+ # no rule left saying what she may do, so she may do nothing.
65
+ def autonomy = settings_config&.autonomy || :off
66
+ def disclosure = settings_config&.disclosure
67
+ def max_turns = settings_config&.max_turns
68
+ def responds_within = settings_config&.responds_within
69
+ def may_open_conversations? = settings_config&.may_open_conversations? || false
70
+
71
+ def disclosed? = settings_config ? settings_config.disclosed? : false
72
+ def signs? = settings_config ? settings_config.signs? : false
73
+ def notice? = settings_config ? settings_config.notice? : false
74
+
75
+ # --- Who she looks like -----------------------------------------------------
76
+
77
+ # The name a REQUESTER sees. Disclosure is not a badge somebody might
78
+ # miss: when she discloses, the name itself says what she is.
79
+ def disclosed_name
80
+ return name unless disclosed?
81
+
82
+ I18n.t("support_desk.assistant.disclosed_name", name: name)
83
+ end
84
+
85
+ # chats reads a display name through the host's own lambda, which
86
+ # usually tries these in order — so all three answer the disclosed name
87
+ # and no host has to special-case a support assistant.
88
+ def display_name = disclosed_name
89
+ def to_s = disclosed_name
90
+ def support_agent_name = disclosed_name
91
+ def support_agent_avatar = avatar
92
+
93
+ # --- Duty -------------------------------------------------------------------
94
+
95
+ # The kill switch, read fresh from the row on every transition.
96
+ def on_duty? = active?
97
+
98
+ # Unlimited by construction: her ceiling is `max_turns` per case and the
99
+ # policy, not a number of cases.
100
+ def support_capacity = nil
101
+
102
+ # Stop her everywhere, now. Cases she holds are released by the silent
103
+ # sweep (`SupportDesk.release_silent_assistants!`), which also asks for a
104
+ # person on each of them — run it, or wait for its schedule, before
105
+ # calling the desk quiet.
106
+ def deactivate!(by:, reason: nil)
107
+ update!(active: false)
108
+ SupportDesk.logger&.warn(
109
+ "[support_desk] assistant #{key} deactivated by #{SupportDesk.actor_key(by) || "somebody"}" \
110
+ "#{": #{reason}" if reason.present?}"
111
+ )
112
+ self
113
+ end
114
+
115
+ # Put her back on duty.
116
+ def activate!(by:)
117
+ update!(active: true)
118
+ SupportDesk.logger&.info(
119
+ "[support_desk] assistant #{key} activated by #{SupportDesk.actor_key(by) || "somebody"}"
120
+ )
121
+ self
122
+ end
123
+
124
+ # --- Where she works --------------------------------------------------------
125
+
126
+ # The desks whose configuration points at her.
127
+ def desks
128
+ SupportDesk.config.desks.each_key.filter_map do |desk_key|
129
+ SupportDesk.desk(desk_key) if SupportDesk.config.desk(desk_key).assistant_key == key.to_sym
130
+ end
131
+ end
132
+
133
+ # The open cases she is sitting on.
134
+ def held_tickets
135
+ Ticket.open.assigned_to(self)
136
+ end
137
+
138
+ # The assistant, in one line.
139
+ def inspect
140
+ "#<SupportDesk::Assistant #{key} #{name.inspect} #{autonomy} #{active? ? "active" : "inactive"}>"
141
+ end
142
+
143
+ private
144
+
145
+ # Her configuration, or nil when nothing declares her any more. Every
146
+ # reader goes through this so a historical row still renders.
147
+ def settings_config
148
+ return nil unless configured?
149
+
150
+ config
151
+ end
152
+ end
153
+ 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,266 @@
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
+ message = nil
129
+ ticket.with_lock(requires_new: true) do
130
+ reload
131
+ ticket.send(:reconcile_unregistered_messages!)
132
+ raise InvalidTransition, "draft #{id} is #{status}, not pending" unless pending?
133
+ unless seen_turn.to_s == ticket.assistant_turn
134
+ raise StaleTurn,
135
+ "the case changed since you read it (#{seen_turn} → #{ticket.assistant_turn}); read it again"
136
+ end
137
+
138
+ final = body.nil? ? self.body : body
139
+ edited = final != self.body
140
+ # BEFORE anything moves: `stale?` reads `pending?`, and the update
141
+ # below is what stops it being true.
142
+ was_stale = stale?
143
+
144
+ message = ticket.reply!(
145
+ final, by: human, files: (files_attached? ? files.blobs : []), request: request,
146
+ metadata: { "support_desk" => { "drafted_by" => author_key, "draft_id" => id.to_s, "edited" => edited } }
147
+ )
148
+ update!(status: "sent", reviewed_by: human, reviewed_at: Time.current, sent_message: message,
149
+ sent_body: (final if edited))
150
+ ticket.send(:reset_draft_associations!)
151
+ ticket.send(:write_transition!, :draft_sent, actor: human, request: request) do
152
+ { "draft" => id.to_s, "assistant" => author_key, "edited" => edited,
153
+ "was_stale" => was_stale, "message" => message.id.to_s }
154
+ end
155
+ end
156
+
157
+ SupportDesk.emit_after_commit(:draft_sent, ticket, self, message, by: human)
158
+ message
159
+ end
160
+
161
+ # Throw it away, with a reason worth reading later: the rejection reasons
162
+ # are what tell a host whether an assistant is ready for a higher level.
163
+ def reject!(by:, reason: nil, request: nil)
164
+ human = ticket.send(:resolve_actor, by)
165
+ if human.is_a?(Symbol) || SupportDesk.ai_actor?(human)
166
+ raise NotAllowed, "an assistant can't review her own draft"
167
+ end
168
+ ticket.send(:ensure_agent!, human)
169
+
170
+ ticket.with_lock(requires_new: true) do
171
+ reload
172
+ raise InvalidTransition, "draft #{id} is #{status}, not pending" unless pending?
173
+
174
+ update!(status: "rejected", reviewed_by: human, reviewed_at: Time.current,
175
+ rejection_reason: reason.presence&.to_s)
176
+ ticket.send(:reset_draft_associations!)
177
+ ticket.send(:write_transition!, :draft_rejected, actor: human, request: request) do
178
+ { "draft" => id.to_s, "assistant" => author_key, "reason" => reason.presence&.to_s }
179
+ end
180
+ end
181
+
182
+ SupportDesk.emit_after_commit(:draft_rejected, ticket, self, by: human, reason: reason)
183
+ self
184
+ end
185
+
186
+ # A newer proposal, a human reply, a takeover or a pause replaced it.
187
+ # Idempotent, and writes no event: nobody decided anything.
188
+ def supersede! # :nodoc:
189
+ return self unless pending?
190
+
191
+ update!(status: "superseded")
192
+ self
193
+ end
194
+
195
+ # The case closed under it.
196
+ def expire! # :nodoc:
197
+ return self unless pending?
198
+
199
+ update!(status: "expired")
200
+ self
201
+ end
202
+
203
+ # The draft, in one line.
204
+ def inspect
205
+ "#<SupportDesk::Draft #{id} #{status} ticket=#{ticket_id} #{author_key}>"
206
+ end
207
+
208
+ private
209
+
210
+ # A model wrote these, so the shape is whatever came back. `to_h` is the
211
+ # generous reading (a list of pairs, a hash-like object) and it RAISES on
212
+ # anything else — `["title", "x"]` is a TypeError, not a hash — so the
213
+ # coercion answers nil and the validation below says what it wanted,
214
+ # instead of taking the save down with an exception nobody can act on.
215
+ def coerce_source(entry)
216
+ return entry.stringify_keys if entry.is_a?(Hash)
217
+ return nil if entry.is_a?(String) || !entry.respond_to?(:to_h)
218
+
219
+ entry.to_h.stringify_keys
220
+ rescue TypeError, ArgumentError
221
+ nil
222
+ end
223
+
224
+ def body_or_files_present
225
+ return if body.present? || files_attached?
226
+
227
+ errors.add(:body, "can't be blank without an attachment")
228
+ end
229
+
230
+ # A model wrote these. They render as links in a console, so they are
231
+ # checked like anything a stranger typed: a list, of pairs, with http(s)
232
+ # URLs and nothing longer than a page can show.
233
+ def sources_well_formed
234
+ value = sources
235
+ return if value.blank?
236
+
237
+ unless value.is_a?(Array)
238
+ errors.add(:sources, "must be a list of { title:, url: } entries")
239
+ return
240
+ end
241
+ if value.size > MAX_SOURCES
242
+ errors.add(:sources, "can't have more than #{MAX_SOURCES} entries (got #{value.size})")
243
+ return
244
+ end
245
+
246
+ value.each do |original|
247
+ entry = coerce_source(original)
248
+ unless entry.is_a?(Hash)
249
+ errors.add(:sources, "entries must be { title:, url: } hashes, got #{original.class}")
250
+ next
251
+ end
252
+
253
+ title = entry["title"]
254
+ url = entry["url"]
255
+ errors.add(:sources, "every entry needs a title") if title.blank?
256
+ errors.add(:sources, "a title can't be longer than #{MAX_SOURCE_TITLE} characters") if
257
+ title.to_s.length > MAX_SOURCE_TITLE
258
+ next if url.blank?
259
+
260
+ errors.add(:sources, "a url can't be longer than #{MAX_SOURCE_URL} characters") if
261
+ url.to_s.length > MAX_SOURCE_URL
262
+ errors.add(:sources, "url #{url.inspect} must be http(s)") unless url.to_s.match?(%r{\Ahttps?://}i)
263
+ end
264
+ end
265
+ end
266
+ 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}>"