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,5 +1,9 @@
1
1
  module Collavre
2
2
  class CommentsController < ApplicationController
3
+ include Collavre::Comments::ApprovalActions
4
+ include Collavre::Comments::Conversion
5
+ include Collavre::Comments::BatchOperations
6
+
3
7
  before_action :set_creative
4
8
  before_action :set_comment, only: [ :destroy, :show, :update, :convert, :approve, :update_action ]
5
9
 
@@ -11,6 +15,9 @@ module Collavre
11
15
  @creatives = []
12
16
  @shared_list = @creative.all_shared_users
13
17
  @auto_fullscreen = true
18
+ # Set params[:id] so the tree URL in creatives/index loads children of this
19
+ # creative instead of the root list.
20
+ params[:id] = @creative.id.to_s
14
21
  # Prepend creatives prefix so partials like 'add_button' resolve to collavre/creatives/_add_button
15
22
  lookup_context.prefixes.prepend "collavre/creatives"
16
23
  render "collavre/creatives/index"
@@ -25,7 +32,7 @@ module Collavre
25
32
  Current.user.id,
26
33
  Current.user.id
27
34
  )
28
- scope = visible_scope.with_attached_images.includes(:topic, :comment_reactions)
35
+ scope = visible_scope.with_attached_images.includes(:topic, :comment_reactions, :comment_versions)
29
36
 
30
37
  if params[:search].present?
31
38
  search_term = ActiveRecord::Base.sanitize_sql_like(params[:search].to_s.strip.downcase)
@@ -191,7 +198,7 @@ module Collavre
191
198
  )
192
199
  end
193
200
  end
194
- @comment = Comment.with_attached_images.includes(:comment_reactions).find(@comment.id)
201
+ @comment = Comment.with_attached_images.includes(:comment_reactions, :comment_versions, :selected_version).find(@comment.id)
195
202
  render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }, status: :created
196
203
  else
197
204
  render json: { errors: @comment.errors.full_messages }, status: :unprocessable_entity
@@ -206,7 +213,7 @@ module Collavre
206
213
  end
207
214
 
208
215
  if @comment.update(safe_params)
209
- @comment = Comment.with_attached_images.includes(:comment_reactions).find(@comment.id)
216
+ @comment = Comment.with_attached_images.includes(:comment_reactions, :comment_versions, :selected_version).find(@comment.id)
210
217
  render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }
211
218
  else
212
219
  render json: { errors: @comment.errors.full_messages }, status: :unprocessable_entity
@@ -248,132 +255,7 @@ module Collavre
248
255
  end
249
256
  end
250
257
 
