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
|
@@ -6,25 +6,35 @@ module Collavre
|
|
|
6
6
|
# agent orchestration pipeline.
|
|
7
7
|
#
|
|
8
8
|
# Created dynamically via the cron_create MCP tool.
|
|
9
|
+
# topic_id can be nil for the main topic.
|
|
9
10
|
class CronActionJob < ApplicationJob
|
|
10
11
|
queue_as :default
|
|
11
12
|
|
|
12
13
|
def perform(creative_id:, topic_id:, agent_id:, message:)
|
|
13
14
|
creative = Creative.find_by(id: creative_id)
|
|
14
|
-
topic = Topic.find_by(id: topic_id)
|
|
15
15
|
agent = User.find_by(id: agent_id)
|
|
16
16
|
|
|
17
|
-
unless creative &&
|
|
17
|
+
unless creative && agent
|
|
18
18
|
Rails.logger.warn(
|
|
19
|
-
"[CronActionJob] Skipping: creative=#{creative_id}
|
|
19
|
+
"[CronActionJob] Skipping: creative=#{creative_id} agent=#{agent_id} - record not found"
|
|
20
20
|
)
|
|
21
21
|
return
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# topic_id nil means main topic; otherwise look up the topic
|
|
25
|
+
topic = nil
|
|
26
|
+
if topic_id.present?
|
|
27
|
+
topic = Topic.find_by(id: topic_id)
|
|
28
|
+
unless topic
|
|
29
|
+
Rails.logger.warn("[CronActionJob] Skipping: topic=#{topic_id} not found")
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
24
34
|
comment = creative.comments.create!(
|
|
25
35
|
content: message,
|
|
26
36
|
user: agent,
|
|
27
|
-
topic_id: topic
|
|
37
|
+
topic_id: topic&.id,
|
|
28
38
|
private: false
|
|
29
39
|
)
|
|
30
40
|
|
|
@@ -40,14 +50,16 @@ module Collavre
|
|
|
40
50
|
description: creative.description
|
|
41
51
|
},
|
|
42
52
|
topic: {
|
|
43
|
-
id: topic
|
|
53
|
+
id: topic&.id
|
|
44
54
|
},
|
|
45
55
|
chat: {
|
|
46
56
|
content: comment.content
|
|
47
57
|
}
|
|
48
58
|
})
|
|
49
59
|
|
|
50
|
-
Rails.logger.info(
|
|
60
|
+
Rails.logger.info(
|
|
61
|
+
"[CronActionJob] Posted cron message to creative #{creative_id}, topic #{topic_id || 'main'}"
|
|
62
|
+
)
|
|
51
63
|
end
|
|
52
64
|
end
|
|
53
65
|
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# CronSchedulerJob is a self-scheduling job that runs every minute and
|
|
5
|
+
# picks up dynamically-created recurring tasks (static: false) whose
|
|
6
|
+
# cron schedule matches the current minute. For each match it enqueues
|
|
7
|
+
# the target job (e.g. CronActionJob).
|
|
8
|
+
#
|
|
9
|
+
# This exists because Solid Queue's built-in Scheduler only processes
|
|
10
|
+
# static (config-defined) recurring tasks — dynamic ones created via
|
|
11
|
+
# cron_create are ignored by the Scheduler.
|
|
12
|
+
#
|
|
13
|
+
# Self-scheduling: after each run, the job re-enqueues itself with
|
|
14
|
+
# `wait: 1.minute`. Boot-time scheduling is handled by the engine
|
|
15
|
+
# initializer (see collavre/engine.rb).
|
|
16
|
+
#
|
|
17
|
+
# Duplicate-execution guard: uses Rails.cache with a per-task, per-minute
|
|
18
|
+
# key so that even if this job runs twice in the same minute window,
|
|
19
|
+
# each task fires at most once. (This cache is process-local and acceptable
|
|
20
|
+
# since CronSchedulerJob runs in a single SQ worker process.)
|
|
21
|
+
#
|
|
22
|
+
# Reschedule guard: uses SolidQueue::Job table (DB-based) to prevent
|
|
23
|
+
# duplicate scheduler chains — works across all environments without
|
|
24
|
+
# shared cache infrastructure.
|
|
25
|
+
class CronSchedulerJob < ApplicationJob
|
|
26
|
+
queue_as :default
|
|
27
|
+
|
|
28
|
+
def perform
|
|
29
|
+
now = Time.current
|
|
30
|
+
dynamic_tasks = SolidQueue::RecurringTask.where(static: false)
|
|
31
|
+
|
|
32
|
+
dynamic_tasks.find_each do |task|
|
|
33
|
+
next unless schedule_matches?(task, now)
|
|
34
|
+
next if already_dispatched?(task, now)
|
|
35
|
+
|
|
36
|
+
enqueue_task(task)
|
|
37
|
+
mark_dispatched(task, now)
|
|
38
|
+
end
|
|
39
|
+
ensure
|
|
40
|
+
reschedule
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def schedule_matches?(task, time)
|
|
46
|
+
cron = Fugit.parse(task.schedule)
|
|
47
|
+
return false unless cron.is_a?(Fugit::Cron)
|
|
48
|
+
|
|
49
|
+
# Truncate to the beginning of the current minute so that
|
|
50
|
+
# cron.match? works regardless of which second the job runs at.
|
|
51
|
+
minute_start = time.change(sec: 0)
|
|
52
|
+
cron.match?(minute_start)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cache_key(task, time)
|
|
56
|
+
minute = time.strftime("%Y%m%d%H%M")
|
|
57
|
+
"cron_scheduler:#{task.key}:#{minute}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def already_dispatched?(task, time)
|
|
61
|
+
Rails.cache.read(cache_key(task, time)).present?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def mark_dispatched(task, time)
|
|
65
|
+
Rails.cache.write(cache_key(task, time), true, expires_in: 2.minutes)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def enqueue_task(task)
|
|
69
|
+
job_class = task.class_name.safe_constantize
|
|
70
|
+
unless job_class
|
|
71
|
+
Rails.logger.warn("[CronScheduler] Unknown job class: #{task.class_name} for task #{task.key}")
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
args = task.arguments || []
|
|
76
|
+
if args.is_a?(Array) && args.first.is_a?(Hash)
|
|
77
|
+
job_class.perform_later(**args.first.symbolize_keys)
|
|
78
|
+
elsif args.is_a?(Array)
|
|
79
|
+
job_class.perform_later(*args)
|
|
80
|
+
else
|
|
81
|
+
job_class.perform_later
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Rails.logger.info("[CronScheduler] Enqueued #{task.class_name} for task #{task.key}")
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
Rails.logger.error("[CronScheduler] Failed to enqueue #{task.key}: #{e.message}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def reschedule
|
|
90
|
+
# DB-based guard: skip if a CronSchedulerJob is already pending
|
|
91
|
+
return if pending_scheduler_exists?
|
|
92
|
+
|
|
93
|
+
self.class.set(wait: 1.minute).perform_later
|
|
94
|
+
Rails.logger.info("[CronScheduler] Rescheduled for next minute")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def pending_scheduler_exists?
|
|
98
|
+
scope = SolidQueue::Job
|
|
99
|
+
.where(class_name: self.class.name)
|
|
100
|
+
.where(finished_at: nil)
|
|
101
|
+
|
|
102
|
+
# Exclude the currently running job to avoid blocking our own reschedule
|
|
103
|
+
scope = scope.where.not(id: provider_job_id) if provider_job_id.present?
|
|
104
|
+
|
|
105
|
+
# Exclude failed jobs — they won't run again without manual retry
|
|
106
|
+
failed_job_ids = SolidQueue::FailedExecution.pluck(:job_id)
|
|
107
|
+
scope = scope.where.not(id: failed_job_ids) if failed_job_ids.any?
|
|
108
|
+
|
|
109
|
+
scope.exists?
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
class Comment < ApplicationRecord
|
|
3
|
+
module Approvable
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
def can_be_approved_by?(user)
|
|
7
|
+
approval_status(user) == :ok
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def approval_status(user)
|
|
11
|
+
return :not_allowed unless user
|
|
12
|
+
|
|
13
|
+
if action.blank?
|
|
14
|
+
return :not_allowed unless approver_id == user&.id
|
|
15
|
+
return :missing_action
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
payload = JSON.parse(action)
|
|
20
|
+
rescue JSON::ParserError
|
|
21
|
+
return :invalid_action_format
|
|
22
|
+
end
|
|
23
|
+
return :invalid_action_format unless payload.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
actions = Array(payload["actions"])
|
|
26
|
+
actions = [ payload ] if actions.empty?
|
|
27
|
+
|
|
28
|
+
requires_admin = actions.any? do |item|
|
|
29
|
+
next false unless item.is_a?(Hash)
|
|
30
|
+
action_type = item["action"] || item["type"]
|
|
31
|
+
action_type == "approve_tool"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if requires_admin && SystemSetting.mcp_tool_approval_required?
|
|
35
|
+
return user.system_admin? ? :ok : :admin_required
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
return :missing_approver if approver_id.blank?
|
|
39
|
+
return :not_allowed unless approver_id == user&.id
|
|
40
|
+
|
|
41
|
+
:ok
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parsed_action_tool_name
|
|
45
|
+
parsed = JSON.parse(action) rescue nil
|
|
46
|
+
parsed&.dig("tool_name") || "unknown"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
class Comment < ApplicationRecord
|
|
3
|
+
module Broadcastable
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
after_create_commit :broadcast_create, :broadcast_badges
|
|
8
|
+
after_update_commit :broadcast_update
|
|
9
|
+
after_destroy_commit :broadcast_destroy, :broadcast_badges
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def broadcast_badges(creative)
|
|
14
|
+
origin = creative.effective_origin
|
|
15
|
+
users = [ origin.user ].compact + origin.all_shared_users(:feedback).map(&:user)
|
|
16
|
+
users.compact!
|
|
17
|
+
users.uniq!
|
|
18
|
+
return if users.empty?
|
|
19
|
+
|
|
20
|
+
user_ids = users.map(&:id)
|
|
21
|
+
|
|
22
|
+
pointers = CommentReadPointer.where(user_id: user_ids, creative: origin).index_by(&:user_id)
|
|
23
|
+
present_user_ids = CommentPresenceStore.list(origin.id)
|
|
24
|
+
|
|
25
|
+
public_count = origin.comments.where(private: false).count
|
|
26
|
+
private_counts = origin.comments
|
|
27
|
+
.where(private: true, user_id: user_ids)
|
|
28
|
+
.group(:user_id)
|
|
29
|
+
.count
|
|
30
|
+
|
|
31
|
+
last_read_ids = pointers.transform_values { |p| p.last_read_comment_id || 0 }
|
|
32
|
+
|
|
33
|
+
unread_public_by_threshold = {}
|
|
34
|
+
last_read_ids.values.uniq.each do |threshold|
|
|
35
|
+
unread_public_by_threshold[threshold] = origin.comments
|
|
36
|
+
.where(private: false)
|
|
37
|
+
.where("comments.id > ?", threshold)
|
|
38
|
+
.count
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
unread_private_counts = {}
|
|
42
|
+
user_ids.each do |uid|
|
|
43
|
+
threshold = last_read_ids[uid] || 0
|
|
44
|
+
unread_private_counts[uid] = origin.comments
|
|
45
|
+
.where(private: true, user_id: uid)
|
|
46
|
+
.where("comments.id > ?", threshold)
|
|
47
|
+
.count
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
users.each do |u|
|
|
51
|
+
user_private_count = private_counts[u.id] || 0
|
|
52
|
+
total_count = public_count + user_private_count
|
|
53
|
+
|
|
54
|
+
threshold = last_read_ids[u.id] || 0
|
|
55
|
+
unread_public = unread_public_by_threshold[threshold] || 0
|
|
56
|
+
unread_private = unread_private_counts[u.id] || 0
|
|
57
|
+
unread_count = unread_public + unread_private
|
|
58
|
+
|
|
59
|
+
unread_count = 0 if present_user_ids.include?(u.id)
|
|
60
|
+
|
|
61
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
62
|
+
[ u, origin, :comment_badge ],
|
|
63
|
+
target: "comment-badge-#{origin.id}",
|
|
64
|
+
partial: "inbox/badge_component/count",
|
|
65
|
+
locals: {
|
|
66
|
+
count: unread_count,
|
|
67
|
+
badge_id: "comment-badge-#{origin.id}",
|
|
68
|
+
show_zero: total_count.positive?
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def broadcast_badge(creative, user)
|
|
75
|
+
origin = creative.effective_origin
|
|
76
|
+
visible_comments = origin.comments.where("comments.private = ? OR comments.user_id = ?", false, user.id)
|
|
77
|
+
comments_count = visible_comments.count
|
|
78
|
+
pointer = CommentReadPointer.find_by(user: user, creative: origin)
|
|
79
|
+
last_read_id = pointer&.last_read_comment_id
|
|
80
|
+
unread_scope = last_read_id ? visible_comments.where("comments.id > ?", last_read_id) : visible_comments
|
|
81
|
+
unread_count = unread_scope.count
|
|
82
|
+
unread_count = 0 if CommentPresenceStore.list(origin.id).include?(user.id)
|
|
83
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
84
|
+
[ user, origin, :comment_badge ],
|
|
85
|
+
target: "comment-badge-#{origin.id}",
|
|
86
|
+
partial: "inbox/badge_component/count",
|
|
87
|
+
locals: {
|
|
88
|
+
count: unread_count,
|
|
89
|
+
badge_id: "comment-badge-#{origin.id}",
|
|
90
|
+
show_zero: comments_count.positive?
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def broadcast_create
|
|
99
|
+
return if private?
|
|
100
|
+
broadcast_append_later_to([ creative, :comments ], target: "comments-list", partial: "collavre/comments/comment")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def broadcast_update
|
|
104
|
+
return if private?
|
|
105
|
+
broadcast_replace_later_to([ creative, :comments ], partial: "collavre/comments/comment")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def broadcast_destroy
|
|
109
|
+
return if private? || !creative
|
|
110
|
+
broadcast_remove_to([ creative, :comments ])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def broadcast_badges
|
|
114
|
+
return unless creative
|
|
115
|
+
self.class.broadcast_badges(creative)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
class Comment < ApplicationRecord
|
|
3
|
+
module Notifiable
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
after_create_commit :notify_write_users, :notify_mentions, :notify_approver
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def mentioned_users
|
|
11
|
+
return Collavre.user_class.none unless user
|
|
12
|
+
emails = mentioned_emails - [ user.email.downcase ]
|
|
13
|
+
names = mentioned_names - [ user.name.downcase ]
|
|
14
|
+
|
|
15
|
+
origin = creative.effective_origin
|
|
16
|
+
mentionable_users = Collavre.user_class.mentionable_for(origin)
|
|
17
|
+
|
|
18
|
+
scope = Collavre.user_class.none
|
|
19
|
+
scope = scope.or(mentionable_users.where(email: emails)) if emails.any?
|
|
20
|
+
scope = scope.or(mentionable_users.where("LOWER(name) IN (?)", names)) if names.any?
|
|
21
|
+
scope
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def create_inbox_item(owner, key, params = {})
|
|
27
|
+
origin = creative&.effective_origin
|
|
28
|
+
metadata = params.to_h.stringify_keys
|
|
29
|
+
metadata["comment_id"] = id
|
|
30
|
+
metadata["creative_id"] = origin&.id
|
|
31
|
+
|
|
32
|
+
InboxItem.create!(
|
|
33
|
+
owner: owner,
|
|
34
|
+
message_key: key,
|
|
35
|
+
message_params: metadata,
|
|
36
|
+
comment: self,
|
|
37
|
+
creative: origin,
|
|
38
|
+
link: Collavre::Engine.routes.url_helpers.creative_comment_url(
|
|
39
|
+
creative,
|
|
40
|
+
self,
|
|
41
|
+
Rails.application.config.action_mailer.default_url_options
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def streaming_placeholder?
|
|
47
|
+
user&.ai_user? && content == STREAMING_PLACEHOLDER_CONTENT
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def mentioned_emails
|
|
51
|
+
return [] unless content
|
|
52
|
+
content.scan(/@([\w.\-+]+@[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,})/)
|
|
53
|
+
.flatten
|
|
54
|
+
.map(&:downcase)
|
|
55
|
+
.uniq
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def mentioned_names
|
|
59
|
+
return [] unless content
|
|
60
|
+
content.scan(/@([^:]+):/)
|
|
61
|
+
.flatten
|
|
62
|
+
.map(&:downcase)
|
|
63
|
+
.uniq
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def notify_write_users
|
|
67
|
+
return if private? || !user
|
|
68
|
+
return if streaming_placeholder?
|
|
69
|
+
base_creative = creative.effective_origin
|
|
70
|
+
present_ids = CommentPresenceStore.list(base_creative.id)
|
|
71
|
+
recipients = base_creative.all_shared_users(:write).map(&:user)
|
|
72
|
+
recipients << base_creative.user
|
|
73
|
+
recipients.compact!
|
|
74
|
+
recipients.uniq!
|
|
75
|
+
recipients.delete(user)
|
|
76
|
+
recipients -= mentioned_users.to_a
|
|
77
|
+
recipients.reject! { |u| present_ids.include?(u.id) }
|
|
78
|
+
recipients.each do |recipient|
|
|
79
|
+
create_inbox_item(
|
|
80
|
+
recipient,
|
|
81
|
+
"inbox.comment_added",
|
|
82
|
+
{ user: user.display_name, comment: content, creative: creative_snippet }
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def notify_mentions
|
|
88
|
+
return if private?
|
|
89
|
+
return if streaming_placeholder?
|
|
90
|
+
mentioned_users.each do |mentioned|
|
|
91
|
+
create_inbox_item(
|
|
92
|
+
mentioned,
|
|
93
|
+
"inbox.user_mentioned",
|
|
94
|
+
{ user: user.display_name, comment: content, creative: creative_snippet }
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def notify_approver
|
|
100
|
+
return unless approver.present? && action.present?
|
|
101
|
+
return if approver == user
|
|
102
|
+
|
|
103
|
+
create_inbox_item(
|
|
104
|
+
approver,
|
|
105
|
+
"inbox.approval_requested",
|
|
106
|
+
{ user: user&.display_name, tool_name: parsed_action_tool_name, creative: creative_snippet }
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|