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
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Execution
6
+ # Collects Expo receipts that were still pending when a push attempt finished.
7
+ # Called at the end of the recovery sweep. It only reads receipts and records them in the
8
+ # attempt's provider_metadata; it never sends, enqueues, or changes delivery or attempt status.
9
+ #
10
+ # Poll window: attempts that finished within RECEIPT_WINDOW. A bounded, best-effort cleanup
11
+ # read just behind the window marks entries that are still pending as unchecked (no HTTP).
12
+ class ExpoReceiptCollection
13
+ RECEIPT_WINDOW = 15.minutes
14
+ BATCH_SIZE = 100
15
+ RECEIPT_ID_CHUNK_SIZE = 1000
16
+ DEVICE_INVALID_ERRORS = %w[DeviceNotRegistered].freeze
17
+ COUNT_KEYS = %i[
18
+ attempt_count
19
+ receipt_ok_count
20
+ receipt_error_count
21
+ receipt_unchecked_count
22
+ invalidated_count
23
+ poll_failure_count
24
+ update_failure_count
25
+ ].freeze
26
+
27
+ def self.call(configuration: nil, http_client: nil, now: Time.current)
28
+ new(configuration:, http_client:, now:).call
29
+ end
30
+
31
+ def initialize(configuration: nil, http_client: nil, now: Time.current)
32
+ @configuration = configuration || Adapters::Expo::Configuration.new
33
+ @http_client = http_client
34
+ @now = now
35
+ @counts = COUNT_KEYS.index_with { 0 }
36
+ end
37
+
38
+ def call
39
+ return @counts unless http_adapter?
40
+
41
+ cleanup_pass
42
+ poll_pass
43
+ @counts
44
+ rescue StandardError
45
+ @counts[:update_failure_count] += 1
46
+ @counts
47
+ ensure
48
+ OperationLogger.receipts_collected(@counts) if http_adapter?
49
+ end
50
+
51
+ private
52
+
53
+ def http_adapter?
54
+ @configuration.expo_configured? && @configuration.adapter_name == "http"
55
+ end
56
+
57
+ def http_client
58
+ @http_client ||= Adapters::Expo::HttpClient.new(configuration: @configuration)
59
+ end
60
+
61
+ def window_start
62
+ @now - RECEIPT_WINDOW
63
+ end
64
+
65
+ def poll_scope
66
+ attempts_scope.where(finished_at: window_start..)
67
+ end
68
+
69
+ def cleanup_scope
70
+ attempts_scope.where(finished_at: (@now - (2 * RECEIPT_WINDOW))...window_start)
71
+ end
72
+
73
+ def attempts_scope
74
+ Messaging::DeliveryAttempt.where.not(provider_metadata: nil)
75
+ end
76
+
77
+ # Keyset batches ordered by (finished_at, id) so the finished_at index serves the range.
78
+ def each_batch(scope)
79
+ table = Messaging::DeliveryAttempt.arel_table
80
+ cursor = nil
81
+
82
+ loop do
83
+ batch_scope = scope.order(:finished_at, :id).limit(BATCH_SIZE)
84
+ if cursor
85
+ batch_scope = batch_scope.where(
86
+ table[:finished_at].gt(cursor[:finished_at]).or(
87
+ table[:finished_at].eq(cursor[:finished_at]).and(table[:id].gt(cursor[:id])),
88
+ ),
89
+ )
90
+ end
91
+
92
+ rows = batch_scope.preload(channel_delivery: :communication).to_a
93
+ break if rows.empty?
94
+
95
+ yield rows
96
+ break if rows.size < BATCH_SIZE
97
+
98
+ cursor = { finished_at: rows.last.finished_at, id: rows.last.id }
99
+ end
100
+ end
101
+
102
+ def cleanup_pass
103
+ each_batch(cleanup_scope) do |rows|
104
+ rows.each do |attempt|
105
+ next if pending_entries(attempt.provider_metadata).empty?
106
+
107
+ update_attempt(attempt, receipts: {}, expire: true)
108
+ end
109
+ end
110
+ end
111
+
112
+ def poll_pass
113
+ each_batch(poll_scope) do |rows|
114
+ pending = rows.select { |attempt| pending_entries(attempt.provider_metadata).any? }
115
+ next if pending.empty?
116
+
117
+ requested_ids = pending.flat_map { |attempt| pending_entries(attempt.provider_metadata).pluck("ticket_id") }
118
+ receipts = fetch_receipts(requested_ids.uniq)
119
+ next if receipts.empty?
120
+
121
+ pending.each do |attempt|
122
+ ticket_ids = pending_entries(attempt.provider_metadata).pluck("ticket_id")
123
+ next unless ticket_ids.any? { |ticket_id| receipts.key?(ticket_id) }
124
+
125
+ update_attempt(attempt, receipts:, expire: false)
126
+ end
127
+ end
128
+ end
129
+
130
+ def fetch_receipts(ticket_ids)
131
+ receipts = {}
132
+ ticket_ids.each_slice(RECEIPT_ID_CHUNK_SIZE) do |slice|
133
+ result = http_client.get_receipts(slice)
134
+ if result.is_a?(Hash) && result[:ok] && result[:receipts].is_a?(Hash)
135
+ receipts.merge!(result[:receipts])
136
+ else
137
+ @counts[:poll_failure_count] += 1
138
+ end
139
+ rescue StandardError
140
+ @counts[:poll_failure_count] += 1
141
+ end
142
+ receipts
143
+ end
144
+
145
+ def pending_entries(metadata)
146
+ endpoints = metadata.is_a?(Hash) ? metadata.dig("expo", "endpoints") : nil
147
+ return [] unless endpoints.is_a?(Array)
148
+
149
+ endpoints.select do |entry|
150
+ entry.is_a?(Hash) &&
151
+ entry["ticket_status"] == "ok" &&
152
+ entry["receipt_status"] == "pending" &&
153
+ entry["ticket_id"].present?
154
+ end
155
+ end
156
+
157
+ # One short transaction per attempt. A failure rolls back only this attempt and the pass
158
+ # continues with the next one; its entries stay pending for a later sweep.
159
+ def update_attempt(attempt, receipts:, expire:)
160
+ deltas = Hash.new(0)
161
+
162
+ ActiveRecord::Base.transaction do
163
+ attempt.lock!
164
+ metadata = attempt.provider_metadata.deep_dup
165
+ changed = false
166
+
167
+ pending_entries(metadata).each do |entry|
168
+ outcome = apply_outcome!(attempt:, entry:, receipt: receipts[entry["ticket_id"]], expire:, deltas:)
169
+ changed = true if outcome
170
+ end
171
+
172
+ attempt.update!(provider_metadata: metadata) if changed
173
+ deltas[:attempt_count] += 1 if changed
174
+ end
175
+
176
+ deltas.each { |key, value| @counts[key] += value }
177
+ rescue StandardError
178
+ @counts[:update_failure_count] += 1
179
+ end
180
+
181
+ # Returns :unchecked, :ok, or :error when the entry changed, nil when it stays pending.
182
+ def apply_outcome!(attempt:, entry:, receipt:, expire:, deltas:)
183
+ if expire
184
+ entry["receipt_status"] = "unchecked"
185
+ deltas[:receipt_unchecked_count] += 1
186
+ return :unchecked
187
+ end
188
+
189
+ case receipt&.dig(:status)
190
+ when "ok"
191
+ entry["receipt_status"] = "ok"
192
+ deltas[:receipt_ok_count] += 1
193
+ :ok
194
+ when "error"
195
+ record_receipt_error!(attempt:, entry:, receipt:, deltas:)
196
+ :error
197
+ end
198
+ end
199
+
200
+ def record_receipt_error!(attempt:, entry:, receipt:, deltas:)
201
+ entry["receipt_status"] = "error"
202
+ if DEVICE_INVALID_ERRORS.include?(receipt[:error].to_s)
203
+ entry["error_code"] = "expo_device_invalid"
204
+ invalidate_endpoint!(attempt:, endpoint_id: entry["endpoint_id"], deltas:)
205
+ else
206
+ entry["error_code"] = "expo_rejected"
207
+ end
208
+ deltas[:receipt_error_count] += 1
209
+ end
210
+
211
+ def invalidate_endpoint!(attempt:, endpoint_id:, deltas:)
212
+ owner_user_id = attempt.channel_delivery&.communication&.user_id
213
+ return if owner_user_id.nil? || endpoint_id.nil?
214
+
215
+ Endpoints.mark_invalid(owner_user_id:, endpoint_id:)
216
+ deltas[:invalidated_count] += 1
217
+ rescue Endpoints::NotFoundError, Endpoints::ValidationError
218
+ # The device endpoint is already gone; the receipt error is still recorded.
219
+ nil
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -45,6 +45,15 @@ module CommandTower
45
45
  info("messaging.execution.recovered", channel_delivery:)
