command_tower 0.16.0 → 0.18.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/README.md +8 -8
- data/app/controllers/command_tower/application_controller.rb +1 -0
- data/app/controllers/command_tower/me/inbox_controller.rb +16 -0
- data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
- data/app/deserializers/command_tower/deserializers/messaging/inbox.rb +85 -0
- data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
- data/app/jobs/command_tower/messaging/communications/produce_recipient_job.rb +2 -0
- data/app/models/command_tower/messaging/communication.rb +31 -0
- data/app/serializers/command_tower/serializers/messaging/inbox.rb +45 -1
- data/app/services/command_tower/email_theme/resolver.rb +45 -0
- data/app/services/command_tower/messaging/accept/coordinator.rb +16 -1
- data/app/services/command_tower/messaging/accept/persister.rb +13 -0
- data/app/services/command_tower/messaging/contract/mappers/communication_mapper.rb +7 -0
- data/app/services/command_tower/messaging/contract/results/communication_result.rb +1 -0
- data/app/services/command_tower/messaging/inbox/conversation_result.rb +31 -0
- data/app/services/command_tower/messaging/inbox/entry_result.rb +36 -0
- data/app/services/command_tower/messaging/inbox/reader.rb +231 -18
- data/app/services/command_tower/messaging/inbox.rb +4 -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 +224 -0
- data/app/services/command_tower/messaging/rendering/inbox_presentation_resolver.rb +52 -0
- data/app/services/command_tower/messaging/rendering/inbox_presentation_snapshot.rb +64 -0
- data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
- data/app/services/command_tower/messaging.rb +5 -1
- data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
- data/app/services/command_tower/services/messaging/communications/produce.rb +4 -0
- data/app/services/command_tower/services/messaging/communications/produce_many.rb +6 -0
- data/app/services/command_tower/services/messaging/inbox.rb +55 -2
- 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/messaging/communications/produce_recipient_workflow.rb +5 -1
- data/app/workflows/command_tower/workflows/messaging/inbox.rb +15 -1
- data/config/routes.rb +1 -0
- data/db/migrate/20261010000001_add_inbox_presentation_snapshot_to_messaging_communications.rb +7 -0
- data/db/migrate/20261010000002_add_conversation_identity_to_messaging_communications.rb +10 -0
- data/docs/api_reference.md +13 -2
- data/docs/authorization.md +1 -1
- data/docs/bootstrap/00-ownership.md +77 -0
- data/docs/bootstrap/01-docker-make-compose.md +267 -0
- data/docs/bootstrap/02-create-the-rails-app.md +75 -0
- data/docs/bootstrap/03-pin-the-gem.md +53 -0
- data/docs/bootstrap/04-secrets-and-env.md +59 -0
- data/docs/bootstrap/05-install-migrate-doctor.md +74 -0
- data/docs/bootstrap/06-mount-and-health.md +53 -0
- data/docs/bootstrap/07-execution-bases.md +38 -0
- data/docs/bootstrap/08-initializer.md +90 -0
- data/docs/bootstrap/09-rbac.md +83 -0
- data/docs/bootstrap/10-roles-and-gates.md +62 -0
- data/docs/bootstrap/11-auth-client-path.md +57 -0
- data/docs/bootstrap/12-smoke-check.md +81 -0
- data/docs/bootstrap/13-optional.md +50 -0
- data/docs/bootstrap/14-sanity-checks.md +44 -0
- data/docs/bootstrap/README.md +90 -0
- data/docs/cookie_authentication_guide.md +11 -0
- data/docs/extending.md +5 -4
- data/docs/host_integration_guide.md +3 -246
- data/docs/initializing.md +37 -28
- data/docs/messaging_integration_guide.md +39 -1
- data/docs/principal_capabilities.md +1 -1
- data/docs/upgrades/0.10.0.md +5 -5
- data/docs/upgrades/0.11.0.md +5 -5
- data/docs/upgrades/0.17.0.md +33 -0
- data/docs/upgrades/0.18.0.md +35 -0
- data/docs/upgrades/README.md +3 -1
- data/lib/command_tower/authorization/default.yml +1 -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/config.rb +6 -0
- data/lib/command_tower/configuration/email_theme/config.rb +69 -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 +2 -0
- data/lib/command_tower/version.rb +1 -1
- metadata +47 -2
|
@@ -0,0 +1,224 @@
|
|
|
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
|
+
# A stored `inbox_presentation_snapshot` replaces that live resolution.
|
|
40
|
+
# Null means there is no retained presentation (legacy rows, or a
|
|
41
|
+
# document with no presentation node) and the live composer still runs.
|
|
42
|
+
# An omitted snapshot drops the presentation node and does not call
|
|
43
|
+
# the composer. A stored payload is rendered as-is.
|
|
44
|
+
#
|
|
45
|
+
# The composer's raw result is always projected down to the
|
|
46
|
+
# registration's declared `output_keys` before it reaches
|
|
47
|
+
# `content.blocks[].data` (corrective slice — presentation
|
|
48
|
+
# ServiceResult/context leak fix). Composer inputs and any other
|
|
49
|
+
# incidental context/result keys never reach the wire, regardless of
|
|
50
|
+
# whether the composer is `ApplicationService`-shaped or a plain Hash.
|
|
51
|
+
class InboxDocumentRenderer
|
|
52
|
+
UNSAFE_SCHEMES = %w[javascript data vbscript].freeze
|
|
53
|
+
DEFAULT_CTA_LABEL = "Open"
|
|
54
|
+
|
|
55
|
+
def self.render(communication:, recipient_id:)
|
|
56
|
+
new(communication:, recipient_id:).render
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.compose_shell(communication)
|
|
60
|
+
new(communication:, recipient_id: nil).compose_shell
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(communication:, recipient_id:)
|
|
64
|
+
@communication = communication
|
|
65
|
+
@recipient_id = recipient_id
|
|
66
|
+
@presentation_attempted = false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def render
|
|
70
|
+
document = compose_shell
|
|
71
|
+
snapshot = stored_presentation_snapshot
|
|
72
|
+
return resolve_presentation_blocks(document) if snapshot.nil?
|
|
73
|
+
return drop_presentation_blocks(document) if InboxPresentationSnapshot.omitted?(snapshot)
|
|
74
|
+
|
|
75
|
+
apply_stored_presentation(document, snapshot)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def compose_shell
|
|
79
|
+
build_from_type_composer || build_generic
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
attr_reader :communication, :recipient_id
|
|
85
|
+
|
|
86
|
+
def build_from_type_composer
|
|
87
|
+
declaration = CommandTower::Messaging::NotificationTypes.lookup(communication.notification_type_key)
|
|
88
|
+
composer_name = declaration.inbox_document_composer
|
|
89
|
+
return nil if composer_name.nil?
|
|
90
|
+
|
|
91
|
+
document = composer_name.constantize.call(communication:)
|
|
92
|
+
document.is_a?(InboxDocument) ? document : nil
|
|
93
|
+
rescue StandardError
|
|
94
|
+
# Fail-open: unregistered type, unresolvable class name, a raising
|
|
95
|
+
# composer, or a composer returning invalid data must never surface
|
|
96
|
+
# as a 500 on the Inbox read path (D-INBOX-TYPE).
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_generic
|
|
101
|
+
blocks = []
|
|
102
|
+
blocks << paragraph_block(communication.body) if communication.body.present?
|
|
103
|
+
cta = generic_cta_block
|
|
104
|
+
blocks << cta if cta
|
|
105
|
+
|
|
106
|
+
InboxDocument.build(blocks:)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Re-walks the already-validated blocks array (produced either by a
|
|
110
|
+
# trusted Ruby composer via `InboxDocument::Builder` or by
|
|
111
|
+
# `build_generic`), resolving any `"presentation"` node in place.
|
|
112
|
+
# There is no untrusted JSON to parse/whitelist here — every block was
|
|
113
|
+
# already constructed via a whitelisted `Builder` method or this
|
|
114
|
+
# class's own `paragraph_block`/`cta_block` helpers.
|
|
115
|
+
def resolve_presentation_blocks(document)
|
|
116
|
+
resolved = document.blocks.map do |block|
|
|
117
|
+
block[:type] == "presentation" ? presentation_block(block) : block
|
|
118
|
+
end.compact
|
|
119
|
+
|
|
120
|
+
InboxDocument.build(blocks: resolved)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def presentation_block(block)
|
|
124
|
+
return nil if @presentation_attempted
|
|
125
|
+
|
|
126
|
+
@presentation_attempted = true
|
|
127
|
+
InboxPresentationResolver.resolve(communication:, recipient_id:, block:)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def stored_presentation_snapshot
|
|
131
|
+
raw = communication.inbox_presentation_snapshot
|
|
132
|
+
return nil if raw.blank?
|
|
133
|
+
return nil unless raw.respond_to?(:stringify_keys)
|
|
134
|
+
|
|
135
|
+
raw.stringify_keys
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def drop_presentation_blocks(document)
|
|
139
|
+
InboxDocument.build(blocks: document.blocks.reject { |block| block[:type] == "presentation" })
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def apply_stored_presentation(document, snapshot)
|
|
143
|
+
applied = false
|
|
144
|
+
blocks = document.blocks.filter_map do |block|
|
|
145
|
+
next block unless block[:type] == "presentation"
|
|
146
|
+
next nil if applied
|
|
147
|
+
|
|
148
|
+
applied = true
|
|
149
|
+
stored_presentation_block(snapshot)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
InboxDocument.build(blocks:)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def stored_presentation_block(snapshot)
|
|
156
|
+
data = snapshot["data"].respond_to?(:to_h) ? snapshot["data"].to_h.deep_symbolize_keys : {}
|
|
157
|
+
{
|
|
158
|
+
type: "presentation",
|
|
159
|
+
key: snapshot["key"].to_s,
|
|
160
|
+
variant: snapshot["variant"],
|
|
161
|
+
data:,
|
|
162
|
+
}.freeze
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def paragraph_block(text)
|
|
166
|
+
return nil unless text.is_a?(String)
|
|
167
|
+
return nil if text.empty?
|
|
168
|
+
|
|
169
|
+
{ type: "paragraph", text: }.freeze
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def generic_cta_block
|
|
173
|
+
cta_block(
|
|
174
|
+
label: metadata_value("cta_label"),
|
|
175
|
+
path: metadata_value("deep_link_path"),
|
|
176
|
+
href: metadata_value("deep_link")
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Internal (`path`) takes precedence over external (`href`) when
|
|
181
|
+
# both are present — a `cta` only ever means one navigation intent
|
|
182
|
+
# (Slice 2.6.4 typed destination; see artifacts/visual-improvements/
|
|
183
|
+
# rich-messaging/plans/2.6.4-ct-fe-inbox-navigation-capability.md).
|
|
184
|
+
def cta_block(label:, path: nil, href: nil)
|
|
185
|
+
destination = cta_destination(path:, href:)
|
|
186
|
+
return nil unless destination
|
|
187
|
+
|
|
188
|
+
resolved_label = label.is_a?(String) && !label.strip.empty? ? label : DEFAULT_CTA_LABEL
|
|
189
|
+
{ type: "cta", label: resolved_label, destination: }.freeze
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def cta_destination(path:, href:)
|
|
193
|
+
if safe_internal_path?(path)
|
|
194
|
+
{ kind: "internal", path: }
|
|
195
|
+
elsif safe_href?(href)
|
|
196
|
+
{ kind: "external", href: }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# A leading `//` is rejected even though it "starts with a slash" —
|
|
201
|
+
# it is a protocol-relative URL, not a safe same-app-only destination.
|
|
202
|
+
def safe_internal_path?(path)
|
|
203
|
+
path.is_a?(String) && path.start_with?("/") && !path.start_with?("//")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def safe_href?(href)
|
|
207
|
+
return false unless href.is_a?(String) && !href.strip.empty?
|
|
208
|
+
|
|
209
|
+
scheme = URI.parse(href).scheme&.downcase
|
|
210
|
+
scheme.present? && UNSAFE_SCHEMES.exclude?(scheme)
|
|
211
|
+
rescue URI::InvalidURIError
|
|
212
|
+
false
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def metadata_value(key)
|
|
216
|
+
metadata = communication.metadata
|
|
217
|
+
return nil unless metadata.is_a?(Hash)
|
|
218
|
+
|
|
219
|
+
metadata[key] || metadata[key.to_sym]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Messaging
|
|
5
|
+
module Rendering
|
|
6
|
+
# Resolves one presentation block through the inbox presentation registry.
|
|
7
|
+
# Returns the projected block, or nil when the capability is missing,
|
|
8
|
+
# the composer fails, or the composer raises. Does not persist and does
|
|
9
|
+
# not perform network I/O. Callers own the one-presentation budget.
|
|
10
|
+
class InboxPresentationResolver
|
|
11
|
+
def self.resolve(communication:, recipient_id:, block:)
|
|
12
|
+
new(communication:, recipient_id:, block:).resolve
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(communication:, recipient_id:, block:)
|
|
16
|
+
@communication = communication
|
|
17
|
+
@recipient_id = recipient_id
|
|
18
|
+
@block = block
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve
|
|
22
|
+
definition = CommandTower.config.registry.inbox_presentations.fetch(block[:key])
|
|
23
|
+
raw_data = unwrap(definition.composer_class.constantize.call(communication:, recipient_id:))
|
|
24
|
+
return nil if raw_data.nil?
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
type: "presentation",
|
|
28
|
+
key: block[:key],
|
|
29
|
+
variant: block[:variant],
|
|
30
|
+
data: raw_data.to_h.slice(*definition.output_keys),
|
|
31
|
+
}.freeze
|
|
32
|
+
rescue StandardError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
attr_reader :communication, :recipient_id, :block
|
|
39
|
+
|
|
40
|
+
def unwrap(composer_result)
|
|
41
|
+
if composer_result.respond_to?(:success?)
|
|
42
|
+
return nil unless composer_result.success?
|
|
43
|
+
|
|
44
|
+
composer_result.data
|
|
45
|
+
else
|
|
46
|
+
composer_result
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Messaging
|
|
5
|
+
module Rendering
|
|
6
|
+
# Captures the first presentation node's projected data at Accept time.
|
|
7
|
+
# Returns nil when the document has no presentation node. Returns an
|
|
8
|
+
# omitted marker when a presentation node was seen and capture failed.
|
|
9
|
+
# Never persists and never performs network I/O.
|
|
10
|
+
class InboxPresentationSnapshot
|
|
11
|
+
VERSION = 1
|
|
12
|
+
|
|
13
|
+
def self.capture(communication:, recipient_id:)
|
|
14
|
+
new(communication:, recipient_id:).capture
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.omitted?(snapshot)
|
|
18
|
+
snapshot.is_a?(Hash) && snapshot.stringify_keys["omitted"] == true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(communication:, recipient_id:)
|
|
22
|
+
@communication = communication
|
|
23
|
+
@recipient_id = recipient_id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def capture
|
|
27
|
+
block = first_presentation_block
|
|
28
|
+
return nil if block.nil?
|
|
29
|
+
|
|
30
|
+
capture_seen_block(block)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :communication, :recipient_id
|
|
36
|
+
|
|
37
|
+
def capture_seen_block(block)
|
|
38
|
+
resolved = InboxPresentationResolver.resolve(communication:, recipient_id:, block:)
|
|
39
|
+
resolved.nil? ? omitted_marker : payload(resolved)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
omitted_marker
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def first_presentation_block
|
|
45
|
+
document = InboxDocumentRenderer.compose_shell(communication)
|
|
46
|
+
document.blocks.find { |block| block[:type] == "presentation" }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def payload(resolved)
|
|
50
|
+
{
|
|
51
|
+
"v" => VERSION,
|
|
52
|
+
"key" => resolved[:key].to_s,
|
|
53
|
+
"variant" => resolved[:variant],
|
|
54
|
+
"data" => resolved[:data].deep_stringify_keys,
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def omitted_marker
|
|
59
|
+
{ "v" => VERSION, "omitted" => true }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
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
|
|
@@ -13,7 +13,9 @@ module CommandTower
|
|
|
13
13
|
metadata: nil,
|
|
14
14
|
preference_state: nil,
|
|
15
15
|
platform_enabled_channels:,
|
|
16
|
-
message_overrides: nil
|
|
16
|
+
message_overrides: nil,
|
|
17
|
+
conversation_key: nil,
|
|
18
|
+
conversation_title: nil
|
|
17
19
|
)
|
|
18
20
|
Accept::Coordinator.call(
|
|
19
21
|
recipient_id:,
|
|
@@ -25,6 +27,8 @@ module CommandTower
|
|
|
25
27
|
preference_state:,
|
|
26
28
|
platform_enabled_channels:,
|
|
27
29
|
message_overrides:,
|
|
30
|
+
conversation_key:,
|
|
31
|
+
conversation_title:,
|
|
28
32
|
)
|
|
29
33
|
end
|
|
30
34
|
|