collavre 0.3.2 → 0.5.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +73 -71
  3. data/app/assets/stylesheets/collavre/activity_logs.css +18 -45
  4. data/app/assets/stylesheets/collavre/comments_popup.css +197 -35
  5. data/app/assets/stylesheets/collavre/creatives.css +101 -51
  6. data/app/assets/stylesheets/collavre/dark_mode.css +221 -88
  7. data/app/assets/stylesheets/collavre/design_tokens.css +334 -0
  8. data/app/assets/stylesheets/collavre/mention_menu.css +13 -9
  9. data/app/assets/stylesheets/collavre/popup.css +57 -27
  10. data/app/assets/stylesheets/collavre/slide_view.css +6 -6
  11. data/app/assets/stylesheets/collavre/user_menu.css +4 -5
  12. data/app/components/collavre/plans_timeline_component.html.erb +2 -2
  13. data/app/controllers/collavre/admin/orchestration_controller.rb +9 -2
  14. data/app/controllers/collavre/admin/settings_controller.rb +199 -0
  15. data/app/controllers/collavre/comments/reactions_controller.rb +1 -9
  16. data/app/controllers/collavre/comments_controller.rb +39 -162
  17. data/app/controllers/collavre/creatives_controller.rb +18 -58
  18. data/app/controllers/collavre/users_controller.rb +31 -3
  19. data/app/helpers/collavre/application_helper.rb +97 -0
  20. data/app/helpers/collavre/creatives_helper.rb +10 -202
  21. data/app/javascript/collavre.js +0 -1
  22. data/app/javascript/components/creative_tree_row.js +3 -2
  23. data/app/javascript/controllers/comment_controller.js +309 -4
  24. data/app/javascript/controllers/comments/form_controller.js +52 -0
  25. data/app/javascript/controllers/comments/presence_controller.js +13 -0
  26. data/app/javascript/controllers/creatives/tree_controller.js +2 -1
  27. data/app/javascript/controllers/link_creative_controller.js +29 -3
  28. data/app/javascript/lib/__tests__/html_code_block_wrapper.test.js +201 -0
  29. data/app/javascript/lib/html_code_block_wrapper.js +168 -0
  30. data/app/javascript/lib/utils/markdown.js +2 -1
  31. data/app/javascript/modules/creative_row_editor.js +5 -1
  32. data/app/javascript/utils/emoji_parser.js +21 -0
  33. data/app/jobs/collavre/ai_agent_job.rb +6 -2
  34. data/app/jobs/collavre/cron_action_job.rb +18 -6
  35. data/app/jobs/collavre/cron_scheduler_job.rb +112 -0
  36. data/app/models/collavre/comment/approvable.rb +50 -0
  37. data/app/models/collavre/comment/broadcastable.rb +119 -0
  38. data/app/models/collavre/comment/notifiable.rb +111 -0
  39. data/app/models/collavre/comment.rb +13 -258
  40. data/app/models/collavre/comment_reaction.rb +15 -0
  41. data/app/models/collavre/creative/describable.rb +86 -0
  42. data/app/models/collavre/creative/linkable.rb +77 -0
  43. data/app/models/collavre/creative/permissible.rb +103 -0
  44. data/app/models/collavre/creative.rb +3 -289
  45. data/app/models/collavre/orchestrator_policy.rb +1 -1
  46. data/app/models/collavre/system_setting.rb +27 -1
  47. data/app/models/collavre/user.rb +42 -0
  48. data/app/models/collavre/user_theme.rb +10 -0
  49. data/app/services/collavre/ai_agent/approval_handler.rb +110 -0
  50. data/app/services/collavre/ai_agent/message_builder.rb +129 -0
  51. data/app/services/collavre/ai_agent/review_handler.rb +70 -0
  52. data/app/services/collavre/ai_agent_service.rb +93 -150
  53. data/app/services/collavre/ai_client.rb +23 -4
  54. data/app/services/collavre/auto_theme_generator.rb +168 -50
  55. data/app/services/collavre/command_menu_service.rb +70 -0
  56. data/app/services/collavre/comment_move_service.rb +94 -0
  57. data/app/services/collavre/comments/action_executor.rb +10 -0
  58. data/app/services/collavre/comments/mcp_command.rb +1 -2
  59. data/app/services/collavre/creatives/create_service.rb +86 -0
  60. data/app/services/collavre/creatives/destroy_service.rb +41 -0
  61. data/app/services/collavre/creatives/index_query.rb +3 -0
  62. data/app/services/collavre/markdown_converter.rb +240 -0
  63. data/app/services/collavre/mention_parser.rb +63 -0
  64. data/app/services/collavre/orchestration/agent_context_builder.rb +24 -8
  65. data/app/services/collavre/orchestration/agent_orchestrator.rb +59 -10
  66. data/app/services/collavre/orchestration/loop_breaker.rb +12 -7
  67. data/app/services/collavre/orchestration/policy_resolver.rb +16 -2
  68. data/app/services/collavre/orchestration/scheduler.rb +4 -3
  69. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  70. data/app/services/collavre/system_events/context_builder.rb +1 -6
  71. data/app/services/collavre/tools/creative_batch_service.rb +107 -0
  72. data/app/services/collavre/tools/creative_update_service.rb +17 -12
  73. data/app/services/collavre/tools/cron_create_service.rb +17 -5
  74. data/app/views/admin/shared/_tabs.html.erb +2 -1
  75. data/app/views/collavre/admin/orchestration/show.html.erb +11 -0
  76. data/app/views/collavre/admin/settings/_system_tab.html.erb +138 -0
  77. data/app/views/collavre/admin/settings/_uiux_tab.html.erb +44 -0
  78. data/app/views/collavre/admin/settings/index.html.erb +11 -0
  79. data/app/views/collavre/admin/settings/uiux.html.erb +11 -0
  80. data/app/views/collavre/comments/_comment.html.erb +15 -5
  81. data/app/views/collavre/comments/_comments_popup.html.erb +9 -2
  82. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +0 -3
  83. data/app/views/collavre/creatives/_share_button.html.erb +0 -52
  84. data/app/views/collavre/creatives/_share_modal.html.erb +52 -0
  85. data/app/views/collavre/creatives/index.html.erb +5 -8
  86. data/app/views/collavre/shared/navigation/_panels.html.erb +2 -2
  87. data/app/views/collavre/user_themes/index.html.erb +7 -9
  88. data/app/views/collavre/users/_contact_management.html.erb +2 -1
  89. data/app/views/collavre/users/edit_ai.html.erb +7 -0
  90. data/app/views/collavre/users/index.html.erb +16 -1
  91. data/app/views/collavre/users/new_ai.html.erb +18 -8
  92. data/app/views/collavre/users/passkeys.html.erb +1 -1
  93. data/app/views/collavre/users/show.html.erb +1 -1
  94. data/app/views/layouts/collavre/slide.html.erb +8 -1
  95. data/config/locales/admin.en.yml +88 -0
  96. data/config/locales/admin.ko.yml +88 -0
  97. data/config/locales/ai_agent.en.yml +5 -1
  98. data/config/locales/ai_agent.ko.yml +5 -1
  99. data/config/locales/comments.en.yml +5 -1
  100. data/config/locales/comments.ko.yml +5 -1
  101. data/config/locales/orchestration.en.yml +8 -0
  102. data/config/locales/orchestration.ko.yml +8 -0
  103. data/config/locales/users.en.yml +12 -0
  104. data/config/locales/users.ko.yml +12 -0
  105. data/config/routes.rb +7 -1
  106. data/db/migrate/20260212011655_add_quoted_comment_to_comments.rb +7 -0
  107. data/db/migrate/20260213044247_add_agent_conf_to_users.rb +5 -0
  108. data/lib/collavre/engine.rb +25 -0
  109. data/lib/collavre/version.rb +1 -1
  110. metadata +32 -1
