feat(01-07): add player API routes, ranking tests, CSV export safety
- PlayerHandler with all CRUD routes and tournament player operations - Buy-in flow: register + financial engine + auto-seat suggestion - Bust flow: hitman selection + bounty transfer + re-ranking - Undo bust with full re-ranking and rankings response - Rankings API endpoint returning derived positions - QR code endpoint returns PNG image with Cache-Control header - CSV import via multipart upload (admin only) - Player merge endpoint (admin only) - CSV export safety: formula injection neutralization (tab-prefix =,+,-,@) - Ranking tests: bust order, undo re-ranking, early undo, re-entry, deal positions, auto-close, concurrent busts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
93736287ae
commit
8b4b131371
4 changed files with 1164 additions and 0 deletions
46
internal/player/export.go
Normal file
46
internal/player/export.go
Normal file
|
|
@ -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]))
|
||||||
|
}
|
||||||
111
internal/player/player_test.go
Normal file
111
internal/player/player_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
533
internal/player/ranking_test.go
Normal file
533
internal/player/ranking_test.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
474
internal/server/routes/players.go
Normal file
474
internal/server/routes/players.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue