felt/internal/server/routes/tables.go
Mikkel Georgsen 2d3cb0ac9e feat(01-08): implement balance engine, break table, and seating API routes
- TDA-compliant balance engine with live-adaptive suggestions
- Break table distributes players evenly across remaining tables
- Stale suggestion detection and invalidation on state changes
- Full REST API for tables, seating, balancing, blueprints, hand-for-hand
- 15 tests covering balance, break table, auto-seat, and dealer button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 04:24:52 +01:00

536 lines
15 KiB
Go

package routes
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/felt-app/felt/internal/seating"
"github.com/felt-app/felt/internal/server/middleware"
)
// TableHandler handles table, seating, balancing, and break table API routes.
type TableHandler struct {
tables *seating.TableService
balance *seating.BalanceEngine
breakTable *seating.BreakTableService
blueprints *seating.BlueprintService
}
// NewTableHandler creates a new table route handler.
func NewTableHandler(
tables *seating.TableService,
balance *seating.BalanceEngine,
breakTable *seating.BreakTableService,
blueprints *seating.BlueprintService,
) *TableHandler {
return &TableHandler{
tables: tables,
balance: balance,
breakTable: breakTable,
blueprints: blueprints,
}
}
// RegisterRoutes registers table and seating routes on the given router.
func (h *TableHandler) RegisterRoutes(r chi.Router) {
// Tournament-scoped routes
r.Route("/tournaments/{id}", func(r chi.Router) {
// Read-only (any authenticated user)
r.Get("/tables", h.handleGetTables)
r.Get("/balance", h.handleCheckBalance)
// Mutation routes (admin or floor)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRole(middleware.RoleFloor))
// Table management
r.Post("/tables", h.handleCreateTable)
r.Post("/tables/from-blueprint", h.handleCreateFromBlueprint)
r.Post("/tables/save-blueprint", h.handleSaveBlueprint)
r.Put("/tables/{tableId}", h.handleUpdateTable)
r.Delete("/tables/{tableId}", h.handleDeactivateTable)
// Seating
r.Post("/players/{playerId}/auto-seat", h.handleAutoSeat)
r.Post("/players/{playerId}/seat", h.handleAssignSeat)
r.Post("/seats/swap", h.handleSwapSeats)
// Balancing
r.Post("/balance/suggest", h.handleSuggestMoves)
r.Post("/balance/suggestions/{suggId}/accept", h.handleAcceptSuggestion)
r.Post("/balance/suggestions/{suggId}/cancel", h.handleCancelSuggestion)
// Break Table
r.Post("/tables/{tableId}/break", h.handleBreakTable)
// Dealer Button
r.Post("/tables/{tableId}/button", h.handleSetButton)
r.Post("/tables/{tableId}/button/advance", h.handleAdvanceButton)
// Hand-for-Hand
r.Post("/hand-for-hand", h.handleSetHandForHand)
r.Post("/tables/{tableId}/hand-complete", h.handleTableHandComplete)
})
})
// Venue-level blueprint routes
r.Route("/blueprints", func(r chi.Router) {
r.Get("/", h.handleListBlueprints)
r.Get("/{id}", h.handleGetBlueprint)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRole(middleware.RoleAdmin))
r.Post("/", h.handleCreateBlueprint)
r.Put("/{id}", h.handleUpdateBlueprint)
r.Delete("/{id}", h.handleDeleteBlueprint)
})
})
}
// ---------- Table Handlers ----------
func (h *TableHandler) handleGetTables(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
tables, err := h.tables.GetTables(r.Context(), tournamentID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, tables)
}
type createTableRequest struct {
Name string `json:"name"`
SeatCount int `json:"seat_count"`
}
func (h *TableHandler) handleCreateTable(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
var req createTableRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
table, err := h.tables.CreateTable(r.Context(), tournamentID, req.Name, req.SeatCount)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, table)
}
type fromBlueprintRequest struct {
BlueprintID int `json:"blueprint_id"`
}
func (h *TableHandler) handleCreateFromBlueprint(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
var req fromBlueprintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
tables, err := h.blueprints.CreateTablesFromBlueprint(r.Context(), h.tables.DB(), tournamentID, req.BlueprintID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, tables)
}
func (h *TableHandler) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
tableID, err := strconv.Atoi(chi.URLParam(r, "tableId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid table id")
return
}
var req createTableRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if err := h.tables.UpdateTable(r.Context(), tournamentID, tableID, req.Name, req.SeatCount); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
func (h *TableHandler) handleDeactivateTable(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
tableID, err := strconv.Atoi(chi.URLParam(r, "tableId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid table id")
return
}
if err := h.tables.DeactivateTable(r.Context(), tournamentID, tableID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deactivated"})
}
// ---------- Seating Handlers ----------
func (h *TableHandler) handleAutoSeat(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
playerID := chi.URLParam(r, "playerId")
assignment, err := h.tables.AutoAssignSeat(r.Context(), tournamentID, playerID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, assignment)
}
type assignSeatRequest struct {
TableID int `json:"table_id"`
SeatPosition int `json:"seat_position"`
}
func (h *TableHandler) handleAssignSeat(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
playerID := chi.URLParam(r, "playerId")
var req assignSeatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if err := h.tables.AssignSeat(r.Context(), tournamentID, playerID, req.TableID, req.SeatPosition); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "seated"})
}
type swapSeatsRequest struct {
Player1ID string `json:"player1_id"`
Player2ID string `json:"player2_id"`
}
func (h *TableHandler) handleSwapSeats(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
var req swapSeatsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if err := h.tables.SwapSeats(r.Context(), tournamentID, req.Player1ID, req.Player2ID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "swapped"})
}
// ---------- Balance Handlers ----------
func (h *TableHandler) handleCheckBalance(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
status, err := h.balance.CheckBalance(r.Context(), tournamentID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *TableHandler) handleSuggestMoves(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
suggestions, err := h.balance.SuggestMoves(r.Context(), tournamentID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"suggestions": suggestions,
})
}
type acceptSuggestionRequest struct {
FromSeat int `json:"from_seat"`
ToSeat int `json:"to_seat"`
}
func (h *TableHandler) handleAcceptSuggestion(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
suggID, err := strconv.Atoi(chi.URLParam(r, "suggId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid suggestion id")
return
}
var req acceptSuggestionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if err := h.balance.AcceptSuggestion(r.Context(), tournamentID, suggID, req.FromSeat, req.ToSeat); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "accepted"})
}
func (h *TableHandler) handleCancelSuggestion(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
suggID, err := strconv.Atoi(chi.URLParam(r, "suggId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid suggestion id")
return
}
if err := h.balance.CancelSuggestion(r.Context(), tournamentID, suggID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"})
}
// ---------- Break Table Handler ----------
func (h *TableHandler) handleBreakTable(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
tableID, err := strconv.Atoi(chi.URLParam(r, "tableId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid table id")
return
}
result, err := h.breakTable.BreakTable(r.Context(), tournamentID, tableID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
// ---------- Dealer Button Handlers ----------
type setButtonRequest struct {
Position int `json:"position"`
}
func (h *TableHandler) handleSetButton(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
tableID, err := strconv.Atoi(chi.URLParam(r, "tableId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid table id")
return
}
var req setButtonRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if err := h.tables.SetDealerButton(r.Context(), tournamentID, tableID, req.Position); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "button_set"})
}
func (h *TableHandler) handleAdvanceButton(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
tableID, err := strconv.Atoi(chi.URLParam(r, "tableId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid table id")
return
}
if err := h.tables.AdvanceDealerButton(r.Context(), tournamentID, tableID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "button_advanced"})
}
// ---------- Hand-for-Hand Handlers ----------
type handForHandRequest struct {
Enabled bool `json:"enabled"`
}
func (h *TableHandler) handleSetHandForHand(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
var req handForHandRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if err := h.tables.SetHandForHand(r.Context(), tournamentID, req.Enabled); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"status": fmt.Sprintf("hand_for_hand_%s", boolStr(req.Enabled)),
})
}
func (h *TableHandler) handleTableHandComplete(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
tableID, err := strconv.Atoi(chi.URLParam(r, "tableId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid table id")
return
}
if err := h.tables.TableHandComplete(r.Context(), tournamentID, tableID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "hand_complete"})
}
// ---------- Blueprint Handlers ----------
func (h *TableHandler) handleListBlueprints(w http.ResponseWriter, r *http.Request) {
blueprints, err := h.blueprints.ListBlueprints(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, blueprints)
}
func (h *TableHandler) handleGetBlueprint(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid blueprint id")
return
}
bp, err := h.blueprints.GetBlueprint(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, bp)
}
type createBlueprintRequest struct {
Name string `json:"name"`
TableConfigs []seating.BlueprintTableConfig `json:"table_configs"`
}
func (h *TableHandler) handleCreateBlueprint(w http.ResponseWriter, r *http.Request) {
var req createBlueprintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
bp, err := h.blueprints.CreateBlueprint(r.Context(), req.Name, req.TableConfigs)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, bp)
}
func (h *TableHandler) handleUpdateBlueprint(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid blueprint id")
return
}
var req createBlueprintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
bp := seating.Blueprint{
ID: id,
Name: req.Name,
TableConfigs: req.TableConfigs,
}
if err := h.blueprints.UpdateBlueprint(r.Context(), bp); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
func (h *TableHandler) handleDeleteBlueprint(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid blueprint id")
return
}
if err := h.blueprints.DeleteBlueprint(r.Context(), id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
type saveBlueprintRequest struct {
Name string `json:"name"`
}
func (h *TableHandler) handleSaveBlueprint(w http.ResponseWriter, r *http.Request) {
tournamentID := chi.URLParam(r, "id")
var req saveBlueprintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
bp, err := h.blueprints.SaveBlueprintFromTournament(r.Context(), h.tables.DB(), tournamentID, req.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, bp)
}
// boolStr returns "enabled" or "disabled" for a bool value.
func boolStr(b bool) string {
if b {
return "enabled"
}
return "disabled"
}