command_tower 0.13.1 → 0.14.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/command_tower/me/push_controller.rb +66 -0
  3. data/app/deserializers/command_tower/deserializers/me/push/token_deserializer.rb +42 -0
  4. data/app/errors/command_tower/errors/account/push_capability_unavailable_error.rb +21 -0
  5. data/app/errors/command_tower/errors/account/push_endpoint_not_found_error.rb +21 -0
  6. data/app/serializers/command_tower/serializers/me/push_serializer.rb +47 -0
  7. data/app/services/command_tower/messaging/channel_detectors.rb +6 -0
  8. data/app/services/command_tower/messaging/endpoints/validators/push.rb +6 -0
  9. data/app/services/command_tower/messaging/execution/adapter_request.rb +21 -3
  10. data/app/services/command_tower/messaging/execution/adapters/expo/adapter.rb +229 -0
  11. data/app/services/command_tower/messaging/execution/adapters/expo/configuration.rb +42 -0
  12. data/app/services/command_tower/messaging/execution/adapters/expo/http_client.rb +137 -0
  13. data/app/services/command_tower/messaging/execution/resolve_recipient_address.rb +7 -0
  14. data/app/services/command_tower/messaging/execution/select_adapter.rb +4 -0
  15. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +21 -3
  16. data/app/services/command_tower/messaging/rendering/rendered_push_payload.rb +50 -0
  17. data/app/services/command_tower/services/account/push/create.rb +25 -0
  18. data/app/services/command_tower/services/account/push/ct_support.rb +38 -0
  19. data/app/services/command_tower/services/account/push/destroy.rb +23 -0
  20. data/app/services/command_tower/services/account/push/list.rb +23 -0
  21. data/app/services/command_tower/services/account/push/replace.rb +26 -0
  22. data/app/services/command_tower/services/me/push_product_gate.rb +18 -0
  23. data/app/views/command_tower/messaging/rendering/push.text.erb +1 -0
  24. data/app/workflows/command_tower/workflows/me/error_mapping.rb +3 -1
  25. data/app/workflows/command_tower/workflows/me/push/create_workflow.rb +37 -0
  26. data/app/workflows/command_tower/workflows/me/push/destroy_workflow.rb +37 -0
  27. data/app/workflows/command_tower/workflows/me/push/index_workflow.rb +34 -0
  28. data/app/workflows/command_tower/workflows/me/push/replace_workflow.rb +38 -0
  29. data/app/workflows/command_tower/workflows/me/push/workflow_support.rb +35 -0
  30. data/app/workflows/command_tower/workflows/messaging/execution/deliver_workflow.rb +1 -0
  31. data/config/routes.rb +9 -0
  32. data/docs/api_reference.md +21 -0
  33. data/docs/controllers.md +1 -0
  34. data/docs/messaging_integration_guide.md +8 -3
  35. data/docs/upgrades/0.14.0.md +31 -0
  36. data/docs/upgrades/README.md +1 -0
  37. data/lib/command_tower/authorization/default.yml +7 -0
  38. data/lib/command_tower/configuration/messaging/config.rb +6 -0
  39. data/lib/command_tower/configuration/messaging/expo.rb +38 -0
  40. data/lib/command_tower/version.rb +1 -1
  41. metadata +25 -2
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module CommandTower
8
+ module Messaging
9
+ module Execution
10
+ module Adapters
11
+ module Expo
12
+ # Narrow Expo Push HTTP client for Messaging notification push.
13
+ # Never logs tokens, Authorization headers, or raw bodies that may contain tokens.
14
+ class HttpClient
15
+ def initialize(configuration: nil)
16
+ @configuration = configuration || Configuration.new
17
+ end
18
+
19
+ # messages: Array of Expo message hashes (to/title/body/data).
20
+ # Returns { ok:, status_code:, tickets: [...] } where each ticket is
21
+ # { status:, id:, message:, error: } (error is Expo details.error when present).
22
+ def send_messages(messages)
23
+ # Expo Push /send accepts a JSON array of messages.
24
+ response = post_json("#{api_base_url}/send", Array(messages))
25
+ parse_send_response(response)
26
+ end
27
+
28
+ # ids: Array of ticket id strings.
29
+ # Returns { ok:, status_code:, receipts: { id => { status:, message:, error: } } }
30
+ def get_receipts(ids)
31
+ response = post_json("#{api_base_url}/getReceipts", { "ids" => Array(ids) })
32
+ parse_receipts_response(response)
33
+ end
34
+
35
+ private
36
+
37
+ def api_base_url
38
+ @configuration.api_base_url.to_s.sub(%r{/\z}, "")
39
+ end
40
+
41
+ def timeout_seconds
42
+ seconds = @configuration.timeout_seconds.to_i
43
+ seconds.positive? ? seconds : 5
44
+ end
45
+
46
+ def access_token
47
+ @configuration.access_token.to_s.strip
48
+ end
49
+
50
+ def post_json(url, payload)
51
+ uri = URI(url)
52
+ request = Net::HTTP::Post.new(uri)
53
+ request["Content-Type"] = "application/json"
54
+ request["Accept"] = "application/json"
55
+ token = access_token
56
+ request["Authorization"] = "Bearer #{token}" unless token.empty?
57
+ request.body = JSON.generate(payload)
58
+
59
+ Net::HTTP.start(
60
+ uri.hostname,
61
+ uri.port,
62
+ use_ssl: uri.scheme == "https",
63
+ open_timeout: timeout_seconds,
64
+ read_timeout: timeout_seconds,
65
+ ) do |http|
66
+ http.request(request)
67
+ end
68
+ end
69
+
70
+ def parse_send_response(response)
71
+ parsed = parse_json(response.body)
72
+ data = parsed.is_a?(Hash) ? parsed["data"] : nil
73
+ tickets =
74
+ if data.is_a?(Array)
75
+ data.map { |ticket| normalize_ticket(ticket) }
76
+ else
77
+ []
78
+ end
79
+
80
+ {
81
+ ok: response.is_a?(Net::HTTPSuccess),
82
+ status_code: response.code.to_i,
83
+ tickets:,
84
+ }
85
+ end
86
+
87
+ def parse_receipts_response(response)
88
+ parsed = parse_json(response.body)
89
+ data = parsed.is_a?(Hash) ? parsed["data"] : nil
90
+ receipts = {}
91
+ if data.is_a?(Hash)
92
+ data.each do |ticket_id, receipt|
93
+ receipts[ticket_id.to_s] = normalize_receipt(receipt)
94
+ end
95
+ end
96
+
97
+ {
98
+ ok: response.is_a?(Net::HTTPSuccess),
99
+ status_code: response.code.to_i,
100
+ receipts:,
101
+ }
102
+ end
103
+
104
+ def normalize_ticket(ticket)
105
+ hash = ticket.is_a?(Hash) ? ticket : {}
106
+ details = hash["details"] || hash[:details]
107
+ error = details.is_a?(Hash) ? (details["error"] || details[:error]) : nil
108
+ {
109
+ status: (hash["status"] || hash[:status]).to_s,
110
+ id: (hash["id"] || hash[:id])&.to_s,
111
+ message: (hash["message"] || hash[:message])&.to_s,
112
+ error: error&.to_s,
113
+ }
114
+ end
115
+
116
+ def normalize_receipt(receipt)
117
+ hash = receipt.is_a?(Hash) ? receipt : {}
118
+ details = hash["details"] || hash[:details]
119
+ error = details.is_a?(Hash) ? (details["error"] || details[:error]) : nil
120
+ {
121
+ status: (hash["status"] || hash[:status]).to_s,
122
+ message: (hash["message"] || hash[:message])&.to_s,
123
+ error: error&.to_s,
124
+ }
125
+ end
126
+
127
+ def parse_json(body)
128
+ JSON.parse(body.to_s)
129
+ rescue JSON::ParserError
130
+ nil
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -42,6 +42,13 @@ module CommandTower
42
42
  else
