nextjs-rewrite #5
@@ -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
|
||||||
|
+221
@@ -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
|
||||||
@@ -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")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Parse CSV
|
# Verify event exists
|
||||||
CSV.foreach(file[:tempfile].path, headers: true, header_converters: :symbol) do |row|
|
|
||||||
begin
|
begin
|
||||||
# Map CSV columns to our database structure
|
events_repo.by_id(event_id)
|
||||||
# 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
|
rescue
|
||||||
Time.now
|
response.flash[:error] = "Invalid tournament ID"
|
||||||
|
response.redirect_to("/admin/matches/upload")
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
match_data = {
|
# Use the specialized importer for Euchre tournament results
|
||||||
played_at: played_at,
|
importer = EuchreCamp::Services::TournamentCsvImporter.new(event_id: event_id)
|
||||||
team_1_p1_id: player_1[:id],
|
results = importer.import(file[:tempfile].path)
|
||||||
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)
|
message = "Created #{results[:matches_created]} matches, #{results[:teams_created]} teams"
|
||||||
EuchreCamp::Jobs::CalculateEloJob.perform_later(match[:id])
|
message += ". Errors: #{results[:errors].join(', ')}" if results[:errors].any?
|
||||||
matches_created += 1
|
|
||||||
|
|
||||||
rescue => e
|
response.flash[:message] = message
|
||||||
errors << "Row #{row}: #{e.message}"
|
response.redirect_to("/admin/tournaments/#{event_id}")
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
response.flash[:message] = "Created #{matches_created} matches.#{' Errors: ' + errors.join(', ') if errors.any?}"
|
|
||||||
response.redirect_to("/admin/matches")
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def find_or_create_player(name)
|
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user