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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comment_versions.css +76 -0
  3. data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
  4. data/app/assets/stylesheets/collavre/creatives.css +73 -1
  5. data/app/assets/stylesheets/collavre/org_chart.css +319 -0
  6. data/app/assets/stylesheets/collavre/popup.css +68 -1
  7. data/app/controllers/collavre/application_controller.rb +13 -0
  8. data/app/controllers/collavre/comments/versions_controller.rb +82 -0
  9. data/app/controllers/collavre/comments_controller.rb +14 -153
  10. data/app/controllers/collavre/concerns/exportable.rb +30 -0
  11. data/app/controllers/collavre/concerns/shareable.rb +28 -0
  12. data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
  13. data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
  14. data/app/controllers/collavre/creative_imports_controller.rb +6 -0
  15. data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
  16. data/app/controllers/collavre/creative_plans_controller.rb +1 -1
  17. data/app/controllers/collavre/creative_shares_controller.rb +84 -14
  18. data/app/controllers/collavre/creatives_controller.rb +70 -194
  19. data/app/controllers/collavre/google_auth_controller.rb +3 -0
  20. data/app/controllers/collavre/invites_controller.rb +2 -1
  21. data/app/controllers/collavre/sessions_controller.rb +3 -0
  22. data/app/controllers/collavre/topics_controller.rb +39 -2
  23. data/app/controllers/collavre/users_controller.rb +5 -404
  24. data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
  25. data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
  26. data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
  27. data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
  28. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
  29. data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
  30. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
  31. data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
  32. data/app/helpers/collavre/application_helper.rb +1 -0
  33. data/app/helpers/collavre/creatives_helper.rb +12 -9
  34. data/app/helpers/collavre/navigation_helper.rb +1 -1
  35. data/app/javascript/collavre.js +0 -1
  36. data/app/javascript/controllers/comment_controller.js +33 -70
  37. data/app/javascript/controllers/comment_version_controller.js +164 -0
  38. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
  39. data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
  40. data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
  41. data/app/javascript/controllers/comments/contexts_controller.js +363 -0
  42. data/app/javascript/controllers/comments/form_controller.js +304 -13
  43. data/app/javascript/controllers/comments/list_controller.js +151 -62
  44. data/app/javascript/controllers/comments/popup_controller.js +66 -38
  45. data/app/javascript/controllers/comments/presence_controller.js +2 -10
  46. data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
  47. data/app/javascript/controllers/comments/topics_controller.js +34 -10
  48. data/app/javascript/controllers/index.js +15 -1
  49. data/app/javascript/controllers/org_chart_controller.js +46 -0
  50. data/app/javascript/controllers/share_modal_controller.js +369 -0
  51. data/app/javascript/controllers/topic_search_controller.js +103 -0
  52. data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
  53. data/app/javascript/lib/api/creatives.js +12 -0
  54. data/app/javascript/lib/api/csrf_fetch.js +35 -0
  55. data/app/javascript/lib/api/drag_drop.js +17 -0
  56. data/app/javascript/modules/command_menu.js +40 -0
  57. data/app/javascript/modules/creative_row_editor.js +88 -0
  58. data/app/javascript/modules/slide_view.js +2 -1
  59. data/app/jobs/collavre/ai_agent_job.rb +42 -30
  60. data/app/jobs/collavre/compress_job.rb +92 -0
  61. data/app/models/collavre/comment.rb +36 -1
  62. data/app/models/collavre/comment_version.rb +15 -0
  63. data/app/models/collavre/creative/describable.rb +1 -1
  64. data/app/models/collavre/creative.rb +51 -0
  65. data/app/models/collavre/task.rb +30 -2
  66. data/app/models/collavre/user.rb +20 -3
  67. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
  68. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
  69. data/app/services/collavre/ai_agent/message_builder.rb +85 -6
  70. data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
  71. data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
  72. data/app/services/collavre/ai_agent/review_handler.rb +18 -1
  73. data/app/services/collavre/ai_agent_service.rb +130 -183
  74. data/app/services/collavre/ai_client.rb +6 -0
  75. data/app/services/collavre/auto_theme_generator.rb +1 -1
  76. data/app/services/collavre/command_menu_service.rb +19 -0
  77. data/app/services/collavre/comments/command_processor.rb +3 -1
  78. data/app/services/collavre/comments/compress_command.rb +75 -0
  79. data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
  80. data/app/services/collavre/comments/work_command.rb +161 -0
  81. data/app/services/collavre/comments/workflow_executor.rb +276 -0
  82. data/app/services/collavre/creatives/plan_tagger.rb +14 -3
  83. data/app/services/collavre/creatives/tree_formatter.rb +53 -13
  84. data/app/services/collavre/gemini_parent_recommender.rb +4 -4
  85. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
  86. data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
  87. data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
  88. data/app/services/collavre/orchestration/scheduler.rb +3 -2
  89. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  90. data/app/services/collavre/system_events/dispatcher.rb +9 -0
  91. data/app/services/collavre/tools/creative_create_service.rb +1 -8
  92. data/app/services/collavre/tools/creative_import_service.rb +46 -0
  93. data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
  94. data/app/services/collavre/tools/creative_update_service.rb +1 -8
  95. data/app/services/collavre/tools/cron_list_service.rb +1 -1
  96. data/app/services/collavre/tools/description_normalizable.rb +16 -0
  97. data/app/views/collavre/comments/_comment.html.erb +25 -8
  98. data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
  99. data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
  100. data/app/views/collavre/creatives/_share_button.html.erb +4 -1
  101. data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
  102. data/app/views/collavre/creatives/index.html.erb +5 -5
  103. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  104. data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
  105. data/app/views/collavre/users/_org_chart.html.erb +68 -0
  106. data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
  107. data/app/views/collavre/users/new_ai.html.erb +9 -0
  108. data/app/views/collavre/users/show.html.erb +32 -8
  109. data/config/locales/comments.en.yml +57 -2
  110. data/config/locales/comments.ko.yml +57 -2
  111. data/config/locales/contacts.en.yml +31 -0
  112. data/config/locales/contacts.ko.yml +31 -0
  113. data/config/locales/contexts.en.yml +8 -0
  114. data/config/locales/contexts.ko.yml +8 -0
  115. data/config/locales/creatives.en.yml +6 -0
  116. data/config/locales/creatives.ko.yml +6 -0
  117. data/config/locales/users.en.yml +1 -0
  118. data/config/locales/users.ko.yml +1 -0
  119. data/config/routes.rb +14 -1
  120. data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
  121. data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
  122. data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
  123. data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
  124. data/lib/collavre/version.rb +1 -1
  125. metadata +47 -10
  126. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
  127. data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
  128. data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
  129. data/app/javascript/modules/share_modal.js +0 -76
  130. data/app/javascript/modules/share_user_popup.js +0 -77
  131. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
  132. data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
  133. data/app/views/collavre/creatives/_delete_button.html.erb +0 -12
