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:
Mikkel Georgsen 2026-03-01 04:35:05 +01:00
parent 93736287ae
commit 8b4b131371
4 changed files with 1164 additions and 0 deletions

46
internal/player/export.go Normal file
View 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]))
}

View 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")
}
}

View 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))
}
}

View 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
}