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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  3. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
  7. data/app/assets/stylesheets/collavre/landing.css +92 -0
  8. data/app/assets/stylesheets/collavre/popup.css +4 -2
  9. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  10. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  11. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  12. data/app/controllers/collavre/attachments_controller.rb +13 -3
  13. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  14. data/app/controllers/collavre/creatives_controller.rb +32 -99
  15. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  16. data/app/controllers/collavre/landing_controller.rb +5 -2
  17. data/app/controllers/collavre/topics_controller.rb +5 -0
  18. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  19. data/app/errors/collavre/api_error.rb +20 -0
  20. data/app/helpers/collavre/creatives_helper.rb +30 -8
  21. data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
  22. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  23. data/app/javascript/components/creative_tree_row.js +8 -2
  24. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  25. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  26. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  27. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  28. data/app/javascript/controllers/comments/topics_controller.js +47 -0
  29. data/app/javascript/controllers/common_popup_controller.js +2 -2
  30. data/app/javascript/controllers/index.js +3 -0
  31. data/app/javascript/controllers/search_popup_controller.js +17 -1
  32. data/app/javascript/controllers/topic_list_controller.js +78 -0
  33. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  34. data/app/javascript/lib/common_popup.js +24 -5
  35. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  36. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  37. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  38. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  39. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  40. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  41. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  42. data/app/javascript/modules/creative_inline_payload.js +86 -0
  43. data/app/javascript/modules/creative_row_editor.js +121 -401
  44. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  45. data/app/javascript/modules/creative_save_queue.js +95 -0
  46. data/app/javascript/modules/creative_tree_dom.js +260 -0
  47. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  48. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  49. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  50. data/app/models/collavre/channel.rb +16 -0
  51. data/app/models/collavre/comment/approvable.rb +16 -0
  52. data/app/models/collavre/comment/notifiable.rb +1 -1
  53. data/app/models/collavre/comment.rb +8 -1
  54. data/app/models/collavre/comment_version.rb +1 -1
  55. data/app/models/collavre/creative/describable.rb +15 -9
  56. data/app/models/collavre/creative/linkable.rb +9 -0
  57. data/app/models/collavre/creative/permissible.rb +97 -49
  58. data/app/models/collavre/creative.rb +73 -5
  59. data/app/models/collavre/creative_share.rb +78 -26
  60. data/app/models/collavre/inbox_item.rb +2 -2
  61. data/app/models/collavre/task.rb +12 -0
  62. data/app/models/collavre/user.rb +33 -9
  63. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  64. data/app/services/collavre/agent_type_classifier.rb +23 -0
  65. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  66. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  67. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  68. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  69. data/app/services/collavre/ai_client.rb +53 -2
  70. data/app/services/collavre/auto_theme_generator.rb +14 -10
  71. data/app/services/collavre/creatives/children_index.rb +86 -0
  72. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  73. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  74. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  75. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  76. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  77. data/app/services/collavre/creatives/tree_builder.rb +105 -77
  78. data/app/services/collavre/http_client.rb +119 -0
  79. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  80. data/app/services/collavre/markdown_importer.rb +30 -6
  81. data/app/services/collavre/mobile/event_summarizer.rb +1 -1
  82. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  83. data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
  84. data/app/services/collavre/system_events/context_builder.rb +1 -14
  85. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  86. data/app/views/collavre/comments/_comment.html.erb +4 -4
  87. data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
  88. data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
  89. data/app/views/collavre/landing/show.html.erb +52 -10
  90. data/config/locales/comments.en.yml +1 -0
  91. data/config/locales/comments.ko.yml +1 -0
  92. data/config/locales/creatives.en.yml +6 -0
  93. data/config/locales/creatives.ko.yml +5 -0
  94. data/config/locales/landing.en.yml +22 -10
  95. data/config/locales/landing.ko.yml +22 -10
  96. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  97. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  98. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  99. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  100. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  101. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  102. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  103. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  104. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  105. data/lib/collavre/version.rb +1 -1
  106. metadata +42 -1
