Moving to github

This commit is contained in:
2026-03-28 19:24:29 -04:00
commit 036fa7ab33
302 changed files with 17838 additions and 0 deletions

27
app/models/api_token.rb Normal file
View File

@@ -0,0 +1,27 @@
class ApiToken < ApplicationRecord
belongs_to :user
# Validations
validates :token, presence: true, uniqueness: true
# Callbacks
before_validation :generate_token, on: :create
# Scopes
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
# Instance methods
def expired?
expires_at.present? && expires_at < Time.current
end
def touch_last_used!
update_column(:last_used_at, Time.current)
end
private
def generate_token
self.token ||= SecureRandom.urlsafe_base64(32)
end
end

View File

@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end

47
app/models/collection.rb Normal file
View File

@@ -0,0 +1,47 @@
class Collection < ApplicationRecord
# Associations
belongs_to :user
belongs_to :parent_collection, class_name: "Collection", optional: true
has_many :subcollections, class_name: "Collection", foreign_key: "parent_collection_id", dependent: :destroy
has_many :collection_games, dependent: :destroy
has_many :games, through: :collection_games
# Validations
validates :name, presence: true
validate :cannot_be_own_parent
validate :subcollection_depth_limit
# Scopes
scope :root_collections, -> { where(parent_collection_id: nil) }
# Instance methods
def game_count
games.count
end
def total_game_count
game_count + subcollections.sum(&:total_game_count)
end
def root?
parent_collection_id.nil?
end
def subcollection?
parent_collection_id.present?
end
private
def cannot_be_own_parent
if parent_collection_id.present? && parent_collection_id == id
errors.add(:parent_collection_id, "cannot be itself")
end
end
def subcollection_depth_limit
if parent_collection.present? && parent_collection.parent_collection.present?
errors.add(:parent_collection_id, "cannot nest more than one level deep")
end
end
end

View File

@@ -0,0 +1,7 @@
class CollectionGame < ApplicationRecord
belongs_to :collection
belongs_to :game
# Validations
validates :game_id, uniqueness: { scope: :collection_id, message: "already in this collection" }
end

View File

66
app/models/game.rb Normal file
View File

@@ -0,0 +1,66 @@
class Game < ApplicationRecord
# Associations
belongs_to :user
belongs_to :platform
has_many :game_genres, dependent: :destroy
has_many :genres, through: :game_genres
has_many :collection_games, dependent: :destroy
has_many :collections, through: :collection_games
has_many :igdb_match_suggestions, dependent: :destroy
belongs_to :igdb_game, foreign_key: :igdb_id, primary_key: :igdb_id, optional: true
# Enums
enum :format, { physical: "physical", digital: "digital" }
enum :completion_status, {
backlog: "backlog",
currently_playing: "currently_playing",
completed: "completed",
on_hold: "on_hold",
not_playing: "not_playing"
}, prefix: true
enum :condition, {
cib: "cib", # Complete in Box
loose: "loose",
sealed: "sealed",
good: "good",
fair: "fair"
}, prefix: true
# Validations
validates :title, presence: true
validates :format, presence: true
validates :date_added, presence: true
validates :user_rating, inclusion: { in: 1..5, message: "must be between 1 and 5" }, allow_nil: true
validates :condition, presence: true, if: :physical?
validates :digital_store, presence: true, if: :digital?
# Callbacks
before_validation :set_date_added, on: :create
# Scopes
scope :physical_games, -> { where(format: "physical") }
scope :digital_games, -> { where(format: "digital") }
scope :currently_playing, -> { where(completion_status: "currently_playing") }
scope :completed, -> { where(completion_status: "completed") }
scope :backlog, -> { where(completion_status: "backlog") }
scope :by_platform, ->(platform_id) { where(platform_id: platform_id) }
scope :by_genre, ->(genre_id) { joins(:genres).where(genres: { id: genre_id }) }
scope :recent, -> { order(date_added: :desc) }
scope :alphabetical, -> { order(:title) }
scope :rated, -> { where.not(user_rating: nil).order(user_rating: :desc) }
scope :igdb_matched, -> { where.not(igdb_id: nil) }
scope :igdb_unmatched, -> { where(igdb_id: nil) }
scope :needs_igdb_review, -> { joins(:igdb_match_suggestions).where(igdb_match_suggestions: { status: "pending" }).distinct }
# Class methods
def self.search(query)
where("title ILIKE ?", "%#{sanitize_sql_like(query)}%")
end
private
def set_date_added
self.date_added ||= Date.current
end
end

7
app/models/game_genre.rb Normal file
View File

@@ -0,0 +1,7 @@
class GameGenre < ApplicationRecord
belongs_to :game
belongs_to :genre
# Validations
validates :game_id, uniqueness: { scope: :genre_id, message: "already has this genre" }
end

