collavre 0.22.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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Api
5
+ module V1
6
+ module Mobile
7
+ # Shared base for the voice-companion mobile API. Inherits Doorkeeper
8
+ # bearer auth from Api::V1::BaseController.
9
+ #
10
+ # ★ The API base controller authenticates the token but does NOT run the
11
+ # host app's locale around_action, so I18n would otherwise serve the
12
+ # process-default locale. Every persisted/spoken string here must match
13
+ # the speaker, so we wrap every action in I18n.with_locale (app-supplied
14
+ # `locale` param wins, else the user's stored locale).
15
+ class BaseController < Collavre::Api::V1::BaseController
16
+ around_action :with_request_locale
17
+
18
+ private
19
+
20
+ def with_request_locale(&block)
21
+ I18n.with_locale(requested_locale, &block)
22
+ end
23
+
24
+ def requested_locale
25
+ normalize_locale(params[:locale].presence || current_user&.locale)
26
+ end
27
+
28
+ def normalize_locale(value)
29
+ return I18n.default_locale if value.blank?
30
+
31
+ base = value.to_s.tr("-", "_").split("_").first.to_sym
32
+ I18n.available_locales.include?(base) ? base : I18n.default_locale
33
+ end
34
+
35
+ def summarizer
36
+ @summarizer ||= Collavre::Mobile::EventSummarizer.new(locale: I18n.locale)
37
+ end
38
+
39
+ # User-owned AI agents (Claude Channel sessions, etc.).
40
+ def agent_ids
41
+ @agent_ids ||= Collavre::User.ai_agents.where(created_by_id: current_user.id).pluck(:id)
42
+ end
43
+
44
+ # Undecided permission prompts the token holder is the approver for.
45
+ def pending_approvals
46
+ Collavre::Comment.where(approver_id: current_user.id, action_executed_at: nil)
47
+ .where.not(action: nil)
48
+ .order(:created_at)
49
+ .limit(50)
50
+ .select(&:claude_channel_permission?)
51
+ end
52
+
53
+ def reply(key)
54
+ I18n.t("collavre.mobile.reply.#{key}")
55
+ end
56
+
57
+ def label_for_topic(topic_id)
58
+ topic = topic_id && Collavre::Topic.find_by(id: topic_id)
59
+ topic&.name.presence || I18n.t("collavre.mobile.default_task_label")
60
+ end
61
+
62
+ # List/display title for an event: "Creative#Topic" so the app can show
63
+ # which thread a message belongs to (spec: 제목을 크리에이티브#토픽 형태로).
64
+ def title_for_topic(topic_id)
65
+ topic = topic_id && Collavre::Topic.find_by(id: topic_id)
66
+ name = topic&.name.presence || I18n.t("collavre.mobile.default_task_label")
67
+ creative = topic&.creative&.effective_origin || topic&.creative
68
+ creative&.description.present? ? "#{creative.description}##{name}" : name
69
+ end
70
+
71
+ def render_speak(reply_key_or_text, action: {}, speak: true, status: :ok)
72
+ text = reply_key_or_text.is_a?(Symbol) ? reply(reply_key_or_text) : reply_key_or_text
73
+ render json: { reply: text, speak: speak, action: action }, status: status
74
+ end
75
+
76
+ # Gate + decide + broadcast a Claude Channel permission. Routes the
77
+ # voice path through the same approval_status gate the web path uses
78
+ # (Comments::ApprovalActions). Without this the caller could decide a
79
+ # prompt they merely own the authoring agent for but are not the
80
+ # designated approver of (authorized_comment? lets agent-owned events
81
+ # through). Returns a symbol: :ok | :unauthorized | :already_decided.
82
+ def decide_permission(comment, behavior)
83
+ return :unauthorized unless comment.can_be_approved_by?(current_user)
84
+
85
+ comment.decide_claude_channel_permission!(behavior, by: current_user)
86
+ comment.broadcast_claude_channel_permission_decision(behavior)
87
+ :ok
88
+ rescue Collavre::Comment::ClaudeChannelPermission::AlreadyDecided
89
+ :already_decided
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Api
5
+ module V1
6
+ module Mobile
7
+ # FCM registration for the Android companion. Push is a follow-up (MVP
8
+ # uses foreground polling), but registering the device now keeps the
9
+ # token current so push can light up without a client change.
10
+ class DevicesController < BaseController
11
+ def create
12
+ device = Collavre::Device.find_by(fcm_token: device_params[:fcm_token]) ||
13
+ current_user.devices.find_or_initialize_by(client_id: device_params[:client_id])
14
+
15
+ device.assign_attributes(device_params)
16
+ device.user = current_user
17
+ device.device_type = :android if device.device_type.blank?
18
+ device.save!
19
+ head :no_content
20
+ end
21
+
22
+ private
23
+
24
+ def device_params
25
+ params.require(:device).permit(:client_id, :device_type, :app_id, :app_version, :fcm_token)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Api
5
+ module V1
6
+ module Mobile
7
+ # A plain mic press (no message selected) routes here: the utterance
8
+ # starts a new piece of work in the user's Inbox#Main, exactly like
9
+ # typing in the inbox chat — dispatch then matches an agent via
10
+ # routing_expression. Replies to a SPECIFIC message go through
11
+ # agent_events#respond instead (they carry the target event id).
12
+ class VoiceCommandsController < BaseController
13
+ def create
14
+ text = params[:text].to_s.strip
15
+ return render_speak(:empty_response, action: { type: "noop" }) if text.blank?
16
+
17
+ inbox = Collavre::Creative.inbox_for(current_user)
18
+ comment = inbox.comments.create!(content: text, user: current_user)
19
+ render_speak(:sent, action: { type: "message_sent", comment_id: comment.id })
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -64,9 +64,19 @@ module Collavre
64
64
  signed_id = blob.signed_id
