chore: save current Ruby implementation state before Next.js rewrite

This commit is contained in:
2026-03-27 14:36:22 -07:00
parent e9365faa10
commit 96f7cb86d3
20 changed files with 894 additions and 106 deletions
+31
View File
@@ -0,0 +1,31 @@
Event #,Round,Table,Seat 1,Seat 3,Odds Points,Seat 2,Seat 4,Evens Points,Winner
1,1,Clubs,Derrick,Jesse C,5,Emma,Alissa,10,Evens
1,1,Hearts,Kevin,Andy,8,Ellie,Jesse,6,Odds
1,1,Diamonds,Sara M,Amelia,10,Mike G,AJ,4,Odds
1,1,Spades,John,Dave B,7,Sara R,Emily,9,Evens
1,1,Stars,Linden,Jim,1,David G,Lynn,10,Evens
1,2,Clubs,John,Morgan,7,Alissa,Emma,14,Evens
1,2,Hearts,Lynn,Mike G,6,Kevin,AJ,8,Evens
1,2,Diamonds,Jesse F,Amelia,6,Linden,Jesse C,8,Evens
1,2,Spades,Lucas,Andy,18,Derrick,Ellie,7,Odds
1,2,Stars,Dave B,Emily,9,Jim,Sara R,12,Evens
1,3,Hearts,Lynn,Derrick,10,Alissa,Emma,13,Evens
1,3,Clubs,Kevin,AJ,2,Andy,Jesse C,11,Evens
1,3,Diamonds,Jim,Mike G,8,Amelia,Kristen,4,Odds
1,3,Spades,Ellie,John,11,Sara R,Dave B,12,Evens
1,3,Stars,Lucas,Linden,7,Emily,Sara M,14,Evens
1,4,Diamonds,John,Kevin,10,Alissa,Emma,7,Odds
1,4,Hearts,Emily,Sara M,11,Jim,Ellie,6,Odds
1,4,Clubs,Linden,AJ,2,Amelia,Mike G,8,Evens
1,4,Spades,Andy,Derrick,10,Sara R,Jesse C,5,Odds
1,4,Stars,Dave B,Lucas,9,David G,Lynn,7,Odds
1,5,Clubs,Amelia,Alissa,11,Linden,Ellie,3,Odds
1,5,Hearts,Emily,Sara R,10,Mike G,Andy,9,Odds
1,5,Diamonds,Derrick,Jesse F,5,Sara M,Jim,5,Evens
1,5,Spades,Kevin,Emma,11,Lynn,Dave B,11,Evens
1,5,Stars,John,Lucas,9,AJ,Jesse C,6,Odds
1,6,Diamonds,Alissa,Jim,11,John,Mike G,6,Odds
1,6,Hearts,Kevin,AJ,8,Sara R,Emma,12,Evens
1,6,Clubs,Derrick,Lucas,6,David G,Jesse C,5,Odds
1,6,Spades,Lynn,Emily,9,Amelia,Dave B,7,Odds
1,6,Stars,Sara M,Ellie,5,Linden,Andy,9,Evens
1 Event # Round Table Seat 1 Seat 3 Odds Points Seat 2 Seat 4 Evens Points Winner
2 1 1 Clubs Derrick Jesse C 5 Emma Alissa 10 Evens
3 1 1 Hearts Kevin Andy 8 Ellie Jesse 6 Odds
4 1 1 Diamonds Sara M Amelia 10 Mike G AJ 4 Odds
5 1 1 Spades John Dave B 7 Sara R Emily 9 Evens
6 1 1 Stars Linden Jim 1 David G Lynn 10 Evens
7 1 2 Clubs John Morgan 7 Alissa Emma 14 Evens
8 1 2 Hearts Lynn Mike G 6 Kevin AJ 8 Evens
9 1 2 Diamonds Jesse F Amelia 6 Linden Jesse C 8 Evens
10 1 2 Spades Lucas Andy 18 Derrick Ellie 7 Odds
11 1 2 Stars Dave B Emily 9 Jim Sara R 12 Evens
12 1 3 Hearts Lynn Derrick 10 Alissa Emma 13 Evens
13 1 3 Clubs Kevin AJ 2 Andy Jesse C 11 Evens
14 1 3 Diamonds Jim Mike G 8 Amelia Kristen 4 Odds
15 1 3 Spades Ellie John 11 Sara R Dave B 12 Evens
16 1 3 Stars Lucas Linden 7 Emily Sara M 14 Evens
17 1 4 Diamonds John Kevin 10 Alissa Emma 7 Odds
18 1 4 Hearts Emily Sara M 11 Jim Ellie 6 Odds
19 1 4 Clubs Linden AJ 2 Amelia Mike G 8 Evens
20 1 4 Spades Andy Derrick 10 Sara R Jesse C 5 Odds
21 1 4 Stars Dave B Lucas 9 David G Lynn 7 Odds
22 1 5 Clubs Amelia Alissa 11 Linden Ellie 3 Odds
23 1 5 Hearts Emily Sara R 10 Mike G Andy 9 Odds
24 1 5 Diamonds Derrick Jesse F 5 Sara M Jim 5 Evens
25 1 5 Spades Kevin Emma 11 Lynn Dave B 11 Evens
26 1 5 Stars John Lucas 9 AJ Jesse C 6 Odds
27 1 6 Diamonds Alissa Jim 11 John Mike G 6 Odds
28 1 6 Hearts Kevin AJ 8 Sara R Emma 12 Evens
29 1 6 Clubs Derrick Lucas 6 David G Jesse C 5 Odds
30 1 6 Spades Lynn Emily 9 Amelia Dave B 7 Odds
31 1 6 Stars Sara M Ellie 5 Linden Andy 9 Evens
+221
View File
@@ -0,0 +1,221 @@
# EuchreCamp User Stories
## Epic 1: Authentication & User Management
### As a new user, I want to register for an account so that I can participate in tournaments and track my games
- Acceptance Criteria:
- Registration form with name, email, password, password confirmation
- Email validation and uniqueness check
- Password strength requirements (8+ chars, uppercase, lowercase, number, special char)
- Account confirmation via email
- Auto-create player profile upon registration
### As a registered user, I want to log in to my account so that I can access my data
- Acceptance Criteria:
- Login form with email and password
- Session management with secure cookies
- "Remember me" functionality
- Error handling for invalid credentials
- Account lockout after 5 failed attempts
### As a logged-in user, I want to log out so that I can secure my account
- Acceptance Criteria:
- Logout button in navigation
- Session cleared on logout
- Redirect to home page
### As a user who forgot my password, I want to reset it so that I can regain access
- Acceptance Criteria:
- "Forgot password" link on login page
- Email input for reset request
- Unique token generation (expires in 1 hour)
- Email with reset link
- Password update form with validation
## Epic 2: Player Profile & Analytics
### As a player, I want to view my profile so that I can see my statistics
- Acceptance Criteria:
- Profile header with name and avatar
- Current Elo rating with trend indicator
- Total games played
- Win rate percentage
- Member since date
### As a player, I want to see my partnership performance so that I can identify my best partners
- Acceptance Criteria:
- Table showing all partners
- Games played with each partner
- Win rate with each partner
- Total Elo change per partnership
- Last played together date
### As a player, I want to see my recent games so that I can track my performance
- Acceptance Criteria:
- Timeline of recent matches
- Date, opponents, scores, and results
- Partner information for each game
- Pagination for loading more games
### As a player, I want to see my tournament history so that I can review past performances
- Acceptance Criteria:
- List of tournaments participated in
- Final standings in each tournament
- Performance summary per tournament
## Epic 3: Rankings & Public Data
### As a visitor, I want to view player rankings so that I can see top players
- Acceptance Criteria:
- Sortable rankings table
- Columns: Rank, Name, Elo, Win Rate, Games Played
- Search/filter functionality
- Pagination
### As a visitor, I want to view player profiles so that I can learn about other players
- Acceptance Criteria:
- Public profile pages (read-only for non-admins)
- Basic stats: Elo, games played, win rate
- Partnership performance (if public)
- Recent games (limited)
## Epic 4: Tournament Management
### As a tournament admin, I want to create a new tournament so that I can organize events
- Acceptance Criteria:
- Tournament creation form
- Fields: Name, Format (round-robin, single elim, double elim, Swiss), Date, Status
- Default settings for each format
- Validation for required fields
### As a tournament admin, I want to manage tournament participants so that I can control who plays
- Acceptance Criteria:
- Add participants from player list
- Remove participants
- Bulk import from CSV
- View participant list with status
### As a tournament admin, I want to create teams so that I can organize players into pairs
- Acceptance Criteria:
- Team creation form
- Select two players per team
- Auto-generate team names
- Edit/delete teams
### As a tournament admin, I want to generate a schedule so that matches are organized
- Acceptance Criteria:
- Round-robin schedule generation
- Single/double elimination bracket generation
- View schedule by round
- Re-schedule matches if needed
### As a tournament admin, I want to record match results so that I can track tournament progress
- Acceptance Criteria:
- Match result entry form
- Select teams and enter scores
- Auto-determine winner from scores
- Validation for score formats
### As a tournament admin, I want to upload results via CSV so that I can batch process matches
- Acceptance Criteria:
- CSV upload form with tournament selection
- Parse CSV with specific format (Event, Round, Table, Seats, Scores, Winner)
- Validate player names exist in system
- Create teams automatically if needed
- Import matches and update Elo ratings
### As a tournament admin, I want to view tournament analytics so that I can assess performance
- Acceptance Criteria:
- Tournament statistics (total matches, average score, etc.)
- Partnership performance within tournament
- Elo changes for participants
- Visual charts for key metrics
## Epic 5: Match Recording & CSV Import
### As a user, I want to record a match result so that I can track my games
- Acceptance Criteria:
- Match creation form
- Select Team 1 and Team 2 (each with 2 players)
- Enter scores for each team
- Auto-calculate winner
- Update Elo ratings for all players
- Update partnership statistics
### As a user, I want to upload match results via CSV so that I can process multiple matches at once
- Acceptance Criteria:
- CSV upload form
- Support for Euchre format (Event, Round, Table, Seat 1-4, Odds/Evens Points, Winner)
- Map table names (Clubs, Hearts, Diamonds, Spades, Stars) to numeric IDs
- Handle player name variations
- Create teams automatically
- Import matches and calculate Elo
- Show import summary with success/error count
### As a user, I want to edit match results so that I can correct mistakes
- Acceptance Criteria:
- Edit form for existing matches
- Validation for time limits (5 minutes for players, unlimited for admins)
- Update Elo ratings on change
- Log changes in activity feed
## Epic 6: Club Administration
### As a club admin, I want to manage all players so that I can maintain the player database
- Acceptance Criteria:
- Player directory with search and filter
- Create, edit, delete players
- Bulk actions (export, update ratings)
- View player activity
### As a club admin, I want to manage all tournaments so that I can oversee club events
- Acceptance Criteria:
- Tournament list with filters
- Clone existing tournaments
- Archive completed tournaments
- View tournament analytics
### As a club admin, I want to configure club settings so that I can customize the system
- Acceptance Criteria:
- Elo rating parameters
- Partnership tracking preferences
- Notification settings
- Default tournament settings
### As a club admin, I want to view club analytics so that I can assess overall performance
- Acceptance Criteria:
- Rating distribution charts
- Activity heatmaps
- Partnership network graph
- Export reports (PDF/CSV)
## Epic 7: Mobile Responsiveness
### As a mobile user, I want to navigate the site so that I can access features on my phone
- Acceptance Criteria:
- Responsive navigation bar
- Bottom navigation for mobile
- Touch-friendly buttons and links
- Optimized layouts for small screens
### As a mobile user, I want to record match results so that I can log games on the go
- Acceptance Criteria:
- Mobile-optimized forms
- Quick entry for common actions
- Offline support (optional)
## Epic 8: Data Management & Export
### As a club admin, I want to export data so that I can backup or analyze externally
- Acceptance Criteria:
- Export players to CSV
- Export tournaments to CSV
- Export matches to CSV
- Export partnership data to CSV
### As a club admin, I want to import data so that I can migrate from other systems
- Acceptance Criteria:
- Import players from CSV
- Import tournaments from CSV
- Import matches from CSV
- Data validation and error reporting
+24 -60
View File
@@ -1,83 +1,47 @@
# frozen_string_literal: true # frozen_string_literal: true
require "csv"
module EuchreCamp module EuchreCamp
module Actions module Actions
module Admin module Admin
module Matches module Matches
class ProcessUpload < EuchreCamp::Action class ProcessUpload < EuchreCamp::Action
include Deps[repo: 'repositories.matches'] include Deps[events_repo: 'repositories.events']
def handle(request, response) def handle(request, response)
# In Hanami, file uploads are in request.params # In Hanami, file uploads are in request.params
file = request.params[:csv_file] file = request.params[:csv_file]
event_id = request.params[:event_id]&.to_i
unless file && file[:tempfile] unless file && file[:tempfile]
response.flash[:error] = "No file uploaded"
response.redirect_to("/admin/matches/upload") response.redirect_to("/admin/matches/upload")
return return
end end
matches_created = 0 unless event_id
errors = [] response.flash[:error] = "Please select a tournament"
response.redirect_to("/admin/matches/upload")
# Parse CSV return
CSV.foreach(file[:tempfile].path, headers: true, header_converters: :symbol) do |row|
begin
# Map CSV columns to our database structure
# Expected columns: played_at, team_1_p1_name, team_1_p2_name, team_1_score, team_2_p1_name, team_2_p2_name, team_2_score
# Find or create players by name
player_1 = find_or_create_player(row[:team_1_p1_name])
player_2 = find_or_create_player(row[:team_1_p2_name])
player_3 = find_or_create_player(row[:team_2_p1_name])
player_4 = find_or_create_player(row[:team_2_p2_name])
played_at = begin
Time.parse(row[:played_at])
rescue
Time.now
end
match_data = {
played_at: played_at,
team_1_p1_id: player_1[:id],
team_1_p2_id: player_2[:id],
team_1_score: row[:team_1_score].to_i,
team_2_p1_id: player_3[:id],
team_2_p2_id: player_4[:id],
team_2_score: row[:team_2_score].to_i,
status: 'completed'
}
match = repo.create(match_data)
EuchreCamp::Jobs::CalculateEloJob.perform_later(match[:id])
matches_created += 1
rescue => e
errors << "Row #{row}: #{e.message}"
end
end end
response.flash[:message] = "Created #{matches_created} matches.#{' Errors: ' + errors.join(', ') if errors.any?}" # Verify event exists
response.redirect_to("/admin/matches") begin
end events_repo.by_id(event_id)
rescue
private response.flash[:error] = "Invalid tournament ID"
response.redirect_to("/admin/matches/upload")
def find_or_create_player(name) return
rom = EuchreCamp::App["persistence.rom"]
return nil unless name
name = name.strip
player = rom.relations[:players].where(name: name).one
if player
player
else
rom.relations[:players].insert(name: name, rating: 0, current_elo: 1000)
rom.relations[:players].where(name: name).one
end end
# Use the specialized importer for Euchre tournament results
importer = EuchreCamp::Services::TournamentCsvImporter.new(event_id: event_id)
results = importer.import(file[:tempfile].path)
message = "Created #{results[:matches_created]} matches, #{results[:teams_created]} teams"
message += ". Errors: #{results[:errors].join(', ')}" if results[:errors].any?
response.flash[:message] = message
response.redirect_to("/admin/tournaments/#{event_id}")
end end
end end
end end
+4 -3
View File
@@ -1,14 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
require "csv"
module EuchreCamp module EuchreCamp
module Actions module Actions
module Admin module Admin
module Matches module Matches
class Upload < EuchreCamp::Action class Upload < EuchreCamp::Action
include Deps[events_repo: 'repositories.events']
def handle(request, response) def handle(request, response)
response.render(view) tournaments = events_repo.all
response.render(view, tournaments: tournaments)
end end
end end
end end
+169
View File
@@ -0,0 +1,169 @@
# frozen_string_literal: true
require "csv"
module EuchreCamp
module Services
class TournamentCsvImporter
# Map table names to numeric IDs
TABLE_NAME_MAP = {
"Clubs" => 1,
"Hearts" => 2,
"Diamonds" => 3,
"Spades" => 4,
"Stars" => 5
}.freeze
def initialize(event_id:)
@event_id = event_id
@rom = EuchreCamp::App["persistence.rom"]
@matches_repo = EuchreCamp::App["repositories.matches"]
@teams_repo = EuchreCamp::App["repositories.teams"]
@events_repo = EuchreCamp::App["repositories.events"]
@rounds_repo = EuchreCamp::App["repositories.tournament_rounds"]
@bracket_matchups_repo = EuchreCamp::App["repositories.bracket_matchups"]
end
def import(csv_file_path)
results = {
matches_created: 0,
teams_created: 0,
rounds_created: 0,
errors: []
}
begin
event = @events_repo.by_id(@event_id)
CSV.foreach(csv_file_path, headers: true, header_converters: :symbol) do |row|
begin
process_row(row, event, results)
rescue => e
results[:errors] << "Row #{row}: #{e.message}"
end
end
rescue => e
results[:errors] << "Fatal error: #{e.message}"
end
results
end
private
def process_row(row, event, results)
# Extract data from CSV row
round_number = row[:round].to_i
table_name = row[:table]
seat_1_name = row[:seat_1]&.strip
seat_3_name = row[:seat_3]&.strip
odds_points = row[:odds_points].to_i
seat_2_name = row[:seat_2]&.strip
seat_4_name = row[:seat_4]&.strip
evens_points = row[:evens_points].to_i
winner = row[:winner]&.strip&.downcase
# Validate required fields
unless seat_1_name && seat_3_name && seat_2_name && seat_4_name
raise "Missing player names in row"
end
# Find or create players
player_1 = find_or_create_player(seat_1_name)
player_2 = find_or_create_player(seat_3_name)
player_3 = find_or_create_player(seat_2_name)
player_4 = find_or_create_player(seat_4_name)
# Find or create teams
team_1 = @teams_repo.find_or_create(@event_id, player_1[:id], player_2[:id], "#{seat_1_name} + #{seat_3_name}")
team_2 = @teams_repo.find_or_create(@event_id, player_3[:id], player_4[:id], "#{seat_2_name} + #{seat_4_name}")
results[:teams_created] += 1 if team_1[:created_at] == team_2[:created_at] # Rough check for new teams
# Find or create round
round = find_or_create_round(round_number)
# Map table name to number
table_number = TABLE_NAME_MAP[table_name] || table_name.to_i
if table_number == 0
raise "Unknown table name: #{table_name}"
end
# Determine winner based on scores or explicit winner column
team_1_score = odds_points
team_2_score = evens_points
# Verify winner matches scores if explicitly stated
if winner
if winner == "odds" && team_1_score <= team_2_score
results[:errors] << "Warning: Row claims Odds won but score is #{team_1_score}-#{team_2_score}"
elsif winner == "evens" && team_2_score <= team_1_score
results[:errors] << "Warning: Row claims Evens won but score is #{team_1_score}-#{team_2_score}"
end
end
# Create match
match_data = {
team_1_p1_id: player_1[:id],
team_1_p2_id: player_2[:id],
team_1_score: team_1_score,
team_2_p1_id: player_3[:id],
team_2_p2_id: player_4[:id],
team_2_score: team_2_score,
played_at: Time.now,
status: 'completed'
}
match = @matches_repo.create(match_data)
results[:matches_created] += 1
# Create bracket matchup entry
bracket_data = {
round_id: round[:id],
event_id: @event_id,
team_1_id: team_1[:id],
team_2_id: team_2[:id],
table_number: table_number,
bracket_position: table_number,
status: 'completed'
}
@bracket_matchups_repo.create(bracket_data)
# Queue Elo calculation
EuchreCamp::Jobs::CalculateEloJob.perform_later(match[:id])
rescue => e
raise "Error processing row: #{e.message}"
end
def find_or_create_player(name)
return nil unless name
player = @rom.relations[:players].where(name: name).one
if player
player
else
# Create player with default rating
@rom.relations[:players].insert(name: name, rating: 0, current_elo: 1000)
@rom.relations[:players].where(name: name).one
end
end
def find_or_create_round(round_number)
round = @rom.relations[:tournament_rounds].where(event_id: @event_id, round_number: round_number).one
if round
round
else
@rounds_repo.create(
event_id: @event_id,
round_number: round_number,
status: 'completed'
)
end
end
end
end
end
+95 -11
View File
@@ -1,26 +1,110 @@
<h1>Upload Matches (CSV)</h1> <h1>Upload Tournament Results (CSV)</h1>
<p>Upload a CSV file with the following columns:</p> <p>Upload a CSV file with Euchre tournament results. The format supports standard Euchre scoring with Odds/Evens teams.</p>
<pre>
played_at, team_1_p1_name, team_1_p2_name, team_1_score, team_2_p1_name, team_2_p2_name, team_2_score
</pre>
<form action="/admin/matches/process_upload" method="post" enctype="multipart/form-data"> <form action="/admin/matches/process_upload" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="<%= csrf_token %>"> <input type="hidden" name="_csrf_token" value="<%= csrf_token %>">
<div> <div class="form-group">
<label for="event_id">Tournament:</label>
<select id="event_id" name="event_id" required>
<option value="">Select a tournament...</option>
<% if defined?(tournaments) && tournaments %>
<% tournaments.each do |tournament| %>
<option value="<%= tournament.id %>"><%= tournament.name %></option>
<% end %>
<% end %>
</select>
</div>
<div class="form-group">
<label for="csv_file">CSV File:</label> <label for="csv_file">CSV File:</label>
<input type="file" id="csv_file" name="csv_file" accept=".csv" required> <input type="file" id="csv_file" name="csv_file" accept=".csv" required>
</div> </div>
<button type="submit">Upload</button> <button type="submit" class="btn-primary">Upload Results</button>
</form> </form>
<p><a href="/admin/matches">Back to Matches</a></p> <p><a href="/admin/matches">Back to Matches</a></p>
<h2>Euchre Tournament CSV Format</h2>
<p>The CSV should have the following columns:</p>
<table>
<thead>
<tr>
<th>Column</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Event #</td>
<td>Tournament ID (can be omitted if selected above)</td>
<td>1</td>
</tr>
<tr>
<td>Round</td>
<td>Round number</td>
<td>1</td>
</tr>
<tr>
<td>Table</td>
<td>Table name (Clubs, Hearts, Diamonds, Spades, Stars)</td>
<td>Clubs</td>
</tr>
<tr>
<td>Seat 1</td>
<td>Player name (Odds team, position 1)</td>
<td>Derrick</td>
</tr>
<tr>
<td>Seat 3</td>
<td>Player name (Odds team, position 2)</td>
<td>Jesse C</td>
</tr>
<tr>
<td>Odds Points</td>
<td>Score for Odds team</td>
<td>5</td>
</tr>
<tr>
<td>Seat 2</td>
<td>Player name (Evens team, position 1)</td>
<td>Emma</td>
</tr>
<tr>
<td>Seat 4</td>
<td>Player name (Evens team, position 2)</td>
<td>Alissa</td>
</tr>
<tr>
<td>Evens Points</td>
<td>Score for Evens team</td>
<td>10</td>
</tr>
<tr>
<td>Winner</td>
<td>"Odds" or "Evens" (optional, for validation)</td>
<td>Evens</td>
</tr>
</tbody>
</table>
<h2>Example CSV:</h2> <h2>Example CSV:</h2>
<pre> <pre style="background: #f5f5f5; padding: 1rem; border-radius: 4px; overflow-x: auto;">
played_at,team_1_p1_name,team_1_p2_name,team_1_score,team_2_p1_name,team_2_p2_name,team_2_score Event #,Round,Table,Seat 1,Seat 3,Odds Points,Seat 2,Seat 4,Evens Points,Winner
2026-03-26 10:00,Alice,Bob,10,Charlie,David,7 1,1,Clubs,Derrick,Jesse C,5,Emma,Alissa,10,Evens
2026-03-26 10:30,Alice,Bob,10,Charlie,David,7 1,1,Hearts,Kevin,Andy,8,Ellie,Jesse,6,Odds
1,1,Diamonds,Sara M,Amelia,10,Mike G,AJ,4,Odds
</pre> </pre>
<h3>Notes:</h3>
<ul>
<li>Player names must match exactly (including middle initials if present)</li>
<li>Table names are mapped to numeric IDs (Clubs=1, Hearts=2, etc.)</li>
<li>New players will be created automatically if they don't exist</li>
<li>Teams are automatically created for each player pair</li>
<li>Elo ratings are calculated after import</li>
</ul>
+2 -2
View File
@@ -10,8 +10,8 @@ ROM::SQL.migration do
add_column :format, String, default: 'single_elim' add_column :format, String, default: 'single_elim'
add_column :status, String, default: 'planned' add_column :status, String, default: 'planned'
add_column :max_participants, Integer add_column :max_participants, Integer
add_column :created_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP add_column :created_at, DateTime, null: false
add_column :updated_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP add_column :updated_at, DateTime, null: false
end end
# Remove the self-referential column that's not being used # Remove the self-referential column that's not being used
@@ -4,16 +4,20 @@ module EuchreCamp
module Persistence module Persistence
module Relations module Relations
class BracketMatchups < ROM::Relation[:sql] class BracketMatchups < ROM::Relation[:sql]
schema(:bracket_matchups, infer: true) do schema(:bracket_matchups) do
attribute :id, ROM::Types::Integer, primary_key: true attribute :id, ROM::Types::Integer, primary_key: true
attribute :event_id, ROM::Types::Integer attribute :event_id, ROM::Types::Integer
attribute :round_id, ROM::Types::Integer attribute :round_id, ROM::Types::Integer
attribute :match_id, ROM::Types::Integer.optional attribute :match_id, ROM::Types::Integer.optional
attribute :team_1_id, ROM::Types::Integer.optional attribute :team_1_id, ROM::Types::Integer.optional
attribute :team_2_id, ROM::Types::Integer.optional attribute :team_2_id, ROM::Types::Integer.optional
attribute :table_number, ROM::Types::Integer.optional
attribute :bracket_position, ROM::Types::Integer.optional attribute :bracket_position, ROM::Types::Integer.optional
attribute :winner_team_id, ROM::Types::Integer.optional attribute :winner_team_id, ROM::Types::Integer.optional
attribute :loser_team_id, ROM::Types::Integer.optional attribute :loser_team_id, ROM::Types::Integer.optional
attribute :status, ROM::Types::String.optional
attribute :created_at, ROM::Types::DateTime.optional
attribute :updated_at, ROM::Types::DateTime.optional
associations do associations do
belongs_to :event belongs_to :event
@@ -4,14 +4,14 @@ module EuchreCamp
module Persistence module Persistence
module Relations module Relations
class EloSnapshots < ROM::Relation[:sql] class EloSnapshots < ROM::Relation[:sql]
schema(:elo_snapshots, infer: true) do schema(:elo_snapshots) do
attribute :id, ROM::Types::Integer, primary_key: true attribute :id, ROM::Types::Integer, primary_key: true
attribute :player_id, ROM::Types::Integer attribute :player_id, ROM::Types::Integer
attribute :match_id, ROM::Types::Integer attribute :match_id, ROM::Types::Integer
attribute :rating_before, ROM::Types::Integer attribute :rating_before, ROM::Types::Integer
attribute :rating_after, ROM::Types::Integer attribute :rating_after, ROM::Types::Integer
attribute :rating_change, ROM::Types::Integer attribute :rating_change, ROM::Types::Integer
attribute :created_at, ROM::Types::DateTime attribute :created_at, ROM::Types::DateTime.optional
end end
end end
end end
@@ -4,14 +4,16 @@ module EuchreCamp
module Persistence module Persistence
module Relations module Relations
class EventParticipants < ROM::Relation[:sql] class EventParticipants < ROM::Relation[:sql]
schema(:event_participants, infer: true) do schema(:event_participants) do
attribute :id, ROM::Types::Integer, primary_key: true attribute :id, ROM::Types::Integer, primary_key: true
attribute :event_id, ROM::Types::Integer attribute :event_id, ROM::Types::Integer
attribute :player_id, ROM::Types::Integer attribute :player_id, ROM::Types::Integer
attribute :team_id, ROM::Types::Integer.optional attribute :team_id, ROM::Types::Integer.optional
attribute :status, ROM::Types::String.default('registered') attribute :status, ROM::Types::String.default('registered')
attribute :seed, ROM::Types::Integer.optional attribute :seed, ROM::Types::Integer.optional
attribute :registration_date, ROM::Types::DateTime attribute :registration_date, ROM::Types::DateTime.optional
attribute :created_at, ROM::Types::DateTime.optional
attribute :updated_at, ROM::Types::DateTime.optional
associations do associations do
belongs_to :event belongs_to :event
@@ -4,7 +4,7 @@ module EuchreCamp
module Persistence module Persistence
module Relations module Relations
class Events < ROM::Relation[:sql] class Events < ROM::Relation[:sql]
schema(:events, infer: true) do schema(:events) do
attribute :id, ROM::Types::Integer, primary_key: true attribute :id, ROM::Types::Integer, primary_key: true
attribute :event_id, ROM::Types::Integer.optional attribute :event_id, ROM::Types::Integer.optional
attribute :name, ROM::Types::String attribute :name, ROM::Types::String
@@ -14,8 +14,8 @@ module EuchreCamp
attribute :format, ROM::Types::String.default('single_elim') attribute :format, ROM::Types::String.default('single_elim')
attribute :status, ROM::Types::String.default('planned') attribute :status, ROM::Types::String.default('planned')
attribute :max_participants, ROM::Types::Integer.optional attribute :max_participants, ROM::Types::Integer.optional
attribute :created_at, ROM::Types::DateTime attribute :created_at, ROM::Types::DateTime.optional
attribute :updated_at, ROM::Types::DateTime attribute :updated_at, ROM::Types::DateTime.optional
associations do associations do
has_many :event_participants has_many :event_participants
@@ -4,7 +4,7 @@ module EuchreCamp
module Persistence module Persistence
module Relations module Relations
class GoodJobs < ROM::Relation[:sql] class GoodJobs < ROM::Relation[:sql]
schema(:good_jobs, infer: true) do schema(:good_jobs) do
attribute :id, ROM::Types::Integer attribute :id, ROM::Types::Integer
attribute :queue_name, ROM::Types::String attribute :queue_name, ROM::Types::String
attribute :priority, ROM::Types::Integer attribute :priority, ROM::Types::Integer
@@ -13,8 +13,8 @@ module EuchreCamp
attribute :performed_at, ROM::Types::DateTime.optional attribute :performed_at, ROM::Types::DateTime.optional
attribute :finished_at, ROM::Types::DateTime.optional attribute :finished_at, ROM::Types::DateTime.optional
attribute :error, ROM::Types::String.optional attribute :error, ROM::Types::String.optional
attribute :created_at, ROM::Types::DateTime attribute :created_at, ROM::Types::DateTime.optional
attribute :updated_at, ROM::Types::DateTime attribute :updated_at, ROM::Types::DateTime.optional
attribute :active_job_id, ROM::Types::String.optional attribute :active_job_id, ROM::Types::String.optional
attribute :concurrency_key, ROM::Types::String.optional attribute :concurrency_key, ROM::Types::String.optional
attribute :sequence, ROM::Types::Integer.optional attribute :sequence, ROM::Types::Integer.optional
@@ -4,7 +4,7 @@ module EuchreCamp
module Persistence module Persistence
module Relations module Relations
class Matches < ROM::Relation[:sql] class Matches < ROM::Relation[:sql]
schema(:matches, infer: true) do schema(:matches) do
attribute :id, ROM::Types::Integer, primary_key: true attribute :id, ROM::Types::Integer, primary_key: true
attribute :event_id, ROM::Types::Integer.optional attribute :event_id, ROM::Types::Integer.optional
attribute :played_at, ROM::Types::DateTime.optional attribute :played_at, ROM::Types::DateTime.optional
@@ -15,6 +15,8 @@ module EuchreCamp
attribute :team_1_score, ROM::Types::Integer attribute :team_1_score, ROM::Types::Integer
attribute :team_2_score, ROM::Types::Integer attribute :team_2_score, ROM::Types::Integer
attribute :status, ROM::Types::String.default('completed') attribute :status, ROM::Types::String.default('completed')
attribute :created_at, ROM::Types::DateTime.optional
attribute :updated_at, ROM::Types::DateTime.optional
associations do associations do
belongs_to :team_1_player, relation: :players, foreign_key: :team_1_p1_id belongs_to :team_1_player, relation: :players, foreign_key: :team_1_p1_id
@@ -4,14 +4,16 @@ module EuchreCamp
module Persistence module Persistence
module Relations module Relations
class PartnershipGames < ROM::Relation[:sql] class PartnershipGames < ROM::Relation[:sql]
schema(:partnership_games, infer: true) do schema(:partnership_games) do
attribute :id, ROM::Types::Integer, primary_key: true attribute :id, ROM::Types::Integer, primary_key: true
attribute :match_id, ROM::Types::Integer attribute :match_id, ROM::Types::Integer
attribute :player_1_id, ROM::Types::Integer attribute :player_1_id, ROM::Types::Integer
attribute :player_2_id, ROM::Types::Integer attribute :player_2_id, ROM::Types::Integer
attribute :team_number, ROM::Types::Integer attribute :team_number, ROM::Types::Integer.optional
attribute :won_match, ROM::Types::Integer attribute :won_match, ROM::Types::Integer.optional
attribute :created_at, ROM::Types::DateTime attribute :player_1_elo_change, ROM::Types::Integer.optional
attribute :player_2_elo_change, ROM::Types::Integer.optional
attribute :created_at, ROM::Types::DateTime.optional
associations do associations do
belongs_to :match belongs_to :match
@@ -4,7 +4,7 @@ module EuchreCamp
module Persistence module Persistence
module Relations module Relations
class PartnershipStats < ROM::Relation[:sql] class PartnershipStats < ROM::Relation[:sql]
schema(:partnership_stats, infer: true) do schema(:partnership_stats) do
attribute :id, ROM::Types::Integer, primary_key: true attribute :id, ROM::Types::Integer, primary_key: true
attribute :player_1_id, ROM::Types::Integer attribute :player_1_id, ROM::Types::Integer
attribute :player_2_id, ROM::Types::Integer attribute :player_2_id, ROM::Types::Integer
@@ -13,8 +13,8 @@ module EuchreCamp
attribute :losses, ROM::Types::Integer.default(0) attribute :losses, ROM::Types::Integer.default(0)
attribute :total_elo_change, ROM::Types::Integer.default(0) attribute :total_elo_change, ROM::Types::Integer.default(0)
attribute :last_played, ROM::Types::DateTime.optional attribute :last_played, ROM::Types::DateTime.optional
attribute :created_at, ROM::Types::DateTime attribute :created_at, ROM::Types::DateTime.optional
attribute :updated_at, ROM::Types::DateTime attribute :updated_at, ROM::Types::DateTime.optional
associations do associations do
belongs_to :player_1, relation: :players, foreign_key: :player_1_id belongs_to :player_1, relation: :players, foreign_key: :player_1_id
@@ -4,11 +4,13 @@ module EuchreCamp
module Persistence module Persistence
module Relations module Relations
class Players < ROM::Relation[:sql] class Players < ROM::Relation[:sql]
schema(:players, infer: true) do schema(:players) do
attribute :id, ROM::Types::Integer, primary_key: true attribute :id, ROM::Types::Integer, primary_key: true
attribute :name, ROM::Types::String attribute :name, ROM::Types::String
attribute :rating, ROM::Types::Integer attribute :rating, ROM::Types::Integer
attribute :current_elo, ROM::Types::Integer.default(1000) attribute :current_elo, ROM::Types::Integer.default(1000)
attribute :created_at, ROM::Types::DateTime.optional
attribute :updated_at, ROM::Types::DateTime.optional
end end
end end
end end
@@ -4,13 +4,14 @@ module EuchreCamp
module Persistence module Persistence
module Relations module Relations
class Teams < ROM::Relation[:sql] class Teams < ROM::Relation[:sql]
schema(:teams, infer: true) do schema(:teams) do
attribute :id, ROM::Types::Integer, primary_key: true attribute :id, ROM::Types::Integer, primary_key: true
attribute :event_id, ROM::Types::Integer attribute :event_id, ROM::Types::Integer
attribute :team_name, ROM::Types::String.optional attribute :team_name, ROM::Types::String.optional
attribute :player_1_id, ROM::Types::Integer attribute :player_1_id, ROM::Types::Integer
attribute :player_2_id, ROM::Types::Integer attribute :player_2_id, ROM::Types::Integer
attribute :created_at, ROM::Types::DateTime attribute :created_at, ROM::Types::DateTime.optional
attribute :updated_at, ROM::Types::DateTime.optional
associations do associations do
belongs_to :event belongs_to :event
@@ -4,7 +4,7 @@ module EuchreCamp
module Persistence module Persistence
module Relations module Relations
class TournamentRounds < ROM::Relation[:sql] class TournamentRounds < ROM::Relation[:sql]
schema(:tournament_rounds, infer: true) do schema(:tournament_rounds) do
attribute :id, ROM::Types::Integer, primary_key: true attribute :id, ROM::Types::Integer, primary_key: true
attribute :event_id, ROM::Types::Integer attribute :event_id, ROM::Types::Integer
attribute :round_number, ROM::Types::Integer attribute :round_number, ROM::Types::Integer
@@ -12,6 +12,8 @@ module EuchreCamp
attribute :scheduled_start, ROM::Types::DateTime.optional attribute :scheduled_start, ROM::Types::DateTime.optional
attribute :actual_start, ROM::Types::DateTime.optional attribute :actual_start, ROM::Types::DateTime.optional
attribute :completed_at, ROM::Types::DateTime.optional attribute :completed_at, ROM::Types::DateTime.optional
attribute :created_at, ROM::Types::DateTime.optional
attribute :updated_at, ROM::Types::DateTime.optional
associations do associations do
belongs_to :event belongs_to :event
@@ -4,7 +4,7 @@ module EuchreCamp
module Persistence module Persistence
module Relations module Relations
class Users < ROM::Relation[:sql] class Users < ROM::Relation[:sql]
schema(:users, infer: true) do schema(:users) do
attribute :id, ROM::Types::Integer, primary_key: true attribute :id, ROM::Types::Integer, primary_key: true
attribute :player_id, ROM::Types::Integer attribute :player_id, ROM::Types::Integer
attribute :email, ROM::Types::String attribute :email, ROM::Types::String
@@ -12,15 +12,15 @@ module EuchreCamp
attribute :role, ROM::Types::String attribute :role, ROM::Types::String
attribute :confirmed, ROM::Types::Bool attribute :confirmed, ROM::Types::Bool
attribute :confirmation_token, ROM::Types::String.optional attribute :confirmation_token, ROM::Types::String.optional
attribute :confirmation_sent_at, ROM::Types::Time.optional attribute :confirmation_sent_at, ROM::Types::DateTime.optional
attribute :reset_password_token, ROM::Types::String.optional attribute :reset_password_token, ROM::Types::String.optional
attribute :reset_password_sent_at, ROM::Types::Time.optional attribute :reset_password_sent_at, ROM::Types::DateTime.optional
attribute :failed_login_attempts, ROM::Types::Integer attribute :failed_login_attempts, ROM::Types::Integer
attribute :locked_until, ROM::Types::Time.optional attribute :locked_until, ROM::Types::DateTime.optional
attribute :last_login_at, ROM::Types::Time.optional attribute :last_login_at, ROM::Types::DateTime.optional
attribute :last_login_ip, ROM::Types::String.optional attribute :last_login_ip, ROM::Types::String.optional
attribute :created_at, ROM::Types::Time attribute :created_at, ROM::Types::DateTime.optional
attribute :updated_at, ROM::Types::Time attribute :updated_at, ROM::Types::DateTime.optional
end end
end end
end end
+303
View File
@@ -0,0 +1,303 @@
# frozen_string_literal: true
require 'playwright'
RSpec.describe 'Authentication', type: :playwright do
# Use RSpec's around hook to manage Playwright lifecycle
around do |example|
Playwright.create(playwright_cli_executable_path: './node_modules/.bin/playwright') do |playwright|
playwright.chromium.launch(headless: true) do |browser|
@browser = browser
example.run
end
end
end
before(:each) do
@context = @browser.new_context(viewport: { width: 1280, height: 800 })
@page = @context.new_page
end
after(:each) do
@context.close
end
describe 'Login Form Display' do
it 'displays login page at /login' do
@page.goto('http://localhost:2300/login')
expect(@page.locator('h1').text_content).to include('Login')
end
it 'has email and password input fields' do
@page.goto('http://localhost:2300/login')
email_input = @page.locator('input[type="email"]')
password_input = @page.locator('input[type="password"]')
expect(email_input.count).to be > 0
expect(password_input.count).to be > 0
end
it 'has a login form with POST method' do
@page.goto('http://localhost:2300/login')
form = @page.locator('form[method="POST"]')
expect(form.count).to be > 0
end
it 'includes CSRF token in login form' do
@page.goto('http://localhost:2300/login')
csrf_input = @page.locator('input[name="_csrf_token"]')
expect(csrf_input.count).to be > 0
expect(csrf_input.input_value).not_to be_empty
end
end
describe 'Login Flow' do
before(:all) do
# Admin user credentials for testing
@admin_email = 'david@dhg.lol'
@admin_password = 'admin'
end
it 'successfully logs in with valid credentials' do
@page.goto('http://localhost:2300/login')
# Fill in login form
@page.locator('input[type="email"]').fill(@admin_email)
@page.locator('input[type="password"]').fill(@admin_password)
# Submit form
@page.locator('button[type="submit"]').click
# Should redirect to rankings page
expect(@page.url).to include('/')
end
it 'shows success message after login' do
@page.goto('http://localhost:2300/login')
# Fill in login form
@page.locator('input[type="email"]').fill(@admin_email)
@page.locator('input[type="password"]').fill(@admin_password)
# Submit form
@page.locator('button[type="submit"]').click
# Should redirect to rankings page
expect(@page.url).to include('/')
end
it 'redirects to admin dashboard when logged in' do
@page.goto('http://localhost:2300/login')
# Login
@page.locator('input[type="email"]').fill(@admin_email)
@page.locator('input[type="password"]').fill(@admin_password)
@page.locator('button[type="submit"]').click
# After login, user is redirected to rankings
# Then navigate to admin
@page.goto('http://localhost:2300/admin')
# Should see admin dashboard
expect(@page.locator('h1').text_content).to include('Admin Dashboard')
end
end
describe 'Failed Login Scenarios' do
it 'rejects invalid email' do
@page.goto('http://localhost:2300/login')
@page.locator('input[type="email"]').fill('invalid@example.com')
@page.locator('input[type="password"]').fill('wrongpassword')
@page.locator('button[type="submit"]').click
# Should show error message
page_content = @page.content
expect(page_content).to include('Invalid email or password')
end
it 'rejects invalid password' do
@page.goto('http://localhost:2300/login')
@page.locator('input[type="email"]').fill('david@dhg.lol')
@page.locator('input[type="password"]').fill('wrongpassword')
@page.locator('button[type="submit"]').click
# Should show error message
page_content = @page.content
expect(page_content).to include('Invalid email or password')
end
it 'requires email field' do
@page.goto('http://localhost:2300/login')
@page.locator('input[type="password"]').fill('somepassword')
@page.locator('button[type="submit"]').click
# Browser validation should prevent submission
# Or server should handle empty email
page_content = @page.content
# Either an error or the form should remain
expect(page_content).to include('Login')
end
it 'requires password field' do
@page.goto('http://localhost:2300/login')
@page.locator('input[type="email"]').fill('david@dhg.lol')
@page.locator('button[type="submit"]').click
# Browser validation should prevent submission
page_content = @page.content
expect(page_content).to include('Login')
end
end
describe 'Session Management' do
before(:all) do
@admin_email = 'david@dhg.lol'
@admin_password = 'admin'
end
it 'creates session after successful login' do
@page.goto('http://localhost:2300/login')
# Login
@page.locator('input[type="email"]').fill(@admin_email)
@page.locator('input[type="password"]').fill(@admin_password)
@page.locator('button[type="submit"]').click
# Check that session cookie is set
cookies = @context.cookies
session_cookie = cookies.find { |c| c['name'] == 'rack.session' }
expect(session_cookie).not_to be_nil
expect(session_cookie['value']).not_to be_empty
end
it 'persists session across page reloads' do
@page.goto('http://localhost:2300/login')
# Login
@page.locator('input[type="email"]').fill(@admin_email)
@page.locator('input[type="password"]').fill(@admin_password)
@page.locator('button[type="submit"]').click
# Verify we're logged in
@page.goto('http://localhost:2300/admin')
expect(@page.locator('h1').text_content).to include('Admin Dashboard')
# Reload page
@page.reload
# Should still be logged in
expect(@page.locator('h1').text_content).to include('Admin Dashboard')
end
end
describe 'Logout Functionality' do
before(:all) do
@admin_email = 'david@dhg.lol'
@admin_password = 'admin'
end
it 'logs out user when clicking logout link' do
@page.goto('http://localhost:2300/login')
# Login
@page.locator('input[type="email"]').fill(@admin_email)
@page.locator('input[type="password"]').fill(@admin_password)
@page.locator('button[type="submit"]').click
# Verify logged in - check for Logout link in navigation
# Navigate to admin to confirm login
@page.goto('http://localhost:2300/admin')
expect(@page.locator('h1').text_content).to include('Admin Dashboard')
# Click logout - use first match since logout may appear in multiple places
@page.locator('a[href="/logout"]').first.click
# After logout, check we're back at rankings
expect(@page.url).to include('/')
end
it 'clears session after logout' do
@page.goto('http://localhost:2300/login')
# Login
@page.locator('input[type="email"]').fill(@admin_email)
@page.locator('input[type="password"]').fill(@admin_password)
@page.locator('button[type="submit"]').click
# Navigate to admin to verify logged in
@page.goto('http://localhost:2300/admin')
expect(@page.locator('h1').text_content).to include('Admin Dashboard')
# Logout
@page.locator('a[href="/logout"]').first.click
# Try to access admin page again - should redirect (not show admin dashboard)
@page.goto('http://localhost:2300/admin')
# Should not see admin dashboard
expect(@page.locator('h1').text_content).not_to include('Admin Dashboard')
end
end
describe 'Authorization Redirects' do
before(:all) do
@admin_email = 'david@dhg.lol'
@admin_password = 'admin'
end
it 'redirects to rankings when accessing admin page without auth' do
@page.goto('http://localhost:2300/admin')
# Unauthenticated users are redirected to rankings page
expect(@page.url).to include('/')
expect(@page.locator('h1').text_content).to include('Rankings')
end
it 'allows access to admin page after login' do
@page.goto('http://localhost:2300/login')
# Login
@page.locator('input[type="email"]').fill(@admin_email)
@page.locator('input[type="password"]').fill(@admin_password)
@page.locator('button[type="submit"]').click
# Navigate to admin page
@page.goto('http://localhost:2300/admin')
# Should see admin dashboard
expect(@page.locator('h1').text_content).to include('Admin Dashboard')
end
it 'redirects to rankings after logout when accessing admin page' do
@page.goto('http://localhost:2300/login')
# Login
@page.locator('input[type="email"]').fill(@admin_email)
@page.locator('input[type="password"]').fill(@admin_password)
@page.locator('button[type="submit"]').click
# Go to admin page
@page.goto('http://localhost:2300/admin')
expect(@page.url).to include('/admin')
# Click logout link in admin page
# Note: logout link is in bottom nav on mobile, main nav on desktop
@page.locator('a[href="/logout"]').first.click
# After logout, try to access admin page again
@page.goto('http://localhost:2300/admin')
# Should redirect to rankings (unauthenticated users see rankings, not login)
expect(@page.url).to include('/')
end
end
end