251
- def convert
252
- unless can_convert_comment?
253
- render json: { error: I18n.t("collavre.comments.convert_not_allowed") }, status: :forbidden and return
254
- end
255
-
256
- created_creatives = ::MarkdownImporter.import(
257
- @comment.content,
258
- parent: @creative,
259
- user: @creative.user,
260
- create_root: true
261
- )
262
-
263
- primary_creative = created_creatives.first
264
- system_message = build_convert_system_message(primary_creative) if primary_creative
265
-
266
- @comment.destroy
267
-
268
- if system_message.present?
269
- Current.set(session: nil) do
270
- @creative.comments.create!(content: system_message, user: nil)
271
- end
272
- end
273
-
274
- head :no_content
275
- end
276
-
277
- def approve
278
- status = @comment.approval_status(Current.user)
279
- if status != :ok
280
- error_key = case status
281
- when :invalid_action_format then "collavre.comments.approve_invalid_format"
282
- when :missing_action then "collavre.comments.approve_missing_action"
283
- when :missing_approver then "collavre.comments.approve_missing_approver"
284
- when :admin_required then "collavre.comments.approve_admin_required"
285
- else "collavre.comments.approve_not_allowed"
286
- end
287
- http_status = case status
288
- when :invalid_action_format, :missing_action, :missing_approver
289
- :unprocessable_entity
290
- else
291
- :forbidden
292
- end
293
- render json: { error: I18n.t(error_key) }, status: http_status and return
294
- end
295
-
296
- begin
297
- ::Comments::ActionExecutor.new(comment: @comment, executor: Current.user).call
298
- @comment = Comment.with_attached_images.includes(:comment_reactions).find(@comment.id)
299
- render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }
300
- rescue ::Comments::ActionExecutor::ExecutionError => e
301
- render json: { error: e.message }, status: :unprocessable_entity
302
- end
303
- end
304
-
305
- def update_action
306
- # Initial checks outside the lock
307
- executed_error = false
308
- update_success = false
309
- approver_mismatch_error = false
310
- status_error_key = nil
311
- status_http_status = nil
312
- validation_error_message = nil
313
-
314
- @comment.with_lock do
315
- @comment.reload
316
-
317
- status_in_lock = @comment.approval_status(Current.user)
318
- # Allow repairing invalid format if user is approver
319
- if status_in_lock == :invalid_action_format
320
- if @comment.approver_id == Current.user&.id
321
- status_in_lock = :ok
322
- else
323
- status_in_lock = :not_allowed
324
- end
325
- end
326
-
327
- if status_in_lock != :ok
328
- approver_mismatch_error = true
329
- status_error_key = case status_in_lock
330
- when :invalid_action_format then "collavre.comments.approve_invalid_format"
331
- when :missing_action then "collavre.comments.approve_missing_action"
332
- when :missing_approver then "collavre.comments.approve_missing_approver"
333
- when :admin_required then "collavre.comments.approve_admin_required"
334
- else "collavre.comments.approve_not_allowed"
335
- end
336
- status_http_status = case status_in_lock
337
- when :invalid_action_format, :missing_action, :missing_approver
338
- :unprocessable_entity
339
- else
340
- :forbidden
341
- end
342
- elsif @comment.action_executed_at.present?
343
- executed_error = true
344
- else
345
- action_payload = params.dig(:comment, :action)
346
- if action_payload.blank?
347
- validation_error_message = I18n.t("collavre.comments.approve_missing_action")
348
- else
349
- begin
350
- validator = ::Comments::ActionValidator.new(comment: @comment)
351
- parsed_payload = validator.validate!(action_payload)
352
- normalized_action = JSON.pretty_generate(parsed_payload)
353
- update_success = @comment.update(action: normalized_action)
354
- rescue ::Comments::ActionValidator::ValidationError => e
355
- validation_error_message = e.message
356
- end
357
- end
358
- end
359
- end
360
258
 
361
- if approver_mismatch_error
362
- render json: { error: I18n.t(status_error_key) }, status: status_http_status
363
- elsif validation_error_message
364
- render json: { error: validation_error_message }, status: :unprocessable_entity
365
- elsif executed_error
366
- render json: { error: I18n.t("collavre.comments.approve_already_executed") }, status: :unprocessable_entity
367
- elsif update_success
368
- @comment = Comment.with_attached_images.includes(:comment_reactions).find(@comment.id)
369
- render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }
370
- else
371
- error_message = @comment.errors.full_messages.to_sentence.presence || I18n.t("collavre.comments.action_update_error")
372
- render json: { error: error_message }, status: :unprocessable_entity
373
- end
374
- rescue ::Comments::ActionValidator::ValidationError => e
375
- render json: { error: e.message }, status: :unprocessable_entity
376
- end
377
259
 
378
260
  def show
379
261
  redirect_to creative_path(@creative, comment_id: @comment.id)
@@ -406,24 +288,14 @@ module Collavre
406
288
  render json: CommandMenuService.new(user: Current.user).items
407
289
  end
408
290
 
409
- def move
410
- result = CommentMoveService.new(creative: @creative, user: Current.user).call(
411
- comment_ids: params[:comment_ids],
412
- target_creative_id: params[:target_creative_id],
413
- target_topic_id: params[:target_topic_id]
414
- )
415
- render json: result
416
- rescue CommentMoveService::MoveError => e
417
- status = e.message == I18n.t("collavre.comments.move_not_allowed") ? :forbidden : :unprocessable_entity
418
- render json: { error: e.message }, status: status
419
- rescue ActiveRecord::RecordInvalid => e
420
- render json: { error: e.record.errors.full_messages.to_sentence.presence || I18n.t("collavre.comments.move_error") }, status: :unprocessable_entity
421
- end
422
291
 