@@ -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
@@ -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
- @permission_cache = {}
15
+ @permission_rank = {}
16
16
  end
17
17
 
18
18
  def build(collection, level: 1)
19
19
  return [] if collection.blank?
20
20
 
21
- creatives = Array(collection)
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, :permission_cache
26
+ attr_reader :user, :view_context, :raw_params, :expanded_state_map, :select_mode, :max_level, :allowed_creative_ids, :progress_map
31
27
 
32
- def preload_permissions(creatives)
33
- return unless user
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
- creative_ids = creatives.map(&:id)
36
- return if creative_ids.empty?
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
- # Skip if already cached
39
- uncached_ids = creative_ids - @permission_cache.keys
40
- return if uncached_ids.empty?
49
+ preload_permissions(creatives)
50
+ preload_comment_badges(creatives)
41
51
 
42
- write_rank = CreativeShare.permissions[:write]
52
+ return if children_suppressed?
43
53
 
44
- # Batch query for user-specific permissions
45
- user_permissions = CreativeSharesCache
46
- .where(creative_id: uncached_ids, user_id: user.id)
47
- .pluck(:creative_id, :permission)
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
- user_permissions.each do |cid, perm|
50
- perm_rank = CreativeSharesCache.permissions[perm]
51
- @permission_cache[cid] = perm_rank && perm_rank >= write_rank && perm_rank != CreativeSharesCache.permissions[:no_access]
52
- end
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
- # For remaining uncached, check public shares
55
- still_uncached = uncached_ids - @permission_cache.keys
56
- if still_uncached.any?
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
- # Mark owned creatives as having write permission
69
- owned_ids = creatives.select { |c| c.user_id == user.id }.map(&:id)
70
- owned_ids.each { |cid| @permission_cache[cid] = true }
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
- # Default to false for any remaining uncached
73
- uncached_ids.each { |cid| @permission_cache[cid] ||= false }
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 cached_can_write?(creative)
83
+ def can_write?(creative)
77
84
  return false unless user
78
- # GitHub-synced creatives are always read-only
79
- return false if creative.github_markdown?
80
-
81
- if @permission_cache.key?(creative.id)
82
- @permission_cache[creative.id]
83
- else
84
- # Fallback to individual check if not cached
85
- creative.has_permission?(user, :write)
86
- end
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
- # Preload permissions for this batch of creatives
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 = filters_applied? || expanded || skip
111
- children_nodes = load_children_now ? build_nodes(filtered_children, level: child_render_level) : []
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: cached_can_write?(creative),
123
- has_children: filtered_children.any?,
138
+ can_write: can_write,
139
+ has_children: has_children,
124
140
  expanded: expanded,
125
- is_root: creative.parent.nil?,
141
+ is_root: creative.parent_id.nil?,
126
142
  archived: creative.archived?,
127
- github_source: creative.github_markdown?,
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: filtered_children.any?),
131
- inline_editor_payload: inline_editor_payload_for(creative, can_write: cached_can_write?(creative)),
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
- filtered_children,
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
- def filtered_children_for(creative)
153
- return [] if raw_params["comment"] == "true" || (raw_params["search"].present? && raw_params["search_mode"] != "tree")
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
- children = creative.children_with_permission(user)
156
- children = children.select { |c| !c.archived? } unless raw_params["show_archived"]
157
- if allowed_creative_ids
158
- children.select { |c| allowed_creative_ids.include?(c.id.to_s) }
159
- else
160
- children
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
- progress_html = view_context.render_creative_progress(creative, select_mode: !!select_mode, has_children: has_children)
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 : creative.has_permission?(user, :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, filtered_children, child_level:, children_nodes:, expanded:, load_children_now:)
204
- return nil unless filtered_children.any?
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