From 15d358f4475278a2a631f95fc1c49849ac7f2ff3 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 15:46:14 +0200 Subject: [PATCH] feat(controllers): extend posts_controller with PostsController --- .../blog/app/controllers/posts_controller.rb | 119 ++++++++++++++++-- 1 file changed, 106 insertions(+), 13 deletions(-) diff --git a/examples/blog/app/controllers/posts_controller.rb b/examples/blog/app/controllers/posts_controller.rb index ce43279..c8bb9c0 100644 --- a/examples/blog/app/controllers/posts_controller.rb +++ b/examples/blog/app/controllers/posts_controller.rb @@ -1,24 +1,117 @@ -class PostsController < Prism::Controller - layout :application +require 'csv' +require 'fileutils' +class PostsController < ApplicationController + before_action :require_authentication, + only: %i[create edit update destroy preview publish unpublish trash restore import move_up move_down] + + # Same content-aware etag idea as #show below, but over the whole page of + # posts: current_page/total_count catch pagination shifting (a post + # created or destroyed elsewhere moves what belongs on this page even + # when none of the posts actually ON this page changed), and the id/ + # updated_at pairs catch edits to any of them. + # + # `.published` is a scope generated by Post's `enum :status` - every post + # starts as a draft (see the model), so a freshly created one is invisible + # here until its author publishes it from posts#show. def index - render(:index, locals: { posts: Post.order(:created_at, :desc), notice: flash[:notice], errors: [] }) + # `days` is an optional "created in the last N days" filter (see + # posts.filter_* links in the view) chained onto .published BEFORE + # .order/.page - Post.created_within hits the range-capable secondary + # index on created_at (Store#lookup_by_index_range) the same way + # .published already hits the one on status, so neither filter alone + # nor the two combined ever falls back to a full table scan. + days = params['days'].to_i + # `params['featured']` is the raw query-string String Rack always hands + # a controller ("1" or nil) - cast through the same TypeCasting a form + # POST would use, rather than a bespoke `== '1'` check here, so this + # filter and the checkbox in _form.erb agree on what counts as true. + featured_only = params['featured'] && Prism::Orm::TypeCasting.cast(:boolean, params['featured']) + scope = Post.published + scope = scope.created_within(days) if days.positive? + scope = scope.featured if featured_only + # The featured view is manually curated (Post#acts_as_list, scoped by + # featured - see the model and #move_up/#move_down above), so it sorts + # by that editorial position instead of recency like every other view + # of this same index action. + posts = featured_only ? scope.order(:position, :asc).page(params['page']).per(5) + : scope.order(:created_at, :desc).page(params['page']).per(5) + respond_to do |format| + format.html do + notice = flash[:notice] + # current_user&.id rides along in the etag, same audit fix as #show + # below: this page's "new post" form and the layout's signed-in-as + # banner are both gated on user_signed_in?/current_user, so without + # this a shared cache keying purely on URL+ETag could serve one + # visitor's signed-in body back as a 304 to a different, or + # signed-out, visitor whose If-None-Match happened to match. `days` + # rides along too so switching the filter without the post set + # actually changing (e.g. nothing published in the last 7 days + # either way) still forces a fresh render, not a stale 304 for the + # wrong filter. `featured_only` rides along for the same reason - + # and so does each post's `position` (see Post#acts_as_list): a + # #move_up/#move_down reorder touches ONLY the position column, + # never updated_at, so without it in the etag two requests with the + # exact same set of ids/updated_at pairs but a different editorial + # order would wrongly hash to the same ETag and serve a stale 304. + fresh_when(etag: [posts.current_page, posts.total_count, + posts.to_a.map { |p| [p.id, p.updated_at, p.position] }, notice, + current_user&.id, days, featured_only]) + render(:index, locals: { posts: posts, new_post: Post.new, notice: notice, errors: [], days: days, + featured_only: featured_only }) + end + format.json do + json(posts.as_json(only: %i[id slug title body cover])) + end + end end + # `notice` rides along in the etag components (not just post/comments + # state) so a pending flash message - which only exists for the one + # request right after a redirect - always forces a fresh render instead + # of being hidden behind a 304 that matches an older, notice-less copy + # of this same page a client happened to have cached. def show - render(:show, locals: { post: Post.find(params['id']) }) + post = Post.find_by!(slug: params['slug']) + comments = post.comments.includes(:likes).visible_to(current_user, post.user_id).order(:created_at, :desc).to_a + notice = flash[:notice] + # current_user.id rides along in the etag, not just the post/comments + # data: this page also shows moderator-only controls (edit/publish/ + # delete, comment approve/reject) and viewer-only pending comments, so + # two different visitors who happen to see the identical VISIBLE + # comment set must still never be served each other's cached 304 body. + # post.claims_count rides along too - a giveaway claim (see + # ClaimsController#create) changes #giveaway_remaining/ + # #giveaway_sold_out? and therefore this page's body without ever + # touching post.updated_at (Claim has no touch: true, on purpose: a + # claim is not an edit to the post itself), so without it here a + # cached 304 would keep serving a stale remaining-slots count and a + # stale claim button after someone had already claimed the last slot. + fresh_when(etag: [post.id, post.updated_at, post.claims_count, current_user&.id, + comments.map { |c| [c.id, c.updated_at] }, notice]) + render(:show, locals: { post: post, comments: comments, new_comment: Comment.new, notice: notice }) rescue Prism::Orm::RecordNotFound - halt(404, 'Post not found') + respond_not_found(t('posts.not_found')) end - def create - post = Post.new(title: params['title'], body: params['body']) - if post.save - flash[:notice] = "Post ##{post.id} created" - redirect url_for(:posts) - else - status(422) - render(:index, locals: { posts: Post.order(:created_at, :desc), notice: nil, errors: post.errors.full_messages }) + # Streamed (Prism::Controller#stream, see lib/prism/streaming_body.rb), + # not rendered into one String first - a real Rails app faces the exact + # same shape of problem exporting a large table, and #index already + # proves .published/.created_within hit a secondary index instead of a + # full scan, so this is the same query with the row-by-row cost paid to + # the client incrementally instead of all at once in memory. The header + # row is written (and would reach the client) before the last matching + # post has even been read out of the store. + def export + days = params['days'].to_i + scope = Post.published + scope = scope.created_within(days) if days.positive? + headers('Content-Type' => 'text/csv') + stream do |out| + out.write(CSV.generate_line(%w[id title created_at])) + scope.order(:created_at, :desc).find_each do |post| + out.write(CSV.generate_line([post.id, post.title, post.created_at.utc.iso8601])) + end end end end