423
292
  private
424
293
 
425
294
  def set_creative
426
295
  @creative = Creative.find(params[:creative_id]).effective_origin
296
+ unless @creative.has_permission?(Current.user, :read)
297
+ render json: { error: I18n.t("collavre.creatives.errors.no_permission") }, status: :forbidden
298
+ end
427
299
  end
428
300
 
429
301
  def set_comment
@@ -438,18 +310,7 @@ module Collavre
438
310
  end
439
311
 
440
312
  def comment_params
441
- params.require(:comment).permit(:content, :private, :topic_id, :quoted_comment_id, :quoted_text, images: [])
442
- end
443
-
444
- def can_convert_comment?
445
- @comment.user == Current.user || @creative.has_permission?(Current.user, :admin)
446
- end
447
-
448
- def build_convert_system_message(creative)
449
- title = helpers.strip_tags(creative.description).to_s.strip
450
- title = I18n.t("collavre.comments.convert_system_message_default_title") if title.blank?
451
- url = creative_path(creative)
452
- I18n.t("collavre.comments.convert_system_message", title: title, url: url)
313
+ params.require(:comment).permit(:content, :private, :topic_id, :quoted_comment_id, :quoted_text, :review_type, images: [])
453
314
  end
454
315
 
455
316
  def current_topic_context
