collavre 0.23.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/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/comments_popup.css +40 -0
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- 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/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +32 -99
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/topics_controller.rb +5 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +8 -2
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- 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/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/topics_controller.js +47 -0
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/common_popup.js +24 -5
- 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/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -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/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +121 -401
- 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/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/notifiable.rb +1 -1
- data/app/models/collavre/comment.rb +8 -1
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +15 -9
- 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 +12 -0
- data/app/models/collavre/user.rb +33 -9
- 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 +53 -2
- data/app/services/collavre/auto_theme_generator.rb +14 -10
- 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/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +105 -77
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +1 -1
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/views/collavre/comments/_comment.html.erb +4 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/config/locales/comments.en.yml +1 -0
- data/config/locales/comments.ko.yml +1 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- 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/lib/collavre/version.rb +1 -1
- metadata +42 -1
|
@@ -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
|
|
@@ -12,85 +12,99 @@ module Creatives
|
|
|
12
12
|
@max_level = max_level
|
|
13
13
|
@allowed_creative_ids = allowed_creative_ids
|
|
14
14
|
@progress_map = progress_map
|
|
15
|
-
@
|
|
15
|
+
@permission_rank = {}
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def build(collection, level: 1)
|
|
19
19
|
return [] if collection.blank?
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
# Preload permissions for all creatives in this batch to avoid N+1
|
|
23
|
-
preload_permissions(creatives)
|
|
24
|
-
|
|
25
|
-
build_nodes(creatives, level: level)
|
|
21
|
+
build_nodes(Array(collection), level: level)
|
|
26
22
|
end
|
|
27
23
|
|
|
28
24
|
private
|
|
29
25
|
|
|
30
|
-
attr_reader :user, :view_context, :raw_params, :expanded_state_map, :select_mode, :max_level, :allowed_creative_ids, :progress_map
|
|
26
|
+
attr_reader :user, :view_context, :raw_params, :expanded_state_map, :select_mode, :max_level, :allowed_creative_ids, :progress_map
|
|
31
27
|
|
|
32
|
-
def
|
|
33
|
-
|
|
28
|
+
def children_index
|
|
29
|
+
@children_index ||= ChildrenIndex.new(
|
|
30
|
+
user: user,
|
|
31
|
+
show_archived: raw_params["show_archived"].present?,
|
|
32
|
+
allowed_creative_ids: allowed_creative_ids
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def comment_badge_index
|
|
37
|
+
@comment_badge_index ||= CommentBadgeIndex.new(user: user)
|
|
38
|
+
end
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
# Everything a level of nodes needs, resolved in a fixed number of queries
|
|
41
|
+
# rather than a handful per node. The recursion re-enters here for each level,
|
|
42
|
+
# so total cost tracks tree *depth*, not node count.
|
|
43
|
+
def prepare_level(creatives)
|
|
44
|
+
ActiveRecord::Associations::Preloader.new(
|
|
45
|
+
records: creatives,
|
|
46
|
+
associations: [ :origin, { tags: :label } ]
|
|
47
|
+
).call
|
|
37
48
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return if uncached_ids.empty?
|
|
49
|
+
preload_permissions(creatives)
|
|
50
|
+
preload_comment_badges(creatives)
|
|
41
51
|
|
|
42
|
-
|
|
52
|
+
return if children_suppressed?
|
|
43
53
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
children_index.index(creatives)
|
|
55
|
+
expanding = creatives.select { |creative| load_children_now?(creative) }
|
|
56
|
+
children_index.load(expanding) if expanding.any?
|
|
57
|
+
end
|
|
48
58
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
# Batch effective-origin permission ranks via the single PermissionFilter
|
|
60
|
+
# read path (owner wins, then the user's own cache entry incl. a no_access
|
|
61
|
+
# deny, then the public entry). Every pending creative gets an explicit
|
|
62
|
+
# entry — nil when no share resolves — so #allowed? can trust
|
|
63
|
+
# @permission_rank.key? and never falls back per-item for absent ids.
|
|
64
|
+
def preload_permissions(creatives)
|
|
65
|
+
pending = creatives.reject { |creative| @permission_rank.key?(creative.id) }
|
|
66
|
+
return if pending.empty?
|
|
53
67
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
public_permissions = CreativeSharesCache
|
|
58
|
-
.where(creative_id: still_uncached, user_id: nil)
|
|
59
|
-
.pluck(:creative_id, :permission)
|
|
60
|
-
|
|
61
|
-
public_permissions.each do |cid, perm|
|
|
62
|
-
next if @permission_cache.key?(cid)
|
|
63
|
-
perm_rank = CreativeSharesCache.permissions[perm]
|
|
64
|
-
@permission_cache[cid] = perm_rank && perm_rank >= write_rank && perm_rank != CreativeSharesCache.permissions[:no_access]
|
|
65
|
-
end
|
|
66
|
-
end
|
|
68
|
+
ranks = PermissionFilter.new(user: user).ranks_for(pending.map(&:id))
|
|
69
|
+
pending.each { |creative| @permission_rank[creative.id] = ranks[creative.id] }
|
|
70
|
+
end
|
|
67
71
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
# Equivalent to `creative.has_permission?(user, permission)`, served from the
|
|
73
|
+
# level's batched ranks. Falls back for a creative the batch never saw.
|
|
74
|
+
def allowed?(creative, permission)
|
|
75
|
+
return creative.has_permission?(user, permission) unless @permission_rank.key?(creative.id)
|
|
71
76
|
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
rank = @permission_rank[creative.id]
|
|
78
|
+
return false if rank.nil?
|
|
79
|
+
|
|
80
|
+
rank >= CreativeShare.permissions[permission.to_s]
|
|
74
81
|
end
|
|
75
82
|
|
|
76
|
-
def
|
|
83
|
+
def can_write?(creative)
|
|
77
84
|
return false unless user
|
|
78
|
-
#
|
|
79
|
-
return false if creative.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
# Read-only-source creatives are never writable
|
|
86
|
+
return false if creative.read_only_source?
|
|
87
|
+
|
|
88
|
+
allowed?(creative, :write)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def can_feedback?(creative)
|
|
92
|
+
allowed?(creative, :feedback)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Only rows that will actually render a badge are worth batching.
|
|
96
|
+
def preload_comment_badges(creatives)
|
|
97
|
+
visible = creatives.reject(&:archived?).select { |creative| can_feedback?(creative) }
|
|
98
|
+
return if visible.empty?
|
|
99
|
+
|
|
100
|
+
comment_badge_index.index(visible.map(&:effective_origin))
|
|
87
101
|
end
|
|
88
102
|
|
|
89
103
|
def build_nodes(creatives, level:)
|
|
90
104
|
return [] if level > max_level
|
|
105
|
+
return [] if creatives.empty?
|
|
91
106
|
|
|
92
|
-
|
|
93
|
-
preload_permissions(creatives) if creatives.any?
|
|
107
|
+
prepare_level(creatives)
|
|
94
108
|
|
|
95
109
|
creatives.flat_map do |creative|
|
|
96
110
|
build_nodes_for_creative(creative, level: level)
|
|
@@ -102,16 +116,18 @@ module Creatives
|
|
|
102
116
|
creative.filtered_progress = progress_map[creative.id.to_s]
|
|
103
117
|
end
|
|
104
118
|
|
|
105
|
-
filtered_children = filtered_children_for(creative)
|
|
106
119
|
expanded = expanded?(creative.id)
|
|
107
120
|
skip = skip_creative?(creative)
|
|
108
121
|
child_level = level + 1
|
|
109
122
|
child_render_level = skip ? level : child_level
|
|
110
|
-
load_children_now =
|
|
111
|
-
|
|
123
|
+
load_children_now = load_children_now?(creative)
|
|
124
|
+
has_children = !children_suppressed? && children_index.has_children?(creative)
|
|
125
|
+
children_nodes = load_children_now ? build_nodes(children_index.children_for(creative), level: child_render_level) : []
|
|
112
126
|
|
|
113
127
|
return children_nodes if skip
|
|
114
128
|
|
|
129
|
+
can_write = can_write?(creative)
|
|
130
|
+
|
|
115
131
|
[
|
|
116
132
|
{
|
|
117
133
|
id: creative.id,
|
|
@@ -119,19 +135,22 @@ module Creatives
|
|
|
119
135
|
parent_id: creative.parent_id,
|
|
120
136
|
level: level,
|
|
121
137
|
select_mode: !!select_mode,
|
|
122
|
-
can_write:
|
|
123
|
-
has_children:
|
|
138
|
+
can_write: can_write,
|
|
139
|
+
has_children: has_children,
|
|
124
140
|
expanded: expanded,
|
|
125
|
-
is_root: creative.
|
|
141
|
+
is_root: creative.parent_id.nil?,
|
|
126
142
|
archived: creative.archived?,
|
|
127
|
-
github_source
|
|
143
|
+
# `github_source` is the wire key the tree renderer JS reads; its value
|
|
144
|
+
# is now the neutral read-only-source flag (GitHub-synced content is one
|
|
145
|
+
# such source) so core names no vendor here.
|
|
146
|
+
github_source: creative.read_only_source?,
|
|
128
147
|
sequence: creative.sequence,
|
|
129
148
|
link_url: view_context.collavre.creative_path(creative),
|
|
130
|
-
templates: template_payload_for(creative, has_children:
|
|
131
|
-
inline_editor_payload: inline_editor_payload_for(creative, can_write:
|
|
149
|
+
templates: template_payload_for(creative, has_children: has_children, can_write: can_write),
|
|
150
|
+
inline_editor_payload: inline_editor_payload_for(creative, can_write: can_write),
|
|
132
151
|
children_container: children_container_payload(
|
|
133
152
|
creative,
|
|
134
|
-
|
|
153
|
+
has_children: has_children,
|
|
135
154
|
child_level: child_level,
|
|
136
155
|
children_nodes: children_nodes,
|
|
137
156
|
expanded: expanded,
|
|
@@ -149,16 +168,17 @@ module Creatives
|
|
|
149
168
|
false
|
|
150
169
|
end
|
|
151
170
|
|
|
152
|
-
|
|
153
|
-
|
|
171
|
+
# The chats feed and flat search render rows, not a tree: no node has children
|
|
172
|
+
# there, so the whole child resolution is skipped.
|
|
173
|
+
def children_suppressed?
|
|
174
|
+
return @children_suppressed if defined?(@children_suppressed)
|
|
154
175
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
end
|
|
176
|
+
@children_suppressed = raw_params["comment"] == "true" ||
|
|
177
|
+
(raw_params["search"].present? && raw_params["search_mode"] != "tree")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def load_children_now?(creative)
|
|
181
|
+
filters_applied? || expanded?(creative.id) || skip_creative?(creative)
|
|
162
182
|
end
|
|
163
183
|
|
|
164
184
|
def expanded?(creative_id)
|
|
@@ -174,9 +194,17 @@ module Creatives
|
|
|
174
194
|
end
|
|
175
195
|
end
|
|
176
196
|
|
|
177
|
-
def template_payload_for(creative, has_children: nil)
|
|
197
|
+
def template_payload_for(creative, has_children: nil, can_write: nil)
|
|
178
198
|
description_html = view_context.embed_youtube_iframe(creative.effective_description(raw_params["tags"]&.first))
|
|
179
|
-
|
|
199
|
+
can_feedback = can_feedback?(creative)
|
|
200
|
+
progress_html = view_context.render_creative_progress(
|
|
201
|
+
creative,
|
|
202
|
+
select_mode: !!select_mode,
|
|
203
|
+
has_children: has_children,
|
|
204
|
+
can_write: can_write,
|
|
205
|
+
can_feedback: can_feedback,
|
|
206
|
+
unread_count: can_feedback ? comment_badge_index.unread_count_for(creative.effective_origin) : nil
|
|
207
|
+
)
|
|
180
208
|
|
|
181
209
|
{
|
|
182
210
|
description_html: description_html,
|
|
@@ -189,7 +217,7 @@ module Creatives
|
|
|
189
217
|
|
|
190
218
|
def inline_editor_payload_for(creative, can_write:)
|
|
191
219
|
effective = creative.effective_origin(Set.new)
|
|
192
|
-
origin_writable = effective.id == creative.id ? can_write :
|
|
220
|
+
origin_writable = effective.id == creative.id ? can_write : allowed?(creative, :write)
|
|
193
221
|
{
|
|
194
222
|
description_raw_html: creative.effective_description(nil, true),
|
|
195
223
|
progress: creative.progress,
|
|
@@ -200,8 +228,8 @@ module Creatives
|
|
|
200
228
|
}
|
|
201
229
|
end
|
|
202
230
|
|
|
203
|
-
def children_container_payload(creative,
|
|
204
|
-
return nil unless
|
|
231
|
+
def children_container_payload(creative, has_children:, child_level:, children_nodes:, expanded:, load_children_now:)
|
|
232
|
+
return nil unless has_children
|
|
205
233
|
|
|
206
234
|
{
|
|
207
235
|
id: "creative-children-#{creative.id}",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Collavre
|
|
8
|
+
# Thin, dependency-free HTTP wrapper over Net::HTTP shared across engines.
|
|
9
|
+
#
|
|
10
|
+
# It centralizes timeout configuration, TLS negotiation and transport-error
|
|
11
|
+
# handling so vendor engines no longer each hand-roll their own Net::HTTP /
|
|
12
|
+
# HTTParty / Faraday setup. Callers keep full ownership of their own response
|
|
13
|
+
# parsing and domain error mapping — this wrapper only performs the request and
|
|
14
|
+
# returns a small Response value object.
|
|
15
|
+
#
|
|
16
|
+
# Transport-layer failures (DNS, connection refused/reset, TLS handshake,
|
|
17
|
+
# open/read timeouts) are wrapped in Collavre::HttpClient::ConnectionError so a
|
|
18
|
+
# caller can rescue a single, stable error type regardless of the underlying
|
|
19
|
+
# exception class.
|
|
20
|
+
class HttpClient
|
|
21
|
+
class Error < StandardError; end
|
|
22
|
+
class ConnectionError < Error; end
|
|
23
|
+
|
|
24
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
25
|
+
DEFAULT_READ_TIMEOUT = 30
|
|
26
|
+
|
|
27
|
+
# Transport-level exceptions that occur before any HTTP response exists.
|
|
28
|
+
TRANSPORT_ERRORS = [
|
|
29
|
+
SocketError, SystemCallError, Timeout::Error, IOError,
|
|
30
|
+
OpenSSL::SSL::SSLError, Net::OpenTimeout, Net::ReadTimeout
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
REQUEST_CLASSES = {
|
|
34
|
+
get: Net::HTTP::Get,
|
|
35
|
+
post: Net::HTTP::Post,
|
|
36
|
+
patch: Net::HTTP::Patch,
|
|
37
|
+
put: Net::HTTP::Put,
|
|
38
|
+
delete: Net::HTTP::Delete
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
# A minimal, read-only view over a Net::HTTP response.
|
|
42
|
+
class Response
|
|
43
|
+
attr_reader :code, :message, :body, :headers
|
|
44
|
+
|
|
45
|
+
def initialize(net_response)
|
|
46
|
+
@net_response = net_response
|
|
47
|
+
@code = net_response.code.to_i
|
|
48
|
+
@message = net_response.message
|
|
49
|
+
@body = net_response.body
|
|
50
|
+
@headers = net_response.to_hash
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def success?
|
|
54
|
+
@net_response.is_a?(Net::HTTPSuccess)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Parse the response body as JSON, or nil when the body is empty.
|
|
58
|
+
def json
|
|
59
|
+
return nil if body.nil? || body.empty?
|
|
60
|
+
|
|
61
|
+
JSON.parse(body)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def initialize(open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, default_headers: {})
|
|
66
|
+
@open_timeout = open_timeout
|
|
67
|
+
@read_timeout = read_timeout
|
|
68
|
+
@default_headers = default_headers
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def get(url, headers: {})
|
|
72
|
+
request(:get, url, headers: headers)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def post(url, body: nil, headers: {})
|
|
76
|
+
request(:post, url, body: body, headers: headers)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def patch(url, body: nil, headers: {})
|
|
80
|
+
request(:patch, url, body: body, headers: headers)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def put(url, body: nil, headers: {})
|
|
84
|
+
request(:put, url, body: body, headers: headers)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def delete(url, headers: {})
|
|
88
|
+
request(:delete, url, headers: headers)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def request(method, url, body: nil, headers: {})
|
|
94
|
+
uri = URI.parse(url)
|
|
95
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
96
|
+
http.use_ssl = uri.scheme == "https"
|
|
97
|
+
http.open_timeout = @open_timeout
|
|
98
|
+
http.read_timeout = @read_timeout
|
|
99
|
+
|
|
100
|
+
req = build_request(method, uri, body, headers)
|
|
101
|
+
Response.new(http.request(req))
|
|
102
|
+
rescue *TRANSPORT_ERRORS => e
|
|
103
|
+
raise ConnectionError, "#{e.class}: #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_request(method, uri, body, headers)
|
|
107
|
+
path = uri.path.presence || "/"
|
|
108
|
+
path = "#{path}?#{uri.query}" if uri.query.present?
|
|
109
|
+
|
|
110
|
+
request_class = REQUEST_CLASSES.fetch(method) do
|
|
111
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
112
|
+
end
|
|
113
|
+
req = request_class.new(path)
|
|
114
|
+
@default_headers.merge(headers).each { |key, value| req[key] = value }
|
|
115
|
+
req.body = body unless body.nil?
|
|
116
|
+
req
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|