@@ -14,12 +14,18 @@ module Collavre
14
14
  belongs_to :approver, class_name: Collavre.configuration.user_class_name, optional: true
15
15
  belongs_to :action_executed_by, class_name: Collavre.configuration.user_class_name, optional: true
16
16
  belongs_to :topic, class_name: "Collavre::Topic", optional: true
17
+ belongs_to :quoted_comment, class_name: "Collavre::Comment", optional: true
17
18
  has_many :activity_logs, class_name: "Collavre::ActivityLog", dependent: :destroy
18
19
  has_many :comment_reactions, class_name: "Collavre::CommentReaction", dependent: :destroy
19
20
 
20
-
21
21
  has_many_attached :images, dependent: :purge_later
22
22
 
23
+ include Broadcastable
24
+ include Notifiable
25
+ include Approvable
26
+
27
+ attribute :skip_default_user, :boolean, default: false
28
+
23
29
  before_validation :use_origin_creative
24
30
  before_validation :assign_default_user, on: :create
25
31
  before_save :apply_link_previews, if: :should_apply_link_previews?
@@ -28,67 +34,17 @@ module Collavre
28
34
  validate :creative_must_be_origin_creative
29
35
  validate :images_must_be_images
30
36
 
31
- after_create_commit :broadcast_create, :notify_write_users, :notify_mentions, :notify_approver, :broadcast_badges
32
- after_update_commit :broadcast_update
33
- after_destroy_commit :broadcast_destroy, :broadcast_badges, :cancel_pending_tasks
37
+ after_destroy_commit :cancel_pending_tasks
38
+
39
+ def review_message?
40
+ quoted_comment_id.present?
41
+ end
34
42
 