65
65
  pattern = "%#{ActiveRecord::Base.sanitize_sql_like(signed_id)}%"
66
66
 
67
- Creative.where("description LIKE ?", pattern).any? do |creative|
68
- creative.has_permission?(Current.user, :write)
69
- end
67
+ creatives = Creative.where("description LIKE ?", pattern).includes(:origin)
68
+ return false if creatives.empty?
69
+
70
+ # Permission is evaluated on the origin for linked creatives. Resolve every
71
+ # referencing creative to its permission base, then delegate the write-rank
72
+ # deny-invariant to the canonical PermissionFilter (owner wins; a
73
+ # user-specific entry — including an explicit no_access deny — takes
74
+ # precedence over the public share). readable_ids batch-loads the cache
75
+ # entries once, preserving the prior N+1 avoidance.
76
+ bases = creatives.map { |c| c.origin_id.nil? ? c : c.origin }.compact.uniq
77
+ Collavre::Creatives::PermissionFilter.new(user: Current.user)
78
+ .readable_ids(bases.map(&:id), min_permission: :write)
79
+ .any?
70
80
  end
71
81
  end
72
82
  end
@@ -134,45 +134,21 @@ module Collavre
134
134
  result
135
135
  end
136
136
 
137
- # Mirrors the access check in Creative#children_with_permission, but
138
- # operates on a pre-fetched set of candidate children so the work
139
- # collapses to a fixed number of queries instead of one per parent.
137
+ # Mirrors the access check in Creative#children_with_permission via the
138
+ # single PermissionFilter read path: the deny-invariant (owner wins, user
139
+ # entry incl. no_access beats public) is served by #readable_ids, and the
140
+ # "a user always sees their own children" listing policy is preserved as an
141
+ # explicit union — identical to children_with_permission's shape, including
142
+ # the owned-shell-with-unreadable-origin case readable_ids gates out.
140
143
  def accessible_child_ids(candidate_ids, candidate_children, user)
141
144
  return Set.new if candidate_ids.empty?
142
145
 
143
- min_rank = CreativeShare.permissions["read"]
144
- accessible = Set.new
146
+ filter = Collavre::Creatives::PermissionFilter.new(user: user)
147
+ accessible = filter.readable_ids(candidate_ids, min_permission: :read).to_set
145
148
 
146
149
  if user