46
46
  end
47
47
 
48
+ # One aggregate event per recovery sweep. Counts only; nothing when nothing happened.
49
+ def receipts_collected(counts)
50
+ counts = counts.to_h.slice(*ExpoReceiptCollection::COUNT_KEYS)
51
+ return if counts.values.sum.zero?
52
+
53
+ failed = counts[:poll_failure_count].to_i.positive? || counts[:update_failure_count].to_i.positive?
54
+ emit(failed ? :error : :info, "messaging.execution.receipts.collected", **counts)
55
+ end
56
+
48
57
  def attempt_started(channel_delivery:, delivery_attempt:)
49
58
  emit_attempt("messaging.execution.attempt.started", channel_delivery:, delivery_attempt:)
50
59
  end
@@ -62,6 +71,28 @@ module CommandTower
62
71
  )
63
72
  end
64
73
 
74
+ def push_destination_fallback(communication:, reason_code:, level:, channel_delivery: nil)
75
+ emit(
76
+ level,
77
+ "messaging.execution.push_destination.fallback",
78
+ channel_delivery:,
79
+ communication_id: communication.id,
80
+ notification_type_key: communication.notification_type_key.to_s,
81
+ reason_code:,
82
+ )
83
+ end
84
+
85
+ def push_presentation_fallback(communication:, reason_code:, channel_delivery: nil)
86
+ emit(
87
+ :warn,
88
+ "messaging.execution.push_presentation.fallback",
89
+ channel_delivery:,
90
+ communication_id: communication.id,
91
+ notification_type_key: communication.notification_type_key.to_s,
92
+ reason_code:,
93
+ )
94
+ end
95
+
65
96
  def attempt_create_failed(channel_delivery:, error:)
