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.
- checksums.yaml +4 -4
- data/app/controllers/command_tower/me/push_controller.rb +74 -0
- data/app/deserializers/command_tower/deserializers/me/push/token_deserializer.rb +42 -0
- data/app/errors/command_tower/errors/account/push_capability_unavailable_error.rb +21 -0
- data/app/errors/command_tower/errors/account/push_endpoint_not_found_error.rb +21 -0
- data/app/errors/command_tower/errors/account/push_test_no_endpoint_error.rb +21 -0
- data/app/errors/command_tower/errors/account/push_test_rate_limit_error.rb +25 -0
- data/app/serializers/command_tower/serializers/me/push_serializer.rb +47 -0
- data/app/services/command_tower/messaging/channel_detectors.rb +6 -0
- data/app/services/command_tower/messaging/endpoints/validators/push.rb +6 -0
- data/app/services/command_tower/messaging/execution/adapter_request.rb +21 -3
- data/app/services/command_tower/messaging/execution/adapters/expo/adapter.rb +229 -0
- data/app/services/command_tower/messaging/execution/adapters/expo/configuration.rb +42 -0
- data/app/services/command_tower/messaging/execution/adapters/expo/http_client.rb +137 -0
- data/app/services/command_tower/messaging/execution/resolve_recipient_address.rb +7 -0
- data/app/services/command_tower/messaging/execution/select_adapter.rb +4 -0
- data/app/services/command_tower/messaging/rendering/channel_renderer.rb +21 -3
- data/app/services/command_tower/messaging/rendering/rendered_push_payload.rb +50 -0
- data/app/services/command_tower/services/account/push/check_self_test_rate_limit.rb +41 -0
- data/app/services/command_tower/services/account/push/create.rb +25 -0
- data/app/services/command_tower/services/account/push/ct_support.rb +38 -0
- data/app/services/command_tower/services/account/push/destroy.rb +23 -0
- data/app/services/command_tower/services/account/push/list.rb +23 -0
- data/app/services/command_tower/services/account/push/replace.rb +26 -0
- data/app/services/command_tower/services/account/push/send_self_test.rb +57 -0
- data/app/services/command_tower/services/me/push_product_gate.rb +18 -0
- data/app/views/command_tower/messaging/rendering/push.text.erb +1 -0
- data/app/workflows/command_tower/workflows/me/error_mapping.rb +6 -2
- data/app/workflows/command_tower/workflows/me/push/create_workflow.rb +37 -0
- data/app/workflows/command_tower/workflows/me/push/destroy_workflow.rb +37 -0
- data/app/workflows/command_tower/workflows/me/push/index_workflow.rb +34 -0
- data/app/workflows/command_tower/workflows/me/push/replace_workflow.rb +38 -0
- data/app/workflows/command_tower/workflows/me/push/test_workflow.rb +54 -0
- data/app/workflows/command_tower/workflows/me/push/workflow_support.rb +35 -0
- data/app/workflows/command_tower/workflows/messaging/execution/deliver_workflow.rb +1 -0
- data/config/routes.rb +11 -0
- data/docs/api_reference.md +21 -0
- data/docs/controllers.md +1 -0
- data/docs/messaging_integration_guide.md +8 -3
- data/docs/upgrades/0.14.0.md +31 -0
- data/docs/upgrades/0.15.0.md +32 -0
- data/docs/upgrades/README.md +2 -0
- data/lib/command_tower/authorization/default.yml +8 -0
- data/lib/command_tower/configuration/messaging/config.rb +6 -0
- data/lib/command_tower/configuration/messaging/expo.rb +51 -0
- data/lib/command_tower/version.rb +1 -1
- 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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= body %>
|
|
@@ -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
|