collavre 0.23.0 → 0.24.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  3. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
  7. data/app/assets/stylesheets/collavre/landing.css +92 -0
  8. data/app/assets/stylesheets/collavre/popup.css +4 -2
  9. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  10. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  11. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  12. data/app/controllers/collavre/attachments_controller.rb +13 -3
  13. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  14. data/app/controllers/collavre/creatives_controller.rb +32 -99
  15. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  16. data/app/controllers/collavre/landing_controller.rb +5 -2
  17. data/app/controllers/collavre/topics_controller.rb +5 -0
  18. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  19. data/app/errors/collavre/api_error.rb +20 -0
  20. data/app/helpers/collavre/creatives_helper.rb +30 -8
  21. data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
  22. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  23. data/app/javascript/components/creative_tree_row.js +8 -2
  24. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  25. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  26. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  27. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  28. data/app/javascript/controllers/comments/topics_controller.js +47 -0
  29. data/app/javascript/controllers/common_popup_controller.js +2 -2
  30. data/app/javascript/controllers/index.js +3 -0
  31. data/app/javascript/controllers/search_popup_controller.js +17 -1
  32. data/app/javascript/controllers/topic_list_controller.js +78 -0
  33. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  34. data/app/javascript/lib/common_popup.js +24 -5
  35. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  36. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  37. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  38. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  39. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  40. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  41. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  42. data/app/javascript/modules/creative_inline_payload.js +86 -0
  43. data/app/javascript/modules/creative_row_editor.js +121 -401
  44. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  45. data/app/javascript/modules/creative_save_queue.js +95 -0
  46. data/app/javascript/modules/creative_tree_dom.js +260 -0
  47. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  48. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  49. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  50. data/app/models/collavre/channel.rb +16 -0
  51. data/app/models/collavre/comment/approvable.rb +16 -0
  52. data/app/models/collavre/comment/notifiable.rb +1 -1
  53. data/app/models/collavre/comment.rb +8 -1
  54. data/app/models/collavre/comment_version.rb +1 -1
  55. data/app/models/collavre/creative/describable.rb +15 -9
  56. data/app/models/collavre/creative/linkable.rb +9 -0
  57. data/app/models/collavre/creative/permissible.rb +97 -49
  58. data/app/models/collavre/creative.rb +73 -5
  59. data/app/models/collavre/creative_share.rb +78 -26
  60. data/app/models/collavre/inbox_item.rb +2 -2
  61. data/app/models/collavre/task.rb +12 -0
  62. data/app/models/collavre/user.rb +33 -9
  63. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  64. data/app/services/collavre/agent_type_classifier.rb +23 -0
  65. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  66. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  67. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  68. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  69. data/app/services/collavre/ai_client.rb +53 -2
  70. data/app/services/collavre/auto_theme_generator.rb +14 -10
  71. data/app/services/collavre/creatives/children_index.rb +86 -0
  72. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  73. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  74. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  75. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  76. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  77. data/app/services/collavre/creatives/tree_builder.rb +105 -77
  78. data/app/services/collavre/http_client.rb +119 -0
  79. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  80. data/app/services/collavre/markdown_importer.rb +30 -6
  81. data/app/services/collavre/mobile/event_summarizer.rb +1 -1
  82. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  83. data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
  84. data/app/services/collavre/system_events/context_builder.rb +1 -14
  85. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  86. data/app/views/collavre/comments/_comment.html.erb +4 -4
  87. data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
  88. data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
  89. data/app/views/collavre/landing/show.html.erb +52 -10
  90. data/config/locales/comments.en.yml +1 -0
  91. data/config/locales/comments.ko.yml +1 -0
  92. data/config/locales/creatives.en.yml +6 -0
  93. data/config/locales/creatives.ko.yml +5 -0
  94. data/config/locales/landing.en.yml +22 -10
  95. data/config/locales/landing.ko.yml +22 -10
  96. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  97. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  98. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  99. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  100. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  101. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  102. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  103. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  104. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  105. data/lib/collavre/version.rb +1 -1
  106. metadata +42 -1
@@ -15,6 +15,10 @@ module Collavre
15
15
  # Dispatch A2A events if the response mentions any AI agents
16
16
  def dispatch
17
17
  return unless @reply_comment&.content.present?
