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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +998 -14
- data/app/assets/stylesheets/support_desk.css +10 -0
- data/app/controllers/support_desk/tickets_controller.rb +23 -1
- data/app/helpers/support_desk/engine_helper.rb +16 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
- data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
- data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
- data/app/views/support_desk/console/tickets/show.html.erb +5 -0
- data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
- data/config/locales/support_desk.console.en.yml +63 -0
- data/config/locales/support_desk.console.es.yml +65 -0
- data/config/locales/support_desk.en.yml +15 -0
- data/config/locales/support_desk.es.yml +23 -0
- data/config/routes.rb +7 -1
- data/lib/generators/support_desk/assistant_generator.rb +193 -0
- data/lib/generators/support_desk/install_generator.rb +12 -0
- data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
- data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
- data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
- data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
- data/lib/generators/support_desk/templates/initializer.rb +33 -0
- data/lib/generators/support_desk/upgrade_generator.rb +12 -2
- data/lib/support_desk/assistant_policy.rb +213 -0
- data/lib/support_desk/brief.rb +283 -0
- data/lib/support_desk/configuration.rb +552 -2
- data/lib/support_desk/console.rb +290 -8
- data/lib/support_desk/context_card.rb +10 -1
- data/lib/support_desk/doctor.rb +144 -1
- data/lib/support_desk/engine.rb +10 -0
- data/lib/support_desk/errors.rb +37 -0
- data/lib/support_desk/events.rb +12 -5
- data/lib/support_desk/macros.rb +14 -1
- data/lib/support_desk/models/assistant.rb +153 -0
- data/lib/support_desk/models/concerns/requester.rb +18 -0
- data/lib/support_desk/models/desk.rb +26 -3
- data/lib/support_desk/models/draft.rb +266 -0
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/ticket/assistance.rb +675 -0
- data/lib/support_desk/models/ticket.rb +334 -37
- data/lib/support_desk/outcome.rb +38 -0
- data/lib/support_desk/queue.rb +41 -5
- data/lib/support_desk/test_helpers.rb +152 -0
- data/lib/support_desk/timeline.rb +40 -11
- data/lib/support_desk/topic.rb +22 -0
- data/lib/support_desk/topic_tree.rb +7 -1
- data/lib/support_desk/transcript.rb +237 -0
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +108 -0
- data/lib/tasks/support_desk.rake +46 -0
- metadata +30 -8
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# What `Ticket#respond!` did — the one verb whose answer depends on policy,
|
|
5
|
+
# so the harness can hand over an answer without first working out what it
|
|
6
|
+
# is allowed to become.
|
|
7
|
+
#
|
|
8
|
+
# outcome = ticket.respond!(text, by: rose, turn: turn)
|
|
9
|
+
# outcome.sent? # it went to the requester
|
|
10
|
+
# outcome.drafted? # a human will send it
|
|
11
|
+
# outcome.withheld? # nothing was written, and #reason says why
|
|
12
|
+
# outcome.turn # the successor turn, for a second action in the same run
|
|
13
|
+
Outcome = Struct.new(:action, :message, :draft, :policy, :reason, :turn, keyword_init: true) do
|
|
14
|
+
def sent? = action == :sent
|
|
15
|
+
def drafted? = action == :drafted
|
|
16
|
+
def withheld? = action == :withheld
|
|
17
|
+
|
|
18
|
+
# Whether the case was ALSO handed to a person: the budget ran out
|
|
19
|
+
# mid-conversation, so the draft is waiting and so is a human.
|
|
20
|
+
def escalated? = reason == :max_turns
|
|
21
|
+
|
|
22
|
+
# Ids, not records: this is what a log line or a job argument wants.
|
|
23
|
+
def to_h
|
|
24
|
+
{
|
|
25
|
+
action: action,
|
|
26
|
+
message: message&.id,
|
|
27
|
+
draft: draft&.id,
|
|
28
|
+
reason: reason,
|
|
29
|
+
turn: turn,
|
|
30
|
+
policy: policy&.to_h
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def inspect
|
|
35
|
+
"#<SupportDesk::Outcome #{action}#{" #{reason}" if reason} turn=#{turn}>"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/support_desk/queue.rb
CHANGED
|
@@ -14,7 +14,7 @@ module SupportDesk
|
|
|
14
14
|
class Queue
|
|
15
15
|
# Every tab a queue can answer for, in display order. `scope` and
|
|
16
16
|
# `counts` both cover all of them.
|
|
17
|
-
TABS = %i[awaiting mine unassigned open snoozed closed].freeze
|
|
17
|
+
TABS = %i[awaiting needs_human mine unassigned open snoozed closed].freeze
|
|
18
18
|
|
|
19
19
|
# Tabs whose FEATURE hasn't shipped yet. Snoozing lands in 0.2: until a
|
|
20
20
|
# ticket can actually be snoozed, the tab is a permanent column of
|
|
@@ -24,7 +24,8 @@ module SupportDesk
|
|
|
24
24
|
# by name — but nothing offers the tab.
|
|
25
25
|
UNRELEASED_TABS = %i[snoozed].freeze
|
|
26
26
|
|
|
27
|
-
# The tabs a console should render, in display order.
|
|
27
|
+
# The tabs a console should render, in display order. `needs_human` is
|
|
28
|
+
# in the list but not always shown — see #visible_tabs.
|
|
28
29
|
VISIBLE_TABS = (TABS - UNRELEASED_TABS).freeze
|
|
29
30
|
|
|
30
31
|
# How long a nav badge may lie. Long enough that a busy console isn't
|
|
@@ -72,6 +73,14 @@ module SupportDesk
|
|
|
72
73
|
scoped.open.awaiting_reply.most_urgent_first
|
|
73
74
|
end
|
|
74
75
|
|
|
76
|
+
# Open, and somebody has asked for a person on it: the assistant handed
|
|
77
|
+
# it over, the customer pressed the door, or the silent sweep did. First
|
|
78
|
+
# in the list on purpose — a case a machine could not finish is the one
|
|
79
|
+
# that must never be the one nobody looks at.
|
|
80
|
+
def needs_human
|
|
81
|
+
scoped.open.needs_human.most_urgent_first
|
|
82
|
+
end
|
|
83
|
+
|
|
75
84
|
# Put aside until a date, the soonest to wake first (0.2).
|
|
76
85
|
def snoozed = scoped.snoozed.order(:snoozed_until)
|
|
77
86
|
|
|
@@ -87,10 +96,10 @@ module SupportDesk
|
|
|
87
96
|
# (status, awaiting, assignee) and adding up in Ruby costs one round
|
|
88
97
|
# trip; six `.count` calls cost six.
|
|
89
98
|
def counts
|
|
90
|
-
rows = scoped.group(:status, :awaiting, :assignee_type, :assignee_id).count
|
|
99
|
+
rows = scoped.group(:status, :awaiting, :assignee_type, :assignee_id, needs_human_grouping).count
|
|
91
100
|
|
|
92
101
|
counts = TABS.index_with(0)
|
|
93
|
-
rows.each do |(status, awaiting, assignee_type, assignee_id), count|
|
|
102
|
+
rows.each do |(status, awaiting, assignee_type, assignee_id, needs_human), count|
|
|
94
103
|
assigned_to_me = agent_key == [ assignee_type, assignee_id.to_s ]
|
|
95
104
|
|
|
96
105
|
case status
|
|
@@ -99,6 +108,7 @@ module SupportDesk
|
|
|
99
108
|
counts[:mine] += count if assigned_to_me
|
|
100
109
|
counts[:unassigned] += count if assignee_id.nil?
|
|
101
110
|
counts[:awaiting] += count if awaiting == "agent"
|
|
111
|
+
counts[:needs_human] += count if needs_human.to_i.positive?
|
|
102
112
|
when "snoozed" then counts[:snoozed] += count
|
|
103
113
|
when "closed" then counts[:closed] += count
|
|
104
114
|
end
|
|
@@ -106,6 +116,18 @@ module SupportDesk
|
|
|
106
116
|
counts
|
|
107
117
|
end
|
|
108
118
|
|
|
119
|
+
# The tabs THIS desk should render. `needs_human` only means something
|
|
120
|
+
# where a machine answers, so a desk without an assistant and without a
|
|
121
|
+
# single flagged case is not given a column of zeros to learn to ignore.
|
|
122
|
+
#
|
|
123
|
+
# Takes the numbers when the caller already has them (`tabs` does), so
|
|
124
|
+
# rendering a tab bar is one query and not two.
|
|
125
|
+
def visible_tabs(numbers = counts)
|
|
126
|
+
return VISIBLE_TABS if desk.assistant? || numbers[:needs_human].to_i.positive?
|
|
127
|
+
|
|
128
|
+
VISIBLE_TABS - [ :needs_human ]
|
|
129
|
+
end
|
|
130
|
+
|
|
109
131
|
# The nav badge: open tickets this agent should feel responsible for —
|
|
110
132
|
# theirs, plus everything nobody has picked up. Cached briefly per agent.
|
|
111
133
|
def badge
|
|
@@ -124,7 +146,7 @@ module SupportDesk
|
|
|
124
146
|
# ready to render.
|
|
125
147
|
def tabs
|
|
126
148
|
numbers = counts
|
|
127
|
-
|
|
149
|
+
visible_tabs(numbers).map { |tab| [ tab, I18n.t("support_desk.queue.tabs.#{tab}"), numbers[tab] ] }
|
|
128
150
|
end
|
|
129
151
|
|
|
130
152
|
# The relation behind a tab name, so a console can route `params[:tab]`
|
|
@@ -149,6 +171,20 @@ module SupportDesk
|
|
|
149
171
|
Ticket.where(desk: desk)
|
|
150
172
|
end
|
|
151
173
|
|
|
174
|
+
# "Has somebody asked for a person on this case?", as something every
|
|
175
|
+
# adapter can GROUP BY. A boolean column would group differently on
|
|
176
|
+
# three databases; a CASE expression counts the same everywhere.
|
|
177
|
+
#
|
|
178
|
+
# Built when it is CALLED, and from `table_name` rather than
|
|
179
|
+
# `quoted_table_name`: an identifier assembled at class-definition time
|
|
180
|
+
# is one more thing that has to work during `assets:precompile` in a
|
|
181
|
+
# container with no database, and the rest of this gem keeps every
|
|
182
|
+
# identifier inside a lambda or a method for exactly that reason. The
|
|
183
|
+
# name is the gem's own constant, so there is nothing to quote.
|
|
184
|
+
def needs_human_grouping
|
|
185
|
+
Arel.sql("CASE WHEN #{Ticket.table_name}.human_required_at IS NULL THEN 0 ELSE 1 END")
|
|
186
|
+
end
|
|
187
|
+
|
|
152
188
|
def mine_or_unassigned
|
|
153
189
|
scoped.open.where(
|
|
154
190
|
Ticket.arel_table[:assignee_id].eq(nil).or(
|
|
@@ -101,6 +101,158 @@ module SupportDesk
|
|
|
101
101
|
"expected no #{kind} event on #{ticket.reference}"
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
+
# --- Assistants -------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
# The assistant record for +key+ (the desk's default when omitted).
|
|
107
|
+
def support_assistant(key = nil)
|
|
108
|
+
SupportDesk.assistant(key)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Answer as the assistant and let policy decide what that becomes.
|
|
112
|
+
# Returns the SupportDesk::Outcome.
|
|
113
|
+
def respond_as(assistant, ticket, body = nil, turn: ticket.assistant_turn, **options)
|
|
114
|
+
ticket.respond!(body, by: assistant, turn: turn, **options)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Propose a reply as the assistant. Returns the SupportDesk::Draft.
|
|
118
|
+
def draft_as(assistant, ticket, body = nil, turn: ticket.assistant_turn, **options)
|
|
119
|
+
ticket.draft!(body, by: assistant, turn: turn, **options)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# --- Assistant assertions ---------------------------------------------------
|
|
123
|
+
|
|
124
|
+
# There is a proposal waiting, optionally matching its text (a String is
|
|
125
|
+
# a substring, a Regexp is a match). Returns the draft.
|
|
126
|
+
def assert_pending_draft(ticket, body: nil)
|
|
127
|
+
ticket.reload
|
|
128
|
+
draft = ticket.pending_draft
|
|
129
|
+
|
|
130
|
+
refute_nil draft, "expected a pending proposal on #{ticket.reference}, found " \
|
|
131
|
+
"#{ticket.drafts.map(&:status).join(", ").presence || "none"}"
|
|
132
|
+
case body
|
|
133
|
+
when Regexp then assert_match body, draft.body.to_s
|
|
134
|
+
when String then assert_includes draft.body.to_s, body
|
|
135
|
+
end
|
|
136
|
+
draft
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Nothing is waiting to be sent.
|
|
140
|
+
def refute_pending_draft(ticket)
|
|
141
|
+
ticket.reload
|
|
142
|
+
|
|
143
|
+
assert_nil ticket.pending_draft,
|
|
144
|
+
"expected no pending proposal on #{ticket.reference}, found #{ticket.pending_draft&.body.inspect}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Somebody has asked for a person on this case, optionally for this
|
|
148
|
+
# reason ("requester_request", "phrase", "assistant_silent", …).
|
|
149
|
+
def assert_needs_human(ticket, reason: nil)
|
|
150
|
+
ticket.reload
|
|
151
|
+
|
|
152
|
+
assert_predicate ticket, :human_required?,
|
|
153
|
+
"expected #{ticket.reference} to need a person"
|
|
154
|
+
return if reason.nil?
|
|
155
|
+
|
|
156
|
+
assert_equal reason.to_s, ticket.human_required_reason,
|
|
157
|
+
"#{ticket.reference} needs a person for a different reason"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Nobody has.
|
|
161
|
+
def refute_needs_human(ticket)
|
|
162
|
+
ticket.reload
|
|
163
|
+
|
|
164
|
+
refute_predicate ticket, :human_required?,
|
|
165
|
+
"expected #{ticket.reference} not to need a person " \
|
|
166
|
+
"(#{ticket.human_required_reason})"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The assistant is sitting on this case.
|
|
170
|
+
def assert_held_by_assistant(ticket, assistant = nil)
|
|
171
|
+
ticket.reload
|
|
172
|
+
|
|
173
|
+
assert_predicate ticket, :held_by_assistant?,
|
|
174
|
+
"expected #{ticket.reference} to be held by an assistant, was #{ticket.assignee.inspect}"
|
|
175
|
+
return if assistant.nil?
|
|
176
|
+
|
|
177
|
+
assert ticket.assigned_to?(assistant),
|
|
178
|
+
"expected #{ticket.reference} to be held by #{assistant.key}, was #{ticket.assignee.inspect}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# No machine has said anything to the requester in this case.
|
|
182
|
+
def refute_assistant_spoke(ticket)
|
|
183
|
+
ticket.reload
|
|
184
|
+
spoken = ticket.conversation.messages.to_a.select { |message| ticket.assistant_message?(message) }
|
|
185
|
+
|
|
186
|
+
assert_empty spoken.map(&:body),
|
|
187
|
+
"expected the assistant to have said nothing on #{ticket.reference}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# What she may do here, and why. `because:` takes a String (substring) or
|
|
191
|
+
# a Regexp, because the sentence is the point: a level with no reason is
|
|
192
|
+
# a refusal nobody can act on.
|
|
193
|
+
def assert_assistant_policy(ticket, level, because: nil)
|
|
194
|
+
policy = ticket.reload.assistant_policy
|
|
195
|
+
|
|
196
|
+
assert_equal level.to_sym, policy.level,
|
|
197
|
+
"expected #{ticket.reference} to be at #{level} for the assistant (#{policy.because})"
|
|
198
|
+
case because
|
|
199
|
+
when Regexp then assert_match because, policy.because
|
|
200
|
+
when String then assert_includes policy.because, because
|
|
201
|
+
end
|
|
202
|
+
policy
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# --- Assistant configuration ------------------------------------------------
|
|
206
|
+
|
|
207
|
+
# Run a block with different assistant settings, then put them back:
|
|
208
|
+
#
|
|
209
|
+
# with_assistant_config(autonomy: :reply) { … }
|
|
210
|
+
# with_assistant_config(:rose, max_turns: 1) { … }
|
|
211
|
+
def with_assistant_config(key = nil, **overrides)
|
|
212
|
+
key ||= SupportDesk.config.default_assistant_key
|
|
213
|
+
configuration = SupportDesk.config.assistant(key)
|
|
214
|
+
previous = overrides.keys.index_with { |name| configuration.read(name) }
|
|
215
|
+
had = overrides.keys.index_with { |name| configuration.own?(name) }
|
|
216
|
+
|
|
217
|
+
overrides.each { |name, value| configuration.public_send(:"#{name}=", value) }
|
|
218
|
+
yield
|
|
219
|
+
ensure
|
|
220
|
+
previous.each do |name, value|
|
|
221
|
+
had[name] ? configuration.public_send(:"#{name}=", value) : configuration.send(:reset_setting, name)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Run a block with one topic capped, then put the tree back.
|
|
226
|
+
#
|
|
227
|
+
# The tree is frozen at boot, so this REBUILDS it with the cap applied —
|
|
228
|
+
# the same shape a host would have declared with `topic :payments,
|
|
229
|
+
# assistant: :draft`, without asking a test to restate the whole tree.
|
|
230
|
+
def with_topic_assistant_cap(path, level, desk: :default)
|
|
231
|
+
configuration = SupportDesk.config.desk(desk)
|
|
232
|
+
original = configuration.topics
|
|
233
|
+
configuration.instance_variable_set(:@topics, rebuild_topics_with_cap(original, path.to_s, level))
|
|
234
|
+
yield
|
|
235
|
+
ensure
|
|
236
|
+
configuration.instance_variable_set(:@topics, original)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
def rebuild_topics_with_cap(tree, path, level) # :nodoc:
|
|
242
|
+
rebuilt = SupportDesk::TopicTree.new
|
|
243
|
+
copy = lambda do |node, parent|
|
|
244
|
+
options = node.options.dup
|
|
245
|
+
options[:assistant] = level if node.path == path
|
|
246
|
+
fresh = SupportDesk::Topic.new(key: node.key, parent: parent, **options)
|
|
247
|
+
rebuilt.add(fresh, parent: parent)
|
|
248
|
+
node.children.each { |child| copy.call(child, fresh) }
|
|
249
|
+
end
|
|
250
|
+
tree.roots.each { |root| copy.call(root, nil) }
|
|
251
|
+
rebuilt.freeze!
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
public
|
|
255
|
+
|
|
104
256
|
# --- Configuration ----------------------------------------------------------
|
|
105
257
|
|
|
106
258
|
# Run a block with different desk settings, then put them back:
|
|
@@ -7,41 +7,60 @@ module SupportDesk
|
|
|
7
7
|
# ticket.timeline.each { |entry| … }
|
|
8
8
|
# ticket.timeline.print # in `rails c`
|
|
9
9
|
#
|
|
10
|
-
# Entries wrap
|
|
11
|
-
# the same three questions — when, who,
|
|
10
|
+
# Entries wrap a Chats::Message, a SupportDesk::Event or a decided
|
|
11
|
+
# SupportDesk::Draft, and answer the same three questions — when, who,
|
|
12
|
+
# what.
|
|
13
|
+
#
|
|
14
|
+
# A draft is here because it is the only part of the story with no message
|
|
15
|
+
# and no event of its own to carry it: the event says a proposal was sent
|
|
16
|
+
# or discarded, and the row is what the proposal actually SAID. A pending
|
|
17
|
+
# one is not history yet, so it stays out — it is on the screen above,
|
|
18
|
+
# waiting for somebody.
|
|
12
19
|
class Timeline
|
|
13
20
|
include Enumerable
|
|
14
21
|
|
|
15
22
|
# One moment in a case.
|
|
16
23
|
class Entry
|
|
17
|
-
attr_reader :at, :message, :event
|
|
24
|
+
attr_reader :at, :message, :event, :draft
|
|
18
25
|
|
|
19
|
-
# One moment: a message, or
|
|
20
|
-
|
|
26
|
+
# One moment: a message, an event or a decided proposal, and when it
|
|
27
|
+
# happened.
|
|
28
|
+
def initialize(at:, message: nil, event: nil, draft: nil)
|
|
21
29
|
@at = at
|
|
22
30
|
@message = message
|
|
23
31
|
@event = event
|
|
32
|
+
@draft = draft
|
|
24
33
|
end
|
|
25
34
|
|
|
26
|
-
# Which of the
|
|
35
|
+
# Which of the three this moment is.
|
|
27
36
|
def message? = !message.nil?
|
|
28
37
|
def event? = !event.nil?
|
|
38
|
+
def draft? = !draft.nil?
|
|
29
39
|
|
|
30
|
-
# :message, or the event's kind (:assigned, :closed, :note…).
|
|
40
|
+
# :message, :draft, or the event's kind (:assigned, :closed, :note…).
|
|
31
41
|
def kind
|
|
32
|
-
|
|
42
|
+
return :message if message?
|
|
43
|
+
return :draft if draft?
|
|
44
|
+
|
|
45
|
+
event.kind.to_sym
|
|
33
46
|
end
|
|
34
47
|
|
|
35
48
|
# Who is responsible for this moment: a message's author (the agent
|
|
36
|
-
# who signed it) or sender,
|
|
49
|
+
# who signed it) or sender, an event's actor, or — for a proposal —
|
|
50
|
+
# the person who decided about it, falling back to the machine that
|
|
51
|
+
# wrote it when nobody did.
|
|
37
52
|
def actor
|
|
53
|
+
return draft.reviewed_by || draft.author if draft?
|
|
38
54
|
return event.actor_or_system if event?
|
|
39
55
|
|
|
40
56
|
message.try(:author) || message.sender
|
|
41
57
|
end
|
|
42
58
|
|
|
43
|
-
# What was said,
|
|
59
|
+
# What was said, what a note said, or what a proposal proposed. Nil
|
|
60
|
+
# for the rest.
|
|
44
61
|
def body
|
|
62
|
+
return draft.final_body if draft?
|
|
63
|
+
|
|
45
64
|
message? ? message.try(:visible_body) : event.note
|
|
46
65
|
end
|
|
47
66
|
|
|
@@ -71,7 +90,8 @@ module SupportDesk
|
|
|
71
90
|
|
|
72
91
|
# Every moment, oldest first.
|
|
73
92
|
def entries
|
|
74
|
-
@entries ||= (message_entries + event_entries
|
|
93
|
+
@entries ||= (message_entries + event_entries + draft_entries)
|
|
94
|
+
.sort_by { |entry| [ entry.at || Time.at(0), entry.kind.to_s ] }
|
|
75
95
|
end
|
|
76
96
|
|
|
77
97
|
# How many moments the case has had, and the latest one.
|
|
@@ -100,5 +120,14 @@ module SupportDesk
|
|
|
100
120
|
def event_entries
|
|
101
121
|
ticket.events.chronological.map { |event| Entry.new(at: event.created_at, event: event) }
|
|
102
122
|
end
|
|
123
|
+
|
|
124
|
+
# Proposals somebody decided about — sent, discarded, or overtaken.
|
|
125
|
+
# One query, and on the overwhelming majority of desks it returns
|
|
126
|
+
# nothing at all, because nothing has ever proposed anything.
|
|
127
|
+
def draft_entries
|
|
128
|
+
ticket.drafts.where.not(status: "pending").chronological.includes(:author, :reviewed_by).map do |draft|
|
|
129
|
+
Entry.new(at: draft.created_at, draft: draft)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
103
132
|
end
|
|
104
133
|
end
|
data/lib/support_desk/topic.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_model/type"
|
|
4
|
+
require_relative "assistant_policy"
|
|
4
5
|
|
|
5
6
|
module SupportDesk
|
|
6
7
|
# What a ticket is about, as a value object.
|
|
@@ -190,6 +191,24 @@ module SupportDesk
|
|
|
190
191
|
# An agent scope or proc that wins over desk routing for this subtree.
|
|
191
192
|
def route_to = inherited_or_own(:route_to)
|
|
192
193
|
|
|
194
|
+
# The most the assistant may produce on a case filed here: the MINIMUM
|
|
195
|
+
# over this node's own cap and every ancestor's own cap, or nil when
|
|
196
|
+
# nobody capped anything.
|
|
197
|
+
#
|
|
198
|
+
# Deliberately not an INHERITED_OPTION. Inheritance would let a child
|
|
199
|
+
# widen what its parent narrowed — `payments` capped at :draft with a
|
|
200
|
+
# `payments/refund` declared :reply would be a child handing itself
|
|
201
|
+
# authority its parent refused. A minimum can only tighten.
|
|
202
|
+
def assistant_cap
|
|
203
|
+
caps = [ self, *ancestors ].filter_map(&:own_assistant_cap)
|
|
204
|
+
caps.min_by { |level| AssistantPolicy::RANK.fetch(level) }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# This node's OWN cap, ignoring the tree (what #assistant_cap minimises).
|
|
208
|
+
def own_assistant_cap # :nodoc:
|
|
209
|
+
options[:assistant]&.to_sym
|
|
210
|
+
end
|
|
211
|
+
|
|
193
212
|
# The desk key tickets under this topic belong to.
|
|
194
213
|
def desk_key
|
|
195
214
|
desk_override || :default
|
|
@@ -254,6 +273,9 @@ module SupportDesk
|
|
|
254
273
|
|
|
255
274
|
def unknown? = true
|
|
256
275
|
def retired? = true
|
|
276
|
+
# A path no tree knows is a path nobody has reasoned about, so the
|
|
277
|
+
# assistant may look and nothing else.
|
|
278
|
+
def assistant_cap = :observe
|
|
257
279
|
def label = translate(i18n_key(:label)) || @path.tr("/", " ").humanize
|
|
258
280
|
def about = []
|
|
259
281
|
def subject_mode = :none
|
|
@@ -163,7 +163,7 @@ module SupportDesk
|
|
|
163
163
|
|
|
164
164
|
KNOWN_OPTIONS = %i[
|
|
165
165
|
label about ask candidates subject prefill placeholder only priority route_to desk retired icon
|
|
166
|
-
free_form
|
|
166
|
+
free_form assistant
|
|
167
167
|
].freeze
|
|
168
168
|
|
|
169
169
|
def validate!(key, options)
|
|
@@ -190,6 +190,12 @@ module SupportDesk
|
|
|
190
190
|
"got #{options[:priority].inspect}"
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
+
if options.key?(:assistant) && !AssistantPolicy::LEVELS.include?(options[:assistant]&.to_sym)
|
|
194
|
+
raise ConfigurationError,
|
|
195
|
+
"topic #{key.inspect}: assistant must be one of #{AssistantPolicy::LEVELS.inspect}, " \
|
|
196
|
+
"got #{options[:assistant].inspect}"
|
|
197
|
+
end
|
|
198
|
+
|
|
193
199
|
%i[candidates only prefill].each do |option|
|
|
194
200
|
next unless options.key?(option)
|
|
195
201
|
next if option == :prefill && options[option].is_a?(String)
|