collavre 0.20.0 → 0.20.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 723a44399902885c80dbfb709086fc0c27e422d767847af131a23b05b56c1efe
4
- data.tar.gz: f2530a94041105e5ce4ef63309e630eab2d3f1172a8ccdf223f2782cb5dfcc71
3
+ metadata.gz: '0294ed9e986fd4e238465f357c6849572439574d448be0ecfaf49962849a77c7'
4
+ data.tar.gz: d8b98afc357d7033e3688dd795532b62fc2cbc053203585c22fca999544dd421
5
5
  SHA512:
6
- metadata.gz: 57f023f98547877c0f62ed2dd7d54860577b87602d47f62f53b0009e3e8dc9c5776ed44933bf7214f46970522867d23fa1c3c12719489af2b96dec672a1ebc98
7
- data.tar.gz: f4c8f7180925e650bd12df67732aa4a68ac6f1543e24a2deac87a3bbe30dacac769a0a5e3450365c432492d17607b092a87f0c5be935cd4d5ac5446330344906
6
+ metadata.gz: 8fc88c14930db1637fb547cbc06390d75c9c18b70814646b3c2d35a8fc7503779b8d272c3d45780ce6ff1cd24b36e964a2e7e0f83fd5d24d72d905a08f6c00df
7
+ data.tar.gz: ffaffdd0c03bef7d6ed95737eff2e765f2612e24a350feddddae91bbd16bdc86651487e43848cdbffb3d31402ce6f4219c2905223c1ebb4cf02f506cbebb6b18
@@ -21,16 +21,161 @@ module Collavre
21
21
 
22
22
  private
23
23
 
24
- def build_slide_ids(node)
25
- return unless node.has_permission?(Current.user, :read)
26
-
27
- @slide_ids << node.id
28
- children = node.children.order(:sequence)
29
- if node.origin_id.present?
30
- linked_children = node.linked_children
31
- children = (children + linked_children).uniq.sort_by(&:sequence)
24
+ # Builds @slide_ids by performing an in-order DFS traversal of the
25
+ # creative tree rooted at +root+, including any "linked children"
26
+ # (children of the node's origin when the node is a linked creative).
27
+ #
28
+ # Performance: a naive recursion issues one query per node for both
29
+ # +children+ and +linked_children+ (O(N) queries for an N-node tree).
30
+ # This implementation walks the tree level-by-level via batched queries,
31
+ # so total DB round-trips scale with tree depth rather than node count.
32
+ def build_slide_ids(root)
33
+ user = Current.user
34
+ return unless root.has_permission?(user, :read)
35
+
36
+ children_by_parent = {}
37
+ permission_cache = { root.id => true }
38
+
39
+ # BFS over the creative tree, batching DB lookups one level at a time.
40
+ # Only nodes the user may read are expanded, matching the original
41
+ # recursion which short-circuits on permission denial.
42
+ frontier = [ root ]
43
+ until frontier.empty?
44
+ # 1) Load own children for every node in the current level in one query.
45
+ parent_ids = frontier.map(&:id)
46
+ own_children_by_parent = Creative
47
+ .where(parent_id: parent_ids)
48
+ .order(:sequence)
49
+ .group_by(&:parent_id)
50
+
51
+ # 2) For nodes with an origin, load linked children (origin's children
52
+ # visible to the user) in a single batched query.
53
+ linked_nodes = frontier.select { |n| n.origin_id.present? }
54
+ linked_children_by_node = preload_linked_children(linked_nodes, user)
55
+
56
+ next_frontier = []
57
+ frontier.each do |node|
58
+ own = own_children_by_parent[node.id] || []
59
+ merged = if node.origin_id.present?
60
+ linked = linked_children_by_node[node.id] || []
61
+ (own + linked).uniq.sort_by(&:sequence)
62
+ else
63
+ own
64
+ end
65
+ children_by_parent[node.id] = merged
66
+ next_frontier.concat(merged)
67
+ end
68
+
69
+ # 3) Bulk permission check for the next level (uniqued by id).
70
+ next_unique = next_frontier.uniq(&:id)
71
+ uncached = next_unique.reject { |c| permission_cache.key?(c.id) }
72
+ uncached.each do |child|
73
+ permission_cache[child.id] = child.has_permission?(user, :read)
74
+ end
75
+
76
+ # Only descend into permitted nodes — matches the recursive version's
77
+ # short-circuit. Iterating in node order keeps duplicates collapsed
78
+ # while preserving discovery order.
79
+ frontier = next_unique.select { |c| permission_cache[c.id] }
32
80
  end
