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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -8
  3. data/app/controllers/command_tower/me/inbox_controller.rb +16 -0
  4. data/app/deserializers/command_tower/deserializers/messaging/inbox.rb +85 -0
  5. data/app/jobs/command_tower/messaging/communications/produce_recipient_job.rb +2 -0
  6. data/app/models/command_tower/messaging/communication.rb +31 -0
  7. data/app/serializers/command_tower/serializers/messaging/inbox.rb +43 -0
  8. data/app/services/command_tower/messaging/accept/coordinator.rb +16 -1
  9. data/app/services/command_tower/messaging/accept/persister.rb +13 -0
  10. data/app/services/command_tower/messaging/contract/mappers/communication_mapper.rb +7 -0
  11. data/app/services/command_tower/messaging/contract/results/communication_result.rb +1 -0
  12. data/app/services/command_tower/messaging/inbox/conversation_result.rb +31 -0
  13. data/app/services/command_tower/messaging/inbox/entry_result.rb +36 -0
  14. data/app/services/command_tower/messaging/inbox/reader.rb +231 -18
  15. data/app/services/command_tower/messaging/inbox.rb +4 -0
  16. data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +53 -36
  17. data/app/services/command_tower/messaging/rendering/inbox_presentation_resolver.rb +52 -0
  18. data/app/services/command_tower/messaging/rendering/inbox_presentation_snapshot.rb +64 -0
  19. data/app/services/command_tower/messaging.rb +5 -1
  20. data/app/services/command_tower/services/messaging/communications/produce.rb +4 -0
  21. data/app/services/command_tower/services/messaging/communications/produce_many.rb +6 -0
  22. data/app/services/command_tower/services/messaging/inbox.rb +47 -1
  23. data/app/workflows/command_tower/workflows/messaging/communications/produce_recipient_workflow.rb +5 -1
  24. data/app/workflows/command_tower/workflows/messaging/inbox.rb +15 -1
  25. data/config/routes.rb +1 -0
  26. data/db/migrate/20261010000001_add_inbox_presentation_snapshot_to_messaging_communications.rb +7 -0
  27. data/db/migrate/20261010000002_add_conversation_identity_to_messaging_communications.rb +10 -0
  28. data/docs/authorization.md +1 -1
  29. data/docs/bootstrap/00-ownership.md +77 -0
  30. data/docs/bootstrap/01-docker-make-compose.md +267 -0
  31. data/docs/bootstrap/02-create-the-rails-app.md +75 -0
  32. data/docs/bootstrap/03-pin-the-gem.md +53 -0
  33. data/docs/bootstrap/04-secrets-and-env.md +59 -0
  34. data/docs/bootstrap/05-install-migrate-doctor.md +74 -0
  35. data/docs/bootstrap/06-mount-and-health.md +53 -0
  36. data/docs/bootstrap/07-execution-bases.md +38 -0
  37. data/docs/bootstrap/08-initializer.md +90 -0
  38. data/docs/bootstrap/09-rbac.md +83 -0
  39. data/docs/bootstrap/10-roles-and-gates.md +62 -0
  40. data/docs/bootstrap/11-auth-client-path.md +57 -0
  41. data/docs/bootstrap/12-smoke-check.md +81 -0
  42. data/docs/bootstrap/13-optional.md +50 -0
  43. data/docs/bootstrap/14-sanity-checks.md +44 -0
  44. data/docs/bootstrap/README.md +90 -0
  45. data/docs/cookie_authentication_guide.md +11 -0
  46. data/docs/extending.md +3 -3
  47. data/docs/host_integration_guide.md +3 -281
  48. data/docs/initializing.md +37 -28
  49. data/docs/messaging_integration_guide.md +1 -1
  50. data/docs/principal_capabilities.md +1 -1
  51. data/docs/upgrades/0.10.0.md +5 -5
  52. data/docs/upgrades/0.11.0.md +5 -5
  53. data/docs/upgrades/0.18.0.md +35 -0
  54. data/docs/upgrades/README.md +2 -1
  55. data/lib/command_tower/authorization/default.yml +1 -0
  56. data/lib/command_tower/install/baseline.rb +2 -0
  57. data/lib/command_tower/version.rb +1 -1
  58. 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
- list_scope =
21
- if scope == SCOPE_ARCHIVED
22
- Messaging::InboxItem.archived_list
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: records.map { |record| ItemResult.from_record(record) },
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 = build_from_type_composer || build_generic
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
- resolve_presentation_blocks(document)
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
- 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
+ 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
- # 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
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 { |item| Inbox.item(item) }
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(
@@ -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?