feat(models): extend claim with Claim and Comment
This commit is contained in:
1 parent
4015436e50
commit
6a702a1ece
5 files changed
+156
No files matched your search
@@ -0,0 +1,22 @@
|
|||||||
|
class Claim < Prism::Orm::Model
|
||||||
|
# counter_cache keeps Post#claims_count (and therefore
|
||||||
|
# Post#giveaway_remaining/#giveaway_sold_out?) O(1) to read - the whole
|
||||||
|
# point of ClaimsController#create locking the post first is to make that
|
||||||
|
# read-then-decide safe under concurrency, which would be undermined if
|
||||||
|
# the read itself required walking every Claim row instead.
|
||||||
|
belongs_to :post, counter_cache: true
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
# Not a user-facing "unclaim" - a claim has no destroy action anywhere in
|
||||||
|
# this app (see ClaimsController, create-only). Paranoid purely so
|
||||||
|
# Post#acts_as_paranoid's `dependent: :destroy` cascade (see the model)
|
||||||
|
# soft-deletes a giveaway's claims along with a trashed post instead of
|
||||||
|
# erasing the record of who already won a slot, and #restore(recursive:
|
||||||
|
# true) brings them back - same shape as Comment/Like.
|
||||||
|
acts_as_paranoid
|
||||||
|
|
||||||
|
# One claim per user per post - see the unique index on [:post_id,
|
||||||
|
# :user_id] in the create_claims migration for the same rule enforced at
|
||||||
|
# the Store level too, inside whatever transaction the insert runs in.
|
||||||
|
validates :user_id, presence: true, uniqueness: { scope: :post_id }
|
||||||
|
end
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
class Comment < Prism::Orm::Model
|
||||||
|
# touch: true keeps Post#updated_at reflecting the most recent comment
|
||||||
|
# activity (created, edited, deleted), not just edits to the post itself -
|
||||||
|
# shown as "Last activity" on the post list/show pages.
|
||||||
|
belongs_to :post, counter_cache: true, touch: true
|
||||||
|
belongs_to :user
|
||||||
|
has_many :likes, as: :likeable, dependent: :destroy
|
||||||
|
|
||||||
|
# Paranoid so PostsController#restore(recursive: true) can bring a
|
||||||
|
# trashed post's comments back along with it - see Post#acts_as_paranoid.
|
||||||
|
acts_as_paranoid
|
||||||
|
|
||||||
|
# Every comment starts life pending (column default 0, see the
|
||||||
|
# add_status_to_comments migration) so a post's author gets a chance to
|
||||||
|
# approve or reject it before strangers can read it - see
|
||||||
|
# CommentsController#approve/#reject and the `visible_to` scope below.
|
||||||
|
enum :status, { pending: 0, approved: 1, rejected: 2 }
|
||||||
|
|
||||||
|
# Who gets to see a comment depends on who's asking, not just its status:
|
||||||
|
# everyone sees approved comments, the post's author (the moderator) sees
|
||||||
|
# everything on their own post regardless of status, and a signed-in
|
||||||
|
# commenter always sees their own comment even while it's still pending.
|
||||||
|
# `self` inside the block is whatever relation this scope was chained
|
||||||
|
# onto (e.g. already narrowed to one post's comments by `post.comments`),
|
||||||
|
# so `approved`/`where` compose with that instead of re-querying globally.
|
||||||
|
scope :visible_to, lambda { |user, post_author_id|
|
||||||
|
next self if user && user.id == post_author_id
|
||||||
|
next approved if user.nil?
|
||||||
|
approved.or(where(user_id: user.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
validates :author, presence: true, length: { minimum: 2 }
|
||||||
|
validates :body, presence: true
|
||||||
|
end
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
class Like < Prism::Orm::Model
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :likeable, polymorphic: true, counter_cache: true
|
||||||
|
|
||||||
|
# Same reasoning as Comment#acts_as_paranoid - a trashed post's likes (and
|
||||||
|
# a trashed post's comments' own likes) come back with it on restore.
|
||||||
|
acts_as_paranoid
|
||||||
|
|
||||||
|
validates :user_id, presence: true, uniqueness: { scope: %i[likeable_id likeable_type] }
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
class LoginSession < Prism::Orm::Model
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
# The persistent-login cookie's actual credential - never rendered
|
||||||
|
# anywhere (see LoginSessionsController#index), and hidden from
|
||||||
|
# serialization so a stray as_json never leaks it either.
|
||||||
|
has_secure_token :token
|
||||||
|
hide_from_json :token
|
||||||
|
end
|
||||||
@@ -1,3 +1,84 @@
|
|||||||
|
require 'csv'
|
||||||
|
|
||||||
class Post < Prism::Orm::Model
|
class Post < Prism::Orm::Model
|
||||||
|
# Result of .import_csv below - `imported` are posts that actually made
|
||||||
|
# it to disk, `skipped` are { title:, errors: } pairs for rows that
|
||||||
|
# never did (blank title, or a slug collision that outlived
|
||||||
|
# MAX_SLUG_ATTEMPTS).
|
||||||
|
ImportResult = Struct.new(:imported, :skipped)
|
||||||
|
|
||||||
|
MAX_SLUG_ATTEMPTS = 20
|
||||||
|
|
||||||
|
belongs_to :user
|
||||||
|
has_many :comments, dependent: :destroy
|
||||||
|
has_many :likes, as: :likeable, dependent: :destroy
|
||||||
|
# dependent: :destroy, not :nullify - same reasoning as comments/likes
|
||||||
|
# above: Claim is paranoid too (see the model), so trashing this post
|
||||||
|
# soft-deletes its claims along with it and #restore(recursive: true)
|
||||||
|
# brings them back, rather than a plain trash permanently erasing the
|
||||||
|
# record of who already won a giveaway slot.
|
||||||
|
has_many :claims, dependent: :destroy
|
||||||
|
|
||||||
|
# A deleted post lands in the trash (see PostsController#destroy/#trash/
|
||||||
|
# #restore) instead of vanishing outright - Post.all/#find already hide
|
||||||
|
# it everywhere else in the app for free. Comment and Like are paranoid
|
||||||
|
# too, so the `dependent: :destroy` cascades above soft-delete them the
|
||||||
|
# moment a post is trashed instead of removing them for real, and
|
||||||
|
# PostsController#restore(recursive: true) brings them all back together
|
||||||
|
# with the post (see Associable#cascade_restore_dependent_associations).
|
||||||
|
acts_as_paranoid
|
||||||
|
|
||||||
|
# Every post starts life as a draft (column default 0, see the
|
||||||
|
# add_status_to_posts migration) - only #publish! on posts#show makes it
|
||||||
|
# show up on the public index (see PostsController#index below).
|
||||||
|
enum :status, { draft: 0, published: 1, archived: 2 }
|
||||||
|
|
||||||
|
# Prism::Orm::Attributes: casts the "featured" checkbox's form value
|
||||||
|
# ("1"/"0", always a String off an HTTP request - see the hidden-field
|
||||||
|
# trick in _form.erb) into a real TrueClass/FalseClass on write, instead
|
||||||
|
# of leaving the literal string "1" sitting in the featured column. That
|
||||||
|
# distinction is not cosmetic: `where(featured: true)` (see the
|
||||||
|
# .featured scope below and PostsController#index's `?featured=1`
|
||||||
|
# filter) does a plain Ruby `==` against whatever is actually stored,
|
||||||
|
# so a stored String would never match a query for the Boolean `true`.
|
||||||
|
attribute :featured, :boolean, default: false
|
||||||
|
|
||||||
|
# Same reasoning as TypeCasting.cast_integer's own comment: blank input
|
||||||
|
# from the (optional) giveaway_slots form field casts to nil, not 0 - nil
|
||||||
|
# means "not a giveaway post" (see #giveaway? below), 0 means "was a
|
||||||
|
# giveaway, every slot already claimed", and those are not the same
|
||||||
|
# state for #giveaway_sold_out? to report.
|
||||||
|
attribute :giveaway_slots, :integer
|
||||||
|
|
||||||
|
scope :featured, -> { where(featured: true) }
|
||||||
|
|
||||||
|
# acts_as_list scoped by the featured flag itself: every post gets a
|
||||||
|
# position within its own featured/non-featured group (the non-featured
|
||||||
|
# group's numbers are never shown or reordered - see posts#index and
|
||||||
|
# PostsController#move_up/#move_down, both gated on featured_only), so
|
||||||
|
# toggling a post's own featured flag on/off cheaply hops it between two
|
||||||
|
# already-maintained lists (append to the bottom of whichever group it
|
||||||
|
# lands in) instead of needing a bespoke third state to track "not in
|
||||||
|
# any list yet". Editors use #move_up/#move_down to curate which
|
||||||
|
# featured posts lead the homepage instead of always showing the most
|
||||||
|
# recently featured one first.
|
||||||
|
acts_as_list column: :position, scope: :featured
|
||||||
|
|
||||||
|
# Backed by the range-capable secondary index on created_at
|
||||||
|
# (db/migrate/20260907000004_add_index_on_posts_created_at.rb) - the
|
||||||
|
# gte/lte hash this builds is exactly what
|
||||||
|
# Query::ConditionNode#indexable_ranges recognizes, so PostsController#index
|
||||||
|
# chaining this onto .published (see below) hits
|
||||||
|
# Store#lookup_by_index_range with an O(log n) binary search over
|
||||||
|
# sorted_values instead of a full table scan. See Store#lookup_by_index_range.
|
||||||
|
scope :created_within, ->(days) { where(created_at: { gte: Time.now.utc - (days.to_i * 86_400) }) }
|
||||||
|
|
||||||
|
# Lets the author moderate comments (edit or remove) from the same form
|
||||||
|
# used to edit the post itself - reject_if: :all_blank so a moderation
|
||||||
|
# form that only ever submits EXISTING comment rows (no "add a comment"
|
||||||
|
# field on this screen) never has to worry about accidentally building
|
||||||
|
# a blank new one.
|
||||||
|
accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: :all_blank
|
||||||
|
|
||||||
validates :title, presence: true, length: { minimum: 3 }
|
validates :title, presence: true, length: { minimum: 3 }
|
||||||
end
|
end
|
||||||
Reference in new issue
Block a user