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 }