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
|
@@ -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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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)
|
|
@@ -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
|