@@ -1,408 +1,9 @@
1
1
  module Collavre
2
2
  class UsersController < ApplicationController
3
- allow_unauthenticated_access only: %i[new create exists]
4
- before_action -> { enforce_auth_provider!(:email) }, only: [ :new, :create ]
5
- before_action :require_system_admin!, only: [ :index, :grant_system_admin, :revoke_system_admin, :unlock, :lock ]
6
-
7
- def new
8
- @user = Collavre::User.new
9
- if params[:invite_token].present?
10
- @invitation = Collavre::Invitation.find_by_token_for(:invite, params[:invite_token])
11
- @user.email = @invitation&.email
12
- end
13
- end
14
-
15
- def create
16
- @user = Collavre::User.new(user_params)
17
- Collavre::Invitation.transaction do
18
- if params[:invite_token].present?
19
- invitation = Collavre::Invitation.find_by_token_for(:invite, params[:invite_token])
20
- if invitation
21
- @invitation = invitation
22
- @user.email = invitation.email
23
- end
24
- end
25
- if @user.save
26
- if invitation
27
- invitation.update(accepted_at: Time.current)
28
- Collavre::CreativeShare.create!(
29
- creative: invitation.creative,
30
- user: @user,
31
- permission: invitation.permission,
32
- shared_by: invitation.inviter
33
- )
34
- Collavre::Contact.ensure(user: invitation.inviter, contact_user: @user)
35
- invitation.creative.create_linked_creative_for_user(@user)
36
- end
37
- Collavre::EmailVerificationMailer.verify(@user).deliver_later
38
- session.delete(:return_to_after_authenticating)
39
- redirect_to new_session_path, notice: I18n.t("collavre.users.new.success_sign_up")
40
- else
41
- render :new, status: :unprocessable_entity
42
- end
43
- rescue ActiveSupport::MessageVerifier::InvalidSignature
44
- flash.now[:alert] = I18n.t("collavre.invites.invalid")
45
- render :new, status: :unprocessable_entity
46
- end
47
- end
48
-
49
- def exists
50
- user = Collavre::User.find_by(email: params[:email])
51
- render json: { exists: user.present? }
52
- end
53
-
54
- def new_ai
55
- @available_tools = load_available_tools
56
-
57
- if params[:copy_from].present?
58
- source = Collavre::User.find_by(id: params[:copy_from])
59
- if source&.ai_user? && source.created_by_id == Current.user.id
60
- @copy_source = source
61
- @copy_name = "#{source.name} (copy)"
62
- end
63
- end
64
- end
65
-
66
- def create_ai
67
- ai_id = params[:ai_id].to_s.strip.downcase
68
- email = "#{ai_id}@ai.local"
69
- searchable = ActiveModel::Type::Boolean.new.cast(params.fetch(:searchable, false))
70
-
71
- @user = Collavre::User.new(
72
- name: params[:name],
73
- email: email,
74
- password: SecureRandom.hex(36),
75
- system_prompt: params[:system_prompt],
76
- llm_vendor: params[:llm_vendor].presence || "google",
77
- llm_model: params[:llm_model],
78
- llm_api_key: params[:llm_api_key],
79
- gateway_url: params[:gateway_url],
80
- tools: params[:tools] || [],
81
- searchable: searchable,
82
- email_verified_at: Time.current,
83
- created_by_id: Current.user.id,
84
- routing_expression: params[:routing_expression]
85
- )
86
- @user.agent_conf = params[:agent_conf] if @user.respond_to?(:agent_conf=) && params[:agent_conf].present?
87
-
88
- if @user.save
89
- Collavre::Contact.ensure(user: Current.user, contact_user: @user)
90
- redirect_to user_path(Current.user, tab: "contacts"), notice: I18n.t("collavre.users.create_ai.success")
91
- else
92
- flash.now[:alert] = @user.errors.full_messages.to_sentence
93
- @available_tools = load_available_tools
94
- render :new_ai, status: :unprocessable_entity
95
- end
96
- end
97
-
98
- def edit_ai
99
- @user = Collavre::User.find(params[:id])
100
- unless @user.ai_user?
101
- redirect_to user_path(@user), alert: I18n.t("collavre.users.edit_ai.not_an_ai")
102
- return
103
- end
104
-
105
- @available_tools = load_available_tools
106
- end
107
-
108
- def update_ai
109
- @user = Collavre::User.find(params[:id])
110
- unless @user.ai_user?
111
- redirect_to user_path(@user), alert: I18n.t("collavre.users.edit_ai.not_an_ai")
112
- return
113
- end
114
-
115
- allowed = Current.user.system_admin? ||
116
- (@user.ai_user? && @user.created_by_id == Current.user.id)
117
-
118
- unless allowed
119
- fallback = user_path(Current.user, tab: "contacts")
120
- redirect_back fallback_location: fallback, alert: I18n.t("collavre.users.destroy.not_authorized")
121
- return
122
- end
123
-
124
- ai_params = params.require(:user).permit(:name, :system_prompt, :llm_vendor, :llm_model, :llm_api_key, :gateway_url, :searchable, :routing_expression, :agent_conf, tools: [])
125
-
126
- if @user.update(ai_params)
127
- redirect_to edit_ai_user_path(@user), notice: I18n.t("collavre.users.update_ai.success")
128
- else
129
- @available_tools = load_available_tools
130
- flash.now[:alert] = @user.errors.full_messages.to_sentence
131
- render :edit_ai, status: :unprocessable_entity
132
- end
133
- end
134
-
135
- def search
136
- term = params[:q].to_s.strip.downcase
137
-
138
- if term.blank? && params[:scope] != "contacts"
139
- return render json: []
140
- end
141
-
142
- creative = Collavre::Creative.find_by(id: params[:creative_id])
143
-
144
- if creative.present? && !creative.has_permission?(Current.user, :read)
145
- head :forbidden and return
146
- end
147
-
148
- scope = if params[:scope] == "contacts" && Current.user
149
- # Include both contacts and searchable users (e.g., AI agents with searchable=true)
150
- contact_ids = Current.user.contact_users.select(:id)
151
- searchable_ids = Collavre::User.where(searchable: true).select(:id)
152
- Collavre::User.where(id: contact_ids).or(Collavre::User.where(id: searchable_ids))
153
- else
154
- Collavre::User.mentionable_for(creative)
155
- end
156
-
157
- users = scope
158
- if term.present?
159
- users = users.where("LOWER(users.email) LIKE :term OR LOWER(users.name) LIKE :term", term: "#{term}%")
160
- end
161
-
162
- limit = params[:limit].to_i
163
- limit = 20 if limit <= 0
164
- limit = 50 if limit > 50
165
-
166
- user_ids = users.select(:id).distinct.limit(limit).pluck(:id)
167
- users = Collavre::User.where(id: user_ids)
168
- 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) } }
169
- end
170
-
171
- def index
172
- @users = Collavre::User.includes(:sessions, :devices)
173
- end
174
-
175
- def show
176
- @user = Collavre::User.find(params[:id])
177
- @active_tab = params[:tab].presence || "profile"
178
- if Current.user
179
- prepare_contacts
180
- else
181
- @contacts = Collavre::Contact.none
182
- @contact_page = 1
183
- @total_contact_pages = 1
184
- @last_login_map = {}
185
- @shared_by_me = {}
186
- @shared_with_me = {}
187
- end
188
- end
189
-
190
- def destroy
191
- @user = Collavre::User.find(params[:id])
192
-
193
- if @user == Current.user
194
- redirect_to users_path, alert: I18n.t("collavre.users.destroy.cannot_delete_self")
195
- return
196
- end
197
-
198
- allowed = Current.user.system_admin? ||
199
- (@user.ai_user? && @user.created_by_id == Current.user.id)
200
-
201
- unless allowed
202
- fallback = user_path(Current.user, tab: "contacts")
203
- redirect_back fallback_location: fallback, alert: I18n.t("collavre.users.destroy.not_authorized")
204
- return
205
- end
206
-
207
- if @user.destroy
208
- fallback = Current.user.system_admin? ? users_path : user_path(Current.user, tab: "contacts")
209
- redirect_back fallback_location: fallback, notice: I18n.t("collavre.users.destroy.success")
210
- else
211
- redirect_to users_path, alert: I18n.t("collavre.users.destroy.failure")
212
- end
213
- end
214
-
215
- def grant_system_admin
216
- @user = Collavre::User.find(params[:id])
217
-
218
- if @user.update(system_admin: true)
219
- redirect_to users_path, notice: I18n.t("collavre.users.system_admin.granted")
220
- else
221
- redirect_to users_path, alert: I18n.t("collavre.users.system_admin.failed")
222
- end
223
- end
224
-
225
- def revoke_system_admin
226
- @user = Collavre::User.find(params[:id])
227
-
228
- if @user.update(system_admin: false)
229
- redirect_to users_path, notice: I18n.t("collavre.users.system_admin.revoked")
230
- else
231
- redirect_to users_path, alert: I18n.t("collavre.users.system_admin.failed")
232
- end
233
- end
234
-
235
- def unlock
236
- @user = Collavre::User.find(params[:id])
237
- @user.unlock_account!
238
- redirect_to users_path, notice: I18n.t("collavre.users.unlock.success", name: @user.display_name)
239
- end
240
-
241
- def lock
242
- @user = Collavre::User.find(params[:id])
243
- if @user == Current.user
244
- redirect_to users_path, alert: I18n.t("collavre.users.lock.cannot_lock_self")
245
- return
246
- end
247
- @user.lock_account!
248
- redirect_to users_path, notice: I18n.t("collavre.users.lock.success", name: @user.display_name)
249
- end
250
-
251
- def update
252
- @user = Collavre::User.find(params[:id])
253
- if @user.update(profile_params)
254
- redirect_to user_path(@user), notice: I18n.t("collavre.users.profile_updated")
255
- else
256
- render :show, status: :unprocessable_entity
257
- end
258
- end
259
-
260
- def notification_settings
261
- if Current.user.update(notification_settings_params)
262
- head :no_content
263
- else
264
- render json: { errors: Current.user.errors.full_messages }, status: :unprocessable_entity
265
- end
266
- end
267
-
268
- def edit_password
269
- @user = Collavre::User.find(params[:id])
270
- end
271
-
272
- def passkeys
273
- @user = Collavre::User.find(params[:id])
274
-
275
- unless @user == Current.user || Current.user.system_admin?
276
- redirect_to user_path(Current.user), alert: I18n.t("collavre.users.destroy.not_authorized")
277
- end
278
- end
279
-
280
- def update_password
281
- @user = Collavre::User.find(params[:id])
282
- if @user.authenticate(params[:user][:current_password])
283
- if @user.update(user_params)
284
- redirect_to user_path(@user), notice: I18n.t("collavre.users.password_updated")
285
- else
286
- flash.now[:alert] = I18n.t("collavre.users.password_update_failed")
287
- render :edit_password, status: :unprocessable_entity
288
- end
289
- else
290
- @user.errors.add(:current_password, I18n.t("collavre.users.current_password_incorrect"))
291
- flash.now[:alert] = I18n.t("collavre.users.password_update_failed")
292
- render :edit_password, status: :unprocessable_entity
293
- end
294
- end
295
-
296
- private
297
-
298
- def load_available_tools
299
- Collavre::McpService.available_tools(Current.user).map do |tool|
300
- {
301
- name: tool[:name],
302
- description: tool[:description],
303
- parameters: tool[:params]
304
- }
305
- end
306
- end
307
-
308
- def prepare_contacts
309
- per_page = 10
310
- @contact_page = [ params[:contact_page].to_i, 1 ].max
311
-
312
- creative_shares = Collavre::CreativeShare.arel_table
313
- creatives = Collavre::Creative.arel_table
314
-
315
- user_creative_origins_sql = Collavre::Creative
316
- .where(user_id: Current.user.id)
317
- .select("COALESCE(origin_id, id) AS origin_id")
318
- .to_sql
319
-
320
- shared_by_me_scope = Collavre::CreativeShare
321
- .joins(:creative)
322
- .where.not(permission: Collavre::CreativeShare.permissions[:no_access])
323
- .where(
324
- creative_shares[:shared_by_id].eq(Current.user.id)
325
- .or(
326
- creative_shares[:shared_by_id].eq(nil).and(creatives[:user_id].eq(Current.user.id))
327
- )
328
- .or(
329
- creatives[:id].in(Arel.sql("(#{user_creative_origins_sql})"))
330
- )
331
- )
332
-
333
- shared_with_me_scope = Collavre::CreativeShare
334
- .joins(:creative)
335
- .where(user_id: Current.user.id)
336
- .where.not(permission: Collavre::CreativeShare.permissions[:no_access])
337
-
338
- contact_ids_sql = [
339
- Current.user.contacts.select("contact_user_id AS user_id").to_sql,
340
- shared_by_me_scope.select("creative_shares.user_id AS user_id").to_sql,
341
- shared_with_me_scope.select("COALESCE(creative_shares.shared_by_id, creatives.user_id) AS user_id").to_sql
342
- ].join(" UNION ")
343
-
344
- contact_users_relation = Collavre::User.where(
345
- id: Collavre::User.from("(#{contact_ids_sql}) AS contact_ids").select(:user_id)
346
- )
347
-
348
- @total_contacts = contact_users_relation.count
349
- @total_contact_pages = [ (@total_contacts.to_f / per_page).ceil, 1 ].max
350
- paged_users = contact_users_relation
351
- .includes(avatar_attachment: :blob)
352
- .order(:name, :id)
353
- .offset((@contact_page - 1) * per_page)
354
- .limit(per_page)
355
-
356
- existing_contacts = Current.user.contacts.includes(contact_user: [ avatar_attachment: :blob ]).index_by(&:contact_user_id)
357
- @contacts = paged_users.map do |user|
358
- existing_contacts[user.id] || Collavre::Contact.new(user: Current.user, contact_user: user)
359
- end
360
-
361
- @last_login_map = Collavre::Session.where(user_id: paged_users.map(&:id)).group(:user_id).maximum(:updated_at)
362
-
363
- shares_from_me = shared_by_me_scope
364
- .where(user_id: paged_users.map(&:id))
365
- .includes(:creative)
366
-
367
- @shared_by_me = shares_from_me.group_by(&:user_id).transform_values { |shares| shares.map(&:creative) }
368
-
369
- shares_to_me = Collavre::CreativeShare
370
- .joins(:creative)
371
- .where(user_id: Current.user.id)
372
- .where.not(permission: Collavre::CreativeShare.permissions[:no_access])
373
- .where(
374
- creative_shares[:shared_by_id].in(paged_users.map(&:id))
375
- .or(creative_shares[:shared_by_id].eq(nil).and(creatives[:user_id].in(paged_users.map(&:id))))
376
- )
377
- .includes(:creative)
378
-
379
- @shared_with_me = shares_to_me.group_by(&:sharer_id)
380
- .transform_values { |shares| shares.map(&:creative) }
381
- end
382
-
383
- def user_params
384
- params.require(:user).permit(:email, :password, :password_confirmation, :name)
385
- end
386
-
387
- def profile_params
388
- params.require(:user).permit(
389
- :avatar,
390
- :avatar_url,
391
- :display_level,
392
- :completion_mark,
393
- :theme,
394
- :name,
395
- :notifications_enabled,
396
- :calendar_id,
397
- :timezone,
398
- :locale
399
- ).tap do |p|
400
- p[:locale] = normalize_supported_locale(p[:locale]) if p.key?(:locale)
401
- end
402
- end
403
-
404
- def notification_settings_params
405
- params.require(:user).permit(:notifications_enabled)
406
- end
3
+ include UsersController::Registration
4
+ include UsersController::AiUserManagement
5
+ include UsersController::ContactManagement
6
+ include UsersController::AdminOperations
7
+ include UsersController::ProfileAndSettings
407
8
  end