33
- children.each { |child| build_slide_ids(child) }
81
+
82
+ # 4) DFS in memory using the prebuilt maps to populate @slide_ids in
83
+ # the same order the recursive version would have produced.
84
+ stack = [ root ]
85
+ until stack.empty?
86
+ node = stack.pop
87
+ next unless permission_cache[node.id]
88
+
89
+ @slide_ids << node.id
90
+ children = children_by_parent[node.id] || []
91
+ # Push in reverse so DFS visits in ascending sequence order.
92
+ children.reverse_each { |child| stack.push(child) }
93
+ end
94
+ end
95
+
96
+ # Returns a hash of +node.id => Array(linked_children)+ for the given
97
+ # nodes (each of which must have +origin_id+ present). Equivalent in
98
+ # result to calling +node.linked_children+ on each node, but executes a
99
+ # bounded number of queries regardless of how many nodes are passed in.
100
+ def preload_linked_children(nodes, user)
101
+ return {} if nodes.empty?
102
+
103
+ # Resolve each node's effective origin (follows linked chains).
104
+ # effective_origin walks the chain by reading the origin association;
105
+ # repeated calls within a request hit ActiveRecord's identity per
106
+ # instance, so this is bounded by the linked-chain depth.
107
+ origin_for_node = {}
108
+ nodes.each do |node|
109
+ origin = node.effective_origin(Set.new)
110
+ origin_for_node[node.id] = origin if origin
111
+ end
112
+
113
+ unique_origin_ids = origin_for_node.values.map(&:id).uniq
114
+ return {} if unique_origin_ids.empty?
115
+
116
+ # Single batched query for all candidate children across all origins.
117
+ candidate_children = Creative
118
+ .where(parent_id: unique_origin_ids)
119
+ .order(:sequence)
120
+ .to_a
121
+ candidates_by_origin = candidate_children.group_by(&:parent_id)
122
+ candidate_ids = candidate_children.map(&:id)
123
+
124
+ accessible_ids = accessible_child_ids(candidate_ids, candidate_children, user)
125
+
126
+ result = {}
127
+ nodes.each do |node|
128
+ origin = origin_for_node[node.id]
129
+ next unless origin
130
+
131
+ children = candidates_by_origin[origin.id] || []
132
+ result[node.id] = children.select { |c| accessible_ids.include?(c.id) }
133
+ end
134
+ result
135
+ end
136
+
137
+ # Mirrors the access check in Creative#children_with_permission, but
138
+ # operates on a pre-fetched set of candidate children so the work
139
+ # collapses to a fixed number of queries instead of one per parent.
140
+ def accessible_child_ids(candidate_ids, candidate_children, user)
141
+ return Set.new if candidate_ids.empty?
142
+
143
+ min_rank = CreativeShare.permissions["read"]
144
+ accessible = Set.new
145
+
146
+ if user
147
+ user_entries = CreativeSharesCache
148
+ .where(creative_id: candidate_ids, user_id: user.id)
149
+ .pluck(:creative_id, :permission)
150
+
151
+ user_has_entry = Set.new
152
+ user_entries.each do |cid, perm|
153
+ user_has_entry << cid
154
+ perm_rank = CreativeSharesCache.permissions[perm]
155
+ if perm_rank && perm_rank >= min_rank && perm_rank != CreativeSharesCache.permissions[:no_access]
156
+ accessible << cid
157
+ end
158
+ end
159
+
160
+ public_accessible = CreativeSharesCache
161
+ .where(creative_id: candidate_ids, user_id: nil)
162
+ .where("permission >= ?", min_rank)
163
+ .where.not(permission: :no_access)
164
+ .pluck(:creative_id)
165
+ accessible.merge(public_accessible.reject { |cid| user_has_entry.include?(cid) })
166
+
167
+ owned_ids = candidate_children.select { |c| c.user_id == user.id }.map(&:id)
168
+ accessible.merge(owned_ids)
169
+ else
170
+ public_accessible = CreativeSharesCache
171
+ .where(creative_id: candidate_ids, user_id: nil)
172
+ .where("permission >= ?", min_rank)
173
+ .where.not(permission: :no_access)
174
+ .pluck(:creative_id)
175
+ accessible.merge(public_accessible)
176
+ end
177
+
178
+ accessible
34
179
  end
35
180
  end
36
181
  end
@@ -3,7 +3,6 @@
3
3
  module Collavre
