felt/internal/server/routes/clock.go
Mikkel Georgsen ae90d9bfae feat(01-04): add clock warnings, API routes, tests, and server wiring
- 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>
2026-03-01 03:56:23 +01:00

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, &notes,
)
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)
}