// HTTP handlers for the catalog (tracks + cars). // // All HTTP routes live under /api/*: // GET /api/tracks list tracks (optional ?scope=system|public|private&tag=) // GET /api/tracks/{id} get a single track // POST /api/tracks create a track // PUT /api/tracks/{id} update a track // DELETE /api/tracks/{id} delete a track // GET /api/cars list cars (optional ?scope=...&owner_id=...) // GET /api/cars/{id} get a single car // POST /api/cars create a car // PUT /api/cars/{id} update a car // DELETE /api/cars/{id} delete a car // GET /api/catalog combined snapshot + summary // // WebSocket access is wired up directly in main.go's readPump switch. package main import ( "context" "encoding/json" "errors" "net/http" "strconv" "strings" "time" "github.com/x0gp/server/internal/catalog" "github.com/x0gp/server/internal/transport" ) // --------------------------------------------------------------------------- // Wire <-> internal conversion // --------------------------------------------------------------------------- func waypointToWire(w catalog.Waypoint) transport.TrackWaypoint { return transport.TrackWaypoint{ X: w.X, Y: w.Y, HeadingRad: w.HeadingRad, SpeedMs: w.SpeedMs, Curvature: w.Curvature, } } func waypointsToWire(in []catalog.Waypoint) []transport.TrackWaypoint { if len(in) == 0 { return nil } out := make([]transport.TrackWaypoint, len(in)) for i, w := range in { out[i] = waypointToWire(w) } return out } func waypointsFromWire(in []transport.TrackWaypoint) []catalog.Waypoint { if len(in) == 0 { return nil } out := make([]catalog.Waypoint, len(in)) for i, w := range in { out[i] = catalog.Waypoint{ X: w.X, Y: w.Y, HeadingRad: w.HeadingRad, SpeedMs: w.SpeedMs, Curvature: w.Curvature, } } return out } func boundsToWire(b catalog.Bounds) transport.TrackBounds { return transport.TrackBounds{ MinX: b.MinX, MinY: b.MinY, MaxX: b.MaxX, MaxY: b.MaxY, LengthM: b.LengthM, WidthM: b.WidthM, } } func trackToWire(t catalog.TrackMeta, activeTracks map[string]bool) transport.TrackWire { return transport.TrackWire{ ID: t.ID, Name: t.Name, Description: t.Description, AuthorID: t.AuthorID, Visibility: string(t.Visibility), Scale: t.Scale, LengthM: t.LengthM, WidthM: t.WidthM, LaneWidthM: t.LaneWidthM, Bounds: boundsToWire(t.Bounds), Surface: string(t.Surface), Tags: t.Tags, Centerline: waypointsToWire(t.Centerline), BestLapMs: t.BestLapMs, BestLapHolder: t.BestLapHolder, CreatedMs: t.CreatedMs, UpdatedMs: t.UpdatedMs, IsActive: activeTracks[t.ID], } } func tracksToWire(in []catalog.TrackMeta, activeTracks map[string]bool) []transport.TrackWire { if len(in) == 0 { return []transport.TrackWire{} } out := make([]transport.TrackWire, len(in)) for i, t := range in { out[i] = trackToWire(t, activeTracks) } return out } func getActiveTracks(ctx context.Context, cat *catalog.Service) map[string]bool { active := make(map[string]bool) calendar, err := cat.GetTrackCalendar(ctx) if err != nil { return active } now := time.Now() for _, entry := range calendar { if (now.Equal(entry.StartAt) || now.After(entry.StartAt)) && now.Before(entry.EndAt) { active[entry.TrackID] = true } } return active } func trackFromWire(w transport.TrackWire) catalog.TrackMeta { return catalog.TrackMeta{ ID: w.ID, Name: w.Name, Description: w.Description, AuthorID: w.AuthorID, Visibility: catalog.Visibility(w.Visibility), Scale: w.Scale, LengthM: w.LengthM, WidthM: w.WidthM, LaneWidthM: w.LaneWidthM, Bounds: catalog.Bounds{ MinX: w.Bounds.MinX, MinY: w.Bounds.MinY, MaxX: w.Bounds.MaxX, MaxY: w.Bounds.MaxY, LengthM: w.Bounds.LengthM, WidthM: w.Bounds.WidthM, }, Surface: catalog.Surface(w.Surface), Tags: w.Tags, Centerline: waypointsFromWire(w.Centerline), BestLapMs: w.BestLapMs, BestLapHolder: w.BestLapHolder, CreatedMs: w.CreatedMs, UpdatedMs: w.UpdatedMs, } } func chassisToWire(c catalog.ChassisSpec) transport.ChassisWire { return transport.ChassisWire{ Model: c.Model, Material: c.Material, Printed: c.Printed, WheelbaseMm: c.WheelbaseMm, TrackMm: c.TrackMm, } } func motorToWire(m catalog.MotorSpec) transport.MotorWire { return transport.MotorWire{ Kind: m.Kind, Class: m.Class, KV: m.KV, PowerW: m.PowerW, } } func batteryToWire(b catalog.BatterySpec) transport.BatteryWire { return transport.BatteryWire{ VoltageV: b.VoltageV, CapacityMah: b.CapacityMah, Cells: b.Cells, Chemistry: b.Chemistry, } } func carToWire(c catalog.CarMeta) transport.CarWire { return transport.CarWire{ ID: c.ID, Name: c.Name, OwnerID: c.OwnerID, Visibility: string(c.Visibility), Scale: c.Scale, LengthMm: c.LengthMm, WidthMm: c.WidthMm, HeightMm: c.HeightMm, WeightG: c.WeightG, Chassis: chassisToWire(c.Chassis), Motor: motorToWire(c.Motor), Battery: batteryToWire(c.Battery), Drive: string(c.Drive), TopSpeedMs: c.TopSpeedMs, ColorHex: c.ColorHex, AvatarURL: c.AvatarURL, Active: c.Active, DeviceID: c.DeviceID, // Stats are read-mostly; the server fills them on read. TotalDistanceM: c.TotalDistanceM, TotalRaces: c.TotalRaces, TotalLaps: c.TotalLaps, BestLapMs: c.BestLapMs, CreatedMs: c.CreatedMs, UpdatedMs: c.UpdatedMs, } } func carsToWire(in []catalog.CarMeta) []transport.CarWire { if len(in) == 0 { return []transport.CarWire{} } out := make([]transport.CarWire, len(in)) for i, c := range in { out[i] = carToWire(c) } return out } func carFromWire(w transport.CarWire) catalog.CarMeta { return catalog.CarMeta{ ID: w.ID, Name: w.Name, OwnerID: w.OwnerID, Visibility: catalog.Visibility(w.Visibility), Scale: w.Scale, LengthMm: w.LengthMm, WidthMm: w.WidthMm, HeightMm: w.HeightMm, WeightG: w.WeightG, Chassis: catalog.ChassisSpec{ Model: w.Chassis.Model, Material: w.Chassis.Material, Printed: w.Chassis.Printed, WheelbaseMm: w.Chassis.WheelbaseMm, TrackMm: w.Chassis.TrackMm, }, Motor: catalog.MotorSpec{ Kind: w.Motor.Kind, Class: w.Motor.Class, KV: w.Motor.KV, PowerW: w.Motor.PowerW, }, Battery: catalog.BatterySpec{ VoltageV: w.Battery.VoltageV, CapacityMah: w.Battery.CapacityMah, Cells: w.Battery.Cells, Chemistry: w.Battery.Chemistry, }, Drive: catalog.Drivetrain(w.Drive), TopSpeedMs: w.TopSpeedMs, ColorHex: w.ColorHex, AvatarURL: w.AvatarURL, Active: w.Active, CreatedMs: w.CreatedMs, UpdatedMs: w.UpdatedMs, DeviceID: w.DeviceID, } } func summaryToWire(s catalog.Stats) transport.CatalogSummary { return transport.CatalogSummary{ TracksTotal: s.TracksTotal, TracksSystem: s.TracksSystem, TracksPublic: s.TracksPublic, TracksPrivate: s.TracksPrivate, CarsTotal: s.CarsTotal, CarsSystem: s.CarsSystem, CarsPublic: s.CarsPublic, CarsPrivate: s.CarsPrivate, CarsActive: s.CarsActive, TSMs: time.Now().UnixMilli(), } } // --------------------------------------------------------------------------- // Patch helpers // --------------------------------------------------------------------------- func trackPatchFromWire(p transport.TrackPatch) catalog.UpdateTrackOptions { return catalog.UpdateTrackOptions{ Name: p.Name, Description: p.Description, Visibility: visPtr(p.Visibility), LengthM: p.LengthM, WidthM: p.WidthM, LaneWidthM: p.LaneWidthM, Surface: surfacePtr(p.Surface), Tags: p.Tags, Centerline: waypointsFromWirePtr(p.Centerline), } } func carPatchFromWire(p transport.CarPatch) catalog.UpdateCarOptions { return catalog.UpdateCarOptions{ Name: p.Name, Visibility: visPtr(p.Visibility), LengthMm: p.LengthMm, WidthMm: p.WidthMm, HeightMm: p.HeightMm, WeightG: p.WeightG, Drive: drivePtr(p.Drive), TopSpeedMs: p.TopSpeedMs, ColorHex: p.ColorHex, Active: p.Active, Chassis: chassisFromPtr(p.Chassis), Motor: motorFromPtr(p.Motor), Battery: batteryFromPtr(p.Battery), DeviceID: p.DeviceID, } } func visPtr(p *string) *catalog.Visibility { if p == nil { return nil } v := catalog.Visibility(*p) return &v } func surfacePtr(p *string) *catalog.Surface { if p == nil { return nil } v := catalog.Surface(*p) return &v } func drivePtr(p *string) *catalog.Drivetrain { if p == nil { return nil } v := catalog.Drivetrain(*p) return &v } func waypointsFromWirePtr(p *[]transport.TrackWaypoint) *[]catalog.Waypoint { if p == nil { return nil } out := waypointsFromWire(*p) return &out } func chassisFromPtr(p *transport.ChassisWire) *catalog.ChassisSpec { if p == nil { return nil } return &catalog.ChassisSpec{ Model: p.Model, Material: p.Material, Printed: p.Printed, WheelbaseMm: p.WheelbaseMm, TrackMm: p.TrackMm, } } func motorFromPtr(p *transport.MotorWire) *catalog.MotorSpec { if p == nil { return nil } return &catalog.MotorSpec{ Kind: p.Kind, Class: p.Class, KV: p.KV, PowerW: p.PowerW, } } func batteryFromPtr(p *transport.BatteryWire) *catalog.BatterySpec { if p == nil { return nil } return &catalog.BatterySpec{ VoltageV: p.VoltageV, CapacityMah: p.CapacityMah, Cells: p.Cells, Chemistry: p.Chemistry, } } func strFromPtr(p *string) string { if p == nil { return "" } return *p } func filterTracksByTag(in []catalog.TrackMeta, tag string) []catalog.TrackMeta { out := make([]catalog.TrackMeta, 0, len(in)) for _, t := range in { for _, tg := range t.Tags { if strings.EqualFold(tg, tag) { out = append(out, t) break } } } return out } // --------------------------------------------------------------------------- // HTTP handlers // --------------------------------------------------------------------------- func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } func writeError(w http.ResponseWriter, status int, code, msg string) { writeJSON(w, status, map[string]any{ "ok": false, "error": msg, "code": code, }) } func mapCatalogError(err error) (int, string) { switch { case errors.Is(err, catalog.ErrNotFound): return http.StatusNotFound, "not_found" case errors.Is(err, catalog.ErrAlreadyExists): return http.StatusConflict, "already_exists" case errors.Is(err, catalog.ErrInvalidInput): return http.StatusBadRequest, "invalid_input" case errors.Is(err, catalog.ErrImmutableSystem): return http.StatusForbidden, "immutable_system" default: return http.StatusInternalServerError, "internal" } } // catalogHandler godoc // @Summary Combined catalog snapshot // @Description Returns the full in-memory catalog: all tracks, all cars, and aggregate counters. This is the cheapest way for a UI to bootstrap its initial state. // @Tags catalog // @Produce json // @Success 200 {object} map[string]interface{} "Snapshot of tracks, cars and summary counters" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/catalog [get] func catalogHandler(svc *catalog.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { snap := svc.Snapshot() activeTracks := getActiveTracks(r.Context(), svc) writeJSON(w, http.StatusOK, map[string]any{ "tracks": tracksToWire(snap.Tracks, activeTracks), "cars": carsToWire(snap.Cars), "summary": summaryToWire(svc.Stats()), }) } } // tracksHandler godoc // @Summary List tracks // @Description Returns all tracks visible to the caller. Optional query filters: `scope=system|public|private`, `tag=`. // @Tags tracks // @Produce json // @Param scope query string false "Filter by visibility" Enums(system, public, private) // @Param tag query string false "Filter by tag (case-insensitive)" // @Success 200 {object} map[string]interface{} "List of tracks with count" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/tracks [get] func tracksHandler(svc *catalog.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { scope := r.URL.Query().Get("scope") tag := r.URL.Query().Get("tag") var out []catalog.TrackMeta switch strings.ToLower(scope) { case "system": out = svc.ListTracksByVisibility(catalog.VisibilitySystem) case "public": out = svc.ListTracksByVisibility(catalog.VisibilityPublic) case "private": out = svc.ListTracksByVisibility(catalog.VisibilityPrivate) default: out = svc.ListTracks() } if tag != "" { out = filterTracksByTag(out, tag) } activeTracks := getActiveTracks(r.Context(), svc) writeJSON(w, http.StatusOK, map[string]any{ "tracks": tracksToWire(out, activeTracks), "count": len(out), }) } } // trackByIDHandler godoc // @Summary Get / update / delete a track by id // @Description Dispatches on HTTP method. The id is taken from the URL path (`/api/tracks/{id}`). System tracks are immutable and cannot be updated or deleted. // @Tags tracks // @Produce json // @Consume json // @Param id path string true "Track id" // @Param track body transport.TrackUpdateRequest false "Patch payload (only for PUT)" // @Success 200 {object} transport.TrackWire "Track payload (GET) or update ack (PUT)" // @Success 204 {object} transport.TrackAck "Delete ack" // @Failure 400 {object} map[string]interface{} "Bad request / invalid input" // @Failure 403 {object} map[string]interface{} "System track is immutable" // @Failure 404 {object} map[string]interface{} "Track not found" // @Failure 409 {object} map[string]interface{} "Track id already exists" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/tracks/{id} [get] // @Router /api/tracks/{id} [put] // @Router /api/tracks/{id} [delete] func trackByIDHandler(svc *catalog.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/api/tracks/") if id == "" || strings.Contains(id, "/") { writeError(w, http.StatusBadRequest, "bad_request", "missing track id") return } activeTracks := getActiveTracks(r.Context(), svc) switch r.Method { case http.MethodGet: t, ok := svc.GetTrack(id) if !ok { writeError(w, http.StatusNotFound, "not_found", "track not found") return } writeJSON(w, http.StatusOK, trackToWire(t, activeTracks)) case http.MethodPut: var req transport.TrackUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } if req.ID == "" { req.ID = id } patch := trackPatchFromWire(req.Patch) updated, err := svc.UpdateTrack(r.Context(), req.ID, patch) if err != nil { status, code := mapCatalogError(err) writeError(w, status, code, err.Error()) return } writeJSON(w, http.StatusOK, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated, activeTracks)}) case http.MethodDelete: if err := svc.DeleteTrack(r.Context(), id); err != nil { status, code := mapCatalogError(err) writeError(w, status, code, err.Error()) return } writeJSON(w, http.StatusOK, transport.TrackAck{Ok: true, ID: id}) default: w.Header().Set("Allow", "GET PUT DELETE") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) } } } // createTrackHandler godoc // @Summary Create a track // @Description Creates a new track. The server fills `id` if the client sends an empty one. `author_id` should be the caller's id in production (auth is disabled in PoC). // @Tags tracks // @Accept json // @Produce json // @Param track body transport.TrackCreateRequest true "Track to create" // @Success 201 {object} transport.TrackAck "Created" // @Failure 400 {object} map[string]interface{} "Invalid input" // @Failure 409 {object} map[string]interface{} "Track id already exists" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/tracks [post] func createTrackHandler(svc *catalog.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req transport.TrackCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } in := trackFromWire(req.Track) created, err := svc.CreateTrack(r.Context(), catalog.CreateTrackOptions{ ID: in.ID, Name: in.Name, Description: in.Description, AuthorID: in.AuthorID, Visibility: in.Visibility, Scale: in.Scale, LengthM: in.LengthM, WidthM: in.WidthM, LaneWidthM: in.LaneWidthM, Surface: in.Surface, Tags: in.Tags, Centerline: in.Centerline, }) if err != nil { status, code := mapCatalogError(err) writeError(w, status, code, err.Error()) return } activeTracks := getActiveTracks(r.Context(), svc) writeJSON(w, http.StatusCreated, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created, activeTracks)}) } } // carsHandler godoc // @Summary List cars // @Description Returns all cars visible to the caller. Optional query filters: `scope=system|public|private`, `owner_id=`. // @Tags cars // @Produce json // @Param scope query string false "Filter by visibility" Enums(system, public, private) // @Param owner_id query string false "Filter by owner id" // @Success 200 {object} map[string]interface{} "List of cars with count" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/cars [get] func carsHandler(svc *catalog.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { scope := r.URL.Query().Get("scope") owner := r.URL.Query().Get("owner_id") var out []catalog.CarMeta if owner != "" { out = svc.ListCarsByOwner(owner) } else { switch strings.ToLower(scope) { case "system": out = svc.ListCarsByVisibility(catalog.VisibilitySystem) case "public": out = svc.ListCarsByVisibility(catalog.VisibilityPublic) case "private": out = svc.ListCarsByVisibility(catalog.VisibilityPrivate) default: out = svc.ListCars() } } writeJSON(w, http.StatusOK, map[string]any{ "cars": carsToWire(out), "count": len(out), }) } } // carByIDHandler godoc // @Summary Get / update / delete a car by id // @Description Dispatches on HTTP method. The id is taken from the URL path (`/api/cars/{id}`). System cars are immutable and cannot be updated or deleted. // @Tags cars // @Produce json // @Consume json // @Param id path string true "Car id" // @Param car body transport.CarUpdateRequest false "Patch payload (only for PUT)" // @Success 200 {object} transport.CarWire "Car payload (GET) or update ack (PUT)" // @Success 204 {object} transport.CarAck "Delete ack" // @Failure 400 {object} map[string]interface{} "Bad request / invalid input" // @Failure 403 {object} map[string]interface{} "System car is immutable" // @Failure 404 {object} map[string]interface{} "Car not found" // @Failure 409 {object} map[string]interface{} "Car id already exists" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/cars/{id} [get] // @Router /api/cars/{id} [put] // @Router /api/cars/{id} [delete] func carByIDHandler(svc *catalog.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/api/cars/") if id == "" || strings.Contains(id, "/") { writeError(w, http.StatusBadRequest, "bad_request", "missing car id") return } switch r.Method { case http.MethodGet: c, ok := svc.GetCar(id) if !ok { writeError(w, http.StatusNotFound, "not_found", "car not found") return } writeJSON(w, http.StatusOK, carToWire(c)) case http.MethodPut: var req transport.CarUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } if req.ID == "" { req.ID = id } patch := carPatchFromWire(req.Patch) updated, err := svc.UpdateCar(r.Context(), req.ID, patch) if err != nil { status, code := mapCatalogError(err) writeError(w, status, code, err.Error()) return } writeJSON(w, http.StatusOK, transport.CarAck{Ok: true, ID: updated.ID, Car: carToWire(updated)}) case http.MethodDelete: if err := svc.DeleteCar(r.Context(), id); err != nil { status, code := mapCatalogError(err) writeError(w, status, code, err.Error()) return } writeJSON(w, http.StatusOK, transport.CarAck{Ok: true, ID: id}) default: w.Header().Set("Allow", "GET PUT DELETE") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) } } } // createCarHandler godoc // @Summary Create a car // @Description Creates a new car. The server fills `id` if the client sends an empty one. `owner_id` should be the caller's id in production (auth is disabled in PoC). // @Tags cars // @Accept json // @Produce json // @Param car body transport.CarCreateRequest true "Car to create" // @Success 201 {object} transport.CarAck "Created" // @Failure 400 {object} map[string]interface{} "Invalid input" // @Failure 409 {object} map[string]interface{} "Car id already exists" // @Failure 500 {object} map[string]interface{} "Internal server error" // @Router /api/cars [post] func createCarHandler(svc *catalog.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req transport.CarCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } in := carFromWire(req.Car) created, err := svc.CreateCar(r.Context(), catalog.CreateCarOptions{ ID: in.ID, Name: in.Name, OwnerID: in.OwnerID, Visibility: in.Visibility, Scale: in.Scale, LengthMm: in.LengthMm, WidthMm: in.WidthMm, HeightMm: in.HeightMm, WeightG: in.WeightG, Chassis: in.Chassis, Motor: in.Motor, Battery: in.Battery, Drive: in.Drive, TopSpeedMs: in.TopSpeedMs, ColorHex: in.ColorHex, AvatarURL: in.AvatarURL, }) if err != nil { status, code := mapCatalogError(err) writeError(w, status, code, err.Error()) return } writeJSON(w, http.StatusCreated, transport.CarAck{Ok: true, ID: created.ID, Car: carToWire(created)}) } } // --------------------------------------------------------------------------- // Method routers // --------------------------------------------------------------------------- // tracksRouter routes /api/tracks to either list (GET) or create (POST). func tracksRouter(svc *catalog.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: tracksHandler(svc)(w, r) case http.MethodPost: createTrackHandler(svc)(w, r) default: w.Header().Set("Allow", "GET POST") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) } } } // carsRouter routes /api/cars to either list (GET) or create (POST). func carsRouter(svc *catalog.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: carsHandler(svc)(w, r) case http.MethodPost: createCarHandler(svc)(w, r) default: w.Header().Set("Allow", "GET POST") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) } } } // trackCalendarRouter routes /api/tracks/calendar to GET or POST func trackCalendarRouter(cat *catalog.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: getTrackCalendarHandler(cat)(w, r) case http.MethodPost: createTrackCalendarHandler(cat)(w, r) default: w.Header().Set("Allow", "GET, POST") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST") } } } // getTrackCalendarHandler godoc // @Summary Get Track Calendar // @Description Returns the full calendar of track active periods. // @Tags tracks // @Produce json // @Success 200 {object} transport.TrackCalendarResponse "List of scheduled active periods" // @Failure 500 {object} map[string]interface{} "Internal error" // @Router /api/tracks/calendar [get] func getTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { entries, err := cat.GetTrackCalendar(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } items := make([]transport.TrackCalendarEntry, len(entries)) for i, entry := range entries { items[i] = transport.TrackCalendarEntry{ ID: entry.ID, TrackID: entry.TrackID, StartAt: entry.StartAt, EndAt: entry.EndAt, } } writeJSON(w, http.StatusOK, transport.TrackCalendarResponse{Items: items}) } } // createTrackCalendarHandler godoc // @Summary Schedule Track Active Period // @Description Schedules an active period for a physical track. // @Tags tracks // @Accept json // @Produce json // @Param payload body transport.TrackCalendarCreateRequest true "Calendar entry details" // @Success 201 {object} transport.TrackCalendarEntry "Created calendar entry" // @Failure 400 {object} map[string]interface{} "Invalid input" // @Failure 404 {object} map[string]interface{} "Track not found" // @Failure 500 {object} map[string]interface{} "Internal error" // @Router /api/tracks/calendar [post] func createTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req transport.TrackCalendarCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) return } entry, err := cat.CreateTrackCalendar(r.Context(), req.TrackID, req.StartAt, req.EndAt) if err != nil { if errors.Is(err, catalog.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", err.Error()) return } if errors.Is(err, catalog.ErrInvalidInput) { writeError(w, http.StatusBadRequest, "invalid_input", err.Error()) return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusCreated, transport.TrackCalendarEntry{ ID: entry.ID, TrackID: entry.TrackID, StartAt: entry.StartAt, EndAt: entry.EndAt, }) } } // deleteTrackCalendarHandler godoc // @Summary Delete Scheduled Period // @Description Deletes a scheduled active period by ID. // @Tags tracks // @Param id path int true "Calendar Entry ID" // @Success 204 "No Content" // @Failure 404 {object} map[string]interface{} "Not found" // @Failure 500 {object} map[string]interface{} "Internal error" // @Router /api/tracks/calendar/{id} [delete] func deleteTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { idStr := strings.TrimPrefix(r.URL.Path, "/api/tracks/calendar/") id, err := strconv.Atoi(idStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid_id", "id must be an integer") return } err = cat.DeleteTrackCalendar(r.Context(), id) if err != nil { if errors.Is(err, catalog.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", err.Error()) return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } w.WriteHeader(http.StatusNoContent) } }