From 96f7cb86d32f6d5355584737f5df80d8c5fe1980 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 27 Mar 2026 14:36:22 -0700 Subject: [PATCH] chore: save current Ruby implementation state before Next.js rewrite --- Euchre Tournament Results - Sheet1.csv | 31 ++ USER_STORIES.md | 221 +++++++++++++ app/actions/admin/matches/process_upload.rb | 84 ++--- app/actions/admin/matches/upload.rb | 7 +- app/services/tournament_csv_importer.rb | 169 ++++++++++ app/templates/admin/matches/upload.html.erb | 106 +++++- db/migrate/20240915000002_enhance_events.rb | 4 +- .../persistence/relations/bracket_matchups.rb | 6 +- .../persistence/relations/elo_snapshots.rb | 4 +- .../relations/event_participants.rb | 6 +- .../persistence/relations/events.rb | 6 +- .../persistence/relations/good_jobs.rb | 6 +- .../persistence/relations/matches.rb | 4 +- .../relations/partnership_games.rb | 10 +- .../relations/partnership_stats.rb | 6 +- .../persistence/relations/players.rb | 4 +- .../persistence/relations/teams.rb | 5 +- .../relations/tournament_rounds.rb | 4 +- .../persistence/relations/users.rb | 14 +- spec/acceptance/authentication_spec.rb | 303 ++++++++++++++++++ 20 files changed, 894 insertions(+), 106 deletions(-) create mode 100644 Euchre Tournament Results - Sheet1.csv create mode 100644 USER_STORIES.md create mode 100644 app/services/tournament_csv_importer.rb create mode 100644 spec/acceptance/authentication_spec.rb diff --git a/Euchre Tournament Results - Sheet1.csv b/Euchre Tournament Results - Sheet1.csv new file mode 100644 index 0000000..c0a9263 --- /dev/null +++ b/Euchre Tournament Results - Sheet1.csv @@ -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 \ No newline at end of file diff --git a/USER_STORIES.md b/USER_STORIES.md new file mode 100644 index 0000000..b50c843 --- /dev/null +++ b/USER_STORIES.md @@ -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 diff --git a/app/actions/admin/matches/process_upload.rb b/app/actions/admin/matches/process_upload.rb index 2d1b2e8..c6517b0 100644 --- a/app/actions/admin/matches/process_upload.rb +++ b/app/actions/admin/matches/process_upload.rb @@ -1,83 +1,47 @@ # frozen_string_literal: true -require "csv" - module EuchreCamp module Actions module Admin module Matches class ProcessUpload < EuchreCamp::Action - include Deps[repo: 'repositories.matches'] + include Deps[events_repo: 'repositories.events'] def handle(request, response) # In Hanami, file uploads are in request.params file = request.params[:csv_file] + event_id = request.params[:event_id]&.to_i unless file && file[:tempfile] + response.flash[:error] = "No file uploaded" response.redirect_to("/admin/matches/upload") return end - matches_created = 0 - errors = [] - - # Parse CSV - 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 + unless event_id + response.flash[:error] = "Please select a tournament" + response.redirect_to("/admin/matches/upload") + return 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 + # Verify event exists + begin + events_repo.by_id(event_id) + rescue + response.flash[:error] = "Invalid tournament ID" + response.redirect_to("/admin/matches/upload") + return 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 diff --git a/app/actions/admin/matches/upload.rb b/app/actions/admin/matches/upload.rb index 21d5334..2c2e8fe 100644 --- a/app/actions/admin/matches/upload.rb +++ b/app/actions/admin/matches/upload.rb @@ -1,14 +1,15 @@ # frozen_string_literal: true -require "csv" - module EuchreCamp module Actions module Admin module Matches class Upload < EuchreCamp::Action + include Deps[events_repo: 'repositories.events'] + def handle(request, response) - response.render(view) + tournaments = events_repo.all + response.render(view, tournaments: tournaments) end end end diff --git a/app/services/tournament_csv_importer.rb b/app/services/tournament_csv_importer.rb new file mode 100644 index 0000000..8d7ed92 --- /dev/null +++ b/app/services/tournament_csv_importer.rb @@ -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 diff --git a/app/templates/admin/matches/upload.html.erb b/app/templates/admin/matches/upload.html.erb index 6378429..60d4896 100644 --- a/app/templates/admin/matches/upload.html.erb +++ b/app/templates/admin/matches/upload.html.erb @@ -1,26 +1,110 @@ -

Upload Matches (CSV)

+

Upload Tournament Results (CSV)

-

Upload a CSV file with the following 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
-
+

Upload a CSV file with Euchre tournament results. The format supports standard Euchre scoring with Odds/Evens teams.

