- 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>
474 lines
13 KiB
Go
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
|
|
}
|