package integration import ( "bytes" "fmt" "io" "net/http" "net/url" "rideaware/internal/config" "rideaware/internal/export" "rideaware/internal/user" "rideaware/internal/workout" ) type GarminClient struct { oauthService *OAuthService workoutRepo *workout.Repository userRepo *user.Repository } func NewGarminClient() *GarminClient { return &GarminClient{ oauthService: NewOAuthService(), workoutRepo: workout.NewRepository(), userRepo: user.NewRepository(), } } // BuildAuthURL constructs the Garmin OAuth2 PKCE authorization URL. func (c *GarminClient) BuildAuthURL(userID uint) (string, error) { cfg := config.OAuth.Garmin if cfg.ClientID == "" { return "", fmt.Errorf("Garmin OAuth is not configured") } stateToken, _, codeChallenge, err := c.oauthService.GenerateStateWithPKCE(userID, "garmin") if err != nil { return "", err } params := url.Values{ "client_id": {cfg.ClientID}, "response_type": {"code"}, "redirect_uri": {cfg.RedirectURI}, "scope": {"TRAINING_API"}, "state": {stateToken}, "code_challenge": {codeChallenge}, "code_challenge_method": {"S256"}, } return cfg.AuthURL + "?" + params.Encode(), nil } // HandleCallback exchanges the authorization code for tokens and stores them. func (c *GarminClient) HandleCallback(code, stateToken string) (uint, error) { state, err := c.oauthService.ValidateState(stateToken) if err != nil { return 0, err } if state.Provider != "garmin" { return 0, fmt.Errorf("invalid state provider: expected garmin, got %s", state.Provider) } cfg := config.OAuth.Garmin params := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {cfg.RedirectURI}, "client_id": {cfg.ClientID}, "code_verifier": {state.CodeVerifier}, } tokenResp, err := c.oauthService.ExchangeCode(cfg.TokenURL, params) if err != nil { return 0, fmt.Errorf("Garmin token exchange failed: %w", err) } if err := c.oauthService.SaveConnection(state.UserID, "garmin", tokenResp); err != nil { return 0, err } return state.UserID, nil } // PushWorkout sends a structured workout to Garmin Connect via the Training API. // If the Training API is not yet available, returns an error with instructions to use FIT export. func (c *GarminClient) PushWorkout(workoutID, userID uint) error { accessToken, err := c.oauthService.GetValidToken(userID, "garmin") 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 } if ftp <= 0 { return fmt.Errorf("FTP must be set in your profile before pushing workouts") } fitData, err := export.EncodeFITWorkout(w, ftp) if err != nil { return fmt.Errorf("failed to generate FIT workout: %w", err) } // Push FIT file to Garmin Training API apiURL := "https://apis.garmin.com/training-api/workout" req, err := http.NewRequest("POST", apiURL, bytes.NewReader(fitData)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/octet-stream") resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("failed to push workout to Garmin: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("Garmin API returned status %d: %s", resp.StatusCode, string(body)) } return nil }