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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +47 -103
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +54 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +28 -5
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +61 -8
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +7 -1
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +42 -10
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +231 -471
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +15 -5
- data/app/models/collavre/comment.rb +87 -32
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +104 -19
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +27 -0
- data/app/models/collavre/user.rb +90 -10
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +81 -12
- data/app/services/collavre/auto_theme_generator.rb +15 -11
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +107 -78
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +9 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +16 -0
- data/config/locales/comments.ko.yml +16 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +96 -2
- 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).
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
138
|
-
#
|
|
139
|
-
#
|
|
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
|
-
|
|
144
|
-
accessible =
|
|
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
|
-
#
|
|
10
|
-
#
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|