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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/app/deserializers/command_tower/deserializers/messaging/preferences/update_deserializer.rb +10 -1
  3. data/app/errors/command_tower/errors/account/push_test_destination_error.rb +21 -0
  4. data/app/models/command_tower/messaging/inbox_item.rb +6 -0
  5. data/app/serializers/command_tower/serializers/messaging/inbox.rb +1 -1
  6. data/app/serializers/command_tower/serializers/messaging/preferences/notification_serializer.rb +2 -0
  7. data/app/services/command_tower/messaging/execution/adapter_result.rb +11 -1
  8. data/app/services/command_tower/messaging/execution/adapters/expo/adapter.rb +146 -25
  9. data/app/services/command_tower/messaging/execution/adapters/expo/configuration.rb +5 -0
  10. data/app/services/command_tower/messaging/execution/adapters/expo/endpoint_ticket_history.rb +86 -0
  11. data/app/services/command_tower/messaging/execution/adapters/expo/interruption_level.rb +41 -0
  12. data/app/services/command_tower/messaging/execution/attempt_ledger.rb +3 -0
  13. data/app/services/command_tower/messaging/execution/expo_receipt_collection.rb +224 -0
  14. data/app/services/command_tower/messaging/execution/operation_logger.rb +55 -5
  15. data/app/services/command_tower/messaging/execution/recovery.rb +8 -0
  16. data/app/services/command_tower/messaging/inbox/reader.rb +15 -2
  17. data/app/services/command_tower/messaging/inbox/unread_count_result.rb +3 -2
  18. data/app/services/command_tower/messaging/notification_types/declaration.rb +19 -1
  19. data/app/services/command_tower/messaging/notification_types/declaration_validator.rb +62 -0
  20. data/app/services/command_tower/messaging/notification_types/permitted_choice.rb +55 -0
  21. data/app/services/command_tower/messaging/notification_types/registry.rb +25 -0
  22. data/app/services/command_tower/messaging/notification_types.rb +3 -0
  23. data/app/services/command_tower/messaging/preferences/catalog.rb +7 -0
  24. data/app/services/command_tower/messaging/preferences/catalog_notification_result.rb +26 -1
  25. data/app/services/command_tower/messaging/preferences/effective_choice.rb +92 -0
  26. data/app/services/command_tower/messaging/preferences/preference_state.rb +24 -4
  27. data/app/services/command_tower/messaging/preferences/store.rb +10 -0
  28. data/app/services/command_tower/messaging/preferences/update.rb +36 -4
  29. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +63 -3
  30. data/app/services/command_tower/messaging/rendering/inbox_document.rb +27 -0
  31. data/app/services/command_tower/messaging/rendering/push_presentation.rb +90 -0
  32. data/app/services/command_tower/messaging/rendering/rendered_push_payload.rb +54 -6
  33. data/app/services/command_tower/messaging/sound_catalog.rb +85 -0
  34. data/app/services/command_tower/services/account/push/send_self_test.rb +7 -1
  35. data/app/services/command_tower/services/messaging/communications/produce.rb +27 -0
  36. data/app/services/command_tower/services/messaging/inbox.rb +5 -1
  37. data/app/workflows/command_tower/workflows/me/error_mapping.rb +1 -0
  38. data/app/workflows/command_tower/workflows/messaging/execution/deliver_workflow.rb +5 -1
  39. data/db/migrate/20261012000001_add_provider_metadata_to_messaging_delivery_attempts.rb +7 -0
  40. data/db/migrate/20261012000002_add_finished_at_index_to_messaging_delivery_attempts.rb +8 -0
  41. data/docs/upgrades/0.19.0.md +40 -0
  42. data/docs/upgrades/README.md +1 -0
  43. data/lib/command_tower/configuration/messaging/config.rb +10 -0
  44. data/lib/command_tower/configuration/messaging/expo.rb +61 -0
  45. data/lib/command_tower/install/baseline.rb +2 -0
  46. data/lib/command_tower/version.rb +1 -1
  47. 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(channels:, inbox:).freeze
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(channels: merged_channels.freeze, inbox: merged_inbox).freeze
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(channels: channels.freeze, inbox:).freeze
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
- return nil if sparse_channels.empty? && sparse_inbox.nil?
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
- PreferenceState.new(channels: sparse_channels.freeze, inbox: sparse_inbox).freeze
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: template_locals[: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(recipient_address:, title:, body:, deep_link: nil)
16
- values = [recipient_address, title, body, deep_link]
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
- unless deep_link.nil?
25
- raise ArgumentError, "deep_link must be a String" unless deep_link.is_a?(String)
26
- raise ArgumentError, "deep_link must not be blank when present" if deep_link.strip.empty?
27
- end
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