command_tower 0.18.0 → 0.19.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/deserializers/command_tower/deserializers/messaging/preferences/update_deserializer.rb +10 -1
- data/app/errors/command_tower/errors/account/push_test_destination_error.rb +21 -0
- data/app/models/command_tower/messaging/inbox_item.rb +6 -0
- data/app/serializers/command_tower/serializers/messaging/inbox.rb +1 -1
- data/app/serializers/command_tower/serializers/messaging/preferences/notification_serializer.rb +2 -0
- data/app/services/command_tower/messaging/execution/adapter_result.rb +11 -1
- data/app/services/command_tower/messaging/execution/adapters/expo/adapter.rb +146 -25
- data/app/services/command_tower/messaging/execution/adapters/expo/configuration.rb +5 -0
- data/app/services/command_tower/messaging/execution/adapters/expo/endpoint_ticket_history.rb +86 -0
- data/app/services/command_tower/messaging/execution/adapters/expo/interruption_level.rb +41 -0
- data/app/services/command_tower/messaging/execution/attempt_ledger.rb +3 -0
- data/app/services/command_tower/messaging/execution/expo_receipt_collection.rb +224 -0
- data/app/services/command_tower/messaging/execution/operation_logger.rb +55 -5
- data/app/services/command_tower/messaging/execution/recovery.rb +8 -0
- data/app/services/command_tower/messaging/inbox/reader.rb +15 -2
- data/app/services/command_tower/messaging/inbox/unread_count_result.rb +3 -2
- data/app/services/command_tower/messaging/notification_types/declaration.rb +19 -1
- data/app/services/command_tower/messaging/notification_types/declaration_validator.rb +62 -0
- data/app/services/command_tower/messaging/notification_types/permitted_choice.rb +55 -0
- data/app/services/command_tower/messaging/notification_types/registry.rb +25 -0
- data/app/services/command_tower/messaging/notification_types.rb +3 -0
- data/app/services/command_tower/messaging/preferences/catalog.rb +7 -0
- data/app/services/command_tower/messaging/preferences/catalog_notification_result.rb +26 -1
- data/app/services/command_tower/messaging/preferences/effective_choice.rb +92 -0
- data/app/services/command_tower/messaging/preferences/preference_state.rb +24 -4
- data/app/services/command_tower/messaging/preferences/store.rb +10 -0
- data/app/services/command_tower/messaging/preferences/update.rb +36 -4
- data/app/services/command_tower/messaging/rendering/channel_renderer.rb +63 -3
- data/app/services/command_tower/messaging/rendering/inbox_document.rb +27 -0
- data/app/services/command_tower/messaging/rendering/push_presentation.rb +90 -0
- data/app/services/command_tower/messaging/rendering/rendered_push_payload.rb +54 -6
- data/app/services/command_tower/messaging/sound_catalog.rb +85 -0
- data/app/services/command_tower/services/account/push/send_self_test.rb +7 -1
- data/app/services/command_tower/services/messaging/communications/produce.rb +27 -0
- data/app/services/command_tower/services/messaging/inbox.rb +5 -1
- data/app/workflows/command_tower/workflows/me/error_mapping.rb +1 -0
- data/app/workflows/command_tower/workflows/messaging/execution/deliver_workflow.rb +5 -1
- data/db/migrate/20261012000001_add_provider_metadata_to_messaging_delivery_attempts.rb +7 -0
- data/db/migrate/20261012000002_add_finished_at_index_to_messaging_delivery_attempts.rb +8 -0
- data/docs/upgrades/0.19.0.md +40 -0
- data/docs/upgrades/README.md +1 -0
- data/lib/command_tower/configuration/messaging/config.rb +10 -0
- data/lib/command_tower/configuration/messaging/expo.rb +61 -0
- data/lib/command_tower/install/baseline.rb +2 -0
- data/lib/command_tower/version.rb +1 -1
- metadata +13 -2
|
@@ -16,6 +16,8 @@ module CommandTower
|
|
|
16
16
|
:inbox_enabled,
|
|
17
17
|
:channels,
|
|
18
18
|
:stored_override_present,
|
|
19
|
+
:urgency,
|
|
20
|
+
:sound,
|
|
19
21
|
) do
|
|
20
22
|
def self.build(
|
|
21
23
|
key:,
|
|
@@ -29,7 +31,9 @@ module CommandTower
|
|
|
29
31
|
available_channels:,
|
|
30
32
|
inbox_enabled:,
|
|
31
33
|
channels:,
|
|
32
|
-
stored_override_present
|
|
34
|
+
stored_override_present:,
|
|
35
|
+
urgency:,
|
|
36
|
+
sound:
|
|
33
37
|
)
|
|
34
38
|
frozen_channels =
|
|
35
39
|
channels.to_h.transform_keys(&:to_s).transform_values { |value| !!value }.freeze
|
|
@@ -47,8 +51,29 @@ module CommandTower
|
|
|
47
51
|
inbox_enabled: !!inbox_enabled,
|
|
48
52
|
channels: frozen_channels,
|
|
49
53
|
stored_override_present: !!stored_override_present,
|
|
54
|
+
urgency: freeze_block(urgency),
|
|
55
|
+
sound: freeze_block(sound),
|
|
50
56
|
).freeze
|
|
51
57
|
end
|
|
58
|
+
|
|
59
|
+
def self.freeze_block(block)
|
|
60
|
+
hash = block.to_h
|
|
61
|
+
allowed = hash[:allowed]
|
|
62
|
+
frozen_allowed =
|
|
63
|
+
if allowed.is_a?(Array) && allowed.first.is_a?(Hash)
|
|
64
|
+
allowed.map { |entry| entry.to_h.transform_keys(&:to_sym).freeze }.freeze
|
|
65
|
+
else
|
|
66
|
+
Array(allowed).map(&:to_s).freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
default: hash[:default].to_s,
|
|
71
|
+
allowed: frozen_allowed,
|
|
72
|
+
selectable: !!hash[:selectable],
|
|
73
|
+
effective: hash[:effective].to_s,
|
|
74
|
+
}.freeze
|
|
75
|
+
end
|
|
76
|
+
private_class_method :freeze_block
|
|
52
77
|
end
|
|
53
78
|
end
|
|
54
79
|
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Messaging
|
|
5
|
+
module Preferences
|
|
6
|
+
# Resolves the urgency and sound a member actually gets, and the blocks
|
|
7
|
+
# the preferences API returns. IOS reads this at send time. It does not send.
|
|
8
|
+
class EffectiveChoice
|
|
9
|
+
def self.call(declaration:, stored:)
|
|
10
|
+
new(declaration:, stored:).call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.urgency_ids(choice)
|
|
14
|
+
choice.permitted(NotificationTypes::URGENCY_LEVELS)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.sound_ids(choice)
|
|
18
|
+
ids = choice.allowed == "all" ? SoundCatalog.ids : Array(choice.allowed)
|
|
19
|
+
ids.select { |id| SoundCatalog.fetch(id) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.selectable?(ids)
|
|
23
|
+
ids.size > 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.validate_write!(declaration:, field:, value:)
|
|
27
|
+
choice = declaration.public_send(field)
|
|
28
|
+
ids = field == :urgency ? urgency_ids(choice) : sound_ids(choice)
|
|
29
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
30
|
+
raise InvalidPreferenceStateError, "preference #{field} must be a string"
|
|
31
|
+
end
|
|
32
|
+
unless selectable?(ids)
|
|
33
|
+
raise InvalidPreferenceWriteError, "#{field} is not selectable for notification type: #{declaration.key}"
|
|
34
|
+
end
|
|
35
|
+
return if ids.include?(value)
|
|
36
|
+
|
|
37
|
+
raise InvalidPreferenceWriteError, "#{field} is not permitted: #{value}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(declaration:, stored:)
|
|
41
|
+
@declaration = declaration
|
|
42
|
+
@stored = stored
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def call
|
|
46
|
+
{
|
|
47
|
+
urgency: urgency_block,
|
|
48
|
+
sound: sound_block,
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def urgency_block
|
|
55
|
+
choice = @declaration.urgency
|
|
56
|
+
ids = self.class.urgency_ids(choice)
|
|
57
|
+
{
|
|
58
|
+
default: choice.default,
|
|
59
|
+
allowed: ids,
|
|
60
|
+
selectable: self.class.selectable?(ids),
|
|
61
|
+
effective: resolve(stored: @stored&.urgency, ids:, fallback: choice.default),
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def sound_block
|
|
66
|
+
choice = @declaration.sound
|
|
67
|
+
ids = self.class.sound_ids(choice)
|
|
68
|
+
{
|
|
69
|
+
default: choice.default,
|
|
70
|
+
allowed: ids.map { |id| { id:, label: SoundCatalog.fetch(id)[:label] } },
|
|
71
|
+
selectable: self.class.selectable?(ids),
|
|
72
|
+
effective: resolve_sound(ids, choice.default),
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resolve(stored:, ids:, fallback:)
|
|
77
|
+
return stored if stored && ids.include?(stored)
|
|
78
|
+
|
|
79
|
+
fallback
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve_sound(ids, fallback)
|
|
83
|
+
stored = @stored&.sound
|
|
84
|
+
return stored if stored && ids.include?(stored)
|
|
85
|
+
return fallback if ids.include?(fallback)
|
|
86
|
+
|
|
87
|
+
SoundCatalog::SYSTEM_DEFAULT[:id]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module CommandTower
|
|
4
4
|
module Messaging
|
|
5
5
|
module Preferences
|
|
6
|
-
PreferenceState = Data.define(:channels, :inbox) do
|
|
6
|
+
PreferenceState = Data.define(:channels, :inbox, :urgency, :sound) do
|
|
7
7
|
def self.normalize(raw)
|
|
8
8
|
return nil if raw.nil?
|
|
9
9
|
|
|
@@ -31,17 +31,35 @@ module CommandTower
|
|
|
31
31
|
|
|
32
32
|
inbox = hash.key?("inbox") ? !!hash["inbox"] : nil
|
|
33
33
|
|
|
34
|
-
new(
|
|
34
|
+
new(
|
|
35
|
+
channels:,
|
|
36
|
+
inbox:,
|
|
37
|
+
urgency: optional_string(hash["urgency"]),
|
|
38
|
+
sound: optional_string(hash["sound"]),
|
|
39
|
+
).freeze
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
def self.from_declaration_default(default_preference_state)
|
|
38
|
-
normalize(default_preference_state) || new(channels: {}.freeze, inbox: nil).freeze
|
|
43
|
+
normalize(default_preference_state) || new(channels: {}.freeze, inbox: nil, urgency: nil, sound: nil).freeze
|
|
39
44
|
end
|
|
40
45
|
|
|
46
|
+
def self.optional_string(value)
|
|
47
|
+
return nil unless value.is_a?(String)
|
|
48
|
+
|
|
49
|
+
stripped = value.strip
|
|
50
|
+
stripped.empty? ? nil : stripped
|
|
51
|
+
end
|
|
52
|
+
private_class_method :optional_string
|
|
53
|
+
|
|
41
54
|
def merge_over(base)
|
|
42
55
|
merged_channels = base.channels.merge(channels)
|
|
43
56
|
merged_inbox = inbox.nil? ? base.inbox : inbox
|
|
44
|
-
self.class.new(
|
|
57
|
+
self.class.new(
|
|
58
|
+
channels: merged_channels.freeze,
|
|
59
|
+
inbox: merged_inbox,
|
|
60
|
+
urgency: urgency.nil? ? base.urgency : urgency,
|
|
61
|
+
sound: sound.nil? ? base.sound : sound,
|
|
62
|
+
).freeze
|
|
45
63
|
end
|
|
46
64
|
|
|
47
65
|
def channel_enabled?(channel_key, default: true)
|
|
@@ -53,6 +71,8 @@ module CommandTower
|
|
|
53
71
|
def to_raw_hash
|
|
54
72
|
hash = { "channels" => channels.to_h }
|
|
55
73
|
hash["inbox"] = inbox unless inbox.nil?
|
|
74
|
+
hash["urgency"] = urgency unless urgency.nil?
|
|
75
|
+
hash["sound"] = sound unless sound.nil?
|
|
56
76
|
hash
|
|
57
77
|
end
|
|
58
78
|
end
|
|
@@ -35,6 +35,10 @@ module CommandTower
|
|
|
35
35
|
declaration = lookup_declaration!(key)
|
|
36
36
|
validate_writable!(declaration)
|
|
37
37
|
|
|
38
|
+
raw = preference_state.to_h.transform_keys(&:to_s)
|
|
39
|
+
validate_choice!(declaration, raw, "urgency")
|
|
40
|
+
validate_choice!(declaration, raw, "sound")
|
|
41
|
+
|
|
38
42
|
normalized = PreferenceState.normalize(preference_state)
|
|
39
43
|
if normalized.nil?
|
|
40
44
|
raise InvalidPreferenceWriteError, "preference_state must be present"
|
|
@@ -86,6 +90,12 @@ module CommandTower
|
|
|
86
90
|
"preference channels outside allowed set: #{extras.join(', ')}"
|
|
87
91
|
end
|
|
88
92
|
|
|
93
|
+
def validate_choice!(declaration, raw, field)
|
|
94
|
+
return unless raw.key?(field)
|
|
95
|
+
|
|
96
|
+
EffectiveChoice.validate_write!(declaration:, field: field.to_sym, value: raw[field])
|
|
97
|
+
end
|
|
98
|
+
|
|
89
99
|
def validate_inbox!(declaration, normalized)
|
|
90
100
|
return if normalized.inbox.nil?
|
|
91
101
|
return if declaration.inbox_available
|
|
@@ -85,7 +85,7 @@ module CommandTower
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
hash = @preference_state.to_h.transform_keys(&:to_s)
|
|
88
|
-
unknown_keys = hash.keys - %w[channels inbox]
|
|
88
|
+
unknown_keys = hash.keys - %w[channels inbox urgency sound]
|
|
89
89
|
if unknown_keys.any?
|
|
90
90
|
raise InvalidPreferenceWriteError,
|
|
91
91
|
"unsupported preference keys: #{unknown_keys.join(', ')}"
|
|
@@ -94,7 +94,12 @@ module CommandTower
|
|
|
94
94
|
channels = parse_channels!(declaration, hash["channels"])
|
|
95
95
|
inbox = parse_inbox!(declaration, hash)
|
|
96
96
|
|
|
97
|
-
PreferenceState.new(
|
|
97
|
+
PreferenceState.new(
|
|
98
|
+
channels: channels.freeze,
|
|
99
|
+
inbox:,
|
|
100
|
+
urgency: parse_choice!(declaration, hash, "urgency"),
|
|
101
|
+
sound: parse_choice!(declaration, hash, "sound"),
|
|
102
|
+
).freeze
|
|
98
103
|
end
|
|
99
104
|
|
|
100
105
|
def parse_channels!(declaration, channels_raw)
|
|
@@ -136,6 +141,13 @@ module CommandTower
|
|
|
136
141
|
hash["inbox"]
|
|
137
142
|
end
|
|
138
143
|
|
|
144
|
+
def parse_choice!(declaration, hash, field)
|
|
145
|
+
return nil unless hash.key?(field)
|
|
146
|
+
|
|
147
|
+
EffectiveChoice.validate_write!(declaration:, field: field.to_sym, value: hash[field])
|
|
148
|
+
hash[field].strip
|
|
149
|
+
end
|
|
150
|
+
|
|
139
151
|
def sparsify(declaration, candidate)
|
|
140
152
|
defaults = PreferenceState.from_declaration_default(declaration.default_preference_state)
|
|
141
153
|
sparse_channels = {}
|
|
@@ -154,9 +166,22 @@ module CommandTower
|
|
|
154
166
|
candidate.inbox
|
|
155
167
|
end
|
|
156
168
|
|
|
157
|
-
|
|
169
|
+
sparse_urgency = sparse_choice(candidate.urgency, declaration.urgency.default)
|
|
170
|
+
sparse_sound = sparse_choice(candidate.sound, declaration.sound.default)
|
|
171
|
+
return nil if sparse_channels.empty? && sparse_inbox.nil? && sparse_urgency.nil? && sparse_sound.nil?
|
|
172
|
+
|
|
173
|
+
PreferenceState.new(
|
|
174
|
+
channels: sparse_channels.freeze,
|
|
175
|
+
inbox: sparse_inbox,
|
|
176
|
+
urgency: sparse_urgency,
|
|
177
|
+
sound: sparse_sound,
|
|
178
|
+
).freeze
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def sparse_choice(value, default)
|
|
182
|
+
return nil if value.nil? || value == default
|
|
158
183
|
|
|
159
|
-
|
|
184
|
+
value
|
|
160
185
|
end
|
|
161
186
|
|
|
162
187
|
def persist_reset!
|
|
@@ -227,6 +252,11 @@ module CommandTower
|
|
|
227
252
|
available =
|
|
228
253
|
declaration.allowed_channels & @platform_enabled_channels & recipient_ready_keys
|
|
229
254
|
|
|
255
|
+
blocks = EffectiveChoice.call(
|
|
256
|
+
declaration:,
|
|
257
|
+
stored: Store.find(recipient_id: @recipient_id, notification_type_key: @notification_type_key),
|
|
258
|
+
)
|
|
259
|
+
|
|
230
260
|
::CommandTower::Messaging::Preferences::CatalogNotificationResult.build(
|
|
231
261
|
key: declaration.key,
|
|
232
262
|
label: declaration.label,
|
|
@@ -240,6 +270,8 @@ module CommandTower
|
|
|
240
270
|
inbox_enabled:,
|
|
241
271
|
channels:,
|
|
242
272
|
stored_override_present: evaluation.stored_override_present,
|
|
273
|
+
urgency: blocks[:urgency],
|
|
274
|
+
sound: blocks[:sound],
|
|
243
275
|
)
|
|
244
276
|
end
|
|
245
277
|
end
|
|
@@ -14,11 +14,12 @@ module CommandTower
|
|
|
14
14
|
TEMPLATE_FILENAME_PATTERN = /\A(?<basename>[a-z]+)\.(?<format>html|text)\.erb\z/
|
|
15
15
|
DEFAULT_TITLE = "Notification"
|
|
16
16
|
|
|
17
|
-
def self.render(communication:, channel_key:, recipient_address:)
|
|
17
|
+
def self.render(communication:, channel_key:, recipient_address:, channel_delivery: nil)
|
|
18
18
|
new(
|
|
19
19
|
communication:,
|
|
20
20
|
channel_key:,
|
|
21
21
|
recipient_address:,
|
|
22
|
+
channel_delivery:,
|
|
22
23
|
).render
|
|
23
24
|
end
|
|
24
25
|
|
|
@@ -26,10 +27,11 @@ module CommandTower
|
|
|
26
27
|
SUPPORTED_CHANNELS.include?(channel_key.to_s)
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
def initialize(communication:, channel_key:, recipient_address:)
|
|
30
|
+
def initialize(communication:, channel_key:, recipient_address:, channel_delivery: nil)
|
|
30
31
|
@communication = communication
|
|
31
32
|
@channel_key = channel_key.to_s
|
|
32
33
|
@recipient_address = recipient_address
|
|
34
|
+
@channel_delivery = channel_delivery
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
def render
|
|
@@ -98,11 +100,18 @@ module CommandTower
|
|
|
98
100
|
body = render_template("push.text.erb").to_s.strip
|
|
99
101
|
raise ArgumentError, "body is required" if body.empty?
|
|
100
102
|
|
|
103
|
+
deep_link = resolve_push_deep_link
|
|
104
|
+
presentation = PushPresentation.call(communication: @communication, channel_delivery: @channel_delivery)
|
|
105
|
+
|
|
101
106
|
RenderedPushPayload.build(
|
|
102
107
|
recipient_address: address,
|
|
103
108
|
title:,
|
|
104
109
|
body:,
|
|
105
|
-
deep_link
|
|
110
|
+
deep_link:,
|
|
111
|
+
sound: presentation.sound,
|
|
112
|
+
urgency: presentation.urgency,
|
|
113
|
+
thread_id: presentation.thread_id,
|
|
114
|
+
badge: presentation.badge,
|
|
106
115
|
)
|
|
107
116
|
end
|
|
108
117
|
|
|
@@ -164,6 +173,57 @@ module CommandTower
|
|
|
164
173
|
deep_link.presence
|
|
165
174
|
end
|
|
166
175
|
|
|
176
|
+
# Metadata deep_link wins. A live inbox item is a destination by itself.
|
|
177
|
+
# Otherwise substitute the host Home link. Never invent a link.
|
|
178
|
+
def resolve_push_deep_link
|
|
179
|
+
existing = deep_link_from_metadata
|
|
180
|
+
return existing if existing
|
|
181
|
+
return nil if live_inbox_item
|
|
182
|
+
|
|
183
|
+
home = CommandTower.config.messaging.expo.resolve_home_deep_link
|
|
184
|
+
if home.nil?
|
|
185
|
+
log_push_destination_fallback(reason_code: "home_deep_link_unresolved", level: :error)
|
|
186
|
+
raise RenderError.new(code: "render_failed", error_class: "HomeDeepLinkUnresolved")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
if inbox_preference_disabled?
|
|
190
|
+
log_push_destination_fallback(reason_code: "inbox_disabled", level: :info)
|
|
191
|
+
else
|
|
192
|
+
log_push_destination_fallback(reason_code: "destination_missing", level: :error)
|
|
193
|
+
end
|
|
194
|
+
home
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def live_inbox_item
|
|
198
|
+
item = @communication.inbox_item
|
|
199
|
+
return nil unless item&.live_for_push?
|
|
200
|
+
|
|
201
|
+
item
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def inbox_preference_disabled?
|
|
205
|
+
decision = @communication.destination_plan&.decision
|
|
206
|
+
return false unless decision.is_a?(Hash)
|
|
207
|
+
|
|
208
|
+
excluded = decision["excluded_destinations"] || decision[:excluded_destinations]
|
|
209
|
+
Array(excluded).any? do |item|
|
|
210
|
+
next false unless item.is_a?(Hash)
|
|
211
|
+
|
|
212
|
+
destination = (item["destination"] || item[:destination]).to_s
|
|
213
|
+
reason = (item["reason_class"] || item[:reason_class]).to_s
|
|
214
|
+
destination == "inbox" && reason == Preferences::ReasonClasses::SUPPRESSED_BY_PREFERENCE
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def log_push_destination_fallback(reason_code:, level:)
|
|
219
|
+
Execution::OperationLogger.push_destination_fallback(
|
|
220
|
+
communication: @communication,
|
|
221
|
+
reason_code:,
|
|
222
|
+
level:,
|
|
223
|
+
channel_delivery: @channel_delivery,
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
|
|
167
227
|
def frozen_metadata
|
|
168
228
|
metadata = @communication.metadata
|
|
169
229
|
return {}.freeze unless metadata.is_a?(Hash)
|
|
@@ -92,6 +92,16 @@ module CommandTower
|
|
|
92
92
|
self
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
+
# ISO-8601 instant with an explicit offset, stored as UTC. Blank,
|
|
96
|
+
# unparseable, and offset-less values add no block.
|
|
97
|
+
def instant(at)
|
|
98
|
+
utc = normalized_instant(at)
|
|
99
|
+
return self unless utc
|
|
100
|
+
|
|
101
|
+
@blocks << { type: "instant", at: utc.iso8601 }.freeze
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
95
105
|
def blocks
|
|
96
106
|
@blocks
|
|
97
107
|
end
|
|
@@ -116,6 +126,23 @@ module CommandTower
|
|
|
116
126
|
path.is_a?(String) && path.start_with?("/") && !path.start_with?("//")
|
|
117
127
|
end
|
|
118
128
|
|
|
129
|
+
EXPLICIT_ZONE_ISO = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/
|
|
130
|
+
|
|
131
|
+
def normalized_instant(at)
|
|
132
|
+
case at
|
|
133
|
+
when Time
|
|
134
|
+
at.getutc
|
|
135
|
+
when String
|
|
136
|
+
stripped = at.strip
|
|
137
|
+
return nil if stripped.empty?
|
|
138
|
+
return nil unless EXPLICIT_ZONE_ISO.match?(stripped)
|
|
139
|
+
|
|
140
|
+
Time.iso8601(stripped).getutc
|
|
141
|
+
end
|
|
142
|
+
rescue ArgumentError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
119
146
|
def safe_href?(href)
|
|
120
147
|
return false unless href.is_a?(String) && !href.strip.empty?
|
|
121
148
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Messaging
|
|
5
|
+
module Rendering
|
|
6
|
+
# Resolves how a push should present itself, in provider-neutral terms: the member's
|
|
7
|
+
# effective urgency, the sound asset (nil for System Default), the conversation
|
|
8
|
+
# thread, and, for a badge-eligible type, the recipient's current badge-eligible
|
|
9
|
+
# unread count. Reads the member's stored preference once. Reads the unread count
|
|
10
|
+
# once, through Messaging::Inbox, only when the declaration is badge-eligible.
|
|
11
|
+
# The provider adapter maps the result to its own fields. Never raises for an
|
|
12
|
+
# unregistered type or an unreadable preference: it returns defaults and records
|
|
13
|
+
# a fallback diagnostic instead. An eligible type keeps its badge on that
|
|
14
|
+
# preference fallback. Any other error, including a count failure, surfaces.
|
|
15
|
+
class PushPresentation
|
|
16
|
+
Result = Data.define(:urgency, :sound, :thread_id, :badge)
|
|
17
|
+
|
|
18
|
+
DEFAULT_URGENCY = "normal"
|
|
19
|
+
|
|
20
|
+
def self.call(communication:, channel_delivery: nil)
|
|
21
|
+
new(communication:, channel_delivery:).call
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(communication:, channel_delivery: nil)
|
|
25
|
+
@communication = communication
|
|
26
|
+
@channel_delivery = channel_delivery
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
declaration = NotificationTypes.lookup(@communication.notification_type_key)
|
|
31
|
+
badge = badge_count(declaration)
|
|
32
|
+
blocks = Preferences::EffectiveChoice.call(declaration:, stored: stored_state(declaration))
|
|
33
|
+
|
|
34
|
+
build(
|
|
35
|
+
urgency: blocks.dig(:urgency, :effective),
|
|
36
|
+
sound: sound_asset(blocks.dig(:sound, :effective)),
|
|
37
|
+
badge:,
|
|
38
|
+
)
|
|
39
|
+
rescue NotificationTypes::NotFoundError
|
|
40
|
+
fall_back(reason_code: "type_unregistered")
|
|
41
|
+
rescue Preferences::StoreError, Preferences::InvalidPreferenceStateError
|
|
42
|
+
fall_back(reason_code: "preference_unreadable", badge:)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# A type the member cannot configure ignores any stored override, as the
|
|
48
|
+
# channel evaluation does.
|
|
49
|
+
def stored_state(declaration)
|
|
50
|
+
return nil unless declaration.user_configurable
|
|
51
|
+
|
|
52
|
+
Preferences::Store.find(
|
|
53
|
+
recipient_id: @communication.user_id,
|
|
54
|
+
notification_type_key: declaration.key,
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def sound_asset(sound_id)
|
|
59
|
+
SoundCatalog.fetch(sound_id)&.fetch(:asset)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Nil for a type that is not badge-eligible, so the adapter omits Expo `badge`.
|
|
63
|
+
# The count is NPB-4's absolute eligible unread total, including 0.
|
|
64
|
+
def badge_count(declaration)
|
|
65
|
+
return nil unless declaration.badge_eligible
|
|
66
|
+
|
|
67
|
+
Inbox.unread_count(recipient_id: @communication.user_id).badge_count
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build(urgency:, sound:, badge: nil)
|
|
71
|
+
Result.new(
|
|
72
|
+
urgency:,
|
|
73
|
+
sound:,
|
|
74
|
+
thread_id: @communication.conversation_key.presence,
|
|
75
|
+
badge:,
|
|
76
|
+
).freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def fall_back(reason_code:, badge: nil)
|
|
80
|
+
Execution::OperationLogger.push_presentation_fallback(
|
|
81
|
+
communication: @communication,
|
|
82
|
+
reason_code:,
|
|
83
|
+
channel_delivery: @channel_delivery,
|
|
84
|
+
)
|
|
85
|
+
build(urgency: DEFAULT_URGENCY, sound: nil, badge:)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -6,14 +6,33 @@ module CommandTower
|
|
|
6
6
|
# Provider-neutral Expo push rendered payload. recipient_address is the opaque
|
|
7
7
|
# first eligible endpoint id for the ChannelRenderer gate — never the Expo token.
|
|
8
8
|
# Fan-out uses AdapterRequest#eligible_endpoint_ids.
|
|
9
|
+
#
|
|
10
|
+
# `sound` is the host sound asset file name, or nil for System Default. `urgency` is
|
|
11
|
+
# the member's effective urgency level. `thread_id` is the conversation key, or nil
|
|
12
|
+
# for a standalone message. `badge` is the absolute badge-eligible unread count, or
|
|
13
|
+
# nil when the type is not badge-eligible. The provider adapter maps them to its
|
|
14
|
+
# own fields. A badge of 0 is a real count.
|
|
9
15
|
RenderedPushPayload = Data.define(
|
|
10
16
|
:recipient_address,
|
|
11
17
|
:title,
|
|
12
18
|
:body,
|
|
13
19
|
:deep_link,
|
|
20
|
+
:sound,
|
|
21
|
+
:urgency,
|
|
22
|
+
:thread_id,
|
|
23
|
+
:badge,
|
|
14
24
|
) do
|
|
15
|
-
def self.build(
|
|
16
|
-
|
|
25
|
+
def self.build(
|
|
26
|
+
recipient_address:,
|
|
27
|
+
title:,
|
|
28
|
+
body:,
|
|
29
|
+
deep_link: nil,
|
|
30
|
+
sound: nil,
|
|
31
|
+
urgency: "normal",
|
|
32
|
+
thread_id: nil,
|
|
33
|
+
badge: nil
|
|
34
|
+
)
|
|
35
|
+
values = [recipient_address, title, body, deep_link, sound, urgency, thread_id, badge]
|
|
17
36
|
raise ArgumentError, "arbitrary hashes are not accepted" if values.any? { |v| v.is_a?(Hash) }
|
|
18
37
|
raise ArgumentError, "ActiveRecord objects are not accepted" if values.any? { |v| active_record_like?(v) }
|
|
19
38
|
raise ArgumentError, "raw exception objects are not accepted" if values.any? { |v| v.is_a?(Exception) }
|
|
@@ -21,16 +40,22 @@ module CommandTower
|
|
|
21
40
|
validate_required_string!(recipient_address, "recipient_address")
|
|
22
41
|
validate_required_string!(title, "title")
|
|
23
42
|
validate_required_string!(body, "body")
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
43
|
+
validate_optional_string!(deep_link, "deep_link")
|
|
44
|
+
validate_optional_string!(sound, "sound")
|
|
45
|
+
validate_urgency!(urgency)
|
|
46
|
+
validate_optional_string!(thread_id, "thread_id")
|
|
47
|
+
validate_badge!(badge)
|
|
48
|
+
raise ArgumentError, "thread_id must be at most 128 characters" if thread_id && thread_id.length > 128
|
|
28
49
|
|
|
29
50
|
new(
|
|
30
51
|
recipient_address:,
|
|
31
52
|
title:,
|
|
32
53
|
body:,
|
|
33
54
|
deep_link:,
|
|
55
|
+
sound:,
|
|
56
|
+
urgency:,
|
|
57
|
+
thread_id:,
|
|
58
|
+
badge:,
|
|
34
59
|
).freeze
|
|
35
60
|
end
|
|
36
61
|
|
|
@@ -40,6 +65,29 @@ module CommandTower
|
|
|
40
65
|
end
|
|
41
66
|
private_class_method :validate_required_string!
|
|
42
67
|
|
|
68
|
+
def self.validate_optional_string!(value, field_name)
|
|
69
|
+
return if value.nil?
|
|
70
|
+
|
|
71
|
+
raise ArgumentError, "#{field_name} must be a String" unless value.is_a?(String)
|
|
72
|
+
raise ArgumentError, "#{field_name} must not be blank when present" if value.strip.empty?
|
|
73
|
+
end
|
|
74
|
+
private_class_method :validate_optional_string!
|
|
75
|
+
|
|
76
|
+
def self.validate_urgency!(urgency)
|
|
77
|
+
return if NotificationTypes::URGENCY_LEVELS.include?(urgency)
|
|
78
|
+
|
|
79
|
+
raise ArgumentError, "urgency must be one of #{NotificationTypes::URGENCY_LEVELS.join(', ')}"
|
|
80
|
+
end
|
|
81
|
+
private_class_method :validate_urgency!
|
|
82
|
+
|
|
83
|
+
def self.validate_badge!(badge)
|
|
84
|
+
return if badge.nil?
|
|
85
|
+
return if badge.is_a?(Integer) && badge >= 0
|
|
86
|
+
|
|
87
|
+
raise ArgumentError, "badge must be a nonnegative integer"
|
|
88
|
+
end
|
|
89
|
+
private_class_method :validate_badge!
|
|
90
|
+
|
|
43
91
|
def self.active_record_like?(value)
|
|
44
92
|
defined?(ActiveRecord::Base) && value.is_a?(ActiveRecord::Base)
|
|
45
93
|
end
|