mirror of
https://github.com/ryankazokas/turbovault-app.git
synced 2026-04-16 23:22:53 +00:00
Moving to github
This commit is contained in:
27
app/models/api_token.rb
Normal file
27
app/models/api_token.rb
Normal 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
|
||||
3
app/models/application_record.rb
Normal file
3
app/models/application_record.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
end
|
||||
47
app/models/collection.rb
Normal file
47
app/models/collection.rb
Normal 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
|
||||
7
app/models/collection_game.rb
Normal file
7
app/models/collection_game.rb
Normal 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
|
||||
0
app/models/concerns/.keep
Normal file
0
app/models/concerns/.keep
Normal file
66
app/models/game.rb
Normal file
66
app/models/game.rb
Normal 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
7
app/models/game_genre.rb
Normal 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
8
app/models/genre.rb
Normal 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
25
app/models/igdb_game.rb
Normal 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
|
||||
148
app/models/igdb_match_suggestion.rb
Normal file
148
app/models/igdb_match_suggestion.rb
Normal 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
|
||||
54
app/models/igdb_platform_mapping.rb
Normal file
54
app/models/igdb_platform_mapping.rb
Normal 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
34
app/models/item.rb
Normal 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
8
app/models/platform.rb
Normal 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
42
app/models/user.rb
Normal 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
|
||||
Reference in New Issue
Block a user