147
- user_entries = CreativeSharesCache
148
- .where(creative_id: candidate_ids, user_id: user.id)
149
- .pluck(:creative_id, :permission)
150
-
151
- user_has_entry = Set.new
152
- user_entries.each do |cid, perm|
153
- user_has_entry << cid
154
- perm_rank = CreativeSharesCache.permissions[perm]
155
- if perm_rank && perm_rank >= min_rank && perm_rank != CreativeSharesCache.permissions[:no_access]
156
- accessible << cid
157
- end
158
- end
159
-
160
- public_accessible = CreativeSharesCache
161
- .where(creative_id: candidate_ids, user_id: nil)
162
- .where("permission >= ?", min_rank)
163
- .where.not(permission: :no_access)
164
- .pluck(:creative_id)
165
- accessible.merge(public_accessible.reject { |cid| user_has_entry.include?(cid) })
166
-
167
150
  owned_ids = candidate_children.select { |c| c.user_id == user.id }.map(&:id)
168
151
  accessible.merge(owned_ids)
169
- else
170
- public_accessible = CreativeSharesCache
171
- .where(creative_id: candidate_ids, user_id: nil)
172
- .where("permission >= ?", min_rank)
173
- .where.not(permission: :no_access)
174
- .pluck(:creative_id)
175
- accessible.merge(public_accessible)
176
152
  end
177
153
 
178
154
  accessible
@@ -6,8 +6,15 @@ module Collavre
6
6
  include Collavre::Concerns::Shareable
7
7
  include Collavre::CreativePermissionGuard
8
8
 
9
- # TODO: for not for security reasons for this Collavre app, we don't expose to public, later it should be controlled by roles for each Creatives
10
- # Removed unauthenticated access to index and show actions
9
+ # Authorization for these read actions is not open-to-public: each action
10
+ # enforces per-Creative read access via has_permission?(Current.user, :read)
11
+ # (index/children go through Creatives::IndexQuery, which permission-filters;
12
+ # show/slide_view/export_markdown check has_permission? directly). Anonymous
13
+ # requests only ever see Creatives whose share grants public read. Requiring
14
+ # a login for all reads is gated by SystemSetting.creatives_login_required?
15
+ # via enforce_creatives_login_policy below. A broader per-Creative role model
16
+ # (beyond the read/feedback/write/admin share levels) is a product decision
17
+ # tracked separately and intentionally deferred.
11
18
  allow_unauthenticated_access only: %i[ index children export_markdown show slide_view ]
12
19
  before_action :enforce_creatives_login_policy, only: %i[ index children export_markdown show slide_view ]
13
20
  before_action :set_creative, only: %i[ show edit update destroy parent_suggestions slide_view request_permission unconvert contexts update_contexts update_metadata archive unarchive trigger_action ]
@@ -39,7 +46,7 @@ module Collavre
39
46
  else
40
47
  {}
41
48
  end
42
- index_result = ::Creatives::IndexQuery.new(user: Current.user, params: params.to_unsafe_h).call
49
+ index_result = ::Creatives::IndexQuery.new(user: Current.user, params: index_query_params).call
43
50
  @creatives = index_result.creatives || []
44
51
  @parent_creative = index_result.parent_creative
45
52
  @shared_creative = index_result.shared_creative
@@ -67,7 +74,9 @@ module Collavre
67
74
  allowed_creative_ids: @allowed_creative_ids,
68
75
  progress_map: @progress_map
69
76
  )
70
- render json: { creatives: @creatives_tree_json }
77
+ payload = { creatives: @creatives_tree_json }
78
+ payload[:pagination] = index_result.pagination if index_result.pagination
79
+ render json: payload
71
80
  end
72
81
  end
73
82
  end
@@ -155,6 +164,10 @@ module Collavre
155
164
  render json: {
156
165
  id: @creative.id,
157
166
  description: @creative.effective_description,
167
+ # Embedded variant for read-only display (e.g. slide view): turns
168
+ # bare YouTube links into preview iframes. `description` stays the
169
+ # raw editable form the inline editor round-trips.
170
+ description_embedded_html: view_context.embed_youtube_iframe(@creative.effective_description),
158
171
  description_raw_html: @creative.description,
159
172
  origin_id: @creative.origin_id,
160
173
  parent_id: @creative.parent_id,
@@ -165,6 +178,7 @@ module Collavre
165
178
  has_children: children_count > 0,
166
179
  data: sanitized_data,
167
180
  content_type: effective.data&.dig("content_type"),
181
+ markdown_editor: effective.data&.dig("editor"),
168
182
  markdown_source: can_edit ? effective.data&.dig("markdown_source") : nil,
169
183
  trigger_loop: trigger_loop_data,
170
184
  is_trigger_task: parent_trigger_enabled,
@@ -210,6 +224,7 @@ module Collavre
210
224
  render json: {
211
225
  id: @creative.id,
212
226
  content_type: @creative.data&.dig("content_type"),
227
+ markdown_editor: @creative.data&.dig("editor"),
213
228
  markdown_source: @creative.data&.dig("markdown_source")
214
229
  }
215
230
  else
@@ -281,7 +296,8 @@ module Collavre
281
296
  progress: base.progress,
282
297
  progress_html: view_context.render_creative_progress(base),
283
298
  has_children: base.children.exists?,
284
- content_type: base.data&.dig("content_type")
299
+ content_type: base.data&.dig("content_type"),
300
+ markdown_editor: base.data&.dig("editor")
285
301
  }
