Class: Collavre::CommentsController

Inherits:
ApplicationController show all
Includes:
Collavre::Comments::ApprovalActions, Collavre::Comments::BatchOperations, Collavre::Comments::CommentScoping, Collavre::Comments::Conversion
Defined in:
app/controllers/collavre/comments_controller.rb

Constant Summary

Constants included from Collavre::Comments::BatchOperations

Collavre::Comments::BatchOperations::MAX_BATCH_DELETE

Instance Method Summary collapse

Methods included from Collavre::Comments::BatchOperations

#batch_destroy, #branch, #merge, #move

Methods included from Collavre::Comments::Conversion

#convert

Methods included from Collavre::Comments::ApprovalActions

#approve, #update_action

Instance Method Details

#commandsObject



278
279
280
281
282
283
284
# File 'app/controllers/collavre/comments_controller.rb', line 278

def commands
  unless @creative.has_permission?(Current.user, :read)
    head :forbidden and return
  end

  render json: CommandMenuService.new(user: Current.user, creative: @creative).items
end

#createObject



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'app/controllers/collavre/comments_controller.rb', line 158

def create
  if @creative.archived?
    render json: { error: I18n.t("collavre.comments.archived_creative") }, status: :forbidden and return
  end

  unless @creative.has_permission?(Current.user, :feedback)
    render json: { error: I18n.t("collavre.comments.no_permission") }, status: :forbidden and return
  end

  comment_attributes = comment_params.except(:images)
  image_attachments = comment_params[:images]

  @comment = @creative.comments.build(comment_attributes)

  validate_topic_id!(@comment.topic_id) or return

  @comment.user = Current.user
  @comment.images.attach(image_attachments) if image_attachments.present?
  response = ::Comments::CommandProcessor.new(comment: @comment, user: Current.user).call
  if response.present?
    @comment.content = "#{@comment.content}\n\n#{response}"
    @comment.skip_dispatch = true
  end
  if @comment.save
    # Cross-post inbox inline replies to the original creative/topic
    InboxReplyService.call(@comment)

    # Dispatch is handled by Comment#after_create_commit callback
    @comment = Comment.with_attached_images.includes(:comment_reactions, :comment_versions, :selected_version).find(@comment.id)
    render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }, status: :created
  else
    render json: { errors: @comment.errors.full_messages }, status: :unprocessable_entity
  end
end

#destroyObject



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'app/controllers/collavre/comments_controller.rb', line 213

def destroy
  if github_synced_content_comment?(@comment)
    render json: { error: I18n.t("collavre.comments.github_synced_readonly") }, status: :forbidden and return
  end

  # @comment is set by before_action
  is_owner = @comment.user == Current.user
  is_admin = @creative.has_permission?(Current.user, :admin)
  is_creative_owner = @creative.user == Current.user

  if is_owner || is_admin || is_creative_owner
    # If admin/creative owner is deleting someone else's comment, send notification
    if (is_admin || is_creative_owner) && !is_owner && @comment.user.present? && !@comment.user.ai_user?
      inbox_creative = Creative.inbox_for(@comment.user)
      creative_path = Collavre::Engine.routes.url_helpers.creative_path(@creative, open_comments: true)
      creative_link = "[#{@creative.creative_snippet}](#{creative_path})"
      msg = I18n.t(
        "inbox.comment_deleted_by_admin",
        admin_name: Current.user.name,
        creative_snippet: creative_link,
        comment_content: @comment.content,
        locale: @comment.user.locale || "en"
      )
      system_topic = inbox_creative.system_topic(fallback_user: @comment.user)
      # Don't use quoted_comment here — the original comment is about to be
      # destroyed and would cascade-delete this inbox comment via
      # has_many :quoting_comments, dependent: :destroy.
      Comment.create!(
        creative: inbox_creative,
        topic: system_topic,
        content: msg,
        user: nil,
        skip_default_user: true
      )
    end

    @comment.destroy
    head :no_content
  else
    render json: { error: I18n.t("collavre.comments.not_owner") }, status: :forbidden
  end
end

#download_imagesObject



286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'app/controllers/collavre/comments_controller.rb', line 286

