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 +4 -4
- data/app/serializers/command_tower/serializers/messaging/preferences/notification_serializer.rb +109 -1
- data/app/services/command_tower/messaging/channels/definition.rb +3 -0
- data/app/services/command_tower/messaging/channels.rb +15 -0
- data/app/services/command_tower/messaging/notification_types/declaration.rb +15 -1
- data/app/services/command_tower/messaging/notification_types/declaration_validator.rb +53 -0
- data/app/services/command_tower/messaging/preferences/catalog.rb +3 -32
- data/app/services/command_tower/messaging/preferences/catalog_notification_result.rb +35 -1
- data/app/services/command_tower/messaging/preferences/channel_catalog.rb +24 -0
- data/app/services/command_tower/messaging/preferences/corrections.rb +50 -0
- data/app/services/command_tower/messaging/preferences/destination_locks.rb +33 -0
- data/app/services/command_tower/messaging/preferences/destinations.rb +121 -0
- data/app/services/command_tower/messaging/preferences/notification_assembly.rb +77 -0
- data/app/services/command_tower/messaging/preferences/setting_controls.rb +25 -0
- data/app/services/command_tower/messaging/preferences/settings_projection.rb +126 -0
- data/app/services/command_tower/messaging/preferences/update.rb +57 -40
- data/docs/upgrades/0.21.0.md +31 -0
- data/docs/upgrades/README.md +1 -0
- data/lib/command_tower/version.rb +1 -1
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b21d84c5b214027953938804efd0dba2a7523229c33f073bb010c5db82128755
|
|
4
|
+
data.tar.gz: 9c74f1edd4ff8774167b3b40e2423fdd8c286107ac58940f88fcae3f9808b04e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 948f30f811a880da5001b091360eefaed16cb6f1ad52bd93bd57d753ce6cef8bfb6f1d425cac0e299da772bdbd931e115b1c30e80a90376587d084f50630ead2
|
|
7
|
+
data.tar.gz: b578871e337d1291d18819c7a37e455b6646fdba6f2f33ffa67fb94fd415aee5a5a7d1b5c9e3c628772724552b764b9c9c6732b076e2e89bc2be4d741376996a
|
data/app/serializers/command_tower/serializers/messaging/preferences/notification_serializer.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
64
|
+
NotificationAssembly.build(
|
|
79
65
|
declaration:,
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
286
|
+
NotificationAssembly.build(
|
|
256
287
|
declaration:,
|
|
257
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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`).
|
data/docs/upgrades/README.md
CHANGED
|
@@ -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 |
|
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.
|
|
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-
|
|
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
|