286
302
  # Expose the post-rewrite markdown source so the client can sync its
287
303
  # textarea after the server replaces inline data: URIs with blob paths.
@@ -394,9 +410,11 @@ module Collavre
394
410
  return
395
411
  end
396
412
  # Reserved markdown fields are not editable via metadata; preserve current values so a stale
397
- # YAML payload from the metadata popup can't overwrite a concurrent markdown edit.
413
+ # YAML payload from the metadata popup (or an API client that omits them) can't overwrite a
414
+ # concurrent markdown edit. "editor" must be reserved too: dropping it would erase the "rich"
415
+ # authoring flag and make a Lexical-authored creative reopen in the advanced textarea.
398
416
  current_data = creative.data || {}
399
- %w[markdown_source content_type].each do |key|
417
+ Collavre::Creative.reserved_metadata_keys.each do |key|
400
418
  if current_data.key?(key)
401
419
  new_data[key] = current_data[key]
402
420
  else
@@ -543,7 +561,20 @@ module Collavre
543
561
  end
544
562
 
545
563
  def creative_params
546
- params.require(:creative).permit(:description, :progress, :parent_id, :sequence, :origin_id, :markdown_source, :content_type_input)
564
+ params.require(:creative).permit(:description, :progress, :parent_id, :sequence, :origin_id, :markdown_source, :content_type_input, :markdown_editor)
565
+ end
566
+
567
+ # Whitelist of query parameters consumed by Creatives::IndexQuery and its
568
+ # FilterPipeline. Passing an explicitly permitted hash (rather than
569
+ # params.to_unsafe_h) keeps arbitrary client-supplied keys out of the
570
+ # query layer while preserving every filter the index endpoint supports.
571
+ def index_query_params
572
+ params.permit(
573
+ :id, :simple, :search, :search_mode, :comment, :has_comments,
574
+ :min_progress, :max_progress, :due_before, :due_after, :has_due_date,
575
+ :assignee_id, :unassigned, :show_archived, :page, :per_page,
576
+ tags: []
577
+ ).to_h
547
578
  end
548
579
 
549
580
  def any_filter_active?
@@ -561,102 +592,15 @@ module Collavre
561
592
  params[:show_archived].present?
562
593
  end
563
594
 
595
+ # Thin delegator to Creatives::CreativeTreeSerializer. Kept as a controller
596
+ # method (rather than inlining the service call at the index call site) so
597
+ # the picker test that drives serialization via controller.send(:serialize_creatives, ...)
598
+ # keeps exercising the same seam.
564
599
  def serialize_creatives(collection)
