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
|
@@ -6,6 +6,9 @@ module CommandTower
|
|
|
6
6
|
class Reader
|
|
7
7
|
DEFAULT_LIMIT = 50
|
|
8
8
|
MAX_LIMIT = 100
|
|
9
|
+
WINDOW_LIMIT = 5
|
|
10
|
+
WINDOW_MAX_OLDER = 2
|
|
11
|
+
OLDEST_UNREAD = "oldest_unread"
|
|
9
12
|
SCOPE_INBOX = "inbox"
|
|
10
13
|
SCOPE_ARCHIVED = "archived"
|
|
11
14
|
ALLOWED_SCOPES = [SCOPE_INBOX, SCOPE_ARCHIVED].freeze
|
|
@@ -17,28 +20,36 @@ module CommandTower
|
|
|
17
20
|
offset = normalize_offset!(offset)
|
|
18
21
|
scope = normalize_scope!(scope)
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
else
|
|
24
|
-
Messaging::InboxItem.default_list
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
scoped = Scope.for_recipient(recipient_id).merge(list_scope)
|
|
28
|
-
total_count = scoped.count
|
|
29
|
-
records =
|
|
30
|
-
scoped
|
|
31
|
-
.includes(:communication)
|
|
32
|
-
.order(Arel.sql("messaging_inbox_items.created_at DESC, messaging_inbox_items.id DESC"))
|
|
33
|
-
.limit(limit)
|
|
34
|
-
.offset(offset)
|
|
35
|
-
.to_a
|
|
23
|
+
scoped = Scope.for_recipient(recipient_id).merge(list_scope_for(scope))
|
|
24
|
+
page_rows = entry_page_rows(scoped, limit:, offset:)
|
|
25
|
+
members = page_members(scoped, page_rows)
|
|
36
26
|
|
|
37
27
|
ListResult.build(
|
|
38
|
-
items:
|
|
28
|
+
items: page_rows.map { |row| build_entry(row, members) },
|
|
39
29
|
limit:,
|
|
40
30
|
offset:,
|
|
41
|
-
total_count
|
|
31
|
+
total_count: entry_count(scoped),
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def conversation(recipient_id:, key:, scope: SCOPE_INBOX, around: nil, before_id: nil, after_id: nil, limit: nil)
|
|
36
|
+
recipient_id = require_recipient_id!(recipient_id)
|
|
37
|
+
raise ValidationError, "key is required" if blank?(key)
|
|
38
|
+
|
|
39
|
+
scope = normalize_scope!(scope)
|
|
40
|
+
records = members_for_key(recipient_id:, key:, scope:)
|
|
41
|
+
raise NotFoundError, "conversation not found" if records.empty?
|
|
42
|
+
|
|
43
|
+
window = conversation_window(records, around:, before_id:, after_id:, limit:)
|
|
44
|
+
latest = records.last
|
|
45
|
+
ConversationResult.build(
|
|
46
|
+
scope:,
|
|
47
|
+
subject: latest.communication.conversation_title,
|
|
48
|
+
message_count: records.size,
|
|
49
|
+
unread_count: records.count { |record| record.viewed_at.nil? },
|
|
50
|
+
inbox_item_ids: records.map(&:id),
|
|
51
|
+
message_ids: window ? window.fetch(:message_ids) : records.map(&:id),
|
|
52
|
+
window: window&.except(:message_ids),
|
|
42
53
|
)
|
|
43
54
|
end
|
|
44
55
|
|
|
@@ -106,6 +117,208 @@ module CommandTower
|
|
|
106
117
|
value
|
|
107
118
|
end
|
|
108
119
|
|
|
120
|
+
def list_scope_for(scope)
|
|
121
|
+
if scope == SCOPE_ARCHIVED
|
|
122
|
+
Messaging::InboxItem.archived_list
|
|
123
|
+
else
|
|
124
|
+
Messaging::InboxItem.default_list
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def entry_page_rows(scoped, limit:, offset:)
|
|
129
|
+
sql = <<~SQL.squish
|
|
130
|
+
SELECT conversation_key, inbox_item_id AS latest_id, item_created_at AS activity_at
|
|
131
|
+
FROM (
|
|
132
|
+
SELECT
|
|
133
|
+
members.*,
|
|
134
|
+
ROW_NUMBER() OVER (
|
|
135
|
+
PARTITION BY entry_group
|
|
136
|
+
ORDER BY item_created_at DESC, inbox_item_id DESC
|
|
137
|
+
) AS recency_rank
|
|
138
|
+
FROM (#{member_groups_sql(scoped)}) members
|
|
139
|
+
) ranked
|
|
140
|
+
WHERE recency_rank = 1
|
|
141
|
+
ORDER BY activity_at DESC, latest_id DESC
|
|
142
|
+
LIMIT #{limit} OFFSET #{offset}
|
|
143
|
+
SQL
|
|
144
|
+
|
|
145
|
+
Messaging::InboxItem.connection.select_all(sql).to_a
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def entry_count(scoped)
|
|
149
|
+
sql = <<~SQL.squish
|
|
150
|
+
SELECT COUNT(*) FROM (
|
|
151
|
+
SELECT entry_group
|
|
152
|
+
FROM (#{member_groups_sql(scoped)}) members
|
|
153
|
+
GROUP BY entry_group
|
|
154
|
+
) entry_groups
|
|
155
|
+
SQL
|
|
156
|
+
|
|
157
|
+
Messaging::InboxItem.connection.select_value(sql).to_i
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def member_groups_sql(scoped)
|
|
161
|
+
scoped.select(Arel.sql(<<~SQL.squish)).to_sql
|
|
162
|
+
messaging_inbox_items.id AS inbox_item_id,
|
|
163
|
+
messaging_inbox_items.created_at AS item_created_at,
|
|
164
|
+
messaging_communications.conversation_key AS conversation_key,
|
|
165
|
+
CASE
|
|
166
|
+
WHEN messaging_communications.conversation_key IS NULL
|
|
167
|
+
THEN CONCAT('m:', messaging_inbox_items.id)
|
|
168
|
+
ELSE CONCAT('c:', messaging_communications.conversation_key)
|
|
169
|
+
END AS entry_group
|
|
170
|
+
SQL
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def page_members(scoped, page_rows)
|
|
174
|
+
keys = page_rows.filter_map { |row| row_value(row, "conversation_key").presence }
|
|
175
|
+
standalone_ids = page_rows.filter_map do |row|
|
|
176
|
+
next if row_value(row, "conversation_key").present?
|
|
177
|
+
|
|
178
|
+
Integer(row_value(row, "latest_id"))
|
|
179
|
+
end
|
|
180
|
+
ids = standalone_ids.dup
|
|
181
|
+
if keys.any?
|
|
182
|
+
ids.concat(
|
|
183
|
+
scoped.where(messaging_communications: { conversation_key: keys }).pluck("messaging_inbox_items.id"),
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
return [] if ids.empty?
|
|
187
|
+
|
|
188
|
+
scoped.where(messaging_inbox_items: { id: ids }).includes(:communication).to_a
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def build_entry(row, members)
|
|
192
|
+
latest_id = Integer(row_value(row, "latest_id"))
|
|
193
|
+
key = row_value(row, "conversation_key").presence
|
|
194
|
+
group =
|
|
195
|
+
if key
|
|
196
|
+
members.select { |member| member.communication.conversation_key == key }
|
|
197
|
+
else
|
|
198
|
+
members.select { |member| member.id == latest_id }
|
|
199
|
+
end
|
|
200
|
+
group = group.sort_by { |member| [member.created_at, member.id] }
|
|
201
|
+
latest = group.find { |member| member.id == latest_id }
|
|
202
|
+
raise InvariantError, "latest inbox member missing" if latest.nil?
|
|
203
|
+
|
|
204
|
+
conversation = key.present?
|
|
205
|
+
EntryResult.build(
|
|
206
|
+
kind: conversation ? "conversation" : "message",
|
|
207
|
+
id: conversation ? key : latest.id,
|
|
208
|
+
title: conversation ? latest.communication.conversation_title : latest.communication.title,
|
|
209
|
+
preview: conversation ? latest.communication.title : nil,
|
|
210
|
+
activity_at: latest.created_at,
|
|
211
|
+
message_count: group.size,
|
|
212
|
+
unread_count: group.count { |member| member.viewed_at.nil? },
|
|
213
|
+
inbox_item_ids: group.map(&:id),
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def conversation_window(records, around:, before_id:, after_id:, limit:)
|
|
218
|
+
windowed = !around.nil? || !before_id.nil? || !after_id.nil?
|
|
219
|
+
unless windowed
|
|
220
|
+
raise ValidationError, "invalid_limit" unless limit.nil?
|
|
221
|
+
|
|
222
|
+
return
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
modes = [around, before_id, after_id].count { |value| !value.nil? }
|
|
226
|
+
raise ValidationError, "invalid_window" unless modes == 1
|
|
227
|
+
|
|
228
|
+
page_limit = normalize_window_limit!(limit)
|
|
229
|
+
cursor = nil
|
|
230
|
+
anchor = nil
|
|
231
|
+
slice =
|
|
232
|
+
if !around.nil?
|
|
233
|
+
anchor = resolve_anchor(records, around)
|
|
234
|
+
around_slice(records, anchor, page_limit)
|
|
235
|
+
elsif !before_id.nil?
|
|
236
|
+
cursor = find_member!(records, before_id)
|
|
237
|
+
records.select { |record| before_record?(record, cursor) }.last(page_limit)
|
|
238
|
+
else
|
|
239
|
+
cursor = find_member!(records, after_id)
|
|
240
|
+
records.select { |record| before_record?(cursor, record) }.first(page_limit)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
flags = window_flags(records, slice, cursor)
|
|
244
|
+
{
|
|
245
|
+
message_ids: slice.map(&:id),
|
|
246
|
+
anchor_inbox_item_id: anchor&.id,
|
|
247
|
+
has_before: flags.fetch(:has_before),
|
|
248
|
+
has_after: flags.fetch(:has_after),
|
|
249
|
+
before_cursor: flags.fetch(:before_cursor),
|
|
250
|
+
after_cursor: flags.fetch(:after_cursor),
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def resolve_anchor(records, around)
|
|
255
|
+
return records.find { |record| record.viewed_at.nil? } || records.last if around.to_s == OLDEST_UNREAD
|
|
256
|
+
|
|
257
|
+
find_member!(records, around)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def around_slice(records, anchor, limit)
|
|
261
|
+
index = records.index(anchor)
|
|
262
|
+
before_count = [WINDOW_MAX_OLDER, index, limit - 1].min
|
|
263
|
+
after_count = [limit - 1 - before_count, records.length - 1 - index].min
|
|
264
|
+
records[(index - before_count)..(index + after_count)]
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def find_member!(records, inbox_item_id)
|
|
268
|
+
id = Integer(inbox_item_id)
|
|
269
|
+
records.find { |record| record.id == id } || raise(ValidationError, "invalid_cursor")
|
|
270
|
+
rescue ArgumentError, TypeError
|
|
271
|
+
raise ValidationError, "invalid_cursor"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def window_flags(records, slice, cursor)
|
|
275
|
+
if slice.any?
|
|
276
|
+
first = slice.first
|
|
277
|
+
last = slice.last
|
|
278
|
+
{
|
|
279
|
+
has_before: records.any? { |record| before_record?(record, first) },
|
|
280
|
+
has_after: records.any? { |record| before_record?(last, record) },
|
|
281
|
+
before_cursor: first.id,
|
|
282
|
+
after_cursor: last.id,
|
|
283
|
+
}
|
|
284
|
+
else
|
|
285
|
+
{
|
|
286
|
+
has_before: !cursor.nil? && records.any? { |record| before_record?(record, cursor) },
|
|
287
|
+
has_after: !cursor.nil? && records.any? { |record| before_record?(cursor, record) },
|
|
288
|
+
before_cursor: nil,
|
|
289
|
+
after_cursor: nil,
|
|
290
|
+
}
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def before_record?(candidate, boundary)
|
|
295
|
+
return candidate.id < boundary.id if candidate.created_at == boundary.created_at
|
|
296
|
+
|
|
297
|
+
candidate.created_at < boundary.created_at
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def normalize_window_limit!(limit)
|
|
301
|
+
value = limit.nil? ? WINDOW_LIMIT : Integer(limit)
|
|
302
|
+
raise ValidationError, "invalid_limit" if value < 1 || value > WINDOW_LIMIT
|
|
303
|
+
|
|
304
|
+
value
|
|
305
|
+
rescue ArgumentError, TypeError
|
|
306
|
+
raise ValidationError, "invalid_limit"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def members_for_key(recipient_id:, key:, scope:)
|
|
310
|
+
Scope.for_recipient(recipient_id)
|
|
311
|
+
.merge(list_scope_for(scope))
|
|
312
|
+
.includes(:communication)
|
|
313
|
+
.where(messaging_communications: { conversation_key: key })
|
|
314
|
+
.order(Arel.sql("messaging_inbox_items.created_at ASC, messaging_inbox_items.id ASC"))
|
|
315
|
+
.to_a
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def row_value(row, key)
|
|
319
|
+
row[key] || row[key.to_sym]
|
|
320
|
+
end
|
|
321
|
+
|
|
109
322
|
def blank?(value)
|
|
110
323
|
value.nil? || (value.respond_to?(:empty?) && value.empty?) ||
|
|
111
324
|
(value.is_a?(String) && value.strip.empty?)
|
|
@@ -13,6 +13,10 @@ module CommandTower
|
|
|
13
13
|
Reader.show(recipient_id:, inbox_item_id:)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def conversation(recipient_id:, key:, scope: Reader::SCOPE_INBOX, around: nil, before_id: nil, after_id: nil, limit: nil)
|
|
17
|
+
Reader.conversation(recipient_id:, key:, scope:, around:, before_id:, after_id:, limit:)
|
|
18
|
+
end
|
|
19
|
+
|
|
16
20
|
def unread_count(recipient_id:)
|
|
17
21
|
Reader.unread_count(recipient_id:)
|
|
18
22
|
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
|