35
43
  # public for db migration
36
44
  def creative_snippet
37
45
  creative.creative_snippet
38
46
  end
39
47
 
40
- def can_be_approved_by?(user)
41
- approval_status(user) == :ok
42
- end
43
-
44
- def approval_status(user)
45
- return :not_allowed unless user
46
-
47
- if action.blank?
48
- return :not_allowed unless approver_id == user&.id
49
- return :missing_action
50
- end
51
-
52
- begin
53
- payload = JSON.parse(action)
54
- rescue JSON::ParserError
55
- return :invalid_action_format
56
- end
57
- return :invalid_action_format unless payload.is_a?(Hash)
58
-
59
- actions = Array(payload["actions"])
60
- actions = [ payload ] if actions.empty?
61
-
62
- requires_admin = actions.any? do |item|
63
- next false unless item.is_a?(Hash)
64
- action_type = item["action"] || item["type"]
65
- action_type == "approve_tool"
66
- end
67
-
68
- if requires_admin && SystemSetting.mcp_tool_approval_required?
69
- return user.system_admin? ? :ok : :admin_required
70
- end
71
-
72
- return :missing_approver if approver_id.blank?
73
- return :not_allowed unless approver_id == user&.id
74
-
75
- :ok
76
- end
77
-
78
- def mentioned_users
79
- return Collavre.user_class.none unless user
80
- emails = mentioned_emails - [ user.email.downcase ]
81
- names = mentioned_names - [ user.name.downcase ]
82
-
83
- origin = creative.effective_origin
84
- mentionable_users = Collavre.user_class.mentionable_for(origin)
85
-
86
- scope = Collavre.user_class.none
87
- scope = scope.or(mentionable_users.where(email: emails)) if emails.any?
88
- scope = scope.or(mentionable_users.where("LOWER(name) IN (?)", names)) if names.any?
89
- scope
90
- end
91
-
92
48
  private
93
49
 
94
50
  def cancel_pending_tasks
@@ -99,117 +55,8 @@ module Collavre
99
55
  end
100
56
  end
101
57
 