66
97
  emit(
67
98
  :error,
@@ -110,32 +141,35 @@ module CommandTower
110
141
  )
111
142
  end
112
143
 
113
- def adapter_accepted(channel_delivery:, delivery_attempt:)
144
+ def adapter_accepted(channel_delivery:, delivery_attempt:, provider_metadata: nil)
114
145
  emit_adapter(
115
146
  :info,
116
147
  "messaging.execution.adapter.accepted",
117
148
  channel_delivery:,
118
149
  delivery_attempt:,
150
+ **provider_metadata_counts(provider_metadata),
119
151
  )
120
152
  end
121
153
 
122
- def adapter_retryable(channel_delivery:, delivery_attempt:, error_code:)
154
+ def adapter_retryable(channel_delivery:, delivery_attempt:, error_code:, provider_metadata: nil)
123
155
  emit_adapter(
124
156
  :error,
125
157
  "messaging.execution.adapter.retryable",
126
158
  channel_delivery:,
127
159
  delivery_attempt:,
128
160
  error_code:,
161
+ **provider_metadata_counts(provider_metadata),
129
162
  )
130
163
  end
131
164
 
132
- def adapter_terminal(channel_delivery:, delivery_attempt:, error_code:)
165
+ def adapter_terminal(channel_delivery:, delivery_attempt:, error_code:, provider_metadata: nil)
133
166
  emit_adapter(
134
167
  :error,
135
168
  "messaging.execution.adapter.terminal",
136
169
  channel_delivery:,
137
170
  delivery_attempt:,
138
171
  error_code:,
172
+ **provider_metadata_counts(provider_metadata),
139
173
  )
140
174
  end
141
175
 
@@ -178,16 +212,32 @@ module CommandTower
178
212
  )
179
213
  end
180
214
 
181
- def emit_adapter(level, event, channel_delivery:, delivery_attempt:, error_code: nil)
215
+ # extra: optional error_code and provider_metadata counts.
216
+ def emit_adapter(level, event, channel_delivery:, delivery_attempt:, **extra)
182
217
  emit(
183
218
  level,
184
219
  event,
185
220
  channel_delivery:,
186
221
  delivery_attempt_id: delivery_attempt.id,
187
- error_code:,
222
+ error_code: nil,
223
+ **extra,
188
224
  )
189
225
  end
190
226
 
227
+ # Counts only; the per-device record stays on the attempt row.
228
+ def provider_metadata_counts(provider_metadata)
229
+ endpoints = provider_metadata.is_a?(Hash) ? provider_metadata.dig("expo", "endpoints") : nil
230
+ return {} unless endpoints.is_a?(Array)
231
+
232
+ ticket_statuses = endpoints.pluck("ticket_status")
233
+ {
234
+ endpoint_count: endpoints.size,
235
+ ticket_ok_count: ticket_statuses.count("ok"),
236
+ ticket_error_count: ticket_statuses.count { |status| %w[error missing].include?(status) },
237
+ receipt_pending_count: endpoints.count { |endpoint| endpoint["receipt_status"] == "pending" },
238
+ }
239
+ end
240
+
191
241
  def info(event, channel_delivery:)
