command_tower 0.20.0 → 0.21.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8bf05b7d610367efda3b4dce6349bfbd002891563a5f16a5688bbd2660717896
4
- data.tar.gz: 5a31b97e28d791fbda2ad4899c121fe54c2f96e944c387c716d5541d7449c8f6
3
+ metadata.gz: b21d84c5b214027953938804efd0dba2a7523229c33f073bb010c5db82128755
4
+ data.tar.gz: 9c74f1edd4ff8774167b3b40e2423fdd8c286107ac58940f88fcae3f9808b04e
5
5
  SHA512:
6
- metadata.gz: 6c1a8b5cf203cb35b4697f593324b94eb1a0e0a7c4d4b171c752b669a700862679a28cbbe494b54a632294181fa6ddf7d78aa117cf5a71e1deb7e9fc036b122e
7
- data.tar.gz: 5232cb631564969ef269b82fd1b9c38ef820ee76523640a09b2846b380348b64e5c4dc62288b0a391edde0586b4e3d28559d26614788d26e7ba26ab8a96c7bd0
6
+ metadata.gz: 948f30f811a880da5001b091360eefaed16cb6f1ad52bd93bd57d753ce6cef8bfb6f1d425cac0e299da772bdbd931e115b1c30e80a90376587d084f50630ead2
7
+ data.tar.gz: b578871e337d1291d18819c7a37e455b6646fdba6f2f33ffa67fb94fd415aee5a5a7d1b5c9e3c628772724552b764b9c9c6732b076e2e89bc2be4d741376996a
@@ -6,7 +6,7 @@ module CommandTower
6
6
  module Preferences
7
7
  class NotificationSerializer
8
8
  def self.serialize(notification)