-
+
+ + +
+ +
- +

Back to Matches

+

Euchre Tournament CSV Format

+ +

The CSV should have the following columns:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ColumnDescriptionExample
Event #Tournament ID (can be omitted if selected above)1
RoundRound number1
TableTable name (Clubs, Hearts, Diamonds, Spades, Stars)Clubs
Seat 1Player name (Odds team, position 1)Derrick
Seat 3Player name (Odds team, position 2)Jesse C
Odds PointsScore for Odds team5
Seat 2Player name (Evens team, position 1)Emma
Seat 4Player name (Evens team, position 2)Alissa
Evens PointsScore for Evens team10
Winner"Odds" or "Evens" (optional, for validation)Evens
+

Example CSV:

-
-played_at,team_1_p1_name,team_1_p2_name,team_1_score,team_2_p1_name,team_2_p2_name,team_2_score
-2026-03-26 10:00,Alice,Bob,10,Charlie,David,7
-2026-03-26 10:30,Alice,Bob,10,Charlie,David,7
+
+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
 
+ +

Notes:

+
    +
  • Player names must match exactly (including middle initials if present)
  • +
  • Table names are mapped to numeric IDs (Clubs=1, Hearts=2, etc.)
  • +
  • New players will be created automatically if they don't exist
  • +
  • Teams are automatically created for each player pair
  • +
  • Elo ratings are calculated after import
  • +