565
- if params[:simple].present?
566
- # Preload origins so effective_origin (used per linked-shell row in both
567
- # children_presence_set and the origin_id mapping below) resolves from
568
- # memory instead of firing a query per shell. Only browse can hold shells;
569
- # search is scoped to origin_id: nil, so this is a no-op there.
570
- ActiveRecord::Associations::Preloader.new(records: collection.to_a, associations: :origin).call
571
- children_ids = children_presence_set(collection)
572
- searching = params[:search].present?
573
- breadcrumbs = searching ? ::Creatives::BreadcrumbResolver.new(collection.map(&:id), user: Current.user, include_archived: params[:show_archived].present?).call : {}
574
- # For hits routed through a linked shell, the path to expand in the
575
- # user's own tree (local folders -> shell) so a breadcrumb jump can
576
- # reach a shell nested under a collapsed folder.
577
- reveal_paths = searching ? ::Creatives::RevealPathResolver.new(collection.map(&:id), user: Current.user, include_archived: params[:show_archived].present?).call : {}
578
- collection.map do |c|
579
- item = {
580
- id: c.id,
581
- description: c.effective_description(nil, false),
582
- progress: c.progress,
583
- has_children: children_ids.include?(c.id)
584
- }
585
- reveal = reveal_paths[c.id]
586
- path = breadcrumbs[c.id]
587
- if path.present?
588
- item[:path] = mask_unreachable_crumbs(path, reveal)
589
- end
590
- item[:reveal_path] = reveal if reveal.present?
591
- # For linked shells, expose the effective origin id so the picker can
592
- # map a search breadcrumb (origin ids) back to the rendered shell node.
593
- item[:origin_id] = c.effective_origin.id if c.origin_id
594
- item
595
- end
596
- else
597
- collection.map { |c| { id: c.id, description: c.effective_description, progress: c.progress } }
598
- end
599
- end
600
-
601
- # A breadcrumb jump expands the tree from a rendered root (or, for a shared
602
- # subtree, a linked shell) down to the clicked crumb. If an ancestor above a
603
- # crumb is itself unrenderable (unreadable, or archived while archived rows
604
- # aren't shown) and no linked shell re-roots the path at/below it, the
605
- # descendant can't be expanded either — so mask it too. BreadcrumbResolver
606
- # masks the unrenderable ancestor itself; this masks everything downstream of
607
- # it on the plain origin chain, matching exactly what the tree can render.
608
- #
609
- # `reveal` is RevealPathResolver's per-origin map: a crumb whose origin id is
610
- # a key re-roots navigation through its own shell (the client anchors at the
611
- # nearest reveal entry at/above the clicked crumb), so it clears the block for
612
- # itself and its descendants regardless of an archived/unreadable origin
613
- # above it.
614
- def mask_unreachable_crumbs(path, reveal)
615
- blocked = false
616
- path.map do |crumb|
617
- if reveal&.key?(crumb[:id])
618
- blocked = false
619
- crumb
620
- elsif crumb[:restricted]
621
- blocked = true
622
- crumb
623
- elsif blocked
624
- crumb.merge(restricted: true, description: nil)
625
- else
626
- crumb
627
- end
628
- end
629
- end
630
-
631
- # Batched "does this node have a child the user can actually browse to?"
632
- # lookup so the picker tree renders expand toggles without an N+1.
633
- #
634
- # Must match exactly what expanding the node shows (IndexQuery#handle_id_query
635
- # -> children_with_permission, minus archived unless show_archived), or the
636
- # toggle either hides a reachable subtree or opens to an empty branch (and
637
- # leaks that hidden children exist). Two alignments are needed:
638
- # 1. Linked shells (origin_id set) store children under the effective
639
- # origin (redirect_parent_to_origin + children->origin migration), so
640
- # resolve each row to its effective origin before the lookup.
641
- # 2. Apply the same archived + read-permission filters as the browse path.
642
- def children_presence_set(collection)
643
- return Set.new if collection.empty?
644
-
645
- origin_id_by_id = collection.to_h { |c| [ c.id, c.effective_origin.id ] }
646
-
647
- candidates = Creative.where(parent_id: origin_id_by_id.values.uniq)
648
- candidates = candidates.where(archived_at: nil) unless params[:show_archived]
649
- child_rows = candidates.pluck(:id, :parent_id) # [child_id, origin_id]
650
- return Set.new if child_rows.empty?
651
-
652
- readable = ::Creatives::PermissionFilter
653
- .new(user: Current.user).readable_ids(child_rows.map(&:first)).to_set
654
- origins_with_visible_children = child_rows
655
- .each_with_object(Set.new) { |(child_id, origin_id), set| set << origin_id if readable.include?(child_id) }
656
-
657
- collection.each_with_object(Set.new) do |c, set|
658
- set << c.id if origins_with_visible_children.include?(origin_id_by_id[c.id])
659
- end
600
+ # Fully-qualified name: this newly extracted service is not registered in
601
+ # config/initializers/collavre_model_aliases.rb, so the top-level
602
+ # ::Creatives::* alias used by the older sibling services is unavailable.
603
+ Collavre::Creatives::CreativeTreeSerializer.new(user: Current.user, params: params).serialize(collection)
660
604
  end
