collavre 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -0,0 +1,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
- return empty_result if result.matched_ids.empty?
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
- matched_creatives = Creative.where(id: result.matched_ids.to_a)
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
- matched_creatives = matched_creatives.sort_by { |c| c.comments.maximum(:updated_at) || c.updated_at }.reverse
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 = creative.origin_id.nil? ? creative : creative.origin
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
- # no_access wins over a public share, so user-level denials are subtracted
9
- # from the public set. Owned creatives are always readable.
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 read, as an Array.
16
- def readable_ids(ids)
17
- ids = ids.to_a
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
- user_accessible = []
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
- perm == "no_access" ? user_denied << cid : user_accessible << cid
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.not(permission: :no_access).pluck(:creative_id)
33
- accessible_ids.merge(public_ids - user_denied.to_a)
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.not(permission: :no_access).pluck(:creative_id).to_set
170
+ .where("permission >= ?", min_rank).pluck(:creative_id).to_set
40
171
  end
41
172
 
42
- accessible_ids.to_a
173
+ accessible_ids
43
174
  end
44
-
45
- private
46
-
47
- attr_reader :user
48
175
  end
49
176
  end
50
177
  end