Files
geniusrun/backend/internal/api/kinds.go

210 lines
6.2 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"smartrun/backend/internal/classify"
"smartrun/backend/internal/store"
)
// workoutKindResponse combines a workout kind's rule/metadata with its pace
// range and expected HR zone (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"`
ExpectedHRZone *int `json:"expected_hr_zone"`
}
func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) {
pace, err := s.DB.GetWorkoutTypePace(r.Context(), k.ID)
if err != nil {
return workoutKindResponse{}, err
}
return workoutKindResponse{
WorkoutKind: k,
PaceMinSecPerKm: pace.PaceMinSecPerKm,
PaceMaxSecPerKm: pace.PaceMaxSecPerKm,
ExpectedHRZone: pace.ExpectedHRZone,
}, 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"`
ExpectedHRZone *int `json:"expected_hr_zone"`
}
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.ExpectedHRZone != nil && (*req.ExpectedHRZone < 1 || *req.ExpectedHRZone > 5) {
return node, errors.New("expected_hr_zone must be between 1 and 5")
}
return node, nil
}
func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) {
activeOnly := r.URL.Query().Get("include_inactive") != "true"
kinds, err := s.DB.ListWorkoutKinds(r.Context(), 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) {
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(), 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) {
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(), 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(), 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(), store.WorkoutTypePace{
WorkoutKindID: id,
PaceMinSecPerKm: req.PaceMinSecPerKm,
PaceMaxSecPerKm: req.PaceMaxSecPerKm,
ExpectedHRZone: req.ExpectedHRZone,
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id)
resp, err := s.toWorkoutKindResponse(r, kind)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
}
// handleReclassifyKind re-runs the rule engine for every activity currently
// assigned to (or under review for) this kind. It's a synchronous, bounded
// operation (unlike sync), so it runs inline rather than in the background.
func (s *Server) handleReclassifyKind(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid workout kind id")
return
}
assignments, err := s.DB.AssignmentsForKind(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
reviewQueue, err := s.DB.ReviewQueue(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
seen := make(map[int64]bool)
var activityIDs []int64
for _, a := range assignments {
if !seen[a.ActivityID] {
seen[a.ActivityID] = true
activityIDs = append(activityIDs, a.ActivityID)
}
}
for _, a := range reviewQueue {
if !seen[a.ActivityID] {
seen[a.ActivityID] = true
activityIDs = append(activityIDs, a.ActivityID)
}
}
for _, activityID := range activityIDs {
if err := s.Sync.ClassifyActivity(r.Context(), activityID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]int{"reclassified": len(activityIDs)})
}