661
605
 
662
606
  def reorderer
@@ -38,12 +38,18 @@ module Collavre
38
38
  end
39
39
 
40
40
  def update
41
- if @inbox_item.owner == Current.user
42
- @inbox_item.update(state: params[:state])
43
- head :no_content
44
- else
41
+ unless @inbox_item.owner == Current.user
45
42
  head :forbidden
43
+ return
46
44
  end
45
+
46
+ unless InboxItem.states.key?(params[:state].to_s)
47
+ head :unprocessable_entity
48
+ return
49
+ end
50
+
51
+ @inbox_item.update(state: params[:state])
52
+ head :no_content
47
53
  end
48
54
 
49
55
  def destroy
@@ -2,7 +2,10 @@ module Collavre
2
2
  class LandingController < ApplicationController
3
3
  allow_unauthenticated_access
4
4
 
5
- def show
6
- end
5
+ # The demo video/poster URLs live in config/locales/landing.*.yml as full
6
+ # absolute URLs pointing at the hosted assets. Nothing to resolve here — the
7
+ # view reads them directly, so the landing page has no dependency on the
8
+ # Creatives model and the URLs work from any environment (preview included).
9
+ def show; end
7
10
  end
8
11
  end
@@ -14,12 +14,21 @@ module Collavre
14
14
  return head :unprocessable_entity
15
15
  end
16
16
 
17
- was_delegated = task.status == "delegated"
17
+ # These statuses count against the topic slot (occupying_topic_slot) yet no
18
+ # live worker will run AiAgentJob's ensure-block drain for them:
19
+ # - delegated / pending_approval already returned from the job holding the
20
+ # slot (should_release = false) — awaiting an MCP reply / approval.
21
+ # - pending may be a waiter that dequeue_next_for_topic promoted
22
+ # queued -> pending before its job starts; once cancelled, that job
23
+ # early-returns at the top of #perform and never reaches the ensure drain.
24
+ # So free the agent slot and drain the topic queue here — otherwise
25
+ # cancelling the blocker leaves agent capacity and the next waiter stuck
26
+ # until stuck recovery. release!/dequeue are idempotent (dequeue is bounded
27
+ # by topic_at_capacity?), so a racing live worker that also drains is harmless.
28
+ held_slot_without_worker = %w[pending delegated pending_approval].include?(task.status)
18
29
  task.update!(status: "cancelled")
19
30
 
20
- # Delegated tasks have no active worker to run the ensure-block release,
21
- # so free the agent slot and drain the topic queue here.
22
- if was_delegated && task.agent
31
+ if held_slot_without_worker && task.agent
23
32
  Collavre::Orchestration::ResourceTracker.for(task.agent).release!(task.id)
24
33
  Collavre::Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
25
34
  end
@@ -128,6 +128,7 @@ module Collavre
128
128
 
129
129
  def move
130
130
  topic = @creative.topics.find(params[:id])
131
+ source_creative = @creative
131
132
  target_creative = Creative.find(params[:target_creative_id]).effective_origin
132
133
 
133
134
  unless target_creative.has_permission?(Current.user, :write) || target_creative.user == Current.user
@@ -144,10 +145,21 @@ module Collavre
144
145
  topic.update!(creative: target_creative)
145
146
  end
146
147
 
148
+ # update_all above skips counter-cache callbacks, so recompute
149
+ # comments_count on both sides after re-parenting the comments.
150
+ Creative.reset_counters(source_creative.id, :comments)
151
+ Creative.reset_counters(target_creative.id, :comments)
152
+
147
153
  broadcast_topic_event("deleted", topic_id: topic.id)
148
154
  broadcast_topic_event("created", creative: target_creative, topic: topic.slice(:id, :name))
149
155
 
150
- render json: { success: true, topic: topic.slice(:id, :name), target_creative_id: target_creative.id }
156
+ render json: {
157
+ success: true,
158
+ topic: topic.slice(:id, :name),
159
+ target_creative_id: target_creative.id,
160
+ target_creative_name: target_creative.creative_snippet,
161
+ missing_members: missing_members_for_move(source_creative, target_creative)
162
+ }
151
163
  end
