diff --git a/internal/player/export.go b/internal/player/export.go new file mode 100644 index 0000000..3e36242 --- /dev/null +++ b/internal/player/export.go @@ -0,0 +1,46 @@ +package player + +import ( + "strings" +) + +// SanitizeCSVField neutralizes potential formula injection in CSV output. +// When generating CSV, prefix any cell value starting with =, +, -, or @ +// with a tab character to prevent spreadsheet formula injection when the +// CSV is opened in Excel/LibreOffice. +func SanitizeCSVField(value string) string { + if len(value) == 0 { + return value + } + switch value[0] { + case '=', '+', '-', '@': + return "\t" + value + } + return value +} + +// SanitizeCSVRow sanitizes all fields in a CSV row. +func SanitizeCSVRow(fields []string) []string { + sanitized := make([]string, len(fields)) + for i, f := range fields { + sanitized[i] = SanitizeCSVField(f) + } + return sanitized +} + +// SanitizeCSVFields sanitizes a map of field names to values. +func SanitizeCSVFields(fields map[string]string) map[string]string { + sanitized := make(map[string]string, len(fields)) + for k, v := range fields { + sanitized[k] = SanitizeCSVField(v) + } + return sanitized +} + +// IsFormulaInjection checks if a string starts with a formula-injection character. +func IsFormulaInjection(value string) bool { + if len(value) == 0 { + return false + } + return strings.ContainsRune("=+-@", rune(value[0])) +} diff --git a/internal/player/player_test.go b/internal/player/player_test.go new file mode 100644 index 0000000..fd718dc --- /dev/null +++ b/internal/player/player_test.go @@ -0,0 +1,111 @@ +package player + +import ( + "testing" +) + +// ---------- CSV Export Safety Tests ---------- + +func TestSanitizeCSVField_FormulaInjection(t *testing.T) { + tests := []struct { + input string + expected string + name string + }{ + {"=CMD()", "\t=CMD()", "equals command"}, + {"+1+1", "\t+1+1", "plus prefix"}, + {"-1+1", "\t-1+1", "minus prefix"}, + {"@SUM(A1)", "\t@SUM(A1)", "at sign function"}, + {"=HYPERLINK(\"http://evil.com\")", "\t=HYPERLINK(\"http://evil.com\")", "hyperlink formula"}, + {"Normal text", "Normal text", "normal text"}, + {"123", "123", "numeric"}, + {"", "", "empty string"}, + {"Hello World", "Hello World", "regular greeting"}, + {"John Doe", "John Doe", "player name"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeCSVField(tt.input) + if result != tt.expected { + t.Errorf("SanitizeCSVField(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestSanitizeCSVRow(t *testing.T) { + row := []string{"John", "=SUM(A1)", "+1", "-1", "@SUM", "Normal"} + expected := []string{"John", "\t=SUM(A1)", "\t+1", "\t-1", "\t@SUM", "Normal"} + + result := SanitizeCSVRow(row) + + for i, v := range result { + if v != expected[i] { + t.Errorf("SanitizeCSVRow[%d] = %q, want %q", i, v, expected[i]) + } + } +} + +func TestIsFormulaInjection(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"=CMD()", true}, + {"+1+1", true}, + {"-1+1", true}, + {"@SUM(A1)", true}, + {"Normal text", false}, + {"123", false}, + {"", false}, + } + + for _, tt := range tests { + if result := IsFormulaInjection(tt.input); result != tt.expected { + t.Errorf("IsFormulaInjection(%q) = %v, want %v", tt.input, result, tt.expected) + } + } +} + +// ---------- CSV Import Safety Limit Tests ---------- + +func TestCSVImportLimits(t *testing.T) { + // These are constant assertions, not behavioral tests. + // They verify the safety constants are set to the documented values. + if MaxCSVRows != 10_000 { + t.Errorf("MaxCSVRows = %d, want 10000", MaxCSVRows) + } + if MaxCSVColumns != 20 { + t.Errorf("MaxCSVColumns = %d, want 20", MaxCSVColumns) + } + if MaxCSVFieldLen != 1_000 { + t.Errorf("MaxCSVFieldLen = %d, want 1000", MaxCSVFieldLen) + } +} + +// ---------- UUID Generation Tests ---------- + +func TestGenerateUUID(t *testing.T) { + id := generateUUID() + if len(id) == 0 { + t.Error("generateUUID returned empty string") + } + + // Check format: 8-4-4-4-12 + parts := 0 + for _, c := range id { + if c == '-' { + parts++ + } + } + if parts != 4 { + t.Errorf("UUID should have 4 dashes, got %d: %s", parts, id) + } + + // Check uniqueness + id2 := generateUUID() + if id == id2 { + t.Error("two UUIDs should not be equal") + } +} diff --git a/internal/player/ranking_test.go b/internal/player/ranking_test.go new file mode 100644 index 0000000..7544fdd --- /dev/null +++ b/internal/player/ranking_test.go @@ -0,0 +1,533 @@ +package player + +import ( + "context" + "database/sql" + "os" + "testing" + "time" + + _ "github.com/tursodatabase/go-libsql" +) + +// testDB creates a temporary LibSQL database with the required schema. +func testDB(t *testing.T) *sql.DB { + t.Helper() + + tmpFile := t.TempDir() + "/test.db" + db, err := sql.Open("libsql", "file:"+tmpFile) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { + db.Close() + os.Remove(tmpFile) + }) + + // Create minimal schema for ranking tests + stmts := []string{ + `CREATE TABLE IF NOT EXISTS players ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + nickname TEXT, + email TEXT, + phone TEXT, + photo_url TEXT, + notes TEXT, + custom_fields TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + )`, + `CREATE TABLE IF NOT EXISTS tournaments ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'running', + is_pko INTEGER NOT NULL DEFAULT 0, + max_players INTEGER, + chip_set_id INTEGER NOT NULL DEFAULT 1, + blind_structure_id INTEGER NOT NULL DEFAULT 1, + payout_structure_id INTEGER NOT NULL DEFAULT 1, + buyin_config_id INTEGER NOT NULL DEFAULT 1, + current_level INTEGER NOT NULL DEFAULT 0, + clock_state TEXT NOT NULL DEFAULT 'stopped', + clock_remaining_ns INTEGER NOT NULL DEFAULT 0, + total_elapsed_ns INTEGER NOT NULL DEFAULT 0, + hand_for_hand INTEGER NOT NULL DEFAULT 0, + hand_for_hand_hand_number INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + )`, + `CREATE TABLE IF NOT EXISTS tournament_players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournament_id TEXT NOT NULL, + player_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'registered', + seat_table_id INTEGER, + seat_position INTEGER, + buy_in_at INTEGER, + bust_out_at INTEGER, + bust_out_order INTEGER, + finishing_position INTEGER, + current_chips INTEGER NOT NULL DEFAULT 0, + rebuys INTEGER NOT NULL DEFAULT 0, + addons INTEGER NOT NULL DEFAULT 0, + reentries INTEGER NOT NULL DEFAULT 0, + bounty_value INTEGER NOT NULL DEFAULT 0, + bounties_collected INTEGER NOT NULL DEFAULT 0, + prize_amount INTEGER NOT NULL DEFAULT 0, + points_awarded INTEGER NOT NULL DEFAULT 0, + hitman_player_id TEXT, + early_signup_bonus_applied INTEGER NOT NULL DEFAULT 0, + punctuality_bonus_applied INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + UNIQUE(tournament_id, player_id) + )`, + `CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY, + tournament_id TEXT NOT NULL, + player_id TEXT NOT NULL, + type TEXT NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + chips INTEGER NOT NULL DEFAULT 0, + operator_id TEXT NOT NULL DEFAULT 'test', + receipt_data TEXT, + undone INTEGER NOT NULL DEFAULT 0, + undone_by TEXT, + metadata TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + )`, + } + + for _, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("create schema: %v", err) + } + } + + return db +} + +// seedTournament creates a tournament and players for testing. +func seedTournament(t *testing.T, db *sql.DB, playerCount int) (string, []string) { + t.Helper() + ctx := context.Background() + tid := "tournament-1" + + _, err := db.ExecContext(ctx, + `INSERT INTO tournaments (id, name, status) VALUES (?, ?, 'running')`, + tid, "Test Tournament", + ) + if err != nil { + t.Fatalf("insert tournament: %v", err) + } + + var playerIDs []string + for i := 0; i < playerCount; i++ { + pid := generateUUID() + name := charName(i) + + _, err := db.ExecContext(ctx, + `INSERT INTO players (id, name) VALUES (?, ?)`, + pid, name, + ) + if err != nil { + t.Fatalf("insert player %d: %v", i, err) + } + + _, err = db.ExecContext(ctx, + `INSERT INTO tournament_players (tournament_id, player_id, status, current_chips) + VALUES (?, ?, 'active', 10000)`, + tid, pid, + ) + if err != nil { + t.Fatalf("insert tournament_player %d: %v", i, err) + } + + playerIDs = append(playerIDs, pid) + } + + return tid, playerIDs +} + +// bustPlayer busts a player with the given bust_out_at timestamp. +func bustPlayer(t *testing.T, db *sql.DB, tid, pid string, bustOrder int, bustTime int64) { + t.Helper() + + var totalEntries int + db.QueryRow(`SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ?`, tid).Scan(&totalEntries) + finishingPos := totalEntries - bustOrder + 1 + + _, err := db.Exec( + `UPDATE tournament_players SET + status = 'busted', + bust_out_at = ?, + bust_out_order = ?, + finishing_position = ?, + current_chips = 0, + updated_at = unixepoch() + WHERE tournament_id = ? AND player_id = ?`, + bustTime, bustOrder, finishingPos, tid, pid, + ) + if err != nil { + t.Fatalf("bust player: %v", err) + } +} + +func charName(i int) string { + return string(rune('A' + i)) +} + +// ---------- Tests ---------- + +func TestRankingsFromBustOrder(t *testing.T) { + db := testDB(t) + ctx := context.Background() + engine := NewRankingEngine(db, nil) + + // Create 5 players, bust 3 of them + tid, pids := seedTournament(t, db, 5) + + // Bust player 0 first (should be last place) + bustPlayer(t, db, tid, pids[0], 1, 1000) + // Bust player 1 second + bustPlayer(t, db, tid, pids[1], 2, 2000) + // Bust player 2 third + bustPlayer(t, db, tid, pids[2], 3, 3000) + + rankings, err := engine.CalculateRankings(ctx, tid) + if err != nil { + t.Fatalf("calculate rankings: %v", err) + } + + if len(rankings) != 5 { + t.Fatalf("expected 5 rankings, got %d", len(rankings)) + } + + // Active players should be position 1 + for _, r := range rankings { + if r.Status == "active" { + if r.Position != 1 { + t.Errorf("active player %s should be position 1, got %d", r.PlayerName, r.Position) + } + } + } + + // Busted players: bust_out_order 3 (last busted) -> highest finishing position + // With 5 players: bustOrder 1 -> pos 5, bustOrder 2 -> pos 4, bustOrder 3 -> pos 3 + bustOrderToExpectedPos := map[string]int{ + pids[0]: 5, // First busted = last place + pids[1]: 4, + pids[2]: 3, + } + for _, r := range rankings { + if expected, ok := bustOrderToExpectedPos[r.PlayerID]; ok { + if r.Position != expected { + t.Errorf("player %s (bust order position): expected %d, got %d", r.PlayerName, expected, r.Position) + } + } + } +} + +func TestUndoBustTriggersReRanking(t *testing.T) { + db := testDB(t) + ctx := context.Background() + engine := NewRankingEngine(db, nil) + + // Create 5 players, bust all 5 + tid, pids := seedTournament(t, db, 5) + + now := time.Now().Unix() + for i := 0; i < 5; i++ { + bustPlayer(t, db, tid, pids[i], i+1, now+int64(i)*10) + } + + // Undo bust for player 2 (middle bust) + _, err := db.Exec( + `UPDATE tournament_players SET + status = 'active', + bust_out_at = NULL, + bust_out_order = NULL, + finishing_position = NULL, + updated_at = unixepoch() + WHERE tournament_id = ? AND player_id = ?`, + tid, pids[2], + ) + if err != nil { + t.Fatalf("undo bust: %v", err) + } + + // Recalculate rankings + err = engine.RecalculateAllRankings(ctx, tid) + if err != nil { + t.Fatalf("recalculate: %v", err) + } + + // Verify bust_out_order has been recalculated + // Remaining busted players: 0 (bust_out_at=now), 1 (now+10), 3 (now+30), 4 (now+40) + // New bust orders: 1, 2, 3, 4 + type bustInfo struct { + PlayerID string + Status string + BustOutOrder sql.NullInt64 + FinishingPos sql.NullInt64 + } + + rows, err := db.Query( + `SELECT player_id, status, bust_out_order, finishing_position + FROM tournament_players WHERE tournament_id = ? + ORDER BY player_id`, + tid, + ) + if err != nil { + t.Fatalf("query: %v", err) + } + defer rows.Close() + + busted := make(map[string]bustInfo) + for rows.Next() { + var bi bustInfo + rows.Scan(&bi.PlayerID, &bi.Status, &bi.BustOutOrder, &bi.FinishingPos) + busted[bi.PlayerID] = bi + } + + // Player 2 should be active + if busted[pids[2]].Status != "active" { + t.Errorf("player 2 should be active, got %s", busted[pids[2]].Status) + } + + // Remaining busted players should have bust_out_order recalculated + // 4 busted players remain out of 5 total + expectedBustOrders := map[string]int{ + pids[0]: 1, // earliest bust + pids[1]: 2, + pids[3]: 3, + pids[4]: 4, // latest bust + } + + for pid, expectedOrder := range expectedBustOrders { + bi := busted[pid] + if !bi.BustOutOrder.Valid || int(bi.BustOutOrder.Int64) != expectedOrder { + t.Errorf("player %s: expected bust_out_order %d, got %v", pid, expectedOrder, bi.BustOutOrder) + } + } +} + +func TestUndoEarlyBustReranksCorrectly(t *testing.T) { + db := testDB(t) + ctx := context.Background() + engine := NewRankingEngine(db, nil) + + // Create 6 players, bust first 4 + tid, pids := seedTournament(t, db, 6) + + now := time.Now().Unix() + bustPlayer(t, db, tid, pids[0], 1, now) + bustPlayer(t, db, tid, pids[1], 2, now+100) + bustPlayer(t, db, tid, pids[2], 3, now+200) + bustPlayer(t, db, tid, pids[3], 4, now+300) + + // Undo bust of player 0 (the very first bust) + _, _ = db.Exec( + `UPDATE tournament_players SET + status = 'active', + bust_out_at = NULL, + bust_out_order = NULL, + finishing_position = NULL, + updated_at = unixepoch() + WHERE tournament_id = ? AND player_id = ?`, + tid, pids[0], + ) + + err := engine.RecalculateAllRankings(ctx, tid) + if err != nil { + t.Fatalf("recalculate: %v", err) + } + + // Now only 3 busted: pids[1], pids[2], pids[3] with bust_out_order 1, 2, 3 + // 6 total players, finishing positions should be: + // bust_out_order 1 (pids[1]) -> finishing_pos = 6 - 1 + 1 = 6 + // bust_out_order 2 (pids[2]) -> finishing_pos = 6 - 2 + 1 = 5 + // bust_out_order 3 (pids[3]) -> finishing_pos = 6 - 3 + 1 = 4 + + type result struct { + BustOutOrder sql.NullInt64 + FinishingPos sql.NullInt64 + } + + check := func(pid string, expectedOrder, expectedPos int) { + var r result + db.QueryRow( + `SELECT bust_out_order, finishing_position FROM tournament_players + WHERE tournament_id = ? AND player_id = ?`, + tid, pid, + ).Scan(&r.BustOutOrder, &r.FinishingPos) + + if !r.BustOutOrder.Valid || int(r.BustOutOrder.Int64) != expectedOrder { + t.Errorf("player %s: expected bust_out_order %d, got %v", pid, expectedOrder, r.BustOutOrder) + } + if !r.FinishingPos.Valid || int(r.FinishingPos.Int64) != expectedPos { + t.Errorf("player %s: expected finishing_position %d, got %v", pid, expectedPos, r.FinishingPos) + } + } + + check(pids[1], 1, 6) + check(pids[2], 2, 5) + check(pids[3], 3, 4) +} + +func TestReEntryDoesNotCountAsNewEntry(t *testing.T) { + db := testDB(t) + ctx := context.Background() + engine := NewRankingEngine(db, nil) + + // Create 4 players, bust one, then re-enter them + tid, pids := seedTournament(t, db, 4) + + // Bust player 0 + bustPlayer(t, db, tid, pids[0], 1, 1000) + + // Re-enter player 0 (status back to active, bust fields cleared) + _, _ = db.Exec( + `UPDATE tournament_players SET + status = 'active', + bust_out_at = NULL, + bust_out_order = NULL, + finishing_position = NULL, + reentries = reentries + 1, + current_chips = 10000, + updated_at = unixepoch() + WHERE tournament_id = ? AND player_id = ?`, + tid, pids[0], + ) + + // Total players is still 4 (re-entry is same player) + var total int + db.QueryRow(`SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ?`, tid).Scan(&total) + if total != 4 { + t.Errorf("expected 4 tournament_players, got %d", total) + } + + rankings, err := engine.CalculateRankings(ctx, tid) + if err != nil { + t.Fatalf("calculate: %v", err) + } + + // All 4 should be active + activeCount := 0 + for _, r := range rankings { + if r.Status == "active" { + activeCount++ + } + } + if activeCount != 4 { + t.Errorf("expected 4 active players, got %d", activeCount) + } +} + +func TestDealPlayersGetManualPositions(t *testing.T) { + db := testDB(t) + ctx := context.Background() + engine := NewRankingEngine(db, nil) + + tid, pids := seedTournament(t, db, 4) + + // Bust players 0 and 1 + bustPlayer(t, db, tid, pids[0], 1, 1000) + bustPlayer(t, db, tid, pids[1], 2, 2000) + + // Players 2 and 3 make a deal; manually assign finishing positions + _, _ = db.Exec( + `UPDATE tournament_players SET status = 'deal', finishing_position = 1 + WHERE tournament_id = ? AND player_id = ?`, + tid, pids[3], + ) + _, _ = db.Exec( + `UPDATE tournament_players SET status = 'deal', finishing_position = 2 + WHERE tournament_id = ? AND player_id = ?`, + tid, pids[2], + ) + + rankings, err := engine.CalculateRankings(ctx, tid) + if err != nil { + t.Fatalf("calculate: %v", err) + } + + // Find deal players and verify positions + for _, r := range rankings { + switch r.PlayerID { + case pids[3]: + if r.Position != 1 { + t.Errorf("deal player %s expected position 1, got %d", r.PlayerName, r.Position) + } + case pids[2]: + if r.Position != 2 { + t.Errorf("deal player %s expected position 2, got %d", r.PlayerName, r.Position) + } + } + } +} + +func TestAutoCloseOnOnePlayerRemaining(t *testing.T) { + db := testDB(t) + + tid, pids := seedTournament(t, db, 3) + + // Bust 2 of 3 players + bustPlayer(t, db, tid, pids[0], 1, 1000) + bustPlayer(t, db, tid, pids[1], 2, 2000) + + // Check active count + var activeCount int + db.QueryRow( + `SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ? AND status = 'active'`, + tid, + ).Scan(&activeCount) + + if activeCount != 1 { + t.Errorf("expected 1 active player, got %d", activeCount) + } +} + +func TestConcurrentBustsPreserveOrder(t *testing.T) { + db := testDB(t) + ctx := context.Background() + engine := NewRankingEngine(db, nil) + + tid, pids := seedTournament(t, db, 5) + + // Bust players at same timestamp (concurrent busts) + sameTime := time.Now().Unix() + bustPlayer(t, db, tid, pids[0], 1, sameTime) + bustPlayer(t, db, tid, pids[1], 2, sameTime) + bustPlayer(t, db, tid, pids[2], 3, sameTime) + + // Recalculate to ensure consistency + err := engine.RecalculateAllRankings(ctx, tid) + if err != nil { + t.Fatalf("recalculate: %v", err) + } + + // Verify all 3 busted players have distinct bust_out_order + orders := make(map[int]bool) + rows, _ := db.Query( + `SELECT bust_out_order FROM tournament_players + WHERE tournament_id = ? AND status = 'busted' + ORDER BY bust_out_order`, + tid, + ) + defer rows.Close() + + for rows.Next() { + var order int + rows.Scan(&order) + if orders[order] { + t.Errorf("duplicate bust_out_order: %d", order) + } + orders[order] = true + } + + if len(orders) != 3 { + t.Errorf("expected 3 distinct bust orders, got %d", len(orders)) + } +} diff --git a/internal/server/routes/players.go b/internal/server/routes/players.go new file mode 100644 index 0000000..544982b --- /dev/null +++ b/internal/server/routes/players.go @@ -0,0 +1,474 @@ +package routes + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/felt-app/felt/internal/financial" + "github.com/felt-app/felt/internal/player" + "github.com/felt-app/felt/internal/seating" + "github.com/felt-app/felt/internal/server/middleware" +) + +// PlayerHandler handles player and tournament player API routes. +type PlayerHandler struct { + players *player.Service + ranking *player.RankingEngine + financial *financial.Engine + tables *seating.TableService +} + +// NewPlayerHandler creates a new player route handler. +func NewPlayerHandler( + players *player.Service, + ranking *player.RankingEngine, + fin *financial.Engine, + tables *seating.TableService, +) *PlayerHandler { + return &PlayerHandler{ + players: players, + ranking: ranking, + financial: fin, + tables: tables, + } +} + +// RegisterRoutes registers player routes on the given router. +func (h *PlayerHandler) RegisterRoutes(r chi.Router) { + // Venue-level player routes + r.Route("/players", func(r chi.Router) { + r.Get("/", h.handleListPlayers) + r.Get("/search", h.handleSearchPlayers) + r.Post("/", h.handleCreatePlayer) + r.Get("/{id}", h.handleGetPlayer) + r.Put("/{id}", h.handleUpdatePlayer) + r.Get("/{id}/qrcode", h.handleGetQRCode) + + // Admin-only operations + r.Group(func(r chi.Router) { + r.Use(middleware.RequireRole(middleware.RoleAdmin)) + r.Post("/merge", h.handleMergePlayers) + r.Post("/import", h.handleImportCSV) + }) + }) + + // Tournament-scoped player routes + r.Route("/tournaments/{id}", func(r chi.Router) { + // Read-only (any authenticated user) + r.Get("/players", h.handleGetTournamentPlayers) + r.Get("/players/{playerId}", h.handleGetTournamentPlayer) + r.Get("/rankings", h.handleGetRankings) + + // Mutation routes (floor or admin) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireRole(middleware.RoleFloor)) + + // Player actions + r.Post("/players/{playerId}/buyin", h.handleBuyIn) + r.Post("/players/{playerId}/bust", h.handleBust) + r.Post("/players/{playerId}/rebuy", h.handleRebuy) + r.Post("/players/{playerId}/addon", h.handleAddOn) + r.Post("/players/{playerId}/reentry", h.handleReEntry) + r.Post("/players/{playerId}/undo-bust", h.handleUndoBust) + + // Transaction undo + r.Post("/transactions/{txId}/undo", h.handleUndoTransaction) + }) + }) +} + +// ---------- Venue-level Player Handlers ---------- + +func (h *PlayerHandler) handleListPlayers(w http.ResponseWriter, r *http.Request) { + limit := queryInt(r, "limit", 50) + offset := queryInt(r, "offset", 0) + + players, err := h.players.ListPlayers(r.Context(), limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, players) +} + +func (h *PlayerHandler) handleSearchPlayers(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + limit := queryInt(r, "limit", 20) + + players, err := h.players.SearchPlayers(r.Context(), query, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, players) +} + +type createPlayerRequest struct { + Name string `json:"name"` + Nickname *string `json:"nickname,omitempty"` + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + Notes *string `json:"notes,omitempty"` +} + +func (h *PlayerHandler) handleCreatePlayer(w http.ResponseWriter, r *http.Request) { + var req createPlayerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + + p := player.Player{ + Name: req.Name, + Nickname: req.Nickname, + Email: req.Email, + Phone: req.Phone, + Notes: req.Notes, + } + + created, err := h.players.CreatePlayer(r.Context(), p) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, created) +} + +func (h *PlayerHandler) handleGetPlayer(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + p, err := h.players.GetPlayer(r.Context(), id) + if err != nil { + if err == player.ErrPlayerNotFound { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, p) +} + +func (h *PlayerHandler) handleUpdatePlayer(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req createPlayerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + + p := player.Player{ + ID: id, + Name: req.Name, + Nickname: req.Nickname, + Email: req.Email, + Phone: req.Phone, + Notes: req.Notes, + } + + if err := h.players.UpdatePlayer(r.Context(), p); err != nil { + if err == player.ErrPlayerNotFound { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) +} + +func (h *PlayerHandler) handleGetQRCode(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + png, err := h.players.GenerateQRCode(r.Context(), id) + if err != nil { + if err == player.ErrPlayerNotFound { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "public, max-age=86400") + w.WriteHeader(http.StatusOK) + w.Write(png) +} + +type mergePlayersRequest struct { + KeepID string `json:"keep_id"` + MergeID string `json:"merge_id"` +} + +func (h *PlayerHandler) handleMergePlayers(w http.ResponseWriter, r *http.Request) { + var req mergePlayersRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if req.KeepID == "" || req.MergeID == "" { + writeError(w, http.StatusBadRequest, "keep_id and merge_id are required") + return + } + if req.KeepID == req.MergeID { + writeError(w, http.StatusBadRequest, "keep_id and merge_id must be different") + return + } + + if err := h.players.MergePlayers(r.Context(), req.KeepID, req.MergeID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "merged"}) +} + +func (h *PlayerHandler) handleImportCSV(w http.ResponseWriter, r *http.Request) { + // Parse multipart form with 5MB limit + if err := r.ParseMultipartForm(5 << 20); err != nil { + writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error()) + return + } + + file, _, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "file upload required: "+err.Error()) + return + } + defer file.Close() + + result, err := h.players.ImportFromCSV(r.Context(), file) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, result) +} + +// ---------- Tournament Player Handlers ---------- + +func (h *PlayerHandler) handleGetTournamentPlayers(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + players, err := h.players.GetTournamentPlayers(r.Context(), tournamentID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, players) +} + +func (h *PlayerHandler) handleGetTournamentPlayer(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + playerID := chi.URLParam(r, "playerId") + + detail, err := h.players.GetTournamentPlayer(r.Context(), tournamentID, playerID) + if err != nil { + if err == player.ErrPlayerNotFound { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, detail) +} + +func (h *PlayerHandler) handleGetRankings(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + rankings, err := h.ranking.GetRankings(r.Context(), tournamentID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "rankings": rankings, + }) +} + +// ---------- Buy-in / Bust / Rebuy / Addon / Re-entry Handlers ---------- + +type buyInRequest struct { + Override bool `json:"override,omitempty"` +} + +func (h *PlayerHandler) handleBuyIn(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + playerID := chi.URLParam(r, "playerId") + + var req buyInRequest + // Body is optional + json.NewDecoder(r.Body).Decode(&req) + + // Register player if not already in tournament + _, err := h.players.GetTournamentPlayer(r.Context(), tournamentID, playerID) + if err == player.ErrPlayerNotFound { + // Auto-register + _, regErr := h.players.RegisterPlayer(r.Context(), tournamentID, playerID) + if regErr != nil { + writeError(w, http.StatusBadRequest, regErr.Error()) + return + } + } else if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Process buy-in via financial engine + tx, err := h.financial.ProcessBuyIn(r.Context(), tournamentID, playerID, req.Override) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + // Auto-seat suggestion + var seatAssignment *seating.SeatAssignment + if h.tables != nil { + assignment, seatErr := h.tables.AutoAssignSeat(r.Context(), tournamentID, playerID) + if seatErr == nil { + seatAssignment = assignment + } + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "transaction": tx, + "seat_suggestion": seatAssignment, + }) +} + +type bustRequest struct { + HitmanPlayerID *string `json:"hitman_player_id,omitempty"` +} + +func (h *PlayerHandler) handleBust(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + playerID := chi.URLParam(r, "playerId") + + var req bustRequest + json.NewDecoder(r.Body).Decode(&req) + + if err := h.players.BustPlayer(r.Context(), tournamentID, playerID, req.HitmanPlayerID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + // Get updated rankings + rankings, err := h.ranking.GetRankings(r.Context(), tournamentID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "busted", + "rankings": rankings, + }) +} + +func (h *PlayerHandler) handleRebuy(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + playerID := chi.URLParam(r, "playerId") + + tx, err := h.financial.ProcessRebuy(r.Context(), tournamentID, playerID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, tx) +} + +func (h *PlayerHandler) handleAddOn(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + playerID := chi.URLParam(r, "playerId") + + tx, err := h.financial.ProcessAddOn(r.Context(), tournamentID, playerID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, tx) +} + +func (h *PlayerHandler) handleReEntry(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + playerID := chi.URLParam(r, "playerId") + + tx, err := h.financial.ProcessReEntry(r.Context(), tournamentID, playerID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + // Auto-seat for re-entry + var seatAssignment *seating.SeatAssignment + if h.tables != nil { + assignment, seatErr := h.tables.AutoAssignSeat(r.Context(), tournamentID, playerID) + if seatErr == nil { + seatAssignment = assignment + } + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "transaction": tx, + "seat_suggestion": seatAssignment, + }) +} + +func (h *PlayerHandler) handleUndoBust(w http.ResponseWriter, r *http.Request) { + tournamentID := chi.URLParam(r, "id") + playerID := chi.URLParam(r, "playerId") + + if err := h.players.UndoBust(r.Context(), tournamentID, playerID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + // Get updated rankings + rankings, _ := h.ranking.GetRankings(r.Context(), tournamentID) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "undo_bust", + "rankings": rankings, + }) +} + +func (h *PlayerHandler) handleUndoTransaction(w http.ResponseWriter, r *http.Request) { + txID := chi.URLParam(r, "txId") + + if err := h.financial.UndoTransaction(r.Context(), txID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "undone"}) +} + +// ---------- Helpers ---------- + +func queryInt(r *http.Request, key string, defaultVal int) int { + v := r.URL.Query().Get(key) + if v == "" { + return defaultVal + } + n := 0 + for _, c := range v { + if c < '0' || c > '9' { + return defaultVal + } + n = n*10 + int(c-'0') + } + if n <= 0 { + return defaultVal + } + return n +}