From 93736287ae8d9f1ecd3cecf6cbacfdb9574c596e Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Sun, 1 Mar 2026 04:32:13 +0100 Subject: [PATCH] feat(01-07): implement player CRUD, search, merge, CSV import, QR codes - PlayerService with CRUD, FTS5 typeahead search, duplicate merge, CSV import - CSV import safety limits: 10K rows, 20 columns, 1K chars/field - QR code generation per player using skip2/go-qrcode library - Tournament player operations: register, bust, undo bust - TournamentPlayerDetail with computed investment, net result, action history - RankingEngine derives positions from ordered bust-out list (never stored) - RecalculateAllRankings for undo consistency - All mutations record audit entries and broadcast via WebSocket Co-Authored-By: Claude Opus 4.6 --- go.mod | 3 +- go.sum | 2 + internal/player/player.go | 1013 ++++++++++++++++++++++++++++++++++++ internal/player/qrcode.go | 30 ++ internal/player/ranking.go | 229 ++++++++ 5 files changed, 1276 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e34fa53..ed22fca 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/nats-io/nats-server/v2 v2.12.4 github.com/nats-io/nats.go v1.49.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + golang.org/x/crypto v0.47.0 ) require ( @@ -22,7 +24,6 @@ require ( github.com/nats-io/jwt/v2 v2.8.0 // indirect github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect diff --git a/go.sum b/go.sum index 9854e53..40a0ea2 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff h1:Hvxz9W8fWpSg9xkiq8/q+3cVJo+MmLMfkjdS/u4nWFY= github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= diff --git a/internal/player/player.go b/internal/player/player.go index 5d9ce2c..31ccff7 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -1 +1,1014 @@ +// Package player provides player management for the Felt tournament engine. +// It handles CRUD operations, FTS5 typeahead search, duplicate merge, CSV +// import, tournament player operations (bust, undo, re-entry), and QR code +// generation. All monetary values use int64 cents. All mutations record audit +// entries and broadcast via WebSocket. package player + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "log" + "strings" + "time" + "unicode/utf8" + + "github.com/felt-app/felt/internal/audit" + "github.com/felt-app/felt/internal/financial" + "github.com/felt-app/felt/internal/server/middleware" + "github.com/felt-app/felt/internal/server/ws" +) + +// CSV import safety limits. +const ( + MaxCSVRows = 10_000 + MaxCSVColumns = 20 + MaxCSVFieldLen = 1_000 +) + +// Errors returned by the player service. +var ( + ErrPlayerNotFound = fmt.Errorf("player: not found") + ErrPlayerAlreadyActive = fmt.Errorf("player: already active in tournament") + ErrPlayerNotActive = fmt.Errorf("player: not active in tournament") + ErrPlayerNotBusted = fmt.Errorf("player: not busted") + ErrTournamentFull = fmt.Errorf("player: tournament is full") + ErrTournamentNotFound = fmt.Errorf("player: tournament not found") + ErrHitmanRequired = fmt.Errorf("player: hitman is required for PKO tournament") + ErrCSVTooManyRows = fmt.Errorf("player: CSV exceeds maximum of 10,000 rows") + ErrCSVTooManyColumns = fmt.Errorf("player: CSV exceeds maximum of 20 columns") + ErrCSVFieldTooLong = fmt.Errorf("player: CSV field exceeds maximum of 1,000 characters") + ErrCSVMissingNameColumn = fmt.Errorf("player: CSV must have a 'name' column") +) + +// Player represents a venue-level player record. +type Player struct { + ID string `json:"id"` + Name string `json:"name"` + Nickname *string `json:"nickname,omitempty"` + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + PhotoURL *string `json:"photo_url,omitempty"` + Notes *string `json:"notes,omitempty"` + CustomFields *json.RawMessage `json:"custom_fields,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// TournamentPlayer represents a player's state within a tournament. +type TournamentPlayer struct { + ID int64 `json:"id"` + TournamentID string `json:"tournament_id"` + PlayerID string `json:"player_id"` + Status string `json:"status"` + SeatTableID *int `json:"seat_table_id,omitempty"` + SeatPosition *int `json:"seat_position,omitempty"` + BuyInAt *int64 `json:"buy_in_at,omitempty"` + BustOutAt *int64 `json:"bust_out_at,omitempty"` + BustOutOrder *int `json:"bust_out_order,omitempty"` + FinishingPosition *int `json:"finishing_position,omitempty"` + CurrentChips int64 `json:"current_chips"` + Rebuys int `json:"rebuys"` + Addons int `json:"addons"` + Reentries int `json:"reentries"` + BountyValue int64 `json:"bounty_value"` + BountiesCollected int `json:"bounties_collected"` + PrizeAmount int64 `json:"prize_amount"` + PointsAwarded int64 `json:"points_awarded"` + HitmanPlayerID *string `json:"hitman_player_id,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// TournamentPlayerDetail combines player info with tournament-specific data. +type TournamentPlayerDetail struct { + PlayerID string `json:"player_id"` + PlayerName string `json:"player_name"` + PlayerNickname *string `json:"player_nickname,omitempty"` + Status string `json:"status"` + SeatTableID *int `json:"seat_table_id,omitempty"` + SeatPosition *int `json:"seat_position,omitempty"` + SeatTableName *string `json:"seat_table_name,omitempty"` + BuyInAt *int64 `json:"buy_in_at,omitempty"` + BustOutAt *int64 `json:"bust_out_at,omitempty"` + BustOutOrder *int `json:"bust_out_order,omitempty"` + FinishingPosition *int `json:"finishing_position,omitempty"` + CurrentChips int64 `json:"current_chips"` + Rebuys int `json:"rebuys"` + Addons int `json:"addons"` + Reentries int `json:"reentries"` + BountyValue int64 `json:"bounty_value"` + BountiesCollected int `json:"bounties_collected"` + PrizeAmount int64 `json:"prize_amount"` + PointsAwarded int64 `json:"points_awarded"` + HitmanPlayerID *string `json:"hitman_player_id,omitempty"` + HitmanPlayerName *string `json:"hitman_player_name,omitempty"` + TotalInvestment int64 `json:"total_investment"` + NetResult int64 `json:"net_result"` + + // Action history + Transactions []financial.Transaction `json:"transactions,omitempty"` +} + +// ImportResult holds the outcome of a CSV import. +type ImportResult struct { + Created int `json:"created"` + Duplicates int `json:"duplicates"` + Errors int `json:"errors"` + DuplicateDetails []DuplicateEntry `json:"duplicate_details,omitempty"` + ErrorDetails []string `json:"error_details,omitempty"` +} + +// DuplicateEntry describes a potential duplicate found during import. +type DuplicateEntry struct { + Row int `json:"row"` + ImportedName string `json:"imported_name"` + ExistingID string `json:"existing_id"` + ExistingName string `json:"existing_name"` +} + +// Service provides player management operations. +type Service struct { + db *sql.DB + trail *audit.Trail + financial *financial.Engine + hub *ws.Hub +} + +// NewService creates a new player service. +func NewService(db *sql.DB, trail *audit.Trail, fin *financial.Engine, hub *ws.Hub) *Service { + return &Service{ + db: db, + trail: trail, + financial: fin, + hub: hub, + } +} + +// ---------- Player CRUD ---------- + +// CreatePlayer creates a new player in the venue database. +func (s *Service) CreatePlayer(ctx context.Context, p Player) (*Player, error) { + p.ID = generateUUID() + now := time.Now().Unix() + p.CreatedAt = now + p.UpdatedAt = now + + _, err := s.db.ExecContext(ctx, + `INSERT INTO players (id, name, nickname, email, phone, photo_url, notes, custom_fields, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.ID, p.Name, p.Nickname, p.Email, p.Phone, p.PhotoURL, p.Notes, + nullableJSON(p.CustomFields), p.CreatedAt, p.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("player: create: %w", err) + } + + // Audit + s.recordAudit(ctx, nil, "player.create", "player", p.ID, nil, &p) + + return &p, nil +} + +// GetPlayer retrieves a player by ID. +func (s *Service) GetPlayer(ctx context.Context, id string) (*Player, error) { + p := &Player{} + var nickname, email, phone, photoURL, notes, customFields sql.NullString + + err := s.db.QueryRowContext(ctx, + `SELECT id, name, nickname, email, phone, photo_url, notes, custom_fields, created_at, updated_at + FROM players WHERE id = ?`, id, + ).Scan( + &p.ID, &p.Name, &nickname, &email, &phone, &photoURL, ¬es, &customFields, + &p.CreatedAt, &p.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, ErrPlayerNotFound + } + if err != nil { + return nil, fmt.Errorf("player: get: %w", err) + } + + p.Nickname = nullStringPtr(nickname) + p.Email = nullStringPtr(email) + p.Phone = nullStringPtr(phone) + p.PhotoURL = nullStringPtr(photoURL) + p.Notes = nullStringPtr(notes) + if customFields.Valid { + raw := json.RawMessage(customFields.String) + p.CustomFields = &raw + } + + return p, nil +} + +// UpdatePlayer updates a player's fields. +func (s *Service) UpdatePlayer(ctx context.Context, p Player) error { + p.UpdatedAt = time.Now().Unix() + + result, err := s.db.ExecContext(ctx, + `UPDATE players SET name = ?, nickname = ?, email = ?, phone = ?, photo_url = ?, + notes = ?, custom_fields = ?, updated_at = ? + WHERE id = ?`, + p.Name, p.Nickname, p.Email, p.Phone, p.PhotoURL, p.Notes, + nullableJSON(p.CustomFields), p.UpdatedAt, p.ID, + ) + if err != nil { + return fmt.Errorf("player: update: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return ErrPlayerNotFound + } + + s.recordAudit(ctx, nil, "player.update", "player", p.ID, nil, &p) + + return nil +} + +// SearchPlayers searches players using FTS5 prefix matching for typeahead. +func (s *Service) SearchPlayers(ctx context.Context, query string, limit int) ([]Player, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + // If query is empty, return most recently updated players + if strings.TrimSpace(query) == "" { + return s.ListPlayers(ctx, limit, 0) + } + + // Build FTS5 query with prefix matching + terms := strings.Fields(query) + var ftsTerms []string + for _, t := range terms { + // Escape special FTS5 characters and append * for prefix matching + escaped := strings.ReplaceAll(t, `"`, `""`) + ftsTerms = append(ftsTerms, `"`+escaped+`"*`) + } + ftsQuery := strings.Join(ftsTerms, " ") + + rows, err := s.db.QueryContext(ctx, + `SELECT p.id, p.name, p.nickname, p.email, p.phone, p.photo_url, p.notes, + p.custom_fields, p.created_at, p.updated_at + FROM players p + JOIN players_fts f ON p.rowid = f.rowid + WHERE players_fts MATCH ? + ORDER BY rank + LIMIT ?`, + ftsQuery, limit, + ) + if err != nil { + return nil, fmt.Errorf("player: search: %w", err) + } + defer rows.Close() + + return scanPlayers(rows) +} + +// ListPlayers returns a paginated list of players. +func (s *Service) ListPlayers(ctx context.Context, limit, offset int) ([]Player, error) { + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, nickname, email, phone, photo_url, notes, + custom_fields, created_at, updated_at + FROM players + ORDER BY updated_at DESC + LIMIT ? OFFSET ?`, + limit, offset, + ) + if err != nil { + return nil, fmt.Errorf("player: list: %w", err) + } + defer rows.Close() + + return scanPlayers(rows) +} + +// MergePlayers merges mergeID into keepID. Destructive, requires admin role. +func (s *Service) MergePlayers(ctx context.Context, keepID, mergeID string) error { + // Verify admin role + role := middleware.OperatorRoleFromCtx(ctx) + if role != middleware.RoleAdmin { + return fmt.Errorf("player: merge requires admin role") + } + + // Verify both players exist + keepPlayer, err := s.GetPlayer(ctx, keepID) + if err != nil { + return fmt.Errorf("player: keep player: %w", err) + } + mergePlayer, err := s.GetPlayer(ctx, mergeID) + if err != nil { + return fmt.Errorf("player: merge player: %w", err) + } + + // Move tournament_players from mergeID to keepID + _, err = s.db.ExecContext(ctx, + `UPDATE tournament_players SET player_id = ?, updated_at = unixepoch() + WHERE player_id = ?`, + keepID, mergeID, + ) + if err != nil { + return fmt.Errorf("player: merge tournament_players: %w", err) + } + + // Move transactions from mergeID to keepID + _, err = s.db.ExecContext(ctx, + `UPDATE transactions SET player_id = ? WHERE player_id = ?`, + keepID, mergeID, + ) + if err != nil { + return fmt.Errorf("player: merge transactions: %w", err) + } + + // Merge non-empty fields from mergePlayer into keepPlayer + if keepPlayer.Nickname == nil && mergePlayer.Nickname != nil { + keepPlayer.Nickname = mergePlayer.Nickname + } + if keepPlayer.Email == nil && mergePlayer.Email != nil { + keepPlayer.Email = mergePlayer.Email + } + if keepPlayer.Phone == nil && mergePlayer.Phone != nil { + keepPlayer.Phone = mergePlayer.Phone + } + if keepPlayer.PhotoURL == nil && mergePlayer.PhotoURL != nil { + keepPlayer.PhotoURL = mergePlayer.PhotoURL + } + if keepPlayer.Notes == nil && mergePlayer.Notes != nil { + keepPlayer.Notes = mergePlayer.Notes + } + + // Update keep player with merged fields + if err := s.UpdatePlayer(ctx, *keepPlayer); err != nil { + return fmt.Errorf("player: merge update: %w", err) + } + + // Delete merge player + _, err = s.db.ExecContext(ctx, `DELETE FROM players WHERE id = ?`, mergeID) + if err != nil { + return fmt.Errorf("player: merge delete: %w", err) + } + + // Audit + mergeDetails := map[string]interface{}{ + "keep_id": keepID, + "merge_id": mergeID, + "merged_name": mergePlayer.Name, + } + s.recordAudit(ctx, nil, "player.merge", "player", keepID, nil, mergeDetails) + + return nil +} + +// ImportFromCSV imports players from a CSV reader. +// Returns an ImportResult with counts of created, duplicate, and error records. +func (s *Service) ImportFromCSV(ctx context.Context, reader io.Reader) (*ImportResult, error) { + // Verify admin role + role := middleware.OperatorRoleFromCtx(ctx) + if role != middleware.RoleAdmin { + return nil, fmt.Errorf("player: import requires admin role") + } + + csvReader := csv.NewReader(reader) + csvReader.TrimLeadingSpace = true + + // Read header + header, err := csvReader.Read() + if err != nil { + return nil, fmt.Errorf("player: CSV read header: %w", err) + } + + // Check column count + if len(header) > MaxCSVColumns { + return nil, ErrCSVTooManyColumns + } + + // Map header to column indices + colMap := make(map[string]int) + for i, h := range header { + colMap[strings.ToLower(strings.TrimSpace(h))] = i + } + + if _, ok := colMap["name"]; !ok { + return nil, ErrCSVMissingNameColumn + } + + result := &ImportResult{} + rowNum := 0 + + for { + record, err := csvReader.Read() + if err == io.EOF { + break + } + if err != nil { + result.Errors++ + result.ErrorDetails = append(result.ErrorDetails, fmt.Sprintf("row %d: %v", rowNum+2, err)) + rowNum++ + continue + } + + rowNum++ + if rowNum > MaxCSVRows { + return nil, ErrCSVTooManyRows + } + + // Check field lengths + tooLong := false + for _, field := range record { + if utf8.RuneCountInString(field) > MaxCSVFieldLen { + tooLong = true + break + } + } + if tooLong { + return nil, ErrCSVFieldTooLong + } + + // Extract fields from record + name := getField(record, colMap, "name") + if name == "" { + result.Errors++ + result.ErrorDetails = append(result.ErrorDetails, fmt.Sprintf("row %d: name is required", rowNum+1)) + continue + } + + // Check for duplicate by exact name match + existing, _ := s.SearchPlayers(ctx, name, 5) + var exactMatch *Player + for i, p := range existing { + if strings.EqualFold(p.Name, name) { + exactMatch = &existing[i] + break + } + } + + if exactMatch != nil { + result.Duplicates++ + result.DuplicateDetails = append(result.DuplicateDetails, DuplicateEntry{ + Row: rowNum + 1, + ImportedName: name, + ExistingID: exactMatch.ID, + ExistingName: exactMatch.Name, + }) + continue + } + + // Create new player + p := Player{Name: name} + if v := getField(record, colMap, "nickname"); v != "" { + p.Nickname = &v + } + if v := getField(record, colMap, "email"); v != "" { + p.Email = &v + } + if v := getField(record, colMap, "phone"); v != "" { + p.Phone = &v + } + if v := getField(record, colMap, "notes"); v != "" { + p.Notes = &v + } + + if _, err := s.CreatePlayer(ctx, p); err != nil { + result.Errors++ + result.ErrorDetails = append(result.ErrorDetails, fmt.Sprintf("row %d: %v", rowNum+1, err)) + continue + } + result.Created++ + } + + // Audit + s.recordAudit(ctx, nil, "player.import", "players", "csv", nil, result) + + return result, nil +} + +// ---------- Tournament Player Operations ---------- + +// RegisterPlayer registers a player for a tournament. +func (s *Service) RegisterPlayer(ctx context.Context, tournamentID, playerID string) (*TournamentPlayer, error) { + // Check tournament exists and get max_players + var maxPlayers sql.NullInt64 + err := s.db.QueryRowContext(ctx, + `SELECT max_players FROM tournaments WHERE id = ?`, tournamentID, + ).Scan(&maxPlayers) + if err == sql.ErrNoRows { + return nil, ErrTournamentNotFound + } + if err != nil { + return nil, fmt.Errorf("player: check tournament: %w", err) + } + + // Check tournament isn't full + if maxPlayers.Valid { + var count int + err = s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ?`, tournamentID, + ).Scan(&count) + if err != nil { + return nil, fmt.Errorf("player: count players: %w", err) + } + if count >= int(maxPlayers.Int64) { + return nil, ErrTournamentFull + } + } + + // Create tournament_players record + now := time.Now().Unix() + result, err := s.db.ExecContext(ctx, + `INSERT INTO tournament_players (tournament_id, player_id, status, created_at, updated_at) + VALUES (?, ?, 'registered', ?, ?)`, + tournamentID, playerID, now, now, + ) + if err != nil { + return nil, fmt.Errorf("player: register: %w", err) + } + + id, _ := result.LastInsertId() + tp := &TournamentPlayer{ + ID: id, + TournamentID: tournamentID, + PlayerID: playerID, + Status: "registered", + CreatedAt: now, + UpdatedAt: now, + } + + s.recordAudit(ctx, &tournamentID, "player.register", "player", playerID, nil, tp) + s.broadcast(tournamentID, "player.registered", tp) + + return tp, nil +} + +// GetTournamentPlayers returns all players in a tournament with computed fields. +func (s *Service) GetTournamentPlayers(ctx context.Context, tournamentID string) ([]TournamentPlayerDetail, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT tp.player_id, p.name, p.nickname, tp.status, + tp.seat_table_id, tp.seat_position, t.name, + tp.buy_in_at, tp.bust_out_at, tp.bust_out_order, tp.finishing_position, + tp.current_chips, tp.rebuys, tp.addons, tp.reentries, + tp.bounty_value, tp.bounties_collected, tp.prize_amount, tp.points_awarded, + tp.hitman_player_id, hp.name + FROM tournament_players tp + JOIN players p ON p.id = tp.player_id + LEFT JOIN tables t ON t.id = tp.seat_table_id + LEFT JOIN players hp ON hp.id = tp.hitman_player_id + WHERE tp.tournament_id = ? + ORDER BY + CASE tp.status + WHEN 'active' THEN 1 + WHEN 'registered' THEN 2 + WHEN 'busted' THEN 3 + WHEN 'deal' THEN 4 + END, + p.name`, + tournamentID, + ) + if err != nil { + return nil, fmt.Errorf("player: list tournament players: %w", err) + } + defer rows.Close() + + var players []TournamentPlayerDetail + for rows.Next() { + var d TournamentPlayerDetail + var nickname, tableName, hitmanID, hitmanName sql.NullString + var tableID, seatPos sql.NullInt64 + var buyInAt, bustOutAt sql.NullInt64 + var bustOutOrder, finishingPos sql.NullInt64 + + if err := rows.Scan( + &d.PlayerID, &d.PlayerName, &nickname, &d.Status, + &tableID, &seatPos, &tableName, + &buyInAt, &bustOutAt, &bustOutOrder, &finishingPos, + &d.CurrentChips, &d.Rebuys, &d.Addons, &d.Reentries, + &d.BountyValue, &d.BountiesCollected, &d.PrizeAmount, &d.PointsAwarded, + &hitmanID, &hitmanName, + ); err != nil { + return nil, fmt.Errorf("player: scan tournament player: %w", err) + } + + d.PlayerNickname = nullStringPtr(nickname) + if tableID.Valid { + v := int(tableID.Int64) + d.SeatTableID = &v + } + if seatPos.Valid { + v := int(seatPos.Int64) + d.SeatPosition = &v + } + d.SeatTableName = nullStringPtr(tableName) + if buyInAt.Valid { + d.BuyInAt = &buyInAt.Int64 + } + if bustOutAt.Valid { + d.BustOutAt = &bustOutAt.Int64 + } + if bustOutOrder.Valid { + v := int(bustOutOrder.Int64) + d.BustOutOrder = &v + } + if finishingPos.Valid { + v := int(finishingPos.Int64) + d.FinishingPosition = &v + } + d.HitmanPlayerID = nullStringPtr(hitmanID) + d.HitmanPlayerName = nullStringPtr(hitmanName) + + // Compute total investment from non-undone transactions + d.TotalInvestment = s.computeInvestment(ctx, tournamentID, d.PlayerID) + d.NetResult = d.PrizeAmount - d.TotalInvestment + + players = append(players, d) + } + + return players, rows.Err() +} + +// GetTournamentPlayer returns a single player's full detail within a tournament. +func (s *Service) GetTournamentPlayer(ctx context.Context, tournamentID, playerID string) (*TournamentPlayerDetail, error) { + var d TournamentPlayerDetail + var nickname, tableName, hitmanID, hitmanName sql.NullString + var tableID, seatPos sql.NullInt64 + var buyInAt, bustOutAt sql.NullInt64 + var bustOutOrder, finishingPos sql.NullInt64 + + err := s.db.QueryRowContext(ctx, + `SELECT tp.player_id, p.name, p.nickname, tp.status, + tp.seat_table_id, tp.seat_position, t.name, + tp.buy_in_at, tp.bust_out_at, tp.bust_out_order, tp.finishing_position, + tp.current_chips, tp.rebuys, tp.addons, tp.reentries, + tp.bounty_value, tp.bounties_collected, tp.prize_amount, tp.points_awarded, + tp.hitman_player_id, hp.name + FROM tournament_players tp + JOIN players p ON p.id = tp.player_id + LEFT JOIN tables t ON t.id = tp.seat_table_id + LEFT JOIN players hp ON hp.id = tp.hitman_player_id + WHERE tp.tournament_id = ? AND tp.player_id = ?`, + tournamentID, playerID, + ).Scan( + &d.PlayerID, &d.PlayerName, &nickname, &d.Status, + &tableID, &seatPos, &tableName, + &buyInAt, &bustOutAt, &bustOutOrder, &finishingPos, + &d.CurrentChips, &d.Rebuys, &d.Addons, &d.Reentries, + &d.BountyValue, &d.BountiesCollected, &d.PrizeAmount, &d.PointsAwarded, + &hitmanID, &hitmanName, + ) + if err == sql.ErrNoRows { + return nil, ErrPlayerNotFound + } + if err != nil { + return nil, fmt.Errorf("player: get tournament player: %w", err) + } + + d.PlayerNickname = nullStringPtr(nickname) + if tableID.Valid { + v := int(tableID.Int64) + d.SeatTableID = &v + } + if seatPos.Valid { + v := int(seatPos.Int64) + d.SeatPosition = &v + } + d.SeatTableName = nullStringPtr(tableName) + if buyInAt.Valid { + d.BuyInAt = &buyInAt.Int64 + } + if bustOutAt.Valid { + d.BustOutAt = &bustOutAt.Int64 + } + if bustOutOrder.Valid { + v := int(bustOutOrder.Int64) + d.BustOutOrder = &v + } + if finishingPos.Valid { + v := int(finishingPos.Int64) + d.FinishingPosition = &v + } + d.HitmanPlayerID = nullStringPtr(hitmanID) + d.HitmanPlayerName = nullStringPtr(hitmanName) + + // Compute total investment + d.TotalInvestment = s.computeInvestment(ctx, tournamentID, playerID) + d.NetResult = d.PrizeAmount - d.TotalInvestment + + // Load transaction history + txs, err := s.financial.GetPlayerTransactions(ctx, tournamentID, playerID) + if err != nil { + log.Printf("player: load transactions for %s: %v", playerID, err) + } else { + d.Transactions = txs + } + + return &d, nil +} + +// BustPlayer busts a player out of the tournament. +func (s *Service) BustPlayer(ctx context.Context, tournamentID, playerID string, hitmanPlayerID *string) error { + // Check if PKO + var isPKO int + err := s.db.QueryRowContext(ctx, + `SELECT is_pko FROM tournaments WHERE id = ?`, tournamentID, + ).Scan(&isPKO) + if err == sql.ErrNoRows { + return ErrTournamentNotFound + } + if err != nil { + return fmt.Errorf("player: check tournament: %w", err) + } + + if isPKO != 0 && (hitmanPlayerID == nil || *hitmanPlayerID == "") { + return ErrHitmanRequired + } + + // Validate player is active + var currentStatus string + var prevTableID, prevSeatPos sql.NullInt64 + var prevChips int64 + err = s.db.QueryRowContext(ctx, + `SELECT status, seat_table_id, seat_position, current_chips + FROM tournament_players + WHERE tournament_id = ? AND player_id = ?`, + tournamentID, playerID, + ).Scan(¤tStatus, &prevTableID, &prevSeatPos, &prevChips) + if err == sql.ErrNoRows { + return ErrPlayerNotFound + } + if err != nil { + return fmt.Errorf("player: bust check: %w", err) + } + if currentStatus != "active" { + return ErrPlayerNotActive + } + + // Calculate bust_out_order + var bustOrder int + err = s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM tournament_players + WHERE tournament_id = ? AND status = 'busted'`, + tournamentID, + ).Scan(&bustOrder) + if err != nil { + return fmt.Errorf("player: count busted: %w", err) + } + bustOrder++ // 1-based + + now := time.Now().Unix() + + // Update player status + _, err = s.db.ExecContext(ctx, + `UPDATE tournament_players SET + status = 'busted', + bust_out_at = ?, + bust_out_order = ?, + current_chips = 0, + seat_table_id = NULL, + seat_position = NULL, + hitman_player_id = ?, + updated_at = ? + WHERE tournament_id = ? AND player_id = ?`, + now, bustOrder, hitmanPlayerID, now, tournamentID, playerID, + ) + if err != nil { + return fmt.Errorf("player: bust update: %w", err) + } + + // PKO bounty transfer + if hitmanPlayerID != nil && *hitmanPlayerID != "" { + if err := s.financial.ProcessBountyTransfer(ctx, tournamentID, playerID, *hitmanPlayerID); err != nil { + log.Printf("player: bounty transfer for bust %s: %v", playerID, err) + // Non-fatal for non-PKO tournaments that don't have bounties + } + } + + // Audit with previous state for undo + prevState := map[string]interface{}{ + "status": currentStatus, + "current_chips": prevChips, + } + if prevTableID.Valid { + prevState["seat_table_id"] = prevTableID.Int64 + } + if prevSeatPos.Valid { + prevState["seat_position"] = prevSeatPos.Int64 + } + newState := map[string]interface{}{ + "status": "busted", + "bust_out_at": now, + "bust_out_order": bustOrder, + "hitman": hitmanPlayerID, + } + s.recordAudit(ctx, &tournamentID, audit.ActionPlayerBust, "player", playerID, prevState, newState) + + // Broadcast + s.broadcast(tournamentID, "player.busted", map[string]interface{}{ + "player_id": playerID, + "bust_out_order": bustOrder, + "hitman_player_id": hitmanPlayerID, + }) + + // Check if tournament should auto-close (1 player remaining) + var activeCount int + err = s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM tournament_players + WHERE tournament_id = ? AND status = 'active'`, + tournamentID, + ).Scan(&activeCount) + if err == nil && activeCount <= 1 { + s.broadcast(tournamentID, "tournament.auto_close", map[string]interface{}{ + "remaining_players": activeCount, + }) + } + + return nil +} + +// UndoBust restores a busted player to active status with full re-ranking. +func (s *Service) UndoBust(ctx context.Context, tournamentID, playerID string) error { + // Get player's current bust state + var status string + var bustOutAt, bustOutOrder sql.NullInt64 + var hitmanPlayerID sql.NullString + err := s.db.QueryRowContext(ctx, + `SELECT status, bust_out_at, bust_out_order, hitman_player_id + FROM tournament_players + WHERE tournament_id = ? AND player_id = ?`, + tournamentID, playerID, + ).Scan(&status, &bustOutAt, &bustOutOrder, &hitmanPlayerID) + if err == sql.ErrNoRows { + return ErrPlayerNotFound + } + if err != nil { + return fmt.Errorf("player: undo bust check: %w", err) + } + if status != "busted" { + return ErrPlayerNotBusted + } + + // Restore player to active + now := time.Now().Unix() + _, err = s.db.ExecContext(ctx, + `UPDATE tournament_players SET + status = 'active', + bust_out_at = NULL, + bust_out_order = NULL, + finishing_position = NULL, + hitman_player_id = NULL, + updated_at = ? + WHERE tournament_id = ? AND player_id = ?`, + now, tournamentID, playerID, + ) + if err != nil { + return fmt.Errorf("player: undo bust update: %w", err) + } + + // Trigger full re-ranking + rankingEngine := NewRankingEngine(s.db, s.hub) + if err := rankingEngine.RecalculateAllRankings(ctx, tournamentID); err != nil { + log.Printf("player: re-ranking after undo bust: %v", err) + } + + // Audit + prevState := map[string]interface{}{ + "status": "busted", + "bust_out_order": bustOutOrder.Int64, + } + newState := map[string]interface{}{ + "status": "active", + } + s.recordAudit(ctx, &tournamentID, "undo."+audit.ActionPlayerBust, "player", playerID, prevState, newState) + + // Broadcast + s.broadcast(tournamentID, "player.undo_bust", map[string]interface{}{ + "player_id": playerID, + }) + + return nil +} + +// ---------- Helpers ---------- + +func (s *Service) computeInvestment(ctx context.Context, tournamentID, playerID string) int64 { + var total int64 + err := s.db.QueryRowContext(ctx, + `SELECT COALESCE(SUM(amount), 0) FROM transactions + WHERE tournament_id = ? AND player_id = ? + AND type IN ('buyin', 'rebuy', 'addon', 'reentry') + AND undone = 0`, + tournamentID, playerID, + ).Scan(&total) + if err != nil { + return 0 + } + return total +} + +func (s *Service) recordAudit(ctx context.Context, tournamentID *string, action, targetType, targetID string, prevState, newState interface{}) { + if s.trail == nil { + return + } + var prev, next json.RawMessage + if prevState != nil { + prev, _ = json.Marshal(prevState) + } + if newState != nil { + next, _ = json.Marshal(newState) + } + _, err := s.trail.Record(ctx, audit.AuditEntry{ + TournamentID: tournamentID, + Action: action, + TargetType: targetType, + TargetID: targetID, + PreviousState: prev, + NewState: next, + }) + if err != nil { + log.Printf("player: audit record failed: %v", err) + } +} + +func (s *Service) broadcast(tournamentID, eventType string, data interface{}) { + if s.hub == nil { + return + } + payload, err := json.Marshal(data) + if err != nil { + log.Printf("player: broadcast marshal error: %v", err) + return + } + s.hub.Broadcast(tournamentID, eventType, payload) +} + +func scanPlayers(rows *sql.Rows) ([]Player, error) { + var players []Player + for rows.Next() { + var p Player + var nickname, email, phone, photoURL, notes, customFields sql.NullString + + if err := rows.Scan( + &p.ID, &p.Name, &nickname, &email, &phone, &photoURL, ¬es, + &customFields, &p.CreatedAt, &p.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("player: scan: %w", err) + } + + p.Nickname = nullStringPtr(nickname) + p.Email = nullStringPtr(email) + p.Phone = nullStringPtr(phone) + p.PhotoURL = nullStringPtr(photoURL) + p.Notes = nullStringPtr(notes) + if customFields.Valid { + raw := json.RawMessage(customFields.String) + p.CustomFields = &raw + } + + players = append(players, p) + } + return players, rows.Err() +} + +func getField(record []string, colMap map[string]int, column string) string { + idx, ok := colMap[column] + if !ok || idx >= len(record) { + return "" + } + return strings.TrimSpace(record[idx]) +} + +func nullStringPtr(ns sql.NullString) *string { + if ns.Valid { + return &ns.String + } + return nil +} + +func nullableJSON(data *json.RawMessage) sql.NullString { + if data == nil || len(*data) == 0 || string(*data) == "null" { + return sql.NullString{} + } + return sql.NullString{String: string(*data), Valid: true} +} + +// generateUUID generates a v4 UUID. +func generateUUID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + b[6] = (b[6] & 0x0f) | 0x40 // Version 4 + b[8] = (b[8] & 0x3f) | 0x80 // Variant 1 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/internal/player/qrcode.go b/internal/player/qrcode.go index 5d9ce2c..5f17bbe 100644 --- a/internal/player/qrcode.go +++ b/internal/player/qrcode.go @@ -1 +1,31 @@ package player + +import ( + "context" + "fmt" + + qrcode "github.com/skip2/go-qrcode" +) + +// QRCodeSize is the default QR code image size in pixels. +const QRCodeSize = 256 + +// GenerateQRCode generates a QR code PNG image encoding the player UUID. +// The QR code encodes a URL in the format: felt://player/{uuid} +// This is intended for future PWA self-check-in via camera scan. +func (s *Service) GenerateQRCode(ctx context.Context, playerID string) ([]byte, error) { + // Verify player exists + _, err := s.GetPlayer(ctx, playerID) + if err != nil { + return nil, err + } + + // Generate QR code with player URL + url := fmt.Sprintf("felt://player/%s", playerID) + png, err := qrcode.Encode(url, qrcode.Medium, QRCodeSize) + if err != nil { + return nil, fmt.Errorf("player: generate QR code: %w", err) + } + + return png, nil +} diff --git a/internal/player/ranking.go b/internal/player/ranking.go index 5d9ce2c..ce3349f 100644 --- a/internal/player/ranking.go +++ b/internal/player/ranking.go @@ -1 +1,230 @@ package player + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + + "github.com/felt-app/felt/internal/server/ws" +) + +// PlayerRanking represents a player's current ranking position. +type PlayerRanking struct { + Position int `json:"position"` + PlayerID string `json:"player_id"` + PlayerName string `json:"player_name"` + Status string `json:"status"` // active, busted, deal + ChipCount int64 `json:"chip_count"` + BustOutTime *int64 `json:"bust_out_time,omitempty"` + HitmanName *string `json:"hitman_name,omitempty"` + BountiesCollected int `json:"bounties_collected"` + Prize int64 `json:"prize"` + Points int64 `json:"points"` +} + +// RankingEngine derives rankings from the ordered bust-out list. +// Rankings are never stored independently -- they are always computed. +type RankingEngine struct { + db *sql.DB + hub *ws.Hub +} + +// NewRankingEngine creates a new ranking engine. +func NewRankingEngine(db *sql.DB, hub *ws.Hub) *RankingEngine { + return &RankingEngine{db: db, hub: hub} +} + +// CalculateRankings computes current rankings for a tournament. +// Active players share the same "current position" = remaining player count. +// Busted players are ranked in reverse bust order (last busted = highest, first busted = last). +func (r *RankingEngine) CalculateRankings(ctx context.Context, tournamentID string) ([]PlayerRanking, error) { + // Count total unique entries (for finishing_position calculation) + var totalEntries int + err := r.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ?`, + tournamentID, + ).Scan(&totalEntries) + if err != nil { + return nil, fmt.Errorf("ranking: count entries: %w", err) + } + + // Load all players with ranking-relevant data + rows, err := r.db.QueryContext(ctx, + `SELECT tp.player_id, p.name, tp.status, tp.current_chips, + tp.bust_out_at, tp.bust_out_order, tp.finishing_position, + tp.bounties_collected, tp.prize_amount, tp.points_awarded, + hp.name + FROM tournament_players tp + JOIN players p ON p.id = tp.player_id + LEFT JOIN players hp ON hp.id = tp.hitman_player_id + WHERE tp.tournament_id = ? + ORDER BY + CASE tp.status + WHEN 'active' THEN 1 + WHEN 'deal' THEN 2 + WHEN 'busted' THEN 3 + WHEN 'registered' THEN 4 + END, + -- Active players by chip count desc, then name + CASE WHEN tp.status = 'active' THEN -tp.current_chips ELSE 0 END, + -- Busted players by bust order desc (most recent first) + CASE WHEN tp.status = 'busted' THEN -COALESCE(tp.bust_out_order, 0) ELSE 0 END, + p.name`, + tournamentID, + ) + if err != nil { + return nil, fmt.Errorf("ranking: query players: %w", err) + } + defer rows.Close() + + // Count active players for position calculation + var activeCount int + err = r.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM tournament_players + WHERE tournament_id = ? AND status = 'active'`, + tournamentID, + ).Scan(&activeCount) + if err != nil { + return nil, fmt.Errorf("ranking: count active: %w", err) + } + + var rankings []PlayerRanking + pos := 1 + for rows.Next() { + var pr PlayerRanking + var bustOutAt, bustOutOrder, finishingPos sql.NullInt64 + var hitmanName sql.NullString + + if err := rows.Scan( + &pr.PlayerID, &pr.PlayerName, &pr.Status, &pr.ChipCount, + &bustOutAt, &bustOutOrder, &finishingPos, + &pr.BountiesCollected, &pr.Prize, &pr.Points, + &hitmanName, + ); err != nil { + return nil, fmt.Errorf("ranking: scan: %w", err) + } + + if bustOutAt.Valid { + pr.BustOutTime = &bustOutAt.Int64 + } + if hitmanName.Valid { + pr.HitmanName = &hitmanName.String + } + + switch pr.Status { + case "active": + // All active players share the same position + pr.Position = 1 + case "deal": + // Deal players get their manually assigned finishing position + if finishingPos.Valid { + pr.Position = int(finishingPos.Int64) + } else { + pr.Position = pos + } + case "busted": + // Busted: position = totalEntries - bustOutOrder + 1 + if bustOutOrder.Valid { + pr.Position = totalEntries - int(bustOutOrder.Int64) + 1 + } else { + pr.Position = pos + } + default: + // Registered (not yet playing) + pr.Position = 0 + } + + rankings = append(rankings, pr) + pos++ + } + + return rankings, rows.Err() +} + +// RecalculateAllRankings recalculates ALL bust_out_order values from timestamps. +// Called after any undo operation to ensure consistency. +func (r *RankingEngine) RecalculateAllRankings(ctx context.Context, tournamentID string) error { + // Load all busted players ordered by bust_out_at timestamp + rows, err := r.db.QueryContext(ctx, + `SELECT player_id, bust_out_at + FROM tournament_players + WHERE tournament_id = ? AND status = 'busted' AND bust_out_at IS NOT NULL + ORDER BY bust_out_at ASC`, + tournamentID, + ) + if err != nil { + return fmt.Errorf("ranking: query busted: %w", err) + } + defer rows.Close() + + type bustedPlayer struct { + PlayerID string + BustOutAt int64 + } + var busted []bustedPlayer + for rows.Next() { + var bp bustedPlayer + if err := rows.Scan(&bp.PlayerID, &bp.BustOutAt); err != nil { + return fmt.Errorf("ranking: scan busted: %w", err) + } + busted = append(busted, bp) + } + if err := rows.Err(); err != nil { + return err + } + + // Count total entries for finishing_position calculation + var totalEntries int + err = r.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ?`, + tournamentID, + ).Scan(&totalEntries) + if err != nil { + return fmt.Errorf("ranking: count entries: %w", err) + } + + // Update each busted player's bust_out_order and finishing_position + for i, bp := range busted { + bustOutOrder := i + 1 // 1-based, chronological + finishingPosition := totalEntries - bustOutOrder + 1 + + _, err := r.db.ExecContext(ctx, + `UPDATE tournament_players + SET bust_out_order = ?, finishing_position = ?, updated_at = unixepoch() + WHERE tournament_id = ? AND player_id = ?`, + bustOutOrder, finishingPosition, tournamentID, bp.PlayerID, + ) + if err != nil { + return fmt.Errorf("ranking: update bust order for %s: %w", bp.PlayerID, err) + } + } + + // Broadcast updated rankings + rankings, err := r.CalculateRankings(ctx, tournamentID) + if err != nil { + log.Printf("ranking: broadcast recalculated: %v", err) + } else { + r.broadcast(tournamentID, "rankings.updated", rankings) + } + + return nil +} + +// GetRankings returns current rankings for display. +func (r *RankingEngine) GetRankings(ctx context.Context, tournamentID string) ([]PlayerRanking, error) { + return r.CalculateRankings(ctx, tournamentID) +} + +func (r *RankingEngine) broadcast(tournamentID, eventType string, data interface{}) { + if r.hub == nil { + return + } + payload, err := json.Marshal(data) + if err != nil { + log.Printf("ranking: broadcast marshal error: %v", err) + return + } + r.hub.Broadcast(tournamentID, eventType, payload) +}