Skip to content

Instantly share code, notes, and snippets.

@prfraser
Created January 8, 2026 02:18
Show Gist options
  • Select an option

  • Save prfraser/6df8378b46c8cee8efcd3cc7378ad474 to your computer and use it in GitHub Desktop.

Select an option

Save prfraser/6df8378b46c8cee8efcd3cc7378ad474 to your computer and use it in GitHub Desktop.

Review Votes (Up/Down)

The Task

"Users should be able to upvote or downvote reviews"

What Makes This Harder Than Simple Upvotes

  • Three states: upvoted, downvoted, not voted
  • Can change vote (upvote → downvote)
  • Net score calculation (upvotes - downvotes)
  • More complex UI (two buttons, current state indication)

What They Need to Build

  • New review_votes table with value column
  • ReviewVote model
  • Vote/change vote/remove vote functionality
  • Display score and current vote state

Good Solution

1. Migration

# db/migrate/xxx_create_review_votes.rb
class CreateReviewVotes < ActiveRecord::Migration[7.1]
  def change
    create_table :review_votes do |t|
      t.references :user, null: false, foreign_key: true
      t.references :review, null: false, foreign_key: true
      t.integer :value, null: false
      t.timestamps
    end

    add_index :review_votes, [:user_id, :review_id], unique: true
  end
end

Look for: value column with null: false, unique composite index.


2. Model

# app/models/review_vote.rb
class ReviewVote < ApplicationRecord
  belongs_to :user
  belongs_to :review

  validates :review_id, uniqueness: { scope: :user_id }
  validates :value, inclusion: { in: [1, -1] }
end
# app/models/review.rb (add)
has_many :review_votes, dependent: :destroy

def score
  review_votes.sum(:value)
end

def vote_from(user)
  return nil unless user
  review_votes.find_by(user: user)
end
# app/models/user.rb (add)
has_many :review_votes, dependent: :destroy

Look for: Value validation with inclusion, score method using sum, helper method for finding user's vote.


3. Routes

# config/routes.rb (add)
resources :reviews, only: [] do
  resource :vote, only: [:create, :destroy], controller: 'review_votes'
end

Generates:

  • POST /reviews/:review_id/vote
  • DELETE /reviews/:review_id/vote

4. Controller

# app/controllers/review_votes_controller.rb
class ReviewVotesController < ApplicationController
  before_action :require_authenticated_user
  before_action :set_review

  def create
    value = params[:value].to_i

    unless [1, -1].include?(value)
      return redirect_back fallback_location: @review.book, alert: "Invalid vote"
    end

    vote = current_user.review_votes.find_or_initialize_by(review: @review)
    vote.value = value

    if vote.save
      redirect_back fallback_location: @review.book
    else
      redirect_back fallback_location: @review.book, alert: "Could not save vote"
    end
  end

  def destroy
    vote = current_user.review_votes.find_by(review: @review)
    vote&.destroy
    redirect_back fallback_location: @review.book
  end

  private

  def set_review
    @review = Review.find(params[:review_id])
  end
end

Look for:

  • Value validation in controller (allowlist)
  • find_or_initialize_by pattern for upsert
  • Scoped to current_user

5. View

<%# app/views/reviews/_vote_controls.html.erb %>
<%# locals: (review:) %>

<div class="vote-controls">
  <span class="score">Score: <%= review.score %></span>

  <% if current_user %>
    <% current_vote = review.vote_from(current_user) %>

    <%= button_to review_vote_path(review),
        params: { value: 1 },
        method: :post,
        class: current_vote&.value == 1 ? "btn-voted" : "btn-vote" do %>
      Upvote
    <% end %>

    <%= button_to review_vote_path(review),
        params: { value: -1 },
        method: :post,
        class: current_vote&.value == -1 ? "btn-voted" : "btn-vote" do %>
      Downvote
    <% end %>

    <% if current_vote %>
      <%= button_to "Clear", review_vote_path(review), method: :delete, class: "btn-clear" %>
    <% end %>
  <% end %>
</div>

Look for:

  • Passing value as param
  • Visual indication of current vote state
  • Clear vote option when voted
  • Login check

Alternative Approaches (All Acceptable)

Controller: Separate actions vs single action

# Separate actions (more RESTful, more code)
def upvote
  vote_with_value(1)
end

def downvote
  vote_with_value(-1)
end

# vs single action with param (shown above)
def create
  value = params[:value].to_i
  # ...
end

Model: Integer vs enum

# Integer (shown above)
validates :value, inclusion: { in: [1, -1] }

# Enum (also acceptable)
enum vote_type: { upvote: 1, downvote: -1 }

Discussion Points

Question What You're Looking For
"50 reviews on page, each calling .score - problem?" N+1 awareness, could preload or add counter cache
"What if we want to sort reviews by score?" Add scope: scope :by_score, -> { ... } with join/subquery
"Should users vote on their own reviews?" Business logic awareness, could add validation
"Integer value vs enum - tradeoffs?" Enum more readable, integer more flexible for future values

Common Mistakes

  • Not validating value in controller (security issue - user could pass any integer)
  • Creating new record instead of updating existing vote
  • N+1 when displaying vote state for many reviews
  • Forgetting to handle the "change vote" case
  • Not scoping queries to current_user

Bonus Points If They...

  • Add scope :by_score to Review model
  • Mention counter cache for performance
  • Add validation preventing self-votes
  • Extract vote checking into a helper method
  • Consider what happens if review is deleted (dependent destroy)

Time Expectation

  • Target: 30-40 minutes
  • Fast candidate: 25 minutes
  • Struggling: 45+ minutes (offer hints on find_or_initialize_by)

Hints to Give If Stuck

If stuck on... Hint
Changing existing vote "What ActiveRecord method finds OR creates a record?"
Passing value to controller "Can button_to accept additional params?"
Showing current vote state "Could the Review model have a helper method?"
Vote validation "What values should be allowed? How do you validate that?"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment