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.
- checksums.yaml +4 -4
- data/app/controllers/command_tower/me/push_controller.rb +66 -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/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/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/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 +3 -1
- 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/workflow_support.rb +35 -0
- data/app/workflows/command_tower/workflows/messaging/execution/deliver_workflow.rb +1 -0
- data/config/routes.rb +9 -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/README.md +1 -0
- data/lib/command_tower/authorization/default.yml +7 -0
- data/lib/command_tower/configuration/messaging/config.rb +6 -0
- data/lib/command_tower/configuration/messaging/expo.rb +38 -0
- data/lib/command_tower/version.rb +1 -1
- 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
|
-
|
|
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,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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= body %>
|
|
@@ -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
|