9
- {
9
+ payload = {
10
10
  key: notification.key,
11
11
  label: notification.label,
12
12
  description: notification.description,
@@ -16,6 +16,9 @@ module CommandTower
16
16
  inboxAvailable: notification.inbox_available,
17
17
  allowedChannels: notification.allowed_channels,
18
18
  availableChannels: notification.available_channels,
19
+ channelCatalog: serialize_channel_catalog(notification.channel_catalog),
20
+ destinations: serialize_destinations(notification.destinations),
21
+ settings: serialize_settings(notification.settings),
19
22
  urgency: notification.urgency,
20
23
  sound: notification.sound,
21
24
  preferences: {
@@ -24,7 +27,112 @@ module CommandTower
24
27
  storedOverridePresent: notification.stored_override_present,
25
28
  },
26
29
  }
30
+
31
+ corrections = serialize_corrections(notification.corrections)
32
+ payload[:corrections] = corrections unless corrections.nil?
33
+
34
+ payload
35
+ end
36
+
37
+ def self.serialize_channel_catalog(entries)
38
+ Array(entries).map do |entry|
39
+ {
40
+ key: entry[:key],
41
+ label: entry[:label],
42
+ description: entry[:description],
43
+ order: entry[:order],
44
+ glyphKey: entry[:glyph_key],
45
+ }
46
+ end
47
+ end
48
+ private_class_method :serialize_channel_catalog
49
+
50
+ def self.serialize_settings(settings)
51
+ Array(settings).map do |entry|
52
+ hash = entry.respond_to?(:to_h) ? entry.to_h : entry
53
+ {
54
+ key: entry_value(hash, :key),
55
+ channelKey: entry_value(hash, :channel_key) || entry_value(hash, :channelKey),
56
+ control: entry_value(hash, :control),
57
+ label: entry_value(hash, :label),
58
+ description: entry_value(hash, :description),
59
+ editable: entry_value(hash, :editable),
60
+ effective: entry_value(hash, :effective),
61
+ options: serialize_setting_options(entry_value(hash, :options)),
62
+ }
63
+ end
64
+ end
65
+ private_class_method :serialize_settings
66
+
67
+ def self.serialize_setting_options(options)
68
+ return nil if options.nil?
69
+
70
+ Array(options).map do |option|
71
+ option_hash = option.respond_to?(:to_h) ? option.to_h : option
72
+ {
73
+ value: entry_value(option_hash, :value),
74
+ label: entry_value(option_hash, :label),
75
+ description: entry_value(option_hash, :description),
76
+ }
77
+ end
78
+ end
79
+ private_class_method :serialize_setting_options
80
+
81
+ def self.serialize_destinations(destinations)
82
+ destinations.to_h.transform_values do |entry|
83
+ serialize_destination_entry(entry)
84
+ end
85
+ end
86
+ private_class_method :serialize_destinations
87
+
88
+ def self.serialize_destination_entry(entry)
89
+ hash =
90
+ if entry.respond_to?(:to_h)
91
+ entry.to_h
92
+ else
93
+ entry
94
+ end
95
+
96
+ {
97
+ state: entry_value(hash, :state),
98
+ effective: entry_value(hash, :effective),
99
+ reasonCode: entry_value(hash, :reason_code) || entry_value(hash, :reasonCode),
100
+ reason: entry_value(hash, :reason),
101
+ }
102
+ end
103
+
104
+ def self.entry_value(hash, key)
105
+ return hash[key] if hash.key?(key)
106
+
107
+ string_key = key.to_s
108
+ return hash[string_key] if hash.key?(string_key)
109
+
110
+ nil
111
+ end
112
+ private_class_method :entry_value
113
+ private_class_method :serialize_destination_entry
114
+
115
+ def self.serialize_corrections(corrections)
116
+ return nil if corrections.nil?
117
+
118
+ Array(corrections).map do |entry|
119
+ hash =
120
+ if entry.respond_to?(:to_h)
121
+ entry.to_h
122
+ else
123
+ entry
124
+ end
125
+
126
+ {
127
+ channelKey: hash[:channel_key] || hash["channelKey"],
128
+ submitted: hash[:submitted],
129
+ effective: hash[:effective],
130
+ reasonCode: hash[:reason_code] || hash["reasonCode"],
131
+ reason: hash[:reason],
132
+ }
133
+ end
27
134
  end
135
+ private_class_method :serialize_corrections
28
136
  end
29
137
  end
30
138
  end
@@ -6,6 +6,9 @@ module CommandTower
6
6
  Definition = Data.define(
7
7
  :key,
8
8
  :label,
9
+ :description,
10
+ :order,
11
+ :glyph_key,
9
12
  :kind,
10
13
  :supports_endpoint_records,
11
14
  :identity_backed,
@@ -12,6 +12,9 @@ module CommandTower
12
12
  Definition.new(
13
13
  key: "inbox",
14
14
  label: "Inbox",
15
+ description: "In-app messages",
16
+ order: 0,
17
+ glyph_key: "inbox",
15
18
  kind: :inbox,
16
19
  supports_endpoint_records: false,
17
20
  identity_backed: false,
@@ -20,6 +23,9 @@ module CommandTower
20
23
  Definition.new(
21
24
  key: "email",
22
25
  label: "Email",
26
+ description: "Email to your account address",
27
+ order: 1,
28
+ glyph_key: "mail",
23
29
  kind: :external,
24
30
  supports_endpoint_records: false,
25
31
  identity_backed: true,
@@ -28,6 +34,9 @@ module CommandTower
28
34
  Definition.new(
29
35
  key: "sms",
30
36
  label: "SMS",
37
+ description: "Text messages",
38
+ order: 2,
39
+ glyph_key: "message",
31
40
  kind: :external,
32
41
  supports_endpoint_records: false,
33
42
  identity_backed: true,
@@ -36,6 +45,9 @@ module CommandTower
36
45
  Definition.new(
37
46
  key: "push",
38
47
  label: "Push",
48
+ description: "Alerts on this device",
49
+ order: 3,
50
+ glyph_key: "bell",
39
51
  kind: :external,
40
52
  supports_endpoint_records: true,
41
53
  identity_backed: false,
@@ -44,6 +56,9 @@ module CommandTower
44
56
  Definition.new(
45
57
  key: "pushover",
46
58
  label: "Pushover",
59
+ description: "Pushover alerts",
60
+ order: 4,
61
+ glyph_key: "radio",
47
62
  kind: :external,
48
63
  supports_endpoint_records: true,
49
64
  identity_backed: false,
@@ -27,6 +27,7 @@ module CommandTower
27
27
  :urgency,
28
28
  :sound,
29
29
  :deep_link_required,
30
+ :destination_locks,
30
31
  ) do
31
32
  def self.build(
32
33
  key:,
@@ -51,7 +52,8 @@ module CommandTower
51
52
  badge_eligible: false,
52
53
  urgency: nil,
53
54
  sound: nil,
54
- deep_link_required: false
55
+ deep_link_required: false,
56
+ destination_locks: {}
55
57
  )
56
58
  new(
57
59
  key: key.nil? ? nil : key.to_s,
@@ -93,9 +95,21 @@ module CommandTower
93
95
  # NPB-5. Push-capable types that are not inbox-backed must set this
94
96
  # so Produce can reject a missing metadata deep_link.
95
97
  deep_link_required: !!deep_link_required,
98
+ destination_locks: freeze_destination_locks(destination_locks),
96
99
  ).freeze
97
100
  end
98
101
 
102
+ def self.freeze_destination_locks(locks)
103
+ return {}.freeze if locks.nil? || locks.empty?
104
+
105
+ unless locks.respond_to?(:to_h)
106
+ return {}.freeze
107
+ end
108
+
109
+ locks.to_h.transform_keys(&:to_s).transform_values(&:to_s).freeze
110
+ end
111
+ private_class_method :freeze_destination_locks
112
+
99
113
  def self.freeze_channel_list(channels)
100
114
  return nil if channels.nil?
101
115
 
@@ -87,6 +87,8 @@ module CommandTower
87
87
  errors << "push-capable types must be inbox-backed or declare deep_link_required"
88
88
  end
89
89
 
90
+ validate_destination_locks(errors, declaration)
91
+
90
92
  errors
91
93
  end
92
94
 
@@ -193,6 +195,57 @@ module CommandTower
193
195
  end
194
196
  private_class_method :push_capable?
195
197
 
198
+ def self.validate_destination_locks(errors, declaration)
199
+ locks = declaration.destination_locks
200
+ return if locks.nil? || locks.empty?
201
+
202
+ unless locks.is_a?(Hash)
203
+ errors << "destination_locks must be a hash"
204
+ return
205
+ end
206
+
207
+ defaults =
208
+ ::CommandTower::Messaging::Preferences::PreferenceState.from_declaration_default(
209
+ declaration.default_preference_state,
210
+ )
211
+ required_channels =
212
+ if declaration.mandatory
213
+ declaration.default_channels & declaration.allowed_channels
214
+ else
215
+ []
216
+ end
217
+ required_inbox =
218
+ declaration.mandatory && declaration.inbox_available && defaults.inbox != false
219
+
220
+ locks.each do |raw_key, raw_value|
221
+ key = raw_key.to_s
222
+ value = raw_value.to_s
223
+
224
+ unless %w[forced_on forced_off].include?(value)
225
+ errors << "destination_locks[#{key.inspect}] must be forced_on or forced_off"
226
+ end
227
+
228
+ permitted =
229
+ if key == "inbox"
230
+ declaration.inbox_available
231
+ else
232
+ declaration.allowed_channels.include?(key)
233
+ end
234
+ unless permitted
235
+ errors << "destination_locks contains unknown destination: #{key.inspect}"
236
+ end
237
+
238
+ next unless value == "forced_off" && declaration.mandatory
239
+
240
+ if key == "inbox" && required_inbox
241
+ errors << "destination_locks cannot force_off mandatory inbox"
242
+ elsif required_channels.include?(key)
243
+ errors << "destination_locks cannot force_off mandatory channel: #{key}"
244
+ end
245
+ end
246
+ end
247
+ private_class_method :validate_destination_locks
248
+
196
249
  def self.validate_channel_list(errors, channels, field:)
197
250
  channels.each do |channel|
198
251
  key = channel.to_s
@@ -58,43 +58,14 @@ module CommandTower
58
58
  end
59
59
 
60
60
  def build_notification(declaration, evaluation, recipient_ready_keys)
61
- effective_state = evaluation.effective_preference_state || {}
62
- effective_channels = effective_state["channels"] || {}
63
- channels =
64
- declaration.allowed_channels.to_h do |channel|
65
- [channel, !!effective_channels[channel]]
66
- end
67
-
68
- inbox_enabled =
69
- if effective_state.key?("inbox")
70
- !!effective_state["inbox"]
71
- else
72
- evaluation.inbox_permitted
73
- end
74
-
75
61
  available =
76
62
  declaration.allowed_channels & @platform_enabled_channels & recipient_ready_keys
77
63
 
78
- blocks = EffectiveChoice.call(
64
+ NotificationAssembly.build(
79
65
  declaration:,
80
- stored: Store.find(recipient_id: @recipient_id, notification_type_key: declaration.key),
81
- )
82
-
83
- ::CommandTower::Messaging::Preferences::CatalogNotificationResult.build(
84
- key: declaration.key,
85
- label: declaration.label,
86
- description: declaration.description,
87
- order: declaration.type_order,
88
- configurable: declaration.user_configurable,
89
- mandatory: declaration.mandatory,
90
- inbox_available: declaration.inbox_available,
91
- allowed_channels: declaration.allowed_channels,
66
+ evaluation:,
92
67
  available_channels: available,
93
- inbox_enabled:,
94
- channels:,
95
- stored_override_present: evaluation.stored_override_present,
96
- urgency: blocks[:urgency],
97
- sound: blocks[:sound],
68
+ recipient_id: @recipient_id,
98
69
  )
99
70
  end
100
71
  end
@@ -18,6 +18,10 @@ module CommandTower
18
18
  :stored_override_present,
19
19
  :urgency,
20
20
  :sound,
21
+ :channel_catalog,
22
+ :destinations,
23
+ :corrections,
24
+ :settings,
21
25
  ) do
22
26
  def self.build(
23
27
  key:,
@@ -33,7 +37,11 @@ module CommandTower
33
37
  channels:,
34
38
  stored_override_present:,
35
39
  urgency:,
36
- sound:
40
+ sound:,
41
+ channel_catalog: [],
42
+ destinations: {},
43
+ corrections: nil,
44
+ settings: []
37
45
  )
38
46
  frozen_channels =
39
47
  channels.to_h.transform_keys(&:to_s).transform_values { |value| !!value }.freeze
@@ -53,9 +61,35 @@ module CommandTower
53
61
  stored_override_present: !!stored_override_present,
54
62
  urgency: freeze_block(urgency),
55
63
  sound: freeze_block(sound),
64
+ channel_catalog: freeze_channel_catalog(channel_catalog),
65
+ destinations: freeze_destinations(destinations),
66
+ corrections: freeze_corrections(corrections),
67
+ settings: freeze_settings(settings),
56
68
  ).freeze
57
69
  end
58
70
 
71
+ def self.freeze_settings(settings)
72
+ Array(settings).map(&:freeze).freeze
73
+ end
74
+ private_class_method :freeze_settings
75
+
76
+ def self.freeze_channel_catalog(entries)
77
+ Array(entries).map(&:freeze).freeze
78
+ end
79
+ private_class_method :freeze_channel_catalog
80
+
81
+ def self.freeze_destinations(destinations)
82
+ destinations.to_h.transform_keys(&:to_s).transform_values(&:freeze).freeze
83
+ end
84
+ private_class_method :freeze_destinations
85
+
86
+ def self.freeze_corrections(corrections)
87
+ return nil if corrections.nil?
88
+
89
+ Array(corrections).map(&:freeze).freeze
90
+ end
91
+ private_class_method :freeze_corrections
92
+
59
93
  def self.freeze_block(block)
60
94
  hash = block.to_h
61
95
  allowed = hash[:allowed]
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Preferences
6
+ class ChannelCatalog
7
+ def self.entries_for(declaration)
8
+ ::CommandTower::Messaging::Channels::DEFINITIONS.filter_map do |definition|
9
+ next if definition.inbox? && !declaration.inbox_available
10
+ next if definition.external? && !declaration.allowed_channels.include?(definition.key)
11
+
12
+ {
13
+ key: definition.key,
14
+ label: definition.label,
15
+ description: definition.description,
16
+ order: definition.order,
17
+ glyph_key: definition.glyph_key,
18
+ }.freeze
19
+ end.freeze
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Preferences
6
+ class Corrections
7
+ CorrectionEntry = Data.define(
8
+ :channel_key,
9
+ :submitted,
10
+ :effective,
11
+ :reason_code,
12
+ :reason,
13
+ )
14
+
15
+ def self.build(submitted:, destinations:)
16
+ return [].freeze if submitted.nil? || submitted.empty?
17
+
18
+ submitted.filter_map do |channel_key, submitted_value|
19
+ destination = destinations[channel_key.to_s]
20
+ next if destination.nil?
21
+
22
+ entry = normalize_destination(destination)
23
+
24
+ next if submitted_value == entry.effective
25
+
26
+ CorrectionEntry.new(
27
+ channel_key: channel_key.to_s,
28
+ submitted: !!submitted_value,
29
+ effective: entry.effective,
30
+ reason_code: entry.reason_code,
31
+ reason: entry.reason,
32
+ )
33
+ end.freeze
34
+ end
35
+
36
+ def self.normalize_destination(destination)
37
+ return destination if destination.is_a?(Destinations::DestinationEntry)
38
+
39
+ Destinations::DestinationEntry.new(
40
+ state: destination.fetch(:state) { destination["state"] },
41
+ effective: destination.fetch(:effective) { destination["effective"] },
42
+ reason_code: destination.fetch(:reason_code) { destination["reasonCode"] },
43
+ reason: destination.fetch(:reason) { destination["reason"] },
44
+ )
45
+ end
46
+ private_class_method :normalize_destination
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Preferences
6
+ class DestinationLocks
7
+ def self.apply(declaration:, state:)
8
+ locks = declaration.destination_locks
9
+ return state if locks.nil? || locks.empty?
10
+
11
+ channels = state.channels.to_h.dup
12
+ inbox = state.inbox
13
+
14
+ locks.each do |key, lock|
15
+ enabled = lock.to_s == "forced_on"
16
+ if key.to_s == "inbox"
17
+ inbox = enabled
18
+ else
19
+ channels[key.to_s] = enabled
20
+ end
21
+ end
22
+
23
+ PreferenceState.new(
24
+ channels: channels.freeze,
25
+ inbox:,
26
+ urgency: state.urgency,
27
+ sound: state.sound,
28
+ ).freeze
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Preferences
6
+ class Destinations
7
+ REASONS = {
8
+ "required" => "This notification is required on this destination.",
9
+ "locked_on" => "This destination is locked on.",
10
+ "locked_off" => "This destination is locked off.",
11
+ "unavailable" => "This destination is unavailable.",
12
+ }.freeze
13
+
14
+ DestinationEntry = Data.define(:state, :effective, :reason_code, :reason)
15
+
16
+ def self.call(declaration:, evaluation:, available_channels:)
17
+ new(declaration:, evaluation:, available_channels:).call
18
+ end
19
+
20
+ def initialize(declaration:, evaluation:, available_channels:)
21
+ @declaration = declaration
22
+ @evaluation = evaluation
23
+ @available_channels = Array(available_channels).map(&:to_s).freeze
24
+ @locks = declaration.destination_locks || {}.freeze
25
+ @defaults = PreferenceState.from_declaration_default(declaration.default_preference_state)
26
+ end
27
+
28
+ def call
29
+ ChannelCatalog.entries_for(@declaration).to_h do |entry|
30
+ key = entry[:key]
31
+ [key, resolve(key).to_h.freeze]
32
+ end.freeze
33
+ end
34
+
35
+ private
36
+
37
+ def resolve(key)
38
+ if key == "inbox"
39
+ resolve_inbox
40
+ else
41
+ resolve_channel(key)
42
+ end
43
+ end
44
+
45
+ def resolve_channel(channel)
46
+ if unavailable_external?(channel)
47
+ return build_entry(state: "unavailable", effective: false, reason_code: "unavailable")
48
+ end
49
+
50
+ lock = @locks[channel]
51
+ return build_entry(state: "forced_off", effective: false, reason_code: "locked_off") if lock == "forced_off"
52
+ return build_entry(state: "forced_on", effective: true, reason_code: "locked_on") if lock == "forced_on"
53
+
54
+ member_on = member_channel_enabled?(channel)
55
+ if mandatory_required_channel?(channel) && !member_on
56
+ return build_entry(state: "forced_on", effective: true, reason_code: "required")
57
+ end
58
+
59
+ build_entry(state: "editable", effective: member_on, reason_code: nil)
60
+ end
61
+
62
+ def resolve_inbox
63
+ lock = @locks["inbox"]
64
+ return build_entry(state: "forced_off", effective: false, reason_code: "locked_off") if lock == "forced_off"
65
+ return build_entry(state: "forced_on", effective: true, reason_code: "locked_on") if lock == "forced_on"
66
+
67
+ member_on = member_inbox_enabled?
68
+ if mandatory_required_inbox? && !member_on
69
+ return build_entry(state: "forced_on", effective: true, reason_code: "required")
70
+ end
71
+
72
+ build_entry(state: "editable", effective: member_on, reason_code: nil)
73
+ end
74
+
75
+ def unavailable_external?(channel)
76
+ @declaration.allowed_channels.include?(channel) && !@available_channels.include?(channel)
77
+ end
78
+
79
+ def mandatory_required_channel?(channel)
80
+ @declaration.mandatory &&
81
+ (@declaration.default_channels & @declaration.allowed_channels).include?(channel)
82
+ end
83
+
84
+ def mandatory_required_inbox?
85
+ @declaration.mandatory && @declaration.inbox_available && @defaults.inbox != false
86
+ end
87
+
88
+ def member_channel_enabled?(channel)
89
+ state = @evaluation.effective_preference_state || {}
90
+ channels = state["channels"] || {}
91
+ if channels.key?(channel)
92
+ !!channels[channel]
93
+ else
94
+ @defaults.channel_enabled?(channel, default: true)
95
+ end
96
+ end
97
+
98
+ def member_inbox_enabled?
99
+ state = @evaluation.effective_preference_state || {}
100
+ if state.key?("inbox")
101
+ !!state["inbox"]
102
+ elsif @defaults.inbox.nil?
103
+ true
104
+ else
105
+ @defaults.inbox
106
+ end
107
+ end
108
+
109
+ def build_entry(state:, effective:, reason_code:)
110
+ reason = reason_code.nil? ? nil : REASONS.fetch(reason_code)
111
+ DestinationEntry.new(
112
+ state:,
113
+ effective: !!effective,
114
+ reason_code:,
115
+ reason:,
116
+ )
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Preferences
6
+ class NotificationAssembly
7
+ def self.build(
8
+ declaration:,
9
+ evaluation:,
10
+ available_channels:,
11
+ recipient_id:,
12
+ submitted_destinations: nil
13
+ )
14
+ effective_state = evaluation.effective_preference_state || {}
15
+ effective_channels = effective_state["channels"] || {}
16
+ channels =
17
+ declaration.allowed_channels.to_h do |channel|
18
+ [channel, !!effective_channels[channel]]
19
+ end
20
+
21
+ inbox_enabled =
22
+ if effective_state.key?("inbox")
23
+ !!effective_state["inbox"]
24
+ else
25
+ evaluation.inbox_permitted
26
+ end
27
+
28
+ channel_catalog = ChannelCatalog.entries_for(declaration)
29
+ destinations = Destinations.call(
30
+ declaration:,
31
+ evaluation:,
32
+ available_channels:,
33
+ )
34
+ corrections =
35
+ if submitted_destinations.nil?
36
+ nil
37
+ else
38
+ built = Corrections.build(submitted: submitted_destinations, destinations:)
39
+ built.empty? ? nil : built
40
+ end
41
+
42
+ blocks = EffectiveChoice.call(
43
+ declaration:,
44
+ stored: Store.find(recipient_id:, notification_type_key: declaration.key),
45
+ )
46
+
47
+ settings = SettingsProjection.for_notification(
48
+ declaration:,
49
+ blocks:,
50
+ destinations:,
51
+ )
52
+
53
+ CatalogNotificationResult.build(
54
+ key: declaration.key,
55
+ label: declaration.label,
56
+ description: declaration.description,
57
+ order: declaration.type_order,
58
+ configurable: declaration.user_configurable,
59
+ mandatory: declaration.mandatory,
60
+ inbox_available: declaration.inbox_available,
61
+ allowed_channels: declaration.allowed_channels,
62
+ available_channels:,
63
+ inbox_enabled:,
64
+ channels:,
65
+ stored_override_present: evaluation.stored_override_present,
66
+ urgency: blocks[:urgency],
67
+ sound: blocks[:sound],
68
+ channel_catalog:,
69
+ destinations:,
70
+ corrections:,
71
+ settings:,
72
+ )
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Preferences
6
+ module SettingControls
7
+ IMPLEMENTED = %w[single_select].freeze
8
+ RESERVED = %w[boolean multi_select read_only].freeze
9
+
10
+ class UnsupportedControlError < StandardError; end
11
+
12
+ def self.validate_implemented!(control)
13
+ normalized = control.to_s
14
+ if RESERVED.include?(normalized) && !IMPLEMENTED.include?(normalized)
15
+ raise UnsupportedControlError,
16
+ "setting control #{normalized.inspect} is reserved and not implemented"
17
+ end
18
+ return if IMPLEMENTED.include?(normalized)
19
+
20
+ raise UnsupportedControlError, "unknown setting control: #{normalized.inspect}"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Preferences
6
+ class SettingsProjection
7
+ PUSH_CHANNEL = "push"
8
+
9
+ URGENCY_LABELS = {
10
+ "normal" => "Normal",
11
+ "time_sensitive" => "Time Sensitive",
12
+ "critical" => "Critical",
13
+ }.freeze
14
+
15
+ URGENCY_DESCRIPTIONS = {
16
+ "normal" => "Delivered with the device’s normal notification behavior.",
17
+ "time_sensitive" => "Asks the device to treat this alert as time sensitive.",
18
+ "critical" =>
19
+ "Highest declared urgency. Actual delivery behavior depends on the application and device capabilities.",
20
+ }.freeze
21
+
22
+ SettingEntry = Data.define(
23
+ :key,
24
+ :channel_key,
25
+ :control,
26
+ :label,
27
+ :description,
28
+ :editable,
29
+ :effective,
30
+ :options,
31
+ )
32
+
33
+ SingleSelectDefinition = Data.define(
34
+ :key,
35
+ :channel_key,
36
+ :label,
37
+ :description,
38
+ :effective,
39
+ :options,
40
+ )
41
+
42
+ def self.for_notification(declaration:, blocks:, destinations:)
43
+ return [].freeze unless push_destination?(declaration, destinations)
44
+
45
+ definitions = []
46
+ urgency = blocks[:urgency]
47
+ if urgency[:selectable]
48
+ definitions << urgency_definition(urgency)
49
+ end
50
+
51
+ sound = blocks[:sound]
52
+ if sound[:selectable]
53
+ definitions << sound_definition(sound)
54
+ end
55
+
56
+ build(definitions)
57
+ end
58
+
59
+ def self.build(definitions)
60
+ Array(definitions).filter_map do |definition|
61
+ project_single_select(definition)
62
+ end.freeze
63
+ end
64
+
65
+ def self.push_destination?(declaration, destinations)
66
+ declaration.allowed_channels.include?(PUSH_CHANNEL) &&
67
+ destinations.key?(PUSH_CHANNEL)
68
+ end
69
+
70
+ def self.urgency_definition(urgency_block)
71
+ options =
72
+ Array(urgency_block[:allowed]).map do |value|
73
+ {
74
+ value:,
75
+ label: URGENCY_LABELS.fetch(value),
76
+ description: URGENCY_DESCRIPTIONS.fetch(value),
77
+ }.freeze
78
+ end
79
+
80
+ SingleSelectDefinition.new(
81
+ key: "urgency",
82
+ channel_key: PUSH_CHANNEL,
83
+ label: "Urgency",
84
+ description: nil,
85
+ effective: urgency_block[:effective].to_s,
86
+ options: options.freeze,
87
+ )
88
+ end
89
+
90
+ def self.sound_definition(sound_block)
91
+ options =
92
+ Array(sound_block[:allowed]).map do |entry|
93
+ id = entry[:id] || entry["id"]
94
+ label = entry[:label] || entry["label"]
95
+ { value: id, label:, description: nil }.freeze
96
+ end
97
+
98
+ SingleSelectDefinition.new(
99
+ key: "sound",
100
+ channel_key: PUSH_CHANNEL,
101
+ label: "Sound",
102
+ description: nil,
103
+ effective: sound_block[:effective].to_s,
104
+ options: options.freeze,
105
+ )
106
+ end
107
+
108
+ def self.project_single_select(definition)
109
+ SettingControls.validate_implemented!("single_select")
110
+
111
+ SettingEntry.new(
112
+ key: definition.key,
113
+ channel_key: definition.channel_key,
114
+ control: "single_select",
115
+ label: definition.label,
116
+ description: definition.description,
117
+ editable: true,
118
+ effective: definition.effective,
119
+ options: definition.options,
120
+ ).to_h.freeze
121
+ end
122
+ private_class_method :project_single_select
123
+ end
124
+ end
125
+ end
126
+ end
@@ -25,10 +25,14 @@ module CommandTower
25
25
  validate_settings_visible!(declaration)
26
26
  validate_writable!(declaration)
27
27
 
28
+ submitted_destinations = nil
29
+
28
30
  if reset_request?
29
31
  persist_reset!
30
32
  else
31
- candidate = build_candidate_state!(declaration)
33
+ patch_hash = normalized_preference_hash
34
+ submitted_destinations = extract_submitted_destinations(patch_hash)
35
+ candidate = build_candidate_state!(declaration, patch_hash:)
32
36
  sparse = sparsify(declaration, candidate)
33
37
  persist_sparse!(sparse)
34
38
  end
@@ -39,7 +43,12 @@ module CommandTower
39
43
  platform_enabled_channels: @platform_enabled_channels,
40
44
  )
41
45
 
42
- build_notification_result(declaration, evaluation, recipient_ready_channel_keys)
46
+ build_notification_result(
47
+ declaration,
48
+ evaluation,
49
+ recipient_ready_channel_keys,
50
+ submitted_destinations:,
51
+ )
43
52
  end
44
53
 
45
54
  private
@@ -79,24 +88,60 @@ module CommandTower
79
88
  @preference_state.respond_to?(:to_h) && @preference_state.to_h.empty?
80
89
  end
81
90
 
82
- def build_candidate_state!(declaration)
91
+ def normalized_preference_hash
83
92
  unless @preference_state.respond_to?(:to_h)
84
93
  raise InvalidPreferenceStateError, "preference_state must be a hash-like object"
85
94
  end
86
95
 
87
- hash = @preference_state.to_h.transform_keys(&:to_s)
96
+ @preference_state.to_h.transform_keys(&:to_s)
97
+ end
98
+
99
+ def extract_submitted_destinations(hash)
100
+ submitted = {}
101
+ submitted["inbox"] = hash["inbox"] if hash.key?("inbox")
102
+ if hash.key?("channels") && hash["channels"].respond_to?(:to_h)
103
+ hash["channels"].to_h.each do |raw_key, raw_value|
104
+ submitted[raw_key.to_s] = raw_value
105
+ end
106
+ end
107
+ submitted.freeze
108
+ end
109
+
110
+ def build_candidate_state!(declaration, patch_hash: nil)
111
+ hash = patch_hash || normalized_preference_hash
88
112
  unknown_keys = hash.keys - %w[channels inbox urgency sound]
89
113
  if unknown_keys.any?
90
114
  raise InvalidPreferenceWriteError,
91
115
  "unsupported preference keys: #{unknown_keys.join(', ')}"
92
116
  end
93
117
 
94
- channels = parse_channels!(declaration, hash["channels"])
95
- inbox = parse_inbox!(declaration, hash)
118
+ patch = build_patch_partial!(declaration, hash)
119
+ merged = patch.merge_over(stored_member_state)
120
+ DestinationLocks.apply(declaration:, state: merged)
121
+ end
122
+
123
+ def stored_member_state
124
+ Store.find(
125
+ recipient_id: @recipient_id,
126
+ notification_type_key: @notification_type_key,
127
+ ) || empty_stored_member_state
128
+ end
129
+
130
+ def empty_stored_member_state
131
+ PreferenceState.new(channels: {}.freeze, inbox: nil, urgency: nil, sound: nil).freeze
132
+ end
133
+
134
+ def build_patch_partial!(declaration, hash)
135
+ channels =
136
+ if hash.key?("channels")
137
+ parse_channels!(declaration, hash["channels"])
138
+ else
139
+ {}.freeze
140
+ end
96
141
 
97
142
  PreferenceState.new(
98
143
  channels: channels.freeze,
99
- inbox:,
144
+ inbox: parse_inbox!(declaration, hash),
100
145
  urgency: parse_choice!(declaration, hash, "urgency"),
101
146
  sound: parse_choice!(declaration, hash, "sound"),
102
147
  ).freeze
@@ -234,44 +279,16 @@ module CommandTower
234
279
  current.to_raw_hash == sparse.to_raw_hash
235
280
  end
236
281
 
237
- def build_notification_result(declaration, evaluation, recipient_ready_keys)
238
- effective_state = evaluation.effective_preference_state || {}
239
- effective_channels = effective_state["channels"] || {}
240
- channels =
241
- declaration.allowed_channels.to_h do |channel|
242
- [channel, !!effective_channels[channel]]
243
- end
244
-
245
- inbox_enabled =
246
- if effective_state.key?("inbox")
247
- !!effective_state["inbox"]
248
- else
249
- evaluation.inbox_permitted
250
- end
251
-
282
+ def build_notification_result(declaration, evaluation, recipient_ready_keys, submitted_destinations: nil)
252
283
  available =
253
284
  declaration.allowed_channels & @platform_enabled_channels & recipient_ready_keys
254
285
 
255
- blocks = EffectiveChoice.call(
286
+ NotificationAssembly.build(
256
287
  declaration:,
257
- stored: Store.find(recipient_id: @recipient_id, notification_type_key: @notification_type_key),
258
- )
259
-
260
- ::CommandTower::Messaging::Preferences::CatalogNotificationResult.build(
261
- key: declaration.key,
262
- label: declaration.label,
263
- description: declaration.description,
264
- order: declaration.type_order,
265
- configurable: declaration.user_configurable,
266
- mandatory: declaration.mandatory,
267
- inbox_available: declaration.inbox_available,
268
- allowed_channels: declaration.allowed_channels,
288
+ evaluation:,
269
289
  available_channels: available,
270
- inbox_enabled:,
271
- channels:,
272
- stored_override_present: evaluation.stored_override_present,
273
- urgency: blocks[:urgency],
274
- sound: blocks[:sound],
290
+ recipient_id: @recipient_id,
291
+ submitted_destinations:,
275
292
  )
276
293
  end
277
294
  end
@@ -0,0 +1,31 @@
1
+ # Upgrade: CommandTower 0.21.0
2
+
3
+ **From:** `0.20.0`
4
+ **To:** `0.21.0`
5
+
6
+ Minor release: member notification preferences expose a channel catalog, per-destination state, and ordered push settings, and preference PATCH merges onto stored member state. Legacy `urgency` and `sound` blocks remain. Push delivery and Inbox behavior are unchanged.
7
+
8
+ ## Host-visible changes
9
+
10
+ | Change | Host impact |
11
+ |--------|-------------|
12
+ | Preference GET / PATCH payload | Each settings-visible notification includes `channelCatalog` (`key`, `label`, `description`, `order`, `glyphKey`), `destinations` (`state`, `effective`, `reasonCode`, `reason`), and `settings` (`single_select` rows). `urgency` and `sound` blocks stay. |
13
+ | Channel catalog | Platform channels carry presentation copy: Inbox, Email, SMS, Push, Pushover (`description`, `order`, `glyphKey`). |
14
+ | `settings` | When Push is a destination, urgency and sound project as ordered `single_select` rows. Option labels and descriptions are server-owned. Sound options use catalog labels. |
15
+ | Preference PATCH | Partial bodies merge onto stored member state. Omitted `channels`, `inbox`, `urgency`, and `sound` are preserved. Validation still runs before any write. |
16
+ | `destination_locks` | Optional notification declaration (`forced_on` / `forced_off` per destination). Applied after the merge. Defaults to `{}`. Cannot force off a mandatory destination. |
17
+ | `corrections` | Present only when the member explicitly submitted a destination boolean that the server corrected (locks or mandatory required). Omitted keys do not emit corrections. |
18
+
19
+ ## Host actions
20
+
21
+ 1. Bump gem to `0.21.0` (or `>= 0.21.0`).
22
+ 2. **No new migration.** Run `bundle exec rails db:migrate` only if other pending host migrations exist.
23
+ 3. Clients that render notification settings should read `channelCatalog`, `destinations`, and `settings`. Existing `urgency` / `sound` consumers keep working.
24
+ 4. Set `destination_locks` on a notification declaration only when a destination must stay forced on or off.
25
+
26
+ ## Not in this release
27
+
28
+ - No new HTTP routes.
29
+ - No Inbox document or rendering changes.
30
+ - No push execution, Expo, or email retry changes.
31
+ - No change to preference PATCH keys (`channels`, `inbox`, `urgency`, `sound`).
@@ -4,6 +4,7 @@ Host-facing upgrade / change summaries for CommandTower releases.
4
4
 
5
5
  | Version | Summary |
6
6
  |---------|---------|
7
+ | [0.21.0](0.21.0.md) | Notification preference reads: channel catalog, destinations, settings rows; PATCH merges onto stored member state |
7
8
  | [0.20.0](0.20.0.md) | Push delivery certainty: JSON `provider_metadata` caster; duplicate-averse push recovery and Expo submission guards |
8
9
  | [0.19.0](0.19.0.md) | Push presentation: badge, urgency, sound, deep links; Expo ticket and receipt metadata; preference urgency and sound |
9
10
  | [0.18.0](0.18.0.md) | Inbox conversations: optional Produce identity, grouped list entries, `GET /me/inbox/conversation` window of at most 5, accept-time presentation snapshot |
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CommandTower
4
- VERSION = "0.20.0"
4
+ VERSION = "0.21.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: command_tower
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.0
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - matt-taylor
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-15 00:00:00.000000000 Z
11
+ date: 2026-10-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: phonelib
@@ -572,6 +572,10 @@ files:
572
572
  - app/services/command_tower/messaging/preferences/catalog_category_result.rb
573
573
  - app/services/command_tower/messaging/preferences/catalog_notification_result.rb
574
574
  - app/services/command_tower/messaging/preferences/catalog_result.rb
575
+ - app/services/command_tower/messaging/preferences/channel_catalog.rb
576
+ - app/services/command_tower/messaging/preferences/corrections.rb
577
+ - app/services/command_tower/messaging/preferences/destination_locks.rb
578
+ - app/services/command_tower/messaging/preferences/destinations.rb
575
579
  - app/services/command_tower/messaging/preferences/effective_choice.rb
576
580
  - app/services/command_tower/messaging/preferences/error.rb
577
581
  - app/services/command_tower/messaging/preferences/evaluation_result.rb
@@ -579,9 +583,12 @@ files:
579
583
  - app/services/command_tower/messaging/preferences/invalid_preference_state_error.rb
580
584
  - app/services/command_tower/messaging/preferences/invalid_preference_write_error.rb
581
585
  - app/services/command_tower/messaging/preferences/not_settings_visible_error.rb
586
+ - app/services/command_tower/messaging/preferences/notification_assembly.rb
582
587
  - app/services/command_tower/messaging/preferences/preference_state.rb
583
588
  - app/services/command_tower/messaging/preferences/reason_classes.rb
584
589
  - app/services/command_tower/messaging/preferences/resolve.rb
590
+ - app/services/command_tower/messaging/preferences/setting_controls.rb
591
+ - app/services/command_tower/messaging/preferences/settings_projection.rb
585
592
  - app/services/command_tower/messaging/preferences/store.rb
586
593
  - app/services/command_tower/messaging/preferences/store_error.rb
587
594
  - app/services/command_tower/messaging/preferences/suppressed_destination.rb
@@ -861,6 +868,7 @@ files:
861
868
  - docs/upgrades/0.18.0.md
862
869
  - docs/upgrades/0.19.0.md
863
870
  - docs/upgrades/0.20.0.md
871
+ - docs/upgrades/0.21.0.md
864
872
  - docs/upgrades/README.md
865
873
  - lib/command_tower.rb
866
874
  - lib/command_tower/admin_scope.rb