command_tower 0.13.1 → 0.15.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/controllers/command_tower/me/push_controller.rb +74 -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/errors/command_tower/errors/account/push_test_no_endpoint_error.rb +21 -0
  7. data/app/errors/command_tower/errors/account/push_test_rate_limit_error.rb +25 -0
  8. data/app/serializers/command_tower/serializers/me/push_serializer.rb +47 -0
  9. data/app/services/command_tower/messaging/channel_detectors.rb +6 -0
  10. data/app/services/command_tower/messaging/endpoints/validators/push.rb +6 -0
  11. data/app/services/command_tower/messaging/execution/adapter_request.rb +21 -3
  12. data/app/services/command_tower/messaging/execution/adapters/expo/adapter.rb +229 -0
  13. data/app/services/command_tower/messaging/execution/adapters/expo/configuration.rb +42 -0
  14. data/app/services/command_tower/messaging/execution/adapters/expo/http_client.rb +137 -0
  15. data/app/services/command_tower/messaging/execution/resolve_recipient_address.rb +7 -0
  16. data/app/services/command_tower/messaging/execution/select_adapter.rb +4 -0
  17. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +21 -3
  18. data/app/services/command_tower/messaging/rendering/rendered_push_payload.rb +50 -0
  19. data/app/services/command_tower/services/account/push/check_self_test_rate_limit.rb +41 -0
  20. data/app/services/command_tower/services/account/push/create.rb +25 -0
  21. data/app/services/command_tower/services/account/push/ct_support.rb +38 -0
  22. data/app/services/command_tower/services/account/push/destroy.rb +23 -0
  23. data/app/services/command_tower/services/account/push/list.rb +23 -0
  24. data/app/services/command_tower/services/account/push/replace.rb +26 -0
  25. data/app/services/command_tower/services/account/push/send_self_test.rb +57 -0
  26. data/app/services/command_tower/services/me/push_product_gate.rb +18 -0
  27. data/app/views/command_tower/messaging/rendering/push.text.erb +1 -0
  28. data/app/workflows/command_tower/workflows/me/error_mapping.rb +6 -2
  29. data/app/workflows/command_tower/workflows/me/push/create_workflow.rb +37 -0
  30. data/app/workflows/command_tower/workflows/me/push/destroy_workflow.rb +37 -0
  31. data/app/workflows/command_tower/workflows/me/push/index_workflow.rb +34 -0
  32. data/app/workflows/command_tower/workflows/me/push/replace_workflow.rb +38 -0
  33. data/app/workflows/command_tower/workflows/me/push/test_workflow.rb +54 -0
  34. data/app/workflows/command_tower/workflows/me/push/workflow_support.rb +35 -0
  35. data/app/workflows/command_tower/workflows/messaging/execution/deliver_workflow.rb +1 -0
  36. data/config/routes.rb +11 -0
  37. data/docs/api_reference.md +21 -0
  38. data/docs/controllers.md +1 -0
  39. data/docs/messaging_integration_guide.md +8 -3
  40. data/docs/upgrades/0.14.0.md +31 -0
  41. data/docs/upgrades/0.15.0.md +32 -0
  42. data/docs/upgrades/README.md +2 -0
  43. data/lib/command_tower/authorization/default.yml +8 -0
  44. data/lib/command_tower/configuration/messaging/config.rb +6 -0
  45. data/lib/command_tower/configuration/messaging/expo.rb +51 -0
  46. data/lib/command_tower/version.rb +1 -1
  47. metadata +31 -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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Account
