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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/actiontext.css +73 -71
- data/app/assets/stylesheets/collavre/activity_logs.css +18 -45
- data/app/assets/stylesheets/collavre/comments_popup.css +197 -35
- data/app/assets/stylesheets/collavre/creatives.css +101 -51
- data/app/assets/stylesheets/collavre/dark_mode.css +221 -88
- data/app/assets/stylesheets/collavre/design_tokens.css +334 -0
- data/app/assets/stylesheets/collavre/mention_menu.css +13 -9
- data/app/assets/stylesheets/collavre/popup.css +57 -27
- data/app/assets/stylesheets/collavre/slide_view.css +6 -6
- data/app/assets/stylesheets/collavre/user_menu.css +4 -5
- data/app/components/collavre/plans_timeline_component.html.erb +2 -2
- data/app/controllers/collavre/admin/orchestration_controller.rb +9 -2
- data/app/controllers/collavre/admin/settings_controller.rb +199 -0
- data/app/controllers/collavre/comments/reactions_controller.rb +1 -9
- data/app/controllers/collavre/comments_controller.rb +39 -162
- data/app/controllers/collavre/creatives_controller.rb +18 -58
- data/app/controllers/collavre/users_controller.rb +31 -3
- data/app/helpers/collavre/application_helper.rb +97 -0
- data/app/helpers/collavre/creatives_helper.rb +10 -202
- data/app/javascript/collavre.js +0 -1
- data/app/javascript/components/creative_tree_row.js +3 -2
- data/app/javascript/controllers/comment_controller.js +309 -4
- data/app/javascript/controllers/comments/form_controller.js +52 -0
- data/app/javascript/controllers/comments/presence_controller.js +13 -0
- data/app/javascript/controllers/creatives/tree_controller.js +2 -1
- data/app/javascript/controllers/link_creative_controller.js +29 -3
- data/app/javascript/lib/__tests__/html_code_block_wrapper.test.js +201 -0
- data/app/javascript/lib/html_code_block_wrapper.js +168 -0
- data/app/javascript/lib/utils/markdown.js +2 -1
- data/app/javascript/modules/creative_row_editor.js +5 -1
- data/app/javascript/utils/emoji_parser.js +21 -0
- data/app/jobs/collavre/ai_agent_job.rb +6 -2
- data/app/jobs/collavre/cron_action_job.rb +18 -6
- data/app/jobs/collavre/cron_scheduler_job.rb +112 -0
- data/app/models/collavre/comment/approvable.rb +50 -0
- data/app/models/collavre/comment/broadcastable.rb +119 -0
- data/app/models/collavre/comment/notifiable.rb +111 -0
- data/app/models/collavre/comment.rb +13 -258
- data/app/models/collavre/comment_reaction.rb +15 -0
- data/app/models/collavre/creative/describable.rb +86 -0
- data/app/models/collavre/creative/linkable.rb +77 -0
- data/app/models/collavre/creative/permissible.rb +103 -0
- data/app/models/collavre/creative.rb +3 -289
- data/app/models/collavre/orchestrator_policy.rb +1 -1
- data/app/models/collavre/system_setting.rb +27 -1
- data/app/models/collavre/user.rb +42 -0
- data/app/models/collavre/user_theme.rb +10 -0
- data/app/services/collavre/ai_agent/approval_handler.rb +110 -0
- data/app/services/collavre/ai_agent/message_builder.rb +129 -0
- data/app/services/collavre/ai_agent/review_handler.rb +70 -0
- data/app/services/collavre/ai_agent_service.rb +93 -150
- data/app/services/collavre/ai_client.rb +23 -4
- data/app/services/collavre/auto_theme_generator.rb +168 -50
- data/app/services/collavre/command_menu_service.rb +70 -0
- data/app/services/collavre/comment_move_service.rb +94 -0
- data/app/services/collavre/comments/action_executor.rb +10 -0
- data/app/services/collavre/comments/mcp_command.rb +1 -2
- data/app/services/collavre/creatives/create_service.rb +86 -0
- data/app/services/collavre/creatives/destroy_service.rb +41 -0
- data/app/services/collavre/creatives/index_query.rb +3 -0
- data/app/services/collavre/markdown_converter.rb +240 -0
- data/app/services/collavre/mention_parser.rb +63 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +24 -8
- data/app/services/collavre/orchestration/agent_orchestrator.rb +59 -10
- data/app/services/collavre/orchestration/loop_breaker.rb +12 -7
- data/app/services/collavre/orchestration/policy_resolver.rb +16 -2
- data/app/services/collavre/orchestration/scheduler.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -6
- data/app/services/collavre/tools/creative_batch_service.rb +107 -0
- data/app/services/collavre/tools/creative_update_service.rb +17 -12
- data/app/services/collavre/tools/cron_create_service.rb +17 -5
- data/app/views/admin/shared/_tabs.html.erb +2 -1
- data/app/views/collavre/admin/orchestration/show.html.erb +11 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +138 -0
- data/app/views/collavre/admin/settings/_uiux_tab.html.erb +44 -0
- data/app/views/collavre/admin/settings/index.html.erb +11 -0
- data/app/views/collavre/admin/settings/uiux.html.erb +11 -0
- data/app/views/collavre/comments/_comment.html.erb +15 -5
- data/app/views/collavre/comments/_comments_popup.html.erb +9 -2
- data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +0 -3
- data/app/views/collavre/creatives/_share_button.html.erb +0 -52
- data/app/views/collavre/creatives/_share_modal.html.erb +52 -0
- data/app/views/collavre/creatives/index.html.erb +5 -8
- data/app/views/collavre/shared/navigation/_panels.html.erb +2 -2
- data/app/views/collavre/user_themes/index.html.erb +7 -9
- data/app/views/collavre/users/_contact_management.html.erb +2 -1
- data/app/views/collavre/users/edit_ai.html.erb +7 -0
- data/app/views/collavre/users/index.html.erb +16 -1
- data/app/views/collavre/users/new_ai.html.erb +18 -8
- data/app/views/collavre/users/passkeys.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +1 -1
- data/app/views/layouts/collavre/slide.html.erb +8 -1
- data/config/locales/admin.en.yml +88 -0
- data/config/locales/admin.ko.yml +88 -0
- data/config/locales/ai_agent.en.yml +5 -1
- data/config/locales/ai_agent.ko.yml +5 -1
- data/config/locales/comments.en.yml +5 -1
- data/config/locales/comments.ko.yml +5 -1
- data/config/locales/orchestration.en.yml +8 -0
- data/config/locales/orchestration.ko.yml +8 -0
- data/config/locales/users.en.yml +12 -0
- data/config/locales/users.ko.yml +12 -0
- data/config/routes.rb +7 -1
- data/db/migrate/20260212011655_add_quoted_comment_to_comments.rb +7 -0
- data/db/migrate/20260213044247_add_agent_conf_to_users.rb +5 -0
- data/lib/collavre/engine.rb +25 -0
- data/lib/collavre/version.rb +1 -1
- 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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|