support_desk 0.1.3 → 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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -0
  3. data/README.md +1101 -22
  4. data/Rakefile +16 -0
  5. data/app/assets/stylesheets/support_desk.css +10 -0
  6. data/app/controllers/support_desk/tickets_controller.rb +28 -4
  7. data/app/helpers/support_desk/engine_helper.rb +23 -1
  8. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  9. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  10. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  11. data/app/views/support_desk/console/tickets/_context_card.html.erb +33 -0
  12. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  13. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  14. data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
  15. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +28 -0
  16. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  17. data/app/views/support_desk/console/tickets/index.html.erb +14 -2
  18. data/app/views/support_desk/console/tickets/new.html.erb +25 -0
  19. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  20. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  21. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  22. data/config/console_routes.rb +1 -1
  23. data/config/locales/support_desk.console.en.yml +89 -0
  24. data/config/locales/support_desk.console.es.yml +91 -0
  25. data/config/locales/support_desk.en.yml +18 -0
  26. data/config/locales/support_desk.es.yml +29 -0
  27. data/config/routes.rb +7 -1
  28. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  29. data/lib/generators/support_desk/console_generator.rb +1 -1
  30. data/lib/generators/support_desk/install_generator.rb +26 -0
  31. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  32. data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
  33. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  34. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  35. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  36. data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
  37. data/lib/generators/support_desk/templates/initializer.rb +63 -0
  38. data/lib/generators/support_desk/upgrade_generator.rb +66 -0
  39. data/lib/support_desk/assistant_policy.rb +213 -0
  40. data/lib/support_desk/brief.rb +283 -0
  41. data/lib/support_desk/configuration.rb +699 -3
  42. data/lib/support_desk/console.rb +634 -30
  43. data/lib/support_desk/console_engine.rb +2 -0
  44. data/lib/support_desk/console_routes.rb +20 -8
  45. data/lib/support_desk/context_card.rb +33 -1
  46. data/lib/support_desk/doctor.rb +195 -2
  47. data/lib/support_desk/engine.rb +10 -0
  48. data/lib/support_desk/errors.rb +42 -0
  49. data/lib/support_desk/events.rb +12 -5
  50. data/lib/support_desk/macros.rb +41 -12
  51. data/lib/support_desk/models/assignment.rb +3 -1
  52. data/lib/support_desk/models/assistant.rb +153 -0
  53. data/lib/support_desk/models/concerns/agent.rb +39 -4
  54. data/lib/support_desk/models/concerns/requester.rb +45 -12
  55. data/lib/support_desk/models/desk.rb +26 -3
  56. data/lib/support_desk/models/draft.rb +266 -0
  57. data/lib/support_desk/models/event.rb +15 -1
  58. data/lib/support_desk/models/ticket/assistance.rb +675 -0
  59. data/lib/support_desk/models/ticket.rb +781 -120
  60. data/lib/support_desk/outcome.rb +38 -0
  61. data/lib/support_desk/queue.rb +41 -5
  62. data/lib/support_desk/summary.rb +1 -1
  63. data/lib/support_desk/test_helpers.rb +160 -3
  64. data/lib/support_desk/timeline.rb +40 -11
  65. data/lib/support_desk/topic.rb +22 -0
  66. data/lib/support_desk/topic_tree.rb +7 -1
  67. data/lib/support_desk/transcript.rb +237 -0
  68. data/lib/support_desk/version.rb +1 -1
  69. data/lib/support_desk/wizard.rb +4 -12
  70. data/lib/support_desk.rb +153 -0
  71. data/lib/tasks/support_desk.rake +73 -0
  72. metadata +35 -8
@@ -48,6 +48,8 @@ module SupportDesk
48
48
  # that makes a later precedence bug much harder to read. SupportDesk
49
49
  # ::Engine already ships them for both namespaces.
50
50
  paths["config/locales"] = []
51
+ # The requester engine owns task discovery too; both share this root.
52
+ paths["lib/tasks"] = []
51
53
 
52
54
  # `concerns: :support_console` in the HOST's routes file. Registered from
