// HTTP handlers for the races surface (list / upcoming / queue / plans). // // Routes: // // GET /api/races list races (keyset paginated) // GET /api/races/upcoming next N lobby|countdown races // POST /api/races/queue/join enqueue driver on the next N upcoming // GET /api/races/queue list driver's queue entries // DELETE /api/races/queue remove a single (driver, race) entry // POST /api/races/plans create a race plan // GET /api/races/plans list race plans (keyset paginated) // DELETE /api/races/plans/{id} delete a plan // // Query parameters: // // GET /api/races: // status comma-separated list, e.g. "racing,finished" // track_id filter by physical track id (no custom lobbies yet) // cursor opaque keyset cursor (returned in next_cursor) // limit max items per page (default 50, max 200) // // GET /api/races/upcoming: // limit number of races to return (default 3, max 16) // // GET /api/races/queue: // driver_id (or X-Driver-Id header) // // GET /api/races/plans: // cursor, limit package main import ( "encoding/json" "errors" "net/http" "strconv" "strings" "github.com/x0gp/server/internal/lobby" "github.com/x0gp/server/internal/races" "github.com/x0gp/server/internal/transport" ) // --------------------------------------------------------------------------- // /api/races // --------------------------------------------------------------------------- // racesListHandler godoc // @Summary List races (keyset paginated) // @Description Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page. // @Description ## Filters // @Description - `status` (comma-separated, e.g. "racing,finished"). Recognised values: all, lobby, racing, finished. // @Description - `track_id` (exact match against a physical track id; custom lobbies are not supported in this build). // @Description - `limit` (1..200, default 50) // @Tags races // @Produce json // @Param status query string false "Comma-separated status filter" Enums(all, lobby, racing, finished) // @Param track_id query string false "Filter by physical track id" // @Param cursor query string false "Opaque keyset cursor (next_cursor from previous page)" // @Param limit query int false "Page size (1..200, default 50)" // @Success 200 {object} transport.RaceListResponse "Page of races" // @Failure 400 {object} map[string]interface{} "Bad request / invalid cursor / invalid status" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/races [get] func racesListHandler(svc *races.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { filter, err := parseRaceListFilter(r) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } res, err := svc.ListRaces(r.Context(), filter) if err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } items := make([]transport.LobbyRace, 0, len(res.Items)) for _, it := range res.Items { if it.Source == "finished" && len(it.Podium) > 0 { items = append(items, lobbyToWireFinished(it.Meta, it.Podium)) } else { items = append(items, lobbyToWire(it.Meta)) } } writeJSON(w, http.StatusOK, transport.RaceListResponse{ Items: items, NextCursor: res.NextCursor, Count: res.TotalCount, }) } } func parseRaceListFilter(r *http.Request) (races.ListFilter, error) { q := r.URL.Query() cur, err := races.DecodeCursor(q.Get("cursor")) if err != nil { return races.ListFilter{}, err } statuses, err := races.ParseStatusFilter(q.Get("status")) if err != nil { return races.ListFilter{}, err } limit, _ := strconv.Atoi(q.Get("limit")) return races.ListFilter{ Statuses: statuses, TrackID: q.Get("track_id"), Cursor: cur, Limit: limit, }, nil } // --------------------------------------------------------------------------- // /api/races/upcoming // --------------------------------------------------------------------------- // racesUpcomingHandler godoc // @Summary Next N upcoming races // @Description Returns up to `limit` races currently in status lobby|countdown, ordered soonest first. Useful for the "join queue" UI and dashboard. // @Tags races // @Produce json // @Param limit query int false "Number of races to return (1..16, default 3)" // @Success 200 {object} transport.RaceUpcomingResponse "Upcoming races" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/races/upcoming [get] func racesUpcomingHandler(svc *races.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if limit <= 0 { limit = 3 } items, err := svc.Upcoming(r.Context(), limit) if err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } out := make([]transport.RaceUpcomingItem, 0, len(items)) for _, it := range items { out = append(out, transport.RaceUpcomingItem{ Meta: lobbyToWire(it.Meta), QueueLen: it.QueueLen, PlanID: it.PlanID, StartAtMs: it.StartAtMs, }) } writeJSON(w, http.StatusOK, transport.RaceUpcomingResponse{Items: out, Count: len(out)}) } } // --------------------------------------------------------------------------- // /api/races/queue // --------------------------------------------------------------------------- // racesQueueJoinHandler godoc // @Summary Enqueue driver on next N upcoming races // @Description Puts the driver in the queue for each of the next N (default 3) lobby|countdown races, soonest first. If a race has a free slot, the driver is also attached to it (best effort). Idempotent per (driver, race). // @Tags races // @Accept json // @Produce json // @Param X-Driver-Id header string false "Driver id (alternative to body)" // @Param body body transport.RaceQueueJoinRequest false "Driver id and optional limit" // @Success 200 {object} transport.RaceQueueJoinResponse "Queue entries created" // @Failure 400 {object} map[string]interface{} "driver_id required" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/races/queue/join [post] func racesQueueJoinHandler(svc *races.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req transport.RaceQueueJoinRequest if r.ContentLength > 0 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } } driverID := req.DriverID if driverID == "" { driverID = r.Header.Get("X-Driver-Id") } if driverID == "" { driverID = r.URL.Query().Get("driver_id") } if driverID == "" { writeError(w, http.StatusBadRequest, "bad_request", "driver_id required") return } limit := req.Limit if limit <= 0 { limit = 3 } res, err := svc.JoinUpcoming(r.Context(), driverID, limit) if err != nil { if errors.Is(err, races.ErrInvalidInput) { writeError(w, http.StatusBadRequest, "invalid_input", err.Error()) return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } joined := make([]transport.RaceQueueEntry, 0, len(res.Joined)) for _, q := range res.Joined { joined = append(joined, transport.RaceQueueEntry{ DriverID: q.DriverID, RaceID: q.RaceID, PlanID: q.PlanID, EnqueuedMs: q.EnqueuedMs, }) } writeJSON(w, http.StatusOK, transport.RaceQueueJoinResponse{ Joined: joined, Skipped: res.Skipped, }) } } // racesQueueListHandler godoc // @Summary List driver's queue entries // @Description Returns the queue entries for a driver, oldest first. // @Tags races // @Produce json // @Param X-Driver-Id header string false "Driver id" // @Param driver_id query string false "Driver id (alternative to header)" // @Success 200 {object} transport.RaceQueueListResponse "Queue entries" // @Failure 400 {object} map[string]interface{} "driver_id required" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/races/queue [get] func racesQueueListHandler(svc *races.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { driverID := r.URL.Query().Get("driver_id") if driverID == "" { driverID = r.Header.Get("X-Driver-Id") } if driverID == "" { writeError(w, http.StatusBadRequest, "bad_request", "driver_id required") return } entries, err := svc.ListQueue(r.Context(), driverID) if err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } out := make([]transport.RaceQueueEntry, 0, len(entries)) for _, q := range entries { out = append(out, transport.RaceQueueEntry{ DriverID: q.DriverID, RaceID: q.RaceID, PlanID: q.PlanID, EnqueuedMs: q.EnqueuedMs, }) } writeJSON(w, http.StatusOK, transport.RaceQueueListResponse{Items: out, Count: len(out)}) } } // racesQueueDeleteHandler godoc // @Summary Leave a queue entry // @Description Removes a single (driver, race) entry from the queue. Does not detach the driver from the race if already joined. // @Tags races // @Produce json // @Param X-Driver-Id header string false "Driver id" // @Param driver_id query string false "Driver id" // @Param race_id query string true "Race id" // @Success 200 {object} map[string]interface{} "ok" // @Failure 400 {object} map[string]interface{} "driver_id and race_id required" // @Failure 404 {object} map[string]interface{} "queue entry not found" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/races/queue [delete] func racesQueueDeleteHandler(svc *races.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { driverID := r.URL.Query().Get("driver_id") raceID := r.URL.Query().Get("race_id") if driverID == "" { driverID = r.Header.Get("X-Driver-Id") } if driverID == "" || raceID == "" { writeError(w, http.StatusBadRequest, "bad_request", "driver_id and race_id required") return } if err := svc.LeaveQueue(r.Context(), driverID, raceID); err != nil { if errors.Is(err, races.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "queue entry not found") return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } } // --------------------------------------------------------------------------- // /api/races/plans // --------------------------------------------------------------------------- // racePlansCreateHandler godoc // @Summary Create a race plan // @Description Creates a recurring or one-off scheduled race. The scheduler materialises a lobby race when `next_fire_ms` <= now. If `interval_s` > 0 the plan repeats; if `count` > 0 the plan disables itself after that many fires. // @Tags plans // @Accept json // @Produce json // @Param plan body transport.RacePlanCreateRequest true "Plan to create" // @Success 201 {object} transport.RacePlan "Created plan" // @Failure 400 {object} map[string]interface{} "Invalid input" // @Failure 409 {object} map[string]interface{} "Plan id already exists" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/races/plans [post] func racePlansCreateHandler(svc *races.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req transport.RacePlanCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } enabled := true if req.Enabled != nil { enabled = *req.Enabled } p, err := svc.CreatePlan(r.Context(), races.CreatePlanInput{ ID: req.ID, Name: req.Name, TrackID: req.TrackID, MaxCars: req.MaxCars, Laps: req.Laps, TimeLimitS: req.TimeLimitS, StartAtMs: req.StartAtMs, IntervalS: req.IntervalS, Count: req.Count, Enabled: enabled, }) if err != nil { if errors.Is(err, races.ErrInvalidInput) { writeError(w, http.StatusBadRequest, "invalid_input", err.Error()) return } if errors.Is(err, races.ErrAlreadyExists) { writeError(w, http.StatusConflict, "already_exists", err.Error()) return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusCreated, racePlanToWire(p)) } } // racePlansListHandler godoc // @Summary List race plans (keyset paginated) // @Description Returns race plans in ascending start_at order. Pass back the `next_cursor` to get the next page. // @Tags plans // @Produce json // @Param cursor query string false "Opaque keyset cursor" // @Param limit query int false "Page size (default 50)" // @Success 200 {object} transport.RacePlanListResponse "Page of plans" // @Failure 400 {object} map[string]interface{} "Invalid cursor" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/races/plans [get] func racePlansListHandler(svc *races.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cur, err := races.DecodeCursor(r.URL.Query().Get("cursor")) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if limit <= 0 { limit = 50 } plans, err := svc.ListPlans(r.Context(), cur, limit) if err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } hasMore := false if len(plans) > limit { hasMore = true plans = plans[:limit] } out := make([]transport.RacePlan, 0, len(plans)) for _, p := range plans { out = append(out, racePlanToWire(p)) } nextCur := "" if hasMore && len(out) > 0 { last := out[len(out)-1] nextCur = races.Cursor{Ms: last.StartAtMs, ID: last.ID}.Encode() } totalCount, err := svc.CountPlans(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusOK, transport.RacePlanListResponse{ Items: out, NextCursor: nextCur, Count: totalCount, }) } } // racePlansDeleteHandler godoc // @Summary Delete a race plan // @Description Removes a plan by id. Materialised races already in the lobby are not affected. // @Tags plans // @Produce json // @Param id path string true "Plan id" // @Success 200 {object} map[string]interface{} "ok" // @Failure 404 {object} map[string]interface{} "Plan not found" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/races/plans/{id} [delete] func racePlansDeleteHandler(svc *races.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/api/races/plans/") if id == "" || strings.Contains(id, "/") { writeError(w, http.StatusBadRequest, "bad_request", "missing plan id") return } if err := svc.DeletePlan(r.Context(), id); err != nil { if errors.Is(err, races.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "plan not found") return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id}) } } // --------------------------------------------------------------------------- // Method routers // --------------------------------------------------------------------------- // racesQueueRouter routes /api/races/queue to GET (list) or DELETE (leave). func racesQueueRouter(svc *races.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: racesQueueListHandler(svc)(w, r) case http.MethodDelete: racesQueueDeleteHandler(svc)(w, r) default: w.Header().Set("Allow", "GET DELETE") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) } } } // racePlansRouter routes /api/races/plans to GET (list) or POST (create). func racePlansRouter(svc *races.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: racePlansListHandler(svc)(w, r) case http.MethodPost: racePlansCreateHandler(svc)(w, r) default: w.Header().Set("Allow", "GET POST") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) } } } func lobbyToWire(m lobby.RaceMeta) transport.LobbyRace { return transport.LobbyRace{ ID: m.ID, Name: m.Name, TrackID: m.TrackID, MaxCars: m.MaxCars, Laps: m.Laps, TimeLimitS: m.TimeLimitS, DriverIDs: append([]string(nil), m.DriverIDs...), Status: string(m.Status), CreatedMs: m.CreatedMs, StartedMs: m.StartedMs, } } // lobbyToWireFinished builds the wire form for a finished race and // attaches the top-3 podium. func lobbyToWireFinished(m lobby.RaceMeta, podium []races.PodiumEntry) transport.LobbyRace { w := lobbyToWire(m) if len(podium) > 0 { w.Podium = make([]transport.RacePodiumEntry, 0, len(podium)) for _, p := range podium { w.Podium = append(w.Podium, transport.RacePodiumEntry{ Position: p.Position, DriverID: p.DriverID, Name: p.Name, TotalTimeMs: p.TotalTimeMs, Nickname: p.Nickname, AvatarUrl: p.AvatarUrl, ClanID: p.ClanID, }) } } return w } func racePlanToWire(p races.RacePlan) transport.RacePlan { return transport.RacePlan{ ID: p.ID, Name: p.Name, TrackID: p.TrackID, MaxCars: p.MaxCars, Laps: p.Laps, TimeLimitS: p.TimeLimitS, StartAtMs: p.StartAtMs, IntervalS: p.IntervalS, Count: p.Count, Enabled: p.Enabled, CreatedMs: p.CreatedMs, UpdatedMs: p.UpdatedMs, NextFireMs: p.NextFireMs, FiresDone: p.FiresDone, } }