192
242
  emit(:info, event, channel_delivery:)
193
243
  end
@@ -23,10 +23,18 @@ module CommandTower
23
23
  recover_queued!
24
24
  recover_failed!
25
25
  recover_stale_executing!
26
+ collect_expo_receipts!
26
27
  end
27
28
 
28
29
  private
29
30
 
31
+ # Runs last so it can never block a redrive. It only reads receipts and updates metadata.
32
+ def collect_expo_receipts!
33
+ ExpoReceiptCollection.call
34
+ rescue StandardError
35
+ OperationLogger.receipts_collected(update_failure_count: 1)
36
+ end
37
+
30
38
  def grace_before
31
39
  @grace_window.ago
32
40
  end
@@ -65,13 +65,26 @@ module CommandTower
65
65
 
66
66
  def unread_count(recipient_id:)
67
67
  recipient_id = require_recipient_id!(recipient_id)
68
+ unread = Scope.for_recipient(recipient_id).merge(Messaging::InboxItem.unread)
68
69
 
69
- count = Scope.for_recipient(recipient_id).merge(Messaging::InboxItem.unread).count
70
- UnreadCountResult.build(recipient_id:, count:)
70
+ UnreadCountResult.build(
71
+ recipient_id:,
72
+ count: unread.count,
73
+ badge_count: badge_eligible_count(unread),
74
+ )
71
75
  end
72
76
 
73
77
  private
74
78
 
79
+ # Unread rows whose declaration is badge_eligible. Unregistered keys are absent
80
+ # from that set. Channel preferences are not consulted.
81
+ def badge_eligible_count(unread)
82
+ keys = CommandTower::Messaging::NotificationTypes.enumerate.select(&:badge_eligible).map(&:key)
83
+ return 0 if keys.empty?
84
+
85
+ unread.where(messaging_communications: { notification_type_key: keys }).count
86
+ end
87
+
75
88
  def require_recipient_id!(recipient_id)
76
89
  raise ValidationError, "recipient_id is required" if blank?(recipient_id)
77
90
 
@@ -3,11 +3,12 @@
3
3
  module CommandTower
4
4
  module Messaging
5
5
  module Inbox
6
- UnreadCountResult = Data.define(:recipient_id, :count) do
7
- def self.build(recipient_id:, count:)
6
+ UnreadCountResult = Data.define(:recipient_id, :count, :badge_count) do
7
+ def self.build(recipient_id:, count:, badge_count:)
8
8
  new(
9
9
  recipient_id:,
10
10
  count: Integer(count),
11
+ badge_count: Integer(badge_count),
11
12
  ).freeze
12
13
  end
13
14
  end
@@ -23,6 +23,10 @@ module CommandTower
23
23
  :delivery_status_visible,
24
24
  :host_ownership,
25
25
  :inbox_document_composer,
26
+ :badge_eligible,
27
+ :urgency,
28
+ :sound,
29
+ :deep_link_required,
26
30
  ) do
27
31
  def self.build(
28
32
  key:,
@@ -43,7 +47,11 @@ module CommandTower
43
47
  retention: nil,
44
48
  delivery_status_visible: nil,
45
49
  host_ownership: nil,
46
- inbox_document_composer: nil
50
+ inbox_document_composer: nil,
51
+ badge_eligible: false,
52
+ urgency: nil,
53
+ sound: nil,
54
+ deep_link_required: false
47
55
  )
48
56
  new(
49
57
  key: key.nil? ? nil : key.to_s,
@@ -75,6 +83,16 @@ module CommandTower
75
83
  # renders via `InboxDocumentRenderer#build_generic`. See
76
84
  # artifacts/visual-improvements/rich-messaging/authority/RICH_MESSAGING_RUBY_INBOX_DEFINITION_DISCOVERY.md.
77
85
  inbox_document_composer: inbox_document_composer.nil? ? nil : inbox_document_composer.to_s,
86
+ # Notification Product Behavior (NPB-1). All default to today's behavior.
87
+ # `badge_eligible`: unread inbox items of this type count toward the app-icon badge.
88
+ # `urgency` / `sound`: `{ default:, allowed: [ids] | "all" }`; nil is fixed
89
+ # "normal" / "default". Invalid input is kept so DeclarationValidator reports it.
90
+ badge_eligible: !!badge_eligible,
91
+ urgency: PermittedChoice.from(urgency, fallback_default: "normal"),
92
+ sound: PermittedChoice.from(sound, fallback_default: "default"),
93
+ # NPB-5. Push-capable types that are not inbox-backed must set this
94
+ # so Produce can reject a missing metadata deep_link.
95
+ deep_link_required: !!deep_link_required,
78
96
  ).freeze
