command_tower 0.14.0 → 0.16.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/experience_states_controller.rb +51 -0
- data/app/controllers/command_tower/me/push_controller.rb +8 -0
- data/app/deserializers/command_tower/deserializers/me/experience_states/complete_deserializer.rb +52 -0
- data/app/errors/command_tower/errors/account/experience_states_host_unconfigured_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/models/command_tower/user_experience_state.rb +17 -0
- data/app/serializers/command_tower/serializers/me/experience_states/experience_state_serializer.rb +36 -0
- data/app/services/command_tower/services/account/experience_states/complete.rb +55 -0
- data/app/services/command_tower/services/account/experience_states/list.rb +28 -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/send_self_test.rb +57 -0
- data/app/workflows/command_tower/workflows/me/error_mapping.rb +5 -2
- data/app/workflows/command_tower/workflows/me/experience_states/complete_workflow.rb +49 -0
- data/app/workflows/command_tower/workflows/me/experience_states/list_workflow.rb +30 -0
- data/app/workflows/command_tower/workflows/me/experience_states/workflow_support.rb +27 -0
- data/app/workflows/command_tower/workflows/me/push/test_workflow.rb +54 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20260906180000_create_user_experience_states.rb +25 -0
- data/docs/api_reference.md +21 -0
- data/docs/controllers.md +1 -0
- data/docs/host_integration_guide.md +2 -1
- data/docs/initializing.md +1 -0
- data/docs/upgrades/0.15.0.md +32 -0
- data/docs/upgrades/0.16.0.md +30 -0
- data/docs/upgrades/README.md +2 -0
- data/lib/command_tower/authorization/default.yml +6 -0
- data/lib/command_tower/configuration/application/config.rb +5 -0
- data/lib/command_tower/configuration/messaging/expo.rb +13 -0
- data/lib/command_tower/configuration/registry/audit/config.rb +12 -0
- data/lib/command_tower/install/baseline.rb +1 -0
- data/lib/command_tower/version.rb +1 -1
- data/spec/factories/user_experience_states.rb +13 -0
- metadata +21 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9e983f8f191ccb75948cdad73067d1cd43b7b1e5a8a3e0d34fc0130a76c313da
|
|
4
|
+
data.tar.gz: 5ef89ea100c06425afe3aee6f87b8b71503e4d28be6a4ff6e309af8a5b02c83f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6555727a8b33efc95493ecdeec48067e39c8ee9eb4f1750c8fd183599a8d41bf1e763d538d379d0be7888697e6f09ca0795ebb643817d5aea2c548c61cd867f6
|
|
7
|
+
data.tar.gz: ca51dafb697b03848b2509f16660df1e9e349f325a01da7d589300ac7db0ee087df33c08d46194a97e60ae6cbc984de8bcbf1fbc65f41d7bd31f31013d64add4
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Me
|
|
5
|
+
class ExperienceStatesController < 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::ExperienceStates::ListWorkflow.call(
|
|
14
|
+
current_user: current_user,
|
|
15
|
+
auth_context: current_auth_context,
|
|
16
|
+
)
|
|
17
|
+
render_application_result(result)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def complete
|
|
21
|
+
deserialized = CommandTower::Deserializers::Me::ExperienceStates::CompleteDeserializer.call(params)
|
|
22
|
+
return render_deserializer_errors unless deserialized.success?
|
|
23
|
+
|
|
24
|
+
result = CommandTower::Workflows::Me::ExperienceStates::CompleteWorkflow.call(
|
|
25
|
+
current_user: current_user,
|
|
26
|
+
experience_key: deserialized.input.experience_key,
|
|
27
|
+
scope_type: deserialized.input.scope_type,
|
|
28
|
+
scope_identifier: deserialized.input.scope_identifier,
|
|
29
|
+
version: deserialized.input.version,
|
|
30
|
+
auth_context: current_auth_context,
|
|
31
|
+
)
|
|
32
|
+
render_application_result(result)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def render_deserializer_errors
|
|
38
|
+
render_application_result(
|
|
39
|
+
CommandTower::Workflows::WorkflowResult.failure(
|
|
40
|
+
errors: [
|
|
41
|
+
CommandTower::Errors::ValidationError.new(
|
|
42
|
+
details: { base: "Missing or invalid experience state completion fields" },
|
|
43
|
+
),
|
|
44
|
+
],
|
|
45
|
+
http_status: :unprocessable_entity,
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -51,6 +51,14 @@ module CommandTower
|
|
|
51
51
|
render_application_result(result)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
def test
|
|
55
|
+
result = CommandTower::Workflows::Me::Push::TestWorkflow.call(
|
|
56
|
+
current_user: current_user,
|
|
57
|
+
auth_context: current_auth_context,
|
|
58
|
+
)
|
|
59
|
+
render_application_result(result)
|
|
60
|
+
end
|
|
61
|
+
|
|
54
62
|
private
|
|
55
63
|
|
|
56
64
|
def render_deserializer_errors
|
data/app/deserializers/command_tower/deserializers/me/experience_states/complete_deserializer.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Deserializers
|
|
5
|
+
module Me
|
|
6
|
+
module ExperienceStates
|
|
7
|
+
class CompleteDeserializer < CommandTower::Deserializers::ApplicationDeserializer
|
|
8
|
+
Input = Data.define(:experience_key, :scope_type, :scope_identifier, :version)
|
|
9
|
+
|
|
10
|
+
MAX_LENGTH = 128
|
|
11
|
+
|
|
12
|
+
def call(params)
|
|
13
|
+
experience_key = extract(params, :experienceKey, :experience_key)
|
|
14
|
+
scope_type = extract(params, :scopeType, :scope_type)
|
|
15
|
+
scope_identifier = extract(params, :scopeIdentifier, :scope_identifier)
|
|
16
|
+
version = extract(params, :version, :Version)
|
|
17
|
+
|
|
18
|
+
if [experience_key, scope_type, scope_identifier, version].any?(&:blank?)
|
|
19
|
+
return failure(errors: { message: "missing_required_fields" })
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if [experience_key, scope_type, scope_identifier, version].any? { |value| value.length > MAX_LENGTH }
|
|
23
|
+
return failure(errors: { message: "invalid_field_length" })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
success(
|
|
27
|
+
Input.new(
|
|
28
|
+
experience_key:,
|
|
29
|
+
scope_type:,
|
|
30
|
+
scope_identifier:,
|
|
31
|
+
version:,
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def extract(params, *keys)
|
|
39
|
+
keys.each do |key|
|
|
40
|
+
raw = params[key] || params[key.to_s]
|
|
41
|
+
next if raw.nil?
|
|
42
|
+
|
|
43
|
+
value = raw.to_s.strip
|
|
44
|
+
return value unless value.empty?
|
|
45
|
+
end
|
|
46
|
+
""
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Errors
|
|
5
|
+
module Account
|
|
6
|
+
class ExperienceStatesHostUnconfiguredError < CommandTower::Errors::ApplicationError
|
|
7
|
+
def code
|
|
8
|
+
"experience_states_host_unconfigured"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def message
|
|
12
|
+
"Experience states 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 PushTestNoEndpointError < CommandTower::Errors::ApplicationError
|
|
7
|
+
def code
|
|
8
|
+
"push_test_no_endpoint"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def message
|
|
12
|
+
"No active push endpoint is registered for this account"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def log_level
|
|
16
|
+
:warn
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Errors
|
|
5
|
+
module Account
|
|
6
|
+
class PushTestRateLimitError < CommandTower::Errors::ApplicationError
|
|
7
|
+
def initialize(retry_after_seconds: nil)
|
|
8
|
+
super(details: retry_after_seconds ? { retry_after_seconds: retry_after_seconds } : nil)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def code
|
|
12
|
+
"push_test_rate_limited"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def message
|
|
16
|
+
"Too many push test notifications. Please try again later."
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def log_level
|
|
20
|
+
:warn
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
class UserExperienceState < CommandTower::ApplicationRecord
|
|
5
|
+
self.table_name = "user_experience_states"
|
|
6
|
+
|
|
7
|
+
belongs_to :user
|
|
8
|
+
|
|
9
|
+
validates :host_key, :experience_key, :scope_type, :scope_identifier, :version, presence: true
|
|
10
|
+
validates :experience_key,
|
|
11
|
+
uniqueness: {
|
|
12
|
+
scope: %i[user_id host_key scope_type scope_identifier version],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
scope :for_user_and_host, ->(user:, host_key:) { where(user:, host_key:) }
|
|
16
|
+
end
|
|
17
|
+
end
|
data/app/serializers/command_tower/serializers/me/experience_states/experience_state_serializer.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Serializers
|
|
5
|
+
module Me
|
|
6
|
+
module ExperienceStates
|
|
7
|
+
class ExperienceStateSerializer
|
|
8
|
+
def self.serialize(state)
|
|
9
|
+
new(state).serialize
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.serialize_collection(states)
|
|
13
|
+
{
|
|
14
|
+
experienceStates: Array(states).map { |state| serialize(state) },
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(state)
|
|
19
|
+
@state = state
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def serialize
|
|
23
|
+
{
|
|
24
|
+
hostKey: @state.host_key,
|
|
25
|
+
experienceKey: @state.experience_key,
|
|
26
|
+
scopeType: @state.scope_type,
|
|
27
|
+
scopeIdentifier: @state.scope_identifier,
|
|
28
|
+
version: @state.version,
|
|
29
|
+
completedAt: @state.completed_at&.iso8601,
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Services
|
|
5
|
+
module Account
|
|
6
|
+
module ExperienceStates
|
|
7
|
+
class Complete < CommandTower::Services::ApplicationService
|
|
8
|
+
validate :user, is_a: User, required: true
|
|
9
|
+
validate :experience_key, is_a: String, required: true
|
|
10
|
+
validate :scope_type, is_a: String, required: true
|
|
11
|
+
validate :scope_identifier, is_a: String, required: true
|
|
12
|
+
validate :version, is_a: String, required: true
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
host_key = CommandTower.config.application.host_key.to_s
|
|
16
|
+
if host_key.blank?
|
|
17
|
+
context.fail!(
|
|
18
|
+
application_error: CommandTower::Errors::Account::ExperienceStatesHostUnconfiguredError.new,
|
|
19
|
+
)
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
identity = {
|
|
24
|
+
user:,
|
|
25
|
+
host_key:,
|
|
26
|
+
experience_key:,
|
|
27
|
+
scope_type:,
|
|
28
|
+
scope_identifier:,
|
|
29
|
+
version:,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
existing = CommandTower::UserExperienceState.find_by(identity)
|
|
33
|
+
if existing
|
|
34
|
+
context.experience_state = existing
|
|
35
|
+
context.created = false
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
created = CommandTower::UserExperienceState.create!(
|
|
41
|
+
identity.merge(completed_at: Time.current),
|
|
42
|
+
)
|
|
43
|
+
context.experience_state = created
|
|
44
|
+
context.created = true
|
|
45
|
+
rescue ActiveRecord::RecordNotUnique
|
|
46
|
+
recovered = CommandTower::UserExperienceState.find_by!(identity)
|
|
47
|
+
context.experience_state = recovered
|
|
48
|
+
context.created = false
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Services
|
|
5
|
+
module Account
|
|
6
|
+
module ExperienceStates
|
|
7
|
+
class List < CommandTower::Services::ApplicationService
|
|
8
|
+
validate :user, is_a: User, required: true
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
host_key = CommandTower.config.application.host_key.to_s
|
|
12
|
+
if host_key.blank?
|
|
13
|
+
context.fail!(
|
|
14
|
+
application_error: CommandTower::Errors::Account::ExperienceStatesHostUnconfiguredError.new,
|
|
15
|
+
)
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
context.experience_states = CommandTower::UserExperienceState.for_user_and_host(
|
|
20
|
+
user:,
|
|
21
|
+
host_key:,
|
|
22
|
+
).order(:completed_at, :id).to_a
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
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,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
|
|
@@ -25,13 +25,16 @@ module CommandTower
|
|
|
25
25
|
CommandTower::Errors::Account::PushoverAlreadyConfiguredError,
|
|
26
26
|
CommandTower::Errors::Account::PushoverVerificationFailedError,
|
|
27
27
|
CommandTower::Errors::Account::PushEndpointNotFoundError,
|
|
28
|
+
CommandTower::Errors::Account::PushTestNoEndpointError,
|
|
28
29
|
CommandTower::Errors::ValidationError
|
|
29
30
|
:unprocessable_entity
|
|
30
|
-
when CommandTower::Errors::Account::PhoneVerificationThrottledError
|
|
31
|
+
when CommandTower::Errors::Account::PhoneVerificationThrottledError,
|
|
32
|
+
CommandTower::Errors::Account::PushTestRateLimitError
|
|
31
33
|
:too_many_requests
|
|
32
34
|
when CommandTower::Errors::Account::SmsCapabilityUnavailableError,
|
|
33
35
|
CommandTower::Errors::Account::PushoverCapabilityUnavailableError,
|
|
34
|
-
CommandTower::Errors::Account::PushCapabilityUnavailableError
|
|
36
|
+
CommandTower::Errors::Account::PushCapabilityUnavailableError,
|
|
37
|
+
CommandTower::Errors::Account::ExperienceStatesHostUnconfiguredError
|
|
35
38
|
:service_unavailable
|
|
36
39
|
when CommandTower::Errors::Account::PhoneVerificationSendFailedError,
|
|
37
40
|
CommandTower::Errors::Account::PushoverProviderUnavailableError
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Workflows
|
|
5
|
+
module Me
|
|
6
|
+
module ExperienceStates
|
|
7
|
+
class CompleteWorkflow < CommandTower::Workflows::ApplicationWorkflow
|
|
8
|
+
retry_strategy :none
|
|
9
|
+
|
|
10
|
+
def call(current_user:, experience_key:, scope_type:, scope_identifier:, version:, auth_context: nil)
|
|
11
|
+
result = CommandTower::Services::Account::ExperienceStates::Complete.call(
|
|
12
|
+
user: current_user,
|
|
13
|
+
experience_key:,
|
|
14
|
+
scope_type:,
|
|
15
|
+
scope_identifier:,
|
|
16
|
+
version:,
|
|
17
|
+
)
|
|
18
|
+
unless result.success?
|
|
19
|
+
error = result.errors.first
|
|
20
|
+
return failure(
|
|
21
|
+
errors: result.errors,
|
|
22
|
+
http_status: CommandTower::Workflows::Me::ErrorMapping.http_status_for(error),
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if result.data[:created]
|
|
27
|
+
audit(
|
|
28
|
+
:experience_state_completed,
|
|
29
|
+
affected_user: current_user,
|
|
30
|
+
changes: {},
|
|
31
|
+
scope_class: :host,
|
|
32
|
+
host_context: {
|
|
33
|
+
type: scope_type,
|
|
34
|
+
identifier: scope_identifier,
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
success(
|
|
40
|
+
payload: WorkflowSupport.serialize_view(result.data[:experience_state]),
|
|
41
|
+
http_status: :ok,
|
|
42
|
+
response_effects: WorkflowSupport.expire_header_effects(auth_context),
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Workflows
|
|
5
|
+
module Me
|
|
6
|
+
module ExperienceStates
|
|
7
|
+
class ListWorkflow < CommandTower::Workflows::ApplicationWorkflow
|
|
8
|
+
retry_strategy :none
|
|
9
|
+
|
|
10
|
+
def call(current_user:, auth_context: nil)
|
|
11
|
+
result = CommandTower::Services::Account::ExperienceStates::List.call(user: current_user)
|
|
12
|
+
unless result.success?
|
|
13
|
+
error = result.errors.first
|
|
14
|
+
return failure(
|
|
15
|
+
errors: result.errors,
|
|
16
|
+
http_status: CommandTower::Workflows::Me::ErrorMapping.http_status_for(error),
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
success(
|
|
21
|
+
payload: WorkflowSupport.serialize_collection(result.data[:experience_states]),
|
|
22
|
+
http_status: :ok,
|
|
23
|
+
response_effects: WorkflowSupport.expire_header_effects(auth_context),
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Workflows
|
|
5
|
+
module Me
|
|
6
|
+
module ExperienceStates
|
|
7
|
+
module WorkflowSupport
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def expire_header_effects(auth_context)
|
|
11
|
+
return if auth_context.nil?
|
|
12
|
+
|
|
13
|
+
{ set_expire_header: auth_context.token_expires_at }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def serialize_view(state)
|
|
17
|
+
CommandTower::Serializers::Me::ExperienceStates::ExperienceStateSerializer.serialize(state)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def serialize_collection(states)
|
|
21
|
+
CommandTower::Serializers::Me::ExperienceStates::ExperienceStateSerializer.serialize_collection(states)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Workflows
|
|
5
|
+
module Me
|
|
6
|
+
module Push
|
|
7
|
+
class TestWorkflow < 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::SendSelfTest.call(user: current_user)
|
|
16
|
+
unless result.success?
|
|
17
|
+
error = result.errors.first
|
|
18
|
+
return failure(
|
|
19
|
+
errors: result.errors,
|
|
20
|
+
http_status: http_status_for(error),
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
success(
|
|
25
|
+
payload: {
|
|
26
|
+
communicationId: result.data[:communication_id],
|
|
27
|
+
destinationPlanId: result.data[:destination_plan_id],
|
|
28
|
+
selectedChannels: result.data[:selected_channels],
|
|
29
|
+
status: result.data[:communication_status],
|
|
30
|
+
},
|
|
31
|
+
http_status: :ok,
|
|
32
|
+
response_effects: WorkflowSupport.expire_header_effects(auth_context),
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def http_status_for(error)
|
|
39
|
+
case error
|
|
40
|
+
when CommandTower::Errors::ValidationError,
|
|
41
|
+
CommandTower::Errors::Messaging::RecipientUnresolvedError,
|
|
42
|
+
CommandTower::Errors::Messaging::AcceptRejectedError
|
|
43
|
+
:unprocessable_entity
|
|
44
|
+
when CommandTower::Errors::Messaging::IdempotencyConflictError
|
|
45
|
+
:conflict
|
|
46
|
+
else
|
|
47
|
+
CommandTower::Workflows::Me::ErrorMapping.http_status_for(error)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -104,11 +104,16 @@ CommandTower::Engine.routes.draw do
|
|
|
104
104
|
# Expo push lifecycle always drawn (no route constraints).
|
|
105
105
|
# Product readiness is workflow-gated as 503 push_capability_unavailable.
|
|
106
106
|
# Collection JSON (multi-active). No verification POST — create/replace mark_verified.
|
|
107
|
+
# Self-test must be declared before :id routes so "test" is not captured as an id.
|
|
107
108
|
get "push", to: "push#index"
|
|
108
109
|
post "push", to: "push#create"
|
|
110
|
+
post "push/test", to: "push#test"
|
|
109
111
|
patch "push/:id", to: "push#update"
|
|
110
112
|
put "push/:id", to: "push#update"
|
|
111
113
|
delete "push/:id", to: "push#destroy"
|
|
114
|
+
|
|
115
|
+
get "experience-states", to: "experience_states#index"
|
|
116
|
+
post "experience-states/complete", to: "experience_states#complete"
|
|
112
117
|
end
|
|
113
118
|
|
|
114
119
|
namespace :admin do
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateUserExperienceStates < ActiveRecord::Migration[7.2]
|
|
4
|
+
def change
|
|
5
|
+
create_table :user_experience_states do |t|
|
|
6
|
+
t.timestamps
|
|
7
|
+
t.references :user, null: false, foreign_key: true
|
|
8
|
+
t.string :host_key, null: false, limit: 128
|
|
9
|
+
t.string :experience_key, null: false, limit: 128
|
|
10
|
+
t.string :scope_type, null: false, limit: 128
|
|
11
|
+
t.string :scope_identifier, null: false, limit: 128
|
|
12
|
+
t.string :version, null: false, limit: 128
|
|
13
|
+
t.datetime :completed_at, null: false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_index :user_experience_states,
|
|
17
|
+
%i[user_id host_key experience_key scope_type scope_identifier version],
|
|
18
|
+
unique: true,
|
|
19
|
+
name: "index_user_experience_states_unique"
|
|
20
|
+
|
|
21
|
+
add_index :user_experience_states,
|
|
22
|
+
%i[user_id host_key],
|
|
23
|
+
name: "index_user_experience_states_on_user_host"
|
|
24
|
+
end
|
|
25
|
+
end
|
data/docs/api_reference.md
CHANGED
|
@@ -447,6 +447,27 @@ There is **no** `POST /me/push/verification`. Create and replace call `Endpoints
|
|
|
447
447
|
|
|
448
448
|
---
|
|
449
449
|
|
|
450
|
+
## Experience states
|
|
451
|
+
|
|
452
|
+
Durable completion facts for host-composed experiences. CommandTower stores opaque identity keys only — no League / Season / Tenant semantics and no presentation instructions such as `showWelcome`.
|
|
453
|
+
|
|
454
|
+
`config.application.host_key` is **server-bound**. The client must not supply `hostKey` / `host_key`. When `host_key` is blank, both routes return **503** `experience_states_host_unconfigured`.
|
|
455
|
+
|
|
456
|
+
| Method | Path | Body | Notes |
|
|
457
|
+
|--------|------|------|--------|
|
|
458
|
+
| `GET` | `/me/experience-states` | — | `{ experienceStates: [...] }` — **completed rows only** for the configured host |
|
|
459
|
+
| `POST` | `/me/experience-states/complete` | `experienceKey`/`experience_key`, `scopeType`/`scope_type`, `scopeIdentifier`/`scope_identifier`, `version` (snake or camelCase) | Idempotent complete; returns the durable fact |
|
|
460
|
+
|
|
461
|
+
**Fact fields:** `hostKey`, `experienceKey`, `scopeType`, `scopeIdentifier`, `version`, `completedAt`. Never `showWelcome` / applicability / presentation instructions.
|
|
462
|
+
|
|
463
|
+
**RBAC:** `me_experience_states` (`index`, `complete`). Grant explicitly on host roles (dummy host `member` includes it).
|
|
464
|
+
|
|
465
|
+
**Audit:** first durable creation emits `experience_state_completed` (opaque `host_context` type/identifier). Idempotent replay does not emit again.
|
|
466
|
+
|
|
467
|
+
**Spec:** `spec/requests/command_tower/me/experience_states_spec.rb`.
|
|
468
|
+
|
|
469
|
+
---
|
|
470
|
+
|
|
450
471
|
## Admin Workspace
|
|
451
472
|
|
|
452
473
|
### `GET /admin/workspace`
|
data/docs/controllers.md
CHANGED
|
@@ -21,6 +21,7 @@ This page is an **index** of route areas. Detailed request/response contracts li
|
|
|
21
21
|
| Phone | `/me/phone*` | Phone endpoint + verification |
|
|
22
22
|
| Pushover | `/me/pushover*` | Pushover endpoint lifecycle + verification |
|
|
23
23
|
| Push | `/me/push*` | Expo push endpoint collection (register / replace / revoke) |
|
|
24
|
+
| Experience states | `/me/experience-states*` | Durable completed experience-state facts (list / complete) |
|
|
24
25
|
| Admin messaging | `/admin/messaging/announcements` | Cohort announcements |
|
|
25
26
|
|
|
26
27
|
Exact paths depend on where the host mounts the engine.
|
|
@@ -32,10 +32,11 @@ In the host initializer, set at least:
|
|
|
32
32
|
- `config.jwt.hmac_secret`
|
|
33
33
|
- `config.signup_session.jwt_secret` (or `SIGNUP_SESSION_JWT_SECRET`)
|
|
34
34
|
- `config.password_recovery_session.jwt_secret` (or `PASSWORD_RECOVERY_SESSION_JWT_SECRET`)
|
|
35
|
+
- `config.application.host_key` — server-bound host/product identity for CT-generic user-scoped state (for example experience states). Not client-supplied. Blank values cause Me experience-state routes to return **503** `experience_states_host_unconfigured`.
|
|
35
36
|
|
|
36
37
|
Re-run `bin/rails command_tower:doctor`. Details: [Initializing — Configuration](initializing.md#configuration).
|
|
37
38
|
|
|
38
|
-
Dummy-host reference: [`rails_app/config/initializers/command_tower.rb`](../rails_app/config/initializers/command_tower.rb).
|
|
39
|
+
Dummy-host reference: [`rails_app/config/initializers/command_tower.rb`](../rails_app/config/initializers/command_tower.rb) (sets `host_key` to `"command_tower"`).
|
|
39
40
|
|
|
40
41
|
### Email / SMTP
|
|
41
42
|
|
data/docs/initializing.md
CHANGED
|
@@ -107,6 +107,7 @@ Required for production-ready hosts:
|
|
|
107
107
|
- `config.jwt.hmac_secret` — typically `SECRET_KEY_BASE` / `Rails.application.secret_key_base`
|
|
108
108
|
- `config.signup_session.jwt_secret` — or `SIGNUP_SESSION_JWT_SECRET`
|
|
109
109
|
- `config.password_recovery_session.jwt_secret` — or `PASSWORD_RECOVERY_SESSION_JWT_SECRET`
|
|
110
|
+
- `config.application.host_key` — server-bound host/product identity for CT-generic user-scoped state (experience states). Not client-supplied. Blank → Me experience-state routes return **503**.
|
|
110
111
|
|
|
111
112
|
Optional / feature-gated:
|
|
112
113
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Upgrade: CommandTower 0.15.0
|
|
2
|
+
|
|
3
|
+
**From:** `0.14.0`
|
|
4
|
+
**To:** `0.15.0`
|
|
5
|
+
|
|
6
|
+
Minor release: Me push self-test (`POST /me/push/test`) through Messaging Produce, with configurable rate limits. No schema migration.
|
|
7
|
+
|
|
8
|
+
## Host-visible changes
|
|
9
|
+
|
|
10
|
+
| Change | Host impact |
|
|
11
|
+
|--------|-------------|
|
|
12
|
+
| `POST /api/me/push/test` | Authenticated self-test. Produces `push_delivery_test` via Communications::Produce (Expo path). Requires at least one active push endpoint. Returns Produce identifiers only (no Expo tokens). |
|
|
13
|
+
| RBAC `me_push` | Adds action `test` (same entity). Hosts that already grant `me_push` get the new action. |
|
|
14
|
+
| `config.messaging.expo.self_test_per_user_hour` | Max self-test sends per user per rolling window (default **10**). |
|
|
15
|
+
| `config.messaging.expo.self_test_window_seconds` | Rolling window length (default **3600**). |
|
|
16
|
+
| Rate limit HTTP | Exceeded limit → **429** `push_test_rate_limited` with `retry_after_seconds` details (existing CT envelope). |
|
|
17
|
+
| No endpoint | **422** `push_test_no_endpoint`. |
|
|
18
|
+
| Gate off | **503** `push_capability_unavailable` (same as other `/me/push*` when Expo adapter disabled). |
|
|
19
|
+
|
|
20
|
+
## Host actions
|
|
21
|
+
|
|
22
|
+
1. Bump gem to `0.15.0` (or `>= 0.15.0`).
|
|
23
|
+
2. **No new migration.**
|
|
24
|
+
3. **Required before enabling Account Push notifications settings:** register notification type `push_delivery_test` with push-only plan (e.g. `allowed/default %w[push]`, `inbox_available: false`, `user_configurable: false`, not preference-visible). CT hardcodes this type key for Me self-test.
|
|
25
|
+
4. Set `config.messaging.expo.self_test_per_user_hour` explicitly when product policy differs from the default (Pick’em: **10**).
|
|
26
|
+
5. Keep Expo adapter enabled (`http` / `fake`) and `me_push` granted for roles that use push.
|
|
27
|
+
|
|
28
|
+
## Not in this release
|
|
29
|
+
|
|
30
|
+
- CT FE Account Push notifications settings / device lifecycle migration
|
|
31
|
+
- Host Pick’em enablement / type registration / IPA
|
|
32
|
+
- Physical TestFlight proof
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Upgrade: CommandTower 0.16.0
|
|
2
|
+
|
|
3
|
+
**From:** `0.15.0`
|
|
4
|
+
**To:** `0.16.0`
|
|
5
|
+
|
|
6
|
+
Minor release: generic Me experience-state persistence and HTTP (completion facts only). New migration.
|
|
7
|
+
|
|
8
|
+
## Host-visible changes
|
|
9
|
+
|
|
10
|
+
| Change | Host impact |
|
|
11
|
+
|--------|-------------|
|
|
12
|
+
| Table `user_experience_states` | Durable completion rows keyed by `user + host_key + experience_key + scope_type + scope_identifier + version` |
|
|
13
|
+
| `config.application.host_key` | **Required** non-blank String for Me experience-states. Server-bound; never client-supplied. Blank → **503** `experience_states_host_unconfigured` |
|
|
14
|
+
| `GET /api/me/experience-states` | Authenticated list of **completed** facts for the current user on this host only |
|
|
15
|
+
| `POST /api/me/experience-states/complete` | Idempotent complete; audit `experience_state_completed` **only on first durable create** |
|
|
16
|
+
| RBAC `me_experience_states` | New entity (`index`, `complete`). Hosts must **explicitly grant** (not auto-added to a generic Admin role) |
|
|
17
|
+
| Audit | Configurable event `experience_state_completed` with opaque `host_context` |
|
|
18
|
+
|
|
19
|
+
## Host actions
|
|
20
|
+
|
|
21
|
+
1. Bump gem to `0.16.0` (or `>= 0.16.0`).
|
|
22
|
+
2. Set `config.application.host_key` (e.g. Pick’em `"pickem"`; CT rails_app `"command_tower"`).
|
|
23
|
+
3. Copy engine migrations and migrate: `bundle exec rails command_tower:install:migrations` then `db:migrate`.
|
|
24
|
+
4. Grant RBAC entity `me_experience_states` to the roles/groups that should use Me experience-states (typically `member`).
|
|
25
|
+
|
|
26
|
+
## Not in this release
|
|
27
|
+
|
|
28
|
+
- CommandTower frontend experience-states capability / hydrate hooks
|
|
29
|
+
- Host Welcome product composition
|
|
30
|
+
- Push silent-sync / Welcome notification step
|
data/docs/upgrades/README.md
CHANGED
|
@@ -4,6 +4,8 @@ Host-facing upgrade / change summaries for CommandTower releases.
|
|
|
4
4
|
|
|
5
5
|
| Version | Summary |
|
|
6
6
|
|---------|---------|
|
|
7
|
+
| [0.16.0](0.16.0.md) | Me experience-states (`GET`/`POST complete`); `host_key`; `user_experience_states` migration; `me_experience_states` RBAC |
|
|
8
|
+
| [0.15.0](0.15.0.md) | Me `POST /me/push/test` → Produce (`push_delivery_test`); configurable Expo self-test rate limit composers; `me_push#test` |
|
|
7
9
|
| [0.14.0](0.14.0.md) | Expo push Messaging channel (`config.messaging.expo`) + `/api/me/push*` registration HTTP; `me_push` RBAC |
|
|
8
10
|
| [0.13.1](0.13.1.md) | Canonical `Intervention::Severity` constants (`blocking`, `warning`, `informational`) |
|
|
9
11
|
| [0.13.0](0.13.0.md) | Account self-service deletion (`DELETE /api/me/account`); tombstone + PII scrub; `me_account` RBAC; `users.deleted_at` migration |
|
|
@@ -75,6 +75,12 @@ entities:
|
|
|
75
75
|
- create
|
|
76
76
|
- update
|
|
77
77
|
- destroy
|
|
78
|
+
- test
|
|
79
|
+
- name: me_experience_states
|
|
80
|
+
controller: CommandTower::Me::ExperienceStatesController
|
|
81
|
+
only:
|
|
82
|
+
- index
|
|
83
|
+
- complete
|
|
78
84
|
- name: me_audit_events
|
|
79
85
|
controller: CommandTower::Me::AuditEventsController
|
|
80
86
|
only:
|
|
@@ -14,6 +14,11 @@ module CommandTower
|
|
|
14
14
|
desc: "The default name of the application",
|
|
15
15
|
default_shown: "# Auto Populates to the name of the application"
|
|
16
16
|
|
|
17
|
+
add_composer :host_key,
|
|
18
|
+
allowed: String,
|
|
19
|
+
default: "",
|
|
20
|
+
desc: "Server-bound host/product identity for CT-generic user-scoped state. Not client-supplied."
|
|
21
|
+
|
|
17
22
|
add_composer :communication_name,
|
|
18
23
|
allowed: String,
|
|
19
24
|
dynamic_default: :app_name,
|
|
@@ -32,6 +32,19 @@ module CommandTower
|
|
|
32
32
|
"Required only if the Expo project enables enhanced push security. Never log.",
|
|
33
33
|
allowed: String,
|
|
34
34
|
default: ""
|
|
35
|
+
|
|
36
|
+
add_composer :self_test_per_user_hour,
|
|
37
|
+
desc: "Max Me push self-test (POST /me/push/test) sends allowed per user per rolling window. " \
|
|
38
|
+
"Hosts enabling Account Push notifications settings should set this explicitly " \
|
|
39
|
+
"(Pick'em: 10).",
|
|
40
|
+
allowed: Integer,
|
|
41
|
+
default: 10
|
|
42
|
+
|
|
43
|
+
add_composer :self_test_window_seconds,
|
|
44
|
+
desc: "Rolling window length in seconds for Me push self-test rate limiting " \
|
|
45
|
+
"(default 3600 = one hour).",
|
|
46
|
+
allowed: Integer,
|
|
47
|
+
default: 3600
|
|
35
48
|
end
|
|
36
49
|
end
|
|
37
50
|
end
|
|
@@ -265,6 +265,18 @@ module CommandTower
|
|
|
265
265
|
subject_type: "User",
|
|
266
266
|
affected_user_required: true,
|
|
267
267
|
global_visible_in_host_scope: true
|
|
268
|
+
},
|
|
269
|
+
experience_state_completed: {
|
|
270
|
+
label: "Experience state completed",
|
|
271
|
+
tags: %w[experience account],
|
|
272
|
+
enabled: true,
|
|
273
|
+
enablement_configurable: true,
|
|
274
|
+
user_history: false,
|
|
275
|
+
sensitive_fields: [],
|
|
276
|
+
allowed_changes: [],
|
|
277
|
+
retention: :permanent,
|
|
278
|
+
subject_required: false,
|
|
279
|
+
affected_user_required: true
|
|
268
280
|
}
|
|
269
281
|
}.freeze
|
|
270
282
|
|
|
@@ -15,6 +15,7 @@ module CommandTower
|
|
|
15
15
|
20260817000001_add_scope_columns_to_command_tower_audit_events.rb
|
|
16
16
|
20260817000003_create_command_tower_impersonation_sessions.rb
|
|
17
17
|
20260826140000_add_deleted_at_to_users.rb
|
|
18
|
+
20260906180000_create_user_experience_states.rb
|
|
18
19
|
].freeze
|
|
19
20
|
|
|
20
21
|
module_function
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
FactoryBot.define do
|
|
4
|
+
factory :user_experience_state, class: "CommandTower::UserExperienceState" do
|
|
5
|
+
user
|
|
6
|
+
host_key { "command_tower" }
|
|
7
|
+
experience_key { "welcome" }
|
|
8
|
+
scope_type { "example_scope" }
|
|
9
|
+
sequence(:scope_identifier) { |n| n.to_s }
|
|
10
|
+
version { "v1" }
|
|
11
|
+
completed_at { Time.current }
|
|
12
|
+
end
|
|
13
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: command_tower
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- matt-taylor
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: phonelib
|
|
@@ -211,6 +211,7 @@ files:
|
|
|
211
211
|
- app/controllers/command_tower/auth/username/availability_controller.rb
|
|
212
212
|
- app/controllers/command_tower/me/account_controller.rb
|
|
213
213
|
- app/controllers/command_tower/me/audit_events_controller.rb
|
|
214
|
+
- app/controllers/command_tower/me/experience_states_controller.rb
|
|
214
215
|
- app/controllers/command_tower/me/inbox_controller.rb
|
|
215
216
|
- app/controllers/command_tower/me/name_controller.rb
|
|
216
217
|
- app/controllers/command_tower/me/password_controller.rb
|
|
@@ -254,6 +255,7 @@ files:
|
|
|
254
255
|
- app/deserializers/command_tower/deserializers/intervention/envelope_deserializer.rb
|
|
255
256
|
- app/deserializers/command_tower/deserializers/me/change_password_deserializer.rb
|
|
256
257
|
- app/deserializers/command_tower/deserializers/me/delete_account_deserializer.rb
|
|
258
|
+
- app/deserializers/command_tower/deserializers/me/experience_states/complete_deserializer.rb
|
|
257
259
|
- app/deserializers/command_tower/deserializers/me/phone_verification/verify_deserializer.rb
|
|
258
260
|
- app/deserializers/command_tower/deserializers/me/push/token_deserializer.rb
|
|
259
261
|
- app/deserializers/command_tower/deserializers/me/pushover/credentials_deserializer.rb
|
|
@@ -262,6 +264,7 @@ files:
|
|
|
262
264
|
- app/deserializers/command_tower/deserializers/messaging/inbox.rb
|
|
263
265
|
- app/deserializers/command_tower/deserializers/messaging/preferences/show_deserializer.rb
|
|
264
266
|
- app/deserializers/command_tower/deserializers/messaging/preferences/update_deserializer.rb
|
|
267
|
+
- app/errors/command_tower/errors/account/experience_states_host_unconfigured_error.rb
|
|
265
268
|
- app/errors/command_tower/errors/account/phone_already_verified_error.rb
|
|
266
269
|
- app/errors/command_tower/errors/account/phone_missing_error.rb
|
|
267
270
|
- app/errors/command_tower/errors/account/phone_verification_code_invalid_error.rb
|
|
@@ -271,6 +274,8 @@ files:
|
|
|
271
274
|
- app/errors/command_tower/errors/account/phone_verification_throttled_error.rb
|
|
272
275
|
- app/errors/command_tower/errors/account/push_capability_unavailable_error.rb
|
|
273
276
|
- app/errors/command_tower/errors/account/push_endpoint_not_found_error.rb
|
|
277
|
+
- app/errors/command_tower/errors/account/push_test_no_endpoint_error.rb
|
|
278
|
+
- app/errors/command_tower/errors/account/push_test_rate_limit_error.rb
|
|
274
279
|
- app/errors/command_tower/errors/account/pushover_already_configured_error.rb
|
|
275
280
|
- app/errors/command_tower/errors/account/pushover_capability_unavailable_error.rb
|
|
276
281
|
- app/errors/command_tower/errors/account/pushover_not_configured_error.rb
|
|
@@ -336,6 +341,7 @@ files:
|
|
|
336
341
|
- app/models/command_tower/messaging/endpoint_pushover_credential.rb
|
|
337
342
|
- app/models/command_tower/messaging/inbox_item.rb
|
|
338
343
|
- app/models/command_tower/messaging/notification_preference.rb
|
|
344
|
+
- app/models/command_tower/user_experience_state.rb
|
|
339
345
|
- app/models/user.rb
|
|
340
346
|
- app/models/user_secret.rb
|
|
341
347
|
- app/serializers/command_tower/serializers/admin/messaging/announcement_response_serializer.rb
|
|
@@ -367,6 +373,7 @@ files:
|
|
|
367
373
|
- app/serializers/command_tower/serializers/me/account_serializer.rb
|
|
368
374
|
- app/serializers/command_tower/serializers/me/change_password_response_serializer.rb
|
|
369
375
|
- app/serializers/command_tower/serializers/me/delete_account_response_serializer.rb
|
|
376
|
+
- app/serializers/command_tower/serializers/me/experience_states/experience_state_serializer.rb
|
|
370
377
|
- app/serializers/command_tower/serializers/me/push_serializer.rb
|
|
371
378
|
- app/serializers/command_tower/serializers/me/pushover_serializer.rb
|
|
372
379
|
- app/serializers/command_tower/serializers/messaging/inbox.rb
|
|
@@ -597,13 +604,17 @@ files:
|
|
|
597
604
|
- app/services/command_tower/service_base.rb
|
|
598
605
|
- app/services/command_tower/service_logging.rb
|
|
599
606
|
- app/services/command_tower/services/account/clear_phone.rb
|
|
607
|
+
- app/services/command_tower/services/account/experience_states/complete.rb
|
|
608
|
+
- app/services/command_tower/services/account/experience_states/list.rb
|
|
600
609
|
- app/services/command_tower/services/account/phone_verification/send.rb
|
|
601
610
|
- app/services/command_tower/services/account/phone_verification/verify.rb
|
|
611
|
+
- app/services/command_tower/services/account/push/check_self_test_rate_limit.rb
|
|
602
612
|
- app/services/command_tower/services/account/push/create.rb
|
|
603
613
|
- app/services/command_tower/services/account/push/ct_support.rb
|
|
604
614
|
- app/services/command_tower/services/account/push/destroy.rb
|
|
605
615
|
- app/services/command_tower/services/account/push/list.rb
|
|
606
616
|
- app/services/command_tower/services/account/push/replace.rb
|
|
617
|
+
- app/services/command_tower/services/account/push/send_self_test.rb
|
|
607
618
|
- app/services/command_tower/services/account/pushover/create.rb
|
|
608
619
|
- app/services/command_tower/services/account/pushover/ct_support.rb
|
|
609
620
|
- app/services/command_tower/services/account/pushover/destroy.rb
|
|
@@ -734,12 +745,16 @@ files:
|
|
|
734
745
|
- app/workflows/command_tower/workflows/me/delete_account_workflow.rb
|
|
735
746
|
- app/workflows/command_tower/workflows/me/error_mapping.rb
|
|
736
747
|
- app/workflows/command_tower/workflows/me/error_status.rb
|
|
748
|
+
- app/workflows/command_tower/workflows/me/experience_states/complete_workflow.rb
|
|
749
|
+
- app/workflows/command_tower/workflows/me/experience_states/list_workflow.rb
|
|
750
|
+
- app/workflows/command_tower/workflows/me/experience_states/workflow_support.rb
|
|
737
751
|
- app/workflows/command_tower/workflows/me/phone_verification/send_workflow.rb
|
|
738
752
|
- app/workflows/command_tower/workflows/me/phone_verification/verify_workflow.rb
|
|
739
753
|
- app/workflows/command_tower/workflows/me/push/create_workflow.rb
|
|
740
754
|
- app/workflows/command_tower/workflows/me/push/destroy_workflow.rb
|
|
741
755
|
- app/workflows/command_tower/workflows/me/push/index_workflow.rb
|
|
742
756
|
- app/workflows/command_tower/workflows/me/push/replace_workflow.rb
|
|
757
|
+
- app/workflows/command_tower/workflows/me/push/test_workflow.rb
|
|
743
758
|
- app/workflows/command_tower/workflows/me/push/workflow_support.rb
|
|
744
759
|
- app/workflows/command_tower/workflows/me/pushover/create_workflow.rb
|
|
745
760
|
- app/workflows/command_tower/workflows/me/pushover/destroy_workflow.rb
|
|
@@ -769,6 +784,7 @@ files:
|
|
|
769
784
|
- db/migrate/20260817000001_add_scope_columns_to_command_tower_audit_events.rb
|
|
770
785
|
- db/migrate/20260817000003_create_command_tower_impersonation_sessions.rb
|
|
771
786
|
- db/migrate/20260826140000_add_deleted_at_to_users.rb
|
|
787
|
+
- db/migrate/20260906180000_create_user_experience_states.rb
|
|
772
788
|
- docs/admin_workspace.md
|
|
773
789
|
- docs/api_reference.md
|
|
774
790
|
- docs/architecture.md
|
|
@@ -798,6 +814,8 @@ files:
|
|
|
798
814
|
- docs/upgrades/0.13.0.md
|
|
799
815
|
- docs/upgrades/0.13.1.md
|
|
800
816
|
- docs/upgrades/0.14.0.md
|
|
817
|
+
- docs/upgrades/0.15.0.md
|
|
818
|
+
- docs/upgrades/0.16.0.md
|
|
801
819
|
- docs/upgrades/README.md
|
|
802
820
|
- lib/command_tower.rb
|
|
803
821
|
- lib/command_tower/admin_scope.rb
|
|
@@ -909,6 +927,7 @@ files:
|
|
|
909
927
|
- spec/factories/messaging.rb
|
|
910
928
|
- spec/factories/role.rb
|
|
911
929
|
- spec/factories/user.rb
|
|
930
|
+
- spec/factories/user_experience_states.rb
|
|
912
931
|
- spec/factories/user_secret.rb
|
|
913
932
|
homepage: https://github.com/matt-taylor/command_tower
|
|
914
933
|
licenses:
|