collavre 0.5.0 → 0.7.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/comment_versions.css +76 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
- data/app/assets/stylesheets/collavre/creatives.css +73 -1
- data/app/assets/stylesheets/collavre/org_chart.css +319 -0
- data/app/assets/stylesheets/collavre/popup.css +68 -1
- data/app/controllers/collavre/application_controller.rb +13 -0
- data/app/controllers/collavre/comments/versions_controller.rb +82 -0
- data/app/controllers/collavre/comments_controller.rb +14 -153
- data/app/controllers/collavre/concerns/exportable.rb +30 -0
- data/app/controllers/collavre/concerns/shareable.rb +28 -0
- data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
- data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
- data/app/controllers/collavre/creative_imports_controller.rb +6 -0
- data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
- data/app/controllers/collavre/creative_plans_controller.rb +1 -1
- data/app/controllers/collavre/creative_shares_controller.rb +84 -14
- data/app/controllers/collavre/creatives_controller.rb +70 -194
- data/app/controllers/collavre/google_auth_controller.rb +3 -0
- data/app/controllers/collavre/invites_controller.rb +2 -1
- data/app/controllers/collavre/sessions_controller.rb +3 -0
- data/app/controllers/collavre/topics_controller.rb +39 -2
- data/app/controllers/collavre/users_controller.rb +5 -404
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
- data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
- data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
- data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
- data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
- data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
- data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +12 -9
- data/app/helpers/collavre/navigation_helper.rb +1 -1
- data/app/javascript/collavre.js +0 -1
- data/app/javascript/controllers/comment_controller.js +33 -70
- data/app/javascript/controllers/comment_version_controller.js +164 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
- data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
- data/app/javascript/controllers/comments/contexts_controller.js +363 -0
- data/app/javascript/controllers/comments/form_controller.js +304 -13
- data/app/javascript/controllers/comments/list_controller.js +151 -62
- data/app/javascript/controllers/comments/popup_controller.js +66 -38
- data/app/javascript/controllers/comments/presence_controller.js +2 -10
- data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
- data/app/javascript/controllers/comments/topics_controller.js +34 -10
- data/app/javascript/controllers/index.js +15 -1
- data/app/javascript/controllers/org_chart_controller.js +46 -0
- data/app/javascript/controllers/share_modal_controller.js +369 -0
- data/app/javascript/controllers/topic_search_controller.js +103 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
- data/app/javascript/lib/api/creatives.js +12 -0
- data/app/javascript/lib/api/csrf_fetch.js +35 -0
- data/app/javascript/lib/api/drag_drop.js +17 -0
- data/app/javascript/modules/command_menu.js +40 -0
- data/app/javascript/modules/creative_row_editor.js +88 -0
- data/app/javascript/modules/slide_view.js +2 -1
- data/app/jobs/collavre/ai_agent_job.rb +42 -30
- data/app/jobs/collavre/compress_job.rb +92 -0
- data/app/models/collavre/comment.rb +36 -1
- data/app/models/collavre/comment_version.rb +15 -0
- data/app/models/collavre/creative/describable.rb +1 -1
- data/app/models/collavre/creative.rb +51 -0
- data/app/models/collavre/task.rb +30 -2
- data/app/models/collavre/user.rb +20 -3
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
- data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
- data/app/services/collavre/ai_agent/message_builder.rb +85 -6
- data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
- data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
- data/app/services/collavre/ai_agent/review_handler.rb +18 -1
- data/app/services/collavre/ai_agent_service.rb +130 -183
- data/app/services/collavre/ai_client.rb +6 -0
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/command_menu_service.rb +19 -0
- data/app/services/collavre/comments/command_processor.rb +3 -1
- data/app/services/collavre/comments/compress_command.rb +75 -0
- data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
- data/app/services/collavre/comments/work_command.rb +161 -0
- data/app/services/collavre/comments/workflow_executor.rb +276 -0
- data/app/services/collavre/creatives/plan_tagger.rb +14 -3
- data/app/services/collavre/creatives/tree_formatter.rb +53 -13
- data/app/services/collavre/gemini_parent_recommender.rb +4 -4
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
- data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
- data/app/services/collavre/orchestration/scheduler.rb +3 -2
- data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
- data/app/services/collavre/system_events/dispatcher.rb +9 -0
- data/app/services/collavre/tools/creative_create_service.rb +1 -8
- data/app/services/collavre/tools/creative_import_service.rb +46 -0
- data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
- data/app/services/collavre/tools/creative_update_service.rb +1 -8
- data/app/services/collavre/tools/cron_list_service.rb +1 -1
- data/app/services/collavre/tools/description_normalizable.rb +16 -0
- data/app/views/collavre/comments/_comment.html.erb +25 -8
- data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
- data/app/views/collavre/creatives/_share_button.html.erb +4 -1
- data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
- data/app/views/collavre/creatives/index.html.erb +5 -5
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
- data/app/views/collavre/users/_org_chart.html.erb +68 -0
- data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
- data/app/views/collavre/users/new_ai.html.erb +9 -0
- data/app/views/collavre/users/show.html.erb +32 -8
- data/config/locales/comments.en.yml +57 -2
- data/config/locales/comments.ko.yml +57 -2
- data/config/locales/contacts.en.yml +31 -0
- data/config/locales/contacts.ko.yml +31 -0
- data/config/locales/contexts.en.yml +8 -0
- data/config/locales/contexts.ko.yml +8 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +6 -0
- data/config/locales/users.en.yml +1 -0
- data/config/locales/users.ko.yml +1 -0
- data/config/routes.rb +14 -1
- data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
- data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
- data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
- data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
- data/lib/collavre/version.rb +1 -1
- metadata +47 -10
- data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
- data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
- data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
- data/app/javascript/modules/share_modal.js +0 -76
- data/app/javascript/modules/share_user_popup.js +0 -77
- data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
- data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
- data/app/views/collavre/creatives/_delete_button.html.erb +0 -12
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module UsersController::AiUserManagement
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
before_action :set_user_for_ai_actions, only: [ :edit_ai, :update_ai ]
|
|
7
|
+
before_action :verify_ai_user, only: [ :edit_ai, :update_ai ]
|
|
8
|
+
before_action :verify_ai_user_authorization, only: [ :update_ai ]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def new_ai
|
|
12
|
+
@available_tools = load_available_tools
|
|
13
|
+
|
|
14
|
+
if params[:copy_from].present?
|
|
15
|
+
source = Collavre::User.find_by(id: params[:copy_from])
|
|
16
|
+
if source&.ai_user? && source.created_by_id == Current.user.id
|
|
17
|
+
@copy_source = source
|
|
18
|
+
@copy_name = "#{source.name} (copy)"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_ai
|
|
24
|
+
ai_id = params[:ai_id].to_s.strip.downcase
|
|
25
|
+
email = "#{ai_id}@ai.local"
|
|
26
|
+
searchable = ActiveModel::Type::Boolean.new.cast(params.fetch(:searchable, false))
|
|
27
|
+
|
|
28
|
+
@user = Collavre::User.new(
|
|
29
|
+
name: params[:name],
|
|
30
|
+
email: email,
|
|
31
|
+
password: SecureRandom.hex(36),
|
|
32
|
+
system_prompt: params[:system_prompt],
|
|
33
|
+
llm_vendor: params[:llm_vendor].presence || "google",
|
|
34
|
+
llm_model: params[:llm_model],
|
|
35
|
+
llm_api_key: params[:llm_api_key],
|
|
36
|
+
gateway_url: params[:gateway_url],
|
|
37
|
+
tools: params[:tools] || [],
|
|
38
|
+
searchable: searchable,
|
|
39
|
+
email_verified_at: Time.current,
|
|
40
|
+
created_by_id: Current.user.id,
|
|
41
|
+
routing_expression: params[:routing_expression]
|
|
42
|
+
)
|
|
43
|
+
@user.agent_conf = params[:agent_conf] if @user.respond_to?(:agent_conf=) && params[:agent_conf].present?
|
|
44
|
+
|
|
45
|
+
if @user.save
|
|
46
|
+
Collavre::Contact.ensure(user: Current.user, contact_user: @user)
|
|
47
|
+
share_ai_agent_to_creative(@user, params[:creative_id])
|
|
48
|
+
redirect_to user_path(Current.user, tab: "contacts"), notice: I18n.t("collavre.users.create_ai.success")
|
|
49
|
+
else
|
|
50
|
+
flash.now[:alert] = @user.errors.full_messages.to_sentence
|
|
51
|
+
@available_tools = load_available_tools
|
|
52
|
+
render :new_ai, status: :unprocessable_entity
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def edit_ai
|
|
57
|
+
@available_tools = load_available_tools
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def update_ai
|
|
61
|
+
ai_params = params.require(:user).permit(:name, :system_prompt, :llm_vendor, :llm_model, :llm_api_key, :gateway_url, :searchable, :routing_expression, :agent_conf, tools: [])
|
|
62
|
+
|
|
63
|
+
if @user.update(ai_params)
|
|
64
|
+
redirect_to edit_ai_user_path(@user), notice: I18n.t("collavre.users.update_ai.success")
|
|
65
|
+
else
|
|
66
|
+
@available_tools = load_available_tools
|
|
67
|
+
flash.now[:alert] = @user.errors.full_messages.to_sentence
|
|
68
|
+
render :edit_ai, status: :unprocessable_entity
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def load_available_tools
|
|
75
|
+
Collavre::McpService.available_tools(Current.user).map do |tool|
|
|
76
|
+
{
|
|
77
|
+
name: tool[:name],
|
|
78
|
+
description: tool[:description],
|
|
79
|
+
parameters: tool[:params]
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def set_user_for_ai_actions
|
|
85
|
+
@user = Collavre::User.find(params[:id])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def verify_ai_user
|
|
89
|
+
unless @user.ai_user?
|
|
90
|
+
redirect_to user_path(@user), alert: I18n.t("collavre.users.edit_ai.not_an_ai")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def verify_ai_user_authorization
|
|
95
|
+
allowed = Current.user.system_admin? ||
|
|
96
|
+
(@user.ai_user? && @user.created_by_id == Current.user.id)
|
|
97
|
+
|
|
98
|
+
unless allowed
|
|
99
|
+
fallback = user_path(Current.user, tab: "contacts")
|
|
100
|
+
redirect_back fallback_location: fallback, alert: I18n.t("collavre.users.destroy.not_authorized")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def share_ai_agent_to_creative(user, creative_id)
|
|
105
|
+
return if creative_id.blank?
|
|
106
|
+
|
|
107
|
+
creative = Collavre::Creative.find_by(id: creative_id)
|
|
108
|
+
return unless creative&.has_permission?(Current.user, :admin)
|
|
109
|
+
|
|
110
|
+
Collavre::CreativeShare.find_or_create_by!(
|
|
111
|
+
creative: creative,
|
|
112
|
+
user: user
|
|
113
|
+
) do |share|
|
|
114
|
+
share.shared_by = Current.user
|
|
115
|
+
share.permission = :feedback
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module UsersController::ContactManagement
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
def search
|
|
6
|
+
term = params[:q].to_s.strip.downcase
|
|
7
|
+
|
|
8
|
+
if term.blank? && params[:scope] != "contacts"
|
|
9
|
+
return render json: []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
creative = Collavre::Creative.find_by(id: params[:creative_id])
|
|
13
|
+
|
|
14
|
+
if creative.present? && !creative.has_permission?(Current.user, :read)
|
|
15
|
+
head :forbidden and return
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
scope = if params[:scope] == "contacts" && Current.user
|
|
19
|
+
# Include both contacts and searchable users (e.g., AI agents with searchable=true)
|
|
20
|
+
contact_ids = Current.user.contact_users.select(:id)
|
|
21
|
+
searchable_ids = Collavre::User.where(searchable: true).select(:id)
|
|
22
|
+
Collavre::User.where(id: contact_ids).or(Collavre::User.where(id: searchable_ids))
|
|
23
|
+
else
|
|
24
|
+
Collavre::User.mentionable_for(creative)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
users = scope
|
|
28
|
+
if term.present?
|
|
29
|
+
users = users.where("LOWER(users.email) LIKE :term OR LOWER(users.name) LIKE :term", term: "#{term}%")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
limit = params[:limit].to_i
|
|
33
|
+
limit = 20 if limit <= 0
|
|
34
|
+
limit = 50 if limit > 50
|
|
35
|
+
|
|
36
|
+
user_ids = users.select(:id).distinct.limit(limit).pluck(:id)
|
|
37
|
+
users = Collavre::User.where(id: user_ids)
|
|
38
|
+
render json: users.map { |u| { id: u.id, name: u.display_name, email: u.email, avatar_url: view_context.user_avatar_url(u, size: 20) } }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def prepare_contacts
|
|
44
|
+
per_page = 20
|
|
45
|
+
@contact_page = [ params[:contact_page].to_i, 1 ].max
|
|
46
|
+
|
|
47
|
+
creative_shares = Collavre::CreativeShare.arel_table
|
|
48
|
+
creatives = Collavre::Creative.arel_table
|
|
49
|
+
|
|
50
|
+
user_creative_origins_sql = Collavre::Creative
|
|
51
|
+
.where(user_id: Current.user.id)
|
|
52
|
+
.select("COALESCE(origin_id, id) AS origin_id")
|
|
53
|
+
.to_sql
|
|
54
|
+
|
|
55
|
+
shared_by_me_scope = Collavre::CreativeShare
|
|
56
|
+
.joins(:creative)
|
|
57
|
+
.where.not(permission: Collavre::CreativeShare.permissions[:no_access])
|
|
58
|
+
.where(
|
|
59
|
+
creative_shares[:shared_by_id].eq(Current.user.id)
|
|
60
|
+
.or(
|
|
61
|
+
creative_shares[:shared_by_id].eq(nil).and(creatives[:user_id].eq(Current.user.id))
|
|
62
|
+
)
|
|
63
|
+
.or(
|
|
64
|
+
creatives[:id].in(Arel.sql("(#{user_creative_origins_sql})"))
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
shared_with_me_scope = Collavre::CreativeShare
|
|
69
|
+
.joins(:creative)
|
|
70
|
+
.where(user_id: Current.user.id)
|
|
71
|
+
.where.not(permission: Collavre::CreativeShare.permissions[:no_access])
|
|
72
|
+
|
|
73
|
+
contact_ids_sql = [
|
|
74
|
+
Current.user.contacts.select("contact_user_id AS user_id").to_sql,
|
|
75
|
+
shared_by_me_scope.select("creative_shares.user_id AS user_id").to_sql,
|
|
76
|
+
shared_with_me_scope.select("COALESCE(creative_shares.shared_by_id, creatives.user_id) AS user_id").to_sql
|
|
77
|
+
].join(" UNION ")
|
|
78
|
+
|
|
79
|
+
contact_users_relation = Collavre::User.where(
|
|
80
|
+
id: Collavre::User.from("(#{contact_ids_sql}) AS contact_ids").select(:user_id)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@total_contacts = contact_users_relation.count
|
|
84
|
+
@total_contact_pages = [ (@total_contacts.to_f / per_page).ceil, 1 ].max
|
|
85
|
+
paged_users = contact_users_relation
|
|
86
|
+
.includes(avatar_attachment: :blob)
|
|
87
|
+
.order(:name, :id)
|
|
88
|
+
.offset((@contact_page - 1) * per_page)
|
|
89
|
+
.limit(per_page)
|
|
90
|
+
|
|
91
|
+
existing_contacts = Current.user.contacts.includes(contact_user: [ avatar_attachment: :blob ]).index_by(&:contact_user_id)
|
|
92
|
+
@contacts = paged_users.map do |user|
|
|
93
|
+
existing_contacts[user.id] || Collavre::Contact.new(user: Current.user, contact_user: user)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
shares_from_me = shared_by_me_scope
|
|
97
|
+
.where(user_id: paged_users.map(&:id))
|
|
98
|
+
.includes(:creative)
|
|
99
|
+
|
|
100
|
+
@shared_by_me = shares_from_me.group_by(&:user_id).transform_values { |shares| shares.map(&:creative) }
|
|
101
|
+
|
|
102
|
+
shares_to_me = Collavre::CreativeShare
|
|
103
|
+
.joins(:creative)
|
|
104
|
+
.where(user_id: Current.user.id)
|
|
105
|
+
.where.not(permission: Collavre::CreativeShare.permissions[:no_access])
|
|
106
|
+
.where(
|
|
107
|
+
creative_shares[:shared_by_id].in(paged_users.map(&:id))
|
|
108
|
+
.or(creative_shares[:shared_by_id].eq(nil).and(creatives[:user_id].in(paged_users.map(&:id))))
|
|
109
|
+
)
|
|
110
|
+
.includes(:creative)
|
|
111
|
+
|
|
112
|
+
@shared_with_me = shares_to_me.group_by(&:sharer_id)
|
|
113
|
+
.transform_values { |shares| shares.map(&:creative) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def prepare_org_chart
|
|
117
|
+
# 1. Creatives with actual shares relevant to current user
|
|
118
|
+
shared_creative_ids = Collavre::Creative.shared_accessible_ids(Current.user)
|
|
119
|
+
|
|
120
|
+
# 2. Walk up ancestor chains to build full paths (A > B > C)
|
|
121
|
+
all_tree_ids = Set.new(shared_creative_ids)
|
|
122
|
+
Collavre::Creative.where(id: shared_creative_ids).find_each do |creative|
|
|
123
|
+
creative.ancestors.each { |a| all_tree_ids.add(a.id) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# 3. Build the tree
|
|
127
|
+
all_creatives = Collavre::Creative.where(id: all_tree_ids.to_a).order(:sequence, :id)
|
|
128
|
+
@org_chart_roots = all_creatives.select { |c| c.parent_id.nil? || !all_tree_ids.include?(c.parent_id) }
|
|
129
|
+
@org_chart_children = all_creatives.select { |c| c.parent_id.present? && all_tree_ids.include?(c.parent_id) }.group_by(&:parent_id)
|
|
130
|
+
|
|
131
|
+
# 4. Preload shares (including origin shares for linked creatives)
|
|
132
|
+
origin_ids = all_creatives.filter_map(&:origin_id).uniq
|
|
133
|
+
share_lookup_ids = (all_tree_ids.to_a + origin_ids).uniq
|
|
134
|
+
shares = Collavre::CreativeShare
|
|
135
|
+
.where(creative_id: share_lookup_ids)
|
|
136
|
+
.includes(user: [ avatar_attachment: :blob ], shared_by: [ avatar_attachment: :blob ])
|
|
137
|
+
shares_by_creative = shares.group_by(&:creative_id)
|
|
138
|
+
|
|
139
|
+
# Map shares: linked creatives inherit from origin if they have no direct shares
|
|
140
|
+
@org_chart_shares = {}
|
|
141
|
+
all_creatives.each do |c|
|
|
142
|
+
direct = shares_by_creative.fetch(c.id, [])
|
|
143
|
+
if direct.empty? && c.origin_id.present?
|
|
144
|
+
@org_chart_shares[c.id] = shares_by_creative.fetch(c.origin_id, [])
|
|
145
|
+
else
|
|
146
|
+
@org_chart_shares[c.id] = direct
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# 5. Preload pending invitations
|
|
151
|
+
@org_chart_invitations = Collavre::Invitation
|
|
152
|
+
.where(creative_id: all_tree_ids.to_a, accepted_at: nil)
|
|
153
|
+
.where("expires_at > ?", Time.current)
|
|
154
|
+
.order(created_at: :desc)
|
|
155
|
+
.group_by(&:creative_id)
|
|
156
|
+
|
|
157
|
+
# 6. Unassigned AI Agents: owned by current user but not in any CreativeShare
|
|
158
|
+
assigned_user_ids = shares.map(&:user_id).uniq
|
|
159
|
+
@org_chart_unassigned = Collavre::User.where(created_by_id: Current.user.id)
|
|
160
|
+
.where.not(id: assigned_user_ids)
|
|
161
|
+
.where.not(llm_vendor: [ nil, "" ])
|
|
162
|
+
.includes(avatar_attachment: :blob)
|
|
163
|
+
.order(:name)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module UsersController::ProfileAndSettings
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
def show
|
|
6
|
+
@user = Collavre::User.find(params[:id])
|
|
7
|
+
@active_tab = params[:tab].presence || "profile"
|
|
8
|
+
@active_tab = "contacts" if @active_tab == "org_chart"
|
|
9
|
+
@contacts_view = params[:contacts_view].presence || "list"
|
|
10
|
+
if Current.user
|
|
11
|
+
if @contacts_view == "org_chart"
|
|
12
|
+
prepare_org_chart
|
|
13
|
+
else
|
|
14
|
+
prepare_contacts
|
|
15
|
+
end
|
|
16
|
+
else
|
|
17
|
+
@contacts = []
|
|
18
|
+
@shared_by_me = {}
|
|
19
|
+
@shared_with_me = {}
|
|
20
|
+
@total_contact_pages = 1
|
|
21
|
+
@contact_page = 1
|
|
22
|
+
@org_chart_roots = []
|
|
23
|
+
@org_chart_shares = {}
|
|
24
|
+
@org_chart_invitations = {}
|
|
25
|
+
@org_chart_children = {}
|
|
26
|
+
@org_chart_unassigned = []
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def update
|
|
31
|
+
@user = Collavre::User.find(params[:id])
|
|
32
|
+
if @user.update(profile_params)
|
|
33
|
+
redirect_to user_path(@user), notice: I18n.t("collavre.users.profile_updated")
|
|
34
|
+
else
|
|
35
|
+
render :show, status: :unprocessable_entity
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def notification_settings
|
|
40
|
+
if Current.user.update(notification_settings_params)
|
|
41
|
+
head :no_content
|
|
42
|
+
else
|
|
43
|
+
render json: { errors: Current.user.errors.full_messages }, status: :unprocessable_entity
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def edit_password
|
|
48
|
+
@user = Collavre::User.find(params[:id])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def passkeys
|
|
52
|
+
@user = Collavre::User.find(params[:id])
|
|
53
|
+
|
|
54
|
+
unless @user == Current.user || Current.user.system_admin?
|
|
55
|
+
redirect_to user_path(Current.user), alert: I18n.t("collavre.users.destroy.not_authorized")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def update_password
|
|
60
|
+
@user = Collavre::User.find(params[:id])
|
|
61
|
+
if @user.authenticate(params[:user][:current_password])
|
|
62
|
+
if @user.update(user_params)
|
|
63
|
+
redirect_to user_path(@user), notice: I18n.t("collavre.users.password_updated")
|
|
64
|
+
else
|
|
65
|
+
flash.now[:alert] = I18n.t("collavre.users.password_update_failed")
|
|
66
|
+
render :edit_password, status: :unprocessable_entity
|
|
67
|
+
end
|
|
68
|
+
else
|
|
69
|
+
@user.errors.add(:current_password, I18n.t("collavre.users.current_password_incorrect"))
|
|
70
|
+
flash.now[:alert] = I18n.t("collavre.users.password_update_failed")
|
|
71
|
+
render :edit_password, status: :unprocessable_entity
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def user_params
|
|
78
|
+
params.require(:user).permit(:email, :password, :password_confirmation, :name)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def profile_params
|
|
82
|
+
params.require(:user).permit(
|
|
83
|
+
:avatar,
|
|
84
|
+
:avatar_url,
|
|
85
|
+
:display_level,
|
|
86
|
+
:completion_mark,
|
|
87
|
+
:theme,
|
|
88
|
+
:name,
|
|
89
|
+
:notifications_enabled,
|
|
90
|
+
:calendar_id,
|
|
91
|
+
:timezone,
|
|
92
|
+
:locale
|
|
93
|
+
).tap do |p|
|
|
94
|
+
p[:locale] = normalize_supported_locale(p[:locale]) if p.key?(:locale)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def notification_settings_params
|
|
99
|
+
params.require(:user).permit(:notifications_enabled)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module UsersController::Registration
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
allow_unauthenticated_access only: %i[new create exists]
|
|
7
|
+
before_action -> { enforce_auth_provider!(:email) }, only: [ :new, :create ]
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def new
|
|
11
|
+
@user = Collavre::User.new
|
|
12
|
+
if params[:invite_token].present?
|
|
13
|
+
@invitation = Collavre::Invitation.find_by_token_for(:invite, params[:invite_token])
|
|
14
|
+
@user.email = @invitation&.email
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create
|
|
19
|
+
@user = Collavre::User.new(user_params)
|
|
20
|
+
Collavre::Invitation.transaction do
|
|
21
|
+
if params[:invite_token].present?
|
|
22
|
+
invitation = Collavre::Invitation.find_by_token_for(:invite, params[:invite_token])
|
|
23
|
+
if invitation
|
|
24
|
+
@invitation = invitation
|
|
25
|
+
@user.email = invitation.email
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
if @user.save
|
|
29
|
+
if invitation
|
|
30
|
+
invitation.update(accepted_at: Time.current)
|
|
31
|
+
Collavre::CreativeShare.create!(
|
|
32
|
+
creative: invitation.creative,
|
|
33
|
+
user: @user,
|
|
34
|
+
permission: invitation.permission,
|
|
35
|
+
shared_by: invitation.inviter
|
|
36
|
+
)
|
|
37
|
+
Collavre::Contact.ensure(user: invitation.inviter, contact_user: @user)
|
|
38
|
+
invitation.creative.create_linked_creative_for_user(@user)
|
|
39
|
+
end
|
|
40
|
+
Collavre::EmailVerificationMailer.verify(@user).deliver_later
|
|
41
|
+
session.delete(:return_to_after_authenticating)
|
|
42
|
+
redirect_to new_session_path, notice: I18n.t("collavre.users.new.success_sign_up")
|
|
43
|
+
else
|
|
44
|
+
render :new, status: :unprocessable_entity
|
|
45
|
+
end
|
|
46
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
47
|
+
flash.now[:alert] = I18n.t("collavre.invites.invalid")
|
|
48
|
+
render :new, status: :unprocessable_entity
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def exists
|
|
53
|
+
user = Collavre::User.find_by(email: params[:email])
|
|
54
|
+
render json: { exists: user.present? }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def user_params
|
|
60
|
+
params.require(:user).permit(:email, :password, :password_confirmation, :name)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
module Collavre
|
|
2
2
|
module CreativesHelper
|
|
3
|
-
# Shared toggle button symbol helper
|
|
4
|
-
def toggle_button_symbol(expanded: false)
|
|
5
|
-
expanded ? "\u25BC" : "\u25B6" # ▼ or ▶
|
|
6
|
-
end
|
|
7
|
-
|
|
8
3
|
def render_tags(labels, class_name = nil, name_only = false)
|
|
9
4
|
return "" if labels&.empty? or labels.nil?
|
|
10
5
|
|
|
@@ -13,8 +8,11 @@ module Collavre
|
|
|
13
8
|
suffix = " 🗓#{label.target_date}" if label.type == "Plan" and !name_only
|
|
14
9
|
index += 1
|
|
15
10
|
content_tag(:span, class: "tag") do
|
|
16
|
-
(
|
|
17
|
-
|
|
11
|
+
safe_join([
|
|
12
|
+
(index == 1 ? "" : " "),
|
|
13
|
+
link_to("##{strip_tags(label.name)}", collavre.creatives_path(tags: [ label.id ]), class: class_name ? class_name: "", title: strip_tags(label.name)),
|
|
14
|
+
suffix
|
|
15
|
+
].compact)
|
|
18
16
|
end
|
|
19
17
|
end)
|
|
20
18
|
end
|
|
@@ -67,9 +65,14 @@ module Collavre
|
|
|
67
65
|
class: classes.join(" ")
|
|
68
66
|
)
|
|
69
67
|
else
|
|
70
|
-
|
|
68
|
+
safe_join([])
|
|
71
69
|
end
|
|
72
|
-
|
|
70
|
+
safe_join([
|
|
71
|
+
render_progress_value(progress_value),
|
|
72
|
+
comment_part,
|
|
73
|
+
tag.br,
|
|
74
|
+
(creative.tags ? render_creative_tags(creative) : safe_join([]))
|
|
75
|
+
])
|
|
73
76
|
end
|
|
74
77
|
end
|
|
75
78
|
|
|
@@ -103,7 +103,7 @@ module Collavre
|
|
|
103
103
|
|
|
104
104
|
def render_nav_raw(item)
|
|
105
105
|
content = resolve_nav_value(item[:content])
|
|
106
|
-
return
|
|
106
|
+
return safe_join([]) if content.nil?
|
|
107
107
|
content.respond_to?(:html_safe?) && content.html_safe? ? content : ERB::Util.html_escape(content)
|
|
108
108
|
end
|
|
109
109
|
|
data/app/javascript/collavre.js
CHANGED
|
@@ -8,7 +8,7 @@ if (!window._streamingCommentIds) window._streamingCommentIds = new Set()
|
|
|
8
8
|
|
|
9
9
|
// Connects to data-controller="comment"
|
|
10
10
|
export default class extends Controller {
|
|
11
|
-
static targets = ["ownerButton", "deleteButton", "approveButton", "actionApproveControls"
|
|
11
|
+
static targets = ["ownerButton", "deleteButton", "approveButton", "actionApproveControls"]
|
|
12
12
|
|
|
13
13
|
get _commentId() {
|
|
14
14
|
return this.element.dataset.commentId
|
|
@@ -123,10 +123,6 @@ export default class extends Controller {
|
|
|
123
123
|
this.handleMouseUp = this.handleMouseUp.bind(this)
|
|
124
124
|
this.element.addEventListener('mouseup', this.handleMouseUp)
|
|
125
125
|
|
|
126
|
-
// Bound handlers for review/replace buttons (stored for cleanup)
|
|
127
|
-
this._boundReviewClick = this._onReviewClick.bind(this)
|
|
128
|
-
this._boundReplaceClick = this._onReplaceClick.bind(this)
|
|
129
|
-
|
|
130
126
|
this.currentUserId = document.body.dataset.currentUserId
|
|
131
127
|
const commentAuthorId = this.element.dataset.userId
|
|
132
128
|
const creativeOwnerId = this.element.dataset.creativeOwnerId
|
|
@@ -228,7 +224,6 @@ export default class extends Controller {
|
|
|
228
224
|
this._streamingTimeout = null
|
|
229
225
|
}
|
|
230
226
|
this.element.removeEventListener('mouseup', this.handleMouseUp)
|
|
231
|
-
this._removeSelectionChangeListener()
|
|
232
227
|
this.hideReviewPopup()
|
|
233
228
|
if (this._reviewPopupEl) {
|
|
234
229
|
this._reviewPopupEl.remove()
|
|
@@ -291,7 +286,7 @@ export default class extends Controller {
|
|
|
291
286
|
const commentId = this.element.dataset.commentId
|
|
292
287
|
const formController = this.findFormController()
|
|
293
288
|
if (formController) {
|
|
294
|
-
formController.
|
|
289
|
+
formController.appendReviewQuote(commentId, selectedText)
|
|
295
290
|
}
|
|
296
291
|
window.getSelection().removeAllRanges()
|
|
297
292
|
this.hideReviewPopup()
|
|
@@ -324,52 +319,43 @@ export default class extends Controller {
|
|
|
324
319
|
return this.application.getControllerForElementAndIdentifier(popup, 'comments--form')
|
|
325
320
|
}
|
|
326
321
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
reviewButtonTargetDisconnected(button) {
|
|
332
|
-
button.removeEventListener('click', this._boundReviewClick)
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
replaceButtonTargetConnected(button) {
|
|
336
|
-
button.addEventListener('click', this._boundReplaceClick)
|
|
337
|
-
// Only listen for selectionchange when a replace button exists
|
|
338
|
-
this._addSelectionChangeListener()
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
replaceButtonTargetDisconnected(button) {
|
|
342
|
-
button.removeEventListener('click', this._boundReplaceClick)
|
|
343
|
-
if (!this.hasReplaceButtonTarget) {
|
|
344
|
-
this._removeSelectionChangeListener()
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
_onReviewClick(event) {
|
|
322
|
+
// Stimulus action: click->comment#reviewClick (bound via data-action in template)
|
|
323
|
+
reviewClick(event) {
|
|
349
324
|
event.preventDefault()
|
|
350
|
-
event.stopPropagation()
|
|
351
|
-
const commentId = this.element.dataset.commentId
|
|
352
|
-
const contentEl = this.element.querySelector('.comment-content')
|
|
353
|
-
const fullText = contentEl ? contentEl.textContent.trim() : ''
|
|
354
|
-
const formController = this.findFormController()
|
|
355
|
-
if (formController && fullText) {
|
|
356
|
-
formController.quoteComment(commentId, fullText)
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
_onReplaceClick(event) {
|
|
361
|
-
event.preventDefault()
|
|
362
|
-
event.stopPropagation()
|
|
363
325
|
const selectedText = this._getSelectedTextInContent()
|
|
364
|
-
if (!selectedText)
|
|
365
|
-
|
|
326
|
+
if (!selectedText) {
|
|
327
|
+
this._showReviewHint(event.currentTarget)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
366
330
|
const commentId = this.element.dataset.commentId
|
|
367
331
|
const formController = this.findFormController()
|
|
368
332
|
if (formController) {
|
|
369
|
-
formController.
|
|
333
|
+
formController.appendReviewQuote(commentId, selectedText)
|
|
334
|
+
const textarea = formController.textareaTarget
|
|
335
|
+
if (textarea) {
|
|
336
|
+
textarea.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
337
|
+
textarea.focus()
|
|
338
|
+
textarea.selectionStart = textarea.selectionEnd = textarea.value.length
|
|
339
|
+
}
|
|
370
340
|
}
|
|
371
341
|
window.getSelection().removeAllRanges()
|
|
372
|
-
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
_showReviewHint(button) {
|
|
345
|
+
// Remove any existing hint
|
|
346
|
+
document.querySelectorAll('.review-hint').forEach(el => el.remove())
|
|
347
|
+
|
|
348
|
+
// Show hint popup near the button using fixed positioning
|
|
349
|
+
const rect = button.getBoundingClientRect()
|
|
350
|
+
const hint = document.createElement('div')
|
|
351
|
+
hint.className = 'review-hint'
|
|
352
|
+
hint.textContent = button.dataset.hintText || 'Select text to review'
|
|
353
|
+
hint.style.position = 'fixed'
|
|
354
|
+
hint.style.top = `${rect.top - 8}px`
|
|
355
|
+
hint.style.left = `${rect.left + rect.width / 2}px`
|
|
356
|
+
hint.style.transform = 'translate(-50%, -100%)'
|
|
357
|
+
document.body.appendChild(hint)
|
|
358
|
+
hint.addEventListener('animationend', () => hint.remove(), { once: true })
|
|
373
359
|
}
|
|
374
360
|
|
|
375
361
|
_getSelectedTextInContent() {
|
|
@@ -388,30 +374,7 @@ export default class extends Controller {
|
|
|
388
374
|
return text
|
|
389
375
|
}
|
|
390
376
|
|
|
391
|
-
|
|
392
|
-
if (this._selectionChangeActive) return
|
|
393
|
-
this._handleSelectionChange = this._handleSelectionChange.bind(this)
|
|
394
|
-
document.addEventListener('selectionchange', this._handleSelectionChange)
|
|
395
|
-
this._selectionChangeActive = true
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
_removeSelectionChangeListener() {
|
|
399
|
-
if (!this._selectionChangeActive) return
|
|
400
|
-
document.removeEventListener('selectionchange', this._handleSelectionChange)
|
|
401
|
-
this._selectionChangeActive = false
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
_handleSelectionChange() {
|
|
405
|
-
this._updateReplaceButton()
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
_updateReplaceButton() {
|
|
409
|
-
if (!this.hasReplaceButtonTarget) return
|
|
410
|
-
const hasSelection = !!this._getSelectedTextInContent()
|
|
411
|
-
this.replaceButtonTargets.forEach((btn) => {
|
|
412
|
-
btn.disabled = !hasSelection
|
|
413
|
-
})
|
|
414
|
-
}
|
|
377
|
+
// Selection change listener and replace button logic removed — unified into review
|
|
415
378
|
|
|
416
379
|
updateReactionsUI(reactionsData) {
|
|
417
380
|
let reactionsContainer = this.element.querySelector('.comment-reactions')
|