79
97
  end
80
98
 
@@ -72,9 +72,36 @@ module CommandTower
72
72
  errors << "settings_visible must be a boolean"
73
73
  end
74
74
 
75
+ validate_permitted_choice(
76
+ errors, declaration.urgency, field: "urgency",
77
+ valid_value: ->(value) { URGENCY_LEVELS.include?(value) },
78
+ invalid_message: "unknown urgency level"
79
+ )
80
+ validate_permitted_choice(
81
+ errors, declaration.sound, field: "sound",
82
+ valid_value: ->(value) { SoundCatalog::ID_FORMAT.match?(value) },
83
+ invalid_message: "malformed sound id"
84
+ )
85
+
86
+ if push_capable?(declaration) && !declaration.inbox_available && !declaration.deep_link_required
87
+ errors << "push-capable types must be inbox-backed or declare deep_link_required"
88
+ end
89
+
75
90
  errors
76
91
  end
77
92
 
93
+ # Seal-time check: every sound a declaration names is registered in the catalog.
94
+ def self.sound_reference_errors(declaration, catalog_ids:)
95
+ choice = declaration&.sound
96
+ return [] unless choice.is_a?(PermittedChoice)
97
+
98
+ named = ([choice.default] + (choice.allowed.is_a?(Array) ? choice.allowed : [])).compact.uniq
99
+ missing = named - catalog_ids
100
+ return [] if missing.empty?
101
+
102
+ ["sound references unregistered sound ids: #{missing.join(', ')}"]
103
+ end
104
+
78
105
  def self.validate_category_consistency!(declaration, peers:)
79
106
  errors = category_consistency_errors(declaration, peers:)
80
107
  return if errors.empty?
@@ -131,6 +158,41 @@ module CommandTower
131
158
  end
132
159
  private_class_method :validate_non_negative_integer
133
160
 
161
+ def self.validate_permitted_choice(errors, choice, field:, valid_value:, invalid_message:)
162
+ unless choice.is_a?(PermittedChoice)
163
+ errors << "#{field} must be a hash with default and allowed"
164
+ return
165
+ end
166
+
167
+ default = choice.default
168
+ allowed = choice.allowed
169
+ values = []
170
+
171
+ if default.is_a?(String) && !default.strip.empty?
172
+ values << default
173
+ else
174
+ errors << "#{field} default must be present"
175
+ end
176
+
177
+ # "all" permits every value in the universe, so only the default is checked.
178
+ if allowed.is_a?(Array) && allowed.any?
179
+ values.concat(allowed)
180
+ errors << "#{field} allowed contains duplicates" if allowed.uniq.size != allowed.size
181
+ errors << "#{field} default must be one of allowed" if default && allowed.exclude?(default)
182
+ elsif allowed != "all"
183
+ errors << "#{field} allowed must be \"all\" or a non-empty array"
184
+ end
185
+
186
+ bad = values.uniq.reject { |value| value.is_a?(String) && valid_value.call(value) }
187
+ errors << "#{field} has #{invalid_message}: #{bad.map(&:inspect).join(', ')}" if bad.any?
188
+ end
189
+ private_class_method :validate_permitted_choice
190
+
191
+ def self.push_capable?(declaration)
192
+ declaration.allowed_channels.is_a?(Array) && declaration.allowed_channels.include?("push")
193
+ end
194
+ private_class_method :push_capable?
195
+
134
196
  def self.validate_channel_list(errors, channels, field:)
135
197
  channels.each do |channel|
