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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bce961e0a6770bd17a23396e3fdb5066655dbe937f91c85b00c4605802c48f7d
|
|
4
|
+
data.tar.gz: e5247dda098ad94825317ec31cfff34c1fa060aac6fcf432ca5a082f93c83e02
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dd5117447c0bf551b4b55e3a402fbda78a915c8bd0e31a2fbc10a785ef747cc9c6b7c5c8e3aa5306978872fd89910233fb67a69563a3dc1c5105fe06ce67834b
|
|
7
|
+
data.tar.gz: bb8d35c52ca0e32b44134015a81462350357fa5e678e3e3bce0daa11a3f76c2e14ff7d2e0731f8ddea812185f579df464886e01464670e68a375490d21528b03
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Me
|
|
5
|
+
class PushController < CommandTower::ApplicationController
|
|
6
|
+
include CommandTower::Auth::AuthenticationBoundary
|
|
7
|
+
include CommandTower::Auth::AuthorizationBoundary
|
|
8
|
+
|
|
9
|
+
before_action :authenticate_request!
|
|
10
|
+
before_action :authorize_request!
|
|
11
|
+
|
|
12
|
+
def index
|
|
13
|
+
result = CommandTower::Workflows::Me::Push::IndexWorkflow.call(
|
|
14
|
+
current_user: current_user,
|
|
15
|
+
auth_context: current_auth_context,
|
|
16
|
+
)
|
|
17
|
+
render_application_result(result)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create
|
|
21
|
+
deserialized = CommandTower::Deserializers::Me::Push::TokenDeserializer.call(params)
|
|
22
|
+
return render_deserializer_errors unless deserialized.success?
|
|
23
|
+
|
|
24
|
+
result = CommandTower::Workflows::Me::Push::CreateWorkflow.call(
|
|
25
|
+
current_user: current_user,
|
|
26
|
+
address: deserialized.input.address,
|
|
27
|
+
auth_context: current_auth_context,
|
|
28
|
+
)
|
|
29
|
+
render_application_result(result)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def update
|
|
33
|
+
deserialized = CommandTower::Deserializers::Me::Push::TokenDeserializer.call(params)
|
|
34
|
+
return render_deserializer_errors unless deserialized.success?
|
|
35
|
+
|
|
36
|
+
result = CommandTower::Workflows::Me::Push::ReplaceWorkflow.call(
|
|
37
|
+
current_user: current_user,
|
|
38
|
+
endpoint_id: params[:id],
|
|
39
|
+
address: deserialized.input.address,
|
|
40
|
+
auth_context: current_auth_context,
|
|
41
|
+
)
|
|
42
|
+
render_application_result(result)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def destroy
|
|
46
|
+
result = CommandTower::Workflows::Me::Push::DestroyWorkflow.call(
|
|
47
|
+
current_user: current_user,
|
|
48
|
+
endpoint_id: params[:id],
|
|
49
|
+
auth_context: current_auth_context,
|
|
50
|
+
)
|
|
51
|
+
render_application_result(result)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def render_deserializer_errors
|
|
57
|
+
render_application_result(
|
|
58
|
+
CommandTower::Workflows::WorkflowResult.failure(
|
|
59
|
+
errors: [CommandTower::Errors::ValidationError.new(details: { base: "Missing or invalid push token" })],
|
|
60
|
+
http_status: :unprocessable_entity,
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Deserializers
|
|
5
|
+
module Me
|
|
6
|
+
module Push
|
|
7
|
+
class TokenDeserializer < CommandTower::Deserializers::ApplicationDeserializer
|
|
8
|
+
Input = Data.define(:address)
|
|
9
|
+
|
|
10
|
+
EXPO_TOKEN = CommandTower::Messaging::Endpoints::Validators::Push::EXPO_TOKEN
|
|
11
|
+
|
|
12
|
+
def call(params)
|
|
13
|
+
address = extract(params, :token, :Token, :address, :Address)
|
|
14
|
+
|
|
15
|
+
if address.blank?
|
|
16
|
+
return failure(errors: { message: "missing_required_fields" })
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
unless address.match?(EXPO_TOKEN)
|
|
20
|
+
return failure(errors: { message: "invalid_push_token" })
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
success(Input.new(address:))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def extract(params, *keys)
|
|
29
|
+
keys.each do |key|
|
|
30
|
+
raw = params[key] || params[key.to_s]
|
|
31
|
+
next if raw.nil?
|
|
32
|
+
|
|
33
|
+
value = raw.to_s.strip
|
|
34
|
+
return value unless value.empty?
|
|
35
|
+
end
|
|
36
|
+
""
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Errors
|
|
5
|
+
module Account
|
|
6
|
+
class PushCapabilityUnavailableError < CommandTower::Errors::ApplicationError
|
|
7
|
+
def code
|
|
8
|
+
"push_capability_unavailable"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def message
|
|
12
|
+
"Push notifications are currently unavailable"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def log_level
|
|
16
|
+
:warn
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Errors
|
|
5
|
+
module Account
|
|
6
|
+
class PushEndpointNotFoundError < CommandTower::Errors::ApplicationError
|
|
7
|
+
def code
|
|
8
|
+
"push_endpoint_not_found"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def message
|
|
12
|
+
"Push endpoint was not found"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def log_level
|
|
16
|
+
:info
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Serializers
|
|
5
|
+
module Me
|
|
6
|
+
class PushSerializer
|
|
7
|
+
def self.serialize(safe_view)
|
|
8
|
+
new(safe_view).serialize
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.serialize_collection(safe_views)
|
|
12
|
+
{
|
|
13
|
+
endpoints: Array(safe_views).map { |view| serialize(view) },
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(safe_view)
|
|
18
|
+
@safe_view = safe_view
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def serialize
|
|
22
|
+
{
|
|
23
|
+
id: @safe_view.id,
|
|
24
|
+
channelKey: @safe_view.channel_key,
|
|
25
|
+
lifecycleState: @safe_view.lifecycle_state,
|
|
26
|
+
verificationState: @safe_view.verification_state,
|
|
27
|
+
maskedDisplayValue: @safe_view.masked_display_value,
|
|
28
|
+
verifiedAt: @safe_view.verified_at&.iso8601,
|
|
29
|
+
createdAt: @safe_view.created_at&.iso8601,
|
|
30
|
+
updatedAt: @safe_view.updated_at&.iso8601,
|
|
31
|
+
actions: actions_for(@safe_view),
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def actions_for(view)
|
|
38
|
+
active = view.lifecycle_state.to_s == "active"
|
|
39
|
+
{
|
|
40
|
+
canReplace: active,
|
|
41
|
+
canRemove: active,
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -26,6 +26,10 @@ module CommandTower
|
|
|
26
26
|
Execution::Adapters::Pushover::Configuration.pushover_configured?
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def expo_configured?
|
|
30
|
+
Execution::Adapters::Expo::Configuration.expo_configured?
|
|
31
|
+
end
|
|
32
|
+
|
|
29
33
|
# Used by RecipientReadiness#platform_configured_for? (fail-closed).
|
|
30
34
|
def configured?(channel_key)
|
|
31
35
|
case channel_key.to_s
|
|
@@ -37,6 +41,8 @@ module CommandTower
|
|
|
37
41
|
sms_configured?
|
|
38
42
|
when "pushover"
|
|
39
43
|
pushover_configured?
|
|
44
|
+
when "push"
|
|
45
|
+
expo_configured?
|
|
40
46
|
else
|
|
41
47
|
false
|
|
42
48
|
end
|
|
@@ -7,10 +7,16 @@ module CommandTower
|
|
|
7
7
|
module Push
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
+
# Expo push token forms used by expo-notifications / Expo Push API.
|
|
11
|
+
EXPO_TOKEN = /\A(ExponentPushToken|ExpoPushToken)\[[^\]]+\]\z/
|
|
12
|
+
|
|
10
13
|
def validate!(address)
|
|
11
14
|
token = address.to_s.strip
|
|
12
15
|
raise ValidationError, "push token must be present" if token.empty?
|
|
13
16
|
raise ValidationError, "push token is too short" if token.length < 8
|
|
17
|
+
unless token.match?(EXPO_TOKEN)
|
|
18
|
+
raise ValidationError, "push token must be an Expo push token (ExponentPushToken[...] or ExpoPushToken[...])"
|
|
19
|
+
end
|
|
14
20
|
|
|
15
21
|
Result.new(
|
|
16
22
|
normalized_address: token,
|
|
@@ -9,8 +9,16 @@ module CommandTower
|
|
|
9
9
|
:channel_key,
|
|
10
10
|
:attempt_id,
|
|
11
11
|
:rendered,
|
|
12
|
+
:eligible_endpoint_ids,
|
|
12
13
|
) do
|
|
13
|
-
def self.build(
|
|
14
|
+
def self.build(
|
|
15
|
+
channel_delivery_id:,
|
|
16
|
+
communication_id:,
|
|
17
|
+
channel_key:,
|
|
18
|
+
attempt_id:,
|
|
19
|
+
rendered:,
|
|
20
|
+
eligible_endpoint_ids: []
|
|
21
|
+
)
|
|
14
22
|
raise InvalidAdapterContractError, "arbitrary hashes are not accepted" if [channel_delivery_id, communication_id, channel_key, attempt_id].any? { |v| v.is_a?(Hash) }
|
|
15
23
|
raise InvalidAdapterContractError, "channel_delivery_id is required" if blank?(channel_delivery_id)
|
|
16
24
|
raise InvalidAdapterContractError, "communication_id is required" if blank?(communication_id)
|
|
@@ -20,11 +28,20 @@ module CommandTower
|
|
|
20
28
|
raise InvalidAdapterContractError, "rendered is required" if rendered.nil?
|
|
21
29
|
unless rendered.is_a?(Rendering::RenderedPayload) ||
|
|
22
30
|
rendered.is_a?(Rendering::RenderedSmsPayload) ||
|
|
23
|
-
rendered.is_a?(Rendering::RenderedPushoverPayload)
|
|
31
|
+
rendered.is_a?(Rendering::RenderedPushoverPayload) ||
|
|
32
|
+
rendered.is_a?(Rendering::RenderedPushPayload)
|
|
24
33
|
raise InvalidAdapterContractError,
|
|
25
|
-
"rendered must be a RenderedPayload, RenderedSmsPayload, or
|
|
34
|
+
"rendered must be a RenderedPayload, RenderedSmsPayload, RenderedPushoverPayload, or RenderedPushPayload"
|
|
26
35
|
end
|
|
27
36
|
raise InvalidAdapterContractError, "rendered must be frozen" unless rendered.frozen?
|
|
37
|
+
raise InvalidAdapterContractError, "eligible_endpoint_ids must be an Array" unless eligible_endpoint_ids.is_a?(Array)
|
|
38
|
+
|
|
39
|
+
ids = eligible_endpoint_ids.map do |id|
|
|
40
|
+
integer_id = Integer(id, exception: false)
|
|
41
|
+
raise InvalidAdapterContractError, "eligible_endpoint_ids must be Integers" if integer_id.nil?
|
|
42
|
+
|
|
43
|
+
integer_id
|
|
44
|
+
end
|
|
28
45
|
|
|
29
46
|
new(
|
|
30
47
|
channel_delivery_id:,
|
|
@@ -32,6 +49,7 @@ module CommandTower
|
|
|
32
49
|
channel_key:,
|
|
33
50
|
attempt_id:,
|
|
34
51
|
rendered:,
|
|
52
|
+
eligible_endpoint_ids: ids.freeze,
|
|
35
53
|
).freeze
|
|
36
54
|
end
|
|
37
55
|
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Messaging
|
|
5
|
+
module Execution
|
|
6
|
+
module Adapters
|
|
7
|
+
module Expo
|
|
8
|
+
# Execution adapter: fans out to eligible push endpoints, sends via Expo
|
|
9
|
+
# Push API, fetches receipts once for ok tickets, and marks invalid devices.
|
|
10
|
+
class Adapter
|
|
11
|
+
DEVICE_INVALID_ERRORS = %w[DeviceNotRegistered].freeze
|
|
12
|
+
|
|
13
|
+
EndpointAttempt = Struct.new(
|
|
14
|
+
:endpoint_id,
|
|
15
|
+
:token,
|
|
16
|
+
:ticket,
|
|
17
|
+
:receipt,
|
|
18
|
+
:outcome,
|
|
19
|
+
:error_code,
|
|
20
|
+
keyword_init: true,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def initialize(configuration: nil, http_client: nil, secret_reader: nil)
|
|
24
|
+
@configuration = configuration || Configuration.new
|
|
25
|
+
@http_client = http_client || HttpClient.new(configuration: @configuration)
|
|
26
|
+
@secret_reader = secret_reader || Endpoints::SecretReader
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(request:)
|
|
30
|
+
raise ArgumentError, "request must be an AdapterRequest" unless request.is_a?(AdapterRequest)
|
|
31
|
+
|
|
32
|
+
rendered = request.rendered
|
|
33
|
+
unless rendered.is_a?(Rendering::RenderedPushPayload)
|
|
34
|
+
return AdapterResult.build(outcome: :terminal_failure, error_code: "render_failed")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
unless @configuration.expo_configured?
|
|
38
|
+
return AdapterResult.build(outcome: :terminal_failure, error_code: "adapter_unconfigured")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
endpoint_ids = Array(request.eligible_endpoint_ids)
|
|
42
|
+
if endpoint_ids.empty?
|
|
43
|
+
return AdapterResult.build(outcome: :terminal_failure, error_code: "recipient_missing")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
delivery = Messaging::ChannelDelivery.includes(:communication).find_by(id: request.channel_delivery_id)
|
|
47
|
+
owner_user_id = delivery&.communication&.user_id
|
|
48
|
+
if owner_user_id.nil?
|
|
49
|
+
return AdapterResult.build(outcome: :terminal_failure, error_code: "recipient_missing")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
attempts = build_endpoint_attempts(owner_user_id:, endpoint_ids:)
|
|
53
|
+
return attempts if attempts.is_a?(AdapterResult)
|
|
54
|
+
|
|
55
|
+
case @configuration.adapter_name
|
|
56
|
+
when "fake", "log"
|
|
57
|
+
AdapterResult.build(outcome: :success, normalized_provider_status: "accepted")
|
|
58
|
+
when "http"
|
|
59
|
+
deliver_http(attempts:, rendered:, owner_user_id:)
|
|
60
|
+
else
|
|
61
|
+
AdapterResult.build(outcome: :terminal_failure, error_code: "adapter_unconfigured")
|
|
62
|
+
end
|
|
63
|
+
rescue StandardError
|
|
64
|
+
AdapterResult.build(outcome: :retryable_failure, error_code: "expo_transient")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def build_endpoint_attempts(owner_user_id:, endpoint_ids:)
|
|
70
|
+
attempts = endpoint_ids.map do |endpoint_id|
|
|
71
|
+
token = @secret_reader.read!(owner_user_id:, endpoint_id:)
|
|
72
|
+
EndpointAttempt.new(endpoint_id:, token:, outcome: nil)
|
|
73
|
+
rescue Endpoints::NotFoundError, Endpoints::ValidationError
|
|
74
|
+
EndpointAttempt.new(
|
|
75
|
+
endpoint_id:,
|
|
76
|
+
token: nil,
|
|
77
|
+
outcome: :terminal_failure,
|
|
78
|
+
error_code: "recipient_missing",
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if attempts.all? { |attempt| attempt.outcome == :terminal_failure }
|
|
83
|
+
return AdapterResult.build(outcome: :terminal_failure, error_code: "recipient_missing")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
attempts
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def deliver_http(attempts:, rendered:, owner_user_id:)
|
|
90
|
+
sendable = attempts.select { |attempt| attempt.token.present? && attempt.outcome.nil? }
|
|
91
|
+
messages = sendable.map { |attempt| expo_message(token: attempt.token, rendered:) }
|
|
92
|
+
|
|
93
|
+
send_result = @http_client.send_messages(messages)
|
|
94
|
+
unless send_result[:ok]
|
|
95
|
+
return map_http_failure(send_result[:status_code])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
tickets = Array(send_result[:tickets])
|
|
99
|
+
sendable.each_with_index do |attempt, index|
|
|
100
|
+
ticket = tickets[index]
|
|
101
|
+
attempt.ticket = ticket
|
|
102
|
+
apply_ticket_outcome!(attempt)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
ok_ticket_ids = sendable.filter_map { |attempt| attempt.ticket&.dig(:id) if attempt.ticket&.dig(:status) == "ok" }
|
|
106
|
+
if ok_ticket_ids.any?
|
|
107
|
+
receipts_result = @http_client.get_receipts(ok_ticket_ids)
|
|
108
|
+
if receipts_result[:ok]
|
|
109
|
+
receipts = receipts_result[:receipts] || {}
|
|
110
|
+
sendable.each do |attempt|
|
|
111
|
+
ticket_id = attempt.ticket&.dig(:id)
|
|
112
|
+
next if ticket_id.nil?
|
|
113
|
+
next unless attempt.ticket&.dig(:status) == "ok"
|
|
114
|
+
|
|
115
|
+
receipt = receipts[ticket_id]
|
|
116
|
+
attempt.receipt = receipt
|
|
117
|
+
apply_receipt_outcome!(attempt)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
# Missing or failed receipt fetch: ticket ok without receipt stays success (plan).
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
sendable.each do |attempt|
|
|
124
|
+
mark_invalid_if_needed!(owner_user_id:, attempt:)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
aggregate_result(attempts)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def expo_message(token:, rendered:)
|
|
131
|
+
message = {
|
|
132
|
+
"to" => token,
|
|
133
|
+
"title" => rendered.title,
|
|
134
|
+
"body" => rendered.body,
|
|
135
|
+
"sound" => "default",
|
|
136
|
+
}
|
|
137
|
+
if rendered.deep_link.present?
|
|
138
|
+
message["data"] = { "deepLink" => rendered.deep_link }
|
|
139
|
+
end
|
|
140
|
+
message
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def apply_ticket_outcome!(attempt)
|
|
144
|
+
ticket = attempt.ticket
|
|
145
|
+
if ticket.nil?
|
|
146
|
+
attempt.outcome = :retryable_failure
|
|
147
|
+
attempt.error_code = "expo_transient"
|
|
148
|
+
return
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
case ticket[:status]
|
|
152
|
+
when "ok"
|
|
153
|
+
attempt.outcome = :success
|
|
154
|
+
when "error"
|
|
155
|
+
if device_invalid_error?(ticket[:error])
|
|
156
|
+
attempt.outcome = :terminal_failure
|
|
157
|
+
attempt.error_code = "expo_device_invalid"
|
|
158
|
+
else
|
|
159
|
+
attempt.outcome = :terminal_failure
|
|
160
|
+
attempt.error_code = "expo_rejected"
|
|
161
|
+
end
|
|
162
|
+
else
|
|
163
|
+
attempt.outcome = :retryable_failure
|
|
164
|
+
attempt.error_code = "expo_transient"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def apply_receipt_outcome!(attempt)
|
|
169
|
+
receipt = attempt.receipt
|
|
170
|
+
return if receipt.nil? # pending / missing → keep ticket success
|
|
171
|
+
|
|
172
|
+
case receipt[:status]
|
|
173
|
+
when "ok"
|
|
174
|
+
attempt.outcome = :success
|
|
175
|
+
when "error"
|
|
176
|
+
if device_invalid_error?(receipt[:error])
|
|
177
|
+
attempt.outcome = :terminal_failure
|
|
178
|
+
attempt.error_code = "expo_device_invalid"
|
|
179
|
+
else
|
|
180
|
+
attempt.outcome = :terminal_failure
|
|
181
|
+
attempt.error_code = "expo_rejected"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def device_invalid_error?(error)
|
|
187
|
+
DEVICE_INVALID_ERRORS.include?(error.to_s)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def mark_invalid_if_needed!(owner_user_id:, attempt:)
|
|
191
|
+
return unless attempt.error_code == "expo_device_invalid"
|
|
192
|
+
|
|
193
|
+
Endpoints.mark_invalid(owner_user_id:, endpoint_id: attempt.endpoint_id)
|
|
194
|
+
rescue StandardError
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def map_http_failure(status_code)
|
|
199
|
+
if status_code.to_i >= 500 || status_code.to_i == 429
|
|
200
|
+
AdapterResult.build(outcome: :retryable_failure, error_code: "expo_transient")
|
|
201
|
+
else
|
|
202
|
+
AdapterResult.build(outcome: :terminal_failure, error_code: "expo_rejected")
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def aggregate_result(attempts)
|
|
207
|
+
successes = attempts.select { |attempt| attempt.outcome == :success }
|
|
208
|
+
if successes.any?
|
|
209
|
+
first_ticket_id = successes.filter_map { |attempt| attempt.ticket&.dig(:id) }.first
|
|
210
|
+
return AdapterResult.build(
|
|
211
|
+
outcome: :success,
|
|
212
|
+
normalized_provider_status: "accepted",
|
|
213
|
+
provider_message_id: first_ticket_id,
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if attempts.all? { |attempt| attempt.outcome == :terminal_failure }
|
|
218
|
+
error_code = attempts.map(&:error_code).compact.first || "expo_rejected"
|
|
219
|
+
return AdapterResult.build(outcome: :terminal_failure, error_code:)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
AdapterResult.build(outcome: :retryable_failure, error_code: "expo_transient")
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Messaging
|
|
5
|
+
module Execution
|
|
6
|
+
module Adapters
|
|
7
|
+
module Expo
|
|
8
|
+
# Platform readiness detector for Expo Push delivery.
|
|
9
|
+
# Ready when transport adapter is fake, log, or http — disabled never counts.
|
|
10
|
+
# http does not require access_token (Expo default); Bearer is optional.
|
|
11
|
+
class Configuration
|
|
12
|
+
CONFIGURED_ADAPTERS = %w[fake log http].freeze
|
|
13
|
+
|
|
14
|
+
def self.expo_configured?
|
|
15
|
+
new.expo_configured?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def expo_configured?
|
|
19
|
+
CONFIGURED_ADAPTERS.include?(adapter_name)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def adapter_name
|
|
23
|
+
CommandTower.config.messaging.expo.adapter.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def api_base_url
|
|
27
|
+
CommandTower.config.messaging.expo.api_base_url.to_s.strip
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def timeout_seconds
|
|
31
|
+
CommandTower.config.messaging.expo.timeout_seconds
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def access_token
|
|
35
|
+
CommandTower.config.messaging.expo.access_token.to_s.strip
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|