53
55
  # an initializer, which is early enough: the app's routes are not drawn
@@ -1,15 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SupportDesk
4
- # The `:support_console` routing concern — the one line that turns two
4
+ # The `:support_console` routing concern — the one line that turns a few
5
5
  # RESTful actions into a working console:
6
6
  #
7
7
  # namespace :madmin do
8
- # resources :support_tickets, only: %i[index show], concerns: :support_console
8
+ # resources :support_tickets, only: %i[index show new], concerns: :support_console
9
9
  # end
10
10
  #
11
- # It draws the collection route first and the member routes after, which is
12
- # what keeps `/madmin/support_tickets/next` from being swallowed by
11
+ # `new` stays yours — add it to `only:` when you render the form the
12
+ # console's `open_conversation` posts to ("Write to someone"). The concern
13
+ # draws the collection routes (`next`, `open_conversation`) and every
14
+ # member verb.
15
+ #
16
+ # It draws the collection routes first and the member routes after, which
17
+ # is what keeps `/madmin/support_tickets/next` from being swallowed by
13
18
  # `/madmin/support_tickets/:id` (a `resources` block draws its concerns
14
19
  # before its own member mappings, so "next" wins).
15
20
  #
@@ -26,8 +31,11 @@ module SupportDesk
26
31
  # The name a host writes in their routes file.
27
32
  CONCERN = :support_console
28
33
 
29
- # The member verbs, in the order an agent uses them.
30
- MEMBER_VERBS = %i[reply take assign hand_off release close reopen note change_topic].freeze
34
+ # The verbs, from the ONE table that has them: the concern that answers
35
+ # them owns it, so a verb can never be routed without an action or
36
+ # answered without a route. Kept here as an alias for a release, because
37
+ # a host may have read it.
38
+ MEMBER_VERBS = SupportDesk::Console::MEMBER_VERBS
31
39
 
32
40
  # Rails' own message for this ("can't use collection outside resource(s)
33
41
  # scope") is true and says nothing about which concern caused it, which
@@ -82,12 +90,16 @@ module SupportDesk
82
90
  # to every route, which is how `concerns :support_console, path: "t"`
83
91
  # keeps working.
84
92
  def call(mapper, options = {})
93
+ # Read at DRAW time, from SupportDesk::Console — which is loaded by
94
+ # the spine long before any routes file runs, so there is no
95
+ # load-order risk in taking the verbs from the concern that answers
96
+ # them rather than keeping a second list here.
85
97
  mapper.collection do
86
- mapper.get :next, **options
98
+ SupportDesk::Console::COLLECTION_VERBS.each { |verb, method| mapper.public_send(method, verb, **options) }
87
99
  end
88
100
 
89
101
  mapper.member do
90
- MEMBER_VERBS.each { |verb| mapper.post verb, **options }
102
+ SupportDesk::Console::MEMBER_VERBS.each { |verb| mapper.post verb, **options }
91
103
  end
92
104
  rescue ArgumentError => e
93
105
  raise unless e.message.include?("outside resource")
@@ -59,9 +59,40 @@ module SupportDesk
59
59
  Chats.avatar_for(requester)
60
60
  end
61
61
 
62
+ # Key/value pairs about the PERSON, from `Requester#support_context` —
63
+ # the same idea as #pairs, one row up: `pairs` describes the thing the
64
+ # case is about, these describe whoever is asking. Empty unless the host
65
+ # filled it in.
66
+ def requester_pairs
67
+ requester.try(:support_context) || {}
68
+ end
69
+
62
70
  # When this requester joined — context for "is this a new user?".
63
71
  def requester_since = requester.try(:created_at)
64
72
 