def download_images
  images = @comment.images
  unless images.attached?
    head :not_found
    return
  end

  # Single image download by index
  if params[:index].present?
    image = images.to_a[params[:index].to_i]
    unless image
      head :not_found
      return
    end
    image.blob.open do |file|
      send_data file.read, filename: image.filename.to_s, type: image.content_type, disposition: "attachment"
    end
    return
  end

  # All images as zip
  require "zip"
  zip_filename = "images-comment-#{@comment.id}.zip"

  buffer = Zip::OutputStream.write_buffer do |zip|
    images.each do |image|
      zip.put_next_entry(image.filename.to_s)
      image.blob.open { |file| zip.write(file.read) }
    end
  end
  buffer.rewind

  send_data buffer.read, filename: zip_filename, type: "application/zip", disposition: "attachment"
end

#fullscreenObject



11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# File 'app/controllers/collavre/comments_controller.rb', line 11

def fullscreen
  # Render the creative index page with comments popup auto-opened in fullscreen.
  # This way the creative list loads behind the popup, so exiting fullscreen
  # doesn't require a page reload.
  @parent_creative = @creative
  @creatives = []
  @shared_list = @creative.all_shared_users
  @auto_fullscreen = true
  # Set params[:id] so the tree URL in creatives/index loads children of this
  # creative instead of the root list.
  params[:id] = @creative.id.to_s
  # Prepend creatives prefix so partials like 'add_button' resolve to collavre/creatives/_add_button
  lookup_context.prefixes.prepend "collavre/creatives"
  render "collavre/creatives/index"
end

#indexObject



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'app/controllers/collavre/comments_controller.rb', line 27

def index
  limit = 20

  visible_scope = @creative.comments.visible_to(Current.user)
  scope = visible_scope.with_attached_images.includes(:task, :topic, :comment_reactions, :comment_versions, :snapshot_as_result)

  if params[:search].present?
    words = params[:search].to_s.strip.downcase.split(/\s+/)
              .first(Creatives::Filters::SearchFilter::MAX_SEARCH_WORDS)
    words.each do |word|
      sanitized = "%#{ActiveRecord::Base.sanitize_sql_like(word)}%"
      scope = scope.where("LOWER(comments.content) LIKE ?", sanitized)
    end
  end

  # Filter by topic
  # Logic:
  # 1. Prefer params[:topic_id] if explicit.
  # 2. If deep linking (around_comment_id), infer from target comment if valid.
  # 3. Default to nil (Main).

  effective_topic_id = params[:topic_id].presence

  if params[:around_comment_id].present?
    target_id = params[:around_comment_id].to_i
    # Ensure target is visible and belongs to this creative
    target_comment = visible_scope.find_by(id: target_id)

    if target_comment
      effective_topic_id = target_comment.topic_id
      # Inform frontend about the topic switch
      response.headers["X-Topic-Id"] = effective_topic_id.to_s
    end
  end

  # Apply the Topic Filter
  if effective_topic_id.present?
    scope = scope.where(topic_id: effective_topic_id)
  else
    # Main view: exclude comments from archived topics
    archived_topic_ids = @creative.topics.archived.pluck(:id)
    scope = scope.where.not(topic_id: archived_topic_ids) if archived_topic_ids.any?
  end

  # Default order: Newest first (created_at DESC)
  # This matches the column-reverse layout where the first item in the list is the visual bottom (Newest).
  scope = scope.order(created_at: :desc)


  @comments = if params[:around_comment_id].present?
    # Deep linking: Load context around a specific comment
    target_id = params[:around_comment_id].to_i

    # Newer messages have HIGHER IDs.
    # Older messages have LOWER IDs.

    # Newer bundle (including target): ID >= target_id
    newer_bundle = scope.where("comments.id >= ?", target_id).reorder(created_at: :asc).limit(limit / 2 + 1)

    # Older bundle: ID < target_id
    older_bundle = scope.where("comments.id < ?", target_id).limit(limit / 2)

    # Combine: [Newer (ASC) ... Target ... Older (DESC)]
    # We need final output to be ASC due to restored view logic: [Oldest ... Target ... Newest]
    (older_bundle.to_a.reverse + newer_bundle.to_a).uniq
  elsif params[:after_id].present? && params[:before_id].present?
      # Invalid state, prioritize before (loading older history)
      scope.where("comments.id < ?", params[:before_id].to_i).limit(limit).to_a.reverse
  elsif params[:before_id].present?
    # Load OLDER messages (lower IDs)
    # Visually scrolling UP
    scope.where("comments.id < ?", params[:before_id].to_i).limit(limit).to_a.reverse
  elsif params[:after_id].present?
    # Load NEWER messages (higher IDs)
    # Visually scrolling DOWN
    # We want the ones immediately *after* the current newest.
    # Since default sort is DESC (Newest first), "after" means id > after_id.
    # But standard DESC query would give us the VERY Newest.
    # We want the ones just above `after_id`.

    # Use reorder(ASC) to get the ones immediately larger than after_id, then reverse back to DESC.
    scope.where("comments.id > ?", params[:after_id].to_i).reorder(created_at: :asc).limit(limit)
  else
    # Initial Load (Latest messages)
    scope.limit(limit).to_a.reverse
  end

  present_user_ids = CommentPresenceStore.list(@creative.id)

  read_receipts = {}
  if @comments.any?
    # Fetch all read pointers for this creative that point to comments in the current list
    # We only care about pointers that match the IDs of the comments we are displaying?
    # Or rather, we want to show the 'line' on the comment that matches the pointer.

    # Optimization: Fetch all pointers for participants of this creative.
    # Scoped to the creative.
    pointers = CommentReadPointer.where(creative: @creative)
                                 .where.not(last_read_comment_id: nil)
                                 .includes(user: { avatar_attachment: :blob })

    # Fetch all visible IDs for correct read-receipt placement transparency
    # Only map read receipts to PUBLIC comments.
    # Users who read private comments will appear on the nearest preceding public comment.
    public_ids = @creative.comments.public_only.order(id: :asc).pluck(:id)

    pointers.each do |pointer|
      effective_id = pointer.effective_comment_id(public_ids)
      if effective_id
        read_receipts[effective_id] ||= []
        read_receipts[effective_id] << pointer.user
      end
    end
  end

  if params[:after_id].present? || params[:before_id].present?
    render partial: "collavre/comments/comment",
           collection: @comments,
           as: :comment,
           locals: { read_receipts: read_receipts, present_user_ids: present_user_ids, current_topic_id: effective_topic_id }
  else
    render partial: "collavre/comments/list", locals: {
      comments: @comments,
      creative: @creative,
      read_receipts: read_receipts,
      present_user_ids: present_user_ids,
      current_topic_id: effective_topic_id
    }
  end