18
+ # An approval-action message (approve button / approved) is a human decision
19
+ # surface and must never reach an AI agent — not even via an @mention in
20
+ # another agent's reply. Same invariant as Comment#dispatch_to_orchestration.
21
+ return if @reply_comment.approval_action?
18
22
 
19
23
  mentioned_agents = find_mentioned_agents
20
24
  return if mentioned_agents.empty?
@@ -146,7 +146,7 @@ module Collavre
146
146
  history_chars = 0
147
147
  count = 0
148
148
 
149
- Comment.public_only.where(creative_id: creative_id)
149
+ Comment.public_only.without_approval_action.where(creative_id: creative_id)
150
150
  .where(topic_id: topic_id)
151
151
  .where.not(user_id: nil)
152
152
  .includes(:user)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module AiAgent
5
+ # Provisions the identities a Claude Code registration needs:
6
+ # - one shared ai_user per (human, agent_name) — the Agent identity
7
+ # - one Topic per (agent, session_id) — the Session identity
8
+ #
9
+ # Extracted from Api::V1::AgentsController#register so the action stays a
10
+ # thin orchestration of provisioning + inbox share. Behavior is identical to
11
+ # the inlined controller methods it replaces.
12
+ class SessionProvisioner
13
+ def initialize(user)
14
+ @user = user
15
+ end
16
+
17
+ # One ai_user per (user, agent_name) so a human's sessions share one Agent
18
+ # identity. Same agent_name re-registering reuses the existing row —
19
+ # idempotent retries (and every additional session) don't proliferate
20
+ # agents.
21
+ #
22
+ # Returns nil when a row with the deterministic email already exists but is
23
+ # owned by someone else or isn't a Claude Channel ai_user. The email format
24
+ # is human-derivable (user.id + slug + digest), so a foreign row could be
25
+ # planted by signup/import; silently reusing it would attach the caller's
26
+ # inbox feedback share to that foreign User and leave the plugin's
27
+ # AgentChannel subscription rejected on ownership mismatch. Caller renders
28
+ # 409 in that case.
29
+ def find_or_create_agent(agent_name)
30
+ email = session_agent_email(agent_name)
31
+
32
+ existing = User.find_by(email: email)
33
+ return verified_agent(existing) if existing
34
+
35
+ # routing_expression: nil so the new ai_user is not matchable until the
36
+ # client claims the per-agent stream via AgentChannel. See the comment on
37
+ # verified_agent.
38
+ User.create!(
39
+ email: email,
40
+ name: "Claude Channel (#{agent_name})",
41
+ password: SecureRandom.hex(32),
42
+ llm_vendor: "anthropic",
43
+ llm_model: "claude-code",
44
+ created_by_id: @user.id,
45
+ searchable: false,
46
+ routing_expression: nil
47
+ )
48
+ rescue ActiveRecord::RecordNotUnique
49
+ # A concurrent registration for the same (user, agent_name) won the
50
+ # users.email unique race. The desired row now exists — re-find and
51
+ # re-verify ownership instead of surfacing a 500 that aborts one of the
52
+ # two simultaneously launching plugin instances.
53
+ verified_agent(User.find_by(email: email))
54
+ end
55
+
56
+ # One Topic per (agent, session_id). On re-register (including --resume from
57
+ # the same cwd, which yields the same session_id) the existing topic is
58
+ # reused — even if archived — so the conversation persists instead of
59
+ # orphaning. A fresh session gets a new topic under the same shared agent,
60
+ # which is how one agent fans out to many sessions.
61
+ def find_or_create_topic(inbox, ai_user, session_id, session_label)
62
+ existing = inbox.topics.find_by(primary_agent_id: ai_user.id, session_id: session_id)
63
+ return existing if existing
64
+
65
+ label = session_label.to_s.strip.presence || session_id
66
+ inbox.topics.create!(
67
+ name: unique_topic_name(inbox, "Claude #{label}"),
68
+ user: @user,
69
+ primary_agent_id: ai_user.id,
70
+ session_id: session_id
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ # Deterministic, collision-free email key for a (user, agent_name) agent.
77
+ # The readable slug is lossy — "qa/bot", "qa bot" and "qa--bot" all squeeze
78
+ # to "qa-bot", and any all-symbol name collapses to "session" — so a short
79
+ # digest of the normalized raw name disambiguates distinct configured names
80
+ # that would otherwise alias onto ONE shared agent identity (ActionCable
81
+ # stream, routing state, tasks, creative shares). The slug stays for human
82
+ # readability; the digest decides identity. Same normalized name -> same
83
+ # key, so idempotent re-register/reuse is preserved. Normalization
84
+ # (strip+downcase) keeps case/whitespace-only differences folded, matching
85
+ # the prior behavior — only the lossy punctuation/spacing collapse is fixed.
86
+ def session_agent_email(agent_name)
87
+ normalized = agent_name.to_s.strip.downcase
88
+ slug = normalized.gsub(/[^a-z0-9-]+/, "-").squeeze("-").gsub(/\A-|-\z/, "")
89
+ slug = "session" if slug.blank?
90
+ digest = Digest::SHA256.hexdigest(normalized)[0, 10]
91
+ "claude-channel-#{@user.id}-#{slug}-#{digest}@agent.collavre.local"
92
+ end
93
+
94
+ # Returns the agent only when it is the current user's own Claude Channel
95
+ # agent; nil otherwise (the caller renders :conflict). Routing activation
96
+ # stays deferred to AgentChannel#subscribe_to_agent_stream so the agent
97
+ # becomes matchable only once a WebSocket subscriber exists for
98
+ # agent:user:<id> — otherwise comments matched between register returning
99
+ # and the client's subsequent cable subscribe would broadcast into an empty
100
+ # stream, stranding delegated tasks until stuck recovery.
101
+ def verified_agent(ai_user)
102
+ return nil unless ai_user &&
103
+ ai_user.created_by_id == @user.id &&
104
+ ai_user.ai_user? &&
105
+ ai_user.claude_channel_agent?
106
+
107
+ ai_user
108
+ end
109
+
110
+ # Topics carry a UNIQUE (creative_id, name) index, so two sessions whose
111
+ # friendly labels collide (e.g. both rooted at a dir named "src") cannot
112
+ # share a name. Append the smallest numeric suffix that is free.
113
+ def unique_topic_name(inbox, desired)
114
+ return desired unless inbox.topics.exists?(name: desired)
115
+
116
+ n = 2
117
+ loop do
118
+ candidate = "#{desired} (#{n})"
119
+ return candidate unless inbox.topics.exists?(name: candidate)
120
+
121
+ n += 1
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module AiAgent
5
+ # Atomically claims and finalizes a delegated task on behalf of a Claude
6
+ # Channel /reply. Extracted from Api::V1::AgentsController#reply so the
7
+ # controller only sequences: claim -> save comment -> finalize. Behavior is
8
+ # identical to the inlined controller methods it replaces.
9
+ class TaskClaimService
10
+ # Atomically claim a delegated task for completion. Two-step:
11
+ # 1. Find a candidate task in delegated state, scoped to this agent +
12
+ # topic. With task_id supplied, exact match (required under topic
13
+ # concurrency > 1 where multiple delegated tasks coexist; the client
14
+ # echoes the dispatch's task_id). Without task_id (legacy clients),
15
+ # oldest-first.
16
+ # 2. Inside a transaction: SELECT FOR UPDATE the row, re-check
17
+ # status == 'delegated' under the lock, then update! to 'done'.
18
+ # Concurrent claimers block on the lock; the loser sees the
19
+ # already-flipped status post-lock and returns nil so the caller can
20
+ # refuse the duplicate.
21
+ # update_all (NOT update!) is required to skip Task's after_update_commit
22
+ # callbacks at claim time. The callbacks fire check_trigger_loop_completion
23
+ # (which enqueues TriggerLoopCheckJob) and broadcast_stop_button_removal
24
+ # (which reads reply_comment). Both depend on the reply comment already
25
+ # existing — but reply() claims BEFORE comment.save to win the race against
26
+ # concurrent /reply calls. If update! fired the trigger-loop check here, the
27
+ # job could run (cooldown_seconds: 0) before comment.save commits, find no
28
+ # agent comment, and leave the loop stuck in "running". #finalize replays
29
+ # both callbacks after the comment is persisted via
30
+ # Task#fire_completion_callbacks_after_external_claim.
31
+ def claim(agent:, topic:, requested_task_id:)
32
+ scope = Task.where(agent_id: agent.id, topic_id: topic.id, status: "delegated")
33
+ candidate =
34
+ if requested_task_id.present?
35
+ scope.find_by(id: requested_task_id)
36
+ else
37
+ scope.order(:created_at).first
38
+ end
39
+ return nil unless candidate
40
+
41
+ claimed = nil
42
+ Task.transaction do
43
+ locked = Task.lock.find_by(id: candidate.id)
44
+ next unless locked && locked.status == "delegated"
45
+
46
+ Task.where(id: locked.id).update_all(status: "done", pending_tool_call: nil, updated_at: Time.current)
47
+ claimed = locked.reload
48
+ end
49
+ claimed
50
+ end
51
+
52
+ # Post-claim side effects, run only after the reply comment is saved. Links
53
+ # the comment to the claimed task, releases the ResourceTracker slot the
54
+ # AiAgentJob held under task.id, advances the parent workflow (if any), and
55
+ # drains the topic queue — mirroring AiAgentJob#perform's success path for
56
+ # non-delegated runs.
57
+ def finalize(agent:, task:, comment:)
58
+ comment.update_column(:task_id, task.id)
59
+
60
+ Orchestration::ResourceTracker.for(agent).release!(task.id)
61
+
62
+ if task.parent_task_id.present?
63
+ Collavre::Comments::WorkflowExecutor.new(task.parent_task).complete_subtask!(task)
64
+ end
65
+
66
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
67
+
68
+ # Replay the after_update_commit callbacks that were bypassed by
69
+ # update_all in #claim — now that the reply comment is linked,
70
+ # TriggerLoopCheckJob can read it and decide whether to advance/await/
71
+ # complete the drop-trigger loop, and the stop-button broadcast has a
72
+ # comment to render.
73
+ task.fire_completion_callbacks_after_external_claim
74
+
75
+ # Clear the typing indicator immediately on reply. ClaudeChannelPresenceJob
76
+ # would also stop on its next beat (task no longer "delegated"), but that
77
+ # is up to HEARTBEAT_SECONDS away — broadcast idle now so the indicator
78
+ # drops the moment Claude's reply lands.
79
+ broadcast_claude_idle(agent, task, comment)
80
+ end
81
+
82
+ private
83
+
84
+ # Clear the chat typing indicator via the canonical status broadcaster (the
85
+ # same one AiAgentService uses for every other agent), so the Claude path
86
+ # emits an identical "idle" agent_status payload.
87
+ def broadcast_claude_idle(agent, task, comment)
88
+ creative = comment.creative&.effective_origin
89
+ return unless creative
90
+
91
+ AiAgent::AgentLifecycleManager.new(task: task, agent: agent, creative: creative)
92
+ .broadcast_status("idle")
93
+ end
94
+ end
95
+ end
96
+ end
@@ -10,6 +10,49 @@ module Collavre
10
10
 
11
11
  attr_reader :last_input_tokens, :last_output_tokens
12
12
 
13
+ # Vendor <select> options for AI-agent config. Core ships only its built-in
14
+ # (stateless) providers; vendor engines append their own through
15
+ # register_vendor_option, so core never names a vendor engine.
16
+ BASE_VENDOR_OPTIONS = [
17
+ [ "Google (Gemini)", "google" ],
18
+ [ "OpenAI", "openai" ],
19
+ [ "Anthropic", "anthropic" ]
20
+ ].freeze
21
+
22
+ class << self
23
+ def registered_vendor_options
24
+ @registered_vendor_options ||= []
25
+ end
26
+
27
+ # Append a vendor <select> option ([label, value]). Idempotent by value.
28
+ def register_vendor_option(label, value)
29
+ value = value.to_s
30
+ return if BASE_VENDOR_OPTIONS.any? { |_l, v| v == value }
31
+ return if registered_vendor_options.any? { |_l, v| v == value }
32
+
33
+ registered_vendor_options << [ label, value ]
34
+ end
35
+
36
+ def vendor_options
37
+ BASE_VENDOR_OPTIONS + registered_vendor_options
38
+ end
39
+
40
+ # Vendors that use stateful/incremental sessions. Base providers are
41
+ # stateless; vendor engines that add session support register here so core
42
+ # never string-matches a vendor name to decide session behavior.
43
+ def session_vendors
44
+ @session_vendors ||= Set.new
45
+ end
46
+
47
+ def register_session_vendor(vendor)
48
+ session_vendors << vendor.to_s.downcase
49
+ end
50
+
51
+ def vendor_supports_session?(vendor)
52
+ session_vendors.include?(vendor.to_s.downcase)
53
+ end
54
+ end
55
+
13
56
  # log_interactions: persist each call to ActivityLog. Default true. Pass false
14
57
  # for ephemeral, high-frequency calls on text the user has not submitted (e.g.
15
58
  # inline typo correction on debounced typing) so private drafts are never
@@ -41,8 +84,16 @@ module Collavre
41
84
  add_messages(@conversation, contents)
42
85
 
43
86
  response = @conversation.complete do |chunk|
44
- delta = extract_chunk_content(chunk)
45
- next if delta.blank?
87
+ delta = extract_chunk_content(chunk).to_s
88
+ # Deliberately NOT `blank?`. A delta of exactly "\n\n" — the paragraph
89
+ # break, which providers routinely emit as a token of its own — is
90
+ # blank? == true, so skipping blanks deletes it. And the deleted delta is
91
+ # never yielded, so it is gone from the caller's stream too: AiAgentService
92
+ # persists ResponseStreamer#content, which is built only from the yielded
93
+ # deltas, and the reply lands in the database with its paragraphs glued
94
+ # together. Only truly empty deltas (role-only / tool-call chunks carry no
95
+ # content) are skippable.
96
+ next if delta.empty?
46
97
 
47
98
  response_content << delta
48
99
  yield delta if block_given?
@@ -57,7 +57,20 @@ module Collavre
57
57
  end
58
58
 
59
59
  def generate(prompt)
60
- system_prompt = <<~PROMPT
60
+ parse_response(request_theme(prompt))
61
+ end
62
+
63
+ private
64
+
65
+ def request_theme(prompt)
66
+ @client.chat([
67
+ { role: :system, parts: [ { text: system_prompt } ] },
68
+ { role: :user, parts: [ { text: "Create a theme description: #{prompt}" } ] }
69
+ ])
70
+ end
71
+
72
+ def system_prompt
73
+ <<~PROMPT
61
74
  You are a color theme designer for a workspace app.
62
75
  Generate a JSON with ONLY these keys: #{REQUIRED_VARIABLES.join(', ')}.
63
76
 
@@ -181,17 +194,8 @@ module Collavre
181
194
  For dark/accent themes, h1 can use a subtle darker surface. Must not clash with text.
182
195
  - For playful themes, vary heading colors. For minimal themes, keep sizes uniform.
183
196
  PROMPT
184
-
185
- response = @client.chat([
186
- { role: :system, parts: [ { text: system_prompt } ] },
187
- { role: :user, parts: [ { text: "Create a theme description: #{prompt}" } ] }
188
- ])
189
-
190
- parse_response(response)
191
197
  end
192
198
 
193
- private
194
-
195
199
  def default_client
196
200
  AiClient.new(
197
201
  vendor: "google",
@@ -0,0 +1,86 @@
1
+ module Collavre
2
+ module Creatives
3
+ # Per-level batch answer to "which children does this node show?" for the
4
+ # browse tree, replacing a per-node `children_with_permission` call.
5
+ #
6
+ # Presence (`has_children?`) is resolved for every node in a level with one
7
+ # plucked scan plus one batched PermissionFilter; the full child rows are
8
+ # materialized only for the nodes that actually render them (expanded, or a
9
+ # filter forcing the subtree open). Presence and content are derived from the
10
+ # same candidate set, so an expand toggle can never open an empty branch or
11
+ # hide a reachable subtree — a drift that would also leak the existence of
12
+ # children the user cannot see.
13
+ #
14
+ # Children of a linked shell live under its effective origin, so every lookup
15
+ # resolves the shell to its origin first. Preload `:origin` on the level before
16
+ # indexing it, or that resolution costs a query per shell.
17
+ class ChildrenIndex
18
+ def initialize(user:, show_archived:, allowed_creative_ids: nil)
19
+ @user = user
20
+ @show_archived = show_archived
21
+ @allowed_creative_ids = allowed_creative_ids
22
+ @child_ids_by_creative = {}
23
+ @rows_by_child_id = {}
24
+ end
25
+
26
+ # Scans a level. Only creatives not seen before cost anything, so the
27
+ # recursive descent pays two queries per level, not per node.
28
+ def index(creatives)
29
+ pending = creatives.reject { |c| @child_ids_by_creative.key?(c.id) }
30
+ return if pending.empty?
31
+
32
+ origin_id_by_id = pending.to_h { |c| [ c.id, c.effective_origin.id ] }
33
+
34
+ candidates = Creative.where(parent_id: origin_id_by_id.values.uniq)
35
+ candidates = candidates.where(archived_at: nil) unless show_archived
36
+ rows = candidates.order(:sequence).pluck(:id, :parent_id)
37
+
38
+ visible_by_origin = visible_child_ids_by_origin(rows)
39
+ pending.each do |creative|
40
+ @child_ids_by_creative[creative.id] = visible_by_origin[origin_id_by_id[creative.id]] || []
41
+ end
42
+ end
43
+
44
+ def has_children?(creative)
45
+ child_ids(creative).any?
46
+ end
47
+
48
+ # Materializes the child rows of `creatives` in a single query. Called only
49
+ # for the nodes whose children actually render.
50
+ def load(creatives)
51
+ wanted = creatives.flat_map { |c| child_ids(c) }.uniq - @rows_by_child_id.keys
52
+ return if wanted.empty?
53
+
54
+ Creative.where(id: wanted).each { |child| @rows_by_child_id[child.id] = child }
55
+ end
56
+
57
+ # Sequence-ordered, permission- and archive-filtered children. Empty unless
58
+ # `load` has materialized them.
59
+ def children_for(creative)
60
+ child_ids(creative).filter_map { |id| @rows_by_child_id[id] }
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :user, :show_archived, :allowed_creative_ids
66
+
67
+ # `rows` arrive in sequence order, so the per-origin lists inherit it.
68
+ def visible_child_ids_by_origin(rows)
69
+ return {} if rows.empty?
70
+
71
+ readable = PermissionFilter.new(user: user).readable_ids(rows.map(&:first)).to_set
72
+
73
+ rows.each_with_object({}) do |(child_id, origin_id), acc|
74
+ next unless readable.include?(child_id)
75
+ next if allowed_creative_ids && !allowed_creative_ids.include?(child_id.to_s)
76
+
77
+ (acc[origin_id] ||= []) << child_id
78
+ end
79
+ end
80
+
81
+ def child_ids(creative)
82
+ @child_ids_by_creative.fetch(creative.id, [])
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,73 @@
1
+ module Collavre
2
+ module Creatives
3
+ # Batches the browse tree's comment badge. Per node this was two queries — a
4
+ # read-pointer lookup and an unread COUNT — which is what made the badge cost
5
+ # scale with the size of the rendered tree.
6
+ #
7
+ # Keyed by *origin*: a linked shell shows its origin's comments.
8
+ class CommentBadgeIndex
9
+ def initialize(user:)
10
+ @user = user
11
+ @unread_by_origin_id = {}
12
+ end
13
+
14
+ def index(origins)
15
+ pending = origins.uniq(&:id).reject { |o| @unread_by_origin_id.key?(o.id) }
16
+ return if pending.empty?
17
+
18
+ watermarks = read_watermarks(pending.map(&:id))
19
+
20
+ # No read pointer means nothing has been read yet, so every comment counts
21
+ # as unread — including private ones, matching the counter cache.
22
+ pending.each do |origin|
23
+ @unread_by_origin_id[origin.id] = origin.comments_count unless watermarks.key?(origin.id)
24
+ end
25
+
26
+ counts = unread_counts(watermarks)
27
+ watermarks.each_key { |origin_id| @unread_by_origin_id[origin_id] = counts.fetch(origin_id, 0) }
28
+ end
29
+
30
+ # nil when the origin was never indexed, which tells the caller to fall back
31
+ # to the single-creative path rather than silently render a zero badge.
32
+ def unread_count_for(origin)
33
+ @unread_by_origin_id[origin.id]
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :user
39
+
40
+ # `user_id: nil` for an anonymous visitor is the lookup the un-batched path
41
+ # made too, so the two agree on which pointer (if any) applies.
42
+ def read_watermarks(origin_ids)
43
+ return {} if origin_ids.empty?
44
+
45
+ CommentReadPointer
46
+ .where(user_id: user&.id, creative_id: origin_ids)
47
+ .where.not(last_read_comment_id: nil)
48
+ .pluck(:creative_id, :last_read_comment_id)
49
+ .to_h
50
+ end
51
+
52
+ # One grouped COUNT for the whole level. Each origin carries its own read
53
+ # watermark, so the thresholds are OR-ed together rather than shared. Built
54
+ # from relations rather than a SQL fragment: a hand-built string here would
55
+ # be safe (literal template, bound values) but unprovably so to a scanner.
56
+ def unread_counts(watermarks)
57
+ return {} if watermarks.empty?
58
+
59
+ newer_than_watermark = watermarks
60
+ .map { |origin_id, last_read_id| unread_scope(origin_id, last_read_id) }
61
+ .reduce { |combined, scope| combined.or(scope) }
62
+
63
+ Comment.where(private: false).merge(newer_than_watermark).group(:creative_id).count
64
+ end
65
+
66
+ def unread_scope(origin_id, last_read_id)
67
+ Comment
68
+ .where(creative_id: origin_id)
69
+ .where(Comment.arel_table[:id].gt(last_read_id))
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,122 @@
1
+ module Collavre
2
+ module Creatives
3
+ # Serializes a flat collection of creatives into the lightweight JSON payload
4
+ # consumed by the picker popup (simple mode) and the plain browse list.
5
+ #
6
+ # Extracted from CreativesController#index so the controller action stays thin.
7
+ # Depends only on the acting user and the request params (read-only), never on
8
+ # view_context, so the whole responsibility lives outside the controller.
9
+ class CreativeTreeSerializer
10
+ def initialize(user:, params:)
11
+ @user = user
12
+ @params = params
13
+ end
14
+
15
+ def serialize(collection)
16
+ if params[:simple].present?
17
+ serialize_simple(collection)
18
+ else
19
+ collection.map { |c| { id: c.id, description: c.effective_description, progress: c.progress } }
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :user, :params
26
+
27
+ def serialize_simple(collection)
28
+ # Preload origins so effective_origin (used per linked-shell row in both
29
+ # children_presence_set and the origin_id mapping below) resolves from
30
+ # memory instead of firing a query per shell. Only browse can hold shells;
31
+ # search is scoped to origin_id: nil, so this is a no-op there.
32
+ ActiveRecord::Associations::Preloader.new(records: collection.to_a, associations: :origin).call
33
+ children_ids = children_presence_set(collection)
34
+ searching = params[:search].present?
35
+ breadcrumbs = searching ? BreadcrumbResolver.new(collection.map(&:id), user: user, include_archived: params[:show_archived].present?).call : {}
36
+ # For hits routed through a linked shell, the path to expand in the
37
+ # user's own tree (local folders -> shell) so a breadcrumb jump can
38
+ # reach a shell nested under a collapsed folder.
39
+ reveal_paths = searching ? RevealPathResolver.new(collection.map(&:id), user: user, include_archived: params[:show_archived].present?).call : {}
40
+ collection.map do |c|
41
+ item = {
42
+ id: c.id,
43
+ description: c.effective_description(nil, false),
44
+ progress: c.progress,
45
+ has_children: children_ids.include?(c.id)
46
+ }
47
+ reveal = reveal_paths[c.id]
48
+ path = breadcrumbs[c.id]
49
+ if path.present?
50
+ item[:path] = mask_unreachable_crumbs(path, reveal)
51
+ end
52
+ item[:reveal_path] = reveal if reveal.present?
53
+ # For linked shells, expose the effective origin id so the picker can
54
+ # map a search breadcrumb (origin ids) back to the rendered shell node.
55
+ item[:origin_id] = c.effective_origin.id if c.origin_id
56
+ item
57
+ end
58
+ end
59
+
60
+ # A breadcrumb jump expands the tree from a rendered root (or, for a shared
61
+ # subtree, a linked shell) down to the clicked crumb. If an ancestor above a
62
+ # crumb is itself unrenderable (unreadable, or archived while archived rows
63
+ # aren't shown) and no linked shell re-roots the path at/below it, the
64
+ # descendant can't be expanded either — so mask it too. BreadcrumbResolver
65
+ # masks the unrenderable ancestor itself; this masks everything downstream of
66
+ # it on the plain origin chain, matching exactly what the tree can render.
67
+ #
68
+ # `reveal` is RevealPathResolver's per-origin map: a crumb whose origin id is
69
+ # a key re-roots navigation through its own shell (the client anchors at the
70
+ # nearest reveal entry at/above the clicked crumb), so it clears the block for
71
+ # itself and its descendants regardless of an archived/unreadable origin
72
+ # above it.
73
+ def mask_unreachable_crumbs(path, reveal)
74
+ blocked = false
75
+ path.map do |crumb|
76
+ if reveal&.key?(crumb[:id])
77
+ blocked = false
78
+ crumb
79
+ elsif crumb[:restricted]
80
+ blocked = true
81
+ crumb
82
+ elsif blocked
83
+ crumb.merge(restricted: true, description: nil)
84
+ else
85
+ crumb
86
+ end
87
+ end
88
+ end
89
+
90
+ # Batched "does this node have a child the user can actually browse to?"
91
+ # lookup so the picker tree renders expand toggles without an N+1.
92
+ #
93
+ # Must match exactly what expanding the node shows (IndexQuery#handle_id_query
94
+ # -> children_with_permission, minus archived unless show_archived), or the
95
+ # toggle either hides a reachable subtree or opens to an empty branch (and
96
+ # leaks that hidden children exist). Two alignments are needed:
97
+ # 1. Linked shells (origin_id set) store children under the effective
98
+ # origin (redirect_parent_to_origin + children->origin migration), so
99
+ # resolve each row to its effective origin before the lookup.
100
+ # 2. Apply the same archived + read-permission filters as the browse path.
101
+ def children_presence_set(collection)
102
+ return Set.new if collection.empty?
103
+
104
+ origin_id_by_id = collection.to_h { |c| [ c.id, c.effective_origin.id ] }
105
+
106
+ candidates = Creative.where(parent_id: origin_id_by_id.values.uniq)
107
+ candidates = candidates.where(archived_at: nil) unless params[:show_archived]
108
+ child_rows = candidates.pluck(:id, :parent_id) # [child_id, origin_id]
109
+ return Set.new if child_rows.empty?
110
+
111
+ readable = PermissionFilter
112
+ .new(user: user).readable_ids(child_rows.map(&:first)).to_set
113
+ origins_with_visible_children = child_rows
114
+ .each_with_object(Set.new) { |(child_id, origin_id), set| set << origin_id if readable.include?(child_id) }
115
+
116
+ collection.each_with_object(Set.new) do |c, set|
117
+ set << c.id if origins_with_visible_children.include?(origin_id_by_id[c.id])
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,37 @@
1
+ module Collavre
2
+ module Creatives
3
+ # Single source of truth for "which creative's cache/ownership actually
4
+ # governs permission for this creative?".
5
+ #
6
+ # A linked creative (origin_id present) borrows its origin's permission: it
7
+ # has no cache entries of its own and its ownership is the origin's ownership.
8
+ # PermissionChecker (single-item) and PermissionFilter (batch) MUST resolve
9
+ # the effective creative through this module so a single check and a batch
10
+ # filter can never diverge for the same user + linked creative.
11
+ module EffectiveCreativeResolution
12
+ module_function
13
+
14
+ # For a single loaded creative: returns the creative whose cache/ownership
15
+ # governs permission. Non-linked creatives resolve to themselves.
16
+ def effective_creative(creative)
17
+ creative.origin_id.nil? ? creative : creative.origin
18
+ end
19
+
20
+ # For a batch of creative ids: returns { original_id => effective_id }.
21
+ # Linked creatives map to their origin_id; every other id (including ids
22
+ # not found in the table) maps to itself. This mirrors
23
+ # +effective_creative+ so the batch reader resolves origin identically.
24
+ def effective_creative_ids(ids)
25
+ # Coerce to Integer: origin_by_id is keyed by integer ids from pluck, so a
26
+ # string id (e.g. sourced from request params) would miss origin lookup
27
+ # and later miss the integer-keyed readable Set. Non-numeric ids can't
28
+ # identify a Creative, so drop them.
29
+ ids = ids.to_a.filter_map { |id| Integer(id, exception: false) }.uniq
30
+ return {} if ids.empty?
31
+
32
+ origin_by_id = Creative.where(id: ids).pluck(:id, :origin_id).to_h
33
+ ids.index_with { |id| origin_by_id[id] || id }
34
+ end
35
+ end
36
+ end
37
+ end