102
- def create_inbox_item(owner, key, params = {})
103
- origin = creative&.effective_origin
104
- metadata = params.to_h.stringify_keys
105
- metadata["comment_id"] = id
106
- metadata["creative_id"] = origin&.id
107
-
108
- InboxItem.create!(
109
- owner: owner,
110
- message_key: key,
111
- message_params: metadata,
112
- comment: self,
113
- creative: origin,
114
- link: Collavre::Engine.routes.url_helpers.creative_comment_url(
115
- creative,
116
- self,
117
- Rails.application.config.action_mailer.default_url_options
118
- )
119
- )
120
- end
121
-
122
- # AI agent streaming placeholder: skip inbox notifications for "..." content
123
- def streaming_placeholder?
124
- user&.ai_user? && content == STREAMING_PLACEHOLDER_CONTENT
125
- end
126
-
127
- def mentioned_emails
128
- return [] unless content
129
- content.scan(/@([\w.\-+]+@[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,})/)
130
- .flatten
131
- .map(&:downcase)
132
- .uniq
133
- end
134
-
135
- def mentioned_names
136
- return [] unless content
137
- content.scan(/@([^:]+):/)
138
- .flatten
139
- .map(&:downcase)
140
- .uniq
141
- end
142
-
143
- def broadcast_create
144
- return if private?
145
- broadcast_append_later_to([ creative, :comments ], target: "comments-list", partial: "collavre/comments/comment")
146
- end
147
-
148
- def broadcast_update
149
- return if private?
150
- broadcast_replace_later_to([ creative, :comments ], partial: "collavre/comments/comment")
151
- end
152
-
153
- def broadcast_destroy
154
- return if private? || !creative
155
- broadcast_remove_to([ creative, :comments ])
156
- end
157
-
158
- def broadcast_badges
159
- return unless creative
160
- Comment.broadcast_badges(creative)
161
- end
162
-
163
- def notify_write_users
164
- return if private? || !user
165
- return if streaming_placeholder?
166
- base_creative = creative.effective_origin
167
- present_ids = CommentPresenceStore.list(base_creative.id)
168
- recipients = base_creative.all_shared_users(:write).map(&:user)
169
- recipients << base_creative.user
170
- recipients.compact!
171
- recipients.uniq!
172
- recipients.delete(user)
173
- recipients -= mentioned_users.to_a
174
- recipients.reject! { |u| present_ids.include?(u.id) }
175
- recipients.each do |recipient|
176
- create_inbox_item(
177
- recipient,
178
- "inbox.comment_added",
179
- { user: user.display_name, comment: content, creative: creative_snippet }
180
- )
181
- end
182
- end
183
-
184
- def notify_mentions
185
- return if private?
186
- return if streaming_placeholder?
187
- mentioned_users.each do |mentioned|
188
- create_inbox_item(
189
- mentioned,
190
- "inbox.user_mentioned",
191
- { user: user.display_name, comment: content, creative: creative_snippet }
192
- )
193
- end
194
- end
195
-
196
- def notify_approver
197
- return unless approver.present? && action.present?
198
- return if approver == user
199
-
200
- create_inbox_item(
201
- approver,
202
- "inbox.approval_requested",
203
- { user: user&.display_name, tool_name: parsed_action_tool_name, creative: creative_snippet }
204
- )
205
- end
206
-
207
- def parsed_action_tool_name
208
- parsed = JSON.parse(action) rescue nil
209
- parsed&.dig("tool_name") || "unknown"
210
- end
211
-
212
58
  def assign_default_user
59
+ return if skip_default_user
213
60
  self.user ||= Collavre.current_user
214
61
  end
215
62
 
@@ -244,97 +91,5 @@ module Collavre
244
91
  errors.add(:images, "must be an image")
245
92
  invalid_images.each(&:purge)
246
93
  end
