package api import ( "encoding/json" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" "geniusrun/backend/internal/classify" "geniusrun/backend/internal/store" ) // workoutKindResponse combines a workout kind's rule/metadata with its pace // and HR range (stored separately in workout_type_paces), since the frontend // always edits and displays them together. type workoutKindResponse struct { store.WorkoutKind PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"` PaceMaxSecPerKm *float64 `json:"pace_max_sec_per_km"` HRMinPctHRR *float64 `json:"hr_min_pct_hrr"` HRMaxPctHRR *float64 `json:"hr_max_pct_hrr"` } func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) { userID := userIDFromContext(r.Context()) pace, err := s.DB.GetWorkoutTypePace(r.Context(), userID, k.ID) if err != nil { return workoutKindResponse{}, err } return workoutKindResponse{ WorkoutKind: k, PaceMinSecPerKm: pace.PaceMinSecPerKm, PaceMaxSecPerKm: pace.PaceMaxSecPerKm, HRMinPctHRR: pace.HRMinPctHRR, HRMaxPctHRR: pace.HRMaxPctHRR, }, nil } type workoutKindRequest struct { Name string `json:"name"` Description string `json:"description"` Color string `json:"color"` Rule json.RawMessage `json:"rule"` Priority int `json:"priority"` IsActive *bool `json:"is_active"` PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"` PaceMaxSecPerKm *float64 `json:"pace_max_sec_per_km"` HRMinPctHRR *float64 `json:"hr_min_pct_hrr"` HRMaxPctHRR *float64 `json:"hr_max_pct_hrr"` } func (req workoutKindRequest) validate() (classify.Node, error) { var node classify.Node if req.Name == "" { return node, errors.New("name is required") } if err := json.Unmarshal(req.Rule, &node); err != nil { return node, errors.New("rule is not valid JSON: " + err.Error()) } if err := node.Validate(); err != nil { return node, errors.New("invalid rule: " + err.Error()) } if req.PaceMinSecPerKm != nil && req.PaceMaxSecPerKm != nil && *req.PaceMinSecPerKm >= *req.PaceMaxSecPerKm { return node, errors.New("pace_min_sec_per_km must be less than pace_max_sec_per_km") } if req.HRMinPctHRR != nil && req.HRMaxPctHRR != nil && *req.HRMinPctHRR >= *req.HRMaxPctHRR { return node, errors.New("hr_min_pct_hrr must be less than hr_max_pct_hrr") } return node, nil } func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) activeOnly := r.URL.Query().Get("include_inactive") != "true" kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, activeOnly) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } resp := make([]workoutKindResponse, 0, len(kinds)) for _, k := range kinds { wr, err := s.toWorkoutKindResponse(r, k) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } resp = append(resp, wr) } writeJSON(w, http.StatusOK, resp) } func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid workout kind id") return } kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !ok { writeError(w, http.StatusNotFound, "workout kind not found") return } resp, err := s.toWorkoutKindResponse(r, kind) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, resp) } func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid workout kind id") return } existing, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !ok { writeError(w, http.StatusNotFound, "workout kind not found") return } var req workoutKindRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if _, err := req.validate(); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } isActive := existing.IsActive if req.IsActive != nil { isActive = *req.IsActive } if err := s.DB.UpdateWorkoutKind(r.Context(), userID, store.WorkoutKind{ ID: id, Name: req.Name, Description: req.Description, Color: req.Color, RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive, }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if err := s.DB.UpdateWorkoutTypePace(r.Context(), userID, store.WorkoutTypePace{ WorkoutKindID: id, PaceMinSecPerKm: req.PaceMinSecPerKm, PaceMaxSecPerKm: req.PaceMaxSecPerKm, HRMinPctHRR: req.HRMinPctHRR, HRMaxPctHRR: req.HRMaxPctHRR, }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } kind, _, _ := s.DB.GetWorkoutKind(r.Context(), userID, id) resp, err := s.toWorkoutKindResponse(r, kind) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, resp) }