43
43
  { address: endpoint_id.to_s, error_code: nil }
44
44
  end
45
+ when "push"
46
+ eligible_ids = Array(@readiness_result&.eligible_endpoint_ids)
47
+ if eligible_ids.empty?
48
+ { address: nil, error_code: "recipient_missing" }
49
+ else
50
+ { address: eligible_ids.first.to_s, error_code: nil }
51
+ end
45
52
  else
46
53
  { address: nil, error_code: "adapter_unconfigured" }
47
54
  end
@@ -36,6 +36,10 @@ module CommandTower
36
36
  if Adapters::Pushover::Configuration.pushover_configured?
37
37
  return Adapters::Pushover::Adapter.new
38
38
  end
39
+ when "push"
40
+ if Adapters::Expo::Configuration.expo_configured?
41
+ return Adapters::Expo::Adapter.new
42
+ end
39
43
  end
40
44
 
41
45
  Adapters::UnconfiguredAdapter.new
@@ -10,7 +10,8 @@ module CommandTower
10
10
  EMAIL_CHANNEL = "email"
11
11
  SMS_CHANNEL = "sms"
12
12
  PUSHOVER_CHANNEL = "pushover"
13
- SUPPORTED_CHANNELS = [EMAIL_CHANNEL, SMS_CHANNEL, PUSHOVER_CHANNEL].freeze
13
+ PUSH_CHANNEL = "push"
14
+ SUPPORTED_CHANNELS = [EMAIL_CHANNEL, SMS_CHANNEL, PUSHOVER_CHANNEL, PUSH_CHANNEL].freeze
14
15
  TEMPLATE_DIR = CommandTower::Engine.root.join("app/views/command_tower/messaging/rendering")
