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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b75ad05bfb533351f79dcf7c242e201a225d86cedd0a3f97358a01e91cbb830
|
|
4
|
+
data.tar.gz: e5e89fe1645e781155862a124f37c625ad0804593959d118d5e8f2c523d629a2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fdbc47eafe86db87598541f3c4cbf86a4b23876e321eb137bef5f2a5d9e9b04f2adfb2d38c822b1980bfd150384443717eab03980442975aa93318fb909e2473
|
|
7
|
+
data.tar.gz: d374ca61c7fef265974aa71ed8c9bee9c98401efb6328cc240b5685997c8fbf9c1e00928fd2bf2bdd0879e7a013acb0a46200a14133307df57b66da0a44ec391
|
data/README.md
CHANGED
|
@@ -8,21 +8,21 @@ The engine is opinionated and configurable. Architecture follows a stable layer
|
|
|
8
8
|
|
|
9
9
|
## Quick start
|
|
10
10
|
|
|
11
|
+
**New Rails host?** Start with [Bootstrap a new host](docs/bootstrap/README.md) (Docker + Make only; local bring-up, not deployment).
|
|
12
|
+
|
|
11
13
|
```ruby
|
|
12
14
|
# Gemfile
|
|
13
15
|
gem "command_tower"
|
|
14
16
|
```
|
|
15
17
|
|
|
16
18
|
```bash
|
|
17
|
-
bundle
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
make bundle
|
|
20
|
+
make rails ARGS='command_tower:install'
|
|
21
|
+
make migrate
|
|
22
|
+
make doctor
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
After install, complete RBAC, feature gates, and a Me smoke check: [Host integration guide](docs/host_integration_guide.md) (**start here for a new Rails host**).
|
|
25
|
+
Those `make` targets run inside Compose. Do not run `bundle` or `bin/rails` on the Mac. Full install contract: [Initializing CommandTower](docs/initializing.md).
|
|
26
26
|
|
|
27
27
|
## Architecture
|
|
28
28
|
|
|
@@ -40,7 +40,7 @@ See [Architecture](docs/architecture.md) and [ServiceBase](app/services/command_
|
|
|
40
40
|
|
|
41
41
|
| Guide | Purpose |
|
|
42
42
|
|-------|---------|
|
|
43
|
-
| [
|
|
43
|
+
| [Bootstrap a new host](docs/bootstrap/README.md) | **Start here** — ordered Docker/Make new-host path |
|
|
44
44
|
| [Upgrades](docs/upgrades/README.md) | Release upgrade summaries (see [0.11.0](docs/upgrades/0.11.0.md)) |
|
|
45
45
|
| [Initializing](docs/initializing.md) | Install, configure, migrate, doctor, upgrades |
|
|
46
46
|
| [Testing](docs/testing.md) | Shared FactoryBot / `CommandTower::Testing` |
|
|
@@ -18,6 +18,22 @@ module CommandTower
|
|
|
18
18
|
))
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
def conversation
|
|
22
|
+
deserialized = CommandTower::Deserializers::Messaging::Inbox::ConversationDeserializer.call(params)
|
|
23
|
+
return render_deserializer_errors unless deserialized.success?
|
|
24
|
+
|
|
25
|
+
input = deserialized.input
|
|
26
|
+
render_application_result(CommandTower::Workflows::Messaging::Inbox::ConversationWorkflow.call(
|
|
27
|
+
user: current_user,
|
|
28
|
+
key: input.key,
|
|
29
|
+
scope: input.scope,
|
|
30
|
+
around: input.around,
|
|
31
|
+
before_id: input.before_id,
|
|
32
|
+
after_id: input.after_id,
|
|
33
|
+
limit: input.limit,
|
|
34
|
+
))
|
|
35
|
+
end
|
|
36
|
+
|
|
21
37
|
def show
|
|
22
38
|
run_item_workflow(CommandTower::Workflows::Messaging::Inbox::ShowWorkflow)
|
|
23
39
|
end
|
|
@@ -37,6 +37,91 @@ module CommandTower
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
class ConversationDeserializer < CommandTower::Deserializers::ApplicationDeserializer
|
|
41
|
+
Input = Data.define(:key, :scope, :around, :before_id, :after_id, :limit)
|
|
42
|
+
ALLOWED_SCOPES = %w[inbox archived].freeze
|
|
43
|
+
OLDEST_UNREAD = "oldest_unread"
|
|
44
|
+
|
|
45
|
+
def call(params)
|
|
46
|
+
key = params[:key]
|
|
47
|
+
key = key.strip if key.is_a?(String)
|
|
48
|
+
return failure(errors: { message: "invalid_key" }) if !key.is_a?(String) || key.empty?
|
|
49
|
+
|
|
50
|
+
scope = params[:scope].presence || "inbox"
|
|
51
|
+
return failure(errors: { message: "invalid_scope" }) unless scope.is_a?(String) && ALLOWED_SCOPES.include?(scope.strip)
|
|
52
|
+
|
|
53
|
+
around = present_param(params[:around])
|
|
54
|
+
before_id = present_param(params[:before])
|
|
55
|
+
after_id = present_param(params[:after])
|
|
56
|
+
modes = [around, before_id, after_id].count { |value| !value.nil? }
|
|
57
|
+
return failure(errors: { message: "invalid_window" }) if modes > 1
|
|
58
|
+
|
|
59
|
+
if modes.zero?
|
|
60
|
+
return failure(errors: { message: "invalid_limit" }) if present_param(params[:limit])
|
|
61
|
+
|
|
62
|
+
return success(Input.new(key:, scope: scope.strip, around: nil, before_id: nil, after_id: nil, limit: nil))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
parsed_around = nil
|
|
66
|
+
parsed_before = nil
|
|
67
|
+
parsed_after = nil
|
|
68
|
+
if around
|
|
69
|
+
parsed_around = parse_around(around)
|
|
70
|
+
return failure(errors: { message: "invalid_around" }) if parsed_around.nil?
|
|
71
|
+
elsif before_id
|
|
72
|
+
parsed_before = positive_integer(before_id)
|
|
73
|
+
return failure(errors: { message: "invalid_cursor" }) if parsed_before.nil?
|
|
74
|
+
else
|
|
75
|
+
parsed_after = positive_integer(after_id)
|
|
76
|
+
return failure(errors: { message: "invalid_cursor" }) if parsed_after.nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
limit = window_limit(params[:limit])
|
|
80
|
+
return failure(errors: { message: "invalid_limit" }) if limit.nil?
|
|
81
|
+
|
|
82
|
+
success(Input.new(
|
|
83
|
+
key:,
|
|
84
|
+
scope: scope.strip,
|
|
85
|
+
around: parsed_around,
|
|
86
|
+
before_id: parsed_before,
|
|
87
|
+
after_id: parsed_after,
|
|
88
|
+
limit:,
|
|
89
|
+
))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def present_param(raw)
|
|
95
|
+
return if raw.nil?
|
|
96
|
+
return if raw.is_a?(String) && raw.strip.empty?
|
|
97
|
+
|
|
98
|
+
raw
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parse_around(raw)
|
|
102
|
+
value = raw.is_a?(String) ? raw.strip : raw
|
|
103
|
+
return OLDEST_UNREAD if value == OLDEST_UNREAD
|
|
104
|
+
|
|
105
|
+
positive_integer(value)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def window_limit(raw)
|
|
109
|
+
return CommandTower::Messaging::Inbox::Reader::WINDOW_LIMIT unless present_param(raw)
|
|
110
|
+
|
|
111
|
+
value = positive_integer(raw)
|
|
112
|
+
return if value.nil? || value > CommandTower::Messaging::Inbox::Reader::WINDOW_LIMIT
|
|
113
|
+
|
|
114
|
+
value
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def positive_integer(raw)
|
|
118
|
+
value = raw.is_a?(Integer) ? raw : (Integer(raw, 10) if raw.is_a?(String) && /\A\d+\z/.match?(raw.strip))
|
|
119
|
+
value if value&.positive?
|
|
120
|
+
rescue ArgumentError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
40
125
|
class ShowDeserializer < CommandTower::Deserializers::ApplicationDeserializer
|
|
41
126
|
Input = Data.define(:inbox_item_id)
|
|
42
127
|
|
|
@@ -16,6 +16,8 @@ module CommandTower
|
|
|
16
16
|
body: attrs.fetch("body"),
|
|
17
17
|
platform_enabled_channels: Array(attrs["platform_enabled_channels"]),
|
|
18
18
|
metadata: attrs["metadata"],
|
|
19
|
+
conversation_key: attrs["conversation_key"],
|
|
20
|
+
conversation_title: attrs["conversation_title"],
|
|
19
21
|
)
|
|
20
22
|
end
|
|
21
23
|
end
|
|
@@ -30,10 +30,23 @@ module CommandTower
|
|
|
30
30
|
dependent: :destroy,
|
|
31
31
|
inverse_of: :communication
|
|
32
32
|
|
|
33
|
+
# Text column. The snapshot column uses the JSON attribute caster below
|
|
34
|
+
# instead of this coder. A second serialize pass would store a JSON
|
|
35
|
+
# string inside a JSON document on MySQL.
|
|
33
36
|
serialize :metadata, coder: JSON, type: Hash
|
|
34
37
|
|
|
38
|
+
# MariaDB reports JSON columns as LONGTEXT. The text caster would write
|
|
39
|
+
# a Ruby hash string and fail `json_valid`. Forcing the JSON caster
|
|
40
|
+
# writes one JSON document on MySQL and on MariaDB.
|
|
41
|
+
attribute :inbox_presentation_snapshot, :json
|
|
42
|
+
|
|
43
|
+
CONVERSATION_KEY_FORMAT = /\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z/
|
|
44
|
+
|
|
35
45
|
validates :notification_type_key, :title, :body, presence: true
|
|
36
46
|
validates :execution_handoff_status, presence: true
|
|
47
|
+
validate :conversation_identity_is_a_pair
|
|
48
|
+
|
|
49
|
+
before_validation :normalize_conversation_identity
|
|
37
50
|
|
|
38
51
|
scope :needing_handoff_recovery, lambda { |grace_before:|
|
|
39
52
|
where(status: STATUS_ACCEPTED, execution_handoff_status: HANDOFF_RECOVERABLE_STATUSES)
|
|
@@ -55,6 +68,24 @@ module CommandTower
|
|
|
55
68
|
def handoff_recoverable?
|
|
56
69
|
HANDOFF_RECOVERABLE_STATUSES.include?(execution_handoff_status)
|
|
57
70
|
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def normalize_conversation_identity
|
|
75
|
+
self.conversation_key = nil if conversation_key.blank?
|
|
76
|
+
self.conversation_title = nil if conversation_title.blank?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def conversation_identity_is_a_pair
|
|
80
|
+
if conversation_key.present? ^ conversation_title.present?
|
|
81
|
+
errors.add(:conversation_key, "must be provided with a title")
|
|
82
|
+
errors.add(:conversation_title, "must be provided with a key")
|
|
83
|
+
end
|
|
84
|
+
return if conversation_key.blank?
|
|
85
|
+
return if conversation_key.match?(CONVERSATION_KEY_FORMAT)
|
|
86
|
+
|
|
87
|
+
errors.add(:conversation_key, "is invalid")
|
|
88
|
+
end
|
|
58
89
|
end
|
|
59
90
|
end
|
|
60
91
|
end
|
|
@@ -4,6 +4,49 @@ module CommandTower
|
|
|
4
4
|
module Serializers
|
|
5
5
|
module Messaging
|
|
6
6
|
module Inbox
|
|
7
|
+
class EntrySerializer
|
|
8
|
+
def self.serialize(entry)
|
|
9
|
+
{
|
|
10
|
+
kind: entry.fetch(:kind),
|
|
11
|
+
id: entry.fetch(:id),
|
|
12
|
+
title: entry.fetch(:title),
|
|
13
|
+
preview: entry[:preview],
|
|
14
|
+
activityAt: entry.fetch(:activity_at)&.iso8601,
|
|
15
|
+
createdAt: entry.fetch(:created_at)&.iso8601,
|
|
16
|
+
messageCount: entry.fetch(:message_count),
|
|
17
|
+
unreadCount: entry.fetch(:unread_count),
|
|
18
|
+
read: entry.fetch(:read),
|
|
19
|
+
inboxItemIds: entry.fetch(:inbox_item_ids),
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class ConversationSerializer
|
|
25
|
+
def self.serialize(conversation)
|
|
26
|
+
payload = {
|
|
27
|
+
scope: conversation.fetch(:scope),
|
|
28
|
+
subject: conversation.fetch(:subject),
|
|
29
|
+
messageCount: conversation.fetch(:message_count),
|
|
30
|
+
unreadCount: conversation.fetch(:unread_count),
|
|
31
|
+
inboxItemIds: conversation.fetch(:inbox_item_ids),
|
|
32
|
+
messages: conversation.fetch(:messages).map { |message| DetailSerializer.serialize(message) },
|
|
33
|
+
}
|
|
34
|
+
window = conversation[:window]
|
|
35
|
+
payload[:window] = serialize_window(window) if window
|
|
36
|
+
payload
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.serialize_window(window)
|
|
40
|
+
{
|
|
41
|
+
anchorInboxItemId: window[:anchor_inbox_item_id],
|
|
42
|
+
hasBefore: window[:has_before],
|
|
43
|
+
hasAfter: window[:has_after],
|
|
44
|
+
beforeCursor: window[:before_cursor],
|
|
45
|
+
afterCursor: window[:after_cursor],
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
7
50
|
class ItemSerializer
|
|
8
51
|
def self.serialize(item)
|
|
9
52
|
{ id: item.fetch(:id), title: item.fetch(:title), status: item.fetch(:status),
|
|
@@ -19,7 +19,9 @@ module CommandTower
|
|
|
19
19
|
metadata: nil,
|
|
20
20
|
preference_state: nil,
|
|
21
21
|
platform_enabled_channels:,
|
|
22
|
-
message_overrides: nil
|
|
22
|
+
message_overrides: nil,
|
|
23
|
+
conversation_key: nil,
|
|
24
|
+
conversation_title: nil
|
|
23
25
|
)
|
|
24
26
|
@recipient_id = recipient_id
|
|
25
27
|
@notification_type_key = notification_type_key
|
|
@@ -30,6 +32,8 @@ module CommandTower
|
|
|
30
32
|
@preference_state = preference_state
|
|
31
33
|
@platform_enabled_channels = platform_enabled_channels
|
|
32
34
|
@message_overrides = message_overrides
|
|
35
|
+
@conversation_key = conversation_key
|
|
36
|
+
@conversation_title = conversation_title
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
def call
|
|
@@ -43,6 +47,8 @@ module CommandTower
|
|
|
43
47
|
preference_state: @preference_state,
|
|
44
48
|
platform_enabled_channels: @platform_enabled_channels,
|
|
45
49
|
message_overrides: @message_overrides,
|
|
50
|
+
conversation_key: @conversation_key,
|
|
51
|
+
conversation_title: @conversation_title,
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
OperationLogger.around(request:) do
|
|
@@ -141,9 +147,18 @@ module CommandTower
|
|
|
141
147
|
rescue ActiveRecord::RecordNotUnique => error
|
|
142
148
|
resolve_record_not_unique!(error, fingerprint)
|
|
143
149
|
rescue ActiveRecord::RecordInvalid => e
|
|
150
|
+
if conversation_pair_invalid?(e)
|
|
151
|
+
raise ValidationError, e.record.errors.full_messages.join("; ")
|
|
152
|
+
end
|
|
153
|
+
|
|
144
154
|
raise PersistenceError, e.message
|
|
145
155
|
end
|
|
146
156
|
|
|
157
|
+
def conversation_pair_invalid?(error)
|
|
158
|
+
names = error.record.errors.attribute_names.map(&:to_sym)
|
|
159
|
+
names.any? && (names - %i[conversation_key conversation_title]).empty?
|
|
160
|
+
end
|
|
161
|
+
|
|
147
162
|
def resolve_record_not_unique!(error, fingerprint)
|
|
148
163
|
existing = find_by_namespace
|
|
149
164
|
if existing
|
|
@@ -28,7 +28,10 @@ module CommandTower
|
|
|
28
28
|
title: @request.fetch(:title),
|
|
29
29
|
body: @request.fetch(:body),
|
|
30
30
|
metadata: freeze_metadata(@request[:metadata]),
|
|
31
|
+
conversation_key: @request[:conversation_key],
|
|
32
|
+
conversation_title: @request[:conversation_title],
|
|
31
33
|
)
|
|
34
|
+
assign_presentation_snapshot!(communication)
|
|
32
35
|
|
|
33
36
|
Messaging::DestinationPlan.create!(
|
|
34
37
|
communication:,
|
|
@@ -56,6 +59,16 @@ module CommandTower
|
|
|
56
59
|
|
|
57
60
|
private
|
|
58
61
|
|
|
62
|
+
def assign_presentation_snapshot!(communication)
|
|
63
|
+
snapshot = CommandTower::Messaging::Rendering::InboxPresentationSnapshot.capture(
|
|
64
|
+
communication:,
|
|
65
|
+
recipient_id: communication.user_id,
|
|
66
|
+
)
|
|
67
|
+
return if snapshot.nil?
|
|
68
|
+
|
|
69
|
+
communication.update!(inbox_presentation_snapshot: snapshot)
|
|
70
|
+
end
|
|
71
|
+
|
|
59
72
|
def freeze_metadata(metadata)
|
|
60
73
|
return nil if metadata.nil?
|
|
61
74
|
|
|
@@ -21,6 +21,7 @@ module CommandTower
|
|
|
21
21
|
title: communication.title,
|
|
22
22
|
body: communication.body,
|
|
23
23
|
metadata: freeze_metadata(communication.metadata),
|
|
24
|
+
inbox_presentation_snapshot: freeze_snapshot(communication.inbox_presentation_snapshot),
|
|
24
25
|
created_at: communication.created_at,
|
|
25
26
|
destination_plan: DestinationPlanMapper.to_result(communication.destination_plan),
|
|
26
27
|
inbox_item: InboxItemMapper.to_result(communication.inbox_item),
|
|
@@ -42,6 +43,12 @@ module CommandTower
|
|
|
42
43
|
metadata.to_h.transform_keys(&:to_s).freeze
|
|
43
44
|
end
|
|
44
45
|
|
|
46
|
+
def freeze_snapshot(snapshot)
|
|
47
|
+
return nil unless snapshot.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
snapshot.deep_stringify_keys.freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
45
52
|
def assert_no_active_record!(value)
|
|
46
53
|
if value.is_a?(ActiveRecord::Base)
|
|
47
54
|
raise Contract::InvariantError, "ActiveRecord must not leak through contract results"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Messaging
|
|
5
|
+
module Inbox
|
|
6
|
+
ConversationResult = Data.define(
|
|
7
|
+
:scope,
|
|
8
|
+
:subject,
|
|
9
|
+
:message_count,
|
|
10
|
+
:unread_count,
|
|
11
|
+
:inbox_item_ids,
|
|
12
|
+
:message_ids,
|
|
13
|
+
:window,
|
|
14
|
+
) do
|
|
15
|
+
def self.build(scope:, subject:, message_count:, unread_count:, inbox_item_ids:, message_ids: nil, window: nil)
|
|
16
|
+
ids = Array(inbox_item_ids).map { |value| Integer(value) }.freeze
|
|
17
|
+
rendered = message_ids.nil? ? ids : Array(message_ids).map { |value| Integer(value) }.freeze
|
|
18
|
+
new(
|
|
19
|
+
scope: scope.to_s,
|
|
20
|
+
subject: subject.to_s,
|
|
21
|
+
message_count: Integer(message_count),
|
|
22
|
+
unread_count: Integer(unread_count),
|
|
23
|
+
inbox_item_ids: ids,
|
|
24
|
+
message_ids: rendered,
|
|
25
|
+
window: window&.freeze,
|
|
26
|
+
).freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Messaging
|
|
5
|
+
module Inbox
|
|
6
|
+
EntryResult = Data.define(
|
|
7
|
+
:kind,
|
|
8
|
+
:id,
|
|
9
|
+
:title,
|
|
10
|
+
:preview,
|
|
11
|
+
:activity_at,
|
|
12
|
+
:created_at,
|
|
13
|
+
:message_count,
|
|
14
|
+
:unread_count,
|
|
15
|
+
:read,
|
|
16
|
+
:inbox_item_ids,
|
|
17
|
+
) do
|
|
18
|
+
def self.build(kind:, id:, title:, preview:, activity_at:, message_count:, unread_count:, inbox_item_ids:)
|
|
19
|
+
kind = kind.to_s
|
|
20
|
+
new(
|
|
21
|
+
kind:,
|
|
22
|
+
id: kind == "conversation" ? id.to_s : Integer(id),
|
|
23
|
+
title: title.to_s,
|
|
24
|
+
preview: preview&.to_s,
|
|
25
|
+
activity_at:,
|
|
26
|
+
created_at: activity_at,
|
|
27
|
+
message_count: Integer(message_count),
|
|
28
|
+
unread_count: Integer(unread_count),
|
|
29
|
+
read: Integer(unread_count).zero?,
|
|
30
|
+
inbox_item_ids: Array(inbox_item_ids).map { |value| Integer(value) }.freeze,
|
|
31
|
+
).freeze
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|