end

#participantsObject



262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# File 'app/controllers/collavre/comments_controller.rb', line 262

def participants
  users = [ @creative.user ].compact + @creative.all_shared_users(:feedback).map(&:user)
  users = users.uniq
  user_data = users.map { |u| view_context.user_json(u, email: true, ai_user: true) }
  response.headers["Cache-Control"] = "no-store"
  response.headers["Pragma"] = "no-cache"
  response.headers["Expires"] = "0"

  render json: {
    users: user_data,
    can_share: @creative.has_permission?(Current.user, :admin),
    can_comment: @creative.has_permission?(Current.user, :feedback),
    has_access: @creative.has_permission?(Current.user, :read)
  }
end

#remove_imageObject



321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'app/controllers/collavre/comments_controller.rb', line 321

def remove_image
  images = @comment.images
  index = params[:index].to_i
  image = images.to_a[index]

  unless image
    head :not_found
    return
  end

  unless @comment.user_id == Current.user.id || Current.user.system_admin?
    head :forbidden
    return
  end

  image.purge
  head :ok
end

#showObject



258
259
260
# File 'app/controllers/collavre/comments_controller.rb', line 258

def show
  redirect_to creative_path(@creative, comment_id: @comment.id)
end

#updateObject



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'app/controllers/collavre/comments_controller.rb', line 193

def update
  if github_synced_content_comment?(@comment)
    render json: { error: I18n.t("collavre.comments.github_synced_readonly") }, status: :forbidden and return
  end

  if @comment.user == Current.user
    safe_params = comment_params.except(:quoted_comment_id, :quoted_text)
    validate_topic_id!(safe_params[:topic_id]) or return

    if @comment.update(safe_params)
      @comment = Comment.with_attached_images.includes(:comment_reactions, :comment_versions, :selected_version).find(@comment.id)
      render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }
    else
      render json: { errors: @comment.errors.full_messages }, status: :unprocessable_entity
    end
  else
    render json: { error: I18n.t("collavre.comments.not_owner") }, status: :forbidden
  end
end