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:
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
@@ -8,6 +8,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
|
name: Linting & Security
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -20,15 +21,37 @@ jobs:
|
|||||||
ruby-version: '3.3'
|
ruby-version: '3.3'
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
- name: Run RuboCop (linting)
|
- name: Run RuboCop (style/linting)
|
||||||
run: bundle exec rubocop --parallel
|
run: bundle exec rubocop --parallel --format progress
|
||||||
continue-on-error: true # Don't fail build on style issues
|
|
||||||
|
|
||||||
- name: Run Brakeman (security scan)
|
- name: Run Brakeman (security scan)
|
||||||
run: bundle exec brakeman -q --no-pager
|
run: bundle exec brakeman -q --no-pager
|
||||||
continue-on-error: true
|
continue-on-error: true # Security warnings shouldn't block PRs
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
name: Type Checking
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Ruby
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
ruby-version: '3.3'
|
||||||
|
bundler-cache: true
|
||||||
|
|
||||||
|
- name: Generate Sorbet RBI files
|
||||||
|
run: |
|
||||||
|
bundle exec tapioca gems --no-doc --workers 1
|
||||||
|
bundle exec tapioca dsl
|
||||||
|
|
||||||
|
- name: Run Sorbet type checker
|
||||||
|
run: bundle exec srb tc
|
||||||
|
|
||||||
test:
|
test:
|
||||||
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -77,11 +100,12 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: bundle exec rails test
|
run: bundle exec rails test
|
||||||
|
|
||||||
- name: Report coverage
|
- name: Report test results
|
||||||
if: always()
|
if: always()
|
||||||
run: echo "✅ Tests completed"
|
run: echo "✅ Tests completed"
|
||||||
|
|
||||||
build-test:
|
build-test:
|
||||||
|
name: Docker Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -80,3 +80,6 @@ docker-compose.override.yml
|
|||||||
# Ignore Kubernetes secrets
|
# Ignore Kubernetes secrets
|
||||||
k8s/secrets.yaml
|
k8s/secrets.yaml
|
||||||
k8s/sealed-secrets.yaml
|
k8s/sealed-secrets.yaml
|
||||||
|
|
||||||
|
# Ignore Sorbet files
|
||||||
|
/sorbet/rbi/
|
||||||
|
|||||||
6
.tapioca
Normal file
6
.tapioca
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
--rbi-max-line-length=120
|
||||||
|
--dsl-dir=sorbet/rbi/dsl
|
||||||
|
--gem-dir=sorbet/rbi/gems
|
||||||
|
--todo-rbi=sorbet/rbi/todo.rbi
|
||||||
|
--shim-rbi=sorbet/rbi/shims
|
||||||
|
--annotations-rbi=sorbet/rbi/annotations
|
||||||
8
Gemfile
8
Gemfile
@@ -70,3 +70,11 @@ gem "tailwindcss-rails", "~> 4.4"
|
|||||||
gem "kaminari", "~> 1.2"
|
gem "kaminari", "~> 1.2"
|
||||||
|
|
||||||
gem "dotenv-rails", "~> 3.2"
|
gem "dotenv-rails", "~> 3.2"
|
||||||
|
|
||||||
|
# Type checking with Sorbet
|
||||||
|
gem "sorbet-runtime"
|
||||||
|
|
||||||
|
group :development do
|
||||||
|
gem "sorbet", require: false
|
||||||
|
gem "tapioca", require: false
|
||||||
|
end
|
||||||
|
|||||||
46
Gemfile.lock
46
Gemfile.lock
@@ -83,6 +83,7 @@ GEM
|
|||||||
bcrypt_pbkdf (1.1.2)
|
bcrypt_pbkdf (1.1.2)
|
||||||
bcrypt_pbkdf (1.1.2-arm64-darwin)
|
bcrypt_pbkdf (1.1.2-arm64-darwin)
|
||||||
bcrypt_pbkdf (1.1.2-x86_64-darwin)
|
bcrypt_pbkdf (1.1.2-x86_64-darwin)
|
||||||
|
benchmark (0.5.0)
|
||||||
bigdecimal (4.1.0)
|
bigdecimal (4.1.0)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.23.0)
|
bootsnap (1.23.0)
|
||||||
@@ -209,6 +210,7 @@ GEM
|
|||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ssh (7.3.2)
|
net-ssh (7.3.2)
|
||||||
|
netrc (0.11.0)
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.19.2-aarch64-linux-gnu)
|
nokogiri (1.19.2-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
@@ -294,6 +296,13 @@ GEM
|
|||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.1)
|
rake (13.3.1)
|
||||||
|
rbi (0.3.9)
|
||||||
|
prism (~> 1.0)
|
||||||
|
rbs (>= 3.4.4)
|
||||||
|
rbs (4.0.2)
|
||||||
|
logger
|
||||||
|
prism (>= 1.6.0)
|
||||||
|
tsort
|
||||||
rdoc (7.2.0)
|
rdoc (7.2.0)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
@@ -301,6 +310,7 @@ GEM
|
|||||||
regexp_parser (2.11.3)
|
regexp_parser (2.11.3)
|
||||||
reline (0.6.3)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
|
require-hooks (0.2.3)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rubocop (1.86.0)
|
rubocop (1.86.0)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
@@ -358,6 +368,23 @@ GEM
|
|||||||
fugit (~> 1.11)
|
fugit (~> 1.11)
|
||||||
railties (>= 7.1)
|
railties (>= 7.1)
|
||||||
thor (>= 1.3.1)
|
thor (>= 1.3.1)
|
||||||
|
sorbet (0.6.13067)
|
||||||
|
sorbet-static (= 0.6.13067)
|
||||||
|
sorbet-runtime (0.6.13067)
|
||||||
|
sorbet-static (0.6.13067-aarch64-linux)
|
||||||
|
sorbet-static (0.6.13067-universal-darwin)
|
||||||
|
sorbet-static (0.6.13067-x86_64-linux)
|
||||||
|
sorbet-static-and-runtime (0.6.13067)
|
||||||
|
sorbet (= 0.6.13067)
|
||||||
|
sorbet-runtime (= 0.6.13067)
|
||||||
|
spoom (1.7.11)
|
||||||
|
erubi (>= 1.10.0)
|
||||||
|
prism (>= 0.28.0)
|
||||||
|
rbi (>= 0.3.3)
|
||||||
|
rbs (>= 4.0.0.dev.4)
|
||||||
|
rexml (>= 3.2.6)
|
||||||
|
sorbet-static-and-runtime (>= 0.5.10187)
|
||||||
|
thor (>= 0.19.2)
|
||||||
sshkit (1.25.0)
|
sshkit (1.25.0)
|
||||||
base64
|
base64
|
||||||
logger
|
logger
|
||||||
@@ -378,6 +405,18 @@ GEM
|
|||||||
tailwindcss-ruby (4.2.1-x86_64-darwin)
|
tailwindcss-ruby (4.2.1-x86_64-darwin)
|
||||||
tailwindcss-ruby (4.2.1-x86_64-linux-gnu)
|
tailwindcss-ruby (4.2.1-x86_64-linux-gnu)
|
||||||
tailwindcss-ruby (4.2.1-x86_64-linux-musl)
|
tailwindcss-ruby (4.2.1-x86_64-linux-musl)
|
||||||
|
tapioca (0.18.0)
|
||||||
|
benchmark
|
||||||
|
bundler (>= 2.2.25)
|
||||||
|
netrc (>= 0.11.0)
|
||||||
|
parallel (>= 1.21.0)
|
||||||
|
rbi (>= 0.3.7)
|
||||||
|
require-hooks (>= 0.2.2)
|
||||||
|
sorbet-static-and-runtime (>= 0.5.11087)
|
||||||
|
spoom (>= 1.7.9)
|
||||||
|
thor (>= 1.2.0)
|
||||||
|
tsort
|
||||||
|
yard-sorbet
|
||||||
thor (1.5.0)
|
thor (1.5.0)
|
||||||
thruster (0.1.20)
|
thruster (0.1.20)
|
||||||
thruster (0.1.20-aarch64-linux)
|
thruster (0.1.20-aarch64-linux)
|
||||||
@@ -407,6 +446,10 @@ GEM
|
|||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
|
yard (0.9.38)
|
||||||
|
yard-sorbet (0.9.0)
|
||||||
|
sorbet-runtime
|
||||||
|
yard
|
||||||
zeitwerk (2.7.5)
|
zeitwerk (2.7.5)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
@@ -443,8 +486,11 @@ DEPENDENCIES
|
|||||||
solid_cable
|
solid_cable
|
||||||
solid_cache
|
solid_cache
|
||||||
solid_queue
|
solid_queue
|
||||||
|
sorbet
|
||||||
|
sorbet-runtime
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
tailwindcss-rails (~> 4.4)
|
tailwindcss-rails (~> 4.4)
|
||||||
|
tapioca
|
||||||
thruster
|
thruster
|
||||||
turbo-rails
|
turbo-rails
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
> Your personal video game collection tracker and manager
|
> Your personal video game collection tracker and manager
|
||||||
|
|
||||||
[](https://rubyonrails.org/)
|
[](https://rubyonrails.org/)
|
||||||
|

|
||||||
|
[](https://github.com/rubocop/rubocop)
|
||||||
|
[](https://sorbet.org)
|
||||||
[](https://www.ruby-lang.org/)
|
[](https://www.ruby-lang.org/)
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
@@ -140,13 +143,14 @@ IGDB sync is enabled by default for all users and runs every 30 minutes.
|
|||||||
- 📖 [API Documentation](docs/API_DOCUMENTATION.md) - RESTful API
|
- 📖 [API Documentation](docs/API_DOCUMENTATION.md) - RESTful API
|
||||||
- 🎮 [IGDB Integration](docs/IGDB_INTEGRATION.md) - Game metadata
|
- 🎮 [IGDB Integration](docs/IGDB_INTEGRATION.md) - Game metadata
|
||||||
- 🎨 [Themes](docs/THEMES.md) - Customization
|
- 🎨 [Themes](docs/THEMES.md) - Customization
|
||||||
|
- 🔷 [Sorbet Types](docs/SORBET.md) - Type checking guide
|
||||||
- 🎯 [Demo Account](docs/DEMO_ACCOUNT.md) - Try it out
|
- 🎯 [Demo Account](docs/DEMO_ACCOUNT.md) - Try it out
|
||||||
|
|
||||||
[**See all documentation →**](docs/README.md)
|
[**See all documentation →**](docs/README.md)
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
- **Framework:** Ruby on Rails 8.1
|
- **Framework:** Ruby on Rails 8.1 with Sorbet type checking
|
||||||
- **Frontend:** Hotwire (Turbo + Stimulus)
|
- **Frontend:** Hotwire (Turbo + Stimulus)
|
||||||
- **Styling:** Tailwind CSS
|
- **Styling:** Tailwind CSS
|
||||||
- **Database:** PostgreSQL with Row Level Security
|
- **Database:** PostgreSQL with Row Level Security
|
||||||
|
|||||||
31
Taskfile.yml
31
Taskfile.yml
@@ -180,3 +180,34 @@ tasks:
|
|||||||
desc: "Clear IGDB sync lock (if job is stuck)"
|
desc: "Clear IGDB sync lock (if job is stuck)"
|
||||||
cmds:
|
cmds:
|
||||||
- rake igdb:clear_lock
|
- rake igdb:clear_lock
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
desc: "Run Sorbet type checker"
|
||||||
|
cmds:
|
||||||
|
- bundle exec srb tc
|
||||||
|
|
||||||
|
typecheck:watch:
|
||||||
|
desc: "Run Sorbet type checker in watch mode"
|
||||||
|
cmds:
|
||||||
|
- bundle exec srb tc --watch
|
||||||
|
|
||||||
|
tapioca:init:
|
||||||
|
desc: "Initialize Tapioca (run once after adding Sorbet)"
|
||||||
|
cmds:
|
||||||
|
- bundle exec tapioca init
|
||||||
|
|
||||||
|
tapioca:gems:
|
||||||
|
desc: "Generate RBI files for gems"
|
||||||
|
cmds:
|
||||||
|
- bundle exec tapioca gems
|
||||||
|
|
||||||
|
tapioca:dsl:
|
||||||
|
desc: "Generate RBI files for DSLs (Rails models, etc.)"
|
||||||
|
cmds:
|
||||||
|
- bundle exec tapioca dsl
|
||||||
|
|
||||||
|
tapioca:all:
|
||||||
|
desc: "Generate all RBI files (gems + DSLs)"
|
||||||
|
cmds:
|
||||||
|
- bundle exec tapioca gems
|
||||||
|
- bundle exec tapioca dsl
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class ApiTokensController < ApplicationController
|
class ApiTokensController < ApplicationController
|
||||||
|
extend T::Sig
|
||||||
before_action :require_authentication
|
before_action :require_authentication
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class CollectionsController < ApplicationController
|
class CollectionsController < ApplicationController
|
||||||
|
extend T::Sig
|
||||||
before_action :require_authentication
|
before_action :require_authentication
|
||||||
before_action :set_collection, only: [ :show, :edit, :update, :destroy, :games ]
|
before_action :set_collection, only: [ :show, :edit, :update, :destroy, :games ]
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def index
|
def index
|
||||||
@root_collections = current_user.collections.root_collections.order(:name)
|
@root_collections = current_user.collections.root_collections.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def show
|
def show
|
||||||
@games = @collection.games.includes(:platform, :genres).page(params[:page]).per(25)
|
@games = @collection.games.includes(:platform, :genres).page(params[:page]).per(25)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def new
|
def new
|
||||||
@collection = current_user.collections.build
|
@collection = current_user.collections.build
|
||||||
@collections = current_user.collections.root_collections.order(:name)
|
@collections = current_user.collections.root_collections.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def create
|
def create
|
||||||
@collection = current_user.collections.build(collection_params)
|
@collection = current_user.collections.build(collection_params)
|
||||||
|
|
||||||
@@ -26,10 +33,12 @@ class CollectionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def edit
|
def edit
|
||||||
@collections = current_user.collections.root_collections.where.not(id: @collection.id).order(:name)
|
@collections = current_user.collections.root_collections.where.not(id: @collection.id).order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def update
|
def update
|
||||||
if @collection.update(collection_params)
|
if @collection.update(collection_params)
|
||||||
redirect_to @collection, notice: "Collection was successfully updated."
|
redirect_to @collection, notice: "Collection was successfully updated."
|
||||||
@@ -39,11 +48,13 @@ class CollectionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def destroy
|
def destroy
|
||||||
@collection.destroy
|
@collection.destroy
|
||||||
redirect_to collections_path, notice: "Collection was successfully deleted."
|
redirect_to collections_path, notice: "Collection was successfully deleted."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def games
|
def games
|
||||||
# Same as show, but maybe with different view
|
# Same as show, but maybe with different view
|
||||||
@games = @collection.games.includes(:platform, :genres).page(params[:page]).per(25)
|
@games = @collection.games.includes(:platform, :genres).page(params[:page]).per(25)
|
||||||
@@ -52,10 +63,12 @@ class CollectionsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def set_collection
|
def set_collection
|
||||||
@collection = current_user.collections.find(params[:id])
|
@collection = current_user.collections.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { returns(T.untyped) }
|
||||||
def collection_params
|
def collection_params
|
||||||
permitted = params.require(:collection).permit(:name, :description, :parent_collection_id)
|
permitted = params.require(:collection).permit(:name, :description, :parent_collection_id)
|
||||||
# Convert empty string to nil for parent_collection_id
|
# Convert empty string to nil for parent_collection_id
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class DashboardController < ApplicationController
|
class DashboardController < ApplicationController
|
||||||
|
extend T::Sig
|
||||||
before_action :require_authentication
|
before_action :require_authentication
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def index
|
def index
|
||||||
@recently_added_games = current_user.games.recent.limit(5)
|
@recently_added_games = current_user.games.recent.limit(5)
|
||||||
@currently_playing_games = current_user.games.currently_playing.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
|
@completed_games = current_user.games.completed.count
|
||||||
@backlog_games = current_user.games.backlog.count
|
@backlog_games = current_user.games.backlog.count
|
||||||
@total_spent = current_user.games.sum(:price_paid) || 0
|
@total_spent = current_user.games.sum(:price_paid) || 0
|
||||||
|
|
||||||
@games_by_platform = current_user.games.joins(:platform)
|
@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)
|
@games_by_genre = current_user.games.joins(:genres)
|
||||||
.group("genres.name")
|
|
||||||
.count
|
|
||||||
.sort_by { |_, count| -count }
|
|
||||||
.first(5)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class GamesController < ApplicationController
|
class GamesController < ApplicationController
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
before_action :require_authentication
|
before_action :require_authentication
|
||||||
before_action :set_game, only: [ :show, :edit, :update, :destroy ]
|
before_action :set_game, only: [ :show, :edit, :update, :destroy ]
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def index
|
def index
|
||||||
@games = current_user.games.includes(:platform, :genres, :collections)
|
@games = current_user.games.includes(:platform, :genres, :collections)
|
||||||
|
|
||||||
@@ -27,9 +32,11 @@ class GamesController < ApplicationController
|
|||||||
@genres = Genre.order(:name)
|
@genres = Genre.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def show
|
def show
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def new
|
def new
|
||||||
@game = current_user.games.build
|
@game = current_user.games.build
|
||||||
@platforms = Platform.order(:name)
|
@platforms = Platform.order(:name)
|
||||||
@@ -37,6 +44,7 @@ class GamesController < ApplicationController
|
|||||||
@collections = current_user.collections.order(:name)
|
@collections = current_user.collections.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def create
|
def create
|
||||||
@game = current_user.games.build(game_params)
|
@game = current_user.games.build(game_params)
|
||||||
|
|
||||||
@@ -53,12 +61,14 @@ class GamesController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def edit
|
def edit
|
||||||
@platforms = Platform.order(:name)
|
@platforms = Platform.order(:name)
|
||||||
@genres = Genre.order(:name)
|
@genres = Genre.order(:name)
|
||||||
@collections = current_user.collections.order(:name)
|
@collections = current_user.collections.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def update
|
def update
|
||||||
if @game.update(game_params)
|
if @game.update(game_params)
|
||||||
redirect_to @game, notice: "Game was successfully updated."
|
redirect_to @game, notice: "Game was successfully updated."
|
||||||
@@ -70,17 +80,20 @@ class GamesController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def destroy
|
def destroy
|
||||||
@game.destroy
|
@game.destroy
|
||||||
redirect_to games_path, notice: "Game was successfully deleted."
|
redirect_to games_path, notice: "Game was successfully deleted."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def import
|
def import
|
||||||
@platforms = Platform.order(:name)
|
@platforms = Platform.order(:name)
|
||||||
@genres = Genre.order(:name)
|
@genres = Genre.order(:name)
|
||||||
@collections = current_user.collections.order(:name)
|
@collections = current_user.collections.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def bulk_edit
|
def bulk_edit
|
||||||
@game_ids = params[:game_ids] || []
|
@game_ids = params[:game_ids] || []
|
||||||
|
|
||||||
@@ -95,6 +108,7 @@ class GamesController < ApplicationController
|
|||||||
@collections = current_user.collections.order(:name)
|
@collections = current_user.collections.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def bulk_update
|
def bulk_update
|
||||||
@game_ids = params[:game_ids] || []
|
@game_ids = params[:game_ids] || []
|
||||||
|
|
||||||
@@ -154,6 +168,7 @@ class GamesController < ApplicationController
|
|||||||
redirect_to games_path, notice: "Successfully updated #{updated_count} game(s)."
|
redirect_to games_path, notice: "Successfully updated #{updated_count} game(s)."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def bulk_create
|
def bulk_create
|
||||||
require "csv"
|
require "csv"
|
||||||
|
|
||||||
@@ -163,7 +178,9 @@ class GamesController < ApplicationController
|
|||||||
csv_text = params[:csv_file].read
|
csv_text = params[:csv_file].read
|
||||||
csv = CSV.parse(csv_text, headers: true)
|
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"])
|
platform = Platform.find_by(name: row["platform"]) || Platform.find_by(abbreviation: row["platform"])
|
||||||
|
|
||||||
unless platform
|
unless platform
|
||||||
@@ -209,6 +226,7 @@ class GamesController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def search_igdb
|
def search_igdb
|
||||||
query = params[:q].to_s.strip
|
query = params[:q].to_s.strip
|
||||||
platform_id = params[:platform_id]
|
platform_id = params[:platform_id]
|
||||||
@@ -250,12 +268,13 @@ class GamesController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def search_locations
|
def search_locations
|
||||||
query = params[:q].to_s.strip
|
query = params[:q].to_s.strip
|
||||||
|
|
||||||
# Get unique locations from user's games that match the query
|
# Get unique locations from user's games that match the query
|
||||||
locations = current_user.games
|
locations = current_user.games
|
||||||
.where.not(location: [nil, ""])
|
.where.not(location: [ nil, "" ])
|
||||||
.where("location ILIKE ?", "%#{query}%")
|
.where("location ILIKE ?", "%#{query}%")
|
||||||
.select(:location)
|
.select(:location)
|
||||||
.distinct
|
.distinct
|
||||||
@@ -266,12 +285,13 @@ class GamesController < ApplicationController
|
|||||||
render json: locations
|
render json: locations
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def search_stores
|
def search_stores
|
||||||
query = params[:q].to_s.strip
|
query = params[:q].to_s.strip
|
||||||
|
|
||||||
# Get unique digital stores from user's games that match the query
|
# Get unique digital stores from user's games that match the query
|
||||||
stores = current_user.games
|
stores = current_user.games
|
||||||
.where.not(digital_store: [nil, ""])
|
.where.not(digital_store: [ nil, "" ])
|
||||||
.where("digital_store ILIKE ?", "%#{query}%")
|
.where("digital_store ILIKE ?", "%#{query}%")
|
||||||
.select(:digital_store)
|
.select(:digital_store)
|
||||||
.distinct
|
.distinct
|
||||||
@@ -284,10 +304,12 @@ class GamesController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def set_game
|
def set_game
|
||||||
@game = current_user.games.includes(:igdb_game).find(params[:id])
|
@game = current_user.games.includes(:igdb_game).find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def sync_igdb_metadata_after_create
|
def sync_igdb_metadata_after_create
|
||||||
# Fetch full game data from IGDB
|
# Fetch full game data from IGDB
|
||||||
service = IgdbService.new
|
service = IgdbService.new
|
||||||
@@ -327,6 +349,7 @@ class GamesController < ApplicationController
|
|||||||
Rails.logger.error("Failed to sync IGDB metadata: #{e.message}")
|
Rails.logger.error("Failed to sync IGDB metadata: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { params(genre_names: T::Array[String]).returns(T::Array[Integer]) }
|
||||||
def map_igdb_genres_to_ids(genre_names)
|
def map_igdb_genres_to_ids(genre_names)
|
||||||
return [] if genre_names.blank?
|
return [] if genre_names.blank?
|
||||||
|
|
||||||
@@ -365,6 +388,7 @@ class GamesController < ApplicationController
|
|||||||
genre_ids
|
genre_ids
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { params(genre_names: T::Array[String]).void }
|
||||||
def assign_igdb_genres_to_game(genre_names)
|
def assign_igdb_genres_to_game(genre_names)
|
||||||
genre_ids = map_igdb_genres_to_ids(genre_names)
|
genre_ids = map_igdb_genres_to_ids(genre_names)
|
||||||
|
|
||||||
@@ -374,6 +398,7 @@ class GamesController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { returns(T.untyped) }
|
||||||
def game_params
|
def game_params
|
||||||
params.require(:game).permit(
|
params.require(:game).permit(
|
||||||
:title, :platform_id, :format, :date_added, :completion_status,
|
:title, :platform_id, :format, :date_added, :completion_status,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class IgdbMatchesController < ApplicationController
|
class IgdbMatchesController < ApplicationController
|
||||||
|
extend T::Sig
|
||||||
before_action :require_authentication
|
before_action :require_authentication
|
||||||
before_action :set_suggestion, only: [ :approve, :reject ]
|
before_action :set_suggestion, only: [ :approve, :reject ]
|
||||||
|
|
||||||
@@ -43,7 +46,7 @@ class IgdbMatchesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Check if games need syncing
|
# 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
|
if unmatched_count == 0
|
||||||
redirect_to igdb_matches_path, alert: "All games are already matched or being processed!"
|
redirect_to igdb_matches_path, alert: "All games are already matched or being processed!"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class PagesController < ApplicationController
|
class PagesController < ApplicationController
|
||||||
|
extend T::Sig
|
||||||
def home
|
def home
|
||||||
if user_signed_in?
|
if user_signed_in?
|
||||||
redirect_to dashboard_path
|
redirect_to dashboard_path
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class PasswordResetsController < ApplicationController
|
class PasswordResetsController < ApplicationController
|
||||||
|
extend T::Sig
|
||||||
before_action :require_no_authentication, only: [ :new, :create, :edit, :update ]
|
before_action :require_no_authentication, only: [ :new, :create, :edit, :update ]
|
||||||
before_action :set_user_by_token, only: [ :edit, :update ]
|
before_action :set_user_by_token, only: [ :edit, :update ]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class ProfilesController < ApplicationController
|
class ProfilesController < ApplicationController
|
||||||
|
extend T::Sig
|
||||||
def show
|
def show
|
||||||
@user = User.find_by!(username: params[:username])
|
@user = User.find_by!(username: params[:username])
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class SessionsController < ApplicationController
|
class SessionsController < ApplicationController
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
before_action :require_no_authentication, only: [ :new, :create ]
|
before_action :require_no_authentication, only: [ :new, :create ]
|
||||||
before_action :require_authentication, only: [ :destroy ]
|
before_action :require_authentication, only: [ :destroy ]
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def new
|
def new
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def create
|
def create
|
||||||
user = User.find_by(email: params[:email].downcase)
|
user = User.find_by(email: params[:email].downcase)
|
||||||
|
|
||||||
@@ -17,6 +23,7 @@ class SessionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def destroy
|
def destroy
|
||||||
sign_out
|
sign_out
|
||||||
redirect_to root_path, notice: "You have been signed out."
|
redirect_to root_path, notice: "You have been signed out."
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class UsersController < ApplicationController
|
class UsersController < ApplicationController
|
||||||
|
extend T::Sig
|
||||||
before_action :require_no_authentication, only: [ :new, :create ]
|
before_action :require_no_authentication, only: [ :new, :create ]
|
||||||
before_action :require_authentication, only: [ :edit, :update, :settings ]
|
before_action :require_authentication, only: [ :edit, :update, :settings ]
|
||||||
before_action :set_user, only: [ :edit, :update ]
|
before_action :set_user, only: [ :edit, :update ]
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def new
|
def new
|
||||||
@user = User.new
|
@user = User.new
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def create
|
def create
|
||||||
@user = User.new(user_params)
|
@user = User.new(user_params)
|
||||||
|
|
||||||
@@ -18,9 +23,11 @@ class UsersController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def edit
|
def edit
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def update
|
def update
|
||||||
if @user.update(user_params)
|
if @user.update(user_params)
|
||||||
redirect_to settings_path, notice: "Your profile has been updated."
|
redirect_to settings_path, notice: "Your profile has been updated."
|
||||||
@@ -29,6 +36,7 @@ class UsersController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def settings
|
def settings
|
||||||
@user = current_user
|
@user = current_user
|
||||||
@api_tokens = current_user.api_tokens.order(created_at: :desc)
|
@api_tokens = current_user.api_tokens.order(created_at: :desc)
|
||||||
@@ -36,10 +44,12 @@ class UsersController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def set_user
|
def set_user
|
||||||
@user = current_user
|
@user = current_user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { returns(T.untyped) }
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:email, :username, :password, :password_confirmation, :bio, :profile_public, :igdb_sync_enabled, :theme)
|
params.require(:user).permit(:email, :username, :password, :password_confirmation, :bio, :profile_public, :igdb_sync_enabled, :theme)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class IgdbSyncJob < ApplicationJob
|
|||||||
# Get games that need IGDB matching
|
# Get games that need IGDB matching
|
||||||
games = user.games
|
games = user.games
|
||||||
.igdb_unmatched
|
.igdb_unmatched
|
||||||
.where(igdb_match_status: [nil, "failed"])
|
.where(igdb_match_status: [ nil, "failed" ])
|
||||||
.includes(:platform)
|
.includes(:platform)
|
||||||
|
|
||||||
return if games.empty?
|
return if games.empty?
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class ApiToken < ApplicationRecord
|
class ApiToken < ApplicationRecord
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
# Validations
|
# Validations
|
||||||
@@ -11,16 +15,19 @@ class ApiToken < ApplicationRecord
|
|||||||
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||||
|
|
||||||
# Instance methods
|
# Instance methods
|
||||||
|
sig { returns(T::Boolean) }
|
||||||
def expired?
|
def expired?
|
||||||
expires_at.present? && expires_at < Time.current
|
expires_at.present? && expires_at < Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def touch_last_used!
|
def touch_last_used!
|
||||||
update_column(:last_used_at, Time.current)
|
update_column(:last_used_at, Time.current)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def generate_token
|
def generate_token
|
||||||
self.token ||= SecureRandom.urlsafe_base64(32)
|
self.token ||= SecureRandom.urlsafe_base64(32)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class Collection < ApplicationRecord
|
class Collection < ApplicationRecord
|
||||||
|
extend T::Sig
|
||||||
# Associations
|
# Associations
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :parent_collection, class_name: "Collection", optional: true
|
belongs_to :parent_collection, class_name: "Collection", optional: true
|
||||||
@@ -15,30 +18,36 @@ class Collection < ApplicationRecord
|
|||||||
scope :root_collections, -> { where(parent_collection_id: nil) }
|
scope :root_collections, -> { where(parent_collection_id: nil) }
|
||||||
|
|
||||||
# Instance methods
|
# Instance methods
|
||||||
|
sig { returns(Integer) }
|
||||||
def game_count
|
def game_count
|
||||||
games.count
|
games.count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { returns(Integer) }
|
||||||
def total_game_count
|
def total_game_count
|
||||||
game_count + subcollections.sum(&:total_game_count)
|
game_count + subcollections.sum(&:total_game_count)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { returns(T::Boolean) }
|
||||||
def root?
|
def root?
|
||||||
parent_collection_id.nil?
|
parent_collection_id.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { returns(T::Boolean) }
|
||||||
def subcollection?
|
def subcollection?
|
||||||
parent_collection_id.present?
|
parent_collection_id.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def cannot_be_own_parent
|
def cannot_be_own_parent
|
||||||
if parent_collection_id.present? && parent_collection_id == id
|
if parent_collection_id.present? && parent_collection_id == id
|
||||||
errors.add(:parent_collection_id, "cannot be itself")
|
errors.add(:parent_collection_id, "cannot be itself")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def subcollection_depth_limit
|
def subcollection_depth_limit
|
||||||
if parent_collection.present? && parent_collection.parent_collection.present?
|
if parent_collection.present? && parent_collection.parent_collection.present?
|
||||||
errors.add(:parent_collection_id, "cannot nest more than one level deep")
|
errors.add(:parent_collection_id, "cannot nest more than one level deep")
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class CollectionGame < ApplicationRecord
|
class CollectionGame < ApplicationRecord
|
||||||
|
extend T::Sig
|
||||||
belongs_to :collection
|
belongs_to :collection
|
||||||
belongs_to :game
|
belongs_to :game
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class Game < ApplicationRecord
|
class Game < ApplicationRecord
|
||||||
|
extend T::Sig
|
||||||
# Associations
|
# Associations
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :platform
|
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 }
|
scope :needs_igdb_review, -> { joins(:igdb_match_suggestions).where(igdb_match_suggestions: { status: "pending" }).distinct }
|
||||||
|
|
||||||
# Class methods
|
# Class methods
|
||||||
|
sig { params(query: String).returns(T.untyped) }
|
||||||
def self.search(query)
|
def self.search(query)
|
||||||
where("title ILIKE ?", "%#{sanitize_sql_like(query)}%")
|
where("title ILIKE ?", "%#{sanitize_sql_like(query)}%")
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def set_date_added
|
def set_date_added
|
||||||
self.date_added ||= Date.current
|
self.date_added ||= Date.current
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class GameGenre < ApplicationRecord
|
class GameGenre < ApplicationRecord
|
||||||
|
extend T::Sig
|
||||||
belongs_to :game
|
belongs_to :game
|
||||||
belongs_to :genre
|
belongs_to :genre
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class Genre < ApplicationRecord
|
class Genre < ApplicationRecord
|
||||||
|
extend T::Sig
|
||||||
# Associations
|
# Associations
|
||||||
has_many :game_genres, dependent: :destroy
|
has_many :game_genres, dependent: :destroy
|
||||||
has_many :games, through: :game_genres
|
has_many :games, through: :game_genres
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class IgdbGame < ApplicationRecord
|
class IgdbGame < ApplicationRecord
|
||||||
|
extend T::Sig
|
||||||
# Associations
|
# Associations
|
||||||
has_many :igdb_match_suggestions
|
has_many :igdb_match_suggestions
|
||||||
has_many :games, foreign_key: :igdb_id, primary_key: :igdb_id
|
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) }
|
scope :recent, -> { order(last_synced_at: :desc) }
|
||||||
|
|
||||||
# Instance methods
|
# Instance methods
|
||||||
|
sig { void }
|
||||||
def increment_match_count!
|
def increment_match_count!
|
||||||
increment!(:match_count)
|
increment!(:match_count)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { params(size: String).returns(T.nilable(String)) }
|
||||||
def cover_image_url(size = "cover_big")
|
def cover_image_url(size = "cover_big")
|
||||||
return nil unless cover_url.present?
|
return nil unless cover_url.present?
|
||||||
# IGDB uses image IDs like "co1234"
|
# IGDB uses image IDs like "co1234"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class Platform < ApplicationRecord
|
class Platform < ApplicationRecord
|
||||||
|
extend T::Sig
|
||||||
# Associations
|
# Associations
|
||||||
has_many :games, dependent: :restrict_with_error
|
has_many :games, dependent: :restrict_with_error
|
||||||
has_many :items, dependent: :restrict_with_error
|
has_many :items, dependent: :restrict_with_error
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
# typed: true
|
||||||
|
|
||||||
class User < ApplicationRecord
|
class User < ApplicationRecord
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
has_secure_password
|
has_secure_password
|
||||||
|
|
||||||
# Associations
|
# Associations
|
||||||
@@ -20,22 +24,26 @@ class User < ApplicationRecord
|
|||||||
before_save :downcase_email
|
before_save :downcase_email
|
||||||
|
|
||||||
# Instance methods
|
# Instance methods
|
||||||
|
sig { returns(T::Boolean) }
|
||||||
def generate_password_reset_token
|
def generate_password_reset_token
|
||||||
self.password_reset_token = SecureRandom.urlsafe_base64
|
self.password_reset_token = SecureRandom.urlsafe_base64
|
||||||
self.password_reset_sent_at = Time.current
|
self.password_reset_sent_at = Time.current
|
||||||
save!
|
save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { returns(T::Boolean) }
|
||||||
def password_reset_expired?
|
def password_reset_expired?
|
||||||
password_reset_sent_at.nil? || password_reset_sent_at < 2.hours.ago
|
password_reset_sent_at.nil? || password_reset_sent_at < 2.hours.ago
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { returns(String) }
|
||||||
def theme_class
|
def theme_class
|
||||||
"theme-#{theme}"
|
"theme-#{theme}"
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def downcase_email
|
def downcase_email
|
||||||
self.email = email.downcase if email.present?
|
self.email = email.downcase if email.present?
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
require 'net/http'
|
require "net/http"
|
||||||
require 'json'
|
require "json"
|
||||||
require 'uri'
|
require "uri"
|
||||||
|
|
||||||
|
# typed: true
|
||||||
class IgdbService
|
class IgdbService
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
BASE_URL = "https://api.igdb.com/v4"
|
BASE_URL = "https://api.igdb.com/v4"
|
||||||
TOKEN_URL = "https://id.twitch.tv/oauth2/token"
|
TOKEN_URL = "https://id.twitch.tv/oauth2/token"
|
||||||
CACHE_KEY = "igdb_access_token"
|
CACHE_KEY = "igdb_access_token"
|
||||||
@@ -10,14 +13,16 @@ class IgdbService
|
|||||||
class ApiError < StandardError; end
|
class ApiError < StandardError; end
|
||||||
class RateLimitError < StandardError; end
|
class RateLimitError < StandardError; end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
def initialize
|
def initialize
|
||||||
@client_id = ENV.fetch("IGDB_CLIENT_ID")
|
@client_id = T.let(ENV.fetch("IGDB_CLIENT_ID"), String)
|
||||||
@client_secret = ENV.fetch("IGDB_CLIENT_SECRET")
|
@client_secret = T.let(ENV.fetch("IGDB_CLIENT_SECRET"), String)
|
||||||
@access_token = get_or_refresh_token
|
@access_token = T.let(get_or_refresh_token, String)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Search for games by title and platform
|
# Search for games by title and platform
|
||||||
# Returns array of matches with confidence scores
|
# 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)
|
def search_game(title, platform = nil, limit = 3)
|
||||||
platform_filter = platform_filter_query(platform)
|
platform_filter = platform_filter_query(platform)
|
||||||
|
|
||||||
@@ -43,6 +48,7 @@ class IgdbService
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Get specific game by IGDB ID
|
# Get specific game by IGDB ID
|
||||||
|
sig { params(igdb_id: Integer).returns(T.nilable(T::Hash[String, T.untyped])) }
|
||||||
def get_game(igdb_id)
|
def get_game(igdb_id)
|
||||||
query = <<~QUERY
|
query = <<~QUERY
|
||||||
fields id, name, slug, cover.url, summary, first_release_date, platforms.name, genres.name;
|
fields id, name, slug, cover.url, summary, first_release_date, platforms.name, genres.name;
|
||||||
@@ -56,6 +62,7 @@ class IgdbService
|
|||||||
private
|
private
|
||||||
|
|
||||||
# Get cached token or generate a new one
|
# Get cached token or generate a new one
|
||||||
|
sig { returns(String) }
|
||||||
def get_or_refresh_token
|
def get_or_refresh_token
|
||||||
# Check if we have a cached token
|
# Check if we have a cached token
|
||||||
cached_token = Rails.cache.read(CACHE_KEY)
|
cached_token = Rails.cache.read(CACHE_KEY)
|
||||||
@@ -66,20 +73,21 @@ class IgdbService
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Generate a new access token from Twitch
|
# Generate a new access token from Twitch
|
||||||
|
sig { returns(String) }
|
||||||
def generate_access_token
|
def generate_access_token
|
||||||
uri = URI(TOKEN_URL)
|
uri = URI(TOKEN_URL)
|
||||||
uri.query = URI.encode_www_form({
|
uri.query = URI.encode_www_form({
|
||||||
client_id: @client_id,
|
client_id: @client_id,
|
||||||
client_secret: @client_secret,
|
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
|
if response.code.to_i == 200
|
||||||
data = JSON.parse(response.body)
|
data = JSON.parse(response.body)
|
||||||
token = data['access_token']
|
token = data["access_token"]
|
||||||
expires_in = data['expires_in'] # seconds until expiration (usually ~5 million seconds / ~60 days)
|
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 token for 90% of its lifetime to be safe
|
||||||
cache_duration = (expires_in * 0.9).to_i
|
cache_duration = (expires_in * 0.9).to_i
|
||||||
@@ -96,6 +104,7 @@ class IgdbService
|
|||||||
raise ApiError, "Cannot authenticate with IGDB: #{e.message}"
|
raise ApiError, "Cannot authenticate with IGDB: #{e.message}"
|
||||||
end
|
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)
|
def post(endpoint, body, retry_count = 0)
|
||||||
uri = URI("#{BASE_URL}#{endpoint}")
|
uri = URI("#{BASE_URL}#{endpoint}")
|
||||||
|
|
||||||
@@ -126,7 +135,7 @@ class IgdbService
|
|||||||
Rails.logger.warn("IGDB token invalid, refreshing...")
|
Rails.logger.warn("IGDB token invalid, refreshing...")
|
||||||
Rails.cache.delete(CACHE_KEY)
|
Rails.cache.delete(CACHE_KEY)
|
||||||
@access_token = generate_access_token
|
@access_token = generate_access_token
|
||||||
return post(endpoint, body, retry_count + 1)
|
post(endpoint, body, retry_count + 1)
|
||||||
else
|
else
|
||||||
Rails.logger.error("IGDB authentication failed after token refresh")
|
Rails.logger.error("IGDB authentication failed after token refresh")
|
||||||
raise ApiError, "IGDB authentication failed"
|
raise ApiError, "IGDB authentication failed"
|
||||||
@@ -145,6 +154,7 @@ class IgdbService
|
|||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { params(platform: T.nilable(String)).returns(String) }
|
||||||
def platform_filter_query(platform)
|
def platform_filter_query(platform)
|
||||||
return "" unless platform
|
return "" unless platform
|
||||||
|
|
||||||
@@ -154,11 +164,13 @@ class IgdbService
|
|||||||
"where platforms = (#{igdb_platform_id});"
|
"where platforms = (#{igdb_platform_id});"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { params(term: String).returns(String) }
|
||||||
def sanitize_search_term(term)
|
def sanitize_search_term(term)
|
||||||
# Escape quotes and remove special characters that might break the query
|
# Escape quotes and remove special characters that might break the query
|
||||||
term.gsub('"', '\\"').gsub(/[^\w\s:'-]/, "")
|
term.gsub('"', '\\"').gsub(/[^\w\s:'-]/, "")
|
||||||
end
|
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)
|
def calculate_confidence(search_title, result_title, search_platform, result_platforms)
|
||||||
score = 0.0
|
score = 0.0
|
||||||
|
|
||||||
@@ -197,6 +209,7 @@ class IgdbService
|
|||||||
score.round(2)
|
score.round(2)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { params(game: T::Hash[String, T.untyped], confidence: Float).returns(T::Hash[Symbol, T.untyped]) }
|
||||||
def format_game_result(game, confidence)
|
def format_game_result(game, confidence)
|
||||||
cover_id = game.dig("cover", "url")&.split("/")&.last&.sub(".jpg", "")
|
cover_id = game.dig("cover", "url")&.split("/")&.last&.sub(".jpg", "")
|
||||||
|
|
||||||
|
|||||||
27
bin/tapioca
Executable file
27
bin/tapioca
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
#
|
||||||
|
# This file was generated by Bundler.
|
||||||
|
#
|
||||||
|
# The application 'tapioca' is installed as part of a gem, and
|
||||||
|
# this file is here to facilitate running it.
|
||||||
|
#
|
||||||
|
|
||||||
|
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
||||||
|
|
||||||
|
bundle_binstub = File.expand_path("bundle", __dir__)
|
||||||
|
|
||||||
|
if File.file?(bundle_binstub)
|
||||||
|
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
|
||||||
|
load(bundle_binstub)
|
||||||
|
else
|
||||||
|
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
||||||
|
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
require "rubygems"
|
||||||
|
require "bundler/setup"
|
||||||
|
|
||||||
|
load Gem.bin_path("tapioca", "tapioca")
|
||||||
307
docs/CI_PIPELINE.md
Normal file
307
docs/CI_PIPELINE.md
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
# CI/CD Pipeline Documentation
|
||||||
|
|
||||||
|
TurboVault uses GitHub Actions for continuous integration and quality assurance.
|
||||||
|
|
||||||
|
## Pipeline Overview
|
||||||
|
|
||||||
|
The CI pipeline runs on:
|
||||||
|
- ✅ Every push to `main` or `develop` branches
|
||||||
|
- ✅ Every pull request to `main` or `develop`
|
||||||
|
|
||||||
|
## Jobs
|
||||||
|
|
||||||
|
### 1. Linting & Security 🔍
|
||||||
|
|
||||||
|
**Purpose:** Code style and security checks
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. **RuboCop** - Ruby style guide enforcement
|
||||||
|
- Uses `rubocop-rails-omakase` (Rails official style guide)
|
||||||
|
- Runs with `--parallel` for speed
|
||||||
|
- **Fails build** if style violations found
|
||||||
|
|
||||||
|
2. **Brakeman** - Security vulnerability scanner
|
||||||
|
- Scans for Rails security issues
|
||||||
|
- `continue-on-error: true` (warnings don't block PRs)
|
||||||
|
|
||||||
|
**Run locally:**
|
||||||
|
```bash
|
||||||
|
task lint # Check style
|
||||||
|
task lint:fix # Auto-fix issues
|
||||||
|
task security # Run Brakeman
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Type Checking 🔷
|
||||||
|
|
||||||
|
**Purpose:** Static type checking with Sorbet
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Generate RBI files for gems and Rails DSLs
|
||||||
|
2. Run Sorbet type checker
|
||||||
|
3. **Fails build** if type errors found
|
||||||
|
|
||||||
|
**Run locally:**
|
||||||
|
```bash
|
||||||
|
task tapioca:all # Generate RBI files
|
||||||
|
task typecheck # Run type checker
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** RBI generation can take 1-2 minutes in CI.
|
||||||
|
|
||||||
|
### 3. Tests 🧪
|
||||||
|
|
||||||
|
**Purpose:** Run test suite
|
||||||
|
|
||||||
|
**Services:**
|
||||||
|
- PostgreSQL 15 database
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Set up PostgreSQL service
|
||||||
|
2. Install system dependencies
|
||||||
|
3. Create test database
|
||||||
|
4. Load schema
|
||||||
|
5. Run test suite
|
||||||
|
|
||||||
|
**Run locally:**
|
||||||
|
```bash
|
||||||
|
task test # Run all tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Docker Build 🐳
|
||||||
|
|
||||||
|
**Purpose:** Verify Docker image builds successfully
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Set up Docker Buildx
|
||||||
|
2. Build production Docker image
|
||||||
|
3. Test that image runs
|
||||||
|
|
||||||
|
**Run locally:**
|
||||||
|
```bash
|
||||||
|
docker build -t turbovault:test .
|
||||||
|
docker run --rm turbovault:test bundle exec ruby --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI Workflow File
|
||||||
|
|
||||||
|
Location: `.github/workflows/ci.yml`
|
||||||
|
|
||||||
|
## Status Badges
|
||||||
|
|
||||||
|
Status badges in `README.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
[](https://github.com/rubocop/rubocop)
|
||||||
|
[](https://sorbet.org)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Don't forget** to replace `YOUR_USERNAME` with your actual GitHub username!
|
||||||
|
|
||||||
|
## What Fails the Build?
|
||||||
|
|
||||||
|
| Check | Fails Build? | Why |
|
||||||
|
|-------|--------------|-----|
|
||||||
|
| RuboCop style issues | ✅ Yes | Code style should be consistent |
|
||||||
|
| Brakeman security warnings | ❌ No | Some warnings are false positives |
|
||||||
|
| Sorbet type errors | ✅ Yes | Type safety is important |
|
||||||
|
| Test failures | ✅ Yes | Tests must pass |
|
||||||
|
| Docker build failure | ✅ Yes | Must be deployable |
|
||||||
|
|
||||||
|
## Fixing CI Failures
|
||||||
|
|
||||||
|
### RuboCop Failures
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auto-fix most issues
|
||||||
|
task lint:fix
|
||||||
|
|
||||||
|
# Check what's left
|
||||||
|
task lint
|
||||||
|
|
||||||
|
# Commit fixes
|
||||||
|
git add .
|
||||||
|
git commit -m "Fix RuboCop style issues"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sorbet Type Errors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Regenerate RBI files (if gems changed)
|
||||||
|
task tapioca:all
|
||||||
|
|
||||||
|
# Check for errors
|
||||||
|
task typecheck
|
||||||
|
|
||||||
|
# Fix type errors in code
|
||||||
|
# See docs/SORBET.md for patterns
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Failures
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests locally
|
||||||
|
task test
|
||||||
|
|
||||||
|
# Run specific test
|
||||||
|
rails test test/models/game_test.rb
|
||||||
|
|
||||||
|
# Fix tests and re-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Build Failures
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build locally to debug
|
||||||
|
docker build -t turbovault:test .
|
||||||
|
|
||||||
|
# Check build logs for errors
|
||||||
|
# Usually missing dependencies or incorrect Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI Performance
|
||||||
|
|
||||||
|
Typical run times:
|
||||||
|
- **Linting & Security:** ~30-60 seconds
|
||||||
|
- **Type Checking:** ~2-3 minutes (RBI generation)
|
||||||
|
- **Tests:** ~1-2 minutes
|
||||||
|
- **Docker Build:** ~3-5 minutes
|
||||||
|
|
||||||
|
**Total:** ~7-11 minutes per run
|
||||||
|
|
||||||
|
## Optimizations
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
|
||||||
|
We use `bundler-cache: true` in Ruby setup to cache gems:
|
||||||
|
```yaml
|
||||||
|
- uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
bundler-cache: true
|
||||||
|
```
|
||||||
|
|
||||||
|
This speeds up runs by ~1-2 minutes.
|
||||||
|
|
||||||
|
### Parallel Execution
|
||||||
|
|
||||||
|
Jobs run in parallel, not sequentially:
|
||||||
|
- Linting, Type Checking, Tests, and Docker Build all run simultaneously
|
||||||
|
- Total time = slowest job (usually Docker Build)
|
||||||
|
|
||||||
|
### RuboCop Parallel
|
||||||
|
|
||||||
|
RuboCop uses `--parallel` to check files concurrently:
|
||||||
|
```bash
|
||||||
|
bundle exec rubocop --parallel
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Development Workflow
|
||||||
|
|
||||||
|
Before pushing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Fix style
|
||||||
|
task lint:fix
|
||||||
|
|
||||||
|
# 2. Check types
|
||||||
|
task typecheck
|
||||||
|
|
||||||
|
# 3. Run tests
|
||||||
|
task test
|
||||||
|
|
||||||
|
# 4. Commit
|
||||||
|
git add .
|
||||||
|
git commit -m "Your changes"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures CI will pass!
|
||||||
|
|
||||||
|
## Skipping CI
|
||||||
|
|
||||||
|
To skip CI on a commit (e.g., documentation only):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "Update README [skip ci]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI for Pull Requests
|
||||||
|
|
||||||
|
When someone submits a PR:
|
||||||
|
1. CI runs automatically
|
||||||
|
2. All jobs must pass before merge
|
||||||
|
3. Status shown in PR page
|
||||||
|
4. Merge button disabled until CI passes
|
||||||
|
|
||||||
|
## Viewing CI Results
|
||||||
|
|
||||||
|
**On GitHub:**
|
||||||
|
1. Go to repository
|
||||||
|
2. Click **Actions** tab
|
||||||
|
3. Click on a workflow run
|
||||||
|
4. View each job's logs
|
||||||
|
|
||||||
|
**In Pull Requests:**
|
||||||
|
- See status checks at bottom of PR
|
||||||
|
- Click "Details" to view logs
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "RBI generation failed"
|
||||||
|
|
||||||
|
**Cause:** Tapioca couldn't generate RBI files
|
||||||
|
|
||||||
|
**Fix:** Usually transient - retry workflow. If persistent, check Gemfile.lock is committed.
|
||||||
|
|
||||||
|
### "Database connection failed"
|
||||||
|
|
||||||
|
**Cause:** PostgreSQL service not ready
|
||||||
|
|
||||||
|
**Fix:** Already handled with health checks:
|
||||||
|
```yaml
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Bundler version mismatch"
|
||||||
|
|
||||||
|
**Cause:** Different Bundler versions locally vs CI
|
||||||
|
|
||||||
|
**Fix:** Run `bundle update --bundler` locally
|
||||||
|
|
||||||
|
## Advanced: Matrix Builds (Future)
|
||||||
|
|
||||||
|
To test multiple Ruby versions:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
ruby-version: ['3.2', '3.3', '3.4']
|
||||||
|
```
|
||||||
|
|
||||||
|
Currently we only test Ruby 3.3.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
We use GitHub Secrets for sensitive data:
|
||||||
|
- `RAILS_MASTER_KEY` (optional, for encrypted credentials)
|
||||||
|
- No other secrets needed for tests
|
||||||
|
|
||||||
|
### Dependabot
|
||||||
|
|
||||||
|
Consider enabling Dependabot to auto-update dependencies:
|
||||||
|
- Settings → Security → Dependabot
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [GitHub Actions Docs](https://docs.github.com/en/actions)
|
||||||
|
- [RuboCop](https://github.com/rubocop/rubocop)
|
||||||
|
- [Sorbet](https://sorbet.org/)
|
||||||
|
- [Brakeman](https://brakemanscanner.org/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Questions?** Check the workflow file: `.github/workflows/ci.yml`
|
||||||
@@ -95,6 +95,28 @@ task lint:fix
|
|||||||
task security
|
task security
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Type Checking with Sorbet
|
||||||
|
|
||||||
|
TurboVault uses Sorbet for gradual static type checking.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First time setup (after bundle install)
|
||||||
|
task tapioca:init
|
||||||
|
task tapioca:all
|
||||||
|
|
||||||
|
# Run type checker
|
||||||
|
task typecheck
|
||||||
|
|
||||||
|
# Watch mode (re-checks on file changes)
|
||||||
|
task typecheck:watch
|
||||||
|
|
||||||
|
# Update type definitions after gem/model changes
|
||||||
|
task tapioca:gems # After bundle install
|
||||||
|
task tapioca:dsl # After model changes
|
||||||
|
```
|
||||||
|
|
||||||
|
**See [SORBET.md](SORBET.md) for complete type checking guide.**
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ Complete documentation for TurboVault - Video Game Collection Tracker
|
|||||||
### Deployment & Development
|
### Deployment & Development
|
||||||
- [Deployment Guide](DEPLOYMENT.md) - Complete deployment reference
|
- [Deployment Guide](DEPLOYMENT.md) - Complete deployment reference
|
||||||
- [Development Guide](DEVELOPMENT_GUIDE.md) - Local development & contributing
|
- [Development Guide](DEVELOPMENT_GUIDE.md) - Local development & contributing
|
||||||
|
- [CI Pipeline](CI_PIPELINE.md) - GitHub Actions & quality checks
|
||||||
- [Kubernetes README](../k8s/README.md) - Kubernetes deployment
|
- [Kubernetes README](../k8s/README.md) - Kubernetes deployment
|
||||||
|
|
||||||
### Features
|
### Features & Development
|
||||||
- [API Documentation](API_DOCUMENTATION.md) - RESTful API reference
|
- [API Documentation](API_DOCUMENTATION.md) - RESTful API reference
|
||||||
- [IGDB Integration](IGDB_INTEGRATION.md) - Game metadata matching
|
- [IGDB Integration](IGDB_INTEGRATION.md) - Game metadata matching
|
||||||
- [Themes](THEMES.md) - Theme customization
|
- [Themes](THEMES.md) - Theme customization
|
||||||
|
- [Sorbet Type Checking](SORBET.md) - Gradual static types
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
- [GitHub Secrets Setup](../.github/SECRETS_SETUP.md) - Optional custom registry
|
- [GitHub Secrets Setup](../.github/SECRETS_SETUP.md) - Optional custom registry
|
||||||
|
|||||||
520
docs/SORBET.md
Normal file
520
docs/SORBET.md
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
# Sorbet Type Checking Guide
|
||||||
|
|
||||||
|
TurboVault uses [Sorbet](https://sorbet.org/) for gradual static type checking.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### First Time Setup
|
||||||
|
|
||||||
|
After installing gems, initialize Sorbet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
bundle install
|
||||||
|
|
||||||
|
# Initialize Tapioca (generates type definitions)
|
||||||
|
task tapioca:init
|
||||||
|
|
||||||
|
# Generate RBI files for gems and Rails DSLs
|
||||||
|
task tapioca:all
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- `sorbet/` - Sorbet configuration
|
||||||
|
- `sorbet/rbi/gems/` - Type definitions for gems (gitignored)
|
||||||
|
- `sorbet/rbi/dsl/` - Generated types for Rails models, etc. (gitignored)
|
||||||
|
|
||||||
|
### Running Type Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check all files
|
||||||
|
task typecheck
|
||||||
|
|
||||||
|
# Watch mode (re-checks on file changes)
|
||||||
|
task typecheck:watch
|
||||||
|
|
||||||
|
# Or use Sorbet directly
|
||||||
|
bundle exec srb tc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type Strictness Levels
|
||||||
|
|
||||||
|
Sorbet uses gradual typing with 5 strictness levels:
|
||||||
|
|
||||||
|
### `# typed: false` (Default - No Checking)
|
||||||
|
```ruby
|
||||||
|
# typed: false
|
||||||
|
class MyClass
|
||||||
|
def do_something(x)
|
||||||
|
x + 1 # No type checking at all
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
- **Use for:** Legacy code, external gems, code you're not ready to type yet
|
||||||
|
- **Checking:** None
|
||||||
|
|
||||||
|
### `# typed: true` (Recommended)
|
||||||
|
```ruby
|
||||||
|
# typed: true
|
||||||
|
class User < ApplicationRecord
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
|
sig { returns(String) }
|
||||||
|
def theme_class
|
||||||
|
"theme-#{theme}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
- **Use for:** Most application code
|
||||||
|
- **Checking:** Method signatures you define with `sig`
|
||||||
|
|
||||||
|
### `# typed: strict` (Advanced)
|
||||||
|
- **Use for:** Critical business logic, libraries
|
||||||
|
- **Checking:** All method signatures required, no untyped code
|
||||||
|
|
||||||
|
### `# typed: strong` (Expert)
|
||||||
|
- **Use for:** Maximum safety
|
||||||
|
- **Checking:** No `T.untyped`, no runtime checks
|
||||||
|
|
||||||
|
## Writing Type Signatures
|
||||||
|
|
||||||
|
### Basic Method Signatures
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# typed: true
|
||||||
|
class Game < ApplicationRecord
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
|
# No parameters, returns String
|
||||||
|
sig { returns(String) }
|
||||||
|
def title
|
||||||
|
read_attribute(:title)
|
||||||
|
end
|
||||||
|
|
||||||
|
# One parameter, returns Boolean
|
||||||
|
sig { params(user: User).returns(T::Boolean) }
|
||||||
|
def owned_by?(user)
|
||||||
|
self.user_id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
# Multiple parameters
|
||||||
|
sig { params(title: String, platform: Platform).returns(Game) }
|
||||||
|
def self.create_game(title, platform)
|
||||||
|
create(title: title, platform: platform)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Void (returns nothing meaningful)
|
||||||
|
sig { void }
|
||||||
|
def update_cache
|
||||||
|
Rails.cache.write("game_#{id}", self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nilable Types
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# typed: true
|
||||||
|
class Game < ApplicationRecord
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
|
# Can return Platform or nil
|
||||||
|
sig { returns(T.nilable(Platform)) }
|
||||||
|
def platform
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parameter can be nil
|
||||||
|
sig { params(platform_id: T.nilable(Integer)).returns(T.untyped) }
|
||||||
|
def self.by_platform(platform_id)
|
||||||
|
where(platform_id: platform_id) if platform_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arrays and Hashes
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# typed: true
|
||||||
|
class IgdbService
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
|
# Array of Hashes
|
||||||
|
sig { params(title: String).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
|
||||||
|
def search_game(title)
|
||||||
|
# Returns: [{id: 1, name: "Game"}, {id: 2, name: "Game 2"}]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Array of specific type
|
||||||
|
sig { returns(T::Array[Game]) }
|
||||||
|
def all_games
|
||||||
|
Game.all.to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
# Hash with specific keys
|
||||||
|
sig { returns(T::Hash[String, Integer]) }
|
||||||
|
def game_counts
|
||||||
|
{ "total" => 100, "completed" => 50 }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Parameters
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# typed: true
|
||||||
|
class IgdbService
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
|
# Optional parameter with default
|
||||||
|
sig { params(title: String, limit: Integer).returns(T::Array[T.untyped]) }
|
||||||
|
def search_game(title, limit = 3)
|
||||||
|
# ...
|
||||||
|
end
|
||||||
|
|
||||||
|
# Optional keyword parameter
|
||||||
|
sig { params(title: String, platform: T.nilable(String)).returns(T::Array[T.untyped]) }
|
||||||
|
def search(title, platform: nil)
|
||||||
|
# ...
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instance Variables
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# typed: true
|
||||||
|
class IgdbService
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
|
sig { void }
|
||||||
|
def initialize
|
||||||
|
@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
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
`T.let(value, Type)` declares the type of an instance variable.
|
||||||
|
|
||||||
|
### Untyped Code
|
||||||
|
|
||||||
|
When you can't determine the type (ActiveRecord relations, dynamic code):
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# typed: true
|
||||||
|
class GamesController < ApplicationController
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
|
sig { void }
|
||||||
|
def index
|
||||||
|
# ActiveRecord relations are complex, use T.untyped
|
||||||
|
@games = T.let(current_user.games.includes(:platform), T.untyped)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `T.untyped` sparingly - it bypasses type checking.
|
||||||
|
|
||||||
|
### CSV Rows (Common Pattern)
|
||||||
|
|
||||||
|
CSV::Row acts like a Hash but Sorbet doesn't know that:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# typed: true
|
||||||
|
sig { void }
|
||||||
|
def bulk_create
|
||||||
|
csv = CSV.parse(csv_text, headers: true)
|
||||||
|
|
||||||
|
csv.each_with_index do |row, index|
|
||||||
|
# Tell Sorbet row is untyped to avoid Array vs Hash confusion
|
||||||
|
row = T.let(row, T.untyped)
|
||||||
|
|
||||||
|
# Now you can access row["column"] without errors
|
||||||
|
title = row["title"]
|
||||||
|
platform = row["platform"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
This is acceptable because CSV parsing is inherently dynamic and validated by your model.
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Controllers
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# typed: true
|
||||||
|
class GamesController < ApplicationController
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
|
sig { void }
|
||||||
|
def index
|
||||||
|
@games = T.let(current_user.games.all, T.untyped)
|
||||||
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
|
def show
|
||||||
|
@game = T.let(current_user.games.find(params[:id]), Game)
|
||||||
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
|
def create
|
||||||
|
@game = T.let(current_user.games.build(game_params), Game)
|
||||||
|
if @game.save
|
||||||
|
redirect_to @game
|
||||||
|
else
|
||||||
|
render :new
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# typed: true
|
||||||
|
class Game < ApplicationRecord
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
|
# Class methods
|
||||||
|
sig { params(query: String).returns(T.untyped) }
|
||||||
|
def self.search(query)
|
||||||
|
where("title ILIKE ?", "%#{query}%")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Instance methods
|
||||||
|
sig { returns(String) }
|
||||||
|
def display_title
|
||||||
|
"#{title} (#{platform&.name || 'Unknown'})"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Private methods
|
||||||
|
private
|
||||||
|
|
||||||
|
sig { void }
|
||||||
|
def set_defaults
|
||||||
|
self.date_added ||= Date.current
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# typed: true
|
||||||
|
class IgdbService
|
||||||
|
extend T::Sig
|
||||||
|
|
||||||
|
sig { void }
|
||||||
|
def initialize
|
||||||
|
@client_id = T.let(ENV.fetch("IGDB_CLIENT_ID"), String)
|
||||||
|
@access_token = T.let(get_token, String)
|
||||||
|
end
|
||||||
|
|
||||||
|
sig { params(title: String, platform: T.nilable(String)).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
|
||||||
|
def search_game(title, platform: nil)
|
||||||
|
results = post("/games", build_query(title, platform))
|
||||||
|
format_results(results)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
sig { params(title: String, platform: T.nilable(String)).returns(String) }
|
||||||
|
def build_query(title, platform)
|
||||||
|
"search \"#{title}\";"
|
||||||
|
end
|
||||||
|
|
||||||
|
sig { params(results: T::Array[T.untyped]).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
|
||||||
|
def format_results(results)
|
||||||
|
results.map { |r| { id: r["id"], name: r["name"] } }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gradual Adoption Strategy
|
||||||
|
|
||||||
|
### Phase 1: Infrastructure (Current)
|
||||||
|
- ✅ Add Sorbet gems
|
||||||
|
- ✅ Generate RBI files
|
||||||
|
- ✅ Add `# typed: true` to key files
|
||||||
|
- ✅ Type critical methods (User, Game, IgdbService)
|
||||||
|
|
||||||
|
### Phase 2: Core Models (Next)
|
||||||
|
- Add types to all models
|
||||||
|
- Type associations and scopes
|
||||||
|
- Type validations and callbacks
|
||||||
|
|
||||||
|
### Phase 3: Controllers
|
||||||
|
- Add types to controller actions
|
||||||
|
- Type params and filters
|
||||||
|
- Type helper methods
|
||||||
|
|
||||||
|
### Phase 4: Services & Jobs
|
||||||
|
- Type all service objects
|
||||||
|
- Type background jobs
|
||||||
|
- Type API clients
|
||||||
|
|
||||||
|
### Phase 5: Increase Strictness
|
||||||
|
- Move files from `typed: true` to `typed: strict`
|
||||||
|
- Remove `T.untyped` where possible
|
||||||
|
- Add runtime type checks where needed
|
||||||
|
|
||||||
|
## IDE Integration
|
||||||
|
|
||||||
|
### VSCode
|
||||||
|
|
||||||
|
Install the [Sorbet extension](https://marketplace.visualstudio.com/items?itemName=sorbet.sorbet-vscode-extension):
|
||||||
|
|
||||||
|
1. Install extension
|
||||||
|
2. Sorbet will auto-detect your project
|
||||||
|
3. Get inline type errors and autocomplete
|
||||||
|
|
||||||
|
### RubyMine
|
||||||
|
|
||||||
|
Sorbet support is built-in:
|
||||||
|
|
||||||
|
1. Enable Sorbet in Settings → Languages & Frameworks → Ruby
|
||||||
|
2. RubyMine will use Sorbet for type checking
|
||||||
|
|
||||||
|
## Updating Types
|
||||||
|
|
||||||
|
When you add gems or change models:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After adding/updating gems
|
||||||
|
task tapioca:gems
|
||||||
|
|
||||||
|
# After changing models/routes/etc
|
||||||
|
task tapioca:dsl
|
||||||
|
|
||||||
|
# Both at once
|
||||||
|
task tapioca:all
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this after:
|
||||||
|
- `bundle install` (new/updated gems)
|
||||||
|
- Adding/changing models
|
||||||
|
- Adding/changing routes
|
||||||
|
- Adding/changing concerns
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
### "Parent of class redefined" (RDoc, etc.)
|
||||||
|
|
||||||
|
```
|
||||||
|
Parent of class RDoc::Markup::Heading redefined from RDoc::Markup::Element to Struct
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix:** This is a known gem incompatibility. Already fixed in `sorbet/config`:
|
||||||
|
```
|
||||||
|
--suppress-payload-superclass-redefinition-for=RDoc::Markup::Heading
|
||||||
|
```
|
||||||
|
|
||||||
|
If you see similar errors for other gems, add suppression flags to `sorbet/config`.
|
||||||
|
|
||||||
|
### "Method does not exist"
|
||||||
|
|
||||||
|
```
|
||||||
|
app/models/game.rb:10: Method `foo` does not exist on `Game`
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix:** Add the method signature or check for typos.
|
||||||
|
|
||||||
|
### "Argument does not have asserted type"
|
||||||
|
|
||||||
|
```
|
||||||
|
Expected String but got T.nilable(String)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix:** Handle nil case or use `T.must()`:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
sig { params(name: String).void }
|
||||||
|
def greet(name)
|
||||||
|
puts "Hello #{name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Wrong
|
||||||
|
greet(user.name) # name might be nil
|
||||||
|
|
||||||
|
# Right
|
||||||
|
greet(user.name || "Guest")
|
||||||
|
greet(T.must(user.name)) # Asserts non-nil
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Constants must have type annotations"
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Wrong
|
||||||
|
MY_CONSTANT = "value"
|
||||||
|
|
||||||
|
# Right
|
||||||
|
MY_CONSTANT = T.let("value", String)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### ✅ DO:
|
||||||
|
- Start with `# typed: true` (not strict)
|
||||||
|
- Use `sig` for public methods
|
||||||
|
- Type critical business logic
|
||||||
|
- Use `T.untyped` for complex ActiveRecord queries
|
||||||
|
- Update RBI files after gem/model changes
|
||||||
|
- Run type checks in CI
|
||||||
|
|
||||||
|
### ❌ DON'T:
|
||||||
|
- Type everything at once (gradual adoption!)
|
||||||
|
- Use `# typed: strict` on new code (too strict)
|
||||||
|
- Ignore type errors (fix or suppress with comment)
|
||||||
|
- Forget to run `tapioca:dsl` after model changes
|
||||||
|
|
||||||
|
## CI Integration
|
||||||
|
|
||||||
|
Add to `.github/workflows/ci.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Type check with Sorbet
|
||||||
|
run: |
|
||||||
|
bundle exec tapioca gems --no-doc
|
||||||
|
bundle exec tapioca dsl
|
||||||
|
bundle exec srb tc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Sorbet Documentation](https://sorbet.org/)
|
||||||
|
- [Sorbet Playground](https://sorbet.run/)
|
||||||
|
- [Tapioca Documentation](https://github.com/Shopify/tapioca)
|
||||||
|
- [T::Types Cheat Sheet](https://sorbet.org/docs/stdlib-generics)
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
**All files start with `# typed: false`** (no checking enabled yet).
|
||||||
|
|
||||||
|
**Ready to type (after running setup):**
|
||||||
|
- `app/models/user.rb`
|
||||||
|
- `app/models/game.rb`
|
||||||
|
- `app/services/igdb_service.rb`
|
||||||
|
- `app/controllers/games_controller.rb`
|
||||||
|
|
||||||
|
**Recommended typing order:**
|
||||||
|
1. Start with Models: User, Game, Platform, Genre
|
||||||
|
2. Then Services: IgdbService
|
||||||
|
3. Then Controllers: GamesController, CollectionsController
|
||||||
|
4. Finally Jobs and Helpers
|
||||||
|
|
||||||
|
**Important:** Run the setup steps first:
|
||||||
|
```bash
|
||||||
|
bundle install
|
||||||
|
task tapioca:init
|
||||||
|
task tapioca:all
|
||||||
|
task typecheck # Should show "No errors!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then start adding `# typed: true` and signatures incrementally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy typing!** 🎉 Start small, add types gradually, and enjoy better IDE support and bug catching!
|
||||||
12
sorbet/config
Normal file
12
sorbet/config
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
--dir
|
||||||
|
.
|
||||||
|
--ignore=/vendor/
|
||||||
|
--ignore=/node_modules/
|
||||||
|
--ignore=/tmp/
|
||||||
|
--ignore=/log/
|
||||||
|
--ignore=/public/
|
||||||
|
--ignore=/bin/
|
||||||
|
--ignore=/db/migrate/
|
||||||
|
|
||||||
|
# Suppress known gem incompatibilities
|
||||||
|
--suppress-payload-superclass-redefinition-for=RDoc::Markup::Heading
|
||||||
13
sorbet/tapioca/config.yml
Normal file
13
sorbet/tapioca/config.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
gem:
|
||||||
|
# Add your `gem` command parameters here:
|
||||||
|
#
|
||||||
|
# exclude:
|
||||||
|
# - gem_name
|
||||||
|
# doc: true
|
||||||
|
# workers: 5
|
||||||
|
dsl:
|
||||||
|
# Add your `dsl` command parameters here:
|
||||||
|
#
|
||||||
|
# exclude:
|
||||||
|
# - SomeGeneratorName
|
||||||
|
# workers: 5
|
||||||
4
sorbet/tapioca/require.rb
Normal file
4
sorbet/tapioca/require.rb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# typed: true
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Add your extra requires here (`bin/tapioca require` can be used to bootstrap this list)
|
||||||
Reference in New Issue
Block a user