@@ -0,0 +1,30 @@
1
+ module Collavre
2
+ module Concerns
3
+ module Exportable
4
+ extend ActiveSupport::Concern
5
+
6
+ def export_markdown
7
+ creatives = if params[:parent_id]
8
+ parent_creative = Creative.find(params[:parent_id])
9
+ effective_origin = parent_creative.effective_origin
10
+ unless parent_creative.has_permission?(Current.user, :read) &&
11
+ effective_origin.has_permission?(Current.user, :read)
12
+ render plain: t("collavre.creatives.errors.no_permission"), status: :forbidden and return
13
+ end
14
+ [ effective_origin ]
15
+ else
16
+ Creative.where(parent_id: nil).map(&:effective_origin).uniq.select do |creative|
17
+ creative.has_permission?(Current.user, :read)
18
+ end
19
+ end
20
+
21
+ if creatives.empty?
22
+ render plain: t("collavre.creatives.errors.no_permission"), status: :forbidden and return
23
+ end
24
+
25
+ markdown = helpers.render_creative_tree_markdown(creatives)
26
+ send_data markdown, filename: "creatives.md", type: "text/markdown"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,28 @@
1
+ module Collavre
2
+ module Concerns
3
+ module Shareable
4
+ extend ActiveSupport::Concern
5
+
6
+ def request_permission
7
+ creative = @creative.effective_origin
8
+ if creative.user == Current.user || creative.has_permission?(Current.user, :read)
9
+ return head :unprocessable_entity
10
+ end
11
+
12
+ short_title = helpers.strip_tags(creative.effective_origin.description).truncate(10)
13
+
14
+ InboxItem.create!(
15
+ owner: creative.user,
16
+ message_key: "inbox.permission_requested",
17
+ message_params: { user: Current.user.display_name, short_title: short_title },
18
+ link: creative_url(
19
+ creative,
20
+ Rails.application.config.action_mailer.default_url_options.merge(share_request: Current.user.email)
21
+ )
22
+ )
23
+
24
+ head :ok
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,37 @@
1
+ module Collavre
2
+ module Concerns
3
+ module SlideViewable
4
+ extend ActiveSupport::Concern
5
+
6
+ def slide_view
7
+ unless @creative.has_permission?(Current.user, :read)
8
+ if Current.user
9
+ redirect_to creatives_path, alert: t("collavre.creatives.errors.no_permission")
10
+ else
11
+ request_authentication
12
+ end
13
+ return
14
+ end
15
+
16
+ @slide_ids = []
17
+ @root_depth = @creative.ancestors.count
18
+ build_slide_ids(@creative)
19
+ render layout: "collavre/slide"
20
+ end
21
+
22
+ private
23
+
24
+ def build_slide_ids(node)
25
+ return unless node.has_permission?(Current.user, :read)
26
+
27
+ @slide_ids << node.id
28
+ children = node.children.order(:sequence)
29
+ if node.origin_id.present?
30
+ linked_children = node.linked_children
31
+ children = (children + linked_children).uniq.sort_by(&:sequence)
32
+ end
33
+ children.each { |child| build_slide_ids(child) }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,141 @@
1
+ module Collavre
2
+ module Concerns
3
+ module TreeManageable
4
+ extend ActiveSupport::Concern
5
+
6
+ def reorder
7
+ dragged_ids = Array(params[:dragged_ids]).map(&:presence).compact
8
+ target_id = params[:target_id]
9
+ direction = params[:direction]
10
+
11
+ if dragged_ids.any?
12
+ reorderer.reorder_multiple(
13
+ dragged_ids: dragged_ids,
14
+ target_id: target_id,
15
+ direction: direction
16
+ )
17
+ else
18
+ reorderer.reorder(
19
+ dragged_id: params[:dragged_id],
20
+ target_id: target_id,
21
+ direction: direction
22
+ )
23
+ end
24
+ head :ok
25
+ rescue ::Creatives::Reorderer::Error
26
+ head :unprocessable_entity
27
+ end
28
+
29
+ def link_drop
30
+ result = reorderer.link_drop(
31
+ dragged_id: params[:dragged_id],
32
+ target_id: params[:target_id],
33
+ direction: params[:direction]
34
+ )
35
+
36
+ new_creative = result.new_creative
37
+ level = new_creative.ancestors.count + 1
38
+ nodes = build_tree(
39
+ [ new_creative ],
40
+ params: params,
41
+ expanded_state_map: {},
42
+ level: level
43
+ )
44
+
45
+ render json: {
46
+ nodes: nodes,
47
+ creative_id: new_creative.id,
48
+ parent_id: result.parent&.id,
49
+ direction: result.direction
50
+ }
51
+ rescue ::Creatives::Reorderer::Error
52
+ head :unprocessable_entity
53
+ end
54
+
55
+ def append_as_parent
56
+ @parent_creative = Creative.find_by(id: params[:parent_id]).parent
57
+ redirect_to new_creative_path(parent_id: @parent_creative&.id, child_id: params[:parent_id], tags: params[:tags])
58
+ end
59
+
60
+ def append_below
61
+ target = Creative.find_by(id: params[:creative_id])
62
+ redirect_to new_creative_path(parent_id: target&.parent_id, after_id: target&.id, tags: params[:tags])
63
+ end
64
+
65
+ def children
66
+ parent = Creative.find(params[:id])
67
+ effective = parent.effective_origin
68
+ # user_id for expanded_state lookup - use owner's state for anonymous users
69
+ state_user_id = Current.user&.id || effective.user_id
70
+
71
+ # HTTP caching disabled for children endpoint:
72
+ # Response depends on child updates, permission changes (CreativeSharesCache),
73
+ # and CreativeExpandedState. Tracking all dependencies reliably is expensive
74
+ # (requires descendant_ids query). Stale 304 responses could leak data after
75
+ # permission revocation. Re-enable when a cheap version key mechanism exists.
76
+ # Use private + no-store to prevent any caching (proxy or browser).
77
+ response.headers["Cache-Control"] = "private, no-store"
78
+
79
+ has_filters = params[:tags].present? || params[:min_progress].present? || params[:max_progress].present?
80
+ if has_filters
81
+ result = ::Creatives::IndexQuery.new(user: Current.user, params: params.merge(id: params[:id])).call
82
+ render_children_json(parent, state_user_id, result.allowed_creative_ids, result.progress_map)
83
+ else
84
+ render_children_json(parent, state_user_id, nil, nil)
85
+ end
86
+ end
87
+
88
+ def unconvert
89
+ base_creative = @creative.effective_origin
90
+ parent = base_creative.parent
91
+ if parent.nil?
92
+ render json: { error: t("collavre.creatives.index.unconvert_no_parent") }, status: :unprocessable_entity and return
93
+ end
94
+
95
+ unless parent.has_permission?(Current.user, :feedback)
96
+ render json: { error: t("collavre.creatives.errors.no_permission") }, status: :forbidden and return
97
+ end
98
+
99
+ unless base_creative.has_permission?(Current.user, :admin)
100
+ render json: { error: t("collavre.creatives.errors.no_permission") }, status: :forbidden and return
101
+ end
102
+
103
+ markdown = helpers.render_creative_tree_markdown([ base_creative ])
104
+ comment = nil
105
+
106
+ ActiveRecord::Base.transaction do
107
+ comment = parent.effective_origin.comments.create!(content: markdown, user: Current.user)
108
+ base_creative.descendants.each(&:destroy!)
109
+ base_creative.destroy!
110
+ end
111
+
112
+ render json: { comment_id: comment.id }, status: :created
113
+ rescue ActiveRecord::RecordInvalid => e
114
+ render json: { error: e.record.errors.full_messages.to_sentence }, status: :unprocessable_entity
115
+ end
116
+
117
+ private
118
+
119
+ def render_children_json(parent, user_id, allowed_ids, progress_map)
120
+ expanded_state_map = CreativeExpandedState
121
+ .where(user_id: user_id, creative_id: parent.id)
122
+ .first&.expanded_status || {}
123
+ children = parent.children_with_permission(Current.user)
124
+
125
+ level = params[:level].to_i
126
+ json_level = level.zero? ? 1 : level
127
+ render json: {
128
+ creatives: build_tree(
129
+ children,
130
+ params: params,
131
+ expanded_state_map: expanded_state_map,
132
+ level: json_level,
133
+ select_mode: params[:select_mode] == "1",
134
+ allowed_creative_ids: allowed_ids,
135
+ progress_map: progress_map
136
+ )
137
+ }
138
+ end
139
+ end
140
+ end
141
+ end
@@ -1,5 +1,7 @@
1
1
  module Collavre
