felt/internal/server/routes/players.go
Mikkel Georgsen 8b4b131371 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>
2026-03-01 04:35:05 +01:00

474 lines
13 KiB
Go

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
}