8
app/models/genre.rb Normal file
View File

@@ -0,0 +1,8 @@
class Genre < ApplicationRecord
# Associations
has_many :game_genres, dependent: :destroy
has_many :games, through: :game_genres
# Validations
validates :name, presence: true, uniqueness: { case_sensitive: false }
end

25
app/models/igdb_game.rb Normal file
View File

@@ -0,0 +1,25 @@
class IgdbGame < ApplicationRecord
# Associations
has_many :igdb_match_suggestions
has_many :games, foreign_key: :igdb_id, primary_key: :igdb_id
# Validations
validates :igdb_id, presence: true, uniqueness: true
validates :name, presence: true
# Scopes
scope :popular, -> { order(match_count: :desc) }
scope :recent, -> { order(last_synced_at: :desc) }
# Instance methods
def increment_match_count!
increment!(:match_count)
end
def cover_image_url(size = "cover_big")
return nil unless cover_url.present?
# IGDB uses image IDs like "co1234"
# We need to construct the full URL
"https://images.igdb.com/igdb/image/upload/t_#{size}/#{cover_url}.jpg"
end
end

View File

@@ -0,0 +1,148 @@
class IgdbMatchSuggestion < ApplicationRecord
# Associations
belongs_to :game
belongs_to :igdb_game, optional: true
# Enums
enum :status, {
pending: "pending",
approved: "approved",
rejected: "rejected"
}, prefix: true
# Validations
validates :igdb_id, presence: true
validates :igdb_name, presence: true
validates :game_id, uniqueness: { scope: :igdb_id }
# Scopes
scope :pending_review, -> { status_pending.order(confidence_score: :desc, created_at: :asc) }
scope :for_user, ->(user) { joins(:game).where(games: { user_id: user.id }) }
scope :high_confidence, -> { where("confidence_score >= ?", 80.0) }
# Instance methods
def approve!
transaction do
update!(status: "approved", reviewed_at: Time.current)
# Update the game with the matched IGDB ID
game.update!(
igdb_id: igdb_id,
igdb_matched_at: Time.current,
igdb_match_status: "matched",
igdb_match_confidence: confidence_score
)
# Find or create the IgdbGame record with full data
igdb_game_record = IgdbGame.find_or_create_by!(igdb_id: igdb_id) do |ig|
ig.name = igdb_name
ig.slug = igdb_slug
ig.cover_url = igdb_cover_url
ig.summary = igdb_summary
ig.first_release_date = igdb_release_date
ig.last_synced_at = Time.current
end
# Update summary if it wasn't already set
if igdb_game_record.summary.blank? && igdb_summary.present?
igdb_game_record.update(summary: igdb_summary)
end
igdb_game_record.increment_match_count!
# Map and assign IGDB genres to the game
sync_genres_to_game if igdb_genres.present?
# Reject all other pending suggestions for this game
game.igdb_match_suggestions
.where.not(id: id)
.status_pending
.update_all(
status: "rejected",
reviewed_at: Time.current
)
end
end
def reject!
transaction do
update!(
status: "rejected",
reviewed_at: Time.current
)
# Reject all other pending suggestions for this game
game.igdb_match_suggestions
.where.not(id: id)
.status_pending
.update_all(
status: "rejected",
reviewed_at: Time.current
)
# Mark game as manually reviewed with no match (only if all suggestions rejected)
if game.igdb_match_suggestions.status_pending.none?
game.update!(
igdb_match_status: "no_match",
igdb_matched_at: Time.current
)
end
end
end
def cover_image_url(size = "cover_big")
return nil unless igdb_cover_url.present?
"https://images.igdb.com/igdb/image/upload/t_#{size}/#{igdb_cover_url}.jpg"
end
private
def sync_genres_to_game
return unless igdb_genres.is_a?(Array) && igdb_genres.any?
# Genre mapping: IGDB genre names to our genre names
genre_mappings = {
"Role-playing (RPG)" => "RPG",
"Fighting" => "Fighting",
"Shooter" => "Shooter",
"Music" => "Music",
"Platform" => "Platformer",
"Puzzle" => "Puzzle",
"Racing" => "Racing",
"Real Time Strategy (RTS)" => "Strategy",
"Simulator" => "Simulation",
"Sport" => "Sports",
"Strategy" => "Strategy",
"Turn-based strategy (TBS)" => "Strategy",
"Tactical" => "Strategy",
"Hack and slash/Beat 'em up" => "Action",
"Quiz/Trivia" => "Puzzle",
"Pinball" => "Arcade",
"Adventure" => "Adventure",
"Indie" => "Indie",
"Arcade" => "Arcade",
"Visual Novel" => "Adventure",
"Card & Board Game" => "Puzzle",
"MOBA" => "Strategy",
"Point-and-click" => "Adventure"
}
# Find or create matching genres
igdb_genres.each do |igdb_genre_name|
# Try exact match first
local_genre = Genre.find_by("LOWER(name) = ?", igdb_genre_name.downcase)
# Try mapped name
if local_genre.nil? && genre_mappings[igdb_genre_name]
mapped_name = genre_mappings[igdb_genre_name]
local_genre = Genre.find_by("LOWER(name) = ?", mapped_name.downcase)
end
# Add genre to game if found and not already assigned
if local_genre && !game.genres.include?(local_genre)
game.genres << local_genre
Rails.logger.info("Added genre '#{local_genre.name}' to game #{game.id} from IGDB")
end
end
end
end