2
2
  class CreativeImportsController < ApplicationController
3
+ # Allow unauthenticated access so we can return JSON 401 instead of HTML redirect
4
+ # for API/fetch callers (import_controller.js uses csrfFetch with Accept: application/json)
3
5
  allow_unauthenticated_access only: :create
4
6
 
5
7
  def create
@@ -8,6 +10,10 @@ module Collavre
8
10
  end
9
11
 
10
12
  parent = params[:parent_id].present? ? Creative.find_by(id: params[:parent_id]) : nil
13
+ if parent && !parent.has_permission?(Current.user, :write)
14
+ render json: { error: I18n.t("collavre.creatives.errors.no_permission") }, status: :forbidden and return
15
+ end
16
+
11
17
  created = ::Creatives::Importer.new(file: params[:markdown], user: Current.user, parent: parent).call
12
18
 
13
19
  if created.any?
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class CreativeInvitationsController < ApplicationController
5
+ before_action :set_invitation
6
+ before_action :authorize_admin!
7
+
8
+ def update
9
+ if @invitation.update(permission: params[:permission])
10
+ respond_to do |format|
11
+ format.html { redirect_back fallback_location: main_app.root_path, notice: t("collavre.creatives.share.permission_updated") }
12
+ format.json { render json: { permission: @invitation.permission }, status: :ok }
13
+ end
14
+ else
15
+ respond_to do |format|
16
+ format.html { redirect_back fallback_location: main_app.root_path, alert: @invitation.errors.full_messages.to_sentence }
17
+ format.json { render json: { error: @invitation.errors.full_messages.to_sentence }, status: :unprocessable_entity }
18
+ end
19
+ end
20
+ end
21
+
22
+ def destroy
23
+ @invitation.destroy
24
+ respond_to do |format|
25
+ format.html { redirect_back fallback_location: main_app.root_path, notice: t("collavre.contacts.org_chart.invite_cancelled") }
26
+ format.json { head :no_content }
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def set_invitation
33
+ @invitation = Invitation.find(params[:id])
34
+ @creative = @invitation.creative
35
+ end
36
+
37
+ def authorize_admin!
38
+ return if @creative.has_permission?(Current.user, :admin)
39
+
40
+ respond_to do |format|
41
+ format.html { redirect_back fallback_location: main_app.root_path, alert: t("collavre.creatives.errors.no_permission") }
42
+ format.json { render json: { error: t("collavre.creatives.errors.no_permission") }, status: :forbidden }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -51,7 +51,7 @@ module Collavre
51
51
  private