136
198
  key = channel.to_s
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module NotificationTypes
6
+ # A declared default plus the values a member may choose from. Used by the
7
+ # `urgency` and `sound` declaration fields. `allowed` is either an Array of ids or
8
+ # the String "all" (every value in the universe). The mode is derived from
9
+ # `allowed`, never stored:
10
+ #
11
+ # fixed one permitted value
12
+ # restricted an explicit list of more than one
13
+ # full "all"
14
+ PermittedChoice = Data.define(:default, :allowed) do
15
+ # Normalizes a declaration input. `nil` becomes a choice fixed on `fallback_default`.
16
+ # A Hash is coerced. Any other value is returned unchanged so the validator can
17
+ # report it (mirrors how `default_preference_state` is handled).
18
+ def self.from(raw, fallback_default:)
19
+ return new(default: fallback_default, allowed: [fallback_default].freeze) if raw.nil?
20
+ return raw unless raw.is_a?(Hash)
21
+
22
+ hash = raw.transform_keys(&:to_sym)
23
+ default = hash[:default]&.to_s
24
+ allowed = hash[:allowed]
25
+ allowed =
26
+ if allowed.nil?
27
+ default.nil? ? nil : [default].freeze
28
+ elsif allowed.is_a?(Array)
29
+ allowed.map(&:to_s).freeze
30
+ elsif allowed.to_s == "all"
31
+ "all"
32
+ else
33
+ allowed
34
+ end
35
+
36
+ new(default:, allowed:)
37
+ end
38
+
39
+ def mode
40
+ return :full if allowed == "all"
41
+
42
+ allowed.size > 1 ? :restricted : :fixed
43
+ end
44
+
45
+ def permitted(universe)
46
+ allowed == "all" ? universe.to_a : allowed
47
+ end
48
+
49
+ def selectable?(universe)
50
+ permitted(universe).size > 1
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -88,10 +88,35 @@ module CommandTower
88
88
  DeclarationValidator.validate!(declaration)
89
89
  end
90
90
 
91
+ validate_sound_references!
92
+
91
93
  @declarations.each_value do |declaration|
92
94
  peers = @declarations.values.reject { |peer| peer.equal?(declaration) || peer.key == declaration.key }
93
95
  DeclarationValidator.validate_category_consistency!(declaration, peers:)
94
96
  end
97
+
98
+ validate_home_deep_link_configuration!
99
+ end
100
+
101
+ # Sound ids are checked at seal, not register, so host initializer order cannot matter.
102
+ def validate_sound_references!
103
+ catalog_errors = SoundCatalog.validation_errors
104
+ raise InvalidDeclarationError, "invalid sound catalog: #{catalog_errors.join('; ')}" if catalog_errors.any?
105
+
106
+ @declarations.each_value do |declaration|
107
+ errors = DeclarationValidator.sound_reference_errors(declaration, catalog_ids: SoundCatalog.ids)
108
+ raise InvalidDeclarationError, "#{declaration.key}: #{errors.join('; ')}" if errors.any?
109
+ end
110
+ end
111
+
112
+ # Presence only. A Proc is configured and is not called here.
113
+ def validate_home_deep_link_configuration!
114
+ return if CommandTower.config.messaging.expo.adapter.to_s == "disabled"
115
+ return unless @declarations.values.any? { |declaration| declaration.allowed_channels.include?("push") }
116
+ return if CommandTower.config.messaging.expo.home_deep_link_configured?
117
+
118
+ raise InvalidDeclarationError,
119
+ "home_deep_link must be configured when a push-capable type is registered and the Expo adapter is enabled"
95
120
  end
96
121
 
97
122
  def type_sort_key(declaration)
@@ -3,6 +3,9 @@
3
3
  module CommandTower
4
4
  module Messaging
5
5
  module NotificationTypes
6
+ # Push urgency levels a declaration may permit (D-8). IOS maps them to the OS.
7
+ URGENCY_LEVELS = %w[normal time_sensitive critical].freeze
8
+
6
9
  module_function
7
10
 
8
11
  def register(declaration)
@@ -75,6 +75,11 @@ module CommandTower
75
75
  available =
76
76
  declaration.allowed_channels & @platform_enabled_channels & recipient_ready_keys
77
77
 
78
+ blocks = EffectiveChoice.call(
79
+ declaration:,
80
+ stored: Store.find(recipient_id: @recipient_id, notification_type_key: declaration.key),
81
+ )
82
+
78
83
  ::CommandTower::Messaging::Preferences::CatalogNotificationResult.build(
79
84
  key: declaration.key,
80
85
  label: declaration.label,
@@ -88,6 +93,8 @@ module CommandTower
88
93
  inbox_enabled:,
89
94
  channels:,
90
95
  stored_override_present: evaluation.stored_override_present,
96
+ urgency: blocks[:urgency],
97
+ sound: blocks[:sound],
91
98
  )
92
99
  end
93
100
  end