package api import ( "encoding/json" "errors" "net/http" "geniusrun/backend/internal/store" ) // validateProfile checks the HR zones are ascending and gap-free once they // start, but deliberately does NOT require zone 1 to start at 0% or zone 5 // to end at 100%: real Karvonen HR training zones (e.g. the seeded defaults, // 50-60/60-70/70-80/80-90/90-100) don't cover the 0-50% range at all -- // below zone 1 simply isn't a named training zone. func validateProfile(p store.Profile) error { if p.RestingHeartRate != nil && p.MaxHeartRate != nil && *p.RestingHeartRate >= *p.MaxHeartRate { return errors.New("resting heart rate must be less than max heart rate") } zones := [][2]float64{ {p.HRZone1MinPct, p.HRZone1MaxPct}, {p.HRZone2MinPct, p.HRZone2MaxPct}, {p.HRZone3MinPct, p.HRZone3MaxPct}, {p.HRZone4MinPct, p.HRZone4MaxPct}, {p.HRZone5MinPct, p.HRZone5MaxPct}, } for i, z := range zones { if z[0] >= z[1] { return errors.New("each HR zone's min must be less than its max") } if i > 0 && z[0] != zones[i-1][1] { return errors.New("HR zones must be contiguous and non-overlapping") } } return nil } func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) p, err := s.DB.GetProfile(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, p) } func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) var p store.Profile if err := json.NewDecoder(r.Body).Decode(&p); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if err := validateProfile(p); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if err := s.DB.UpdateProfile(r.Context(), userID, p); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } client, err := s.clientFor(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } client.UpdateCredentials(p.GarminEmail, p.GarminPassword) updated, err := s.DB.GetProfile(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, updated) } // handleDeleteProfile permanently deletes the signed-in user's entire // geniusrun account (profile, workout kinds/paces, activities and // everything under them, sync state/runs -- see schema.sql's ON DELETE // CASCADE from users(id)) and tears down their cached Garmin client and // token-store directory. It does not touch the session cookie itself -- // the frontend follows a successful call with a real logout navigation // (see docs/superpowers/specs/2026-07-26-profile-deletion-design.md). func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) s.mu.Lock() inProgress := s.userSyncRunning[userID] s.mu.Unlock() if inProgress { writeError(w, http.StatusConflict, "a sync is in progress for this account; wait for it to finish before deleting your profile") return } if err := s.DB.DeleteUser(r.Context(), userID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.removeUserClient(userID) w.WriteHeader(http.StatusNoContent) }