15
16
  DEFAULT_TITLE = "Notification"
16
17
 
@@ -48,6 +49,8 @@ module CommandTower
48
49
  render_sms(address)
49
50
  when PUSHOVER_CHANNEL
50
51
  render_pushover(address)
52
+ when PUSH_CHANNEL
53
+ render_push(address)
51
54
  else
52
55
  raise RenderError.new(code: "render_failed", error_class: "UnsupportedChannel")
53
56
  end
@@ -80,7 +83,7 @@ module CommandTower
80
83
  end
81
84
 
82
85
  def render_pushover(address)
83
- title = pushover_title
86
+ title = notification_title
84
87
  message = render_template("pushover.text.erb").to_s.strip
85
88
  raise ArgumentError, "message is required" if message.empty?
86
89
 
@@ -91,7 +94,20 @@ module CommandTower
91
94
  )
92
95
  end
93
96
 
94
- def pushover_title
97
+ def render_push(address)
98
+ title = notification_title
99
+ body = render_template("push.text.erb").to_s.strip
100
+ raise ArgumentError, "body is required" if body.empty?
101
+
102
+ RenderedPushPayload.build(
103
+ recipient_address: address,
104
+ title:,
105
+ body:,
106
+ deep_link: template_locals[:deep_link],
107
+ )
108
+ end
109
+
110
+ def notification_title
95
111
  title = @communication.title.to_s.strip
96
112
  return title unless title.empty?
97
113
 
@@ -101,6 +117,8 @@ module CommandTower
101
117
  DEFAULT_TITLE
102
118
  end
103
119
 
120
+ alias pushover_title notification_title
121
+
104
122
  def render_template(filename)
105
123
  path = TEMPLATE_DIR.join(filename)