4
4
  class CreativeInvitationsController < ApplicationController
5
5
  before_action :set_invitation
6
- before_action :authorize_admin!
7
6
 
8
7
  def update
9
8
  if @invitation.update(permission: params[:permission])
@@ -29,18 +28,16 @@ module Collavre
29
28
 
30
29
  private
31
30
 
31
+ # Scoped lookup: only returns the invitation if Current.user has admin
32
+ # permission on its creative. Raises ActiveRecord::RecordNotFound otherwise
33
+ # so that record existence is not leaked to unauthorized users.
32
34
  def set_invitation
33
- @invitation = Invitation.find(params[:id])
34
- @creative = @invitation.creative
35
- end
36
-
37
- def authorize_admin!
38
- return if @creative.has_permission?(Current.user, :admin)
35
+ invitation = Invitation.find_by(id: params[:id])
36
+ creative = invitation&.creative
37
+ raise ActiveRecord::RecordNotFound unless creative&.has_permission?(Current.user, :admin)
39
38
 
40
- respond_to do |format|
41
- format.html { redirect_back fallback_location: main_app.root_path, alert: t("collavre.creatives.errors.no_permission") }
42
- format.json { render json: { error: t("collavre.creatives.errors.no_permission") }, status: :forbidden }
43
- end
39
+ @invitation = invitation
40
+ @creative = creative
44
41
  end
45
42
  end
46
43
  end
@@ -107,14 +107,7 @@ module Collavre
107
107
  end
108
108
 
109
109
  def update
110
- @creative_share = CreativeShare.find(params[:id])
111
- unless @creative_share.creative.has_permission?(Current.user, :admin)
112
- respond_to do |format|
113
- format.html { redirect_back fallback_location: main_app.root_path, alert: t("collavre.creatives.errors.no_permission") }
114
- format.json { render json: { error: t("collavre.creatives.errors.no_permission") }, status: :forbidden }
115
- end
116
- return
117
- end
110
+ @creative_share = find_admin_creative_share(params[:id])
118
111
 
119
112
  if @creative_share.update(permission: params[:permission])
120
113
  respond_to do |format|
@@ -130,14 +123,7 @@ module Collavre
130
123
  end
131
124
 
132
125
  def destroy
133
- @creative_share = CreativeShare.find(params[:id])
134
- unless @creative_share.creative.has_permission?(Current.user, :admin)
135
- respond_to do |format|
136
- format.html { redirect_back fallback_location: main_app.root_path, alert: t("collavre.creatives.errors.no_permission") }
137
- format.json { render json: { error: t("collavre.creatives.errors.no_permission") }, status: :forbidden }
138
- end
139
- return
140
- end
126
+ @creative_share = find_admin_creative_share(params[:id])
141
127
 
142
128
  @creative_share.destroy
143
129
  # remove linked creative if it exists
@@ -151,6 +137,16 @@ module Collavre
151
137
 
152
138
  private
153
139
 
140
+ # Scoped lookup: only returns the share if Current.user has admin permission
141
+ # on its creative. Raises ActiveRecord::RecordNotFound otherwise so that
142
+ # record existence is not leaked via 403 vs 404 distinction.
143
+ def find_admin_creative_share(id)
144
+ share = CreativeShare.find_by(id: id)
145
+ raise ActiveRecord::RecordNotFound unless share&.creative&.has_permission?(Current.user, :admin)
146
+
147
+ share
148
+ end
149
+
154
150
  def all_descendants(creative)
155
151
  creative.children.flat_map { |child| [ child ] + all_descendants(child) }
156
152
  end
@@ -0,0 +1,6 @@
1
+ class AddTaskIdToComments < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :comments, :task_id, :integer
4
+ add_index :comments, :task_id
5
+ end
6
+ end
@@ -1,3 +1,3 @@
1
1
  module Collavre
2
- VERSION = "0.20.0"
2
+ VERSION = "0.20.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: collavre
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.0
4
+ version: 0.20.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre
@@ -708,6 +708,7 @@ files:
708
708
  - db/migrate/20260401120000_remove_emoji_prefix_from_inbox_creatives.rb
709
709
  - db/migrate/20260409000000_add_source_topic_id_to_topics.rb
710
710
  - db/migrate/20260415000000_create_main_topics_for_existing_creatives.rb
711
+ - db/migrate/20260415094811_add_task_id_to_comments.rb
711
712
  - lib/collavre.rb
712
713
  - lib/collavre/configuration.rb
713
714
  - lib/collavre/engine.rb