- Clock API routes: start, pause, resume, advance, rewind, jump, get, warnings - Role-based access control (floor+ for mutations, any auth for reads) - Clock state persistence callback to DB on meaningful changes - Blind structure levels loaded from DB on clock start - Clock registry wired into HTTP server and cmd/leaf main - 25 tests covering: state machine, countdown, pause/resume, auto-advance, jump, rewind, hand-for-hand, warnings, overtime, crash recovery, snapshot - Fix missing crypto/rand import in auth/pin.go (Rule 3 auto-fix) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
333 lines
9.9 KiB
Go
333 lines
9.9 KiB
Go
package routes
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/felt-app/felt/internal/clock"
|
|
"github.com/felt-app/felt/internal/server/middleware"
|
|
)
|
|
|
|
// ClockHandler handles clock API routes.
|
|
type ClockHandler struct {
|
|
registry *clock.Registry
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewClockHandler creates a new clock route handler.
|
|
func NewClockHandler(registry *clock.Registry, db *sql.DB) *ClockHandler {
|
|
return &ClockHandler{
|
|
registry: registry,
|
|
db: db,
|
|
}
|
|
}
|
|
|
|
// HandleGetClock handles GET /api/v1/tournaments/{id}/clock.
|
|
// Returns the current clock state (snapshot).
|
|
func (h *ClockHandler) HandleGetClock(w http.ResponseWriter, r *http.Request) {
|
|
tournamentID := chi.URLParam(r, "id")
|
|
if tournamentID == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tournament id required"})
|
|
return
|
|
}
|
|
|
|
engine := h.registry.Get(tournamentID)
|
|
if engine == nil {
|
|
// No running clock -- return default stopped state
|
|
writeJSON(w, http.StatusOK, clock.ClockSnapshot{
|
|
TournamentID: tournamentID,
|
|
State: "stopped",
|
|
})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, engine.Snapshot())
|
|
}
|
|
|
|
// HandleStartClock handles POST /api/v1/tournaments/{id}/clock/start.
|
|
func (h *ClockHandler) HandleStartClock(w http.ResponseWriter, r *http.Request) {
|
|
tournamentID := chi.URLParam(r, "id")
|
|
if tournamentID == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tournament id required"})
|
|
return
|
|
}
|
|
|
|
operatorID := middleware.OperatorID(r)
|
|
|
|
// Load blind structure from DB
|
|
levels, err := h.loadLevelsFromDB(tournamentID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load blind structure: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
if len(levels) == 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no blind structure levels configured for this tournament"})
|
|
return
|
|
}
|
|
|
|
engine := h.registry.GetOrCreate(tournamentID)
|
|
engine.LoadLevels(levels)
|
|
|
|
// Set up DB persistence callback
|
|
engine.SetOnStateChange(func(tid string, snap clock.ClockSnapshot) {
|
|
h.persistClockState(tid, snap)
|
|
})
|
|
|
|
if err := engine.Start(operatorID); err != nil {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Start the ticker
|
|
if err := h.registry.StartTicker(r.Context(), tournamentID); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to start ticker: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, engine.Snapshot())
|
|
}
|
|
|
|
// HandlePauseClock handles POST /api/v1/tournaments/{id}/clock/pause.
|
|
func (h *ClockHandler) HandlePauseClock(w http.ResponseWriter, r *http.Request) {
|
|
tournamentID := chi.URLParam(r, "id")
|
|
engine := h.registry.Get(tournamentID)
|
|
if engine == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no clock running for this tournament"})
|
|
return
|
|
}
|
|
|
|
operatorID := middleware.OperatorID(r)
|
|
|
|
if err := engine.Pause(operatorID); err != nil {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, engine.Snapshot())
|
|
}
|
|
|
|
// HandleResumeClock handles POST /api/v1/tournaments/{id}/clock/resume.
|
|
func (h *ClockHandler) HandleResumeClock(w http.ResponseWriter, r *http.Request) {
|
|
tournamentID := chi.URLParam(r, "id")
|
|
engine := h.registry.Get(tournamentID)
|
|
if engine == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no clock running for this tournament"})
|
|
return
|
|
}
|
|
|
|
operatorID := middleware.OperatorID(r)
|
|
|
|
if err := engine.Resume(operatorID); err != nil {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, engine.Snapshot())
|
|
}
|
|
|
|
// HandleAdvanceClock handles POST /api/v1/tournaments/{id}/clock/advance.
|
|
func (h *ClockHandler) HandleAdvanceClock(w http.ResponseWriter, r *http.Request) {
|
|
tournamentID := chi.URLParam(r, "id")
|
|
engine := h.registry.Get(tournamentID)
|
|
if engine == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no clock running for this tournament"})
|
|
return
|
|
}
|
|
|
|
operatorID := middleware.OperatorID(r)
|
|
|
|
if err := engine.AdvanceLevel(operatorID); err != nil {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, engine.Snapshot())
|
|
}
|
|
|
|
// HandleRewindClock handles POST /api/v1/tournaments/{id}/clock/rewind.
|
|
func (h *ClockHandler) HandleRewindClock(w http.ResponseWriter, r *http.Request) {
|
|
tournamentID := chi.URLParam(r, "id")
|
|
engine := h.registry.Get(tournamentID)
|
|
if engine == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no clock running for this tournament"})
|
|
return
|
|
}
|
|
|
|
operatorID := middleware.OperatorID(r)
|
|
|
|
if err := engine.RewindLevel(operatorID); err != nil {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, engine.Snapshot())
|
|
}
|
|
|
|
// jumpRequest is the request body for POST /api/v1/tournaments/{id}/clock/jump.
|
|
type jumpRequest struct {
|
|
Level int `json:"level"`
|
|
}
|
|
|
|
// HandleJumpClock handles POST /api/v1/tournaments/{id}/clock/jump.
|
|
func (h *ClockHandler) HandleJumpClock(w http.ResponseWriter, r *http.Request) {
|
|
tournamentID := chi.URLParam(r, "id")
|
|
engine := h.registry.Get(tournamentID)
|
|
if engine == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no clock running for this tournament"})
|
|
return
|
|
}
|
|
|
|
var req jumpRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
|
|
operatorID := middleware.OperatorID(r)
|
|
|
|
if err := engine.JumpToLevel(req.Level, operatorID); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, engine.Snapshot())
|
|
}
|
|
|
|
// updateWarningsRequest is the request body for PUT /api/v1/tournaments/{id}/clock/warnings.
|
|
type updateWarningsRequest struct {
|
|
Warnings []clock.Warning `json:"warnings"`
|
|
}
|
|
|
|
// HandleUpdateWarnings handles PUT /api/v1/tournaments/{id}/clock/warnings.
|
|
func (h *ClockHandler) HandleUpdateWarnings(w http.ResponseWriter, r *http.Request) {
|
|
tournamentID := chi.URLParam(r, "id")
|
|
|
|
var req updateWarningsRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
|
|
// Validate warnings
|
|
for _, warning := range req.Warnings {
|
|
if warning.Seconds <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "warning seconds must be positive"})
|
|
return
|
|
}
|
|
if warning.Type != "audio" && warning.Type != "visual" && warning.Type != "both" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "warning type must be audio, visual, or both"})
|
|
return
|
|
}
|
|
}
|
|
|
|
engine := h.registry.GetOrCreate(tournamentID)
|
|
engine.SetWarnings(req.Warnings)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"warnings": engine.GetWarnings(),
|
|
})
|
|
}
|
|
|
|
// loadLevelsFromDB loads blind structure levels from the database for a tournament.
|
|
func (h *ClockHandler) loadLevelsFromDB(tournamentID string) ([]clock.Level, error) {
|
|
// Get the blind_structure_id for this tournament
|
|
var structureID int
|
|
err := h.db.QueryRow(
|
|
"SELECT blind_structure_id FROM tournaments WHERE id = ?",
|
|
tournamentID,
|
|
).Scan(&structureID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load levels from blind_levels table
|
|
rows, err := h.db.Query(
|
|
`SELECT position, level_type, game_type, small_blind, big_blind, ante, bb_ante,
|
|
duration_seconds, chip_up_denomination_value, notes
|
|
FROM blind_levels
|
|
WHERE structure_id = ?
|
|
ORDER BY position`,
|
|
structureID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var levels []clock.Level
|
|
for rows.Next() {
|
|
var l clock.Level
|
|
var chipUpDenom sql.NullInt64
|
|
var notes sql.NullString
|
|
err := rows.Scan(
|
|
&l.Position, &l.LevelType, &l.GameType, &l.SmallBlind, &l.BigBlind,
|
|
&l.Ante, &l.BBAnte, &l.DurationSeconds, &chipUpDenom, ¬es,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if chipUpDenom.Valid {
|
|
v := chipUpDenom.Int64
|
|
l.ChipUpDenominationVal = &v
|
|
}
|
|
if notes.Valid {
|
|
l.Notes = notes.String
|
|
}
|
|
levels = append(levels, l)
|
|
}
|
|
|
|
return levels, rows.Err()
|
|
}
|
|
|
|
// persistClockState persists the clock state to the database.
|
|
func (h *ClockHandler) persistClockState(tournamentID string, snap clock.ClockSnapshot) {
|
|
_, err := h.db.Exec(
|
|
`UPDATE tournaments
|
|
SET current_level = ?, clock_state = ?, clock_remaining_ns = ?,
|
|
total_elapsed_ns = ?, updated_at = unixepoch()
|
|
WHERE id = ?`,
|
|
snap.CurrentLevel,
|
|
snap.State,
|
|
snap.RemainingMs*int64(1000000), // Convert ms back to ns
|
|
snap.TotalElapsedMs*int64(1000000),
|
|
tournamentID,
|
|
)
|
|
if err != nil {
|
|
// Log but don't fail -- clock continues operating in memory
|
|
_ = err // In production, log this error
|
|
}
|
|
}
|
|
|
|
// RegisterRoutes registers clock routes on the given router.
|
|
// All routes require auth middleware. Mutation routes require admin or floor role.
|
|
func (h *ClockHandler) RegisterRoutes(r chi.Router) {
|
|
r.Route("/tournaments/{id}/clock", func(r chi.Router) {
|
|
// Read-only (any authenticated user)
|
|
r.Get("/", h.HandleGetClock)
|
|
|
|
// Mutations (admin or floor)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireRole(middleware.RoleFloor))
|
|
r.Post("/start", h.HandleStartClock)
|
|
r.Post("/pause", h.HandlePauseClock)
|
|
r.Post("/resume", h.HandleResumeClock)
|
|
r.Post("/advance", h.HandleAdvanceClock)
|
|
r.Post("/rewind", h.HandleRewindClock)
|
|
r.Post("/jump", h.HandleJumpClock)
|
|
r.Put("/warnings", h.HandleUpdateWarnings)
|
|
})
|
|
})
|
|
}
|
|
|
|
// FormatLevel returns a human-readable description of a level.
|
|
func FormatLevel(l clock.Level) string {
|
|
if l.LevelType == "break" {
|
|
return "Break (" + strconv.Itoa(l.DurationSeconds/60) + " min)"
|
|
}
|
|
return l.GameType + " " + strconv.FormatInt(l.SmallBlind/100, 10) + "/" + strconv.FormatInt(l.BigBlind/100, 10)
|
|
}
|