command_tower 0.15.0 → 0.17.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/application_controller.rb +1 -0
- data/app/controllers/command_tower/me/experience_states_controller.rb +51 -0
- data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -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/client_update_required_error.rb +27 -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/serializers/command_tower/serializers/messaging/inbox.rb +2 -1
- data/app/services/command_tower/email_theme/resolver.rb +45 -0
- data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
- data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
- data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
- data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +207 -0
- data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -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/client_compatibility/evaluate.rb +219 -0
- data/app/services/command_tower/services/messaging/inbox.rb +8 -1
- data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
- data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
- data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
- data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
- data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
- data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
- data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
- data/app/workflows/command_tower/workflows/me/error_mapping.rb +2 -1
- 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/config/routes.rb +3 -0
- data/db/migrate/20260906180000_create_user_experience_states.rb +25 -0
- data/docs/api_reference.md +34 -2
- data/docs/controllers.md +1 -0
- data/docs/extending.md +2 -1
- data/docs/host_integration_guide.md +37 -1
- data/docs/initializing.md +1 -0
- data/docs/messaging_integration_guide.md +38 -0
- data/docs/upgrades/0.16.0.md +30 -0
- data/docs/upgrades/0.17.0.md +33 -0
- data/docs/upgrades/README.md +2 -0
- data/lib/command_tower/authorization/default.yml +5 -0
- data/lib/command_tower/client_compatibility/version.rb +45 -0
- data/lib/command_tower/client_compatibility.rb +23 -0
- data/lib/command_tower/configuration/application/config.rb +5 -0
- data/lib/command_tower/configuration/config.rb +6 -0
- data/lib/command_tower/configuration/email_theme/config.rb +69 -0
- data/lib/command_tower/configuration/registry/audit/config.rb +12 -0
- data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
- data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
- data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
- data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
- data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
- data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
- data/lib/command_tower/configuration/registry/config.rb +14 -0
- data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
- data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
- data/lib/command_tower/current.rb +1 -0
- data/lib/command_tower/engine.rb +4 -0
- data/lib/command_tower/inbox_presentations.rb +19 -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 +37 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 465f6aea711f7cb8158e7b3eb9389663c2ac06d5104e89335da339d04cda81b6
|
|
4
|
+
data.tar.gz: 3e8c5a0b870ec8fd71f954ee3e04573cf87bf3eb3b8091e056dddb0db37176a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38ac950878823bda838001b2948ef4c54fe6b07cd0520242681607415f0ffce8c3ac795280fda9248a8a7ba449ffeef1cd8403a89777940d1ee0e846ba683906
|
|
7
|
+
data.tar.gz: b42d36dc8afb1cc9d0df9f254622b01981984d50f7839eebe8040056714e91d04b1b30dd397828133f270056f7032a695b06071c7cb958d65f4f0885d95c415f
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module CommandTower
|
|
4
4
|
class ApplicationController < ActionController::API
|
|
5
5
|
include CommandTower::Execution::HttpBoundary
|
|
6
|
+
include CommandTower::Execution::ClientCompatibilityBoundary
|
|
6
7
|
|
|
7
8
|
AUTHENTICATION_HEADER = CommandTower::Jwt::AuthorizationHelper::AUTHENTICATION_HEADER
|
|
8
9
|
AUTHENTICATION_EXPIRE_HEADER = CommandTower::Jwt::AuthorizationHelper::AUTHENTICATION_EXPIRE_HEADER
|
|
@@ -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
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Execution
|
|
5
|
+
# Runs the client-version-compatibility check for every JSON request,
|
|
6
|
+
# before any authentication/authorization filter. Transport-only: it
|
|
7
|
+
# invokes exactly one workflow and renders its result — all decisioning
|
|
8
|
+
# lives in `Workflows::ClientCompatibility::EvaluateWorkflow` /
|
|
9
|
+
# `Services::ClientCompatibility::Evaluate` (authority §10).
|
|
10
|
+
#
|
|
11
|
+
# Hosts that need to exempt a controller (e.g. a healthz-style endpoint)
|
|
12
|
+
# do so with `skip_before_action :evaluate_client_compatibility!`.
|
|
13
|
+
module ClientCompatibilityBoundary
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
include CommandTower::Api::ApplicationResponseRenderer
|
|
18
|
+
before_action :evaluate_client_compatibility!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def evaluate_client_compatibility!
|
|
24
|
+
result = CommandTower::Workflows::ClientCompatibility::EvaluateWorkflow.call(
|
|
25
|
+
request: request,
|
|
26
|
+
controller_class: self.class,
|
|
27
|
+
action_name: action_name
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
unless result.success?
|
|
31
|
+
render_application_result(result)
|
|
32
|
+
return false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
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,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Errors
|
|
5
|
+
# Hard client-version incompatibility (authority §14). Deliberately NOT
|
|
6
|
+
# registered in `SessionErrorStatus` — its HTTP status (426) is set
|
|
7
|
+
# directly by `Workflows::ClientCompatibility::EvaluateWorkflow`, not
|
|
8
|
+
# derived from that closed 401/403/412 mapping table.
|
|
9
|
+
class ClientUpdateRequiredError < ApplicationError
|
|
10
|
+
def initialize(details: {})
|
|
11
|
+
super(details:)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def code
|
|
15
|
+
"client_update_required"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def message
|
|
19
|
+
"Client update required"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def log_level
|
|
23
|
+
:warn
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
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
|
|
@@ -16,7 +16,8 @@ module CommandTower
|
|
|
16
16
|
def self.serialize(item)
|
|
17
17
|
ItemSerializer.serialize(item).merge(
|
|
18
18
|
body: item.fetch(:body), metadata: item[:metadata],
|
|
19
|
-
notificationTypeKey: item.fetch(:notification_type_key)
|
|
19
|
+
notificationTypeKey: item.fetch(:notification_type_key),
|
|
20
|
+
content: item.fetch(:content)
|
|
20
21
|
)
|
|
21
22
|
end
|
|
22
23
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module EmailTheme
|
|
5
|
+
# Internal rendering-time collaborator (not a Service/Workflow/Controller).
|
|
6
|
+
#
|
|
7
|
+
# Assembles the full semantic token set an email template needs: the
|
|
8
|
+
# visual tokens from CommandTower.config.email_theme plus the identity
|
|
9
|
+
# tokens CommandTower already exposes (config.application). Shared by
|
|
10
|
+
# Messaging email templates and, later, CT's own auth mail templates —
|
|
11
|
+
# hence its own top-level namespace rather than living under
|
|
12
|
+
# Messaging::Rendering.
|
|
13
|
+
#
|
|
14
|
+
# No template calls this yet (Rich Messaging Strategy §12 steps C/D are
|
|
15
|
+
# separate, later slices). This is deliberately not a
|
|
16
|
+
# CommandTower::Services::ApplicationService: it takes no arguments,
|
|
17
|
+
# performs no I/O, and cannot fail under normal config state — there is
|
|
18
|
+
# nothing to validate and nothing to fail. Mirrors
|
|
19
|
+
# Messaging::Rendering::TemplateResolver's own documented exemption.
|
|
20
|
+
class Resolver
|
|
21
|
+
class << self
|
|
22
|
+
def resolve
|
|
23
|
+
new.resolve
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resolve
|
|
28
|
+
theme = CommandTower.config.email_theme
|
|
29
|
+
{
|
|
30
|
+
canvas_background: theme.canvas_background,
|
|
31
|
+
surface_background: theme.surface_background,
|
|
32
|
+
surface_border: theme.surface_border,
|
|
33
|
+
primary_text: theme.primary_text,
|
|
34
|
+
body_text: theme.body_text,
|
|
35
|
+
muted_text: theme.muted_text,
|
|
36
|
+
accent: theme.accent,
|
|
37
|
+
text_on_accent: theme.text_on_accent,
|
|
38
|
+
primary_action: theme.primary_action,
|
|
39
|
+
product_name: CommandTower.app_name_for_comms,
|
|
40
|
+
product_url: CommandTower.config.application.composed_url
|
|
41
|
+
}.freeze
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -22,6 +22,7 @@ module CommandTower
|
|
|
22
22
|
:retention,
|
|
23
23
|
:delivery_status_visible,
|
|
24
24
|
:host_ownership,
|
|
25
|
+
:inbox_document_composer,
|
|
25
26
|
) do
|
|
26
27
|
def self.build(
|
|
27
28
|
key:,
|
|
@@ -41,7 +42,8 @@ module CommandTower
|
|
|
41
42
|
priority: nil,
|
|
42
43
|
retention: nil,
|
|
43
44
|
delivery_status_visible: nil,
|
|
44
|
-
host_ownership: nil
|
|
45
|
+
host_ownership: nil,
|
|
46
|
+
inbox_document_composer: nil
|
|
45
47
|
)
|
|
46
48
|
new(
|
|
47
49
|
key: key.nil? ? nil : key.to_s,
|
|
@@ -62,6 +64,17 @@ module CommandTower
|
|
|
62
64
|
retention:,
|
|
63
65
|
delivery_status_visible:,
|
|
64
66
|
host_ownership:,
|
|
67
|
+
# Rich Messaging Slice 2.5 revision — String class name of a
|
|
68
|
+
# pure-Ruby Inbox document composer (`.call(communication:) ->
|
|
69
|
+
# InboxDocument`), lazily `.constantize`d by
|
|
70
|
+
# `InboxDocumentRenderer` at render time, never at registration
|
|
71
|
+
# time (mirrors the existing RBAC `Entity#controller` deferred
|
|
72
|
+
# class-string convention — decouples host `to_prepare`
|
|
73
|
+
# registration order from composer class-load order). `nil`
|
|
74
|
+
# means this type intentionally has no custom Inbox document and
|
|
75
|
+
# renders via `InboxDocumentRenderer#build_generic`. See
|
|
76
|
+
# artifacts/visual-improvements/rich-messaging/authority/RICH_MESSAGING_RUBY_INBOX_DEFINITION_DISCOVERY.md.
|
|
77
|
+
inbox_document_composer: inbox_document_composer.nil? ? nil : inbox_document_composer.to_s,
|
|
65
78
|
).freeze
|
|
66
79
|
end
|
|
67
80
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "erb"
|
|
4
3
|
require "cgi"
|
|
5
4
|
|
|
6
5
|
module CommandTower
|
|
@@ -12,7 +11,7 @@ module CommandTower
|
|
|
12
11
|
PUSHOVER_CHANNEL = "pushover"
|
|
13
12
|
PUSH_CHANNEL = "push"
|
|
14
13
|
SUPPORTED_CHANNELS = [EMAIL_CHANNEL, SMS_CHANNEL, PUSHOVER_CHANNEL, PUSH_CHANNEL].freeze
|
|
15
|
-
|
|
14
|
+
TEMPLATE_FILENAME_PATTERN = /\A(?<basename>[a-z]+)\.(?<format>html|text)\.erb\z/
|
|
16
15
|
DEFAULT_TITLE = "Notification"
|
|
17
16
|
|
|
18
17
|
def self.render(communication:, channel_key:, recipient_address:)
|
|
@@ -120,24 +119,56 @@ module CommandTower
|
|
|
120
119
|
alias pushover_title notification_title
|
|
121
120
|
|
|
122
121
|
def render_template(filename)
|
|
123
|
-
|
|
124
|
-
raise Errno::ENOENT, "missing template #{filename}" unless
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
122
|
+
match = TEMPLATE_FILENAME_PATTERN.match(filename.to_s)
|
|
123
|
+
raise Errno::ENOENT, "missing template #{filename}" unless match
|
|
124
|
+
|
|
125
|
+
TemplateResolver.render(
|
|
126
|
+
basename: match[:basename],
|
|
127
|
+
format: match[:format].to_sym,
|
|
128
|
+
notification_type_key: @communication.notification_type_key,
|
|
129
|
+
generic_locals: template_locals,
|
|
130
|
+
type_locals: type_template_locals,
|
|
131
|
+
)
|
|
128
132
|
end
|
|
129
133
|
|
|
134
|
+
# ActionView's OutputBuffer auto-escapes any interpolated value that is
|
|
135
|
+
# not marked `html_safe?`, for every format (html and text alike) —
|
|
136
|
+
# see `ActionView::OutputBuffer#<<`. Generic templates historically
|
|
137
|
+
# relied on plain ERB, which never auto-escaped anything; callers
|
|
138
|
+
# (namely `email.html.erb`) explicitly call `h.call(...)` when they
|
|
139
|
+
# want escaping. To preserve that exact contract under ActionView
|
|
140
|
+
# rendering, `title`/`body`/`deep_link` are pre-marked `html_safe` (so
|
|
141
|
+
# raw interpolation stays unescaped, matching legacy behavior) and
|
|
142
|
+
# `h.call` marks its own already-escaped output `html_safe` too, so it
|
|
143
|
+
# is not escaped a second time by the output buffer.
|
|
130
144
|
def template_locals
|
|
145
|
+
{
|
|
146
|
+
title: @communication.title.to_s.html_safe,
|
|
147
|
+
body: @communication.body.to_s.html_safe,
|
|
148
|
+
deep_link: deep_link_from_metadata&.html_safe,
|
|
149
|
+
h: ->(value) { CGI.escapeHTML(value.to_s).html_safe },
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def type_template_locals
|
|
154
|
+
template_locals.merge(
|
|
155
|
+
metadata: frozen_metadata,
|
|
156
|
+
notification_type_key: @communication.notification_type_key.to_s,
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def deep_link_from_metadata
|
|
131
161
|
metadata = @communication.metadata
|
|
132
162
|
deep_link = metadata.is_a?(Hash) ? metadata["deep_link"] || metadata[:deep_link] : nil
|
|
133
163
|
deep_link = deep_link.to_s if deep_link
|
|
164
|
+
deep_link.presence
|
|
165
|
+
end
|
|
134
166
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
167
|
+
def frozen_metadata
|
|
168
|
+
metadata = @communication.metadata
|
|
169
|
+
return {}.freeze unless metadata.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
metadata.to_h.transform_keys(&:to_s).freeze
|
|
141
172
|
end
|
|
142
173
|
end
|
|
143
174
|
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module CommandTower
|
|
6
|
+
module Messaging
|
|
7
|
+
module Rendering
|
|
8
|
+
# Frozen v1 Inbox content document. Rendered at read (never persisted) by
|
|
9
|
+
# `InboxDocumentRenderer`. Blocks are plain frozen Hashes, not nested
|
|
10
|
+
# `Data` objects, so `#to_h` is already the exact JSON-serializable shape
|
|
11
|
+
# the `content` response key needs — no extra conversion at the
|
|
12
|
+
# serializer boundary.
|
|
13
|
+
#
|
|
14
|
+
# This value object only guarantees the envelope (`schema` tag, `blocks`
|
|
15
|
+
# is an Array); block-level construction and validation is
|
|
16
|
+
# `InboxDocumentRenderer`'s responsibility.
|
|
17
|
+
InboxDocument = Data.define(:schema, :blocks)
|
|
18
|
+
|
|
19
|
+
# Reopened (rather than defined inside the `Data.define` block) so that
|
|
20
|
+
# `SCHEMA_V1` and `.build` attach to `InboxDocument` under normal
|
|
21
|
+
# lexical constant scoping — a block passed to `Data.define` does not
|
|
22
|
+
# change the lexical scope for constant assignment the way `class ...
|
|
23
|
+
# end` does.
|
|
24
|
+
class InboxDocument
|
|
25
|
+
SCHEMA_V1 = "inbox_document_v1"
|
|
26
|
+
|
|
27
|
+
def self.build(blocks:)
|
|
28
|
+
raise ArgumentError, "blocks must be an Array" unless blocks.is_a?(Array)
|
|
29
|
+
raise ArgumentError, "arbitrary hashes are not accepted as blocks entries" if blocks.any? { |b| !b.is_a?(Hash) }
|
|
30
|
+
|
|
31
|
+
new(schema: SCHEMA_V1, blocks: blocks.freeze).freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Small pure-Ruby authoring DSL (Rich Messaging Slice 2.5 revision —
|
|
35
|
+
# replaces `inbox_document.json.erb` entirely; see
|
|
36
|
+
# artifacts/visual-improvements/rich-messaging/authority/RICH_MESSAGING_RUBY_INBOX_DEFINITION_DISCOVERY.md).
|
|
37
|
+
# A notification-type Inbox document composer calls `.compose` and
|
|
38
|
+
# authors an ordered document via exactly three methods:
|
|
39
|
+
#
|
|
40
|
+
# doc.paragraph "..."
|
|
41
|
+
# doc.cta "label", path: "/internal/route"
|
|
42
|
+
# doc.cta "label", href: "https://external.example/..."
|
|
43
|
+
# doc.presentation "pickem.make_picks", variant: :compact
|
|
44
|
+
#
|
|
45
|
+
# `cta` takes exactly one of `path:` (an internal, origin-free
|
|
46
|
+
# destination the host's own navigation owns) or `href:` (an
|
|
47
|
+
# absolute external URL opened via the platform's normal external-
|
|
48
|
+
# link handling). The composer — which already knows whether a
|
|
49
|
+
# destination is its own app's route or a third-party URL — states
|
|
50
|
+
# that intent explicitly via a typed `destination`, rather than
|
|
51
|
+
# having the frontend infer it from an absolute URL (Slice 2.6.4;
|
|
52
|
+
# see artifacts/visual-improvements/rich-messaging/plans/
|
|
53
|
+
# 2.6.4-ct-fe-inbox-navigation-capability.md).
|
|
54
|
+
# `presentation` intentionally has no `data:`/`props:` argument at
|
|
55
|
+
# all — the message type may only *name* a registered presentation
|
|
56
|
+
# capability. Its data is composed separately, post-authoring, by
|
|
57
|
+
# `InboxDocumentRenderer` and the registered presentation composer
|
|
58
|
+
# (Rich Messaging Presentation Authority §8). There is no argument
|
|
59
|
+
# slot to violate that boundary with.
|
|
60
|
+
def self.compose
|
|
61
|
+
builder = Builder.new
|
|
62
|
+
yield builder
|
|
63
|
+
build(blocks: builder.blocks)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class Builder
|
|
67
|
+
UNSAFE_SCHEMES = %w[javascript data vbscript].freeze
|
|
68
|
+
DEFAULT_CTA_LABEL = "Open"
|
|
69
|
+
|
|
70
|
+
def initialize
|
|
71
|
+
@blocks = []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def paragraph(text)
|
|
75
|
+
return self unless text.is_a?(String) && !text.empty?
|
|
76
|
+
|
|
77
|
+
@blocks << { type: "paragraph", text: }.freeze
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def cta(label, path: nil, href: nil)
|
|
82
|
+
destination = cta_destination(path:, href:)
|
|
83
|
+
return self unless destination
|
|
84
|
+
|
|
85
|
+
resolved_label = label.is_a?(String) && !label.strip.empty? ? label : DEFAULT_CTA_LABEL
|
|
86
|
+
@blocks << { type: "cta", label: resolved_label, destination: }.freeze
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def presentation(key, variant: nil)
|
|
91
|
+
@blocks << { type: "presentation", key: key.to_s, variant: variant&.to_s }.freeze
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def blocks
|
|
96
|
+
@blocks
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Internal takes precedence when both are supplied — a `cta` only
|
|
102
|
+
# ever means one navigation intent, and a composer that mistakenly
|
|
103
|
+
# supplies both almost certainly means the internal one.
|
|
104
|
+
def cta_destination(path:, href:)
|
|
105
|
+
if safe_internal_path?(path)
|
|
106
|
+
{ kind: "internal", path: }
|
|
107
|
+
elsif safe_href?(href)
|
|
108
|
+
{ kind: "external", href: }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A leading `//` is rejected even though it "starts with a slash" —
|
|
113
|
+
# it is a protocol-relative URL (browsers/RN resolve it against
|
|
114
|
+
# whatever scheme is active), not a safe same-app-only destination.
|
|
115
|
+
def safe_internal_path?(path)
|
|
116
|
+
path.is_a?(String) && path.start_with?("/") && !path.start_with?("//")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def safe_href?(href)
|
|
120
|
+
return false unless href.is_a?(String) && !href.strip.empty?
|
|
121
|
+
|
|
122
|
+
scheme = URI.parse(href).scheme&.downcase
|
|
123
|
+
scheme.present? && UNSAFE_SCHEMES.exclude?(scheme)
|
|
124
|
+
rescue URI::InvalidURIError
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|