package integration import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "rideaware/internal/config" "rideaware/internal/user" "rideaware/internal/workout" ) type WahooClient struct { oauthService *OAuthService workoutRepo *workout.Repository userRepo *user.Repository } func NewWahooClient() *WahooClient { return &WahooClient{ oauthService: NewOAuthService(), workoutRepo: workout.NewRepository(), userRepo: user.NewRepository(), } } // BuildAuthURL constructs the Wahoo OAuth2 authorization URL. func (c *WahooClient) BuildAuthURL(userID uint) (string, error) { cfg := config.OAuth.Wahoo if cfg.ClientID == "" { return "", fmt.Errorf("Wahoo OAuth is not configured") } stateToken, err := c.oauthService.GenerateState(userID, "wahoo") if err != nil { return "", err } params := url.Values{ "client_id": {cfg.ClientID}, "response_type": {"code"}, "redirect_uri": {cfg.RedirectURI}, "scope": {"workouts_write plans_write user_read offline_data"}, "state": {stateToken}, } return cfg.AuthURL + "?" + params.Encode(), nil } // HandleCallback exchanges the authorization code for tokens and stores them. func (c *WahooClient) HandleCallback(code, stateToken string) (uint, error) { state, err := c.oauthService.ValidateState(stateToken) if err != nil { return 0, err } if state.Provider != "wahoo" { return 0, fmt.Errorf("invalid state provider: expected wahoo, got %s", state.Provider) } cfg := config.OAuth.Wahoo params := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {cfg.RedirectURI}, "client_id": {cfg.ClientID}, "client_secret": {cfg.ClientSecret}, } tokenResp, err := c.oauthService.ExchangeCode(cfg.TokenURL, params) if err != nil { return 0, fmt.Errorf("Wahoo token exchange failed: %w", err) } if err := c.oauthService.SaveConnection(state.UserID, "wahoo", tokenResp); err != nil { return 0, err } return state.UserID, nil } // Wahoo plan JSON structures type wahooPlanHeader struct { Version string `json:"version"` Name string `json:"name"` Description string `json:"description"` WorkoutTypeFamily string `json:"workout_type_family"` WorkoutTypeLocation string `json:"workout_type_location"` FTP int `json:"ftp"` TotalDuration int `json:"total_duration"` } type wahooPlanTarget struct { Type string `json:"type"` Low float64 `json:"low"` High float64 `json:"high"` } type wahooPlanInterval struct { Name string `json:"name"` Duration int `json:"duration"` IntensityType string `json:"intensity_type"` Targets []wahooPlanTarget `json:"targets"` } type wahooPlan struct { Header wahooPlanHeader `json:"header"` Intervals []wahooPlanInterval `json:"intervals"` } // PushWorkout sends a structured workout to Wahoo as a plan. func (c *WahooClient) PushWorkout(workoutID, userID uint) error { accessToken, err := c.oauthService.GetValidToken(userID, "wahoo") if err != nil { return err } w, err := c.workoutRepo.GetWorkoutByID(workoutID, userID) if err != nil { return fmt.Errorf("workout not found: %w", err) } u, err := c.userRepo.GetUserByID(userID) if err != nil { return fmt.Errorf("user not found: %w", err) } ftp := 0 if u.Profile != nil { ftp = u.Profile.FTP } plan := buildWahooPlan(w, ftp) planJSON, err := json.Marshal(plan) if err != nil { return fmt.Errorf("failed to build Wahoo plan: %w", err) } // Step 1: Create plan via Wahoo API planID, err := c.createWahooPlan(accessToken, planJSON, w.Title) if err != nil { return err } // Step 2: Create workout referencing the plan if err := c.createWahooWorkout(accessToken, planID, w); err != nil { return err } return nil } func buildWahooPlan(w *workout.Workout, ftp int) wahooPlan { name := w.WorkoutData.Name if name == "" { name = w.Title } plan := wahooPlan{ Header: wahooPlanHeader{ Version: "1.0", Name: name, Description: w.Description, WorkoutTypeFamily: "CYCLING", WorkoutTypeLocation: "INDOOR", FTP: ftp, TotalDuration: w.WorkoutData.TotalDuration, }, } for _, seg := range w.WorkoutData.Segments { interval := segmentToWahooInterval(seg) plan.Intervals = append(plan.Intervals, interval) } return plan } func segmentToWahooInterval(seg workout.WorkoutSegment) wahooPlanInterval { interval := wahooPlanInterval{ Duration: seg.Duration, } switch seg.Type { case "warmup": interval.Name = "Warm Up" interval.IntensityType = "WARMUP" if seg.PowerLow != 0 || seg.PowerHigh != 0 { interval.Targets = []wahooPlanTarget{{ Type: "POWER_ZONE", Low: seg.PowerLow, High: seg.PowerHigh, }} } case "steadystate": interval.Name = "Steady State" interval.IntensityType = "ACTIVE" if seg.Power != 0 { interval.Targets = []wahooPlanTarget{{ Type: "POWER_ZONE", Low: seg.Power, High: seg.Power, }} } case "interval": interval.Name = "Interval" interval.IntensityType = "ACTIVE" if seg.PowerLow != 0 || seg.PowerHigh != 0 { interval.Targets = []wahooPlanTarget{{ Type: "POWER_ZONE", Low: seg.PowerLow, High: seg.PowerHigh, }} } case "cooldown": interval.Name = "Cool Down" interval.IntensityType = "COOLDOWN" if seg.PowerLow != 0 || seg.PowerHigh != 0 { interval.Targets = []wahooPlanTarget{{ Type: "POWER_ZONE", Low: seg.PowerLow, High: seg.PowerHigh, }} } case "ramp": interval.Name = "Ramp" interval.IntensityType = "ACTIVE" if seg.PowerLow != 0 || seg.PowerHigh != 0 { interval.Targets = []wahooPlanTarget{{ Type: "POWER_ZONE", Low: seg.PowerLow, High: seg.PowerHigh, }} } case "freeride": interval.Name = "Free Ride" interval.IntensityType = "REST" default: interval.Name = seg.Type interval.IntensityType = "ACTIVE" } return interval } func (c *WahooClient) createWahooPlan(accessToken string, planJSON []byte, title string) (string, error) { apiURL := "https://api.wahooligan.com/v1/plans" body := map[string]interface{}{ "plan": map[string]interface{}{ "name": title, "file": planJSON, }, } jsonBody, err := json.Marshal(body) if err != nil { return "", err } req, err := http.NewRequest("POST", apiURL, bytes.NewReader(jsonBody)) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return "", fmt.Errorf("failed to create Wahoo plan: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return "", err } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return "", fmt.Errorf("Wahoo API returned status %d: %s", resp.StatusCode, string(respBody)) } var result struct { ID string `json:"id"` } if err := json.Unmarshal(respBody, &result); err != nil { return "", fmt.Errorf("failed to parse Wahoo plan response: %w", err) } return result.ID, nil } func (c *WahooClient) createWahooWorkout(accessToken, planID string, w *workout.Workout) error { apiURL := "https://api.wahooligan.com/v1/workouts" body := map[string]interface{}{ "workout": map[string]interface{}{ "name": w.Title, "plan_id": planID, "workout_type": 0, // cycling "scheduled_date": w.ScheduledDate.Format("2006-01-02"), }, } jsonBody, err := json.Marshal(body) if err != nil { return err } req, err := http.NewRequest("POST", apiURL, bytes.NewReader(jsonBody)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("failed to create Wahoo workout: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("Wahoo API returned status %d: %s", resp.StatusCode, string(respBody)) } return nil }