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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module CommandTower
|
|
6
|
+
module Messaging
|
|
7
|
+
module Rendering
|
|
8
|
+
# Internal Messaging::Rendering collaborator (peer of `ChannelRenderer`,
|
|
9
|
+
# not a host-facing service). Builds the `InboxDocument` shown at Inbox
|
|
10
|
+
# detail read time.
|
|
11
|
+
#
|
|
12
|
+
# Generic path: one `paragraph` block from `communication.body`, plus an
|
|
13
|
+
# optional `cta` block with a typed `destination` — internal
|
|
14
|
+
# (`metadata["deep_link_path"]`, origin-free, host-navigation-owned) if
|
|
15
|
+
# present and safe, else external (`metadata["deep_link"]`) if that is
|
|
16
|
+
# a safe href (D-INBOX-GENERIC; typed destination added Slice 2.6.4).
|
|
17
|
+
#
|
|
18
|
+
# Optional per-`notification_type_key` override: a pure-Ruby composer
|
|
19
|
+
# class (`declaration.inbox_document_composer`, resolved via
|
|
20
|
+
# `NotificationTypes.lookup` + `.constantize`), `.call(communication:) ->
|
|
21
|
+
# InboxDocument`. Any failure (unregistered type, absent composer,
|
|
22
|
+
# unresolvable class name, a raising composer, or a composer returning
|
|
23
|
+
# something other than an `InboxDocument`) fails open to the generic
|
|
24
|
+
# document — the Inbox read path must never 500 on a bad type composer
|
|
25
|
+
# (D-INBOX-TYPE). This replaces the Rich Messaging Campaign 2 ERB/JSON
|
|
26
|
+
# authoring path entirely — see
|
|
27
|
+
# artifacts/visual-improvements/rich-messaging/authority/RICH_MESSAGING_RUBY_INBOX_DEFINITION_DISCOVERY.md.
|
|
28
|
+
#
|
|
29
|
+
# After the document (type-specific or generic) is built, any
|
|
30
|
+
# `"presentation"` block is resolved against
|
|
31
|
+
# `CommandTower.config.registry.inbox_presentations` — at most one
|
|
32
|
+
# attempted/resolved presentation capability per document (Rich
|
|
33
|
+
# Messaging Slice 2.5). The budget is consumed the instant a
|
|
34
|
+
# `"presentation"`-typed block is seen, before lookup or composition,
|
|
35
|
+
# regardless of outcome, so a malformed/unregistered first node cannot
|
|
36
|
+
# fail open and then let a second node still execute composer domain
|
|
37
|
+
# I/O.
|
|
38
|
+
#
|
|
39
|
+
# The composer's raw result is always projected down to the
|
|
40
|
+
# registration's declared `output_keys` before it reaches
|
|
41
|
+
# `content.blocks[].data` (corrective slice — presentation
|
|
42
|
+
# ServiceResult/context leak fix). Composer inputs and any other
|
|
43
|
+
# incidental context/result keys never reach the wire, regardless of
|
|
44
|
+
# whether the composer is `ApplicationService`-shaped or a plain Hash.
|
|
45
|
+
class InboxDocumentRenderer
|
|
46
|
+
UNSAFE_SCHEMES = %w[javascript data vbscript].freeze
|
|
47
|
+
DEFAULT_CTA_LABEL = "Open"
|
|
48
|
+
|
|
49
|
+
def self.render(communication:, recipient_id:)
|
|
50
|
+
new(communication:, recipient_id:).render
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(communication:, recipient_id:)
|
|
54
|
+
@communication = communication
|
|
55
|
+
@recipient_id = recipient_id
|
|
56
|
+
@presentation_attempted = false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def render
|
|
60
|
+
document = build_from_type_composer || build_generic
|
|
61
|
+
|
|
62
|
+
resolve_presentation_blocks(document)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
attr_reader :communication, :recipient_id
|
|
68
|
+
|
|
69
|
+
def build_from_type_composer
|
|
70
|
+
declaration = CommandTower::Messaging::NotificationTypes.lookup(communication.notification_type_key)
|
|
71
|
+
composer_name = declaration.inbox_document_composer
|
|
72
|
+
return nil if composer_name.nil?
|
|
73
|
+
|
|
74
|
+
document = composer_name.constantize.call(communication:)
|
|
75
|
+
document.is_a?(InboxDocument) ? document : nil
|
|
76
|
+
rescue StandardError
|
|
77
|
+
# Fail-open: unregistered type, unresolvable class name, a raising
|
|
78
|
+
# composer, or a composer returning invalid data must never surface
|
|
79
|
+
# as a 500 on the Inbox read path (D-INBOX-TYPE).
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_generic
|
|
84
|
+
blocks = []
|
|
85
|
+
blocks << paragraph_block(communication.body) if communication.body.present?
|
|
86
|
+
cta = generic_cta_block
|
|
87
|
+
blocks << cta if cta
|
|
88
|
+
|
|
89
|
+
InboxDocument.build(blocks:)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Re-walks the already-validated blocks array (produced either by a
|
|
93
|
+
# trusted Ruby composer via `InboxDocument::Builder` or by
|
|
94
|
+
# `build_generic`), resolving any `"presentation"` node in place.
|
|
95
|
+
# There is no untrusted JSON to parse/whitelist here — every block was
|
|
96
|
+
# already constructed via a whitelisted `Builder` method or this
|
|
97
|
+
# class's own `paragraph_block`/`cta_block` helpers.
|
|
98
|
+
def resolve_presentation_blocks(document)
|
|
99
|
+
resolved = document.blocks.map do |block|
|
|
100
|
+
block[:type] == "presentation" ? presentation_block(block) : block
|
|
101
|
+
end.compact
|
|
102
|
+
|
|
103
|
+
InboxDocument.build(blocks: resolved)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def presentation_block(block)
|
|
107
|
+
return nil if @presentation_attempted
|
|
108
|
+
|
|
109
|
+
@presentation_attempted = true
|
|
110
|
+
key = block[:key]
|
|
111
|
+
variant = block[:variant]
|
|
112
|
+
|
|
113
|
+
definition = CommandTower.config.registry.inbox_presentations.fetch(key)
|
|
114
|
+
composer_result = definition.composer_class.constantize.call(communication:, recipient_id:)
|
|
115
|
+
|
|
116
|
+
# Duck-typed: a registered composer may be a plain class returning a
|
|
117
|
+
# Hash directly, or a `CommandTower::Services::ApplicationService`
|
|
118
|
+
# (whose `.call` always returns a `ServiceResult`) — either way the
|
|
119
|
+
# unwrapped result below is a plain Hash (Rich Messaging Presentation
|
|
120
|
+
# Authority's composer contract).
|
|
121
|
+
if composer_result.respond_to?(:success?)
|
|
122
|
+
return nil unless composer_result.success?
|
|
123
|
+
|
|
124
|
+
raw_data = composer_result.data
|
|
125
|
+
else
|
|
126
|
+
raw_data = composer_result
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Explicit output projection (corrective slice — presentation
|
|
130
|
+
# ServiceResult/context leak fix): only the keys the presentation's
|
|
131
|
+
# own registration declares in `output_keys` ever reach
|
|
132
|
+
# `content.blocks[].data`. This is a declared allow-list owned by
|
|
133
|
+
# the registration, not an inference from `ApplicationService`
|
|
134
|
+
# argument metadata — composer inputs (e.g. `communication`,
|
|
135
|
+
# `recipient_id` on an `ApplicationService`-shaped composer),
|
|
136
|
+
# incidental intermediate context/result keys, and anything else
|
|
137
|
+
# are dropped unconditionally here, independent of composer shape.
|
|
138
|
+
{ type: "presentation", key:, variant:, data: raw_data.to_h.slice(*definition.output_keys) }.freeze
|
|
139
|
+
rescue StandardError
|
|
140
|
+
# Fail-open: unregistered capability, unresolvable composer class,
|
|
141
|
+
# or a raising/failing composer must never surface as a 500 on the
|
|
142
|
+
# Inbox read path, and must never allow a subsequent presentation
|
|
143
|
+
# block in the same document to still attempt composition (the
|
|
144
|
+
# budget above is consumed regardless of this outcome).
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def paragraph_block(text)
|
|
149
|
+
return nil unless text.is_a?(String)
|
|
150
|
+
return nil if text.empty?
|
|
151
|
+
|
|
152
|
+
{ type: "paragraph", text: }.freeze
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def generic_cta_block
|
|
156
|
+
cta_block(
|
|
157
|
+
label: metadata_value("cta_label"),
|
|
158
|
+
path: metadata_value("deep_link_path"),
|
|
159
|
+
href: metadata_value("deep_link")
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Internal (`path`) takes precedence over external (`href`) when
|
|
164
|
+
# both are present — a `cta` only ever means one navigation intent
|
|
165
|
+
# (Slice 2.6.4 typed destination; see artifacts/visual-improvements/
|
|
166
|
+
# rich-messaging/plans/2.6.4-ct-fe-inbox-navigation-capability.md).
|
|
167
|
+
def cta_block(label:, path: nil, href: nil)
|
|
168
|
+
destination = cta_destination(path:, href:)
|
|
169
|
+
return nil unless destination
|
|
170
|
+
|
|
171
|
+
resolved_label = label.is_a?(String) && !label.strip.empty? ? label : DEFAULT_CTA_LABEL
|
|
172
|
+
{ type: "cta", label: resolved_label, destination: }.freeze
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def cta_destination(path:, href:)
|
|
176
|
+
if safe_internal_path?(path)
|
|
177
|
+
{ kind: "internal", path: }
|
|
178
|
+
elsif safe_href?(href)
|
|
179
|
+
{ kind: "external", href: }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# A leading `//` is rejected even though it "starts with a slash" —
|
|
184
|
+
# it is a protocol-relative URL, not a safe same-app-only destination.
|
|
185
|
+
def safe_internal_path?(path)
|
|
186
|
+
path.is_a?(String) && path.start_with?("/") && !path.start_with?("//")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def safe_href?(href)
|
|
190
|
+
return false unless href.is_a?(String) && !href.strip.empty?
|
|
191
|
+
|
|
192
|
+
scheme = URI.parse(href).scheme&.downcase
|
|
193
|
+
scheme.present? && UNSAFE_SCHEMES.exclude?(scheme)
|
|
194
|
+
rescue URI::InvalidURIError
|
|
195
|
+
false
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def metadata_value(key)
|
|
199
|
+
metadata = communication.metadata
|
|
200
|
+
return nil unless metadata.is_a?(Hash)
|
|
201
|
+
|
|
202
|
+
metadata[key] || metadata[key.to_sym]
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_view"
|
|
4
|
+
|
|
5
|
+
module CommandTower
|
|
6
|
+
module Messaging
|
|
7
|
+
module Rendering
|
|
8
|
+
# Internal Execution-pipeline collaborator (not a Service/Workflow/Controller).
|
|
9
|
+
#
|
|
10
|
+
# Resolves one rendered-destination basename (e.g. "email", "sms") to an
|
|
11
|
+
# ActionView template, preferring a `notification_type_key`-specific view
|
|
12
|
+
# over the generic one, and preferring the host application's `app/views`
|
|
13
|
+
# over CommandTower's own engine views for either.
|
|
14
|
+
#
|
|
15
|
+
# Host convention (documentation, not code):
|
|
16
|
+
#
|
|
17
|
+
# app/views/command_tower/messaging/rendering/
|
|
18
|
+
# email.html.erb # optional host override of the generic chrome
|
|
19
|
+
# <notification_type_key>/email.html.erb # optional type-specific override
|
|
20
|
+
#
|
|
21
|
+
# Hosts customize by dropping ERB files at that path; they never call this
|
|
22
|
+
# class directly. `ChannelRenderer` remains the only public Ruby entry into
|
|
23
|
+
# rendering. `prepend_view_path`/`reset_view_paths!` exist solely as a
|
|
24
|
+
# messaging spec fixture seam (mirrors Rails' own `prepend_view_path`
|
|
25
|
+
# testing idiom) and are not part of any host-facing contract.
|
|
26
|
+
class TemplateResolver
|
|
27
|
+
TEMPLATE_PREFIX = "command_tower/messaging/rendering"
|
|
28
|
+
TYPE_KEY_PATTERN = /\A[a-z0-9_]+\z/
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def render(basename:, format:, notification_type_key:, generic_locals:, type_locals:)
|
|
32
|
+
new.render(
|
|
33
|
+
basename:,
|
|
34
|
+
format:,
|
|
35
|
+
notification_type_key:,
|
|
36
|
+
generic_locals:,
|
|
37
|
+
type_locals:,
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Spec-only seam: prepend an additional view root ahead of the host
|
|
42
|
+
# and engine defaults so fixture views can prove override precedence.
|
|
43
|
+
def prepend_view_path(path)
|
|
44
|
+
extra_view_paths.unshift(path.to_s)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reset_view_paths!
|
|
48
|
+
extra_view_paths.clear
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Type-only, no-generic-fallback resolution: returns `nil` when
|
|
52
|
+
# there is no matching type template (invalid/sanitized-out key, or
|
|
53
|
+
# the type directory/basename simply does not exist) instead of
|
|
54
|
+
# raising `Errno::ENOENT` like `.render` does. Callers that have no
|
|
55
|
+
# generic ERB file to fall back to (e.g. `InboxDocumentRenderer`,
|
|
56
|
+
# whose generic path is a pure-Ruby builder) use this instead of
|
|
57
|
+
# `.render`. A template that is *found* but raises mid-render still
|
|
58
|
+
# propagates (via `render_named`'s `ActionView::Template::Error`
|
|
59
|
+
# unwrap) so callers can distinguish "no type content" from "type
|
|
60
|
+
# content is broken" if they choose to.
|
|
61
|
+
def render_type_template(basename:, format:, notification_type_key:, locals:)
|
|
62
|
+
new.render_type_template(basename:, format:, notification_type_key:, locals:)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def extra_view_paths
|
|
66
|
+
@extra_view_paths ||= []
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render(basename:, format:, notification_type_key:, generic_locals:, type_locals:)
|
|
71
|
+
type_key = sanitized_type_key(notification_type_key)
|
|
72
|
+
|
|
73
|
+
if type_key
|
|
74
|
+
begin
|
|
75
|
+
return render_named("#{TEMPLATE_PREFIX}/#{type_key}/#{basename}", format, type_locals)
|
|
76
|
+
rescue ActionView::MissingTemplate
|
|
77
|
+
# No type-specific template for this basename; fall through to generic.
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
render_named("#{TEMPLATE_PREFIX}/#{basename}", format, generic_locals)
|
|
83
|
+
rescue ActionView::MissingTemplate
|
|
84
|
+
raise Errno::ENOENT, "missing template #{basename}.#{format}.erb"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def render_type_template(basename:, format:, notification_type_key:, locals:)
|
|
89
|
+
type_key = sanitized_type_key(notification_type_key)
|
|
90
|
+
return nil unless type_key
|
|
91
|
+
|
|
92
|
+
render_named("#{TEMPLATE_PREFIX}/#{type_key}/#{basename}", format, locals)
|
|
93
|
+
rescue ActionView::MissingTemplate
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def sanitized_type_key(notification_type_key)
|
|
100
|
+
key = notification_type_key.to_s
|
|
101
|
+
key.match?(TYPE_KEY_PATTERN) ? key : nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def render_named(template_name, format, locals)
|
|
105
|
+
view.render(template: template_name, formats: [format], locals:)
|
|
106
|
+
rescue ActionView::Template::Error => error
|
|
107
|
+
# ActionView wraps any error raised while executing a *found*
|
|
108
|
+
# template in `ActionView::Template::Error`. Legacy plain-ERB
|
|
109
|
+
# rendering never wrapped errors, so unwrap here to preserve the
|
|
110
|
+
# original `error_class` that `ChannelRenderer#render`'s outer
|
|
111
|
+
# rescue surfaces on `RenderError`.
|
|
112
|
+
raise(error.cause || error)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def view
|
|
116
|
+
@view ||= ActionView::Base.with_empty_template_cache.with_view_paths(view_paths)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def view_paths
|
|
120
|
+
self.class.extra_view_paths + [
|
|
121
|
+
Rails.root.join("app/views").to_s,
|
|
122
|
+
CommandTower::Engine.root.join("app/views").to_s,
|
|
123
|
+
]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
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,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Services
|
|
5
|
+
module ClientCompatibility
|
|
6
|
+
# Pure, deterministic client-version-compatibility decision.
|
|
7
|
+
#
|
|
8
|
+
# Same (headers, controller/action, registry state) always produces the
|
|
9
|
+
# same complete `Decision`. This service MUST NOT mutate
|
|
10
|
+
# `CommandTower::Current` or any other request-global — it has zero side
|
|
11
|
+
# effects beyond returning its result. Any placement of recommendation
|
|
12
|
+
# metadata onto `Current` is transport plumbing that belongs to the
|
|
13
|
+
# workflow or boundary layer, never here.
|
|
14
|
+
class Evaluate
|
|
15
|
+
DECISIONS = %i[compatible recommended incompatible identity_invalid].freeze
|
|
16
|
+
|
|
17
|
+
# Immutable decision/projection. `recommendation_projection` is the
|
|
18
|
+
# camelCase `clientCompatibility` shape (authority §13), present only
|
|
19
|
+
# when identity is parseable and current >= effective minimum.
|
|
20
|
+
class Decision
|
|
21
|
+
attr_reader :decision, :platform, :current_version, :effective_minimum, :scope,
|
|
22
|
+
:recovery, :update_url, :matched_entity_names, :recommendation_projection
|
|
23
|
+
|
|
24
|
+
def initialize(
|
|
25
|
+
decision:,
|
|
26
|
+
platform: nil,
|
|
27
|
+
current_version: nil,
|
|
28
|
+
effective_minimum: nil,
|
|
29
|
+
scope: nil,
|
|
30
|
+
recovery: nil,
|
|
31
|
+
update_url: nil,
|
|
32
|
+
matched_entity_names: [],
|
|
33
|
+
recommendation_projection: nil
|
|
34
|
+
)
|
|
35
|
+
@decision = decision
|
|
36
|
+
@platform = platform
|
|
37
|
+
@current_version = current_version
|
|
38
|
+
@effective_minimum = effective_minimum
|
|
39
|
+
@scope = scope
|
|
40
|
+
@recovery = recovery
|
|
41
|
+
@update_url = update_url
|
|
42
|
+
@matched_entity_names = matched_entity_names
|
|
43
|
+
@recommendation_projection = recommendation_projection
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def compatible? = %i[compatible recommended].include?(@decision)
|
|
47
|
+
|
|
48
|
+
def incompatible? = !compatible?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.call(app_version_header:, platform_header:, controller_class:, action_name:, registry: CommandTower.config.registry.client_compatibility)
|
|
52
|
+
new(
|
|
53
|
+
app_version_header:,
|
|
54
|
+
platform_header:,
|
|
55
|
+
controller_class:,
|
|
56
|
+
action_name:,
|
|
57
|
+
registry:
|
|
58
|
+
).call
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(app_version_header:, platform_header:, controller_class:, action_name:, registry:)
|
|
62
|
+
@app_version_header = app_version_header
|
|
63
|
+
@platform_header = platform_header
|
|
64
|
+
@controller_class = controller_class
|
|
65
|
+
@action_name = action_name.to_s
|
|
66
|
+
@registry = registry
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def call
|
|
70
|
+
return no_op_decision unless registry.opted_in?
|
|
71
|
+
|
|
72
|
+
platform = normalized_platform
|
|
73
|
+
version = normalized_version
|
|
74
|
+
return identity_invalid_decision(platform:) if platform.nil? || version.nil?
|
|
75
|
+
|
|
76
|
+
policy = registry.platform_policy(platform)
|
|
77
|
+
return identity_invalid_decision(platform:, current_version: version) if policy.nil?
|
|
78
|
+
|
|
79
|
+
matched = matched_entity_names
|
|
80
|
+
effective_minimum, scope = effective_minimum_and_scope(policy:, platform:, matched:)
|
|
81
|
+
|
|
82
|
+
current = Gem::Version.new(version)
|
|
83
|
+
minimum = Gem::Version.new(effective_minimum)
|
|
84
|
+
|
|
85
|
+
if current < minimum
|
|
86
|
+
return incompatible_decision(
|
|
87
|
+
platform:, current_version: version, effective_minimum:, scope:, matched:, policy:
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
compatible_or_recommended_decision(
|
|
92
|
+
platform:, current_version: version, effective_minimum:, matched:, policy:, current:
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
attr_reader :controller_class, :action_name, :registry
|
|
99
|
+
|
|
100
|
+
def normalized_platform
|
|
101
|
+
token = @platform_header.to_s.strip.downcase
|
|
102
|
+
return nil if token.empty?
|
|
103
|
+
return nil unless CommandTower::Configuration::Registry::ClientCompatibility::Config::PLATFORMS.map(&:to_s).include?(token)
|
|
104
|
+
|
|
105
|
+
token.to_sym
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def normalized_version
|
|
109
|
+
CommandTower::ClientCompatibility::Version.normalize(@app_version_header)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def matched_entity_names
|
|
113
|
+
CommandTower::Authorization::Entity.entities.values
|
|
114
|
+
.select { |entity| entity.matches?(controller: controller_class, method: action_name.to_sym) == true }
|
|
115
|
+
.map { |entity| entity.name.to_s }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def effective_minimum_and_scope(policy:, platform:, matched:)
|
|
119
|
+
strictest = policy.minimum
|
|
120
|
+
scope = :application
|
|
121
|
+
|
|
122
|
+
matched.each do |entity_name|
|
|
123
|
+
requirement = registry.entity_requirement(entity_name)
|
|
124
|
+
next if requirement.nil?
|
|
125
|
+
|
|
126
|
+
entity_minimum = entity_minimum_for(requirement, platform:)
|
|
127
|
+
next if entity_minimum.nil?
|
|
128
|
+
|
|
129
|
+
if Gem::Version.new(entity_minimum) > Gem::Version.new(strictest)
|
|
130
|
+
strictest = entity_minimum
|
|
131
|
+
scope = :capability
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
[strictest, scope]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def entity_minimum_for(requirement, platform:)
|
|
139
|
+
if requirement.client_contract.present?
|
|
140
|
+
binding = registry.binding_for(requirement.client_contract)
|
|
141
|
+
binding&.for_platform(platform)
|
|
142
|
+
else
|
|
143
|
+
requirement.minimum.for_platform(platform)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def no_op_decision
|
|
148
|
+
Decision.new(decision: :compatible)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def identity_invalid_decision(platform: nil, current_version: nil)
|
|
152
|
+
Decision.new(
|
|
153
|
+
decision: :identity_invalid,
|
|
154
|
+
platform:,
|
|
155
|
+
current_version:,
|
|
156
|
+
scope: :application,
|
|
157
|
+
recovery: recovery_for(platform),
|
|
158
|
+
update_url: update_url_for(platform)
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def incompatible_decision(platform:, current_version:, effective_minimum:, scope:, matched:, policy:)
|
|
163
|
+
Decision.new(
|
|
164
|
+
decision: :incompatible,
|
|
165
|
+
platform:,
|
|
166
|
+
current_version:,
|
|
167
|
+
effective_minimum:,
|
|
168
|
+
scope:,
|
|
169
|
+
recovery: recovery_for(platform),
|
|
170
|
+
update_url: update_url_for(platform),
|
|
171
|
+
matched_entity_names: matched,
|
|
172
|
+
recommendation_projection: nil
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def compatible_or_recommended_decision(platform:, current_version:, effective_minimum:, matched:, policy:, current:)
|
|
177
|
+
recommended = policy.recommended
|
|
178
|
+
update_available = recommended.present? && current < Gem::Version.new(recommended)
|
|
179
|
+
|
|
180
|
+
Decision.new(
|
|
181
|
+
decision: update_available ? :recommended : :compatible,
|
|
182
|
+
platform:,
|
|
183
|
+
current_version:,
|
|
184
|
+
effective_minimum:,
|
|
185
|
+
scope: :application,
|
|
186
|
+
matched_entity_names: matched,
|
|
187
|
+
recommendation_projection: recommendation_projection(
|
|
188
|
+
platform:, current_version:, effective_minimum:, recommended:, update_available:
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def recommendation_projection(platform:, current_version:, effective_minimum:, recommended:, update_available:)
|
|
194
|
+
{
|
|
195
|
+
platform: platform.to_s,
|
|
196
|
+
currentVersion: current_version,
|
|
197
|
+
minimumVersion: effective_minimum,
|
|
198
|
+
recommendedVersion: recommended,
|
|
199
|
+
updateAvailable: update_available,
|
|
200
|
+
recovery: recovery_for(platform).to_s,
|
|
201
|
+
updateUrl: update_url_for(platform)
|
|
202
|
+
}.compact
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def recovery_for(platform)
|
|
206
|
+
return :reload if platform.nil? || platform == :web
|
|
207
|
+
|
|
208
|
+
:app_store
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def update_url_for(platform)
|
|
212
|
+
return nil if platform.nil? || platform == :web
|
|
213
|
+
|
|
214
|
+
registry.platform_policy(platform)&.update_url
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -120,7 +120,14 @@ module CommandTower
|
|
|
120
120
|
communication = CommandTower::Messaging::Contract::Communications.find(
|
|
121
121
|
CommandTower::Messaging::Contract::Requests::FindCommunication.build(communication_id: item.communication_id, recipient_id:)
|
|
122
122
|
)
|
|
123
|
-
|
|
123
|
+
content = CommandTower::Messaging::Rendering::InboxDocumentRenderer.render(communication:, recipient_id:).to_h
|
|
124
|
+
|
|
125
|
+
item(item).merge(
|
|
126
|
+
body: communication.body,
|
|
127
|
+
metadata: communication.metadata,
|
|
128
|
+
notification_type_key: communication.notification_type_key,
|
|
129
|
+
content:,
|
|
130
|
+
)
|
|
124
131
|
end
|
|
125
132
|
end
|
|
126
133
|
end
|