support_desk 0.5.0 → 0.6.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 +79 -0
- data/README.md +151 -6
- data/app/controllers/support_desk/tickets_controller.rb +6 -4
- data/app/helpers/support_desk/engine_helper.rb +17 -7
- data/app/views/chats/slots/_inbox_top.html.erb +1 -1
- data/app/views/support_desk/launcher/_badge.html.erb +11 -3
- data/app/views/support_desk/launcher/_launcher.html.erb +2 -2
- data/lib/generators/support_desk/install_generator.rb +12 -0
- data/lib/generators/support_desk/templates/add_owners_to_support_desk.rb.erb +73 -0
- data/lib/generators/support_desk/templates/add_replay_to_support_desk.rb.erb +73 -0
- data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +6 -1
- data/lib/generators/support_desk/templates/initializer.rb +12 -0
- data/lib/generators/support_desk/upgrade_generator.rb +22 -2
- data/lib/support_desk/brief.rb +1 -1
- data/lib/support_desk/configuration.rb +106 -6
- data/lib/support_desk/context_card.rb +1 -1
- data/lib/support_desk/current.rb +22 -0
- data/lib/support_desk/events.rb +41 -0
- data/lib/support_desk/launcher.rb +14 -4
- data/lib/support_desk/models/assistant.rb +37 -9
- data/lib/support_desk/models/concerns/requester.rb +3 -2
- data/lib/support_desk/models/desk.rb +40 -7
- data/lib/support_desk/models/event.rb +15 -1
- data/lib/support_desk/models/ticket/assistance.rb +36 -7
- data/lib/support_desk/models/ticket.rb +80 -25
- data/lib/support_desk/queue.rb +3 -1
- data/lib/support_desk/replay.rb +109 -0
- data/lib/support_desk/timeline.rb +1 -1
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk.rb +58 -23
- metadata +6 -6
|
@@ -241,6 +241,18 @@ SupportDesk.configure do |config|
|
|
|
241
241
|
# desk.agents { User.where(finance: true) }
|
|
242
242
|
# end
|
|
243
243
|
|
|
244
|
+
# ==========================================================================
|
|
245
|
+
# A DESK PER CUSTOMER
|
|
246
|
+
# ==========================================================================
|
|
247
|
+
#
|
|
248
|
+
# Set the owner per request (`SupportDesk::Current.owner = current_account`
|
|
249
|
+
# in a before_action) and per job, and every owner gets its own desks.
|
|
250
|
+
# Their settings can come from their own rows — a Hash of desk or
|
|
251
|
+
# assistant settings, overlaid on everything above:
|
|
252
|
+
#
|
|
253
|
+
# config.desk_settings = ->(desk) { desk.owner&.support_settings }
|
|
254
|
+
# config.assistant_settings = ->(assistant) { assistant.owner&.assistant_settings }
|
|
255
|
+
|
|
244
256
|
# ==========================================================================
|
|
245
257
|
# EVENTS — the gem emits, your app delivers
|
|
246
258
|
# ==========================================================================
|
|
@@ -16,7 +16,7 @@ module SupportDesk
|
|
|
16
16
|
include ActiveRecord::Generators::Migration
|
|
17
17
|
|
|
18
18
|
source_root File.expand_path("templates", __dir__)
|
|
19
|
-
desc "Add the migrations a support_desk version bump needs (opened_by, assistants, message registration receipts)"
|
|
19
|
+
desc "Add the migrations a support_desk version bump needs (opened_by, assistants, message registration receipts, owners, replay)"
|
|
20
20
|
|
|
21
21
|
# Rails' migration numbering, borrowed from ActiveRecord's generators.
|
|
22
22
|
def self.next_migration_number(dir)
|
|
@@ -38,6 +38,18 @@ module SupportDesk
|
|
|
38
38
|
File.join(db_migrate_path, "create_support_desk_message_registrations.rb")
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# 0.6: owners on desks and assistants, and the replay columns. The same
|
|
42
|
+
# files on a fresh install and an upgrade.
|
|
43
|
+
def create_owners_migration
|
|
44
|
+
migration_template "add_owners_to_support_desk.rb.erb",
|
|
45
|
+
File.join(db_migrate_path, "add_owners_to_support_desk.rb")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_replay_migration
|
|
49
|
+
migration_template "add_replay_to_support_desk.rb.erb",
|
|
50
|
+
File.join(db_migrate_path, "add_replay_to_support_desk.rb")
|
|
51
|
+
end
|
|
52
|
+
|
|
41
53
|
|
|
42
54
|
def display_post_upgrade_message
|
|
43
55
|
say "\n🎫 support_desk upgrade migrations copied.", :green
|
|
@@ -61,7 +73,15 @@ module SupportDesk
|
|
|
61
73
|
say " old web/jobs, migrate, then start only the new version. Review historical"
|
|
62
74
|
say " suspect callbacks before resuming; old clocks cannot prove delivery."
|
|
63
75
|
say " Do not roll code back to a writer that cannot record receipts."
|
|
64
|
-
say " 6.
|
|
76
|
+
say " 6. 0.6.0: desks and assistants gain an optional owner (one desk per"
|
|
77
|
+
say " customer: SupportDesk::Current.owner = current_account). Turn it on in"
|
|
78
|
+
say " THREE STAGES: migrate; deploy 0.6 to every process and retire every 0.5"
|
|
79
|
+
say " one; only then set SupportDesk::Current.owner. A 0.5 process finds desks"
|
|
80
|
+
say " by key alone and, once owned rows exist, can pick another owner's."
|
|
81
|
+
say " On PostgreSQL 13+, events and message registrations gain xact_id and"
|
|
82
|
+
say " position for SupportDesk::Events.replay. That migration rewrites the"
|
|
83
|
+
say " events table: run it in a quiet window."
|
|
84
|
+
say " 7. See the CHANGELOG for the full list.\n", :green
|
|
65
85
|
end
|
|
66
86
|
|
|
67
87
|
private
|
data/lib/support_desk/brief.rb
CHANGED
|
@@ -179,7 +179,7 @@ module SupportDesk
|
|
|
179
179
|
{
|
|
180
180
|
name: Chats.display_name_for(requester),
|
|
181
181
|
since: requester.try(:created_at),
|
|
182
|
-
open_cases: Ticket.not_closed.where(requester: requester).count,
|
|
182
|
+
open_cases: Ticket.owned_by(ticket.desk&.owner).not_closed.where(requester: requester).count,
|
|
183
183
|
context: requester.try(:support_context) || {}
|
|
184
184
|
}
|
|
185
185
|
end
|
|
@@ -81,6 +81,9 @@ module SupportDesk
|
|
|
81
81
|
assistant: nil
|
|
82
82
|
}.freeze
|
|
83
83
|
|
|
84
|
+
# Everything `apply` may set: the defaults, plus the agent pool.
|
|
85
|
+
SETTABLE = (DEFAULTS.keys + %i[agents]).freeze
|
|
86
|
+
|
|
84
87
|
attr_reader :key, :fallback
|
|
85
88
|
|
|
86
89
|
# A desk's settings, falling back to +fallback+ for anything it
|
|
@@ -522,6 +525,25 @@ module SupportDesk
|
|
|
522
525
|
# Whether THIS desk states the setting itself, rather than inheriting.
|
|
523
526
|
def own?(name) = @settings.key?(name) # :nodoc:
|
|
524
527
|
|
|
528
|
+
# Set every setting in +settings+ through its own setter, so a value
|
|
529
|
+
# from a row is validated exactly like one from the initializer.
|
|
530
|
+
# `topics:` takes the same block `config.topics` does. What
|
|
531
|
+
# `config.desk_settings` returns comes in through here.
|
|
532
|
+
def apply(settings) # :nodoc:
|
|
533
|
+
settings.to_h.each do |name, value|
|
|
534
|
+
name = name.to_sym
|
|
535
|
+
if name == :topics
|
|
536
|
+
topics(&value)
|
|
537
|
+
elsif SETTABLE.include?(name)
|
|
538
|
+
public_send(:"#{name}=", value)
|
|
539
|
+
else
|
|
540
|
+
raise ConfigurationError,
|
|
541
|
+
"desk #{key}: #{name.inspect} is not a desk setting (#{SETTABLE.map(&:inspect).join(", ")}, :topics)"
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
self
|
|
545
|
+
end
|
|
546
|
+
|
|
525
547
|
# Forget a setting so this desk inherits it again (tests).
|
|
526
548
|
def reset_setting(name) # :nodoc:
|
|
527
549
|
@settings.delete(name)
|
|
@@ -662,12 +684,18 @@ module SupportDesk
|
|
|
662
684
|
disclosure_line: :"support_desk.system.assistant_disclosure"
|
|
663
685
|
}.freeze
|
|
664
686
|
|
|
665
|
-
|
|
687
|
+
# Everything `apply` may set.
|
|
688
|
+
SETTABLE = (DEFAULTS.keys + %i[hand_off_when cap]).freeze
|
|
689
|
+
|
|
690
|
+
attr_reader :key, :fallback
|
|
666
691
|
|
|
667
692
|
# A fresh assistant, everything at its documented default — except
|
|
668
|
-
# `disclosure`, which has none.
|
|
669
|
-
|
|
693
|
+
# `disclosure`, which has none. With a +fallback+ (the initializer's
|
|
694
|
+
# own assistant, for one whose settings come from a row), anything it
|
|
695
|
+
# doesn't state is read from there instead.
|
|
696
|
+
def initialize(key, fallback: nil)
|
|
670
697
|
@key = key.to_sym
|
|
698
|
+
@fallback = fallback
|
|
671
699
|
@settings = {}
|
|
672
700
|
@hand_off_when = nil
|
|
673
701
|
@cap = nil
|
|
@@ -845,7 +873,7 @@ module SupportDesk
|
|
|
845
873
|
def hand_off_when(&block)
|
|
846
874
|
return @hand_off_when = block if block
|
|
847
875
|
|
|
848
|
-
@hand_off_when
|
|
876
|
+
@hand_off_when || fallback&.hand_off_when
|
|
849
877
|
end
|
|
850
878
|
|
|
851
879
|
# Set it, validating on assignment (see the reader above).
|
|
@@ -859,7 +887,7 @@ module SupportDesk
|
|
|
859
887
|
def cap(&block)
|
|
860
888
|
return @cap = block if block
|
|
861
889
|
|
|
862
|
-
@cap
|
|
890
|
+
@cap || fallback&.cap
|
|
863
891
|
end
|
|
864
892
|
|
|
865
893
|
# Set it, validating on assignment (see the reader above).
|
|
@@ -926,6 +954,7 @@ module SupportDesk
|
|
|
926
954
|
|
|
927
955
|
def read(name) # :nodoc:
|
|
928
956
|
return @settings[name] if @settings.key?(name)
|
|
957
|
+
return fallback.read(name) if fallback
|
|
929
958
|
|
|
930
959
|
DEFAULTS[name]
|
|
931
960
|
end
|
|
@@ -933,6 +962,22 @@ module SupportDesk
|
|
|
933
962
|
# Whether this assistant states the setting herself (tests).
|
|
934
963
|
def own?(name) = @settings.key?(name) # :nodoc:
|
|
935
964
|
|
|
965
|
+
# Set every setting in +settings+ through its own setter — what
|
|
966
|
+
# `config.assistant_settings` returns comes in through here.
|
|
967
|
+
def apply(settings) # :nodoc:
|
|
968
|
+
settings.to_h.each do |name, value|
|
|
969
|
+
name = name.to_sym
|
|
970
|
+
unless SETTABLE.include?(name)
|
|
971
|
+
raise ConfigurationError,
|
|
972
|
+
"assistant #{key}: #{name.inspect} is not an assistant setting " \
|
|
973
|
+
"(#{SETTABLE.map(&:inspect).join(", ")})"
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
public_send(:"#{name}=", value)
|
|
977
|
+
end
|
|
978
|
+
self
|
|
979
|
+
end
|
|
980
|
+
|
|
936
981
|
# Forget a setting so it goes back to its default (tests).
|
|
937
982
|
def reset_setting(name) # :nodoc:
|
|
938
983
|
@settings.delete(name)
|
|
@@ -1081,6 +1126,8 @@ module SupportDesk
|
|
|
1081
1126
|
@thread_page = :chats
|
|
1082
1127
|
|
|
1083
1128
|
@desks = { default: DeskConfiguration.new(:default) }
|
|
1129
|
+
@desk_settings = nil
|
|
1130
|
+
@assistant_settings = nil
|
|
1084
1131
|
@assistants = {}
|
|
1085
1132
|
@default_assistant = nil
|
|
1086
1133
|
@warnings = []
|
|
@@ -1127,7 +1174,7 @@ module SupportDesk
|
|
|
1127
1174
|
# back records, a relation, or plain desk keys — all three read the same
|
|
1128
1175
|
# way in an initializer, so all three are accepted here.
|
|
1129
1176
|
def desks_visible_to(agent)
|
|
1130
|
-
return Desk.
|
|
1177
|
+
return Desk.owned_by(Current.owner) if visible_desks_for.nil?
|
|
1131
1178
|
|
|
1132
1179
|
Array(visible_desks_for.call(agent)).filter_map do |desk|
|
|
1133
1180
|
desk.is_a?(Desk) ? desk : SupportDesk.desk(desk)
|
|
@@ -1160,6 +1207,50 @@ module SupportDesk
|
|
|
1160
1207
|
false
|
|
1161
1208
|
end
|
|
1162
1209
|
|
|
1210
|
+
# --- Settings that live in rows ------------------------------------------------
|
|
1211
|
+
|
|
1212
|
+
# Where a desk's settings come from when they are not all in code — one
|
|
1213
|
+
# desk per customer, product or workspace, each with its own name and
|
|
1214
|
+
# promise:
|
|
1215
|
+
#
|
|
1216
|
+
# config.desk_settings = ->(desk) { desk.owner&.support_settings }
|
|
1217
|
+
#
|
|
1218
|
+
# Called with the Desk record; returns a Hash of desk settings (`name:`,
|
|
1219
|
+
# `reply_within:`, `topics:` with a block…) or nil. Each value goes
|
|
1220
|
+
# through its setter, so a bad row fails like a bad initializer, and
|
|
1221
|
+
# anything the Hash leaves out is read from the initializer as before.
|
|
1222
|
+
# Read once per record: an owned desk is resolved once per request or
|
|
1223
|
+
# job, so a change is picked up by the next one.
|
|
1224
|
+
attr_reader :desk_settings
|
|
1225
|
+
|
|
1226
|
+
def desk_settings=(value)
|
|
1227
|
+
@desk_settings = value.nil? ? nil : ensure_callable(value, "desk_settings")
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
# The same, for an assistant: `autonomy:`, `disclosure:`, `max_turns:`…
|
|
1231
|
+
#
|
|
1232
|
+
# config.assistant_settings = ->(assistant) { assistant.owner&.assistant_settings }
|
|
1233
|
+
attr_reader :assistant_settings
|
|
1234
|
+
|
|
1235
|
+
def assistant_settings=(value)
|
|
1236
|
+
@assistant_settings = value.nil? ? nil : ensure_callable(value, "assistant_settings")
|
|
1237
|
+
end
|
|
1238
|
+
|
|
1239
|
+
# The configuration a Desk record works under: the initializer's desk,
|
|
1240
|
+
# overlaid with what `desk_settings` says about this one. Internal —
|
|
1241
|
+
# read it as `desk.config`.
|
|
1242
|
+
def desk_configuration_for(record) # :nodoc:
|
|
1243
|
+
base = desk(record.key)
|
|
1244
|
+
overlay(base, desk_settings&.call(record)) { DeskConfiguration.new(base.key, fallback: base) }
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
# The same, for an Assistant record. Raises ConfigurationError when the
|
|
1248
|
+
# initializer no longer declares her — rows add to code, never replace it.
|
|
1249
|
+
def assistant_configuration_for(record) # :nodoc:
|
|
1250
|
+
base = assistant(record.key)
|
|
1251
|
+
overlay(base, assistant_settings&.call(record)) { AssistantConfiguration.new(base.key, fallback: base) }
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1163
1254
|
# --- Desks ------------------------------------------------------------------
|
|
1164
1255
|
|
|
1165
1256
|
# Read or configure a desk:
|
|
@@ -1422,6 +1513,15 @@ module SupportDesk
|
|
|
1422
1513
|
"Add `supportable topic: :#{topic.path}` to #{name}."
|
|
1423
1514
|
end
|
|
1424
1515
|
|
|
1516
|
+
def overlay(base, settings)
|
|
1517
|
+
return base if settings.blank?
|
|
1518
|
+
unless settings.respond_to?(:to_h)
|
|
1519
|
+
raise ConfigurationError, "#{base.key}: settings hooks must return a Hash or nil, got #{settings.inspect}"
|
|
1520
|
+
end
|
|
1521
|
+
|
|
1522
|
+
yield.apply(settings)
|
|
1523
|
+
end
|
|
1524
|
+
|
|
1425
1525
|
def ensure_class_name(value, name)
|
|
1426
1526
|
string = value.is_a?(Class) ? value.name : value.to_s
|
|
1427
1527
|
raise ConfigurationError, "#{name} can't be blank" if string.strip.empty?
|
|
@@ -95,7 +95,7 @@ module SupportDesk
|
|
|
95
95
|
|
|
96
96
|
# How many open cases this requester has right now, this one included.
|
|
97
97
|
def requester_open_tickets
|
|
98
|
-
Ticket.not_closed.where(requester: requester).count
|
|
98
|
+
Ticket.owned_by(ticket.desk&.owner).not_closed.where(requester: requester).count
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
# The same card as a Hash, for a JSON console.
|
data/lib/support_desk/current.rb
CHANGED
|
@@ -22,5 +22,27 @@ module SupportDesk
|
|
|
22
22
|
# The current ActionDispatch::Request, when there is one. Only ever used
|
|
23
23
|
# to enrich event payloads (IP, user agent) — never to authorize.
|
|
24
24
|
attribute :request
|
|
25
|
+
|
|
26
|
+
# Whose desks these are: the account, workspace or product this request
|
|
27
|
+
# or job serves, in an app that runs one support desk per customer.
|
|
28
|
+
# `SupportDesk.desk(:default)` is then THIS owner's default desk, and
|
|
29
|
+
# `Ticket.for_desk`, the SLA scopes and the console's desk list read
|
|
30
|
+
# this owner's rows only. nil — the default — is the single-desk app
|
|
31
|
+
# every install was before 0.6, and it reads the rows with no owner.
|
|
32
|
+
#
|
|
33
|
+
# before_action { SupportDesk::Current.owner = current_workspace }
|
|
34
|
+
# SupportDesk::Current.set(owner: workspace) { … } # in a job
|
|
35
|
+
#
|
|
36
|
+
# Anything reached FROM a ticket ignores it: a case knows its desk, and
|
|
37
|
+
# a desk knows its owner.
|
|
38
|
+
attribute :owner
|
|
39
|
+
|
|
40
|
+
# The owned desks and assistants this request or job has resolved —
|
|
41
|
+
# SupportDesk.desk's cache for owned rows. Gone with the request.
|
|
42
|
+
attribute :resolved
|
|
43
|
+
|
|
44
|
+
def self.resolved_records # :nodoc:
|
|
45
|
+
self.resolved ||= {}
|
|
46
|
+
end
|
|
25
47
|
end
|
|
26
48
|
end
|
data/lib/support_desk/events.rb
CHANGED
|
@@ -19,7 +19,48 @@ module SupportDesk
|
|
|
19
19
|
# 3. **Mirrored.** Every event is also published on ActiveSupport
|
|
20
20
|
# ::Notifications as `"<event>.support_desk"` for APM and hosts that
|
|
21
21
|
# prefer that bus.
|
|
22
|
+
#
|
|
23
|
+
# In-memory events are lost with the process that was going to fire them.
|
|
24
|
+
# What is NOT lost is what the transitions wrote down, and
|
|
25
|
+
# `SupportDesk::Events.replay` reads that back — see below.
|
|
22
26
|
module Events
|
|
27
|
+
# Read the desk's durable record — every event row (transitions, plus the
|
|
28
|
+
# `assistant_turn` and `draft_proposed` signals) and every message
|
|
29
|
+
# registration — from +horizon+, oldest first. PostgreSQL 13+.
|
|
30
|
+
#
|
|
31
|
+
# horizon = SupportDesk::Events.replay(horizon: checkpoint.horizon) do |row|
|
|
32
|
+
# Bridge.ingest(row) # idempotent: row.position is unique across both tables
|
|
33
|
+
# end
|
|
34
|
+
# checkpoint.update!(horizon: horizon)
|
|
35
|
+
#
|
|
36
|
+
# The horizon is a TRANSACTION id, never a row position: positions are
|
|
37
|
+
# handed out at insert and transactions commit in any order, so a reader
|
|
38
|
+
# that checkpoints "the last position I saw" skips the row a slower
|
|
39
|
+
# transaction committed behind it. Each call captures the oldest
|
|
40
|
+
# transaction still running (`pg_snapshot_xmin(pg_current_snapshot())`),
|
|
41
|
+
# yields every row with `horizon <= xact_id < that`, in `(xact_id,
|
|
42
|
+
# position)` order and in pages of +batch_size+, and only then returns
|
|
43
|
+
# it as the next horizon. A raise returns nothing, and the range is read
|
|
44
|
+
# again next time — so readers deduplicate.
|
|
45
|
+
#
|
|
46
|
+
# A long range can be resumed. The block's second argument is a cursor
|
|
47
|
+
# into the range; save it once a row's effects are durable, and pass it
|
|
48
|
+
# back as `resume:` to continue where that left off, the range's end
|
|
49
|
+
# unchanged:
|
|
50
|
+
#
|
|
51
|
+
# SupportDesk::Events.replay(horizon: c.horizon, resume: c.resume) do |row, resume|
|
|
52
|
+
# Bridge.ingest(row)
|
|
53
|
+
# c.update!(resume: resume)
|
|
54
|
+
# end
|
|
55
|
+
#
|
|
56
|
+
# Call it outside a transaction: a reader holding a transaction id holds
|
|
57
|
+
# the horizon back with it.
|
|
58
|
+
def self.replay(horizon:, resume: nil, batch_size: 500, &block)
|
|
59
|
+
raise ArgumentError, "SupportDesk::Events.replay needs a block" unless block
|
|
60
|
+
|
|
61
|
+
Replay.new(horizon: horizon, resume: resume, batch_size: batch_size).each_row(&block)
|
|
62
|
+
end
|
|
63
|
+
|
|
23
64
|
# The catalogue. Keys are event names; values document the arguments
|
|
24
65
|
# subscribers receive (see 09-events-and-notifications).
|
|
25
66
|
CATALOGUE = {
|
|
@@ -39,7 +39,14 @@ module SupportDesk
|
|
|
39
39
|
return unless conversation&.subject_type == SupportDesk::Ticket.polymorphic_name
|
|
40
40
|
return unless messager.respond_to?(:unread_support_count)
|
|
41
41
|
|
|
42
|
-
broadcast_badge_of(messager)
|
|
42
|
+
broadcast_badge_of(messager, owner: conversation.subject&.desk&.owner)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The stream a requester's badge listens on: one per owner, because a
|
|
46
|
+
# person who is a customer of two owners has two launchers, and each
|
|
47
|
+
# counts only its own owner's cases.
|
|
48
|
+
def stream(requester, owner = SupportDesk::Current.owner)
|
|
49
|
+
[ requester, :support_desk, owner ].compact
|
|
43
50
|
end
|
|
44
51
|
|
|
45
52
|
# Replace +requester+'s launcher badge, wherever it is on screen: one
|
|
@@ -51,12 +58,15 @@ module SupportDesk
|
|
|
51
58
|
# telling, from a job, whether anybody is listening, and a switch for it
|
|
52
59
|
# would let a launcher's badge silently never move. Support is
|
|
53
60
|
# human-paced, so this is one small job per message in a case.
|
|
54
|
-
|
|
61
|
+
#
|
|
62
|
+
# The job knows nothing of the request that wrote the message, so the
|
|
63
|
+
# owner travels with it and the badge counts under it.
|
|
64
|
+
def broadcast_badge_of(requester, owner: nil)
|
|
55
65
|
Turbo::StreamsChannel.broadcast_replace_later_to(
|
|
56
|
-
requester,
|
|
66
|
+
*stream(requester, owner),
|
|
57
67
|
target: BADGE_ID,
|
|
58
68
|
partial: BADGE_PARTIAL,
|
|
59
|
-
locals: { requester: requester }
|
|
69
|
+
locals: { requester: requester, owner: owner }.compact
|
|
60
70
|
)
|
|
61
71
|
end
|
|
62
72
|
end
|
|
@@ -24,6 +24,14 @@ module SupportDesk
|
|
|
24
24
|
|
|
25
25
|
acts_as_support_agent kind: :ai, if: :active?
|
|
26
26
|
|
|
27
|
+
# Whose assistant she is, in an app with a desk per customer — the same
|
|
28
|
+
# owner as the desks she works. nil for the single-desk app.
|
|
29
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
30
|
+
|
|
31
|
+
# nil, rather than a MissingAttributeError, until the 0.6 migration has
|
|
32
|
+
# added the column: an unmigrated install has no owners.
|
|
33
|
+
def owner = has_attribute?(:owner_id) ? super : nil
|
|
34
|
+
|
|
27
35
|
# Ruby-side default so settings is always a Hash even on MySQL, where a
|
|
28
36
|
# JSON column can't carry a DB default.
|
|
29
37
|
attribute :settings, default: -> { {} }
|
|
@@ -34,18 +42,36 @@ module SupportDesk
|
|
|
34
42
|
|
|
35
43
|
# The assistant for +key+, found or created. Never INSERT-first, exactly
|
|
36
44
|
# like Desk.for: read on every transition, written once.
|
|
37
|
-
def self.for(key)
|
|
45
|
+
def self.for(key, owner: nil)
|
|
38
46
|
key = key.to_s
|
|
39
|
-
|
|
47
|
+
scope = owned_by(owner)
|
|
48
|
+
scope.find_by(key: key) || scope.create_or_find_by!(key: key)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Hers, for +owner+ — see Desk.owned_by.
|
|
52
|
+
def self.owned_by(owner)
|
|
53
|
+
return all if owner.nil? && !column_names.include?("owner_id")
|
|
54
|
+
|
|
55
|
+
where(owner: owner)
|
|
40
56
|
end
|
|
41
57
|
|
|
42
58
|
# --- Configuration ----------------------------------------------------------
|
|
43
59
|
|
|
44
|
-
# Her slice of the configuration
|
|
45
|
-
#
|
|
46
|
-
#
|
|
60
|
+
# Her slice of the configuration: the initializer's, overlaid with what
|
|
61
|
+
# `config.assistant_settings` says about this row. Raises
|
|
62
|
+
# ConfigurationError when nothing declares her any more — which is why
|
|
63
|
+
# everything below reads through #settings_config and falls back to the
|
|
64
|
+
# safest answer instead.
|
|
47
65
|
def config
|
|
48
|
-
SupportDesk.config.assistant(key)
|
|
66
|
+
return SupportDesk.config.assistant(key) if SupportDesk.config.assistant_settings.nil?
|
|
67
|
+
|
|
68
|
+
@config ||= SupportDesk.config.assistant_configuration_for(self)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A reloaded assistant reads her settings again.
|
|
72
|
+
def reload(*)
|
|
73
|
+
@config = nil
|
|
74
|
+
super
|
|
49
75
|
end
|
|
50
76
|
|
|
51
77
|
# Whether this row still has configuration behind it. False for an
|
|
@@ -158,10 +184,11 @@ module SupportDesk
|
|
|
158
184
|
|
|
159
185
|
# --- Where she works --------------------------------------------------------
|
|
160
186
|
|
|
161
|
-
# The desks whose configuration points at her.
|
|
187
|
+
# The desks whose configuration points at her — her owner's.
|
|
162
188
|
def desks
|
|
163
189
|
SupportDesk.config.desks.each_key.filter_map do |desk_key|
|
|
164
|
-
|
|
190
|
+
desk = SupportDesk.desk(desk_key, owner: owner)
|
|
191
|
+
desk if desk.config.assistant_key == key.to_sym
|
|
165
192
|
end
|
|
166
193
|
end
|
|
167
194
|
|
|
@@ -172,7 +199,8 @@ module SupportDesk
|
|
|
172
199
|
|
|
173
200
|
# The assistant, in one line.
|
|
174
201
|
def inspect
|
|
175
|
-
"#<SupportDesk::Assistant #{key} #{name.inspect} #{autonomy} #{active? ? "active" : "inactive"}
|
|
202
|
+
"#<SupportDesk::Assistant #{key} #{name.inspect} #{autonomy} #{active? ? "active" : "inactive"}" \
|
|
203
|
+
"#{" #{owner_type}##{owner_id}" if try(:owner_id)}>"
|
|
176
204
|
end
|
|
177
205
|
|
|
178
206
|
private
|
|
@@ -87,9 +87,10 @@ module SupportDesk
|
|
|
87
87
|
# what they don't.
|
|
88
88
|
def support_context = {}
|
|
89
89
|
|
|
90
|
-
# True when any of this requester's open tickets is waiting on the desk
|
|
90
|
+
# True when any of this requester's open tickets is waiting on the desk
|
|
91
|
+
# — on the current owner's desks, like everything a requester sees.
|
|
91
92
|
def awaiting_support_reply?
|
|
92
|
-
support_tickets.not_closed.awaiting_reply.exists?
|
|
93
|
+
support_tickets.on_current_desks.not_closed.awaiting_reply.exists?
|
|
93
94
|
end
|
|
94
95
|
|
|
95
96
|
# Unread messages across every support conversation this requester has,
|
|
@@ -19,9 +19,20 @@ module SupportDesk
|
|
|
19
19
|
#
|
|
20
20
|
# Rows are created lazily and never INSERT-first: a desk is read on every
|
|
21
21
|
# page and written once in its life.
|
|
22
|
+
#
|
|
23
|
+
# An app that runs a desk per customer gives each one an OWNER (any
|
|
24
|
+
# record: an account, a workspace, a product). The key stays the same —
|
|
25
|
+
# every owner has a `:default` desk — and SupportDesk::Current.owner says
|
|
26
|
+
# whose is meant. A desk with no owner is the single-desk app.
|
|
22
27
|
class Desk < ApplicationRecord
|
|
23
28
|
self.table_name = "support_desk_desks"
|
|
24
29
|
|
|
30
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
31
|
+
|
|
32
|
+
# nil, rather than a MissingAttributeError, until the 0.6 migration has
|
|
33
|
+
# added the column: an unmigrated install has no owners.
|
|
34
|
+
def owner = has_attribute?(:owner_id) ? super : nil
|
|
35
|
+
|
|
25
36
|
acts_as_messager notifications: false,
|
|
26
37
|
blockable: false,
|
|
27
38
|
inbox: :grouped,
|
|
@@ -50,14 +61,35 @@ module SupportDesk
|
|
|
50
61
|
# written once in its life — and it is why configuration, not
|
|
51
62
|
# `settings`, is the place to put anything that has to change together
|
|
52
63
|
# everywhere.
|
|
53
|
-
def self.for(key)
|
|
64
|
+
def self.for(key, owner: nil)
|
|
54
65
|
key = key.to_s
|
|
55
|
-
|
|
66
|
+
scope = owned_by(owner)
|
|
67
|
+
scope.find_by(key: key) || scope.create_or_find_by!(key: key)
|
|
56
68
|
end
|
|
57
69
|
|
|
58
|
-
#
|
|
70
|
+
# The desks that belong to +owner+; nil means the desks that belong to
|
|
71
|
+
# nobody. Before the 0.6 migration there is no owner to ask about, and
|
|
72
|
+
# every desk belongs to nobody.
|
|
73
|
+
def self.owned_by(owner)
|
|
74
|
+
return all if owner.nil? && !column_names.include?("owner_id")
|
|
75
|
+
|
|
76
|
+
where(owner: owner)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# This desk's slice of the configuration: the initializer's, overlaid
|
|
80
|
+
# with what `config.desk_settings` says about this row when the host
|
|
81
|
+
# set one. Read once per record — see SupportDesk.desk for how long a
|
|
82
|
+
# record lives.
|
|
59
83
|
def config
|
|
60
|
-
SupportDesk.config.desk(key)
|
|
84
|
+
return SupportDesk.config.desk(key) if SupportDesk.config.desk_settings.nil?
|
|
85
|
+
|
|
86
|
+
@config ||= SupportDesk.config.desk_configuration_for(self)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A reloaded desk reads its settings again.
|
|
90
|
+
def reload(*)
|
|
91
|
+
@config = nil
|
|
92
|
+
super
|
|
61
93
|
end
|
|
62
94
|
|
|
63
95
|
# What requesters see as the counterpart. Configuration wins; the
|
|
@@ -79,13 +111,14 @@ module SupportDesk
|
|
|
79
111
|
config.read(:email) || settings["email"].presence
|
|
80
112
|
end
|
|
81
113
|
|
|
82
|
-
# The assistant who works this desk, or nil
|
|
114
|
+
# The assistant who works this desk, or nil: the one with this desk's
|
|
115
|
+
# owner, whatever SupportDesk::Current.owner says. Resolved through
|
|
83
116
|
# `SupportDesk.assistant`, so it is the same memoised record everywhere.
|
|
84
117
|
def assistant
|
|
85
118
|
key = config.assistant_key
|
|
86
119
|
return nil if key.nil?
|
|
87
120
|
|
|
88
|
-
SupportDesk.assistant(key)
|
|
121
|
+
SupportDesk.assistant(key, owner: owner)
|
|
89
122
|
end
|
|
90
123
|
|
|
91
124
|
# Whether this desk has one at all.
|
|
@@ -124,7 +157,7 @@ module SupportDesk
|
|
|
124
157
|
|
|
125
158
|
# The desk, in one line.
|
|
126
159
|
def inspect
|
|
127
|
-
"#<SupportDesk::Desk #{key} #{name.inspect}>"
|
|
160
|
+
"#<SupportDesk::Desk #{key} #{name.inspect}#{" #{owner_type}##{owner_id}" if try(:owner_id)}>"
|
|
128
161
|
end
|
|
129
162
|
end
|
|
130
163
|
end
|
|
@@ -8,6 +8,13 @@ module SupportDesk
|
|
|
8
8
|
# describe — exactly one row per transition — and are read-only forever
|
|
9
9
|
# after. Hosts with tamper-evident logs mirror them by subscribing to
|
|
10
10
|
# `:ticket_transitioned` rather than by writing here.
|
|
11
|
+
#
|
|
12
|
+
# Two kinds are not transitions but SIGNALS, written in the transaction
|
|
13
|
+
# that produced them so a durable reader (SupportDesk::Events.replay) sees
|
|
14
|
+
# them even when the process dies before the in-memory event fires:
|
|
15
|
+
# `assistant_turn` (there is something for the assistant to answer, at
|
|
16
|
+
# this turn) and `draft_proposed`. Neither is history, so neither is in
|
|
17
|
+
# the timeline.
|
|
11
18
|
class Event < ApplicationRecord
|
|
12
19
|
self.table_name = "support_desk_events"
|
|
13
20
|
|
|
@@ -15,15 +22,20 @@ module SupportDesk
|
|
|
15
22
|
opened assigned handed_off released drop_in closed reopened topic_changed subject_attached note
|
|
16
23
|
snoozed woken escalated channel_added email_bounced email_unverified rated tagged
|
|
17
24
|
human_requested assistant_paused assistant_resumed draft_sent draft_rejected assistant_withheld
|
|
25
|
+
assistant_turn draft_proposed
|
|
18
26
|
].freeze
|
|
19
27
|
|
|
20
|
-
# The
|
|
28
|
+
# The kinds that say "something is now owed", not "something happened".
|
|
29
|
+
SIGNAL_KINDS = %w[assistant_turn draft_proposed].freeze
|
|
30
|
+
|
|
31
|
+
# The kinds that describe how the desk works rather than what
|
|
21
32
|
# happened to the case. They join `note` and `drop_in` outside
|
|
22
33
|
# #requester_visible: a customer's export says a person was asked for
|
|
23
34
|
# (`escalated`, `human_requested`), never that a machine's proposal was
|
|
24
35
|
# discarded.
|
|
25
36
|
INTERNAL_KINDS = %w[
|
|
26
37
|
note drop_in assistant_paused assistant_resumed draft_sent draft_rejected assistant_withheld
|
|
38
|
+
assistant_turn draft_proposed
|
|
27
39
|
].freeze
|
|
28
40
|
|
|
29
41
|
belongs_to :ticket, class_name: "SupportDesk::Ticket", inverse_of: :events
|
|
@@ -39,6 +51,8 @@ module SupportDesk
|
|
|
39
51
|
# Everything a requester may see in an export: their own case's story,
|
|
40
52
|
# never the desk's internal reasoning.
|
|
41
53
|
scope :requester_visible, -> { where.not(kind: INTERNAL_KINDS) }
|
|
54
|
+
# What happened to the case, without the signals — what a timeline shows.
|
|
55
|
+
scope :history, -> { where.not(kind: SIGNAL_KINDS) }
|
|
42
56
|
|
|
43
57
|
# Write one event. `actor` may be a record or a Symbol (`:system`,
|
|
44
58
|
# `:routing`) — symbols are kept in the payload, since there is no row
|