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
|
@@ -3,13 +3,30 @@ module Collavre
|
|
|
3
3
|
module Permissible
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
|
+
# Single declarative registry of which persisted-attribute changes
|
|
7
|
+
# invalidate the permission cache, and how each is rebuilt. A single
|
|
8
|
+
# after_commit dispatcher maps the accumulated changes through this map so
|
|
9
|
+
# a new mutation path can never silently skip a required rebuild.
|
|
10
|
+
#
|
|
11
|
+
# :rebuild -> rebuild_for_creative (self + descendant subtree)
|
|
12
|
+
# :rebuild_owner -> update_owner (move the owner cache entry)
|
|
13
|
+
#
|
|
14
|
+
# origin_id is immutable after create (see Linkable#attr_readonly), so its
|
|
15
|
+
# entry only fires on link creation and is defensive.
|
|
16
|
+
PERMISSION_INVALIDATING_ATTRIBUTES = {
|
|
17
|
+
"parent_id" => :rebuild,
|
|
18
|
+
"user_id" => :rebuild_owner,
|
|
19
|
+
"origin_id" => :rebuild
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
6
22
|
included do
|
|
7
23
|
has_many :creative_shares, class_name: "Collavre::CreativeShare", dependent: :destroy
|
|
8
24
|
has_many :creative_shares_caches, class_name: "Collavre::CreativeSharesCache", dependent: :delete_all
|
|
9
25
|
|
|
10
|
-
|
|
26
|
+
after_save :accumulate_permission_cache_changes
|
|
27
|
+
after_commit :dispatch_permission_cache_invalidation, unless: :destroyed?
|
|
11
28
|
after_commit :cache_owner_permission, on: :create
|
|
12
|
-
|
|
29
|
+
after_rollback :clear_accumulated_permission_changes
|
|
13
30
|
end
|
|
14
31
|
|
|
15
32
|
def has_permission?(user, required_permission = :read)
|
|
@@ -23,42 +40,21 @@ module Collavre
|
|
|
23
40
|
children_ids = children_scope.pluck(:id)
|
|
24
41
|
return [] if children_ids.empty?
|
|
25
42
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
public_accessible = CreativeSharesCache
|
|
44
|
-
.where(creative_id: children_ids, user_id: nil)
|
|
45
|
-
.where("permission >= ?", min_rank)
|
|
46
|
-
.where.not(permission: :no_access)
|
|
47
|
-
.pluck(:creative_id)
|
|
48
|
-
accessible_ids.merge(public_accessible - user_has_entry.to_a)
|
|
49
|
-
|
|
50
|
-
owned_ids = children_scope.where(user_id: user.id).pluck(:id)
|
|
51
|
-
accessible_ids.merge(owned_ids)
|
|
52
|
-
else
|
|
53
|
-
accessible_ids = CreativeSharesCache
|
|
54
|
-
.where(creative_id: children_ids, user_id: nil)
|
|
55
|
-
.where("permission >= ?", min_rank)
|
|
56
|
-
.where.not(permission: :no_access)
|
|
57
|
-
.pluck(:creative_id)
|
|
58
|
-
.to_set
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
children_scope.where(id: accessible_ids.to_a).order(:sequence).to_a
|
|
43
|
+
# The deny-invariant (owner wins → user entry, incl. a no_access deny,
|
|
44
|
+
# beats public → effective-origin resolution) now lives in exactly one
|
|
45
|
+
# place: PermissionFilter. This site used to re-implement it inline.
|
|
46
|
+
accessible_ids = Collavre::Creatives::PermissionFilter.new(user: user)
|
|
47
|
+
.readable_ids(children_ids, min_permission: min_permission)
|
|
48
|
+
|
|
49
|
+
# readable_ids gates a linked shell on its origin being readable AND the
|
|
50
|
+
# viewer being able to see the shell's placement (owns it, or it sits in
|
|
51
|
+
# a subtree shared with the viewer). Listing one's OWN tree keeps the
|
|
52
|
+
# prior policy that a viewer always sees their own children — including a
|
|
53
|
+
# shell they own whose origin is no longer shared with them — so union
|
|
54
|
+
# those owned rows back in. (Owner has admin, so this is rank-independent.)
|
|
55
|
+
accessible_ids |= children_scope.where(user_id: user.id).pluck(:id) if user
|
|
56
|
+
|
|
57
|
+
children_scope.where(id: accessible_ids).order(:sequence).to_a
|
|
62
58
|
end
|
|
63
59
|
|
|
64
60
|
def all_shared_users(required_permission = :no_access)
|
|
@@ -105,21 +101,73 @@ module Collavre
|
|
|
105
101
|
|
|
106
102
|
private
|
|
107
103
|
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
# Accumulate permission-affecting changes across every save in the
|
|
105
|
+
# transaction. after_commit sees only the *final* save's saved_changes, so
|
|
106
|
+
# if a permission attribute changes in one save and a later same-
|
|
107
|
+
# transaction save of this record touches only untracked columns, that
|
|
108
|
+
# permission change is clobbered out of saved_changes and a saved_changes-
|
|
109
|
+
# gated dispatcher would skip the rebuild, leaving the permission cache
|
|
110
|
+
# stale (fail-open, with no TTL/self-heal). Merging each save's changes
|
|
111
|
+
# keeps the invariant regardless of save ordering. This mirrors the fail-
|
|
112
|
+
# closed intent of the CreativeShare fix (#1393 / 00502d7d); it is
|
|
113
|
+
# centralized here via cross-save accumulation rather than an
|
|
114
|
+
# unconditional refresh so that permission-irrelevant Creative updates
|
|
115
|
+
# (progress rollup, description edits — a hot write path) stay cheap.
|
|
116
|
+
#
|
|
117
|
+
# No production path performs two real saves touching a permission
|
|
118
|
+
# attribute then an untracked column on one Creative in a single
|
|
119
|
+
# transaction today (the reorderer re-saves :sequence via update_column,
|
|
120
|
+
# which does not run changes_applied and so preserves saved_changes), so
|
|
121
|
+
# this is a defensive/latent fix. Note: update_column bypasses this
|
|
122
|
+
# callback too, which is why the reorderer's parent change still refreshes.
|
|
123
|
+
def accumulate_permission_cache_changes
|
|
124
|
+
changes = saved_changes.slice(*PERMISSION_INVALIDATING_ATTRIBUTES.keys)
|
|
125
|
+
return if changes.empty?
|
|
126
|
+
|
|
127
|
+
@accumulated_permission_changes ||= {}
|
|
128
|
+
changes.each do |attribute, (old_value, new_value)|
|
|
129
|
+
existing = @accumulated_permission_changes[attribute]
|
|
130
|
+
# Keep the earliest "old" and the latest "new" across saves.
|
|
131
|
+
@accumulated_permission_changes[attribute] = [ existing ? existing.first : old_value, new_value ]
|
|
132
|
+
end
|
|
110
133
|
end
|
|
111
134
|
|
|
112
|
-
def
|
|
113
|
-
|
|
135
|
+
def clear_accumulated_permission_changes
|
|
136
|
+
@accumulated_permission_changes = nil
|
|
114
137
|
end
|
|
115
138
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
139
|
+
# Single dispatch point for permission-cache invalidation. Maps the
|
|
140
|
+
# accumulated permission-attribute changes through
|
|
141
|
+
# PERMISSION_INVALIDATING_ATTRIBUTES and enqueues each distinct rebuild
|
|
142
|
+
# exactly once.
|
|
143
|
+
def dispatch_permission_cache_invalidation
|
|
144
|
+
accumulated = @accumulated_permission_changes || {}
|
|
145
|
+
clear_accumulated_permission_changes
|
|
146
|
+
|
|
147
|
+
operations = accumulated.keys
|
|
148
|
+
.map { |attr| PERMISSION_INVALIDATING_ATTRIBUTES[attr] }
|
|
149
|
+
.uniq
|
|
150
|
+
return if operations.empty?
|
|
151
|
+
|
|
152
|
+
operations.each { |operation| run_permission_cache_operation(operation, accumulated) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def run_permission_cache_operation(operation, accumulated)
|
|
156
|
+
case operation
|
|
157
|
+
when :rebuild
|
|
158
|
+
PermissionCacheJob.perform_later(:rebuild_for_creative, creative_id: id)
|
|
159
|
+
when :rebuild_owner
|
|
160
|
+
old_user_id, new_user_id = accumulated["user_id"]
|
|
161
|
+
PermissionCacheJob.perform_later(:update_owner,
|
|
162
|
+
creative_id: id,
|
|
163
|
+
old_user_id: old_user_id,
|
|
164
|
+
new_user_id: new_user_id
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def cache_owner_permission
|
|
170
|
+
PermissionCacheJob.perform_later(:cache_owner, creative_id: id)
|
|
123
171
|
end
|
|
124
172
|
end
|
|
125
173
|
end
|
|
@@ -5,6 +5,40 @@ module Collavre
|
|
|
5
5
|
class Creative < ApplicationRecord
|
|
6
6
|
self.table_name = "creatives"
|
|
7
7
|
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# Reserved metadata key registry — engines register their own namespaces so
|
|
10
|
+
# `update_metadata` preserves them without core naming vendor-specific keys.
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
BUILTIN_RESERVED_METADATA_KEYS = %w[markdown_source content_type editor].freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def registered_reserved_metadata_keys
|
|
16
|
+
@registered_reserved_metadata_keys ||= []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def register_reserved_metadata_key(key)
|
|
20
|
+
registered_reserved_metadata_keys << key.to_s unless registered_reserved_metadata_keys.include?(key.to_s)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reserved_metadata_keys
|
|
24
|
+
(BUILTIN_RESERVED_METADATA_KEYS + registered_reserved_metadata_keys).freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# ------------------------------------------------------------------------
|
|
28
|
+
# Read-only source registry — vendor engines register the `data.source.type`
|
|
29
|
+
# values whose content is owned by an external system (e.g. a synced GitHub
|
|
30
|
+
# repository) and therefore must not be edited in-app. Core enforces the
|
|
31
|
+
# read-only behavior via `read_only_source?` without naming any vendor.
|
|
32
|
+
# ------------------------------------------------------------------------
|
|
33
|
+
def read_only_source_types
|
|
34
|
+
@read_only_source_types ||= Set.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def register_read_only_source(type)
|
|
38
|
+
read_only_source_types << type.to_s
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
8
42
|
# Use non-namespaced partial path for backward compatibility
|
|
9
43
|
def to_partial_path
|
|
10
44
|
"creatives/creative"
|
|
@@ -43,10 +77,28 @@ module Collavre
|
|
|
43
77
|
data&.dig("kind") == "inbox"
|
|
44
78
|
end
|
|
45
79
|
|
|
46
|
-
|
|
80
|
+
# Bypass the read-only-source guard for a single save (used by the vendor
|
|
81
|
+
# sync services that legitimately write the synced content into core).
|
|
82
|
+
attr_accessor :skip_read_only_source_validation
|
|
83
|
+
|
|
84
|
+
# The registered source identifier for this creative, or nil when the
|
|
85
|
+
# description is authored in-app.
|
|
86
|
+
def source_type
|
|
87
|
+
data.is_a?(Hash) ? data.dig("source", "type") : nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Whether this creative's description is owned by an external, registered
|
|
91
|
+
# source and therefore read-only in-app.
|
|
92
|
+
def read_only_source?
|
|
93
|
+
type = source_type
|
|
94
|
+
type.present? && self.class.read_only_source_types.include?(type)
|
|
95
|
+
end
|
|
47
96
|
|
|
97
|
+
# GitHub-sourced content still needs a vendor-specific predicate for the
|
|
98
|
+
# comment view's inline-image rendering (a GitHub-only concern, distinct
|
|
99
|
+
# from the neutral read-only behavior above).
|
|
48
100
|
def github_markdown?
|
|
49
|
-
|
|
101
|
+
source_type == "github_markdown"
|
|
50
102
|
end
|
|
51
103
|
|
|
52
104
|
# Find or create the "System" topic for this inbox creative.
|
|
@@ -133,7 +185,14 @@ module Collavre
|
|
|
133
185
|
|
|
134
186
|
after_save :update_parent_progress
|
|
135
187
|
after_destroy :update_parent_progress
|
|
136
|
-
|
|
188
|
+
# Re-derive MCP tools only when the description (the tool source of truth)
|
|
189
|
+
# actually changed, and defer the HTML parsing to a background job so it
|
|
190
|
+
# never runs inline on progress/move/autosave writes. The dirty flag is
|
|
191
|
+
# captured in after_save (where saved_change_to_description? is reliable);
|
|
192
|
+
# a later same-transaction save can clobber saved_changes before the
|
|
193
|
+
# after_commit hook runs.
|
|
194
|
+
after_save :mark_mcp_tools_sync_pending, if: :saved_change_to_description?
|
|
195
|
+
after_commit :enqueue_mcp_tools_sync, if: :mcp_tools_sync_pending?
|
|
137
196
|
|
|
138
197
|
# --- Drop Trigger ---
|
|
139
198
|
def drop_trigger_enabled?
|
|
@@ -292,8 +351,17 @@ module Collavre
|
|
|
292
351
|
@progress_service ||= Collavre::Creatives::ProgressService.new(self)
|
|
293
352
|
end
|
|
294
353
|
|
|
295
|
-
def
|
|
296
|
-
|
|
354
|
+
def mark_mcp_tools_sync_pending
|
|
355
|
+
@mcp_tools_sync_pending = true
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def mcp_tools_sync_pending?
|
|
359
|
+
@mcp_tools_sync_pending == true
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def enqueue_mcp_tools_sync
|
|
363
|
+
@mcp_tools_sync_pending = false
|
|
364
|
+
UpdateMcpToolsJob.perform_later(id)
|
|
297
365
|
end
|
|
298
366
|
|
|
299
367
|
def progress_cannot_change_if_has_origin
|
|
@@ -20,11 +20,32 @@ module Collavre
|
|
|
20
20
|
validates :permission, presence: true
|
|
21
21
|
validates :user_id, uniqueness: { scope: :creative_id }, allow_nil: true
|
|
22
22
|
|
|
23
|
+
# Single declarative registry mirroring Creative::Permissible: which
|
|
24
|
+
# persisted CreativeShare attributes affect the permission cache, and how.
|
|
25
|
+
# Every create and update re-propagates the share unconditionally (see
|
|
26
|
+
# dispatch_share_cache_invalidation) — a fail-closed refresh that matches
|
|
27
|
+
# the prior propagate_cache and survives a multi-save transaction clobbering
|
|
28
|
+
# an earlier permission change out of the final saved_changes. This map
|
|
29
|
+
# governs the *extra* cleanup a move or reassignment needs on top of that
|
|
30
|
+
# base refresh, so a new share-mutation path still can't silently skip it:
|
|
31
|
+
#
|
|
32
|
+
# :relocate -> creative_id moved: purge this share's rows and rebuild
|
|
33
|
+
# the vacated (old creative, old user) subtree
|
|
34
|
+
# :reassign -> user_id changed: purge this share's rows and rebuild the
|
|
35
|
+
# old user's subtree at the current creative
|
|
36
|
+
# :repropagate -> permission changed: covered by the unconditional
|
|
37
|
+
# re-propagate above (listed to document the invariant)
|
|
38
|
+
PERMISSION_INVALIDATING_ATTRIBUTES = {
|
|
39
|
+
"creative_id" => :relocate,
|
|
40
|
+
"user_id" => :reassign,
|
|
41
|
+
"permission" => :repropagate
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
23
44
|
after_create_commit :notify_recipient, unless: :no_access?
|
|
24
45
|
after_save :touch_creative_subtree
|
|
25
46
|
after_destroy :touch_creative_subtree
|
|
26
47
|
|
|
27
|
-
after_commit :
|
|
48
|
+
after_commit :dispatch_share_cache_invalidation, on: [ :create, :update ]
|
|
28
49
|
after_commit :broadcast_share_change, on: [ :create, :update ]
|
|
29
50
|
after_destroy_commit :remove_cache
|
|
30
51
|
after_destroy_commit :broadcast_share_destroy
|
|
@@ -92,34 +113,65 @@ module Collavre
|
|
|
92
113
|
Creative.exists?(origin_id: creative.id, user_id: user.id)
|
|
93
114
|
end
|
|
94
115
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
# Single dispatch point for permission-cache invalidation on share writes.
|
|
117
|
+
# Every create and update re-propagates the share unconditionally — a
|
|
118
|
+
# fail-closed refresh matching the prior propagate_cache, correct even when
|
|
119
|
+
# a multi-save transaction clobbers an earlier permission change out of the
|
|
120
|
+
# final saved_changes. On an update, a move (creative_id) or reassignment
|
|
121
|
+
# (user_id) additionally purges the stale rows this share left behind — the
|
|
122
|
+
# purge is carried on the same propagate_share job (purge_stale:) rather than
|
|
123
|
+
# deleted synchronously, so timing is uniform and it no longer blocks commit.
|
|
124
|
+
def dispatch_share_cache_invalidation
|
|
125
|
+
propagate_args = { creative_share_id: id }
|
|
126
|
+
|
|
127
|
+
unless previously_new_record?
|
|
128
|
+
operations = (saved_changes.keys & PERMISSION_INVALIDATING_ATTRIBUTES.keys)
|
|
129
|
+
.map { |attr| PERMISSION_INVALIDATING_ATTRIBUTES[attr] }
|
|
130
|
+
.uniq
|
|
131
|
+
|
|
132
|
+
# A move (creative_id) or reassignment (user_id) leaves stale rows keyed
|
|
133
|
+
# to this share. The purge rides ON the propagate_share job (purge_stale:
|
|
134
|
+
# true), which deletes those rows immediately before re-propagating — in
|
|
135
|
+
# one job, never as a standalone enqueue. The authz queue runs two
|
|
136
|
+
# threads, so a separate purge could otherwise run AFTER propagate and
|
|
137
|
+
# delete the freshly written rows (both key on source_share_id), dropping
|
|
138
|
+
# the share's access until an unrelated rebuild. Folding it in keeps the
|
|
139
|
+
# work async (off the commit path) so the revoke of the vacated access is
|
|
140
|
+
# merely deferred by the queue latency (~1-2s) — it only PROLONGS an
|
|
141
|
+
# already-granted permission by that window, never grants a new one,
|
|
142
|
+
# which the CTO judged acceptable for the perf win. Then rebuild the
|
|
143
|
+
# vacated subtree. creative_id takes precedence when both change,
|
|
144
|
+
# preserving the prior branch order.
|
|
145
|
+
if operations.include?(:relocate) || operations.include?(:reassign)
|
|
146
|
+
propagate_args[:purge_stale] = true
|
|
147
|
+
rebuild_vacated_subtree(relocated: operations.include?(:relocate))
|
|
119
148
|
end
|
|
120
149
|
end
|
|
121
150
|
|
|
122
|
-
PermissionCacheJob.perform_later(:propagate_share,
|
|
151
|
+
PermissionCacheJob.perform_later(:propagate_share, **propagate_args)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Rebuild the cache the moved/reassigned share vacated, for the old user in
|
|
155
|
+
# the old location, so an ancestor share (or its absence) is re-applied.
|
|
156
|
+
def rebuild_vacated_subtree(relocated:)
|
|
157
|
+
if relocated
|
|
158
|
+
old_creative_id = creative_id_before_last_save
|
|
159
|
+
old_user_id = user_id_before_last_save || user_id
|
|
160
|
+
return unless old_creative_id
|
|
161
|
+
|
|
162
|
+
PermissionCacheJob.perform_later(:rebuild_user_cache_for_subtree,
|
|
163
|
+
creative_id: old_creative_id,
|
|
164
|
+
user_id: old_user_id
|
|
165
|
+
)
|
|
166
|
+
else
|
|
167
|
+
old_user_id = user_id_before_last_save
|
|
168
|
+
return unless old_user_id
|
|
169
|
+
|
|
170
|
+
PermissionCacheJob.perform_later(:rebuild_user_cache_for_subtree,
|
|
171
|
+
creative_id: creative_id,
|
|
172
|
+
user_id: old_user_id
|
|
173
|
+
)
|
|
174
|
+
end
|
|
123
175
|
end
|
|
124
176
|
|
|
125
177
|
def remove_cache
|
|
@@ -16,10 +16,10 @@ module Collavre
|
|
|
16
16
|
after_commit :broadcast_badge_update, on: %i[create update destroy]
|
|
17
17
|
after_create_commit :enqueue_push_notification
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
validates :state, inclusion: { in: %w[new read archived] }
|
|
19
|
+
enum :state, { new: "new", read: "read", archived: "archived" }, default: :new, scopes: false
|
|
21
20
|
validates :message_key, presence: true
|
|
22
21
|
|
|
22
|
+
# Kept explicit (not enum's generated `.new` class-scope, which collides with the constructor).
|
|
23
23
|
scope :new_items, -> { where(state: "new") }
|
|
24
24
|
|
|
25
25
|
def localized_message(locale: I18n.locale)
|
data/app/models/collavre/task.rb
CHANGED
|
@@ -11,6 +11,18 @@ module Collavre
|
|
|
11
11
|
|
|
12
12
|
validates :name, presence: true
|
|
13
13
|
|
|
14
|
+
enum :status, {
|
|
15
|
+
pending: "pending",
|
|
16
|
+
queued: "queued",
|
|
17
|
+
running: "running",
|
|
18
|
+
delegated: "delegated",
|
|
19
|
+
pending_approval: "pending_approval",
|
|
20
|
+
done: "done",
|
|
21
|
+
failed: "failed",
|
|
22
|
+
cancelled: "cancelled",
|
|
23
|
+
escalated: "escalated"
|
|
24
|
+
}, default: :pending
|
|
25
|
+
|
|
14
26
|
after_update_commit :check_trigger_loop_completion, if: :trigger_loop_candidate?
|
|
15
27
|
after_update_commit :broadcast_stop_button_removal, if: :became_terminal?
|
|
16
28
|
|
|
@@ -24,6 +36,21 @@ module Collavre
|
|
|
24
36
|
rel = rel.where(creative_id: creative_id) if creative_id
|
|
25
37
|
rel.order(:created_at)
|
|
26
38
|
}
|
|
39
|
+
# Tasks that hold a topic concurrency slot: running/delegated (executing)
|
|
40
|
+
# plus pending — a task that has been claimed (dequeue_next_for_topic moves a
|
|
41
|
+
# waiter queued -> pending, a retry re-queues to pending, initial dispatch
|
|
42
|
+
# creates pending) but whose AiAgentJob has not started yet — plus
|
|
43
|
+
# pending_approval — a task paused awaiting tool approval that intentionally
|
|
44
|
+
# keeps its resource (AiAgentJob sets should_release = false) and does NOT
|
|
45
|
+
# drain the topic queue (dequeue_next_for_topic only fires on terminal
|
|
46
|
+
# statuses done/failed/cancelled/escalated). Orphan detection must count all
|
|
47
|
+
# of these, otherwise a claimed-but-not-started or approval-paused slot looks
|
|
48
|
+
# free and a second waiter gets promoted into the same slot.
|
|
49
|
+
scope :occupying_topic_slot, ->(topic_id, creative_id = nil) {
|
|
50
|
+
rel = where(topic_id: topic_id, status: %w[running delegated pending pending_approval])
|
|
51
|
+
rel = rel.where(creative_id: creative_id) if creative_id
|
|
52
|
+
rel
|
|
53
|
+
}
|
|
27
54
|
|
|
28
55
|
# Check if agent already has an in-flight task triggered by the same comment.
|
|
29
56
|
# Treats "delegated" as in-flight: a Claude Channel task that is waiting on
|
data/app/models/collavre/user.rb
CHANGED
|
@@ -44,6 +44,15 @@ module Collavre
|
|
|
44
44
|
has_many :creatives, class_name: "Collavre::Creative", dependent: nil
|
|
45
45
|
before_destroy :destroy_creatives_leaf_first
|
|
46
46
|
|
|
47
|
+
# /compress and /merge summaries are durable recovery artifacts: they replace
|
|
48
|
+
# the deleted original conversation and anchor the restore control (rendered
|
|
49
|
+
# from the surviving result comment via CommentSnapshot). Detach them (nullify
|
|
50
|
+
# author) BEFORE the comments dependent: :destroy cascade — prepend ensures we
|
|
51
|
+
# run first — so deleting the authoring agent/user doesn't destroy the summary
|
|
52
|
+
# and orphan the snapshot, which would erase the only path to restore the
|
|
53
|
+
# compressed conversation.
|
|
54
|
+
before_destroy :preserve_durable_summary_comments, prepend: true
|
|
55
|
+
|
|
47
56
|
belongs_to :creator, class_name: "Collavre::User", foreign_key: "created_by_id", optional: true
|
|
48
57
|
has_many :created_ai_users, class_name: "Collavre::User", foreign_key: "created_by_id", dependent: :destroy
|
|
49
58
|
|
|
@@ -58,6 +67,15 @@ module Collavre
|
|
|
58
67
|
attribute :system_admin, :boolean, default: false
|
|
59
68
|
attribute :searchable, :boolean, default: false
|
|
60
69
|
|
|
70
|
+
# Typo correction (2D gating: typing-device AND input-location must both be on).
|
|
71
|
+
attribute :typo_correction_enabled, :boolean, default: true
|
|
72
|
+
attribute :typo_correction_threshold, :integer, default: 80
|
|
73
|
+
attribute :typo_correction_on_soft_keyboard, :boolean, default: true
|
|
74
|
+
attribute :typo_correction_on_voice, :boolean, default: true
|
|
75
|
+
attribute :typo_correction_on_physical_keyboard, :boolean, default: false
|
|
76
|
+
attribute :typo_correction_in_chat, :boolean, default: true
|
|
77
|
+
attribute :typo_correction_in_editor, :boolean, default: false
|
|
78
|
+
|
|
61
79
|
attribute :google_uid, :string
|
|
62
80
|
attribute :google_access_token, :string
|
|
63
81
|
attribute :google_refresh_token, :string
|
|
@@ -114,12 +132,14 @@ module Collavre
|
|
|
114
132
|
end
|
|
115
133
|
|
|
116
134
|
# Whether this agent uses stateful sessions (incremental messaging).
|
|
117
|
-
# nil in agent_conf = auto-detect
|
|
135
|
+
# nil in agent_conf = auto-detect via the AiClient adapter layer, which
|
|
136
|
+
# vendor engines register into (core never string-matches a vendor name).
|
|
118
137
|
def supports_session?
|
|
119
138
|
explicit = parsed_agent_conf.dig("session", "enabled")
|
|
120
139
|
return ActiveModel::Type::Boolean.new.cast(explicit) unless explicit.nil?
|
|
140
|
+
return false if llm_vendor.blank?
|
|
121
141
|
|
|
122
|
-
llm_vendor
|
|
142
|
+
Collavre::AiClient.vendor_supports_session?(llm_vendor)
|
|
123
143
|
end
|
|
124
144
|
|
|
125
145
|
encrypts :llm_api_key, deterministic: false
|
|
@@ -140,15 +160,45 @@ module Collavre
|
|
|
140
160
|
end
|
|
141
161
|
end
|
|
142
162
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
163
|
+
TYPO_CORRECTION_DEVICES = %w[voice soft_keyboard physical_keyboard].freeze
|
|
164
|
+
TYPO_CORRECTION_LOCATIONS = %w[chat editor].freeze
|
|
165
|
+
|
|
166
|
+
# 2D gating: typo correction runs only when the master switch is on AND the
|
|
167
|
+
# originating typing device AND the input location are both enabled. Unknown
|
|
168
|
+
# device/location values are treated as disabled (fail closed).
|
|
169
|
+
def typo_correction_active_for?(device:, location:)
|
|
170
|
+
return false unless typo_correction_enabled
|
|
171
|
+
|
|
172
|
+
device_on = case device.to_s
|
|
173
|
+
when "voice" then typo_correction_on_voice
|
|
174
|
+
when "soft_keyboard" then typo_correction_on_soft_keyboard
|
|
175
|
+
when "physical_keyboard" then typo_correction_on_physical_keyboard
|
|
176
|
+
else false
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
location_on = case location.to_s
|
|
180
|
+
when "chat" then typo_correction_in_chat
|
|
181
|
+
when "editor" then typo_correction_in_editor
|
|
182
|
+
else false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
device_on && location_on
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# LLM_VENDOR_OPTIONS is resolved dynamically from the AiClient vendor-option
|
|
189
|
+
# registry so core lists only its built-in providers while vendor engines
|
|
190
|
+
# (e.g. OpenClaw) contribute their own. Resolved lazily via const_missing so
|
|
191
|
+
# the value always reflects registrations made after this class first loads
|
|
192
|
+
# (they run in a to_prepare hook), and existing views keep referring to the
|
|
193
|
+
# constant unchanged.
|
|
194
|
+
def self.const_missing(name)
|
|
195
|
+
return Collavre::AiClient.vendor_options if name == :LLM_VENDOR_OPTIONS
|
|
196
|
+
|
|
197
|
+
super
|
|
198
|
+
end
|
|
149
199
|
|
|
150
200
|
SUPPORTED_LLM_MODELS = [
|
|
151
|
-
"gemini-3-flash-
|
|
201
|
+
"gemini-3.1-flash-lite",
|
|
152
202
|
"gemini-1.5-flash",
|
|
153
203
|
"gemini-1.5-pro"
|
|
154
204
|
].freeze
|
|
@@ -194,6 +244,11 @@ module Collavre
|
|
|
194
244
|
validates :timezone,
|
|
195
245
|
inclusion: { in: ActiveSupport::TimeZone.all.map { |z| z.tzinfo.identifier } },
|
|
196
246
|
allow_nil: true
|
|
247
|
+
# Column is NOT NULL; clearing the profile field (or a crafted PATCH) casts to
|
|
248
|
+
# nil and would raise a DB error on save. Validate so the form re-renders.
|
|
249
|
+
validates :typo_correction_threshold,
|
|
250
|
+
presence: true,
|
|
251
|
+
numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
|
|
197
252
|
|
|
198
253
|
generates_token_for :email_verification, expires_in: 1.day do
|
|
199
254
|
email
|
|
@@ -255,10 +310,35 @@ module Collavre
|
|
|
255
310
|
end
|
|
256
311
|
end
|
|
257
312
|
|
|
313
|
+
# Keep durable compress/merge summaries (snapshot result comments) alive when
|
|
314
|
+
# their author is deleted: nullify authorship instead of cascading destroy.
|
|
315
|
+
def preserve_durable_summary_comments
|
|
316
|
+
Collavre::Comment
|
|
317
|
+
.where(id: Collavre::CommentSnapshot.where(result_comment_id: comments.select(:id)).select(:result_comment_id))
|
|
318
|
+
.update_all(user_id: nil)
|
|
319
|
+
end
|
|
320
|
+
|
|
258
321
|
# Destroy creatives deepest-first so closure_tree always finds its parent
|
|
259
322
|
def destroy_creatives_leaf_first
|
|
260
323
|
all_creatives = creatives.flat_map { |c| c.self_and_descendants.to_a }.uniq
|
|
261
|
-
|
|
324
|
+
|
|
325
|
+
# Order by depth in memory. The subtree is contiguous within all_creatives
|
|
326
|
+
# (every node between an owned root and its descendant is itself a
|
|
327
|
+
# descendant), so following parent_id links yields the same leaf-first
|
|
328
|
+
# ordering as self_and_ancestors.count without firing a COUNT query per
|
|
329
|
+
# creative (the previous N+1).
|
|
330
|
+
by_id = all_creatives.index_by(&:id)
|
|
331
|
+
depth_of = lambda do |creative|
|
|
332
|
+
depth = 0
|
|
333
|
+
node = creative
|
|
334
|
+
while node&.parent_id && (parent = by_id[node.parent_id])
|
|
335
|
+
depth += 1
|
|
336
|
+
node = parent
|
|
337
|
+
end
|
|
338
|
+
depth
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
all_creatives.sort_by { |c| -depth_of.call(c) }.each do |c|
|
|
262
342
|
c.reload.destroy! if Creative.exists?(c.id)
|
|
263
343
|
end
|
|
264
344
|
end
|