package api import ( "encoding/json" "errors" "net/http" "strings" "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 } // profilePayload is the profile API's request/response shape: the profiles // row plus Name, which lives on the users row (the account's single // human-facing name) but is edited from the same Profile screen. type profilePayload struct { store.Profile Name string } 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 } u, _ := userFromContext(r.Context()) writeJSON(w, http.StatusOK, profilePayload{Profile: p, Name: u.Name}) } func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) var p profilePayload if err := json.NewDecoder(r.Body).Decode(&p); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if strings.TrimSpace(p.Name) == "" { writeError(w, http.StatusBadRequest, "name is required") return } if err := validateProfile(p.Profile); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if err := s.DB.UpdateProfile(r.Context(), userID, p.Profile); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if err := s.DB.UpdateUserName(r.Context(), userID, strings.TrimSpace(p.Name)); 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, profilePayload{Profile: updated, Name: strings.TrimSpace(p.Name)}) } // 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) }