247
-
248
- def self.broadcast_badges(creative)
249
- origin = creative.effective_origin
250
- users = [ origin.user ].compact + origin.all_shared_users(:feedback).map(&:user)
251
- users.compact!
252
- users.uniq!
253
- return if users.empty?
254
-
255
- user_ids = users.map(&:id)
256
-
257
- # Batch load all comment read pointers for these users
258
- pointers = CommentReadPointer.where(user_id: user_ids, creative: origin).index_by(&:user_id)
259
-
260
- # Get present users once
261
- present_user_ids = CommentPresenceStore.list(origin.id)
262
-
263
- # Batch count queries - get counts grouped by user visibility
264
- # For public comments (visible to all)
265
- public_count = origin.comments.where(private: false).count
266
-
267
- # For private comments, get counts per user
268
- private_counts = origin.comments
269
- .where(private: true, user_id: user_ids)
270
- .group(:user_id)
271
- .count
272
-
273
- # Batch unread counts - first get the min last_read_id per user
274
- last_read_ids = pointers.transform_values { |p| p.last_read_comment_id || 0 }
275
-
276
- # Get unread public comments for each threshold
277
- unread_public_by_threshold = {}
278
- last_read_ids.values.uniq.each do |threshold|
279
- unread_public_by_threshold[threshold] = origin.comments
280
- .where(private: false)
281
- .where("comments.id > ?", threshold)
282
- .count
283
- end
284
-
285
- # Get unread private comments per user
286
- unread_private_counts = {}
287
- user_ids.each do |uid|
288
- threshold = last_read_ids[uid] || 0
289
- unread_private_counts[uid] = origin.comments
290
- .where(private: true, user_id: uid)
291
- .where("comments.id > ?", threshold)
292
- .count
293
- end
294
-
295
- users.each do |u|
296
- user_private_count = private_counts[u.id] || 0
297
- total_count = public_count + user_private_count
298
-
299
- threshold = last_read_ids[u.id] || 0
300
- unread_public = unread_public_by_threshold[threshold] || 0
301
- unread_private = unread_private_counts[u.id] || 0
302
- unread_count = unread_public + unread_private
303
-
304
- unread_count = 0 if present_user_ids.include?(u.id)
305
-
306
- Turbo::StreamsChannel.broadcast_replace_to(
307
- [ u, origin, :comment_badge ],
308
- target: "comment-badge-#{origin.id}",
309
- partial: "inbox/badge_component/count",
310
- locals: {
311
- count: unread_count,
312
- badge_id: "comment-badge-#{origin.id}",
313
- show_zero: total_count.positive?
314
- }
315
- )
316
- end
317
- end
318
-
319
- def self.broadcast_badge(creative, user)
320
- origin = creative.effective_origin
321
- visible_comments = origin.comments.where("comments.private = ? OR comments.user_id = ?", false, user.id)
322
- comments_count = visible_comments.count
323
- pointer = CommentReadPointer.find_by(user: user, creative: origin)
324
- last_read_id = pointer&.last_read_comment_id
325
- unread_scope = last_read_id ? visible_comments.where("comments.id > ?", last_read_id) : visible_comments
326
- unread_count = unread_scope.count
327
- unread_count = 0 if CommentPresenceStore.list(origin.id).include?(user.id)
328
- Turbo::StreamsChannel.broadcast_replace_to(
329
- [ user, origin, :comment_badge ],
330
- target: "comment-badge-#{origin.id}",
331
- partial: "inbox/badge_component/count",
332
- locals: {
333
- count: unread_count,
334
- badge_id: "comment-badge-#{origin.id}",
335
- show_zero: comments_count.positive?
336
- }
337
- )
338
- end
339
94
  end
340
95
  end
@@ -7,5 +7,20 @@ module Collavre
7
7
 
8
8
  validates :emoji, presence: true, length: { maximum: 16 }
9
9
  validates :user_id, uniqueness: { scope: [ :comment_id, :emoji ] }
10
+
11
+ def self.broadcast_reaction_update(comment)
12
+ creative = comment.creative
13
+ reactions = comment.comment_reactions.reload.to_a
14
+ payload = reactions.group_by(&:emoji).map do |emoji, grouped|
15
+ { emoji: emoji, count: grouped.size, user_ids: grouped.map(&:user_id) }
16
+ end
17
+
18
+ Turbo::StreamsChannel.broadcast_action_to(
19
+ [ creative, :comments ],
20
+ action: "update_reactions",
21
+ target: "comment_#{comment.id}",
22
+ attributes: { data: payload.to_json }
23
+ )
24
+ end
10
25
  end
11
26
  end