52
52
 
53
53
  def tagger
54
- ::Creatives::PlanTagger.new(plan_id: params[:plan_id], creative_ids: parsed_creative_ids)
54
+ ::Creatives::PlanTagger.new(plan_id: params[:plan_id], creative_ids: parsed_creative_ids, user: Current.user)
55
55
  end
56
56
 
57
57
  def parsed_creative_ids
@@ -1,22 +1,52 @@
1
1
  module Collavre
2
2
  class CreativeSharesController < ApplicationController
3
+ def index
4
+ @creative = Creative.find(params[:creative_id])
5
+ unless @creative.has_permission?(Current.user, :admin)
6
+ head :forbidden and return
7
+ end
8
+
9
+ @shared_list = CreativeShare.where(creative: @creative)
10
+ .includes(user: [ avatar_attachment: :blob ])
11
+
12
+ @pending_invitations = Invitation.where(creative: @creative, accepted_at: nil)
13
+ .where("expires_at > ?", Time.current)
14
+ .order(created_at: :desc)
15
+
16
+ render partial: "collavre/creatives/share_modal", layout: false
17
+ end
18
+
3
19
  def create
4
20
  @creative = Creative.find(params[:creative_id]).effective_origin
5
21
 
22
+ unless @creative.has_permission?(Current.user, :admin)
23
+ respond_to do |format|
24
+ format.html { redirect_back fallback_location: creatives_path, alert: t("collavre.creatives.errors.no_permission") }
25
+ format.json { render json: { error: t("collavre.creatives.errors.no_permission") }, status: :forbidden }
26
+ end
27
+ return
28
+ end
29
+
6
30
  user = nil
7
31
  if params[:user_email].present?
8
32
  user = User.find_by(email: params[:user_email])
9
33
  unless user
10
34
  invitation = Invitation.create!(email: params[:user_email], inviter: Current.user, creative: @creative, permission: params[:permission])
11
35
  InvitationMailer.with(invitation: invitation).invite.deliver_later
12
- flash[:notice] = t("collavre.invites.invite_sent")
13
- redirect_back(fallback_location: creatives_path) and return
36
+ respond_to do |format|
37
+ format.html { redirect_back fallback_location: creatives_path, notice: t("collavre.invites.invite_sent") }
38
+ format.json { render json: { notice: t("collavre.invites.invite_sent") }, status: :created }
39
+ end
40
+ return
14
41
  end
15
42
 
16
43
  # Restrict sharing non-searchable AI agents to their owners only
17
44
  if user.ai_user? && !user.searchable? && user.created_by_id != Current.user.id
18
- flash[:alert] = t("collavre.creatives.share.cannot_share_private_ai_agent")
19
- redirect_back(fallback_location: creatives_path) and return
45
+ respond_to do |format|
46
+ format.html { redirect_back fallback_location: creatives_path, alert: t("collavre.creatives.share.cannot_share_private_ai_agent") }
47
+ format.json { render json: { error: t("collavre.creatives.share.cannot_share_private_ai_agent") }, status: :forbidden }
48
+ end
49
+ return
20
50
  end
21
51
  end
22
52
 
@@ -36,14 +66,18 @@ module Collavre
36
66
  Rails.logger.debug "### closest_parent_share = #{closest_parent_share.inspect}, is_param_no_access: #{is_param_no_access}"
37
67
  if closest_parent_share.present?
