command_tower 0.17.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/me/inbox_controller.rb +16 -0
- data/app/deserializers/command_tower/deserializers/messaging/inbox.rb +85 -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 +43 -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/rendering/inbox_document_renderer.rb +53 -36
- 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.rb +5 -1
- 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 +47 -1
- 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/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 +3 -3
- data/docs/host_integration_guide.md +3 -281
- data/docs/initializing.md +37 -28
- data/docs/messaging_integration_guide.md +1 -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.18.0.md +35 -0
- data/docs/upgrades/README.md +2 -1
- data/lib/command_tower/authorization/default.yml +1 -0
- data/lib/command_tower/install/baseline.rb +2 -0
- data/lib/command_tower/version.rb +1 -1
- metadata +25 -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
|
|
@@ -36,6 +36,12 @@ module CommandTower
|
|
|
36
36
|
# fail open and then let a second node still execute composer domain
|
|
37
37
|
# I/O.
|
|
38
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
|
+
#
|
|
39
45
|
# The composer's raw result is always projected down to the
|
|
40
46
|
# registration's declared `output_keys` before it reaches
|
|
41
47
|
# `content.blocks[].data` (corrective slice — presentation
|
|
@@ -50,6 +56,10 @@ module CommandTower
|
|
|
50
56
|
new(communication:, recipient_id:).render
|
|
51
57
|
end
|
|
52
58
|
|
|
59
|
+
def self.compose_shell(communication)
|
|
60
|
+
new(communication:, recipient_id: nil).compose_shell
|
|
61
|
+
end
|
|
62
|
+
|
|
53
63
|
def initialize(communication:, recipient_id:)
|
|
54
64
|
@communication = communication
|
|
55
65
|
@recipient_id = recipient_id
|
|
@@ -57,9 +67,16 @@ module CommandTower
|
|
|
57
67
|
end
|
|
58
68
|
|
|
59
69
|
def render
|
|
60
|
-
document =
|
|
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
|
|
61
77
|
|
|
62
|
-
|
|
78
|
+
def compose_shell
|
|
79
|
+
build_from_type_composer || build_generic
|
|
63
80
|
end
|
|
64
81
|
|
|
65
82
|
private
|
|
@@ -107,42 +124,42 @@ module CommandTower
|
|
|
107
124
|
return nil if @presentation_attempted
|
|
108
125
|
|
|
109
126
|
@presentation_attempted = true
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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)
|
|
127
150
|
end
|
|
128
151
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
|
146
163
|
end
|
|
147
164
|
|
|
148
165
|
def paragraph_block(text)
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -15,6 +15,8 @@ module CommandTower
|
|
|
15
15
|
validate :body, is_a: String, required: true
|
|
16
16
|
validate :metadata, is_a: Hash, required: false
|
|
17
17
|
validate :platform_enabled_channels, is_a: Array, required: true
|
|
18
|
+
validate :conversation_key, is_a: String, required: false
|
|
19
|
+
validate :conversation_title, is_a: String, required: false
|
|
18
20
|
|
|
19
21
|
def call
|
|
20
22
|
recipient_result = CommandTower::Services::Messaging::Recipients.call(user: user)
|
|
@@ -33,6 +35,8 @@ module CommandTower
|
|
|
33
35
|
preference_state: nil,
|
|
34
36
|
platform_enabled_channels:,
|
|
35
37
|
message_overrides: nil,
|
|
38
|
+
conversation_key:,
|
|
39
|
+
conversation_title:,
|
|
36
40
|
)
|
|
37
41
|
|
|
38
42
|
# Use communication_status — ServiceResult strips Interactor reserved :status.
|
|
@@ -18,6 +18,8 @@ module CommandTower
|
|
|
18
18
|
validate :body, is_a: String, required: true
|
|
19
19
|
validate :platform_enabled_channels, is_a: Array, required: true
|
|
20
20
|
validate :metadata, is_a: Hash, required: false
|
|
21
|
+
validate :conversation_key, is_a: String, required: false
|
|
22
|
+
validate :conversation_title, is_a: String, required: false
|
|
21
23
|
validate :execution_mode, is_one: EXECUTION_MODES, required: false, default: :async
|
|
22
24
|
|
|
23
25
|
def call
|
|
@@ -128,6 +130,8 @@ module CommandTower
|
|
|
128
130
|
body:,
|
|
129
131
|
metadata:,
|
|
130
132
|
platform_enabled_channels:,
|
|
133
|
+
conversation_key:,
|
|
134
|
+
conversation_title:,
|
|
131
135
|
)
|
|
132
136
|
end
|
|
133
137
|
|
|
@@ -139,6 +143,8 @@ module CommandTower
|
|
|
139
143
|
"body" => body,
|
|
140
144
|
"platform_enabled_channels" => platform_enabled_channels,
|
|
141
145
|
"metadata" => metadata,
|
|
146
|
+
"conversation_key" => conversation_key,
|
|
147
|
+
"conversation_title" => conversation_title,
|
|
142
148
|
}
|
|
143
149
|
end
|
|
144
150
|
|
|
@@ -34,7 +34,7 @@ module CommandTower
|
|
|
34
34
|
def call
|
|
35
35
|
with_recipient do |recipient_id|
|
|
36
36
|
result = CommandTower::Messaging::Inbox.list(recipient_id:, limit:, offset:, scope:)
|
|
37
|
-
context.items = result.items.map { |
|
|
37
|
+
context.items = result.items.map { |entry| Inbox.entry(entry) }
|
|
38
38
|
context.pagination = { limit: result.limit, offset: result.offset, total_count: result.total_count }
|
|
39
39
|
end
|
|
40
40
|
end
|
|
@@ -80,6 +80,37 @@ module CommandTower
|
|
|
80
80
|
end
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
class Conversation < CommandTower::Services::ApplicationService
|
|
84
|
+
include FailureMapping
|
|
85
|
+
validate :user, is_a: User, required: true
|
|
86
|
+
validate :key, is_a: String, required: true
|
|
87
|
+
validate :scope, is_a: String, required: false, default: "inbox"
|
|
88
|
+
validate :around, is_a: [String, Integer], required: false
|
|
89
|
+
validate :before_id, is_a: Integer, required: false
|
|
90
|
+
validate :after_id, is_a: Integer, required: false
|
|
91
|
+
validate :limit, is_a: Integer, required: false
|
|
92
|
+
|
|
93
|
+
def call
|
|
94
|
+
with_recipient do |recipient_id|
|
|
95
|
+
found = CommandTower::Messaging::Inbox.conversation(
|
|
96
|
+
recipient_id:, key:, scope:, around:, before_id:, after_id:, limit:,
|
|
97
|
+
)
|
|
98
|
+
messages = found.message_ids.map do |inbox_item_id|
|
|
99
|
+
Inbox.detail(recipient_id:, inbox_item_id:)
|
|
100
|
+
end
|
|
101
|
+
context.conversation = {
|
|
102
|
+
scope: found.scope,
|
|
103
|
+
subject: found.subject,
|
|
104
|
+
message_count: found.message_count,
|
|
105
|
+
unread_count: found.unread_count,
|
|
106
|
+
inbox_item_ids: found.inbox_item_ids,
|
|
107
|
+
messages:,
|
|
108
|
+
window: found.window,
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
83
114
|
class UnreadCount < CommandTower::Services::ApplicationService
|
|
84
115
|
include FailureMapping
|
|
85
116
|
validate :user, is_a: User, required: true
|
|
@@ -115,6 +146,21 @@ module CommandTower
|
|
|
115
146
|
{ id: item.id, title: item.title, status: item.status, viewed_at: item.viewed_at, created_at: item.created_at, updated_at: item.updated_at }
|
|
116
147
|
end
|
|
117
148
|
|
|
149
|
+
def entry(entry)
|
|
150
|
+
{
|
|
151
|
+
kind: entry.kind,
|
|
152
|
+
id: entry.id,
|
|
153
|
+
title: entry.title,
|
|
154
|
+
preview: entry.preview,
|
|
155
|
+
activity_at: entry.activity_at,
|
|
156
|
+
created_at: entry.created_at,
|
|
157
|
+
message_count: entry.message_count,
|
|
158
|
+
unread_count: entry.unread_count,
|
|
159
|
+
read: entry.read,
|
|
160
|
+
inbox_item_ids: entry.inbox_item_ids,
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
118
164
|
def detail(recipient_id:, inbox_item_id:)
|
|
119
165
|
item = CommandTower::Messaging::Inbox.show(recipient_id:, inbox_item_id:)
|
|
120
166
|
communication = CommandTower::Messaging::Contract::Communications.find(
|
data/app/workflows/command_tower/workflows/messaging/communications/produce_recipient_workflow.rb
CHANGED
|
@@ -15,7 +15,9 @@ module CommandTower
|
|
|
15
15
|
title:,
|
|
16
16
|
body:,
|
|
17
17
|
platform_enabled_channels:,
|
|
18
|
-
metadata: nil
|
|
18
|
+
metadata: nil,
|
|
19
|
+
conversation_key: nil,
|
|
20
|
+
conversation_title: nil
|
|
19
21
|
)
|
|
20
22
|
user = User.find_by(id: user_id)
|
|
21
23
|
unless user
|
|
@@ -34,6 +36,8 @@ module CommandTower
|
|
|
34
36
|
body:,
|
|
35
37
|
metadata:,
|
|
36
38
|
platform_enabled_channels:,
|
|
39
|
+
conversation_key:,
|
|
40
|
+
conversation_title:,
|
|
37
41
|
)
|
|
38
42
|
|
|
39
43
|
unless result.success?
|