feat(controllers): add AccountController, edit and ApplicationController
This commit is contained in:
1 parent
550ce1dbce
commit
89fb0a8d43
2 files changed
+180
No files matched your search
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user