@@ -0,0 +1,86 @@
1
+ module Collavre
2
+ class Creative < ApplicationRecord
3
+ module Describable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ validates :description, presence: true, unless: -> { origin_id.present? }
8
+ validate :description_cannot_change_if_has_origin, on: :update
9
+
10
+ before_save :sanitize_description_html
11
+ after_destroy_commit :purge_description_attachments
12
+ end
13
+
14
+ # Linked Creative의 description을 안전하게 반환
15
+ def effective_description(variation_id = nil, html = true)
16
+ if variation_id.present?
17
+ variation_tag = tags.find_by(label_id: variation_id)
18
+ return variation_tag.value if variation_tag&.value.present?
19
+ end
20
+ description_val = origin_id.nil? ? description : origin.description
21
+ if html
22
+ description_val&.to_s || ""
23
+ else
24
+ ActionController::Base.helpers.strip_tags(description_val&.to_s || "")
25
+ end
26
+ end
27
+
28
+ def creative_snippet
29
+ ActionController::Base.helpers.strip_tags(effective_origin.description || "").truncate(24, omission: "...")
30
+ end
31
+
32
+ private
33
+
34
+ def sanitize_description_html
35
+ table_tags = %w[table thead tbody tfoot tr th td]
36
+ table_attrs = %w[colspan rowspan]
37
+ self.description = ActionController::Base.helpers.sanitize(
38
+ description,
39
+ tags: Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + table_tags,
40
+ attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + table_attrs + %w[data-lexical]
41
+ )
42
+ end
43
+
44
+ def purge_description_attachments
45
+ return if description.blank?
46
+
47
+ signed_ids = extract_signed_ids_from_description
48
+ return if signed_ids.empty?
49
+
50
+ signed_ids.each do |signed_id|
51
+ begin
52
+ blob = ActiveStorage::Blob.find_signed(signed_id)
53
+ next unless blob
54
+
55
+ next if Creative.where.not(id: id)
56
+ .where("description LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(signed_id)}%")
57
+ .exists?
58
+
59
+ blob.purge
60
+ rescue ActiveRecord::RecordNotFound, ActiveSupport::MessageVerifier::InvalidSignature
61
+ Rails.logger.warn("Creative##{id}: could not find blob for signed_id=#{signed_id}")
62
+ rescue => e
63
+ Rails.logger.error("Creative##{id}: failed to purge blob #{signed_id}: #{e.message}")
64
+ end
65
+ end
66
+ end
67
+
68
+ def extract_signed_ids_from_description
69
+ return [] if description.blank?
70
+
71
+ html = description.to_s
72
+
73
+ ids = html.scan(%r{/rails/active_storage/blobs/(?:redirect|proxy)/([^/?#]+)}).flatten
74
+ ids += html.scan(%r{/rails/active_storage/blobs/([^/?#]+)}).flatten
75
+
76
+ ids.uniq
77
+ end
78
+
79
+ def description_cannot_change_if_has_origin
80
+ if origin_id.present? && will_save_change_to_description?
81
+ errors.add(:description, "cannot be changed directly when linked to an origin")
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,77 @@
1
+ module Collavre
2
+ class Creative < ApplicationRecord
3
+ module Linkable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ belongs_to :origin, class_name: "Collavre::Creative", optional: true
8
+ has_many :linked_creatives, class_name: "Collavre::Creative", foreign_key: :origin_id, dependent: :destroy
9
+
10
+ validate :origin_cannot_be_self
11
+ before_validation :redirect_parent_to_origin
12
+ end
13
+
14
+ # Returns the effective attribute for linked creatives
15
+ def effective_attribute(attr, visited_ids = Set.new)
16
+ return self[attr] if origin_id.nil? || attr.to_s == "parent_id"
17
+ return self[attr] if visited_ids.include?(id)
18
+
19
+ visited_ids.add(id)
20
+ origin.effective_attribute(attr, visited_ids)
21
+ end
22
+
23
+ def effective_origin(visited_ids = Set.new)
24
+ return self if origin_id.nil?
25
+ return self if visited_ids.include?(id)
26
+
27
+ visited_ids.add(id)
28
+ origin.effective_origin(visited_ids)
29
+ end
30
+
31
+ def linked_children
32
+ origin_id.nil? ? children_with_permission(Collavre.current_user, :read) : origin&.children_with_permission(Collavre.current_user, :read) || []
33
+ end
34
+
35
+ def progress
36
+ effective_attribute(:progress, Set.new)
37
+ end
38
+
39
+ def user
40
+ target = effective_origin(Set.new)
41
+ return super if target == self
42
+
43
+ target.user
44
+ end
45
+
46
+ # 공유 대상 사용자를 위해 Linked Creative를 생성합니다.
47
+ def create_linked_creative_for_user(user)
48
+ original = effective_origin(Set.new)
49
+ return if original.user_id == user.id
50
+ ancestor_ids = original.ancestors.pluck(:id)
51
+ has_ancestor_share = CreativeShare.where(creative_id: ancestor_ids, user_id: user.id)
52
+ .where.not(permission: :no_access)
53
+ .exists?
54
+ has_owning_ancestors = Creative.where(id: ancestor_ids, user_id: user.id)
55
+ .exists?
56
+ return if has_ancestor_share or has_owning_ancestors
57
+ Creative.find_or_create_by!(origin_id: original.id, user_id: user.id) do |c|
58
+ c.parent_id = nil
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def redirect_parent_to_origin
65
+ if parent&.origin_id.present?
66
+ self.parent = parent.origin
67
+ end
68
+ end
69
+
70
+ def origin_cannot_be_self
71
+ if origin_id.present? && origin_id == id
72
+ errors.add(:origin_id, "cannot be the same as id")
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,103 @@
1
+ module Collavre
2
+ class Creative < ApplicationRecord
3
+ module Permissible
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ has_many :creative_shares, class_name: "Collavre::CreativeShare", dependent: :destroy
8
+ has_many :creative_shares_caches, class_name: "Collavre::CreativeSharesCache", dependent: :delete_all
9
+
10
+ after_commit :rebuild_permission_cache, if: :saved_change_to_parent_id?, unless: :destroyed?
11
+ after_commit :cache_owner_permission, on: :create
12
+ after_commit :update_owner_cache, if: :saved_change_to_user_id?, unless: :destroyed?
13
+ end
14
+
15
+ def has_permission?(user, required_permission = :read)
16
+ Collavre::Creatives::PermissionChecker.new(self, user).allowed?(required_permission)
17
+ end
18
+
19
+ # Returns only children for which the user has at least the given permission
20
+ def children_with_permission(user = nil, min_permission = :read)
21
+ user ||= Collavre.current_user
22
+ children_scope = effective_origin(Set.new).children
23
+ children_ids = children_scope.pluck(:id)
24
+ return [] if children_ids.empty?
25
+
26
+ min_rank = CreativeShare.permissions[min_permission.to_s]
27
+ accessible_ids = Set.new
28
+
29
+ if user
30
+ user_entries = CreativeSharesCache
31
+ .where(creative_id: children_ids, user_id: user.id)
32
+ .pluck(:creative_id, :permission)
33
+
34
+ user_has_entry = Set.new
35
+ user_entries.each do |cid, perm|
36
+ user_has_entry << cid
37
+ perm_rank = CreativeSharesCache.permissions[perm]
38
+ if perm_rank && perm_rank >= min_rank && perm_rank != CreativeSharesCache.permissions[:no_access]
39
+ accessible_ids << cid
40
+ end
41
+ end
42
+
43
+ public_accessible = CreativeSharesCache
44
+ .where(creative_id: children_ids, user_id: nil)
45
+ .where("permission >= ?", min_rank)
46
+ .where.not(permission: :no_access)
47
+ .pluck(:creative_id)
48
+ accessible_ids.merge(public_accessible - user_has_entry.to_a)
49
+
50
+ owned_ids = children_scope.where(user_id: user.id).pluck(:id)
51
+ accessible_ids.merge(owned_ids)
52
+ else
53
+ accessible_ids = CreativeSharesCache
54
+ .where(creative_id: children_ids, user_id: nil)
55
+ .where("permission >= ?", min_rank)
56
+ .where.not(permission: :no_access)
57
+ .pluck(:creative_id)
58
+ .to_set
59
+ end
60
+
61
+ children_scope.where(id: accessible_ids.to_a).order(:sequence).to_a
62
+ end
63
+
64
+ def all_shared_users(required_permission = :no_access)
65
+ base_creative = effective_origin(Set.new)
66
+ ancestor_ids = [ base_creative.id ] + base_creative.ancestors.pluck(:id)
67
+ required_permission_level = CreativeShare.permissions.fetch(required_permission.to_s)
68
+
69
+ shares = CreativeShare.where(creative_id: ancestor_ids).includes(:user)
70
+ shares_for_user_hash = shares.group_by(&:user_id)
71
+
72
+ shares_for_user_hash.filter_map do |_user_id, user_shares|
73
+ closest_share = CreativeShare.closest_parent_share(ancestor_ids, user_shares)
74
+ next unless closest_share
75
+
76
+ closest_permission_level = CreativeShare.permissions.fetch(closest_share.permission.to_s)
77
+ next if closest_permission_level < required_permission_level
78
+
79
+ closest_share
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def rebuild_permission_cache
86
+ PermissionCacheJob.perform_later(:rebuild_for_creative, creative_id: id)
87
+ end
88
+
89
+ def cache_owner_permission
90
+ PermissionCacheJob.perform_later(:cache_owner, creative_id: id)
91
+ end
92
+
93
+ def update_owner_cache
94
+ old_user_id, new_user_id = saved_change_to_user_id
95
+ PermissionCacheJob.perform_later(:update_owner,
96
+ creative_id: id,
97
+ old_user_id: old_user_id,
98
+ new_user_id: new_user_id
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end