From 29f97c2db4fe56877d060aea0c071421ae386d4f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 14:23:08 +0200 Subject: [PATCH] feat(controllers): add CommentsController and index --- .../app/controllers/comments_controller.rb | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 examples/blog/app/controllers/comments_controller.rb diff --git a/examples/blog/app/controllers/comments_controller.rb b/examples/blog/app/controllers/comments_controller.rb new file mode 100644 index 0000000..e2655d0 --- /dev/null +++ b/examples/blog/app/controllers/comments_controller.rb @@ -0,0 +1,197 @@ +class CommentsController < ApplicationController + before_action :require_authentication, only: %i[create edit update destroy approve reject trash restore] + + # Comments were previously only ever visible nested inside + # PostsController#show (whatever fits on that one page). This gives them + # their own paginated URL, same etag-over-the-page trick as + # PostsController#index: current_page/total_count catch a comment being + # added or removed shifting what belongs on this page even when nothing + # ON this page changed, the id/updated_at pairs catch edits to any of them. + def index + post = Post.find_by!(slug: params['post_id']) + comments = post.comments.includes(:likes).visible_to(current_user, post.user_id) + .order(:created_at, :desc).page(params['page']).per(10) + respond_to do |format| + format.html do + fresh_when(etag: [post.id, post.updated_at, current_user&.id, comments.current_page, comments.total_count, + comments.to_a.map { |c| [c.id, c.updated_at] }]) + render(:index, locals: { post: post, comments: comments }) + end + format.json { json(comments.as_json(only: %i[id author body post_id status])) } + end + rescue Prism::Orm::RecordNotFound + respond_not_found(t('posts.not_found')) + end + + def create + post = Post.find_by!(slug: params['post_id']) + comment = Comment.new(params.require('comment').permit(:author, :body).to_h + .merge('post_id' => post.id, 'user_id' => current_user.id)) + + if comment.save + respond_to do |format| + format.html do + flash[:notice] = t('comments.created_notice') + redirect url_for(:post, slug: post.to_param) + end + format.json { json(comment.as_json(only: %i[id author body post_id status]), 201) } + end + else + respond_to do |format| + format.html do + flash[:notice] = comment.errors.full_messages.join(', ') + redirect url_for(:post, slug: post.to_param) + end + format.json { render_errors(comment) } + end + end + rescue Prism::Orm::RecordNotFound + respond_not_found(t('posts.not_found')) + end + + def edit + post = Post.find_by!(slug: params['post_id']) + comment = Comment.find(params['id']) + authorize(comment, :update?) + render(:edit, locals: { post: post, comment: comment, errors: [] }) + rescue Prism::Orm::RecordNotFound + respond_not_found(t('comments.not_found')) + end + + # Only the body is editable (author is fixed at creation, same reasoning + # as PostsController#update not letting the edit form touch cover/user_id) + # so Comment#saved_changes below can only ever report `body`. `lock_version` + # rides along the same way it does for Post - the value the edit form was + # rendered with, checked against the row's actual current version by + # Comment#save (locking_enabled? auto-detects the column added by the + # add_lock_version_to_comments migration, no model change needed). + def update + post = Post.find_by!(slug: params['post_id']) + comment = Comment.find(params['id']) + authorize(comment) + comment.assign(params.require('comment').permit(:body, :lock_version).to_h) + + if comment.save + changed_fields = comment.saved_changes.keys - ['updated_at', 'lock_version'] + respond_to do |format| + format.html do + flash[:notice] = changed_fields.empty? ? t('comments.update_noop_notice') : t('comments.updated_notice', fields: changed_fields.join(', ')) + redirect url_for(:post, slug: post.to_param) + end + format.json { json(comment.as_json(only: %i[id author body post_id status])) } + end + else + respond_to do |format| + format.html do + status(422) + render(:edit, locals: { post: post, comment: comment, errors: comment.errors.full_messages }) + end + format.json { render_errors(comment) } + end + end + rescue Prism::Orm::RecordNotFound + respond_not_found(t('comments.not_found')) + rescue Prism::Orm::StaleObjectError + respond_to do |format| + format.html do + status(409) + render(:edit, locals: { post: post, comment: Comment.find(params['id']), errors: [t('comments.stale_notice')] }) + end + format.json do + json({ errors: [jsonapi_error(status: 409, pointer: '/data/attributes/lock_version', + title: 'is stale', detail: t('comments.stale_notice'))] }, 409) + end + end + end + + def destroy + post = Post.find_by!(slug: params['post_id']) + comment = Comment.find(params['id']) + authorize(comment) + comment.destroy + broadcast_comment_count(post) + redirect url_for(:post, slug: post.to_param) + rescue Prism::Orm::RecordNotFound + respond_not_found(t('posts.not_found')) + end + + # approved!/rejected! come straight from Comment's `enum :status` (see + # the model), same "find, authorize like an edit, flip the status" shape + # as PostsController#publish/#unpublish. CommentPolicy#approve?/#reject? + # check the POST's owner, not the comment's - moderation is the post + # author's call, not the commenter's. + def approve + post = Post.find_by!(slug: params['post_id']) + comment = Comment.find(params['id']) + authorize(comment) + comment.approved! + broadcast_comment_count(post) + flash[:notice] = t('comments.approved_notice') + redirect url_for(:post, slug: post.to_param) + rescue Prism::Orm::RecordNotFound + respond_not_found(t('comments.not_found')) + end + + def reject + post = Post.find_by!(slug: params['post_id']) + comment = Comment.find(params['id']) + authorize(comment) + comment.rejected! + broadcast_comment_count(post) + flash[:notice] = t('comments.rejected_notice') + redirect url_for(:post, slug: post.to_param) + rescue Prism::Orm::RecordNotFound + respond_not_found(t('comments.not_found')) + end + + # Every comment a signed-in user has removed, across every post they ever + # commented on - a flat, top-level page (see the routes in app.rb), not + # nested under any one post, since a commenter's trash is not the same + # thing as PostsController#trash and does not depend on whether the post + # itself is trashed (Comment#acts_as_paranoid keeps its own deleted_at, + # independent of its post's - see the model). Comment.only_deleted (see + # acts_as_paranoid) is the one place in this controller that deliberately + # reaches past the default paranoid scope every other action relies on to + # hide soft-deleted rows. + def trash + comments = Comment.only_deleted.where(user_id: current_user.id).order(:updated_at, :desc).to_a + render(:trash, locals: { comments: comments, notice: flash[:notice] }) + end + + # find_by! against only_deleted (not the default Comment.find, which + # hides trashed rows) - restoring a comment that was never trashed, or + # someone else's, both come back as the same 404 a stranger's comment id + # would. recursive: true brings back this comment's own likes if they + # were soft-deleted alongside it by the dependent: :destroy cascade on + # Comment#destroy (Like is paranoid too - see cascade_restore_dependent_ + # associations); it does not touch the comment's post either way. + def restore + comment = Comment.only_deleted.find_by!(id: params['id'].to_i) + authorize(comment, :restore?) + comment.restore(recursive: true) + flash[:notice] = t('comments.restored_notice') + redirect url_for(:trash_comments) + rescue Prism::Orm::RecordNotFound + respond_not_found(t('comments.not_found')) + end + + private + + # Pushed to every open posts#show tab's PostActivityChannel subscription + # (see the model) - approve/reject/destroy are the only three actions + # that can change how many APPROVED comments a post has (create cannot, + # every comment starts life pending), so this is called from exactly + # those three, never from create/update. + def broadcast_comment_count(post) + # stream_for/post_id_from (see the channel) treat this as an opaque + # string key, not a real id - post.to_param (the slug) so the stream a + # client subscribes to via GET /cable?post_id= (posts/show.erb + # has no inline subscriber script yet, but blog_websocket_demo.rb + # subscribes using the same slug it got from the post's own URL) + # actually matches what gets broadcast to here. + PostActivityChannel.hub.broadcast( + PostActivityChannel.stream_for(post.to_param), + event: 'comment_count', post_id: post.to_param, count: post.comments.approved.count + ) + end +end