View File

@@ -0,0 +1,54 @@
class IgdbPlatformMapping < ApplicationRecord
# Associations
belongs_to :platform
# Validations
validates :platform_id, presence: true
validates :igdb_platform_id, presence: true, uniqueness: { scope: :platform_id }
# Class methods
def self.igdb_id_for_platform(platform)
find_by(platform: platform)&.igdb_platform_id
end
def self.seed_common_mappings!
mappings = {
"Nintendo 64" => 4,
"PlayStation" => 7,
"PlayStation 2" => 8,
"PlayStation 3" => 9,
"PlayStation 4" => 48,
"PlayStation 5" => 167,
"Xbox" => 11,
"Xbox 360" => 12,
"Xbox One" => 49,
"Xbox Series X/S" => 169,
"Nintendo Switch" => 130,
"Wii" => 5,
"Wii U" => 41,
"GameCube" => 21,
"Super Nintendo Entertainment System" => 19,
"Nintendo Entertainment System" => 18,
"Game Boy" => 33,
"Game Boy Color" => 22,
"Game Boy Advance" => 24,
"Nintendo DS" => 20,
"Nintendo 3DS" => 37,
"PC" => 6,
"Sega Genesis" => 29,
"Sega Dreamcast" => 23,
"PlayStation Portable" => 38,
"PlayStation Vita" => 46
}
mappings.each do |platform_name, igdb_id|
platform = Platform.find_by(name: platform_name)
next unless platform
find_or_create_by!(platform: platform) do |mapping|
mapping.igdb_platform_id = igdb_id
mapping.igdb_platform_name = platform_name
end
end
end
end

34
app/models/item.rb Normal file
View File

@@ -0,0 +1,34 @@
class Item < ApplicationRecord
# Associations
belongs_to :user
belongs_to :platform, optional: true
# Enums
enum :item_type, {
console: "console",
controller: "controller",
accessory: "accessory",
other: "other"
}, prefix: true
# Validations
validates :name, presence: true
validates :item_type, presence: true
validates :date_added, presence: true
# Callbacks
before_validation :set_date_added, on: :create
# Scopes
scope :consoles, -> { where(item_type: "console") }
scope :controllers, -> { where(item_type: "controller") }
scope :accessories, -> { where(item_type: "accessory") }
scope :by_platform, ->(platform_id) { where(platform_id: platform_id) }
scope :recent, -> { order(date_added: :desc) }
private
def set_date_added
self.date_added ||= Date.current
end
end

8
app/models/platform.rb Normal file
View File

@@ -0,0 +1,8 @@
class Platform < ApplicationRecord
# Associations
has_many :games, dependent: :restrict_with_error
has_many :items, dependent: :restrict_with_error
# Validations
validates :name, presence: true, uniqueness: { case_sensitive: false }
end

42
app/models/user.rb Normal file
View File

@@ -0,0 +1,42 @@
class User < ApplicationRecord
has_secure_password
# Associations
has_many :games, dependent: :destroy
has_many :collections, dependent: :destroy
has_many :items, dependent: :destroy
has_many :api_tokens, dependent: :destroy
has_many :igdb_match_suggestions, through: :games
# Validations
validates :email, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :username, presence: true, uniqueness: { case_sensitive: false },
length: { minimum: 3, maximum: 30 },
format: { with: /\A[a-zA-Z0-9_]+\z/, message: "only allows letters, numbers, and underscores" }
validates :password, length: { minimum: 8 }, if: -> { password.present? }
validates :theme, presence: true, inclusion: { in: %w[light dark midnight retro ocean] }
# Callbacks
before_save :downcase_email
# Instance methods
def generate_password_reset_token
self.password_reset_token = SecureRandom.urlsafe_base64
self.password_reset_sent_at = Time.current
save!
end
def password_reset_expired?
password_reset_sent_at.nil? || password_reset_sent_at < 2.hours.ago
end
def theme_class
"theme-#{theme}"
end
private
def downcase_email
self.email = email.downcase if email.present?
end
end