package api import ( "encoding/json" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" "smartrun/backend/internal/classify" "smartrun/backend/internal/store" ) 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"` } 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()) } 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 } writeJSON(w, http.StatusOK, kinds) } 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 } writeJSON(w, http.StatusOK, kind) } func (s *Server) handleCreateWorkoutKind(w http.ResponseWriter, r *http.Request) { 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 := true if req.IsActive != nil { isActive = *req.IsActive } id, err := s.DB.CreateWorkoutKind(r.Context(), store.WorkoutKind{ Name: req.Name, Description: req.Description, Color: req.Color, RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive, }) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id) writeJSON(w, http.StatusCreated, kind) } 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 } kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id) writeJSON(w, http.StatusOK, kind) } func (s *Server) handleDeleteWorkoutKind(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 } if err := s.DB.SoftDeleteWorkoutKind(r.Context(), id); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } // 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)}) }