106
124
  raise Errno::ENOENT, "missing template #{filename}" unless path.file?
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Rendering
6
+ # Provider-neutral Expo push rendered payload. recipient_address is the opaque
7
+ # first eligible endpoint id for the ChannelRenderer gate — never the Expo token.
8
+ # Fan-out uses AdapterRequest#eligible_endpoint_ids.
9
+ RenderedPushPayload = Data.define(
10
+ :recipient_address,
11
+ :title,
12
+ :body,
13
+ :deep_link,
14
+ ) do
15
+ def self.build(recipient_address:, title:, body:, deep_link: nil)
16
+ values = [recipient_address, title, body, deep_link]
17
+ raise ArgumentError, "arbitrary hashes are not accepted" if values.any? { |v| v.is_a?(Hash) }
18
+ raise ArgumentError, "ActiveRecord objects are not accepted" if values.any? { |v| active_record_like?(v) }
19
+ raise ArgumentError, "raw exception objects are not accepted" if values.any? { |v| v.is_a?(Exception) }
20
+
21
+ validate_required_string!(recipient_address, "recipient_address")
22
+ validate_required_string!(title, "title")
23
+ 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
28
+
29
+ new(
30
+ recipient_address:,
31
+ title:,
32
+ body:,
33
+ deep_link:,
34
+ ).freeze
35
+ end
36
+
37
+ def self.validate_required_string!(value, field_name)
38
+ raise ArgumentError, "#{field_name} is required" if value.nil? || (value.respond_to?(:empty?) && value.empty?)
39
+ raise ArgumentError, "#{field_name} must be a String" unless value.is_a?(String)
40
+ end
41
+ private_class_method :validate_required_string!
42
+
43
+ def self.active_record_like?(value)
44
+ defined?(ActiveRecord::Base) && value.is_a?(ActiveRecord::Base)
45
+ end
46
+ private_class_method :active_record_like?
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Account
6
+ module Push
7
+ class Create < CommandTower::Services::ApplicationService
8
+ validate :user, is_a: User, required: true
9
+ validate :address, is_a: String, required: true
10
+
11
+ def call
12
+ view = CommandTower::Messaging::Endpoints.create(
13
+ owner_user_id: user.id,
14
+ channel_key: "push",
15
+ address:,
16
+ )
17
+ context.safe_view = CtSupport.ensure_verified!(owner_user_id: user.id, safe_view: view)
18
+ rescue CommandTower::Messaging::Endpoints::Error => e
19
+ context.fail!(application_error: CtSupport.map_ct_exception!(e))
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Account
6
+ module Push
7
+ # Shared CT Endpoints helpers for Account push capability services.
8
+ module CtSupport
9
+ module_function
10
+
11
+ def ensure_verified!(owner_user_id:, safe_view:)
12
+ return safe_view if safe_view.verification_state.to_s == "verified"
13
+
14
+ CommandTower::Messaging::Endpoints.mark_verified(
15
+ owner_user_id:,
16
+ endpoint_id: safe_view.id,
17
+ )
18
+ end
19
+
20
+ def map_ct_exception!(error)
21
+ case error
22
+ when CommandTower::Messaging::Endpoints::NotFoundError
23
+ CommandTower::Errors::Account::PushEndpointNotFoundError.new
24
+ when CommandTower::Messaging::Endpoints::ValidationError
25
+ if error.message.to_s.include?("disabled")
26
+ CommandTower::Errors::Account::PushCapabilityUnavailableError.new
27
+ else
28
+ CommandTower::Errors::ValidationError.new(details: { base: "Invalid push token" })
29
+ end
30
+ else
31
+ CommandTower::Errors::InternalError.new
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Account
6
+ module Push
7
+ class Destroy < CommandTower::Services::ApplicationService
8
+ validate :user, is_a: User, required: true
9
+ validate :endpoint_id, required: true
10
+
11
+ def call
12
+ context.safe_view = CommandTower::Messaging::Endpoints.revoke(
13
+ owner_user_id: user.id,
14
+ endpoint_id:,
15
+ )
16
+ rescue CommandTower::Messaging::Endpoints::Error => e
17
+ context.fail!(application_error: CtSupport.map_ct_exception!(e))
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Account
6
+ module Push
7
+ class List < CommandTower::Services::ApplicationService
8
+ validate :user, is_a: User, required: true
9
+
10
+ def call
11
+ views = CommandTower::Messaging::Endpoints.list(
12
+ owner_user_id: user.id,
13
+ channel_key: "push",
14
+ )
15
+ context.safe_views = views.select { |view| view.lifecycle_state.to_s == "active" }
16
+ rescue CommandTower::Messaging::Endpoints::Error => e
17
+ context.fail!(application_error: CtSupport.map_ct_exception!(e))
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Account
6
+ module Push
7
+ class Replace < CommandTower::Services::ApplicationService
8
+ validate :user, is_a: User, required: true
9
+ validate :endpoint_id, required: true
10
+ validate :address, is_a: String, required: true
11
+
12
+ def call
13
+ view = CommandTower::Messaging::Endpoints.replace(
14
+ owner_user_id: user.id,
15
+ endpoint_id:,
16
+ address:,
17
+ )
18
+ context.safe_view = CtSupport.ensure_verified!(owner_user_id: user.id, safe_view: view)
19
+ rescue CommandTower::Messaging::Endpoints::Error => e
20
+ context.fail!(application_error: CtSupport.map_ct_exception!(e))
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Me
6
+ # Product gate for Expo push Me/Account endpoint lifecycle HTTP.
7
+ # Matches ChannelDetectors.configured?("push") / expo_configured?.
8
+ # Does not invent route constraints — callers return 503 when disabled.
9
+ module PushProductGate
10
+ module_function
11
+
12
+ def enabled?
13
+ CommandTower::Messaging::ChannelDetectors.configured?("push")
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -24,12 +24,14 @@ module CommandTower
24
24
  CommandTower::Errors::Account::PushoverNotConfiguredError,
