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) }