408
9
  end
@@ -0,0 +1,108 @@
1
+ module Collavre
2
+ module Comments
3
+ module ApprovalActions
4
+ extend ActiveSupport::Concern
5
+
6
+ def approve
7
+ status = @comment.approval_status(Current.user)
8
+ if status != :ok
9
+ error_key = case status
10
+ when :invalid_action_format then "collavre.comments.approve_invalid_format"
11
+ when :missing_action then "collavre.comments.approve_missing_action"
12
+ when :missing_approver then "collavre.comments.approve_missing_approver"
13
+ when :admin_required then "collavre.comments.approve_admin_required"
14
+ else "collavre.comments.approve_not_allowed"
15
+ end
16
+ http_status = case status
17
+ when :invalid_action_format, :missing_action, :missing_approver
18
+ :unprocessable_entity
19
+ else
20
+ :forbidden
21
+ end
22
+ render json: { error: I18n.t(error_key) }, status: http_status and return
23
+ end
24
+
25
+ begin
26
+ ::Comments::ActionExecutor.new(comment: @comment, executor: Current.user).call
27
+ @comment = Comment.with_attached_images.includes(:comment_reactions, :comment_versions, :selected_version).find(@comment.id)
28
+ render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }
29
+ rescue ::Comments::ActionExecutor::ExecutionError => e
30
+ render json: { error: e.message }, status: :unprocessable_entity
31
+ end
32
+ end
33
+
34
+ def update_action
35
+ # Initial checks outside the lock
36
+ executed_error = false
37
+ update_success = false
38
+ approver_mismatch_error = false
39
+ status_error_key = nil
40
+ status_http_status = nil
41
+ validation_error_message = nil
42
+
43
+ @comment.with_lock do
44
+ @comment.reload
45
+
46
+ status_in_lock = @comment.approval_status(Current.user)
47
+ # Allow repairing invalid format if user is approver
48
+ if status_in_lock == :invalid_action_format
49
+ if @comment.approver_id == Current.user&.id
50
+ status_in_lock = :ok
51
+ else
52
+ status_in_lock = :not_allowed
53
+ end
54
+ end
55
+
56
+ if status_in_lock != :ok
57
+ approver_mismatch_error = true
58
+ status_error_key = case status_in_lock
59
+ when :invalid_action_format then "collavre.comments.approve_invalid_format"
60
+ when :missing_action then "collavre.comments.approve_missing_action"
61
+ when :missing_approver then "collavre.comments.approve_missing_approver"
62
+ when :admin_required then "collavre.comments.approve_admin_required"
63
+ else "collavre.comments.approve_not_allowed"
64
+ end
65
+ status_http_status = case status_in_lock
66
+ when :invalid_action_format, :missing_action, :missing_approver
67
+ :unprocessable_entity
68
+ else
69
+ :forbidden
70
+ end
71
+ elsif @comment.action_executed_at.present?
72
+ executed_error = true
73
+ else
74
+ action_payload = params.dig(:comment, :action)
75
+ if action_payload.blank?
76
+ validation_error_message = I18n.t("collavre.comments.approve_missing_action")
77
+ else
78
+ begin
79
+ validator = ::Comments::ActionValidator.new(comment: @comment)
80
+ parsed_payload = validator.validate!(action_payload)
81
+ normalized_action = JSON.pretty_generate(parsed_payload)
82
+ update_success = @comment.update(action: normalized_action)
83
+ rescue ::Comments::ActionValidator::ValidationError => e
84
+ validation_error_message = e.message
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ if approver_mismatch_error
91
+ render json: { error: I18n.t(status_error_key) }, status: status_http_status
92
+ elsif validation_error_message
93
+ render json: { error: validation_error_message }, status: :unprocessable_entity
94
+ elsif executed_error
95
+ render json: { error: I18n.t("collavre.comments.approve_already_executed") }, status: :unprocessable_entity
96
+ elsif update_success
97
+ @comment = Comment.with_attached_images.includes(:comment_reactions, :comment_versions, :selected_version).find(@comment.id)
98
+ render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }
99
+ else
100
+ error_message = @comment.errors.full_messages.to_sentence.presence || I18n.t("collavre.comments.action_update_error")
101
+ render json: { error: error_message }, status: :unprocessable_entity
102
+ end
103
+ rescue ::Comments::ActionValidator::ValidationError => e
104
+ render json: { error: e.message }, status: :unprocessable_entity
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,55 @@
1
+ module Collavre
2
+ module Comments
3
+ module BatchOperations
4
+ extend ActiveSupport::Concern
5
+
6
+ MAX_BATCH_DELETE = 100
7
+
8
+ def batch_destroy
9
+ comment_ids = Array(params[:comment_ids]).map(&:to_i).uniq.first(MAX_BATCH_DELETE)
10
+ if comment_ids.empty?
11
+ render json: { error: I18n.t("collavre.comments.batch_delete_no_selection") }, status: :unprocessable_entity and return
12
+ end
13
+
14
+ is_admin = @creative.has_permission?(Current.user, :admin)
15
+ is_creative_owner = @creative.user == Current.user
16
+
17
+ visible_scope = @creative.comments.where(
18
+ "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
19
+ false, Current.user.id, Current.user.id
20
+ )
21
+ comments = visible_scope.where(id: comment_ids).to_a
22
+
23
+ if comments.length != comment_ids.length
24
+ render json: { error: I18n.t("collavre.comments.batch_delete_not_found") }, status: :not_found and return
25
+ end
26
+
27
+ # Check permissions: user must own all comments, or be admin/creative owner
28
+ unless is_admin || is_creative_owner
29
+ unauthorized = comments.reject { |c| c.user == Current.user }
30
+ if unauthorized.any?
31
+ render json: { error: I18n.t("collavre.comments.not_owner") }, status: :forbidden and return
32
+ end
33
+ end
34
+
35
+ Comment.where(id: comments.map(&:id)).destroy_all
36
+
37
+ head :no_content
38
+ end
39
+
40
+ def move
41
+ result = CommentMoveService.new(creative: @creative, user: Current.user).call(
42
+ comment_ids: params[:comment_ids],
43
+ target_creative_id: params[:target_creative_id],
44
+ target_topic_id: params[:target_topic_id]
45
+ )
46
+ render json: result
47
+ rescue CommentMoveService::MoveError => e
48
+ status = e.message == I18n.t("collavre.comments.move_not_allowed") ? :forbidden : :unprocessable_entity
49
+ render json: { error: e.message }, status: status
50
+ rescue ActiveRecord::RecordInvalid => e
51
+ render json: { error: e.record.errors.full_messages.to_sentence.presence || I18n.t("collavre.comments.move_error") }, status: :unprocessable_entity
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,46 @@
1
+ module Collavre
2
+ module Comments
3
+ module Conversion
4
+ extend ActiveSupport::Concern
5
+
6
+ def convert
7
+ unless can_convert_comment?
8
+ render json: { error: I18n.t("collavre.comments.convert_not_allowed") }, status: :forbidden and return
9
+ end
10
+
11
+ created_creatives = ::MarkdownImporter.import(
12
+ @comment.content,
13
+ parent: @creative,
14
+ user: @creative.user,
15
+ create_root: true
16
+ )
17
+
18
+ primary_creative = created_creatives.first
19
+ system_message = build_convert_system_message(primary_creative) if primary_creative
20
+
21
+ @comment.destroy
22
+
23
+ if system_message.present?
24
+ Current.set(session: nil) do
25
+ @creative.comments.create!(content: system_message, user: nil)
26
+ end
27
+ end
28
+
29
+ head :no_content
30
+ end
31
+
32
+ private
33
+
34
+ def can_convert_comment?
35
+ @comment.user == Current.user || @creative.has_permission?(Current.user, :admin)
36
+ end
37
+
38
+ def build_convert_system_message(creative)
39
+ title = helpers.strip_tags(creative.description).to_s.strip
40
+ title = I18n.t("collavre.comments.convert_system_message_default_title") if title.blank?
41
+ url = creative_path(creative)
42
+ I18n.t("collavre.comments.convert_system_message", title: title, url: url)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,74 @@
1
+ module Collavre
2
+ module UsersController::AdminOperations
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ before_action :require_system_admin!, only: [ :index, :grant_system_admin, :revoke_system_admin, :unlock, :lock ]
7
+ end
8
+
9
+ def index
10
+ @users = Collavre::User.includes(:sessions, :devices)
11
+ end
12
+
13
+ def destroy
14
+ @user = Collavre::User.find(params[:id])
15
+
16
+ if @user == Current.user
17
+ redirect_to users_path, alert: I18n.t("collavre.users.destroy.cannot_delete_self")
18
+ return
19
+ end
20
+
21
+ allowed = Current.user.system_admin? ||
22
+ (@user.ai_user? && @user.created_by_id == Current.user.id)
23
+
24
+ unless allowed
25
+ fallback = user_path(Current.user, tab: "contacts")
26
+ redirect_back fallback_location: fallback, alert: I18n.t("collavre.users.destroy.not_authorized")
27
+ return
28
+ end
29
+
30
+ if @user.destroy
31
+ fallback = Current.user.system_admin? ? users_path : user_path(Current.user, tab: "contacts")
32
+ redirect_back fallback_location: fallback, notice: I18n.t("collavre.users.destroy.success")
33
+ else
34
+ redirect_to users_path, alert: I18n.t("collavre.users.destroy.failure")
35
+ end
36
+ end
37
+
38
+ def grant_system_admin
39
+ @user = Collavre::User.find(params[:id])
40
+
41
+ if @user.update(system_admin: true)
42
+ redirect_to users_path, notice: I18n.t("collavre.users.system_admin.granted")
43
+ else
44
+ redirect_to users_path, alert: I18n.t("collavre.users.system_admin.failed")
45
+ end
46
+ end
47
+
48
+ def revoke_system_admin
49
+ @user = Collavre::User.find(params[:id])
50
+
51
+ if @user.update(system_admin: false)
52
+ redirect_to users_path, notice: I18n.t("collavre.users.system_admin.revoked")
53
+ else
54
+ redirect_to users_path, alert: I18n.t("collavre.users.system_admin.failed")
55
+ end
56
+ end
57
+
58
+ def unlock
59
+ @user = Collavre::User.find(params[:id])
60
+ @user.unlock_account!
61
+ redirect_to users_path, notice: I18n.t("collavre.users.unlock.success", name: @user.display_name)
62
+ end
63
+
64
+ def lock
65
+ @user = Collavre::User.find(params[:id])
66
+ if @user == Current.user
67
+ redirect_to users_path, alert: I18n.t("collavre.users.lock.cannot_lock_self")
68
+ return
69
+ end
70
+ @user.lock_account!
71
+ redirect_to users_path, notice: I18n.t("collavre.users.lock.success", name: @user.display_name)
72
+ end
73
+ end
74
+ end