73
+ # Who opened the case, as the console should print it: their name when
74
+ # the record still resolves, the desk's name in front of it when WE
75
+ # wrote first, and an honest label when there is no record to name.
76
+ #
77
+ # The three unnameable cases are not the same thing and must not read the
78
+ # same way: no provenance at all, an actor whose record is gone, and a
79
+ # requester-opened case, which is just their name.
80
+ #
81
+ # "Not recorded" rather than "automation": 0.2 opens every case as a
82
+ # record (automation openers are refused — docs/12-open-questions.md
83
+ # Q17), so a NULL pair is a row 0.1 wrote and nobody has backfilled yet,
84
+ # which is exactly what `SupportDesk.doctor` asks somebody to do.
85
+ def opened_by_label
86
+ opener = ticket.opened_by
87
+ return I18n.t("support_desk.console.context.not_recorded") if ticket.opened_by_id.blank?
88
+ return I18n.t("support_desk.console.context.unavailable") if opener.nil?
89
+
90
+ name = opener.try(:support_agent_name) || Chats.display_name_for(opener)
91
+ return name if ticket.opened_by_requester?
92
+
93
+ "#{ticket.desk.name} · #{name}"
94
+ end
95
+
65
96
  # How many open cases this requester has right now, this one included.
66
97
  def requester_open_tickets
67
98
  Ticket.not_closed.where(requester: requester).count
@@ -79,7 +110,8 @@ module SupportDesk
79
110
  requester: {
80
111
  name: requester_name,
81
112
  since: requester_since,
82
- open_tickets: requester_open_tickets
113
+ open_tickets: requester_open_tickets,
114
+ context: requester_pairs
83
115
  }
84
116
  }
85
117
  end
@@ -103,6 +103,13 @@ module SupportDesk
103
103
  ok_with("#{tree.count} topic(s), #{tree.leaves.size} leaf/leaves")
104
104
  end
105
105
 
106
+ checks << check("opening lines (#{desk.key})") do
107
+ problems = desk.opening_line_problems
108
+ next fail_with(problems.join("; ")) if problems.any?
109
+
110
+ ok_with("resolve")
111
+ end
112
+
106
113
  checks << check("supportables (#{desk.key})") do
107
114
  missing = desk.topics.about_class_names.reject do |name|
108
115
  klass = name.safe_constantize
@@ -114,6 +121,81 @@ module SupportDesk
114
121
  end
115
122
  end
116
123
 
124
+ checks << check("find_requester") do
125
+ callable = SupportDesk.config.find_requester
126
+ next ok_with("not set — the console takes a GlobalID from your own pages") if callable.nil?
127
+ next fail_with("find_requester must respond to #call") unless callable.respond_to?(:call)
128
+
129
+ # Asked of the SHAPE, never by calling it: a diagnostic that runs a
130
+ # host's lookup is a diagnostic that queries production. A callable
131
+ # object is as valid as a lambda, so `arity` (Proc-only) is out.
132
+ parameters = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
133
+ required = parameters.count { |type, _| type == :req }
134
+ open_ended = parameters.any? { |type, _| %i[opt rest].include?(type) }
135
+ unless required <= 1 && (required == 1 || open_ended)
136
+ next fail_with("find_requester takes #{required} required argument(s); the console calls it with one")
137
+ end
138
+
139
+ ok_with("the console can look people up")
140
+ end
141
+
142
+ if SupportDesk.config.assistants.any?
143
+ checks << check("assistants (config)") do
144
+ problems = []
145
+ warnings = []
146
+ SupportDesk.config.assistants.each_value do |assistant|
147
+ problems.concat(assistant.line_problems)
148
+ warnings << "#{assistant.key} has no max_turns — a loop with no bound is a loop" if
149
+ assistant.max_turns.nil?
150
+ warnings << "#{assistant.key} has no responds_within — nothing will notice if she goes quiet" if
151
+ assistant.responds_within.nil?
152
+ end
153
+ SupportDesk.config.desks.each_value do |desk|
154
+ key = desk.assistant_key
155
+ next if key.nil? || SupportDesk.config.assistant?(key)
156
+
157
+ problems << "desk #{desk.key} points at assistant #{key.inspect}, which isn't configured"
158
+ end
159
+ next fail_with(problems.join("; ")) if problems.any?
160
+ next warn_with(warnings.join("; ")) if warnings.any?
161
+
162
+ ok_with("#{SupportDesk.config.assistants.size} assistant(s) configured")
163
+ end
164
+
165
+ checks << check("assistant turn subscriber") do
166
+ next ok_with("no assistant on any desk") if assistant_desks.empty?
167
+ next warn_with("nothing subscribes to :assistant_turn — no harness will ever answer. See the " \
168
+ "README's assistants section") if SupportDesk.subscribers[:assistant_turn].empty?
169
+
170
+ ok_with("#{SupportDesk.subscribers[:assistant_turn].size} subscriber(s)")
171
+ end
172
+
173
+ checks << check("assistant authorship") do
174
+ blank = assistant_desks.filter_map do |desk|
175
+ assistant = desk.assistant
176
+ assistant.key if Chats.display_name_for(assistant).blank?
177
+ end
178
+ next fail_with("chats has no display name for #{blank.join(", ")} — a signed message would go out " \
179
+ "unsigned; check `Chats.config.messager_display_name`") if blank.any?
180
+
181
+ ok_with("every assistant has a name chats can print")
182
+ end
183
+
184
+ checks << check("ai agents without policy") do
185
+ strays = SupportDesk.agent_class_names.select do |name|
186
+ klass = name.safe_constantize
187
+ next false if klass.nil? || klass == SupportDesk::Assistant
188
+ next false unless klass.respond_to?(:support_desk_agent_options)
189
+
190
+ klass.support_desk_agent_options[:kind] == :ai
191
+ end
192
+ next warn_with("#{strays.join(", ")} declares `kind: :ai` but isn't this gem's assistant — every " \
193
+ "support write by it, or to it, is refused") if strays.any?
194
+
195
+ ok_with("no unmanaged AI agents")
196
+ end
197
+ end
198
+
117
199
  checks << check("engine mount") do