diff --git a/db/migrate/20240915000002_enhance_events.rb b/db/migrate/20240915000002_enhance_events.rb index 6c69b72..c641658 100644 --- a/db/migrate/20240915000002_enhance_events.rb +++ b/db/migrate/20240915000002_enhance_events.rb @@ -10,8 +10,8 @@ ROM::SQL.migration do add_column :format, String, default: 'single_elim' add_column :status, String, default: 'planned' add_column :max_participants, Integer - add_column :created_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP - add_column :updated_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP + add_column :created_at, DateTime, null: false + add_column :updated_at, DateTime, null: false end # Remove the self-referential column that's not being used diff --git a/lib/euchre_camp/persistence/relations/bracket_matchups.rb b/lib/euchre_camp/persistence/relations/bracket_matchups.rb index 02cf282..2c5d707 100644 --- a/lib/euchre_camp/persistence/relations/bracket_matchups.rb +++ b/lib/euchre_camp/persistence/relations/bracket_matchups.rb @@ -4,16 +4,20 @@ module EuchreCamp module Persistence module Relations class BracketMatchups < ROM::Relation[:sql] - schema(:bracket_matchups, infer: true) do + schema(:bracket_matchups) do attribute :id, ROM::Types::Integer, primary_key: true attribute :event_id, ROM::Types::Integer attribute :round_id, ROM::Types::Integer attribute :match_id, ROM::Types::Integer.optional attribute :team_1_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 :winner_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 belongs_to :event diff --git a/lib/euchre_camp/persistence/relations/elo_snapshots.rb b/lib/euchre_camp/persistence/relations/elo_snapshots.rb index c1052b0..3a5c957 100644 --- a/lib/euchre_camp/persistence/relations/elo_snapshots.rb +++ b/lib/euchre_camp/persistence/relations/elo_snapshots.rb @@ -4,14 +4,14 @@ module EuchreCamp module Persistence module Relations class EloSnapshots < ROM::Relation[:sql] - schema(:elo_snapshots, infer: true) do + schema(:elo_snapshots) do attribute :id, ROM::Types::Integer, primary_key: true attribute :player_id, ROM::Types::Integer attribute :match_id, ROM::Types::Integer attribute :rating_before, ROM::Types::Integer attribute :rating_after, 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 diff --git a/lib/euchre_camp/persistence/relations/event_participants.rb b/lib/euchre_camp/persistence/relations/event_participants.rb index d44ee31..c3d9350 100644 --- a/lib/euchre_camp/persistence/relations/event_participants.rb +++ b/lib/euchre_camp/persistence/relations/event_participants.rb @@ -4,14 +4,16 @@ module EuchreCamp module Persistence module Relations class EventParticipants < ROM::Relation[:sql] - schema(:event_participants, infer: true) do + schema(:event_participants) do attribute :id, ROM::Types::Integer, primary_key: true attribute :event_id, ROM::Types::Integer attribute :player_id, ROM::Types::Integer attribute :team_id, ROM::Types::Integer.optional attribute :status, ROM::Types::String.default('registered') 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 belongs_to :event diff --git a/lib/euchre_camp/persistence/relations/events.rb b/lib/euchre_camp/persistence/relations/events.rb index 267d09d..3ff11c5 100644 --- a/lib/euchre_camp/persistence/relations/events.rb +++ b/lib/euchre_camp/persistence/relations/events.rb @@ -4,7 +4,7 @@ module EuchreCamp module Persistence module Relations class Events < ROM::Relation[:sql] - schema(:events, infer: true) do + schema(:events) do attribute :id, ROM::Types::Integer, primary_key: true attribute :event_id, ROM::Types::Integer.optional attribute :name, ROM::Types::String @@ -14,8 +14,8 @@ module EuchreCamp attribute :format, ROM::Types::String.default('single_elim') attribute :status, ROM::Types::String.default('planned') attribute :max_participants, ROM::Types::Integer.optional - attribute :created_at, ROM::Types::DateTime - attribute :updated_at, ROM::Types::DateTime + attribute :created_at, ROM::Types::DateTime.optional + attribute :updated_at, ROM::Types::DateTime.optional associations do has_many :event_participants diff --git a/lib/euchre_camp/persistence/relations/good_jobs.rb b/lib/euchre_camp/persistence/relations/good_jobs.rb index 765b3b9..824b683 100644 --- a/lib/euchre_camp/persistence/relations/good_jobs.rb +++ b/lib/euchre_camp/persistence/relations/good_jobs.rb @@ -4,7 +4,7 @@ module EuchreCamp module Persistence module Relations class GoodJobs < ROM::Relation[:sql] - schema(:good_jobs, infer: true) do + schema(:good_jobs) do attribute :id, ROM::Types::Integer attribute :queue_name, ROM::Types::String attribute :priority, ROM::Types::Integer @@ -13,8 +13,8 @@ module EuchreCamp attribute :performed_at, ROM::Types::DateTime.optional attribute :finished_at, ROM::Types::DateTime.optional attribute :error, ROM::Types::String.optional - attribute :created_at, ROM::Types::DateTime - attribute :updated_at, ROM::Types::DateTime + attribute :created_at, ROM::Types::DateTime.optional + attribute :updated_at, ROM::Types::DateTime.optional attribute :active_job_id, ROM::Types::String.optional attribute :concurrency_key, ROM::Types::String.optional attribute :sequence, ROM::Types::Integer.optional diff --git a/lib/euchre_camp/persistence/relations/matches.rb b/lib/euchre_camp/persistence/relations/matches.rb index 5f9cd29..a1b77fc 100644 --- a/lib/euchre_camp/persistence/relations/matches.rb +++ b/lib/euchre_camp/persistence/relations/matches.rb @@ -4,7 +4,7 @@ module EuchreCamp module Persistence module Relations class Matches < ROM::Relation[:sql] - schema(:matches, infer: true) do + schema(:matches) do attribute :id, ROM::Types::Integer, primary_key: true attribute :event_id, ROM::Types::Integer.optional attribute :played_at, ROM::Types::DateTime.optional @@ -15,6 +15,8 @@ module EuchreCamp attribute :team_1_score, ROM::Types::Integer attribute :team_2_score, ROM::Types::Integer attribute :status, ROM::Types::String.default('completed') + attribute :created_at, ROM::Types::DateTime.optional + attribute :updated_at, ROM::Types::DateTime.optional associations do belongs_to :team_1_player, relation: :players, foreign_key: :team_1_p1_id diff --git a/lib/euchre_camp/persistence/relations/partnership_games.rb b/lib/euchre_camp/persistence/relations/partnership_games.rb index 01dd3b2..b8be75e 100644 --- a/lib/euchre_camp/persistence/relations/partnership_games.rb +++ b/lib/euchre_camp/persistence/relations/partnership_games.rb @@ -4,14 +4,16 @@ module EuchreCamp module Persistence module Relations class PartnershipGames < ROM::Relation[:sql] - schema(:partnership_games, infer: true) do + schema(:partnership_games) do attribute :id, ROM::Types::Integer, primary_key: true attribute :match_id, ROM::Types::Integer attribute :player_1_id, ROM::Types::Integer attribute :player_2_id, ROM::Types::Integer - attribute :team_number, ROM::Types::Integer - attribute :won_match, ROM::Types::Integer - attribute :created_at, ROM::Types::DateTime + attribute :team_number, ROM::Types::Integer.optional + attribute :won_match, ROM::Types::Integer.optional + 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 belongs_to :match diff --git a/lib/euchre_camp/persistence/relations/partnership_stats.rb b/lib/euchre_camp/persistence/relations/partnership_stats.rb index 12f3ea3..c9f61ce 100644 --- a/lib/euchre_camp/persistence/relations/partnership_stats.rb +++ b/lib/euchre_camp/persistence/relations/partnership_stats.rb @@ -4,7 +4,7 @@ module EuchreCamp module Persistence module Relations class PartnershipStats < ROM::Relation[:sql] - schema(:partnership_stats, infer: true) do + schema(:partnership_stats) do attribute :id, ROM::Types::Integer, primary_key: true attribute :player_1_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 :total_elo_change, ROM::Types::Integer.default(0) attribute :last_played, ROM::Types::DateTime.optional - attribute :created_at, ROM::Types::DateTime - attribute :updated_at, ROM::Types::DateTime + attribute :created_at, ROM::Types::DateTime.optional + attribute :updated_at, ROM::Types::DateTime.optional associations do belongs_to :player_1, relation: :players, foreign_key: :player_1_id diff --git a/lib/euchre_camp/persistence/relations/players.rb b/lib/euchre_camp/persistence/relations/players.rb index cebece8..4dd772e 100644 --- a/lib/euchre_camp/persistence/relations/players.rb +++ b/lib/euchre_camp/persistence/relations/players.rb @@ -4,11 +4,13 @@ module EuchreCamp module Persistence module Relations class Players < ROM::Relation[:sql] - schema(:players, infer: true) do + schema(:players) do attribute :id, ROM::Types::Integer, primary_key: true attribute :name, ROM::Types::String attribute :rating, ROM::Types::Integer 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 diff --git a/lib/euchre_camp/persistence/relations/teams.rb b/lib/euchre_camp/persistence/relations/teams.rb index 954fffe..7d6ceaa 100644 --- a/lib/euchre_camp/persistence/relations/teams.rb +++ b/lib/euchre_camp/persistence/relations/teams.rb @@ -4,13 +4,14 @@ module EuchreCamp module Persistence module Relations class Teams < ROM::Relation[:sql] - schema(:teams, infer: true) do + schema(:teams) do attribute :id, ROM::Types::Integer, primary_key: true attribute :event_id, ROM::Types::Integer attribute :team_name, ROM::Types::String.optional attribute :player_1_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 belongs_to :event diff --git a/lib/euchre_camp/persistence/relations/tournament_rounds.rb b/lib/euchre_camp/persistence/relations/tournament_rounds.rb index 9aea8d9..1184d5e 100644 --- a/lib/euchre_camp/persistence/relations/tournament_rounds.rb +++ b/lib/euchre_camp/persistence/relations/tournament_rounds.rb @@ -4,7 +4,7 @@ module EuchreCamp module Persistence module Relations class TournamentRounds < ROM::Relation[:sql] - schema(:tournament_rounds, infer: true) do + schema(:tournament_rounds) do attribute :id, ROM::Types::Integer, primary_key: true attribute :event_id, ROM::Types::Integer attribute :round_number, ROM::Types::Integer @@ -12,6 +12,8 @@ module EuchreCamp attribute :scheduled_start, ROM::Types::DateTime.optional attribute :actual_start, 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 belongs_to :event diff --git a/lib/euchre_camp/persistence/relations/users.rb b/lib/euchre_camp/persistence/relations/users.rb index 888c502..41f377a 100644 --- a/lib/euchre_camp/persistence/relations/users.rb +++ b/lib/euchre_camp/persistence/relations/users.rb @@ -4,7 +4,7 @@ module EuchreCamp module Persistence module Relations class Users < ROM::Relation[:sql] - schema(:users, infer: true) do + schema(:users) do attribute :id, ROM::Types::Integer, primary_key: true attribute :player_id, ROM::Types::Integer attribute :email, ROM::Types::String @@ -12,15 +12,15 @@ module EuchreCamp attribute :role, ROM::Types::String attribute :confirmed, ROM::Types::Bool 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_sent_at, ROM::Types::Time.optional + attribute :reset_password_sent_at, ROM::Types::DateTime.optional attribute :failed_login_attempts, ROM::Types::Integer - attribute :locked_until, ROM::Types::Time.optional - attribute :last_login_at, ROM::Types::Time.optional + attribute :locked_until, ROM::Types::DateTime.optional + attribute :last_login_at, ROM::Types::DateTime.optional attribute :last_login_ip, ROM::Types::String.optional - attribute :created_at, ROM::Types::Time - attribute :updated_at, ROM::Types::Time + attribute :created_at, ROM::Types::DateTime.optional + attribute :updated_at, ROM::Types::DateTime.optional end end end diff --git a/spec/acceptance/authentication_spec.rb b/spec/acceptance/authentication_spec.rb new file mode 100644 index 0000000..2ed830d --- /dev/null +++ b/spec/acceptance/authentication_spec.rb @@ -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 \ No newline at end of file