152
164
 
153
165
  def archive
@@ -204,6 +216,47 @@ module Collavre
204
216
  @creative = Creative.find(params[:creative_id]).effective_origin
205
217
  end
206
218
 
219
+ # When a topic moves to a different creative, members who had access on the
220
+ # source creative may lose visibility on the target. Returns the human
221
+ # members who are effectively present on the source (owner + feedback-or-
222
+ # higher shares) but have no resolvable access on the target, so the client
223
+ # can offer to re-add them with the same permission. Only returned when the
224
+ # current user can actually grant shares on the target (admin/owner);
225
+ # otherwise the suggestion would be unusable.
226
+ def missing_members_for_move(source_creative, target_creative)
227
+ return [] unless target_creative.has_permission?(Current.user, :admin) || target_creative.user == Current.user
228
+
229
+ # Any user with a resolvable share on the target chain (including inherited
230
+ # shares and explicit no_access blocks) already has — or is intentionally
231
+ # denied — access, so they are never "missing".
232
+ excluded_user_ids = target_creative.all_shared_users(:no_access).map(&:user_id).compact.to_set
233
+ excluded_user_ids << target_creative.user_id
234
+ excluded_user_ids << Current.user&.id
235
+
236
+ # Candidate => grant permission. Source owner had full control, so we
237
+ # suggest admin; shared members keep their own (closest) permission.
238
+ candidates = {}
239
+ owner = source_creative.user
240
+ if owner && !owner.ai_user? && excluded_user_ids.exclude?(owner.id)
241
+ candidates[owner.id] = { user: owner, permission: "admin" }
242
+ end
243
+
244
+ source_creative.all_shared_users(:feedback).each do |share|
245
+ user = share.user
246
+ next if user.nil? || user.ai_user?
247
+ next if excluded_user_ids.include?(user.id)
248
+
249
+ candidates[user.id] ||= { user: user, permission: share.permission }
250
+ end
251
+
252
+ candidates.values.map do |candidate|
253
+ {
254
+ user: view_context.user_json(candidate[:user], email: true),
255
+ permission: candidate[:permission]
256
+ }
257
+ end
258
+ end
259
+
207
260
  def topic_params
208
261
  params.require(:topic).permit(:name)
209
262
  end
@@ -0,0 +1,39 @@
1
+ module Collavre
2
+ # Session-authenticated endpoint backing the inline typo-correction UI. The
3
+ # frontend also gates by device/location, but we re-check here (fail closed)
4
+ # before spending an LLM call, and echo the user's auto-apply threshold so the
5
+ # client never has to hardcode it.
6
+ class TypoCorrectionsController < ApplicationController
7
+ # The client debounce is only a hint — a scripted/compromised page can loop
8
+ # this endpoint with arbitrary text, and every accepted call spends an LLM
9
+ # request. Bound both the call rate and the per-call size server-side. The
10
+ # chat composer is short text; anything larger is not a typo-correction case.
11
+ MAX_TEXT_LENGTH = 4_000
12
+ RATE_LIMIT = 120 # requests
13
+ RATE_PERIOD = 1.minute # ...per user per minute
14
+
15
+ def create
16
+ user = Current.user
17
+ device = params[:device].to_s
18
+ location = params[:location].presence || "chat"
19
+ text = params[:text].to_s
20
+
21
+ unless user.typo_correction_active_for?(device: device, location: location)
22
+ return render json: { edits: [], threshold: user.typo_correction_threshold }
23
+ end
24
+
25
+ # Oversized input: skip the LLM entirely (return no edits, not an error —
26
+ # this is a background affordance, not a user-facing failure).
27
+ if text.length > MAX_TEXT_LENGTH
28
+ return render json: { edits: [], threshold: user.typo_correction_threshold }
29
+ end
30
+
31
+ # Raises RateLimitExceeded -> 429 (DynamicRateLimiting), which the client
32
+ # treats as "no suggestions this round".
33
+ check_rate_limit!(key: "typo_correction:#{user.id}", limit: RATE_LIMIT, period: RATE_PERIOD)
34
+
35
+ edits = TypoCorrector.new.correct(text)
36
+ render json: { edits: edits, threshold: user.typo_correction_threshold }
37
+ end
38
+ end
39
+ end