diff --git a/examples/blog/app/controllers/posts_controller.rb b/examples/blog/app/controllers/posts_controller.rb index c8bb9c0..6a1dc02 100644 --- a/examples/blog/app/controllers/posts_controller.rb +++ b/examples/blog/app/controllers/posts_controller.rb @@ -114,4 +114,177 @@ class PostsController < ApplicationController end end end + + # The reverse of #export above: bulk-creates posts from an uploaded CSV + # (title,body columns). All the actual work - the per-row unique-slug + # retry and the outer transaction wrapping the whole batch - lives in + # Post.import_csv; this action is just multipart plumbing and reporting. + def import + file = params['file'] + unless file.is_a?(Prism::UploadedFile) && !file.original_filename.to_s.empty? + flash[:notice] = t('posts.import_missing_file') + redirect url_for(:posts) + return + end + result = Post.import_csv(file.read, author: current_user) + flash[:notice] = t('posts.import_notice', imported: result.imported.size, skipped: result.skipped.size) + redirect url_for(:posts) + end + + # The create form's submit button says "Publish" (see posts.submit in the + # locale files), so a post made here goes live immediately - status only + # ever starts at the enum's draft default for a row nothing else besides + # this action ever inserts. #unpublish/#publish (below) are how an + # existing post is pulled back to draft and put up again. + def create + attrs = params.require('post').permit(:title, :body, :featured, :giveaway_slots).to_h + attrs['status'] = :published + attrs['cover'] = store_cover(params['post']['cover']) + attrs['user_id'] = current_user.id + # Post.create_with_unique_slug (see the model), not a plain Post.new+#save + # - every post needs a slug for the unique index the add_slug_to_posts + # migration put on posts.slug, and this is the only place besides + # Post.import_csv that inserts a post at all. + post = Post.create_with_unique_slug(attrs) + + if post.persisted? + respond_to do |format| + format.html do + flash[:notice] = t('posts.created_notice', id: post.id) + redirect url_for(:post, slug: post.to_param) + end + format.json { json(post.as_json(only: %i[id slug title body cover]), 201) } + end + else + respond_to do |format| + format.html do + status(422) + render(:index, locals: { posts: Post.published.order(:created_at, :desc).page(1).per(5), new_post: post, notice: nil, + errors: post.errors.full_messages, days: 0, featured_only: false }) + end + format.json { render_errors(post) } + end + end + end + + def edit + post = Post.find_by!(slug: params['slug']) + authorize(post, :update?) + render(:edit, locals: { post: post, errors: [] }) + rescue Prism::Orm::RecordNotFound + respond_not_found(t('posts.not_found')) + end + + # Diffs the submitted title/body against the persisted row WITHOUT + # saving anything - assign() dirty-tracks the attempt, #changes captures + # the old/new pair for each touched field, then Dirty#restore_attributes + # reverts `post` back to exactly what #save last loaded so the rest of + # this action (and the view it renders) can keep using the same instance + # as a clean, still-persisted record instead of hitting the store again. + # `attempted` is kept separately (not read back off `post`, which is now + # clean again) so the confirm form below can still resubmit what the + # visitor actually typed. + def preview + post = Post.find_by!(slug: params['slug']) + authorize(post, :update?) + attempted = params.require('post').permit(:title, :body, :lock_version).to_h + post.assign(attempted) + post.valid? + diff = post.changes.reject { |field, _| field == 'lock_version' } + errors = post.errors.full_messages + post.restore_attributes + render(:preview, locals: { post: post, diff: diff, errors: errors, attempted: attempted }) + rescue Prism::Orm::RecordNotFound + respond_not_found(t('posts.not_found')) + end + + # Only assigns the attributes the edit form actually submits (not cover/ + # user_id) so Post#saved_changes below can only ever list fields the + # visitor meant to touch, then uses that dirty-tracking snapshot to tell + # them exactly what took effect instead of a generic "post updated". + # `lock_version` rides along as a permitted attribute holding whatever + # version the edit form was rendered with (not a fresh read) - see + # Post#save's optimistic-locking check, which compares that submitted + # value against the row's ACTUAL current version, not the just-loaded one. + def update + post = Post.find_by!(slug: params['slug']) + authorize(post) + permitted = params.require('post').permit( + :title, :body, :featured, :giveaway_slots, :lock_version, comments_attributes: %i[id body _destroy] + ).to_h + post.assign(permitted) + + if post.save + changed_fields = post.saved_changes.keys - ['updated_at', 'lock_version'] + respond_to do |format| + format.html do + flash[:notice] = changed_fields.empty? ? t('posts.update_noop_notice') : t('posts.updated_notice', fields: changed_fields.join(', ')) + redirect url_for(:post, slug: post.to_param) + end + format.json { json(post.as_json(only: %i[id slug title body cover])) } + end + else + respond_to do |format| + format.html do + status(422) + render(:edit, locals: { post: post, errors: post.errors.full_messages }) + end + format.json { render_errors(post) } + end + end + rescue Prism::Orm::RecordNotFound + respond_not_found(t('posts.not_found')) + rescue Prism::Orm::StaleObjectError + respond_to do |format| + format.html do + status(409) + render(:edit, locals: { post: Post.find_by!(slug: params['slug']), errors: [t('posts.stale_notice')] }) + end + format.json do + json({ errors: [jsonapi_error(status: 409, pointer: '/data/attributes/lock_version', + title: 'is stale', detail: t('posts.stale_notice'))] }, 409) + end + end + end + + # Post#acts_as_paranoid means this call moves the post to the trash + # instead of removing it for real - #trash/#restore below are how its + # own author gets it back. + def destroy + post = Post.find_by!(slug: params['slug']) + authorize(post) + post.destroy + flash[:notice] = t('posts.deleted_notice', id: post.id) + redirect url_for(:posts) + rescue Prism::Orm::RecordNotFound + respond_not_found(t('posts.not_found')) + end + + # Only the signed-in author's own trashed posts - Post.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 + posts = Post.only_deleted.where(user_id: current_user.id).order(:updated_at, :desc).to_a + render(:trash, locals: { posts: posts, notice: flash[:notice] }) + end + + # find_by! against only_deleted (not the default Post.find, which hides + # trashed rows) - restoring a post that was never trashed, or someone + # else's, both come back as the same 404 a stranger's post id would. + def restore + # slug survives a soft delete unchanged (it's just a column, untouched + # by deleted_at) - no numeric coercion needed here the way the old + # id-keyed lookup required, since a String slug compares correctly + # against row['slug'] (also a String) with plain ==. + post = Post.only_deleted.find_by!(slug: params['slug']) + authorize(post, :restore?) + # recursive: true - a plain post.restore would only bring the post's + # own row back and leave its comments/likes trashed forever, since + # they were soft-deleted as a side effect of dependent: :destroy when + # the post itself was trashed (see Post#acts_as_paranoid). + post.restore(recursive: true) + flash[:notice] = t('posts.restored_notice', id: post.id) + redirect url_for(:trash_posts) + rescue Prism::Orm::RecordNotFound end