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,122 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
# Serializes a flat collection of creatives into the lightweight JSON payload
|
|
4
|
+
# consumed by the picker popup (simple mode) and the plain browse list.
|
|
5
|
+
#
|
|
6
|
+
# Extracted from CreativesController#index so the controller action stays thin.
|
|
7
|
+
# Depends only on the acting user and the request params (read-only), never on
|
|
8
|
+
# view_context, so the whole responsibility lives outside the controller.
|
|
9
|
+
class CreativeTreeSerializer
|
|
10
|
+
def initialize(user:, params:)
|
|
11
|
+
@user = user
|
|
12
|
+
@params = params
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def serialize(collection)
|
|
16
|
+
if params[:simple].present?
|
|
17
|
+
serialize_simple(collection)
|
|
18
|
+
else
|
|
19
|
+
collection.map { |c| { id: c.id, description: c.effective_description, progress: c.progress } }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :user, :params
|
|
26
|
+
|
|
27
|
+
def serialize_simple(collection)
|
|
28
|
+
# Preload origins so effective_origin (used per linked-shell row in both
|
|
29
|
+
# children_presence_set and the origin_id mapping below) resolves from
|
|
30
|
+
# memory instead of firing a query per shell. Only browse can hold shells;
|
|
31
|
+
# search is scoped to origin_id: nil, so this is a no-op there.
|
|
32
|
+
ActiveRecord::Associations::Preloader.new(records: collection.to_a, associations: :origin).call
|
|
33
|
+
children_ids = children_presence_set(collection)
|
|
34
|
+
searching = params[:search].present?
|
|
35
|
+
breadcrumbs = searching ? BreadcrumbResolver.new(collection.map(&:id), user: user, include_archived: params[:show_archived].present?).call : {}
|
|
36
|
+
# For hits routed through a linked shell, the path to expand in the
|
|
37
|
+
# user's own tree (local folders -> shell) so a breadcrumb jump can
|
|
38
|
+
# reach a shell nested under a collapsed folder.
|
|
39
|
+
reveal_paths = searching ? RevealPathResolver.new(collection.map(&:id), user: user, include_archived: params[:show_archived].present?).call : {}
|
|
40
|
+
collection.map do |c|
|
|
41
|
+
item = {
|
|
42
|
+
id: c.id,
|
|
43
|
+
description: c.effective_description(nil, false),
|
|
44
|
+
progress: c.progress,
|
|
45
|
+
has_children: children_ids.include?(c.id)
|
|
46
|
+
}
|
|
47
|
+
reveal = reveal_paths[c.id]
|
|
48
|
+
path = breadcrumbs[c.id]
|
|
49
|
+
if path.present?
|
|
50
|
+
item[:path] = mask_unreachable_crumbs(path, reveal)
|
|
51
|
+
end
|
|
52
|
+
item[:reveal_path] = reveal if reveal.present?
|
|
53
|
+
# For linked shells, expose the effective origin id so the picker can
|
|
54
|
+
# map a search breadcrumb (origin ids) back to the rendered shell node.
|
|
55
|
+
item[:origin_id] = c.effective_origin.id if c.origin_id
|
|
56
|
+
item
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A breadcrumb jump expands the tree from a rendered root (or, for a shared
|
|
61
|
+
# subtree, a linked shell) down to the clicked crumb. If an ancestor above a
|
|
62
|
+
# crumb is itself unrenderable (unreadable, or archived while archived rows
|
|
63
|
+
# aren't shown) and no linked shell re-roots the path at/below it, the
|
|
64
|
+
# descendant can't be expanded either — so mask it too. BreadcrumbResolver
|
|
65
|
+
# masks the unrenderable ancestor itself; this masks everything downstream of
|
|
66
|
+
# it on the plain origin chain, matching exactly what the tree can render.
|
|
67
|
+
#
|
|
68
|
+
# `reveal` is RevealPathResolver's per-origin map: a crumb whose origin id is
|
|
69
|
+
# a key re-roots navigation through its own shell (the client anchors at the
|
|
70
|
+
# nearest reveal entry at/above the clicked crumb), so it clears the block for
|
|
71
|
+
# itself and its descendants regardless of an archived/unreadable origin
|
|
72
|
+
# above it.
|
|
73
|
+
def mask_unreachable_crumbs(path, reveal)
|
|
74
|
+
blocked = false
|
|
75
|
+
path.map do |crumb|
|
|
76
|
+
if reveal&.key?(crumb[:id])
|
|
77
|
+
blocked = false
|
|
78
|
+
crumb
|
|
79
|
+
elsif crumb[:restricted]
|
|
80
|
+
blocked = true
|
|
81
|
+
crumb
|
|
82
|
+
elsif blocked
|
|
83
|
+
crumb.merge(restricted: true, description: nil)
|
|
84
|
+
else
|
|
85
|
+
crumb
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Batched "does this node have a child the user can actually browse to?"
|
|
91
|
+
# lookup so the picker tree renders expand toggles without an N+1.
|
|
92
|
+
#
|
|
93
|
+
# Must match exactly what expanding the node shows (IndexQuery#handle_id_query
|
|
94
|
+
# -> children_with_permission, minus archived unless show_archived), or the
|
|
95
|
+
# toggle either hides a reachable subtree or opens to an empty branch (and
|
|
96
|
+
# leaks that hidden children exist). Two alignments are needed:
|
|
97
|
+
# 1. Linked shells (origin_id set) store children under the effective
|
|
98
|
+
# origin (redirect_parent_to_origin + children->origin migration), so
|
|
99
|
+
# resolve each row to its effective origin before the lookup.
|
|
100
|
+
# 2. Apply the same archived + read-permission filters as the browse path.
|
|
101
|
+
def children_presence_set(collection)
|
|
102
|
+
return Set.new if collection.empty?
|
|
103
|
+
|
|
104
|
+
origin_id_by_id = collection.to_h { |c| [ c.id, c.effective_origin.id ] }
|
|
105
|
+
|
|
106
|
+
candidates = Creative.where(parent_id: origin_id_by_id.values.uniq)
|
|
107
|
+
candidates = candidates.where(archived_at: nil) unless params[:show_archived]
|
|
108
|
+
child_rows = candidates.pluck(:id, :parent_id) # [child_id, origin_id]
|
|
109
|
+
return Set.new if child_rows.empty?
|
|
110
|
+
|
|
111
|
+
readable = PermissionFilter
|
|
112
|
+
.new(user: user).readable_ids(child_rows.map(&:first)).to_set
|
|
113
|
+
origins_with_visible_children = child_rows
|
|
114
|
+
.each_with_object(Set.new) { |(child_id, origin_id), set| set << origin_id if readable.include?(child_id) }
|
|
115
|
+
|
|
116
|
+
collection.each_with_object(Set.new) do |c, set|
|
|
117
|
+
set << c.id if origins_with_visible_children.include?(origin_id_by_id[c.id])
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
# Single source of truth for "which creative's cache/ownership actually
|
|
4
|
+
# governs permission for this creative?".
|
|
5
|
+
#
|
|
6
|
+
# A linked creative (origin_id present) borrows its origin's permission: it
|
|
7
|
+
# has no cache entries of its own and its ownership is the origin's ownership.
|
|
8
|
+
# PermissionChecker (single-item) and PermissionFilter (batch) MUST resolve
|
|
9
|
+
# the effective creative through this module so a single check and a batch
|
|
10
|
+
# filter can never diverge for the same user + linked creative.
|
|
11
|
+
module EffectiveCreativeResolution
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# For a single loaded creative: returns the creative whose cache/ownership
|
|
15
|
+
# governs permission. Non-linked creatives resolve to themselves.
|
|
16
|
+
def effective_creative(creative)
|
|
17
|
+
creative.origin_id.nil? ? creative : creative.origin
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# For a batch of creative ids: returns { original_id => effective_id }.
|
|
21
|
+
# Linked creatives map to their origin_id; every other id (including ids
|
|
22
|
+
# not found in the table) maps to itself. This mirrors
|
|
23
|
+
# +effective_creative+ so the batch reader resolves origin identically.
|
|
24
|
+
def effective_creative_ids(ids)
|
|
25
|
+
# Coerce to Integer: origin_by_id is keyed by integer ids from pluck, so a
|
|
26
|
+
# string id (e.g. sourced from request params) would miss origin lookup
|
|
27
|
+
# and later miss the integer-keyed readable Set. Non-numeric ids can't
|
|
28
|
+
# identify a Creative, so drop them.
|
|
29
|
+
ids = ids.to_a.filter_map { |id| Integer(id, exception: false) }.uniq
|
|
30
|
+
return {} if ids.empty?
|
|
31
|
+
|
|
32
|
+
origin_by_id = Creative.where(id: ids).pluck(:id, :origin_id).to_h
|
|
33
|
+
ids.index_with { |id| origin_by_id[id] || id }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -10,6 +10,11 @@ module Creatives
|
|
|
10
10
|
# than its entire match set.
|
|
11
11
|
PERMISSION_FILTER_BATCH = 200
|
|
12
12
|
|
|
13
|
+
# Page size for the top-nav "Chats" feed (the comment filter). The list is
|
|
14
|
+
# unbounded by nature (it grows with every commented creative), so it is
|
|
15
|
+
# paginated instead of materialized whole.
|
|
16
|
+
COMMENT_CHATS_PER_PAGE = 30
|
|
17
|
+
|
|
13
18
|
Result = Struct.new(
|
|
14
19
|
:creatives,
|
|
15
20
|
:parent_creative,
|
|
@@ -18,6 +23,7 @@ module Creatives
|
|
|
18
23
|
:overall_progress,
|
|
19
24
|
:allowed_creative_ids,
|
|
20
25
|
:progress_map,
|
|
26
|
+
:pagination,
|
|
21
27
|
keyword_init: true
|
|
22
28
|
)
|
|
23
29
|
|
|
@@ -38,7 +44,8 @@ module Creatives
|
|
|
38
44
|
shared_list: shared_list,
|
|
39
45
|
overall_progress: result[:overall_progress] || 0,
|
|
40
46
|
allowed_creative_ids: result[:allowed_ids],
|
|
41
|
-
progress_map: result[:progress_map]
|
|
47
|
+
progress_map: result[:progress_map],
|
|
48
|
+
pagination: result[:pagination]
|
|
42
49
|
)
|
|
43
50
|
end
|
|
44
51
|
|
|
@@ -83,29 +90,46 @@ module Creatives
|
|
|
83
90
|
|
|
84
91
|
result = pipeline.call
|
|
85
92
|
|
|
86
|
-
|
|
93
|
+
# The comment ("Chats") path still returns a pagination object on an empty
|
|
94
|
+
# match so the client gets consistent metadata; other filters short-circuit.
|
|
95
|
+
return empty_result if result.matched_ids.empty? && params[:comment] != "true"
|
|
87
96
|
|
|
88
97
|
# For search/comment filters, return matched items directly (flat results sorted by relevance)
|
|
89
98
|
# Unless search_mode=tree is specified, which returns tree structure instead
|
|
90
99
|
# For other filters (tags, progress), return tree start nodes
|
|
91
100
|
if (params[:search].present? || params[:comment] == "true") && params[:search_mode] != "tree"
|
|
92
|
-
|
|
93
|
-
.order(:sequence)
|
|
94
|
-
.select { |c| readable?(c) }
|
|
101
|
+
parent = params[:id] ? Creative.find_by(id: params[:id]) : nil
|
|
95
102
|
|
|
96
|
-
# Sort by comment updated_at for comment filter
|
|
97
103
|
if params[:comment] == "true"
|
|
98
|
-
|
|
104
|
+
# Top-nav "Chats" list: one page of commented creatives, newest chat
|
|
105
|
+
# first. The matched set is permission-filtered in a single batch and
|
|
106
|
+
# the recency ordering is computed by the DB (LIMIT/OFFSET over a
|
|
107
|
+
# grouped MAX(comments.updated_at)), so response cost is a fixed page
|
|
108
|
+
# regardless of how many chats exist. This replaces an unbounded load
|
|
109
|
+
# that ran a per-row readable? query and a per-row MAX(updated_at)
|
|
110
|
+
# aggregate (two N+1s that grew with the chat count).
|
|
111
|
+
page = comment_chats_page(result.matched_ids)
|
|
112
|
+
{
|
|
113
|
+
creatives: page[:creatives],
|
|
114
|
+
parent: parent,
|
|
115
|
+
allowed_ids: result.allowed_ids,
|
|
116
|
+
overall_progress: result.overall_progress,
|
|
117
|
+
progress_map: result.progress_map,
|
|
118
|
+
pagination: page[:pagination]
|
|
119
|
+
}
|
|
120
|
+
else
|
|
121
|
+
matched_creatives = Creative.where(id: result.matched_ids.to_a)
|
|
122
|
+
.order(:sequence)
|
|
123
|
+
.select { |c| readable?(c) }
|
|
124
|
+
|
|
125
|
+
{
|
|
126
|
+
creatives: matched_creatives,
|
|
127
|
+
parent: parent,
|
|
128
|
+
allowed_ids: result.allowed_ids,
|
|
129
|
+
overall_progress: result.overall_progress,
|
|
130
|
+
progress_map: result.progress_map
|
|
131
|
+
}
|
|
99
132
|
end
|
|
100
|
-
|
|
101
|
-
parent = params[:id] ? Creative.find_by(id: params[:id]) : nil
|
|
102
|
-
{
|
|
103
|
-
creatives: matched_creatives,
|
|
104
|
-
parent: parent,
|
|
105
|
-
allowed_ids: result.allowed_ids,
|
|
106
|
-
overall_progress: result.overall_progress,
|
|
107
|
-
progress_map: result.progress_map
|
|
108
|
-
}
|
|
109
133
|
else
|
|
110
134
|
start_nodes = determine_start_nodes(result.allowed_ids)
|
|
111
135
|
parent = params[:id] ? Creative.find_by(id: params[:id]) : nil
|
|
@@ -119,6 +143,51 @@ module Creatives
|
|
|
119
143
|
end
|
|
120
144
|
end
|
|
121
145
|
|
|
146
|
+
# One page of the "Chats" feed from the comment-matched id set, ordered by
|
|
147
|
+
# most-recent comment. Permission filtering is batched (PermissionFilter does
|
|
148
|
+
# a handful of IN queries, not one per row) and the recency sort + windowing
|
|
149
|
+
# happen in SQL, so neither cost scales with the total number of chats.
|
|
150
|
+
def comment_chats_page(matched_ids)
|
|
151
|
+
per_page = comment_chats_per_page
|
|
152
|
+
page = comment_chats_page_number
|
|
153
|
+
offset = (page - 1) * per_page
|
|
154
|
+
|
|
155
|
+
readable_ids = PermissionFilter.new(user: user).readable_ids(matched_ids.to_a)
|
|
156
|
+
return { creatives: [], pagination: pagination_meta(page, false) } if readable_ids.empty?
|
|
157
|
+
|
|
158
|
+
# Fetch one extra row to detect whether a further page exists. id is the
|
|
159
|
+
# tiebreaker so the order is total and stable across page boundaries.
|
|
160
|
+
page_ids = Creative.where(id: readable_ids)
|
|
161
|
+
.joins(:comments)
|
|
162
|
+
.group("creatives.id")
|
|
163
|
+
.order(Arel.sql("MAX(comments.updated_at) DESC, creatives.id DESC"))
|
|
164
|
+
.offset(offset)
|
|
165
|
+
.limit(per_page + 1)
|
|
166
|
+
.pluck("creatives.id")
|
|
167
|
+
|
|
168
|
+
has_more = page_ids.size > per_page
|
|
169
|
+
page_ids = page_ids.first(per_page)
|
|
170
|
+
|
|
171
|
+
by_id = Creative.where(id: page_ids).index_by(&:id)
|
|
172
|
+
creatives = page_ids.filter_map { |id| by_id[id] }
|
|
173
|
+
|
|
174
|
+
{ creatives: creatives, pagination: pagination_meta(page, has_more) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def comment_chats_per_page
|
|
178
|
+
per = params[:per_page].presence&.to_i || COMMENT_CHATS_PER_PAGE
|
|
179
|
+
per <= 0 || per > 100 ? COMMENT_CHATS_PER_PAGE : per
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def comment_chats_page_number
|
|
183
|
+
page = params[:page].presence&.to_i || 1
|
|
184
|
+
page < 1 ? 1 : page
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def pagination_meta(page, has_more)
|
|
188
|
+
{ page: page, next_page: has_more ? page + 1 : nil, has_more: has_more }
|
|
189
|
+
end
|
|
190
|
+
|
|
122
191
|
def simple_search?
|
|
123
192
|
params[:search].present? && params[:simple].present? &&
|
|
124
193
|
params[:search_mode] != "tree" && params[:comment] != "true"
|
|
@@ -7,7 +7,7 @@ module Collavre
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def allowed?(required_permission = :read)
|
|
10
|
-
base =
|
|
10
|
+
base = EffectiveCreativeResolution.effective_creative(creative)
|
|
11
11
|
|
|
12
12
|
# Owner always has admin permission (fallback for fixtures and missing cache entries)
|
|
13
13
|
return true if base.user_id == user&.id
|
|
@@ -5,46 +5,173 @@ module Creatives
|
|
|
5
5
|
# has_children presence check can apply the exact same permission posture as
|
|
6
6
|
# the browse endpoint (children_with_permission) without re-deriving it.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
8
|
+
# A user-specific cache entry is authoritative over a public share: it grants
|
|
9
|
+
# only when its own rank meets the requested threshold and otherwise suppresses
|
|
10
|
+
# the public share (so a no_access — or any below-threshold user entry — wins
|
|
11
|
+
# over a more permissive public share). Owned creatives are always readable.
|
|
10
12
|
class PermissionFilter
|
|
13
|
+
# An owner always resolves to admin, the top rank, so it satisfies any
|
|
14
|
+
# min_permission threshold. This is the canonical owner rank the read-path
|
|
15
|
+
# callers (TreeBuilder, SlideViewable) inherit via #ranks_for / #readable_ids.
|
|
16
|
+
OWNER_RANK = CreativeShare.permissions[:admin]
|
|
17
|
+
|
|
11
18
|
def initialize(user:)
|
|
12
19
|
@user = user
|
|
13
20
|
end
|
|
14
21
|
|
|
15
|
-
# Returns the subset of `ids` the user may
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
# Returns the subset of `ids` the user may access at `min_permission` or
|
|
23
|
+
# higher, as an Array. `min_permission:` defaults to `:read`, so the no-arg
|
|
24
|
+
# form is unchanged; pass `:write` (etc.) to share the one batch filter with
|
|
25
|
+
# write-posture sites instead of re-deriving the rank comparison.
|
|
26
|
+
#
|
|
27
|
+
# Each id is resolved to its effective (origin) creative via the SAME
|
|
28
|
+
# logic PermissionChecker uses, so a linked creative yields identical
|
|
29
|
+
# permission whether checked one-by-one or filtered in a batch.
|
|
30
|
+
def readable_ids(ids, min_permission: :read)
|
|
31
|
+
# Normalize to Integer so the final select (and owned-shell lookup) compare
|
|
32
|
+
# against pluck-derived integer ids rather than string inputs; keeps the
|
|
33
|
+
# canonical batch filter robust to param-sourced ids. Non-numeric ids drop.
|
|
34
|
+
ids = ids.to_a.filter_map { |id| Integer(id, exception: false) }.uniq
|
|
18
35
|
return [] if ids.empty?
|
|
19
36
|
|
|
37
|
+
min_rank = rank_for(min_permission)
|
|
38
|
+
effective_by_id = EffectiveCreativeResolution.effective_creative_ids(ids)
|
|
39
|
+
readable_effective = readable_effective_ids(effective_by_id.values.uniq, min_rank)
|
|
40
|
+
|
|
41
|
+
# A linked creative borrows its ORIGIN's permission, but the shell row
|
|
42
|
+
# itself represents a specific PLACEMENT of that link inside a tree.
|
|
43
|
+
# Returning a shell solely because its origin is readable would leak other
|
|
44
|
+
# users' private shell placements into search/filter results — a batch can
|
|
45
|
+
# be fed foreign shells (e.g. FilterPipeline#resolve_ancestors pulls every
|
|
46
|
+
# Creative.where(origin_id: ...) regardless of owner). So a shell is gated
|
|
47
|
+
# on the viewer being able to SEE its placement at the requested rank, in
|
|
48
|
+
# ADDITION to origin readability: either the viewer owns the shell, or the
|
|
49
|
+
# shell sits in a subtree shared with the viewer AT >= min_permission (a
|
|
50
|
+
# propagated CreativeSharesCache entry on the shell row itself — e.g. a
|
|
51
|
+
# public help doc's linked child). The placement is checked at min_rank
|
|
52
|
+
# (not always :read) because higher-privilege callers share this batch —
|
|
53
|
+
# children_with_permission(user, :admin), used by DestroyService's
|
|
54
|
+
# recursive delete, must not reach a shell the viewer only has read on. A
|
|
55
|
+
# shell in a foreign PRIVATE tree (no viewer entry) stays hidden. Non-shell
|
|
56
|
+
# ids keep origin-readability-only behaviour.
|
|
57
|
+
visible_shells = visible_shell_ids(effective_by_id, min_rank)
|
|
58
|
+
|
|
59
|
+
ids.select do |id|
|
|
60
|
+
next false unless readable_effective.include?(effective_by_id[id])
|
|
61
|
+
|
|
62
|
+
effective_by_id[id] == id || visible_shells.include?(id)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns { input_id => effective_permission_rank } for the ids the user has
|
|
67
|
+
# ANY relationship to, mirroring single-item PermissionChecker: owner wins
|
|
68
|
+
# (admin rank), else the user's own cache entry (INCLUDING a no_access deny,
|
|
69
|
+
# which is rank 0 and beats a public share), else the public entry. Ids with
|
|
70
|
+
# no owner/entry/public share are ABSENT from the hash (distinct from rank 0).
|
|
71
|
+
#
|
|
72
|
+
# The rank is the raw CreativeShare.permissions integer, so callers compare
|
|
73
|
+
# `rank >= rank_for(:write)` themselves. Unlike readable_ids this does NOT
|
|
74
|
+
# apply the shell-ownership anti-leak gate — it is for tree_builder /
|
|
75
|
+
# slide_viewable which rank creatives already inside the viewer's own tree,
|
|
76
|
+
# exactly as PermissionChecker#allowed? would resolve them one by one.
|
|
77
|
+
def ranks_for(ids)
|
|
78
|
+
ids = ids.to_a.filter_map { |id| Integer(id, exception: false) }.uniq
|
|
79
|
+
return {} if ids.empty?
|
|
80
|
+
|
|
81
|
+
effective_by_id = EffectiveCreativeResolution.effective_creative_ids(ids)
|
|
82
|
+
effective_ids = effective_by_id.values.uniq
|
|
83
|
+
|
|
84
|
+
owner_by_effective = Creative.where(id: effective_ids).pluck(:id, :user_id).to_h
|
|
85
|
+
|
|
86
|
+
user_entries = if user
|
|
87
|
+
CreativeSharesCache.where(creative_id: effective_ids, user_id: user.id)
|
|
88
|
+
.pluck(:creative_id, :permission).to_h
|
|
89
|
+
else
|
|
90
|
+
{}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
needs_public = effective_ids - user_entries.keys
|
|
94
|
+
public_entries = if needs_public.any?
|
|
95
|
+
CreativeSharesCache.where(creative_id: needs_public, user_id: nil)
|
|
96
|
+
.pluck(:creative_id, :permission).to_h
|
|
97
|
+
else
|
|
98
|
+
{}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
ranks = {}
|
|
102
|
+
ids.each do |id|
|
|
103
|
+
effective = effective_by_id[id]
|
|
104
|
+
if user && owner_by_effective[effective] == user.id
|
|
105
|
+
ranks[id] = OWNER_RANK
|
|
106
|
+
else
|
|
107
|
+
permission = user_entries[effective] || public_entries[effective]
|
|
108
|
+
ranks[id] = CreativeShare.permissions[permission] if permission
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
ranks
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
attr_reader :user
|
|
117
|
+
|
|
118
|
+
def rank_for(permission)
|
|
119
|
+
CreativeShare.permissions.fetch(permission.to_s)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The subset of shell ids (effective != self) whose PLACEMENT the viewer can
|
|
123
|
+
# see AT `min_rank`: shells the viewer owns, plus shells sitting in a subtree
|
|
124
|
+
# shared with the viewer at >= min_rank. A shell inside a shared/public tree
|
|
125
|
+
# inherits a propagated CreativeSharesCache entry keyed on its own id
|
|
126
|
+
# (PermissionCacheBuilder#propagate_share walks [creative] + descendants,
|
|
127
|
+
# which includes placed shells), so readability of the shell id itself
|
|
128
|
+
# captures exactly "the viewer may act on this link here at this rank". The
|
|
129
|
+
# placement is checked at the caller's min_rank rather than always :read
|
|
130
|
+
# because higher-privilege callers share this batch — e.g.
|
|
131
|
+
# children_with_permission(user, :admin) drives DestroyService's recursive
|
|
132
|
+
# delete, which must not reach a placement the viewer only has read on. A
|
|
133
|
+
# shell in a foreign private tree has no entry and is excluded, preserving
|
|
134
|
+
# the anti-leak guarantee against FilterPipeline#resolve_ancestors' owner-
|
|
135
|
+
# agnostic pull.
|
|
136
|
+
def visible_shell_ids(effective_by_id, min_rank = rank_for(:read))
|
|
137
|
+
shell_ids = effective_by_id.filter_map { |id, effective| id if effective != id }
|
|
138
|
+
return Set.new if shell_ids.empty?
|
|
139
|
+
|
|
140
|
+
readable_effective_ids(shell_ids, min_rank)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Returns the subset of already-origin-resolved ids the user may access at
|
|
144
|
+
# `min_rank` or higher, as a Set. A user-specific entry is authoritative:
|
|
145
|
+
# it grants only when its own rank meets the threshold and otherwise
|
|
146
|
+
# suppresses the public share entirely (so a user no_access — or any
|
|
147
|
+
# below-threshold user entry — wins over a more permissive public share).
|
|
148
|
+
# Owned creatives are always accessible (owner has admin).
|
|
149
|
+
def readable_effective_ids(ids, min_rank = rank_for(:read))
|
|
150
|
+
return Set.new if ids.empty?
|
|
151
|
+
|
|
20
152
|
accessible_ids = Set.new
|
|
21
153
|
|
|
22
154
|
if user
|
|
23
|
-
|
|
24
|
-
user_denied = Set.new
|
|
155
|
+
user_has_entry = Set.new
|
|
25
156
|
CreativeSharesCache.where(creative_id: ids, user_id: user.id)
|
|
26
157
|
.pluck(:creative_id, :permission).each do |cid, perm|
|
|
27
|
-
|
|
158
|
+
user_has_entry << cid
|
|
159
|
+
accessible_ids << cid if CreativeShare.permissions[perm] >= min_rank
|
|
28
160
|
end
|
|
29
|
-
accessible_ids.merge(user_accessible)
|
|
30
161
|
|
|
31
162
|
public_ids = CreativeSharesCache.where(creative_id: ids, user_id: nil)
|
|
32
|
-
.where
|
|
33
|
-
accessible_ids.merge(public_ids
|
|
163
|
+
.where("permission >= ?", min_rank).pluck(:creative_id)
|
|
164
|
+
accessible_ids.merge(public_ids.reject { |cid| user_has_entry.include?(cid) })
|
|
34
165
|
|
|
35
166
|
owned_ids = Creative.where(id: ids, user_id: user.id).pluck(:id)
|
|
36
167
|
accessible_ids.merge(owned_ids)
|
|
37
168
|
else
|
|
38
169
|
accessible_ids = CreativeSharesCache.where(creative_id: ids, user_id: nil)
|
|
39
|
-
.where
|
|
170
|
+
.where("permission >= ?", min_rank).pluck(:creative_id).to_set
|
|
40
171
|
end
|
|
41
172
|
|
|
42
|
-
accessible_ids
|
|
173
|
+
accessible_ids
|
|
43
174
|
end
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
attr_reader :user
|
|
48
175
|
end
|
|
49
176
|
end
|
|
50
177
|
end
|