25
25
  CommandTower::Errors::Account::PushoverAlreadyConfiguredError,
26
26
  CommandTower::Errors::Account::PushoverVerificationFailedError,
27
+ CommandTower::Errors::Account::PushEndpointNotFoundError,
27
28
  CommandTower::Errors::ValidationError
28
29
  :unprocessable_entity
29
30
  when CommandTower::Errors::Account::PhoneVerificationThrottledError
30
31
  :too_many_requests
31
32
  when CommandTower::Errors::Account::SmsCapabilityUnavailableError,
32
- CommandTower::Errors::Account::PushoverCapabilityUnavailableError
33
+ CommandTower::Errors::Account::PushoverCapabilityUnavailableError,
34
+ CommandTower::Errors::Account::PushCapabilityUnavailableError
33
35
  :service_unavailable
34
36
  when CommandTower::Errors::Account::PhoneVerificationSendFailedError,
35
37
  CommandTower::Errors::Account::PushoverProviderUnavailableError
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Workflows
5
+ module Me
6
+ module Push
7
+ class CreateWorkflow < CommandTower::Workflows::ApplicationWorkflow
8
+ retry_strategy :none
9
+
10
+ def call(current_user:, address:, auth_context: nil)
11
+ unless CommandTower::Services::Me::PushProductGate.enabled?
12
+ return failure(**WorkflowSupport.capability_failure)
13
+ end
14
+
15
+ result = CommandTower::Services::Account::Push::Create.call(
16
+ user: current_user,
17
+ address:,
18
+ )
19
+ unless result.success?
20
+ error = result.errors.first
21
+ return failure(
22
+ errors: result.errors,
23
+ http_status: CommandTower::Workflows::Me::ErrorMapping.http_status_for(error),
24
+ )
25
+ end
26
+
27
+ success(
28
+ payload: WorkflowSupport.serialize_view(result.data[:safe_view]),
29
+ http_status: :ok,
30
+ response_effects: WorkflowSupport.expire_header_effects(auth_context),
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Workflows
5
+ module Me
6
+ module Push
7
+ class DestroyWorkflow < CommandTower::Workflows::ApplicationWorkflow
8
+ retry_strategy :none
9
+
10
+ def call(current_user:, endpoint_id:, auth_context: nil)
11
+ unless CommandTower::Services::Me::PushProductGate.enabled?
12
+ return failure(**WorkflowSupport.capability_failure)
13
+ end
14
+
15
+ result = CommandTower::Services::Account::Push::Destroy.call(
16
+ user: current_user,
17
+ endpoint_id:,
18
+ )
19
+ unless result.success?
20
+ error = result.errors.first
21
+ return failure(
22
+ errors: result.errors,
23
+ http_status: CommandTower::Workflows::Me::ErrorMapping.http_status_for(error),
24
+ )
25
+ end
26
+
27
+ success(
28
+ payload: WorkflowSupport.serialize_view(result.data[:safe_view]),
29
+ http_status: :ok,
30
+ response_effects: WorkflowSupport.expire_header_effects(auth_context),
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Workflows
5
+ module Me
6
+ module Push
7
+ class IndexWorkflow < CommandTower::Workflows::ApplicationWorkflow
8
+ retry_strategy :none
9
+
10
+ def call(current_user:, auth_context: nil)
11
+ unless CommandTower::Services::Me::PushProductGate.enabled?
12
+ return failure(**WorkflowSupport.capability_failure)
13
+ end
14
+
15
+ result = CommandTower::Services::Account::Push::List.call(user: current_user)
16
+ unless result.success?
17
+ error = result.errors.first
18
+ return failure(
19
+ errors: result.errors,
20
+ http_status: CommandTower::Workflows::Me::ErrorMapping.http_status_for(error),
21
+ )
22
+ end
23
+
24
+ success(
25
+ payload: WorkflowSupport.serialize_collection(result.data[:safe_views]),
26
+ http_status: :ok,
27
+ response_effects: WorkflowSupport.expire_header_effects(auth_context),
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Workflows
5
+ module Me
6
+ module Push
7
+ class ReplaceWorkflow < CommandTower::Workflows::ApplicationWorkflow
8
+ retry_strategy :none
9
+
10
+ def call(current_user:, endpoint_id:, address:, auth_context: nil)
11
+ unless CommandTower::Services::Me::PushProductGate.enabled?
12
+ return failure(**WorkflowSupport.capability_failure)
13
+ end
14
+
15
+ result = CommandTower::Services::Account::Push::Replace.call(
16
+ user: current_user,
17
+ endpoint_id:,
18
+ address:,
19
+ )
20
+ unless result.success?
21
+ error = result.errors.first
22
+ return failure(
23
+ errors: result.errors,
24
+ http_status: CommandTower::Workflows::Me::ErrorMapping.http_status_for(error),
25
+ )
26
+ end
27
+
28
+ success(
29
+ payload: WorkflowSupport.serialize_view(result.data[:safe_view]),
30
+ http_status: :ok,
31
+ response_effects: WorkflowSupport.expire_header_effects(auth_context),
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Workflows
5
+ module Me
6
+ module Push
7
+ module WorkflowSupport
8
+ module_function
9
+
10
+ def capability_failure
11
+ error = CommandTower::Errors::Account::PushCapabilityUnavailableError.new
12
+ {
13
+ errors: [error],
14
+ http_status: CommandTower::Workflows::Me::ErrorMapping.http_status_for(error),
15
+ }
16
+ end
17
+
18
+ def expire_header_effects(auth_context)
19
+ return if auth_context.nil?
20
+
21
+ { set_expire_header: auth_context.token_expires_at }
22
+ end
23
+
24
+ def serialize_view(safe_view)
25
+ CommandTower::Serializers::Me::PushSerializer.serialize(safe_view)
26
+ end
27
+
28
+ def serialize_collection(safe_views)
29
+ CommandTower::Serializers::Me::PushSerializer.serialize_collection(safe_views)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -114,6 +114,7 @@ module CommandTower
114
114
  channel_key: delivery.channel_key,
115
115
  attempt_id: attempt.id,
116
116
  rendered:,
117
+ eligible_endpoint_ids: Array(readiness.eligible_endpoint_ids),
117
118
  )
118
119
  adapter = CommandTower::Messaging::Execution::SelectAdapter.call(delivery:, executor:)
119
120
  CommandTower::Messaging::Execution::OperationLogger.adapter_started(
data/config/routes.rb CHANGED
@@ -100,6 +100,15 @@ CommandTower::Engine.routes.draw do
100
100
  put "pushover", to: "pushover#update"
101
101
  delete "pushover", to: "pushover#destroy"
102
102
  post "pushover/verification", to: "pushover/verifications#create"
103
+
104
+ # Expo push lifecycle always drawn (no route constraints).
105
+ # Product readiness is workflow-gated as 503 push_capability_unavailable.
106
+ # Collection JSON (multi-active). No verification POST — create/replace mark_verified.
107
+ get "push", to: "push#index"
108
+ post "push", to: "push#create"
109
+ patch "push/:id", to: "push#update"
110
+ put "push/:id", to: "push#update"
111
+ delete "push/:id", to: "push#destroy"
103
112
  end
104
113
 
105
114
  namespace :admin do