mirror of
https://github.com/ryankazokas/turbovault-app.git
synced 2026-04-16 22:12:53 +00:00
Adds types
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
# typed: true
|
||||
|
||||
class ApiTokensController < ApplicationController
|
||||
extend T::Sig
|
||||
before_action :require_authentication
|
||||
|
||||
def index
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
# typed: true
|
||||
|
||||
class CollectionsController < ApplicationController
|
||||
extend T::Sig
|
||||
before_action :require_authentication
|
||||
before_action :set_collection, only: [ :show, :edit, :update, :destroy, :games ]
|
||||
|
||||
sig { void }
|
||||
def index
|
||||
@root_collections = current_user.collections.root_collections.order(:name)
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def show
|
||||
@games = @collection.games.includes(:platform, :genres).page(params[:page]).per(25)
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def new
|
||||
@collection = current_user.collections.build
|
||||
@collections = current_user.collections.root_collections.order(:name)
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def create
|
||||
@collection = current_user.collections.build(collection_params)
|
||||
|
||||
@@ -26,10 +33,12 @@ class CollectionsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def edit
|
||||
@collections = current_user.collections.root_collections.where.not(id: @collection.id).order(:name)
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def update
|
||||
if @collection.update(collection_params)
|
||||
redirect_to @collection, notice: "Collection was successfully updated."
|
||||
@@ -39,11 +48,13 @@ class CollectionsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def destroy
|
||||
@collection.destroy
|
||||
redirect_to collections_path, notice: "Collection was successfully deleted."
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def games
|
||||
# Same as show, but maybe with different view
|
||||
@games = @collection.games.includes(:platform, :genres).page(params[:page]).per(25)
|
||||
@@ -52,10 +63,12 @@ class CollectionsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
sig { void }
|
||||
def set_collection
|
||||
@collection = current_user.collections.find(params[:id])
|
||||
end
|
||||
|
||||
sig { returns(T.untyped) }
|
||||
def collection_params
|
||||
permitted = params.require(:collection).permit(:name, :description, :parent_collection_id)
|
||||
# Convert empty string to nil for parent_collection_id
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# typed: true
|
||||
|
||||
class DashboardController < ApplicationController
|
||||
extend T::Sig
|
||||
before_action :require_authentication
|
||||
|
||||
sig { void }
|
||||
def index
|
||||
@recently_added_games = current_user.games.recent.limit(5)
|
||||
@currently_playing_games = current_user.games.currently_playing.limit(5)
|
||||
@@ -10,17 +14,7 @@ class DashboardController < ApplicationController
|
||||
@completed_games = current_user.games.completed.count
|
||||
@backlog_games = current_user.games.backlog.count
|
||||
@total_spent = current_user.games.sum(:price_paid) || 0
|
||||
|
||||
@games_by_platform = current_user.games.joins(:platform)
|
||||
.group("platforms.name")
|
||||
.count
|
||||
.sort_by { |_, count| -count }
|
||||
.first(5)
|
||||
|
||||
@games_by_genre = current_user.games.joins(:genres)
|
||||
.group("genres.name")
|
||||
.count
|
||||
.sort_by { |_, count| -count }
|
||||
.first(5)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
# typed: true
|
||||
|
||||
class GamesController < ApplicationController
|
||||
extend T::Sig
|
||||
|
||||
before_action :require_authentication
|
||||
before_action :set_game, only: [ :show, :edit, :update, :destroy ]
|
||||
|
||||
sig { void }
|
||||
def index
|
||||
@games = current_user.games.includes(:platform, :genres, :collections)
|
||||
|
||||
@@ -27,9 +32,11 @@ class GamesController < ApplicationController
|
||||
@genres = Genre.order(:name)
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def show
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def new
|
||||
@game = current_user.games.build
|
||||
@platforms = Platform.order(:name)
|
||||
@@ -37,13 +44,14 @@ class GamesController < ApplicationController
|
||||
@collections = current_user.collections.order(:name)
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def create
|
||||
@game = current_user.games.build(game_params)
|
||||
|
||||
if @game.save
|
||||
# If game was created with IGDB ID, sync the metadata
|
||||
sync_igdb_metadata_after_create if @game.igdb_id.present?
|
||||
|
||||
|
||||
redirect_to @game, notice: "Game was successfully created."
|
||||
else
|
||||
@platforms = Platform.order(:name)
|
||||
@@ -53,12 +61,14 @@ class GamesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def edit
|
||||
@platforms = Platform.order(:name)
|
||||
@genres = Genre.order(:name)
|
||||
@collections = current_user.collections.order(:name)
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def update
|
||||
if @game.update(game_params)
|
||||
redirect_to @game, notice: "Game was successfully updated."
|
||||
@@ -70,50 +80,54 @@ class GamesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def destroy
|
||||
@game.destroy
|
||||
redirect_to games_path, notice: "Game was successfully deleted."
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def import
|
||||
@platforms = Platform.order(:name)
|
||||
@genres = Genre.order(:name)
|
||||
@collections = current_user.collections.order(:name)
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def bulk_edit
|
||||
@game_ids = params[:game_ids] || []
|
||||
|
||||
|
||||
if @game_ids.empty?
|
||||
redirect_to games_path, alert: "Please select at least one game to edit."
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
@games = current_user.games.where(id: @game_ids)
|
||||
@platforms = Platform.order(:name)
|
||||
@genres = Genre.order(:name)
|
||||
@collections = current_user.collections.order(:name)
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def bulk_update
|
||||
@game_ids = params[:game_ids] || []
|
||||
|
||||
|
||||
if @game_ids.empty?
|
||||
redirect_to games_path, alert: "No games selected."
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
@games = current_user.games.where(id: @game_ids)
|
||||
updated_count = 0
|
||||
|
||||
|
||||
@games.each do |game|
|
||||
updates = {}
|
||||
|
||||
|
||||
# Only update fields that have values provided
|
||||
updates[:completion_status] = params[:completion_status] if params[:completion_status].present?
|
||||
updates[:location] = params[:location] if params[:location].present?
|
||||
updates[:condition] = params[:condition] if params[:condition].present?
|
||||
|
||||
|
||||
# Handle collection assignment
|
||||
if params[:collection_action].present?
|
||||
case params[:collection_action]
|
||||
@@ -129,7 +143,7 @@ class GamesController < ApplicationController
|
||||
game.collection_ids = params[:collection_ids] if params[:collection_ids].present?
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
# Handle genre assignment
|
||||
if params[:genre_action].present?
|
||||
case params[:genre_action]
|
||||
@@ -145,15 +159,16 @@ class GamesController < ApplicationController
|
||||
game.genre_ids = params[:genre_ids] if params[:genre_ids].present?
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
if game.update(updates)
|
||||
updated_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
redirect_to games_path, notice: "Successfully updated #{updated_count} game(s)."
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def bulk_create
|
||||
require "csv"
|
||||
|
||||
@@ -163,9 +178,11 @@ class GamesController < ApplicationController
|
||||
csv_text = params[:csv_file].read
|
||||
csv = CSV.parse(csv_text, headers: true)
|
||||
|
||||
csv.each_with_index do |row, index|
|
||||
csv.each_with_index do |csv_row, index|
|
||||
# Cast to untyped to avoid Sorbet CSV::Row vs Hash confusion
|
||||
row = T.let(csv_row, T.untyped)
|
||||
platform = Platform.find_by(name: row["platform"]) || Platform.find_by(abbreviation: row["platform"])
|
||||
|
||||
|
||||
unless platform
|
||||
results[:failed] += 1
|
||||
results[:errors] << "Row #{index + 2}: Platform '#{row['platform']}' not found"
|
||||
@@ -209,10 +226,11 @@ class GamesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def search_igdb
|
||||
query = params[:q].to_s.strip
|
||||
platform_id = params[:platform_id]
|
||||
|
||||
|
||||
if query.length < 2
|
||||
render json: []
|
||||
return
|
||||
@@ -221,15 +239,15 @@ class GamesController < ApplicationController
|
||||
begin
|
||||
service = IgdbService.new
|
||||
platform = platform_id.present? ? Platform.find_by(id: platform_id) : nil
|
||||
|
||||
|
||||
# Search IGDB (limit to 10 results for autocomplete)
|
||||
results = service.search_game(query, platform, 10)
|
||||
|
||||
|
||||
# Format results for autocomplete
|
||||
formatted_results = results.map do |result|
|
||||
# Map IGDB genres to our local genre IDs
|
||||
genre_ids = map_igdb_genres_to_ids(result[:genres] || [])
|
||||
|
||||
|
||||
{
|
||||
igdb_id: result[:igdb_id],
|
||||
name: result[:name],
|
||||
@@ -242,7 +260,7 @@ class GamesController < ApplicationController
|
||||
confidence: result[:confidence_score]
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
render json: formatted_results
|
||||
rescue => e
|
||||
Rails.logger.error("IGDB search error: #{e.message}")
|
||||
@@ -250,49 +268,53 @@ class GamesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def search_locations
|
||||
query = params[:q].to_s.strip
|
||||
|
||||
|
||||
# Get unique locations from user's games that match the query
|
||||
locations = current_user.games
|
||||
.where.not(location: [nil, ""])
|
||||
.where.not(location: [ nil, "" ])
|
||||
.where("location ILIKE ?", "%#{query}%")
|
||||
.select(:location)
|
||||
.distinct
|
||||
.order(:location)
|
||||
.limit(10)
|
||||
.pluck(:location)
|
||||
|
||||
|
||||
render json: locations
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def search_stores
|
||||
query = params[:q].to_s.strip
|
||||
|
||||
|
||||
# Get unique digital stores from user's games that match the query
|
||||
stores = current_user.games
|
||||
.where.not(digital_store: [nil, ""])
|
||||
.where.not(digital_store: [ nil, "" ])
|
||||
.where("digital_store ILIKE ?", "%#{query}%")
|
||||
.select(:digital_store)
|
||||
.distinct
|
||||
.order(:digital_store)
|
||||
.limit(10)
|
||||
.pluck(:digital_store)
|
||||
|
||||
|
||||
render json: stores
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
sig { void }
|
||||
def set_game
|
||||
@game = current_user.games.includes(:igdb_game).find(params[:id])
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def sync_igdb_metadata_after_create
|
||||
# Fetch full game data from IGDB
|
||||
service = IgdbService.new
|
||||
igdb_data = service.get_game(@game.igdb_id)
|
||||
|
||||
|
||||
return unless igdb_data
|
||||
|
||||
# Create or update IgdbGame record
|
||||
@@ -301,11 +323,11 @@ class GamesController < ApplicationController
|
||||
ig.slug = igdb_data["slug"]
|
||||
ig.summary = igdb_data["summary"]
|
||||
ig.first_release_date = igdb_data["first_release_date"] ? Time.at(igdb_data["first_release_date"]).to_date : nil
|
||||
|
||||
|
||||
# Extract cover URL
|
||||
cover_url = igdb_data.dig("cover", "url")&.split("/")&.last&.sub(".jpg", "")
|
||||
ig.cover_url = cover_url
|
||||
|
||||
|
||||
ig.last_synced_at = Time.current
|
||||
end
|
||||
|
||||
@@ -327,9 +349,10 @@ class GamesController < ApplicationController
|
||||
Rails.logger.error("Failed to sync IGDB metadata: #{e.message}")
|
||||
end
|
||||
|
||||
sig { params(genre_names: T::Array[String]).returns(T::Array[Integer]) }
|
||||
def map_igdb_genres_to_ids(genre_names)
|
||||
return [] if genre_names.blank?
|
||||
|
||||
|
||||
# Genre mapping (same as in IgdbMatchSuggestion)
|
||||
genre_mappings = {
|
||||
"Role-playing (RPG)" => "RPG",
|
||||
@@ -352,28 +375,30 @@ class GamesController < ApplicationController
|
||||
genre_names.each do |igdb_genre_name|
|
||||
# Try exact match
|
||||
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
|
||||
|
||||
|
||||
genre_ids << local_genre.id if local_genre
|
||||
end
|
||||
|
||||
|
||||
genre_ids
|
||||
end
|
||||
|
||||
sig { params(genre_names: T::Array[String]).void }
|
||||
def assign_igdb_genres_to_game(genre_names)
|
||||
genre_ids = map_igdb_genres_to_ids(genre_names)
|
||||
|
||||
|
||||
genre_ids.each do |genre_id|
|
||||
genre = Genre.find(genre_id)
|
||||
@game.genres << genre unless @game.genres.include?(genre)
|
||||
end
|
||||
end
|
||||
|
||||
sig { returns(T.untyped) }
|
||||
def game_params
|
||||
params.require(:game).permit(
|
||||
:title, :platform_id, :format, :date_added, :completion_status,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# typed: true
|
||||
|
||||
class IgdbMatchesController < ApplicationController
|
||||
extend T::Sig
|
||||
before_action :require_authentication
|
||||
before_action :set_suggestion, only: [ :approve, :reject ]
|
||||
|
||||
@@ -10,7 +13,7 @@ class IgdbMatchesController < ApplicationController
|
||||
.where(games: { igdb_id: nil })
|
||||
.includes(game: :platform)
|
||||
.group_by(&:game)
|
||||
|
||||
|
||||
@matched_games = current_user.games.igdb_matched.count
|
||||
@unmatched_games = current_user.games.igdb_unmatched.count
|
||||
@pending_review_count = current_user.igdb_match_suggestions
|
||||
@@ -43,8 +46,8 @@ class IgdbMatchesController < ApplicationController
|
||||
end
|
||||
|
||||
# Check if games need syncing
|
||||
unmatched_count = current_user.games.igdb_unmatched.where(igdb_match_status: [nil, "failed"]).count
|
||||
|
||||
unmatched_count = current_user.games.igdb_unmatched.where(igdb_match_status: [ nil, "failed" ]).count
|
||||
|
||||
if unmatched_count == 0
|
||||
redirect_to igdb_matches_path, alert: "All games are already matched or being processed!"
|
||||
return
|
||||
@@ -53,7 +56,7 @@ class IgdbMatchesController < ApplicationController
|
||||
# Try to run job immediately for faster feedback
|
||||
begin
|
||||
IgdbSyncJob.perform_later
|
||||
|
||||
|
||||
redirect_to igdb_matches_path, notice: "IGDB sync started! Processing #{unmatched_count} games. This may take a few minutes - the page will auto-refresh."
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to start IGDB sync: #{e.message}")
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# typed: true
|
||||
|
||||
class PagesController < ApplicationController
|
||||
extend T::Sig
|
||||
def home
|
||||
if user_signed_in?
|
||||
redirect_to dashboard_path
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# typed: true
|
||||
|
||||
class PasswordResetsController < ApplicationController
|
||||
extend T::Sig
|
||||
before_action :require_no_authentication, only: [ :new, :create, :edit, :update ]
|
||||
before_action :set_user_by_token, only: [ :edit, :update ]
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# typed: true
|
||||
|
||||
class ProfilesController < ApplicationController
|
||||
extend T::Sig
|
||||
def show
|
||||
@user = User.find_by!(username: params[:username])
|
||||
|
||||
@@ -11,13 +14,13 @@ class ProfilesController < ApplicationController
|
||||
@physical_games = @user.games.physical_games.count
|
||||
@digital_games = @user.games.digital_games.count
|
||||
@completed_games = @user.games.completed.count
|
||||
|
||||
|
||||
@games_by_platform = @user.games.joins(:platform)
|
||||
.group("platforms.name")
|
||||
.count
|
||||
.sort_by { |_, count| -count }
|
||||
.first(10)
|
||||
|
||||
|
||||
@public_collections = @user.collections.root_collections.order(:name)
|
||||
@recent_games = @user.games.includes(:platform).recent.limit(10)
|
||||
end
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
# typed: true
|
||||
|
||||
class SessionsController < ApplicationController
|
||||
extend T::Sig
|
||||
|
||||
before_action :require_no_authentication, only: [ :new, :create ]
|
||||
before_action :require_authentication, only: [ :destroy ]
|
||||
|
||||
sig { void }
|
||||
def new
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def create
|
||||
user = User.find_by(email: params[:email].downcase)
|
||||
|
||||
@@ -17,6 +23,7 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def destroy
|
||||
sign_out
|
||||
redirect_to root_path, notice: "You have been signed out."
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
# typed: true
|
||||
|
||||
class UsersController < ApplicationController
|
||||
extend T::Sig
|
||||
before_action :require_no_authentication, only: [ :new, :create ]
|
||||
before_action :require_authentication, only: [ :edit, :update, :settings ]
|
||||
before_action :set_user, only: [ :edit, :update ]
|
||||
|
||||
sig { void }
|
||||
def new
|
||||
@user = User.new
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def create
|
||||
@user = User.new(user_params)
|
||||
|
||||
@@ -18,9 +23,11 @@ class UsersController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def edit
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def update
|
||||
if @user.update(user_params)
|
||||
redirect_to settings_path, notice: "Your profile has been updated."
|
||||
@@ -29,6 +36,7 @@ class UsersController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def settings
|
||||
@user = current_user
|
||||
@api_tokens = current_user.api_tokens.order(created_at: :desc)
|
||||
@@ -36,10 +44,12 @@ class UsersController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
sig { void }
|
||||
def set_user
|
||||
@user = current_user
|
||||
end
|
||||
|
||||
sig { returns(T.untyped) }
|
||||
def user_params
|
||||
params.require(:user).permit(:email, :username, :password, :password_confirmation, :bio, :profile_public, :igdb_sync_enabled, :theme)
|
||||
end
|
||||
|
||||
@@ -22,7 +22,7 @@ class IgdbSyncJob < ApplicationJob
|
||||
end
|
||||
|
||||
self.class.mark_running!
|
||||
|
||||
|
||||
begin
|
||||
sync_users_with_igdb
|
||||
ensure
|
||||
@@ -34,16 +34,16 @@ class IgdbSyncJob < ApplicationJob
|
||||
|
||||
def sync_users_with_igdb
|
||||
users = User.where(igdb_sync_enabled: true)
|
||||
|
||||
|
||||
Rails.logger.info("Starting IGDB sync for #{users.count} users")
|
||||
|
||||
|
||||
users.find_each do |user|
|
||||
sync_user_games(user)
|
||||
rescue => e
|
||||
Rails.logger.error("Error syncing user #{user.id}: #{e.message}")
|
||||
next
|
||||
end
|
||||
|
||||
|
||||
Rails.logger.info("IGDB sync completed")
|
||||
end
|
||||
|
||||
@@ -51,20 +51,20 @@ class IgdbSyncJob < ApplicationJob
|
||||
# Get games that need IGDB matching
|
||||
games = user.games
|
||||
.igdb_unmatched
|
||||
.where(igdb_match_status: [nil, "failed"])
|
||||
.where(igdb_match_status: [ nil, "failed" ])
|
||||
.includes(:platform)
|
||||
|
||||
|
||||
return if games.empty?
|
||||
|
||||
Rails.logger.info("Syncing #{games.count} games for user #{user.id}")
|
||||
|
||||
|
||||
igdb_service = IgdbService.new
|
||||
games_synced = 0
|
||||
|
||||
|
||||
games.find_each do |game|
|
||||
process_game_matching(game, igdb_service)
|
||||
games_synced += 1
|
||||
|
||||
|
||||
# Rate limiting: Additional sleep every 10 games
|
||||
sleep(1) if games_synced % 10 == 0
|
||||
rescue => e
|
||||
@@ -78,16 +78,16 @@ class IgdbSyncJob < ApplicationJob
|
||||
|
||||
def process_game_matching(game, igdb_service)
|
||||
Rails.logger.info("Searching IGDB for: #{game.title} (#{game.platform.name})")
|
||||
|
||||
|
||||
# Try searching WITH platform first
|
||||
results = igdb_service.search_game(game.title, game.platform, 3)
|
||||
|
||||
|
||||
# If no results, try WITHOUT platform (broader search)
|
||||
if results.empty?
|
||||
Rails.logger.info("No results with platform, trying without platform filter...")
|
||||
results = igdb_service.search_game(game.title, nil, 3)
|
||||
end
|
||||
|
||||
|
||||
if results.empty?
|
||||
Rails.logger.info("No IGDB matches found for game #{game.id}")
|
||||
game.update(igdb_match_status: "no_results")
|
||||
@@ -95,7 +95,7 @@ class IgdbSyncJob < ApplicationJob
|
||||
end
|
||||
|
||||
Rails.logger.info("Found #{results.count} potential matches for game #{game.id}")
|
||||
|
||||
|
||||
# Create match suggestions for user review
|
||||
results.each do |result|
|
||||
create_match_suggestion(game, result)
|
||||
@@ -130,7 +130,7 @@ class IgdbSyncJob < ApplicationJob
|
||||
confidence_score: result[:confidence_score],
|
||||
status: "pending"
|
||||
)
|
||||
|
||||
|
||||
Rails.logger.info("Created match suggestion: #{result[:name]} (confidence: #{result[:confidence_score]}%)")
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.warn("Failed to create match suggestion: #{e.message}")
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# typed: true
|
||||
|
||||
class ApiToken < ApplicationRecord
|
||||
extend T::Sig
|
||||
|
||||
belongs_to :user
|
||||
|
||||
# Validations
|
||||
@@ -11,16 +15,19 @@ class ApiToken < ApplicationRecord
|
||||
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
|
||||
# Instance methods
|
||||
sig { returns(T::Boolean) }
|
||||
def expired?
|
||||
expires_at.present? && expires_at < Time.current
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def touch_last_used!
|
||||
update_column(:last_used_at, Time.current)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
sig { void }
|
||||
def generate_token
|
||||
self.token ||= SecureRandom.urlsafe_base64(32)
|
||||
end
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# typed: true
|
||||
|
||||
class Collection < ApplicationRecord
|
||||
extend T::Sig
|
||||
# Associations
|
||||
belongs_to :user
|
||||
belongs_to :parent_collection, class_name: "Collection", optional: true
|
||||
@@ -15,30 +18,36 @@ class Collection < ApplicationRecord
|
||||
scope :root_collections, -> { where(parent_collection_id: nil) }
|
||||
|
||||
# Instance methods
|
||||
sig { returns(Integer) }
|
||||
def game_count
|
||||
games.count
|
||||
end
|
||||
|
||||
sig { returns(Integer) }
|
||||
def total_game_count
|
||||
game_count + subcollections.sum(&:total_game_count)
|
||||
end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def root?
|
||||
parent_collection_id.nil?
|
||||
end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def subcollection?
|
||||
parent_collection_id.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
sig { void }
|
||||
def cannot_be_own_parent
|
||||
if parent_collection_id.present? && parent_collection_id == id
|
||||
errors.add(:parent_collection_id, "cannot be itself")
|
||||
end
|
||||
end
|
||||
|
||||
sig { void }
|
||||
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")
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# typed: true
|
||||
|
||||
class CollectionGame < ApplicationRecord
|
||||
extend T::Sig
|
||||
belongs_to :collection
|
||||
belongs_to :game
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# typed: true
|
||||
|
||||
class Game < ApplicationRecord
|
||||
extend T::Sig
|
||||
# Associations
|
||||
belongs_to :user
|
||||
belongs_to :platform
|
||||
@@ -54,12 +57,14 @@ class Game < ApplicationRecord
|
||||
scope :needs_igdb_review, -> { joins(:igdb_match_suggestions).where(igdb_match_suggestions: { status: "pending" }).distinct }
|
||||
|
||||
# Class methods
|
||||
sig { params(query: String).returns(T.untyped) }
|
||||
def self.search(query)
|
||||
where("title ILIKE ?", "%#{sanitize_sql_like(query)}%")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
sig { void }
|
||||
def set_date_added
|
||||
self.date_added ||= Date.current
|
||||
end
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# typed: true
|
||||
|
||||
class GameGenre < ApplicationRecord
|
||||
extend T::Sig
|
||||
belongs_to :game
|
||||
belongs_to :genre
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# typed: true
|
||||
|
||||
class Genre < ApplicationRecord
|
||||
extend T::Sig
|
||||
# Associations
|
||||
has_many :game_genres, dependent: :destroy
|
||||
has_many :games, through: :game_genres
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# typed: true
|
||||
|
||||
class IgdbGame < ApplicationRecord
|
||||
extend T::Sig
|
||||
# Associations
|
||||
has_many :igdb_match_suggestions
|
||||
has_many :games, foreign_key: :igdb_id, primary_key: :igdb_id
|
||||
@@ -12,10 +15,12 @@ class IgdbGame < ApplicationRecord
|
||||
scope :recent, -> { order(last_synced_at: :desc) }
|
||||
|
||||
# Instance methods
|
||||
sig { void }
|
||||
def increment_match_count!
|
||||
increment!(:match_count)
|
||||
end
|
||||
|
||||
sig { params(size: String).returns(T.nilable(String)) }
|
||||
def cover_image_url(size = "cover_big")
|
||||
return nil unless cover_url.present?
|
||||
# IGDB uses image IDs like "co1234"
|
||||
|
||||
@@ -24,7 +24,7 @@ class IgdbMatchSuggestion < ApplicationRecord
|
||||
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,
|
||||
@@ -49,10 +49,10 @@ class IgdbMatchSuggestion < ApplicationRecord
|
||||
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)
|
||||
@@ -131,13 +131,13 @@ class IgdbMatchSuggestion < ApplicationRecord
|
||||
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
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# typed: true
|
||||
|
||||
class Platform < ApplicationRecord
|
||||
extend T::Sig
|
||||
# Associations
|
||||
has_many :games, dependent: :restrict_with_error
|
||||
has_many :items, dependent: :restrict_with_error
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# typed: true
|
||||
|
||||
class User < ApplicationRecord
|
||||
extend T::Sig
|
||||
|
||||
has_secure_password
|
||||
|
||||
# Associations
|
||||
@@ -10,7 +14,7 @@ class User < ApplicationRecord
|
||||
|
||||
# Validations
|
||||
validates :email, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :username, presence: true, uniqueness: { case_sensitive: false },
|
||||
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? }
|
||||
@@ -20,22 +24,26 @@ class User < ApplicationRecord
|
||||
before_save :downcase_email
|
||||
|
||||
# Instance methods
|
||||
sig { returns(T::Boolean) }
|
||||
def generate_password_reset_token
|
||||
self.password_reset_token = SecureRandom.urlsafe_base64
|
||||
self.password_reset_sent_at = Time.current
|
||||
save!
|
||||
end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def password_reset_expired?
|
||||
password_reset_sent_at.nil? || password_reset_sent_at < 2.hours.ago
|
||||
end
|
||||
|
||||
sig { returns(String) }
|
||||
def theme_class
|
||||
"theme-#{theme}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
sig { void }
|
||||
def downcase_email
|
||||
self.email = email.downcase if email.present?
|
||||
end
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
require 'net/http'
|
||||
require 'json'
|
||||
require 'uri'
|
||||
require "net/http"
|
||||
require "json"
|
||||
require "uri"
|
||||
|
||||
# typed: true
|
||||
class IgdbService
|
||||
extend T::Sig
|
||||
|
||||
BASE_URL = "https://api.igdb.com/v4"
|
||||
TOKEN_URL = "https://id.twitch.tv/oauth2/token"
|
||||
CACHE_KEY = "igdb_access_token"
|
||||
|
||||
|
||||
class ApiError < StandardError; end
|
||||
class RateLimitError < StandardError; end
|
||||
|
||||
sig { void }
|
||||
def initialize
|
||||
@client_id = ENV.fetch("IGDB_CLIENT_ID")
|
||||
@client_secret = ENV.fetch("IGDB_CLIENT_SECRET")
|
||||
@access_token = get_or_refresh_token
|
||||
@client_id = T.let(ENV.fetch("IGDB_CLIENT_ID"), String)
|
||||
@client_secret = T.let(ENV.fetch("IGDB_CLIENT_SECRET"), String)
|
||||
@access_token = T.let(get_or_refresh_token, String)
|
||||
end
|
||||
|
||||
# Search for games by title and platform
|
||||
# Returns array of matches with confidence scores
|
||||
sig { params(title: String, platform: T.nilable(String), limit: Integer).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
|
||||
def search_game(title, platform = nil, limit = 3)
|
||||
platform_filter = platform_filter_query(platform)
|
||||
|
||||
|
||||
query = <<~QUERY
|
||||
search "#{sanitize_search_term(title)}";
|
||||
fields id, name, slug, cover.url, summary, first_release_date, platforms.name, genres.name;
|
||||
@@ -29,7 +34,7 @@ class IgdbService
|
||||
QUERY
|
||||
|
||||
results = post("/games", query)
|
||||
|
||||
|
||||
return [] if results.empty?
|
||||
|
||||
# Calculate confidence scores and format results
|
||||
@@ -43,6 +48,7 @@ class IgdbService
|
||||
end
|
||||
|
||||
# Get specific game by IGDB ID
|
||||
sig { params(igdb_id: Integer).returns(T.nilable(T::Hash[String, T.untyped])) }
|
||||
def get_game(igdb_id)
|
||||
query = <<~QUERY
|
||||
fields id, name, slug, cover.url, summary, first_release_date, platforms.name, genres.name;
|
||||
@@ -56,6 +62,7 @@ class IgdbService
|
||||
private
|
||||
|
||||
# Get cached token or generate a new one
|
||||
sig { returns(String) }
|
||||
def get_or_refresh_token
|
||||
# Check if we have a cached token
|
||||
cached_token = Rails.cache.read(CACHE_KEY)
|
||||
@@ -66,27 +73,28 @@ class IgdbService
|
||||
end
|
||||
|
||||
# Generate a new access token from Twitch
|
||||
sig { returns(String) }
|
||||
def generate_access_token
|
||||
uri = URI(TOKEN_URL)
|
||||
uri.query = URI.encode_www_form({
|
||||
client_id: @client_id,
|
||||
client_secret: @client_secret,
|
||||
grant_type: 'client_credentials'
|
||||
grant_type: "client_credentials"
|
||||
})
|
||||
|
||||
response = Net::HTTP.post(uri, '')
|
||||
|
||||
response = Net::HTTP.post(uri, "")
|
||||
|
||||
if response.code.to_i == 200
|
||||
data = JSON.parse(response.body)
|
||||
token = data['access_token']
|
||||
expires_in = data['expires_in'] # seconds until expiration (usually ~5 million seconds / ~60 days)
|
||||
|
||||
token = data["access_token"]
|
||||
expires_in = data["expires_in"] # seconds until expiration (usually ~5 million seconds / ~60 days)
|
||||
|
||||
# Cache token for 90% of its lifetime to be safe
|
||||
cache_duration = (expires_in * 0.9).to_i
|
||||
Rails.cache.write(CACHE_KEY, token, expires_in: cache_duration)
|
||||
|
||||
|
||||
Rails.logger.info("Generated new IGDB access token (expires in #{expires_in / 86400} days)")
|
||||
|
||||
|
||||
token
|
||||
else
|
||||
raise ApiError, "Failed to get IGDB token: #{response.code} - #{response.body}"
|
||||
@@ -96,12 +104,13 @@ class IgdbService
|
||||
raise ApiError, "Cannot authenticate with IGDB: #{e.message}"
|
||||
end
|
||||
|
||||
sig { params(endpoint: String, body: String, retry_count: Integer).returns(T::Array[T::Hash[String, T.untyped]]) }
|
||||
def post(endpoint, body, retry_count = 0)
|
||||
uri = URI("#{BASE_URL}#{endpoint}")
|
||||
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
|
||||
|
||||
request = Net::HTTP::Post.new(uri.path)
|
||||
request["Client-ID"] = @client_id
|
||||
request["Authorization"] = "Bearer #{@access_token}"
|
||||
@@ -114,9 +123,9 @@ class IgdbService
|
||||
sleep(0.3)
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
|
||||
Rails.logger.info("IGDB Response: #{response.code} - #{response.body[0..200]}")
|
||||
|
||||
|
||||
case response.code.to_i
|
||||
when 200
|
||||
JSON.parse(response.body)
|
||||
@@ -126,7 +135,7 @@ class IgdbService
|
||||
Rails.logger.warn("IGDB token invalid, refreshing...")
|
||||
Rails.cache.delete(CACHE_KEY)
|
||||
@access_token = generate_access_token
|
||||
return post(endpoint, body, retry_count + 1)
|
||||
post(endpoint, body, retry_count + 1)
|
||||
else
|
||||
Rails.logger.error("IGDB authentication failed after token refresh")
|
||||
raise ApiError, "IGDB authentication failed"
|
||||
@@ -145,6 +154,7 @@ class IgdbService
|
||||
[]
|
||||
end
|
||||
|
||||
sig { params(platform: T.nilable(String)).returns(String) }
|
||||
def platform_filter_query(platform)
|
||||
return "" unless platform
|
||||
|
||||
@@ -154,11 +164,13 @@ class IgdbService
|
||||
"where platforms = (#{igdb_platform_id});"
|
||||
end
|
||||
|
||||
sig { params(term: String).returns(String) }
|
||||
def sanitize_search_term(term)
|
||||
# Escape quotes and remove special characters that might break the query
|
||||
term.gsub('"', '\\"').gsub(/[^\w\s:'-]/, "")
|
||||
end
|
||||
|
||||
sig { params(search_title: String, result_title: String, search_platform: T.nilable(String), result_platforms: T.nilable(T::Array[T::Hash[String, T.untyped]])).returns(Float) }
|
||||
def calculate_confidence(search_title, result_title, search_platform, result_platforms)
|
||||
score = 0.0
|
||||
|
||||
@@ -183,7 +195,7 @@ class IgdbService
|
||||
if search_platform && result_platforms
|
||||
platform_names = result_platforms.map { |p| p["name"].downcase }
|
||||
igdb_platform_id = IgdbPlatformMapping.igdb_id_for_platform(search_platform)
|
||||
|
||||
|
||||
# Check if our platform is in the result platforms
|
||||
if igdb_platform_id
|
||||
# Exact platform match
|
||||
@@ -197,27 +209,28 @@ class IgdbService
|
||||
score.round(2)
|
||||
end
|
||||
|
||||
sig { params(game: T::Hash[String, T.untyped], confidence: Float).returns(T::Hash[Symbol, T.untyped]) }
|
||||
def format_game_result(game, confidence)
|
||||
cover_id = game.dig("cover", "url")&.split("/")&.last&.sub(".jpg", "")
|
||||
|
||||
|
||||
platform_name = if game["platforms"]&.any?
|
||||
game["platforms"].first["name"]
|
||||
else
|
||||
else
|
||||
"Unknown"
|
||||
end
|
||||
end
|
||||
|
||||
release_date = if game["first_release_date"]
|
||||
Time.at(game["first_release_date"]).to_date
|
||||
else
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Extract genre names
|
||||
genre_names = if game["genres"]&.any?
|
||||
game["genres"].map { |g| g["name"] }
|
||||
else
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
igdb_id: game["id"],
|
||||
|
||||
Reference in New Issue
Block a user