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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -8
  3. data/app/controllers/command_tower/application_controller.rb +1 -0
  4. data/app/controllers/command_tower/me/inbox_controller.rb +16 -0
  5. data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
  6. data/app/deserializers/command_tower/deserializers/messaging/inbox.rb +85 -0
  7. data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
  8. data/app/jobs/command_tower/messaging/communications/produce_recipient_job.rb +2 -0
  9. data/app/models/command_tower/messaging/communication.rb +31 -0
  10. data/app/serializers/command_tower/serializers/messaging/inbox.rb +45 -1
  11. data/app/services/command_tower/email_theme/resolver.rb +45 -0
  12. data/app/services/command_tower/messaging/accept/coordinator.rb +16 -1
  13. data/app/services/command_tower/messaging/accept/persister.rb +13 -0
  14. data/app/services/command_tower/messaging/contract/mappers/communication_mapper.rb +7 -0
  15. data/app/services/command_tower/messaging/contract/results/communication_result.rb +1 -0
  16. data/app/services/command_tower/messaging/inbox/conversation_result.rb +31 -0
  17. data/app/services/command_tower/messaging/inbox/entry_result.rb +36 -0
  18. data/app/services/command_tower/messaging/inbox/reader.rb +231 -18
  19. data/app/services/command_tower/messaging/inbox.rb +4 -0
  20. data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
  21. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
  22. data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
  23. data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +224 -0
  24. data/app/services/command_tower/messaging/rendering/inbox_presentation_resolver.rb +52 -0
  25. data/app/services/command_tower/messaging/rendering/inbox_presentation_snapshot.rb +64 -0
  26. data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
  27. data/app/services/command_tower/messaging.rb +5 -1
  28. data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
  29. data/app/services/command_tower/services/messaging/communications/produce.rb +4 -0
  30. data/app/services/command_tower/services/messaging/communications/produce_many.rb +6 -0
  31. data/app/services/command_tower/services/messaging/inbox.rb +55 -2
  32. data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
  33. data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
  34. data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
  35. data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
  36. data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
  37. data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
  38. data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
  39. data/app/workflows/command_tower/workflows/messaging/communications/produce_recipient_workflow.rb +5 -1
  40. data/app/workflows/command_tower/workflows/messaging/inbox.rb +15 -1
  41. data/config/routes.rb +1 -0
  42. data/db/migrate/20261010000001_add_inbox_presentation_snapshot_to_messaging_communications.rb +7 -0
  43. data/db/migrate/20261010000002_add_conversation_identity_to_messaging_communications.rb +10 -0
  44. data/docs/api_reference.md +13 -2
  45. data/docs/authorization.md +1 -1
  46. data/docs/bootstrap/00-ownership.md +77 -0
  47. data/docs/bootstrap/01-docker-make-compose.md +267 -0
  48. data/docs/bootstrap/02-create-the-rails-app.md +75 -0
  49. data/docs/bootstrap/03-pin-the-gem.md +53 -0
  50. data/docs/bootstrap/04-secrets-and-env.md +59 -0
  51. data/docs/bootstrap/05-install-migrate-doctor.md +74 -0
  52. data/docs/bootstrap/06-mount-and-health.md +53 -0
  53. data/docs/bootstrap/07-execution-bases.md +38 -0
  54. data/docs/bootstrap/08-initializer.md +90 -0
  55. data/docs/bootstrap/09-rbac.md +83 -0
  56. data/docs/bootstrap/10-roles-and-gates.md +62 -0
  57. data/docs/bootstrap/11-auth-client-path.md +57 -0
  58. data/docs/bootstrap/12-smoke-check.md +81 -0
  59. data/docs/bootstrap/13-optional.md +50 -0
  60. data/docs/bootstrap/14-sanity-checks.md +44 -0
  61. data/docs/bootstrap/README.md +90 -0
  62. data/docs/cookie_authentication_guide.md +11 -0
  63. data/docs/extending.md +5 -4
  64. data/docs/host_integration_guide.md +3 -246
  65. data/docs/initializing.md +37 -28
  66. data/docs/messaging_integration_guide.md +39 -1
  67. data/docs/principal_capabilities.md +1 -1
  68. data/docs/upgrades/0.10.0.md +5 -5
  69. data/docs/upgrades/0.11.0.md +5 -5
  70. data/docs/upgrades/0.17.0.md +33 -0
  71. data/docs/upgrades/0.18.0.md +35 -0
  72. data/docs/upgrades/README.md +3 -1
  73. data/lib/command_tower/authorization/default.yml +1 -0
  74. data/lib/command_tower/client_compatibility/version.rb +45 -0
  75. data/lib/command_tower/client_compatibility.rb +23 -0
  76. data/lib/command_tower/configuration/config.rb +6 -0
  77. data/lib/command_tower/configuration/email_theme/config.rb +69 -0
  78. data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
  79. data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
  80. data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
  81. data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
  82. data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
  83. data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
  84. data/lib/command_tower/configuration/registry/config.rb +14 -0
  85. data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
  86. data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
  87. data/lib/command_tower/current.rb +1 -0
  88. data/lib/command_tower/engine.rb +4 -0
  89. data/lib/command_tower/inbox_presentations.rb +19 -0
  90. data/lib/command_tower/install/baseline.rb +2 -0
  91. data/lib/command_tower/version.rb +1 -1
  92. metadata +47 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e983f8f191ccb75948cdad73067d1cd43b7b1e5a8a3e0d34fc0130a76c313da
4
- data.tar.gz: 5ef89ea100c06425afe3aee6f87b8b71503e4d28be6a4ff6e309af8a5b02c83f
3
+ metadata.gz: 8b75ad05bfb533351f79dcf7c242e201a225d86cedd0a3f97358a01e91cbb830
4
+ data.tar.gz: e5e89fe1645e781155862a124f37c625ad0804593959d118d5e8f2c523d629a2
5
5
  SHA512:
6
- metadata.gz: 6555727a8b33efc95493ecdeec48067e39c8ee9eb4f1750c8fd183599a8d41bf1e763d538d379d0be7888697e6f09ca0795ebb643817d5aea2c548c61cd867f6
7
- data.tar.gz: ca51dafb697b03848b2509f16660df1e9e349f325a01da7d589300ac7db0ee087df33c08d46194a97e60ae6cbc984de8bcbf1fbc65f41d7bd31f31013d64add4
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 install
18
- bin/rails command_tower:install
19
- bin/rails db:migrate
20
- bin/rails command_tower:doctor
19
+ make bundle
20
+ make rails ARGS='command_tower:install'
21
+ make migrate
22
+ make doctor
21
23
  ```
22
24
 
23
- Full install, upgrade, configuration, and troubleshooting: [Initializing CommandTower](docs/initializing.md).
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
- | [Host integration](docs/host_integration_guide.md) | **Start here** — step-by-step new Rails host path |
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"
@@ -11,6 +11,7 @@ module CommandTower
11
11
  :title,
12
12
  :body,
13
13
  :metadata,
14
+ :inbox_presentation_snapshot,
14
15
  :created_at,
15
16
  :destination_plan,
16
17
  :inbox_item,
@@ -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