118
200
  path = SupportDesk.root_path
119
201
  next warn_with("SupportDesk::Engine isn't mounted — requesters have nowhere to write") if path.nil?
@@ -162,7 +244,7 @@ module SupportDesk
162
244
  return [ check("database") { warn_with("support_desk tables are missing — run rails db:migrate") } ] unless
163
245
  tables?
164
246
 
165
- [
247
+ checks = [
166
248
  check("conversations") do
167
249
  orphans = Ticket.where(conversation_id: nil).count
168
250
  next fail_with("#{orphans} ticket(s) without a conversation") if orphans.positive?
@@ -185,8 +267,33 @@ module SupportDesk
185
267
 
186
268
  ok_with("assignee matches the open assignment")
187
269
  end,
270
+ check("provenance") do
271
+ next warn_with("no opened_by column — run `rails g support_desk:upgrade` and migrate") unless
272
+ Ticket.column_names.include?("opened_by_id")
273
+
274
+ half = Ticket.where(opened_by_type: nil).where.not(opened_by_id: nil)
275
+ .or(Ticket.where.not(opened_by_type: nil).where(opened_by_id: nil)).count
276
+ next fail_with("#{half} ticket(s) with half an opened_by (a type and no id, or the reverse)") if
277
+ half.positive?
278
+
279
+ # NULL is not automation: nothing in this version writes it, so
280
+ # every one of these is a row 0.1 opened, or one an old process
281
+ # wrote during the upgrade window.
282
+ legacy = Ticket.where(opened_by_id: nil).count
283
+ next warn_with("#{legacy} case(s) don't say who opened them — run " \
284
+ "`rake support_desk:backfill_opened_by`") if legacy.positive?
285
+
286
+ ok_with("every case says who opened it")
287
+ end,
188
288
  check("awaiting") do
189
- stale = Ticket.awaiting_reply.where("last_agent_message_at > last_requester_message_at").count
289
+ # The NULL leg is not decoration: `last_agent_message_at >
290
+ # last_requester_message_at` is NULL when the requester has never
291
+ # written, so a case waiting on the desk that only the desk has
292
+ # spoken in slips past a plain comparison.
293
+ stale = Ticket.awaiting_reply.where(
294
+ "last_agent_message_at > last_requester_message_at OR " \
295
+ "(last_agent_message_at IS NOT NULL AND last_requester_message_at IS NULL)"
296
+ ).count
190
297
  next fail_with("#{stale} ticket(s) waiting on the desk after the desk already answered") if stale.positive?
191
298
 
192
299
  ok_with("awaiting agrees with the transcript")
@@ -198,6 +305,92 @@ module SupportDesk
198
305
  ok_with("references are unique")
199
306
  end
200
307
  ]
308
+
309
+ checks.concat(assistant_invariant_checks)
310
+ checks
311
+ end
312
+
313
+ # The assistants' own invariants: nobody sitting on a case they may not
314
+ # work, nobody silently not answering, one pending proposal per case.
315
+ #
316
+ # Every one of them is asked of EVIDENCE — a seat, a timestamp, a row —
317
+ # and never of the policy's own verdict. A policy cannot page anybody
318
+ # about its own bug.
319
+ def assistant_invariant_checks
320
+ # Nothing about a feature nobody turned on: a host with no assistant
321
+ # configured runs not one extra query (I1).
322
+ return [] if SupportDesk.config.assistants.empty?
323
+ return [] unless assistants_migrated?
324
+
325
+ checks = []
326
+
327
+ SupportDesk.config.assistants.each_key do |key|
328
+ assistant = SupportDesk.assistant(key)
329
+ window = assistant&.responds_within
330
+ next if window.nil?
331
+
332
+ checks << check("assistant silence (#{key})") do
333
+ quiet = Ticket.open.assigned_to(assistant).awaiting_reply.waiting_over(window).count
334
+ next fail_with("#{quiet} case(s) have waited longer than #{window.inspect} for #{key} — is the " \
335
+ "harness running? `rake support_desk:release_silent_assistants` hands them over") if
336
+ quiet.positive?
337
+
338
+ ok_with("nobody is waiting on #{key}")
339
+ end
340
+ end
341
+
342
+ checks << check("assistant seats") do
343
+ seated = Ticket.open.held_by_assistants.to_a
344
+ wrong = seated.reject { |ticket| ticket.assistant_policy.may_hold? }
345
+ next fail_with("#{wrong.size} case(s) held by an assistant who may not hold them " \
346
+ "(#{wrong.first(3).map(&:reference).join(", ")}) — release them") if wrong.any?
347
+
348
+ ok_with("#{seated.size} seat(s), all of them allowed")
349
+ end
350
+
351
+ assistant_desks.each do |desk|
352
+ window = desk.assistant.responds_within
353
+ next if window.nil?
354
+
355
+ checks << check("assistant idle turns (#{desk.key})") do
356
+ idle = Ticket.open.for_desk(desk.key).assistant_idle_since((window * 3).ago).count
357
+ next warn_with("#{idle} case(s) have had no answer and no assistant action — the harness may be " \
358
+ "down; `rake support_desk:redispatch_assistant_turns` re-emits their turns") if
359
+ idle.positive?
360
+
361
+ ok_with("every turn has been picked up")
362
+ end
363
+ end
364
+
365
+ checks << check("drafts") do
366
+ duplicated = Draft.pending.group(:ticket_id).having("COUNT(*) > 1").count.size
367
+ next fail_with("#{duplicated} case(s) with more than one pending proposal") if duplicated.positive?
368
+
369
+ orphaned = Draft.pending.where(ticket_id: Ticket.closed.select(:id)).count
370
+ next warn_with("#{orphaned} pending proposal(s) on closed cases — a close expires them, so these " \
371
+ "predate 0.3 or were written by hand") if orphaned.positive?
372
+
373
+ ok_with("at most one pending proposal per case")
374
+ end
375
+
376
+ checks
377
+ end
378
+
379
+ # The desks that actually have an assistant, as Desk records.
380
+ def assistant_desks
381
+ SupportDesk.config.desks.each_key.filter_map do |key|
382
+ desk = SupportDesk.desk(key)
383
+ desk if desk&.assistant?
384
+ end
385
+ rescue StandardError
386
+ []
387
+ end
388
+
389
+ def assistants_migrated?
390
+ Ticket.column_names.include?("human_required_at") &&
391
+ ActiveRecord::Base.connection.table_exists?(Draft.table_name)
392
+ rescue StandardError
393
+ false
201
394
  end
202
395
 
203
396
  def tables?
@@ -135,6 +135,16 @@ module SupportDesk
135
135
  # the worst shape for a bug. Found by the CarHey integration (#2).
136
136
  _desk = SupportDesk::Desk
137
137
 
138
+ # And the assistant, for the THIRD registry with the same shape:
139
+ # `acts_as_support_agent` registers a class when that class loads, and
140
+ # both `SupportDesk.agent_class?` and the doctor's "ai agents without
141
+ # policy" check read that registry. Under lazy autoloading nothing has
142
+ # referenced her before the first case is answered, so the doctor would
143
+ # report a healthy desk as having no assistant at all. A constant
144
+ # reference and nothing else: no row is created and no connection is
145
+ # needed to load the class.
146
+ _assistant = SupportDesk::Assistant
147
+
138
148
  SupportDesk.config.validate_classes! if SupportDesk.configured?
139
149
  end
140
150
  end
@@ -17,6 +17,11 @@ module SupportDesk
17
17
  # agent (no `acts_as_support_agent`, or its `if:` said no).
18
18
  class NotAnAgent < Error; end
19
19
 
20
+ # Raised when the record handed to a requester-side operation isn't an
21
+ # eligible requester (no `has_support_tickets`, or its `if:` said no) — a
22
+ # closed account, for instance, can neither ask nor be written to.
23
+ class NotARequester < Error; end
24
+
20
25
  # Raised by `hand_off!` when the actor doesn't currently hold the ticket.
21
26
  class NotTheAssignee < Error; end
22
27
 
@@ -46,4 +51,41 @@ module SupportDesk
46
51
 
47
52
  # Raised when a requester is already at `config.max_open_tickets`.
48
53
  class TooManyOpenTickets < Error; end
54
+
55
+ # Raised when an AI-kind actor is not the desk's own SupportDesk::Assistant.
56
+ # A host model declared `acts_as_support_agent kind: :ai` never inherits
57
+ # human authority: every support write by it, or to it, is refused rather
58
+ # than quietly treated as a human's (see the README's assistants section).
59
+ class NotAnAssistant < NotAnAgent; end
60
+
61
+ # Raised when the assistant's policy forbids the verb on this case. It
62
+ # carries the policy that refused and the verb that was asked for, so a
63
+ # host can log the RULE rather than parse the sentence.
64
+ class AssistantNotAllowed < NotAllowed
65
+ attr_reader :policy, :verb
66
+
67
+ def initialize(policy, verb:, message: nil)
68
+ @policy = policy
69
+ @verb = verb.to_sym
70
+ super(message || default_message)
71
+ end
72
+
73
+ private
74
+
75
+ # "rose may not reply on T-AB12CD: topic payments caps rose at draft" —
76
+ # who, what, where, and the rule that decided it.
77
+ def default_message
78
+ return "an assistant may not #{@verb} here" if @policy.nil?
79
+
80
+ "#{@policy.assistant&.key || "the assistant"} may not #{@verb} on " \
81
+ "#{@policy.ticket&.reference || "this case"}: #{@policy.because}"
82
+ end
83
+ end
84
+
85
+ # Raised when an assistant acts on a turn that is no longer the case's:
86
+ # somebody (the requester, a human, the assistant itself) changed the case
87
+ # since it was read. A subclass of InvalidTransition, so a host that
88
+ # rescues that still catches it — and a late, retried or redelivered job
89
+ # writes nothing.
90
+ class StaleTurn < InvalidTransition; end
49
91
  end
@@ -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
@@ -20,11 +20,15 @@ module SupportDesk
20
20
  module Macros
21
21
  # The requester side. `desk:` names which desk this model writes to;
22
22
  # `as:` records a role on every ticket ("driver", "passenger") so the
23
- # console can tell two kinds of requester apart.
24
- def has_support_tickets(desk: :default, as: nil)
23
+ # console can tell two kinds of requester apart; `if:` is the per-record
24
+ # check for "may this person ask for help — and be written to — right
25
+ # now" (`if: :kept?` for a host with soft-deleted accounts).
26
+ def has_support_tickets(desk: :default, as: nil, **options)
27
+ condition = Macros.condition!(options, macro: "has_support_tickets", known: ":if, :desk, :as")
28
+
25
29
  include SupportDesk::Requester
26
30
 
27
- self.support_desk_requester_options = { desk: desk.to_sym, as: as&.to_s }.freeze
31
+ self.support_desk_requester_options = { desk: desk.to_sym, as: as&.to_s, if: condition }.freeze
28
32
  SupportDesk.register_requester(self)
29
33
  end
30
34
 
@@ -48,26 +52,51 @@ module SupportDesk
48
52
 
49
53
  # The agent side: who may answer. `if:` is the per-record eligibility
50
54
  # check (a Symbol method name or a callable); `kind:` is :human or :ai.
51
- # No verbs are added — the ticket is the subject of every sentence
52
- # (`ticket.assign!(to: lucia)`), never the agent.
55
+ # One verb is added — `open_support_conversation_with!`, the only agent
56
+ # action that has no ticket yet. Everything else keeps the ticket as the
57
+ # subject of the sentence (`ticket.assign!(to: lucia)`).
53
58
  def acts_as_support_agent(kind: :human, **options)
59
+ condition = Macros.condition!(options, macro: "acts_as_support_agent", known: ":if, :kind")
60
+ kind = Macros.kind!(kind)
61
+
62
+ include SupportDesk::Agent
63
+
64
+ self.support_desk_agent_options = { if: condition, kind: kind }.freeze
65
+ SupportDesk.register_agent(self)
66
+ end
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
+
80
+ # The `if:` both macros take: a method name or a callable, and the only
81
+ # option either of them accepts beyond its own keywords. Written once
82
+ # because "may this person answer" and "may this person ask" are the same
83
+ # question asked of a different side, and two copies of it would be two
84
+ # places to fix the day one grows a third shape.
85
+ def self.condition!(options, macro:, known:)
54
86
  unknown = options.keys - [ :if ]
55
87
  if unknown.any?
56
88
  raise ConfigurationError,
57
- "unknown acts_as_support_agent option#{"s" if unknown.size > 1} " \
58
- "#{unknown.map(&:inspect).join(", ")} — known options are :if, :kind"
89
+ "unknown #{macro} option#{"s" if unknown.size > 1} " \
90
+ "#{unknown.map(&:inspect).join(", ")} — known options are #{known}"
59
91
  end
60
92
 
61
93
  condition = options[:if]
62
94
  unless condition.nil? || condition.is_a?(Symbol) || condition.respond_to?(:call)
63
95
  raise ConfigurationError,
64
- "acts_as_support_agent if: must be a method name or a callable, got #{condition.inspect}"
96
+ "#{macro} if: must be a method name or a callable, got #{condition.inspect}"
65
97
  end
66
98
 
67
- include SupportDesk::Agent
68
-
69
- self.support_desk_agent_options = { if: condition, kind: kind.to_sym }.freeze
70
- SupportDesk.register_agent(self)
99
+ condition
71
100
  end
72
101
  end
73
102
  end
@@ -11,7 +11,9 @@ module SupportDesk
11
11
  class Assignment < ApplicationRecord
12
12
  self.table_name = "support_desk_assignments"
13
13
 
14
- REASONS = %w[taken assigned handed_off routed drop_in_takeover escalated reopened].freeze
14
+ # `opened`: the agent who wrote first holds the case. It is a seat nobody
15
+ # was told about — see Ticket.open!(by:) — not an assignment somebody made.
16
+ REASONS = %w[taken assigned handed_off routed drop_in_takeover escalated reopened opened].freeze
15
17
  RELEASE_REASONS = %w[handed_off released shift_end closed escalated].freeze
16
18
 
17
19
  belongs_to :ticket, class_name: "SupportDesk::Ticket", inverse_of: :assignments