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
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` |
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module CommandTower
|
|
4
4
|
class ApplicationController < ActionController::API
|
|
5
5
|
include CommandTower::Execution::HttpBoundary
|
|
6
|
+
include CommandTower::Execution::ClientCompatibilityBoundary
|
|
6
7
|
|
|
7
8
|
AUTHENTICATION_HEADER = CommandTower::Jwt::AuthorizationHelper::AUTHENTICATION_HEADER
|
|
8
9
|
AUTHENTICATION_EXPIRE_HEADER = CommandTower::Jwt::AuthorizationHelper::AUTHENTICATION_EXPIRE_HEADER
|
|
@@ -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
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Execution
|
|
5
|
+
# Runs the client-version-compatibility check for every JSON request,
|
|
6
|
+
# before any authentication/authorization filter. Transport-only: it
|
|
7
|
+
# invokes exactly one workflow and renders its result — all decisioning
|
|
8
|
+
# lives in `Workflows::ClientCompatibility::EvaluateWorkflow` /
|
|
9
|
+
# `Services::ClientCompatibility::Evaluate` (authority §10).
|
|
10
|
+
#
|
|
11
|
+
# Hosts that need to exempt a controller (e.g. a healthz-style endpoint)
|
|
12
|
+
# do so with `skip_before_action :evaluate_client_compatibility!`.
|
|
13
|
+
module ClientCompatibilityBoundary
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
include CommandTower::Api::ApplicationResponseRenderer
|
|
18
|
+
before_action :evaluate_client_compatibility!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def evaluate_client_compatibility!
|
|
24
|
+
result = CommandTower::Workflows::ClientCompatibility::EvaluateWorkflow.call(
|
|
25
|
+
request: request,
|
|
26
|
+
controller_class: self.class,
|
|
27
|
+
action_name: action_name
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
unless result.success?
|
|
31
|
+
render_application_result(result)
|
|
32
|
+
return false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
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
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Errors
|
|
5
|
+
# Hard client-version incompatibility (authority §14). Deliberately NOT
|
|
6
|
+
# registered in `SessionErrorStatus` — its HTTP status (426) is set
|
|
7
|
+
# directly by `Workflows::ClientCompatibility::EvaluateWorkflow`, not
|
|
8
|
+
# derived from that closed 401/403/412 mapping table.
|
|
9
|
+
class ClientUpdateRequiredError < ApplicationError
|
|
10
|
+
def initialize(details: {})
|
|
11
|
+
super(details:)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def code
|
|
15
|
+
"client_update_required"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def message
|
|
19
|
+
"Client update required"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def log_level
|
|
23
|
+
:warn
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -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),
|
|
@@ -16,7 +59,8 @@ module CommandTower
|
|
|
16
59
|
def self.serialize(item)
|
|
17
60
|
ItemSerializer.serialize(item).merge(
|
|
18
61
|
body: item.fetch(:body), metadata: item[:metadata],
|
|
19
|
-
notificationTypeKey: item.fetch(:notification_type_key)
|
|
62
|
+
notificationTypeKey: item.fetch(:notification_type_key),
|
|
63
|
+
content: item.fetch(:content)
|
|
20
64
|
)
|
|
21
65
|
end
|
|
22
66
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module EmailTheme
|
|
5
|
+
# Internal rendering-time collaborator (not a Service/Workflow/Controller).
|
|
6
|
+
#
|
|
7
|
+
# Assembles the full semantic token set an email template needs: the
|
|
8
|
+
# visual tokens from CommandTower.config.email_theme plus the identity
|
|
9
|
+
# tokens CommandTower already exposes (config.application). Shared by
|
|
10
|
+
# Messaging email templates and, later, CT's own auth mail templates —
|
|
11
|
+
# hence its own top-level namespace rather than living under
|
|
12
|
+
# Messaging::Rendering.
|
|
13
|
+
#
|
|
14
|
+
# No template calls this yet (Rich Messaging Strategy §12 steps C/D are
|
|
15
|
+
# separate, later slices). This is deliberately not a
|
|
16
|
+
# CommandTower::Services::ApplicationService: it takes no arguments,
|
|
17
|
+
# performs no I/O, and cannot fail under normal config state — there is
|
|
18
|
+
# nothing to validate and nothing to fail. Mirrors
|
|
19
|
+
# Messaging::Rendering::TemplateResolver's own documented exemption.
|
|
20
|
+
class Resolver
|
|
21
|
+
class << self
|
|
22
|
+
def resolve
|
|
23
|
+
new.resolve
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resolve
|
|
28
|
+
theme = CommandTower.config.email_theme
|
|
29
|
+
{
|
|
30
|
+
canvas_background: theme.canvas_background,
|
|
31
|
+
surface_background: theme.surface_background,
|
|
32
|
+
surface_border: theme.surface_border,
|
|
33
|
+
primary_text: theme.primary_text,
|
|
34
|
+
body_text: theme.body_text,
|
|
35
|
+
muted_text: theme.muted_text,
|
|
36
|
+
accent: theme.accent,
|
|
37
|
+
text_on_accent: theme.text_on_accent,
|
|
38
|
+
primary_action: theme.primary_action,
|
|
39
|
+
product_name: CommandTower.app_name_for_comms,
|
|
40
|
+
product_url: CommandTower.config.application.composed_url
|
|
41
|
+
}.freeze
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -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
|