38
68
  if closest_parent_share.permission == :no_access.to_s
39
- flash[:alert] = t("collavre.creatives.share.can_not_share_by_no_access_in_parent")
40
- redirect_back(fallback_location: creatives_path) and return
69
+ respond_to do |format|
70
+ format.html { redirect_back fallback_location: creatives_path, alert: t("collavre.creatives.share.can_not_share_by_no_access_in_parent") }
71
+ format.json { render json: { error: t("collavre.creatives.share.can_not_share_by_no_access_in_parent") }, status: :unprocessable_entity }
72
+ end
73
+ return
41
74
  else
42
- if is_param_no_access
43
- # can set!
44
- else
45
- flash[:alert] = t("collavre.creatives.share.already_shared_in_parent")
46
- redirect_back(fallback_location: creatives_path) and return
75
+ unless is_param_no_access
76
+ respond_to do |format|
77
+ format.html { redirect_back fallback_location: creatives_path, alert: t("collavre.creatives.share.already_shared_in_parent") }
78
+ format.json { render json: { error: t("collavre.creatives.share.already_shared_in_parent") }, status: :unprocessable_entity }
79
+ end
80
+ return
47
81
  end
48
82
  end
49
83
  end
@@ -57,15 +91,51 @@ module Collavre
57
91
  Contact.ensure(user: Current.user, contact_user: user)
58
92
  Contact.ensure(user: @creative.user, contact_user: user)
59
93
  end
60
- flash[:notice] = t("collavre.creatives.share.shared")
94
+ respond_to do |format|
95
+ format.html { redirect_back fallback_location: creatives_path, notice: t("collavre.creatives.share.shared") }
96
+ format.json { render json: { notice: t("collavre.creatives.share.shared") }, status: :created }
97
+ end
61
98
  else
62
- flash[:alert] = share.errors.full_messages.to_sentence
99
+ respond_to do |format|
100
+ format.html { redirect_back fallback_location: creatives_path, alert: share.errors.full_messages.to_sentence }
101
+ format.json { render json: { error: share.errors.full_messages.to_sentence }, status: :unprocessable_entity }
102
+ end
103
+ end
104
+ end
105
+
106
+ def update
107
+ @creative_share = CreativeShare.find(params[:id])
108
+ unless @creative_share.creative.has_permission?(Current.user, :admin)
109
+ respond_to do |format|
110
+ format.html { redirect_back fallback_location: main_app.root_path, alert: t("collavre.creatives.errors.no_permission") }
111
+ format.json { render json: { error: t("collavre.creatives.errors.no_permission") }, status: :forbidden }
112
+ end
113
+ return
114
+ end
115
+
116
+ if @creative_share.update(permission: params[:permission])
117
+ respond_to do |format|
118
+ format.html { redirect_back fallback_location: main_app.root_path, notice: t("collavre.creatives.share.permission_updated") }
119
+ format.json { render json: { permission: @creative_share.permission }, status: :ok }
120
+ end
121
+ else
122
+ respond_to do |format|
123
+ format.html { redirect_back fallback_location: main_app.root_path, alert: @creative_share.errors.full_messages.to_sentence }
124
+ format.json { render json: { error: @creative_share.errors.full_messages.to_sentence }, status: :unprocessable_entity }
125
+ end
63
126
  end
64
- redirect_back(fallback_location: creatives_path)
65
127
  end
66
128
 
67
129
  def destroy
68
130
  @creative_share = CreativeShare.find(params[:id])
131
+ unless @creative_share.creative.has_permission?(Current.user, :admin)
132
+ respond_to do |format|
133
+ format.html { redirect_back fallback_location: main_app.root_path, alert: t("collavre.creatives.errors.no_permission") }
134
+ format.json { render json: { error: t("collavre.creatives.errors.no_permission") }, status: :forbidden }
135
+ end
136
+ return
137
+ end
138
+
69
139
  @creative_share.destroy
70
140
  # remove linked creative if it exists
71
141
  linked_creative = Creative.find_by(origin_id: @creative_share.creative_id, user_id: @creative_share.user_id)