From 89fb0a8d43bc9928430ea4d8c47518bdb5a3e3a3 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 23:40:47 +0200 Subject: [PATCH] feat(controllers): add AccountController, edit and ApplicationController --- .../app/controllers/account_controller.rb | 68 +++++++++++ .../app/controllers/application_controller.rb | 112 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 examples/blog/app/controllers/account_controller.rb create mode 100644 examples/blog/app/controllers/application_controller.rb diff --git a/examples/blog/app/controllers/account_controller.rb b/examples/blog/app/controllers/account_controller.rb new file mode 100644 index 0000000..bf5f6b8 --- /dev/null +++ b/examples/blog/app/controllers/account_controller.rb @@ -0,0 +1,68 @@ +class AccountController < ApplicationController + before_action :require_authentication + + def edit + render(:edit, locals: { user: current_user, errors: [] }) + end + + # current_user.comments is a plain `Comment.where(user_id: id)` (Associable + # #has_many, no through:/as:) - a single HashNode equality answerable by + # Store#lookup_by_index on comments.user_id the moment that index exists + # (see db/migrate/20260907000008_add_index_on_comments_user_id.rb), unlike + # every other approved/visible_to call site in this app: those all chain + # off post.comments first, so their equalities hash already carries + # post_id too and the pre-existing post_id index wins the tie - this is + # the one place comments.user_id gets to be the index actually picked. + def comments + comments = current_user.comments.order(:created_at, :desc).page(params['page']).per(10) + fresh_when(etag: [current_user.id, comments.current_page, comments.total_count, + comments.to_a.map { |c| [c.id, c.updated_at] }]) + render(:comments, locals: { comments: comments }) + end + + # A post's comments start life pending (Comment's `enum :status`) so its + # author can approve/reject before strangers see them - but the only way + # to act on that today is opening each post's own page one at a time + # (posts/_comment.erb's approve/reject buttons). This gathers every + # pending comment across EVERY post the current user owns onto one page, + # the same "flatten across posts" shape as #comments above but filtered + # to pending instead of to authorship. Two index-backed queries, not one + # scan each: current_user.posts (posts.user_id, see the migration + # referenced on User) narrows to this user's own post ids first, then + # Comment.pending.where(post_id: ...) reuses the existing comments.post_id + # index's IN-list lookup (Store#lookup_by_index_in) across all of them at + # once, with the leftover `status` equality filtered from that already- + # small candidate set in memory - see ConditionNode#indexed_candidates. + def moderation + post_ids = current_user.posts.pluck(:id) + comments = Comment.pending.where(post_id: post_ids) + .order(:created_at, :desc).page(params['page']).per(10) + fresh_when(etag: [current_user.id, comments.current_page, comments.total_count, + comments.to_a.map { |c| [c.id, c.updated_at] }]) + render(:moderation, locals: { comments: comments }) + end + + # User#email is `encrypts :email, deterministic: true` (see the model) - + # this is the one place in the whole demo where a user's own encrypted + # attribute is genuinely rewritten through a real HTTP request instead of + # only being set once at sign-up, which is exactly what forces a fresh + # `Encryption.encrypt` call under the CURRENTLY configured primary key. + # That makes this action the natural vehicle for exercising key rotation + # end to end (see rotation_worker.rb): a user who visits this page after + # the app's encryption secret has rotated and re-saves their (possibly + # unchanged) email migrates that one row onto the new key for free, + # exactly the way Prism::Orm::Encryption's own comments describe. + def update + attrs = params.require('user').permit(:email).to_h + current_user.assign(attrs) + + if current_user.save + changed_fields = current_user.saved_changes.keys - ['updated_at'] + flash[:notice] = changed_fields.empty? ? t('account.update_noop_notice') : t('account.updated_notice', fields: changed_fields.join(', ')) + redirect url_for(:edit_account) + else + status(422) + render(:edit, locals: { user: current_user, errors: current_user.errors.full_messages }) + end + end +end diff --git a/examples/blog/app/controllers/application_controller.rb b/examples/blog/app/controllers/application_controller.rb new file mode 100644 index 0000000..d475933 --- /dev/null +++ b/examples/blog/app/controllers/application_controller.rb @@ -0,0 +1,112 @@ +class ApplicationController < Prism::Controller + include Prism::Authenticatable + include Prism::Authorizable + + layout :application + protect_from_forgery + authenticate_with model: User, unauthenticated_redirect_path: '/session/new', remember_sessions: LoginSession, + max_remember_sessions: 3 + rescue_from Prism::Policy::NotAuthorizedError, with: :forbidden + + # How many likes a post or comment has, and the current visitor's own + # Like on it (if any) - used by views to decide between a "Like" and an + # "Unlike" button without querying the polymorphic association twice. + def likes_count(record) + record.likes_count + end + + def current_user_like(record) + return nil unless current_user + record.likes.find { |like| like.user_id == current_user.id } + end + + # Same shape as current_user_like above, for posts#show's giveaway + # section - whether the current visitor already holds a claim decides + # between showing a "claim a slot" button and a "you already claimed + # one" notice (see Post#giveaway?/#giveaway_sold_out? for the two other + # states that section can be in). + def current_user_claim(post) + return nil unless current_user + post.claims.find { |claim| claim.user_id == current_user.id } + end + + # Display label for the post a comment hangs off of, robust to the post + # being trashed independently of the comment itself (its own trash/restore + # cycle - see Post#acts_as_paranoid). Reaches past the default paranoid + # scope (Post.with_deleted) because a plain Comment#post/Post.find would + # raise Prism::Orm::RecordNotFound instead of returning the trashed row. + # Shared by CommentsController#trash and AccountController#comments - both + # list comments detached from their normal post-scoped page, where a + # comment can legitimately outlive its post being in the trash. + def comment_post_label(comment) + post = comment_post(comment) + return t('comments.orphaned_post') if post.nil? + post.deleted? ? t('comments.post_also_trashed', title: post.title) : post.title + end + + def comment_post_linkable?(comment) + post = comment_post(comment) + post && !post.deleted? + end + + # The slug for comment_post_linkable?'s url_for(:post, ...) - only ever + # called from inside that guard, so the post here is always the live + # (not-deleted) one, but reuses the same with_deleted lookup/memoization + # as the other two helpers above rather than assuming that. + def comment_post_slug(comment) + comment_post(comment)&.to_param + end + + private + + # comment_post_label/#comment_post_linkable?/#comment_post_slug all need + # the SAME with_deleted Post lookup for a given comment - memoized per + # post_id so a comment list page (moderation/trash/account#comments) + # costs one query per distinct post, not up to three per comment. + def comment_post(comment) + @comment_post_cache ||= {} + @comment_post_cache.fetch(comment.post_id) { @comment_post_cache[comment.post_id] = Post.with_deleted.find_by(id: comment.post_id) } + end + + # One-off JSON:API error object (see Prism::Orm::Errors#to_jsonapi) for + # failures that do not come from a model's own validations, such as a + # stale optimistic-locking conflict - keeps the wire shape identical to + # render_errors so a JSON client only has to understand one error format. + def jsonapi_error(status:, pointer:, title:, detail:) + { status: status.to_s, source: { pointer: pointer }, title: title, detail: detail } + end + + # Content-negotiated 404 for a Prism::Orm::RecordNotFound rescue: plain + # text/HTML for browser requests, a JSON:API error object (via + # Renderable#render_not_found) for .json ones - the same halt(404, ...) + # every controller used to call regardless of what the client asked for, + # which meant a `.json` 404 came back as a bare HTML-ish string instead + # of an `errors` array a JSON client could actually parse. + def respond_not_found(detail) + respond_to do |format| + format.html { halt(404, detail) } + format.json { render_not_found(detail) } + end + end + + # Sends the visitor back to the post they were looking at either way - + # whether it was the post itself, one of its comments, or a like on + # either - instead of a bare 403, so a signed-in user who simply clicked + # someone else's delete/unlike button lands somewhere useful. + def forbidden(exception) + flash[:notice] = t('errors.forbidden') + redirect url_for(:post, slug: post_slug_for(exception.record)) + end + + # The record a NotAuthorizedError carries is always live (Comment.find/ + # Post.find already hide soft-deleted rows before a policy ever runs), + # so `record.post` here is a plain association read, not the with_deleted + # lookup comment_post/#comment_post_label above need for the trash pages. + def post_slug_for(record) + case record + when Comment then record.post.slug + when Like then post_slug_for(record.likeable) + else record.to_param + end + end +end