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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +151 -6
  4. data/app/controllers/support_desk/tickets_controller.rb +6 -4
  5. data/app/helpers/support_desk/engine_helper.rb +17 -7
  6. data/app/views/chats/slots/_inbox_top.html.erb +1 -1
  7. data/app/views/support_desk/launcher/_badge.html.erb +11 -3
  8. data/app/views/support_desk/launcher/_launcher.html.erb +2 -2
  9. data/lib/generators/support_desk/install_generator.rb +12 -0
  10. data/lib/generators/support_desk/templates/add_owners_to_support_desk.rb.erb +73 -0
  11. data/lib/generators/support_desk/templates/add_replay_to_support_desk.rb.erb +73 -0
  12. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +6 -1
  13. data/lib/generators/support_desk/templates/initializer.rb +12 -0
  14. data/lib/generators/support_desk/upgrade_generator.rb +22 -2
  15. data/lib/support_desk/brief.rb +1 -1
  16. data/lib/support_desk/configuration.rb +106 -6
  17. data/lib/support_desk/context_card.rb +1 -1
  18. data/lib/support_desk/current.rb +22 -0
  19. data/lib/support_desk/events.rb +41 -0
  20. data/lib/support_desk/launcher.rb +14 -4
  21. data/lib/support_desk/models/assistant.rb +37 -9
  22. data/lib/support_desk/models/concerns/requester.rb +3 -2
  23. data/lib/support_desk/models/desk.rb +40 -7
  24. data/lib/support_desk/models/event.rb +15 -1
  25. data/lib/support_desk/models/ticket/assistance.rb +36 -7
  26. data/lib/support_desk/models/ticket.rb +80 -25
  27. data/lib/support_desk/queue.rb +3 -1
  28. data/lib/support_desk/replay.rb +109 -0
  29. data/lib/support_desk/timeline.rb +1 -1
  30. data/lib/support_desk/version.rb +1 -1
  31. data/lib/support_desk.rb +58 -23
  32. 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. See the CHANGELOG for the full list.\n", :green
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
@@ -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
- attr_reader :key
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
- def initialize(key)
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.all if visible_desks_for.nil?
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.
@@ -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
@@ -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
- def broadcast_badge_of(requester)
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, :support_desk,
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
- find_by(key: key) || create_or_find_by!(key: key)
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. 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.
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
- SupportDesk.desk(desk_key) if SupportDesk.config.desk(desk_key).assistant_key == key.to_sym
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
- find_by(key: key) || create_or_find_by!(key: key)
66
+ scope = owned_by(owner)
67
+ scope.find_by(key: key) || scope.create_or_find_by!(key: key)
56
68
  end
57
69
 
58
- # This desk's slice of the configuration.
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. Resolved through
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 five kinds that describe how the desk works rather than what
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