6
+ module Push
7
+ # Enforces Me push self-test rate limits via existing RateLimits::Check.
8
+ # Ceiling and window come from config.messaging.expo composers — not hardcoded here.
9
+ class CheckSelfTestRateLimit < CommandTower::Services::ApplicationService
10
+ validate :user, is_a: User, required: true
11
+
12
+ def call
13
+ expo = CommandTower.config.messaging.expo
14
+ limit = expo.self_test_per_user_hour
15
+ ttl_seconds = expo.self_test_window_seconds
16
+
17
+ result = CommandTower::Services::RateLimits::Check.call(
18
+ key: "me:push:self_test:user:#{user.id}",
19
+ ttl_seconds:,
20
+ )
21
+ if result.failure?
22
+ context.fail!(application_error: result.errors.first)
23
+ return
24
+ end
25
+
26
+ count = result.data[:count]
27
+ return if count <= limit
28
+
29
+ ttl = result.data[:ttl]
30
+ retry_after = ttl.positive? ? ttl : nil
31
+ context.fail!(
32
+ application_error: CommandTower::Errors::Account::PushTestRateLimitError.new(
33
+ retry_after_seconds: retry_after,
34
+ ),
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ 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,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Account
6
+ module Push
7
+ # Me self-test: Produce a host-registered push_delivery_test communication.
8
+ # Does not bypass Messaging; does not talk to Expo directly.
9
+ class SendSelfTest < CommandTower::Services::ApplicationService
10
+ NOTIFICATION_TYPE_KEY = "push_delivery_test"
11
+ TITLE = "Push notifications"
12
+ BODY = "This is a test notification from your account."
13
+
14
+ validate :user, is_a: User, required: true
15
+
16
+ def call
17
+ list_result = CommandTower::Services::Account::Push::List.call(user:)
18
+ unless list_result.success?
19
+ context.fail!(application_error: list_result.errors.first)
20
+ return
21
+ end
22
+
23
+ if Array(list_result.data[:safe_views]).empty?
24
+ context.fail!(application_error: CommandTower::Errors::Account::PushTestNoEndpointError.new)
25
+ return
26
+ end
27
+
28
+ rate_result = CommandTower::Services::Account::Push::CheckSelfTestRateLimit.call(user:)
29
+ unless rate_result.success?
30
+ context.fail!(application_error: rate_result.errors.first)
31
+ return
32
+ end
33
+
34
+ produce_result = CommandTower::Services::Messaging::Communications::Produce.call(
35
+ user:,
36
+ notification_type_key: NOTIFICATION_TYPE_KEY,
37
+ host_event_identity: "push_delivery_test/#{user.id}/#{SecureRandom.uuid}",
38
+ title: TITLE,
39
+ body: BODY,
40
+ metadata: {},
41
+ platform_enabled_channels: CommandTower::Services::Messaging::Preferences::PlatformEnabledChannels.call,
42
+ )
43
+ unless produce_result.success?
44
+ context.fail!(application_error: produce_result.errors.first)
45
+ return
46
+ end
47
+
48
+ context.communication_id = produce_result.data[:communication_id]
49
+ context.destination_plan_id = produce_result.data[:destination_plan_id]
50
+ context.selected_channels = produce_result.data[:selected_channels]
51
+ context.communication_status = produce_result.data[:communication_status]
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ 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,16 @@ 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,
28
+ CommandTower::Errors::Account::PushTestNoEndpointError,
27
29
  CommandTower::Errors::ValidationError
28
30
  :unprocessable_entity
29
- when CommandTower::Errors::Account::PhoneVerificationThrottledError
31
+ when CommandTower::Errors::Account::PhoneVerificationThrottledError,
32
+ CommandTower::Errors::Account::PushTestRateLimitError
30
33
  :too_many_requests
31
34
  when CommandTower::Errors::Account::SmsCapabilityUnavailableError,
32
- CommandTower::Errors::Account::PushoverCapabilityUnavailableError
35
+ CommandTower::Errors::Account::PushoverCapabilityUnavailableError,
36
+ CommandTower::Errors::Account::PushCapabilityUnavailableError
33
37
  :service_unavailable
34
38
  when CommandTower::Errors::Account::PhoneVerificationSendFailedError,
35
39
  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