From 781c14aa2d9fc2838482008ee6e189705406b0ee Mon Sep 17 00:00:00 2001 From: Aleksandr Fenin Date: Thu, 23 Oct 2025 19:41:30 +0300 Subject: [PATCH] api/migrate-to-wger: replace ExerciseDB with wger API integration --- cmd/server/main.go | 6 +- docker-compose.yml | 7 +- env.example | 18 +- internal/clients/exercisedb_client.go | 218 ----------- internal/clients/exercisedb_client_test.go | 213 ----------- internal/clients/wger_client.go | 280 +++++++++++++++ internal/clients/wger_client_test.go | 340 ++++++++++++++++++ internal/config/config.go | 16 +- internal/http/exercise_handlers.go | 4 +- internal/models/exercise.go | 42 ++- internal/models/wger_models.go | 62 ++++ internal/repositories/exercise_repository.go | 228 ++++++------ .../repositories/exercise_repository_test.go | 49 +-- internal/services/exercise_cache_service.go | 110 +++--- internal/services/exercise_service_test.go | 18 +- migrations/000005_exercises.down.sql | 9 +- migrations/000005_exercises.up.sql | 69 ++-- 17 files changed, 972 insertions(+), 717 deletions(-) delete mode 100644 internal/clients/exercisedb_client.go delete mode 100644 internal/clients/exercisedb_client_test.go create mode 100644 internal/clients/wger_client.go create mode 100644 internal/clients/wger_client_test.go create mode 100644 internal/models/wger_models.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 1cce3fc..70da3a9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -120,9 +120,9 @@ func setupServices(db *database.Database, cfg *config.Config) *Services { calorieService := services.NewCalorieService(calorieRepo) var exerciseService services.ExerciseService - if cfg.ExerciseDB.Enabled { - exerciseDBClient := clients.NewExerciseDBClient(&cfg.ExerciseDB, logger.New(cfg.Log.Level, cfg.Log.Format)) - cacheService := services.NewExerciseCacheService(exerciseRepo, exerciseDBClient, logger.New(cfg.Log.Level, cfg.Log.Format), 24*time.Hour) + if cfg.Wger.Enabled { + wgerClient := clients.NewWgerClient(&cfg.Wger, logger.New(cfg.Log.Level, cfg.Log.Format)) + cacheService := services.NewExerciseCacheService(exerciseRepo, wgerClient, logger.New(cfg.Log.Level, cfg.Log.Format), 24*time.Hour) exerciseService = services.NewExerciseService(exerciseRepo, cacheService, logger.New(cfg.Log.Level, cfg.Log.Format)) } else { exerciseService = nil diff --git a/docker-compose.yml b/docker-compose.yml index a22b7a0..737f723 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,21 +15,20 @@ services: - DB_PASSWORD=password - DB_NAME=strive - DB_SSL_MODE=disable - - JWT_SECRET=production-secret-key-very-long-and-secure - JWT_ISSUER=strive-api - JWT_AUDIENCE=strive-app - JWT_CLOCK_SKEW=2m - - CORS_ALLOWED_ORIGINS=https://your-production-frontend.com + - CORS_ALLOWED_ORIGINS=http://localhost:4200,http://127.0.0.1:4200,http://192.168.1.186:4200,https://satanlittlehelper.github.io - CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS - CORS_ALLOWED_HEADERS=Accept,Authorization,Content-Type,X-Request-ID - CORS_EXPOSED_HEADERS=X-Request-ID - CORS_ALLOW_CREDENTIALS=true - CORS_MAX_AGE=86400 - COOKIE_SECURE=true - - COOKIE_SAMESITE=Strict + - COOKIE_SAMESITE=None - COOKIE_DOMAIN= - RATE_LIMIT_ENABLED=true - - RATE_LIMIT_AUTH_PER_MINUTE=5 + - RATE_LIMIT_AUTH_PER_MINUTE=20 - RATE_LIMIT_GENERAL_PER_MINUTE=60 - RATE_LIMIT_BURST_SIZE=10 - EXERCISEDB_ENABLED=true diff --git a/env.example b/env.example index d30cbb8..c3ee8e2 100644 --- a/env.example +++ b/env.example @@ -79,15 +79,17 @@ SECURITY_REFERRER_POLICY=strict-origin-when-cross-origin # X-XSS-Protection (0, 1, 1; mode=block) SECURITY_XSS_PROTECTION=1; mode=block -# ExerciseDB Configuration -# Base URL for ExerciseDB API -EXERCISEDB_BASE_URL=https://exercise.hellogym.io -# Request timeout for ExerciseDB API -EXERCISEDB_TIMEOUT=30s +# wger API Configuration +# Base URL for wger API +WGER_API_BASE_URL=https://wger.de/api/v2 +# API key for wger API (optional, but recommended for higher rate limits) +WGER_API_KEY= +# Request timeout for wger API +WGER_API_TIMEOUT=30s # Number of retry attempts for failed requests -EXERCISEDB_RETRY_COUNT=3 -# Enable/disable ExerciseDB integration -EXERCISEDB_ENABLED=true +WGER_API_RETRY_COUNT=3 +# Enable/disable wger API integration +WGER_API_ENABLED=true # Security Requirements: # - JWT_SECRET must be at least 32 characters diff --git a/internal/clients/exercisedb_client.go b/internal/clients/exercisedb_client.go deleted file mode 100644 index ec12e15..0000000 --- a/internal/clients/exercisedb_client.go +++ /dev/null @@ -1,218 +0,0 @@ -package clients - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/aleksandr/strive-api/internal/config" - "github.com/aleksandr/strive-api/internal/logger" -) - -type ExerciseDBClient struct { - baseURL string - httpClient *http.Client - logger *logger.Logger - retryCount int -} - -type ExerciseDBResponse struct { - ID int `json:"id"` - LicenseAuthor string `json:"license_author"` - Status string `json:"status"` - Description string `json:"description"` - Name string `json:"name"` - NameOriginal string `json:"name_original"` - CreationDate string `json:"creation_date"` - UUID string `json:"uuid"` - License int `json:"license"` - Category int `json:"category"` - Language int `json:"language"` - Muscles []int `json:"muscles"` - MusclesSecondary []int `json:"muscles_secondary"` - Equipment []int `json:"equipment"` -} - -type MuscleGroupResponse struct { - ID int `json:"id"` - Name string `json:"name"` -} - -type EquipmentResponse struct { - ID int `json:"id"` - Name string `json:"name"` -} - -type TargetResponse struct { - ID int `json:"id"` - Name string `json:"name"` -} - -func NewExerciseDBClient(cfg *config.ExerciseDBConfig, logger *logger.Logger) *ExerciseDBClient { - return &ExerciseDBClient{ - baseURL: cfg.BaseURL, - httpClient: &http.Client{ - Timeout: cfg.Timeout, - }, - logger: logger, - retryCount: cfg.RetryCount, - } -} - -func (c *ExerciseDBClient) GetAllExercises(ctx context.Context) ([]ExerciseDBResponse, error) { - url := fmt.Sprintf("%s/api/v2/exercise/", c.baseURL) - var result []ExerciseDBResponse - err := c.makeRequest(ctx, url, &result) - if err != nil { - return nil, err - } - return result, nil -} - -func (c *ExerciseDBClient) GetExercisesByCategory(ctx context.Context, category int) ([]ExerciseDBResponse, error) { - url := fmt.Sprintf("%s/api/v2/exercise/?category=%d", c.baseURL, category) - var result []ExerciseDBResponse - err := c.makeRequest(ctx, url, &result) - if err != nil { - return nil, err - } - return result, nil -} - -func (c *ExerciseDBClient) GetExercisesByMuscle(ctx context.Context, muscleID int) ([]ExerciseDBResponse, error) { - url := fmt.Sprintf("%s/api/v2/exercise/?muscles=%d", c.baseURL, muscleID) - var result []ExerciseDBResponse - err := c.makeRequest(ctx, url, &result) - if err != nil { - return nil, err - } - return result, nil -} - -func (c *ExerciseDBClient) GetExercisesByEquipment(ctx context.Context, equipmentID int) ([]ExerciseDBResponse, error) { - url := fmt.Sprintf("%s/api/v2/exercise/?equipment=%d", c.baseURL, equipmentID) - var result []ExerciseDBResponse - err := c.makeRequest(ctx, url, &result) - if err != nil { - return nil, err - } - return result, nil -} - -func (c *ExerciseDBClient) GetExerciseByID(ctx context.Context, id int) (*ExerciseDBResponse, error) { - url := fmt.Sprintf("%s/api/v2/exercise/%d", c.baseURL, id) - var result []ExerciseDBResponse - err := c.makeRequest(ctx, url, &result) - if err != nil { - return nil, err - } - if len(result) == 0 { - return nil, fmt.Errorf("exercise with id %d not found", id) - } - return &result[0], nil -} - -func (c *ExerciseDBClient) GetMuscleGroups(ctx context.Context) ([]MuscleGroupResponse, error) { - url := fmt.Sprintf("%s/api/v2/bodypart/", c.baseURL) - var result []MuscleGroupResponse - err := c.makeRequest(ctx, url, &result) - if err != nil { - return nil, err - } - return result, nil -} - -func (c *ExerciseDBClient) GetEquipment(ctx context.Context) ([]EquipmentResponse, error) { - url := fmt.Sprintf("%s/api/v2/equipment/", c.baseURL) - var result []EquipmentResponse - err := c.makeRequest(ctx, url, &result) - if err != nil { - return nil, err - } - return result, nil -} - -func (c *ExerciseDBClient) GetTargets(ctx context.Context) ([]TargetResponse, error) { - url := fmt.Sprintf("%s/api/v2/target/", c.baseURL) - var result []TargetResponse - err := c.makeRequest(ctx, url, &result) - if err != nil { - return nil, err - } - return result, nil -} - -func (c *ExerciseDBClient) makeRequest(ctx context.Context, url string, result interface{}) error { - var lastErr error - - for attempt := 0; attempt <= c.retryCount; attempt++ { - if attempt > 0 { - backoff := time.Duration(attempt) * time.Second - c.logger.Debug("Retrying request", "attempt", attempt, "backoff", backoff, "url", url) - time.Sleep(backoff) - } - - req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody) - if err != nil { - lastErr = fmt.Errorf("failed to create request: %w", err) - continue - } - - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "Strive-API/1.0") - - c.logger.Debug("Making request to ExerciseDB", "url", url, "attempt", attempt+1) - - resp, err := c.httpClient.Do(req) - if err != nil { - lastErr = fmt.Errorf("failed to make request: %w", err) - c.logger.Warn("Request failed, will retry", "error", err, "url", url, "attempt", attempt+1) - continue - } - - if resp.StatusCode == http.StatusOK { - body, err := io.ReadAll(resp.Body) - _ = resp.Body.Close() - - if err != nil { - lastErr = fmt.Errorf("failed to read response body: %w", err) - c.logger.Warn("Failed to read response body, will retry", "error", err, "url", url, "attempt", attempt+1) - continue - } - - if err := json.Unmarshal(body, &result); err != nil { - lastErr = fmt.Errorf("failed to unmarshal response: %w", err) - c.logger.Warn("Failed to unmarshal response, will retry", "error", err, "url", url, "attempt", attempt+1) - continue - } - - c.logger.Debug("Successfully received response from ExerciseDB", "url", url, "attempt", attempt+1) - return nil - } - - body, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - - if resp.StatusCode >= 500 { - lastErr = fmt.Errorf("API returned server error %d: %s", resp.StatusCode, string(body)) - c.logger.Warn("Server error, will retry", "status", resp.StatusCode, "url", url, "attempt", attempt+1) - continue - } - - lastErr = fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) - c.logger.Error("Client error, will not retry", "status", resp.StatusCode, "body", string(body), "url", url) - break - } - - c.logger.Error("All retry attempts failed", "error", lastErr, "url", url, "retry_count", c.retryCount) - return lastErr -} - -func (c *ExerciseDBClient) HealthCheck(ctx context.Context) error { - url := fmt.Sprintf("%s/api/v2/exercise/?limit=1", c.baseURL) - var result []ExerciseDBResponse - return c.makeRequest(ctx, url, &result) -} diff --git a/internal/clients/exercisedb_client_test.go b/internal/clients/exercisedb_client_test.go deleted file mode 100644 index eb65f50..0000000 --- a/internal/clients/exercisedb_client_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package clients - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/aleksandr/strive-api/internal/config" - "github.com/aleksandr/strive-api/internal/logger" -) - -const exerciseAPIPath = "/api/v2/exercise/" - -func TestExerciseDBClient_GetAllExercises(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != exerciseAPIPath { - t.Errorf("Expected path %s, got %s", exerciseAPIPath, r.URL.Path) - } - - exercises := []ExerciseDBResponse{ - { - ID: 1, - Name: "Push-up", - Description: "Basic push-up exercise", - Category: 9, - Muscles: []int{1, 2}, - MusclesSecondary: []int{3}, - Equipment: []int{}, - }, - { - ID: 2, - Name: "Squat", - Description: "Basic squat exercise", - Category: 9, - Muscles: []int{10}, - MusclesSecondary: []int{8}, - Equipment: []int{}, - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(exercises) - })) - defer mockServer.Close() - - cfg := &config.ExerciseDBConfig{ - BaseURL: mockServer.URL, - Timeout: 5 * time.Second, - RetryCount: 2, - Enabled: true, - } - - logger := logger.New("DEBUG", "text") - client := NewExerciseDBClient(cfg, logger) - - ctx := context.Background() - exercises, err := client.GetAllExercises(ctx) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if len(exercises) != 2 { - t.Errorf("Expected 2 exercises, got %d", len(exercises)) - } - - if exercises[0].Name != "Push-up" { - t.Errorf("Expected first exercise to be 'Push-up', got '%s'", exercises[0].Name) - } -} - -func TestExerciseDBClient_GetExercisesByCategory(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - expectedPath := "/api/v2/exercise/" - if r.URL.Path != expectedPath { - t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) - } - - expectedCategory := "9" - if r.URL.Query().Get("category") != expectedCategory { - t.Errorf("Expected category %s, got %s", expectedCategory, r.URL.Query().Get("category")) - } - - exercises := []ExerciseDBResponse{ - { - ID: 1, - Name: "Bench Press", - Category: 9, - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(exercises) - })) - defer mockServer.Close() - - cfg := &config.ExerciseDBConfig{ - BaseURL: mockServer.URL, - Timeout: 5 * time.Second, - RetryCount: 2, - Enabled: true, - } - - logger := logger.New("DEBUG", "text") - client := NewExerciseDBClient(cfg, logger) - - ctx := context.Background() - exercises, err := client.GetExercisesByCategory(ctx, 9) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if len(exercises) != 1 { - t.Errorf("Expected 1 exercise, got %d", len(exercises)) - } - - if exercises[0].Name != "Bench Press" { - t.Errorf("Expected exercise to be 'Bench Press', got '%s'", exercises[0].Name) - } -} - -func TestExerciseDBClient_RetryLogic(t *testing.T) { - attemptCount := 0 - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - attemptCount++ - if attemptCount <= 2 { - w.WriteHeader(http.StatusInternalServerError) - return - } - - exercises := []ExerciseDBResponse{ - { - ID: 1, - Name: "Success Exercise", - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(exercises) - })) - defer mockServer.Close() - - cfg := &config.ExerciseDBConfig{ - BaseURL: mockServer.URL, - Timeout: 5 * time.Second, - RetryCount: 3, - Enabled: true, - } - - logger := logger.New("DEBUG", "text") - client := NewExerciseDBClient(cfg, logger) - - ctx := context.Background() - exercises, err := client.GetAllExercises(ctx) - if err != nil { - t.Fatalf("Expected no error after retries, got %v", err) - } - - if len(exercises) != 1 { - t.Errorf("Expected 1 exercise, got %d", len(exercises)) - } - - if exercises[0].Name != "Success Exercise" { - t.Errorf("Expected exercise to be 'Success Exercise', got '%s'", exercises[0].Name) - } - - if attemptCount != 3 { - t.Errorf("Expected 3 attempts, got %d", attemptCount) - } -} - -func TestExerciseDBClient_HealthCheck(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - expectedPath := "/api/v2/exercise/" - if r.URL.Path != expectedPath { - t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) - } - - expectedLimit := "1" - if r.URL.Query().Get("limit") != expectedLimit { - t.Errorf("Expected limit %s, got %s", expectedLimit, r.URL.Query().Get("limit")) - } - - exercises := []ExerciseDBResponse{ - { - ID: 1, - Name: "Health Check Exercise", - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(exercises) - })) - defer mockServer.Close() - - cfg := &config.ExerciseDBConfig{ - BaseURL: mockServer.URL, - Timeout: 5 * time.Second, - RetryCount: 2, - Enabled: true, - } - - logger := logger.New("DEBUG", "text") - client := NewExerciseDBClient(cfg, logger) - - ctx := context.Background() - err := client.HealthCheck(ctx) - if err != nil { - t.Fatalf("Expected no error from health check, got %v", err) - } -} diff --git a/internal/clients/wger_client.go b/internal/clients/wger_client.go new file mode 100644 index 0000000..5c23805 --- /dev/null +++ b/internal/clients/wger_client.go @@ -0,0 +1,280 @@ +package clients + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/aleksandr/strive-api/internal/config" + "github.com/aleksandr/strive-api/internal/logger" + "github.com/aleksandr/strive-api/internal/models" +) + +type WgerClient struct { + baseURL string + apiKey string + httpClient *http.Client + logger *logger.Logger + retryCount int +} + +func NewWgerClient(cfg *config.WgerConfig, logger *logger.Logger) *WgerClient { + return &WgerClient{ + baseURL: cfg.BaseURL, + apiKey: cfg.APIKey, + httpClient: &http.Client{ + Timeout: cfg.Timeout, + }, + logger: logger, + retryCount: cfg.RetryCount, + } +} + +func (c *WgerClient) GetAllExercises(ctx context.Context) ([]models.WgerExercise, error) { + var allExercises []models.WgerExercise + url := fmt.Sprintf("%s/exerciseinfo/?limit=100", c.baseURL) + + for url != "" { + var response models.WgerExerciseListResponse + err := c.makeRequest(ctx, url, &response) + if err != nil { + return nil, err + } + + allExercises = append(allExercises, response.Results...) + + if response.Next != nil { + url = *response.Next + } else { + url = "" + } + } + + return allExercises, nil +} + +func (c *WgerClient) GetExerciseByID(ctx context.Context, id int) (*models.WgerExercise, error) { + url := fmt.Sprintf("%s/exerciseinfo/%d/", c.baseURL, id) + var result models.WgerExercise + err := c.makeRequest(ctx, url, &result) + if err != nil { + return nil, err + } + return &result, nil +} + +func (c *WgerClient) GetExercisesByCategory(ctx context.Context, category int) ([]models.WgerExercise, error) { + var allExercises []models.WgerExercise + url := fmt.Sprintf("%s/exerciseinfo/?category=%d&limit=100", c.baseURL, category) + + for url != "" { + var response models.WgerExerciseListResponse + err := c.makeRequest(ctx, url, &response) + if err != nil { + return nil, err + } + + allExercises = append(allExercises, response.Results...) + + if response.Next != nil { + url = *response.Next + } else { + url = "" + } + } + + return allExercises, nil +} + +func (c *WgerClient) GetExercisesByMuscle(ctx context.Context, muscleID int) ([]models.WgerExercise, error) { + var allExercises []models.WgerExercise + url := fmt.Sprintf("%s/exerciseinfo/?muscles=%d&limit=100", c.baseURL, muscleID) + + for url != "" { + var response models.WgerExerciseListResponse + err := c.makeRequest(ctx, url, &response) + if err != nil { + return nil, err + } + + allExercises = append(allExercises, response.Results...) + + if response.Next != nil { + url = *response.Next + } else { + url = "" + } + } + + return allExercises, nil +} + +func (c *WgerClient) GetExercisesByEquipment(ctx context.Context, equipmentID int) ([]models.WgerExercise, error) { + var allExercises []models.WgerExercise + url := fmt.Sprintf("%s/exerciseinfo/?equipment=%d&limit=100", c.baseURL, equipmentID) + + for url != "" { + var response models.WgerExerciseListResponse + err := c.makeRequest(ctx, url, &response) + if err != nil { + return nil, err + } + + allExercises = append(allExercises, response.Results...) + + if response.Next != nil { + url = *response.Next + } else { + url = "" + } + } + + return allExercises, nil +} + +func (c *WgerClient) GetMuscles(ctx context.Context) ([]models.WgerMuscle, error) { + var allMuscles []models.WgerMuscle + url := fmt.Sprintf("%s/muscle/?limit=100", c.baseURL) + + for url != "" { + var response models.WgerMuscleListResponse + err := c.makeRequest(ctx, url, &response) + if err != nil { + return nil, err + } + + allMuscles = append(allMuscles, response.Results...) + + if response.Next != nil { + url = *response.Next + } else { + url = "" + } + } + + return allMuscles, nil +} + +func (c *WgerClient) GetEquipment(ctx context.Context) ([]models.WgerEquipment, error) { + var allEquipment []models.WgerEquipment + url := fmt.Sprintf("%s/equipment/?limit=100", c.baseURL) + + for url != "" { + var response models.WgerEquipmentListResponse + err := c.makeRequest(ctx, url, &response) + if err != nil { + return nil, err + } + + allEquipment = append(allEquipment, response.Results...) + + if response.Next != nil { + url = *response.Next + } else { + url = "" + } + } + + return allEquipment, nil +} + +func (c *WgerClient) GetCategories(ctx context.Context) ([]models.WgerCategory, error) { + var allCategories []models.WgerCategory + url := fmt.Sprintf("%s/exercisecategory/?limit=100", c.baseURL) + + for url != "" { + var response models.WgerCategoryListResponse + err := c.makeRequest(ctx, url, &response) + if err != nil { + return nil, err + } + + allCategories = append(allCategories, response.Results...) + + if response.Next != nil { + url = *response.Next + } else { + url = "" + } + } + + return allCategories, nil +} + +func (c *WgerClient) makeRequest(ctx context.Context, url string, result interface{}) error { + var lastErr error + + for attempt := 0; attempt <= c.retryCount; attempt++ { + if attempt > 0 { + backoff := time.Duration(attempt) * time.Second + c.logger.Debug("Retrying request", "attempt", attempt, "backoff", backoff, "url", url) + time.Sleep(backoff) + } + + req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody) + if err != nil { + lastErr = fmt.Errorf("failed to create request: %w", err) + continue + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Strive-API/1.0") + if c.apiKey != "" { + req.Header.Set("Authorization", fmt.Sprintf("Token %s", c.apiKey)) + } + + c.logger.Debug("Making request to wger API", "url", url, "attempt", attempt+1) + + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = fmt.Errorf("failed to make request: %w", err) + c.logger.Warn("Request failed, will retry", "error", err, "url", url, "attempt", attempt+1) + continue + } + + if resp.StatusCode == http.StatusOK { + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + if err != nil { + lastErr = fmt.Errorf("failed to read response body: %w", err) + c.logger.Warn("Failed to read response body, will retry", "error", err, "url", url, "attempt", attempt+1) + continue + } + + if err := json.Unmarshal(body, &result); err != nil { + lastErr = fmt.Errorf("failed to unmarshal response: %w", err) + c.logger.Warn("Failed to unmarshal response, will retry", "error", err, "url", url, "attempt", attempt+1) + continue + } + + c.logger.Debug("Successfully received response from wger API", "url", url, "attempt", attempt+1) + return nil + } + + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + if resp.StatusCode >= 500 { + lastErr = fmt.Errorf("API returned server error %d: %s", resp.StatusCode, string(body)) + c.logger.Warn("Server error, will retry", "status", resp.StatusCode, "url", url, "attempt", attempt+1) + continue + } + + lastErr = fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + c.logger.Error("Client error, will not retry", "status", resp.StatusCode, "body", string(body), "url", url) + break + } + + c.logger.Error("All retry attempts failed", "error", lastErr, "url", url, "retry_count", c.retryCount) + return lastErr +} + +func (c *WgerClient) HealthCheck(ctx context.Context) error { + url := fmt.Sprintf("%s/exerciseinfo/?limit=1", c.baseURL) + var result models.WgerExerciseListResponse + return c.makeRequest(ctx, url, &result) +} diff --git a/internal/clients/wger_client_test.go b/internal/clients/wger_client_test.go new file mode 100644 index 0000000..720c171 --- /dev/null +++ b/internal/clients/wger_client_test.go @@ -0,0 +1,340 @@ +package clients + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/aleksandr/strive-api/internal/config" + "github.com/aleksandr/strive-api/internal/logger" + "github.com/aleksandr/strive-api/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupWgerTestClient(t *testing.T, handler http.HandlerFunc) (*WgerClient, *httptest.Server) { + t.Helper() + + server := httptest.NewServer(handler) + + cfg := &config.WgerConfig{ + BaseURL: server.URL, + APIKey: "test-api-key", + Timeout: 5 * time.Second, + RetryCount: 2, + } + + log := logger.New("debug", "json") + + client := NewWgerClient(cfg, log) + + return client, server +} + +func TestNewWgerClient(t *testing.T) { + cfg := &config.WgerConfig{ + BaseURL: "https://wger.de/api/v2", + APIKey: "test-key", + Timeout: 10 * time.Second, + RetryCount: 3, + } + + log := logger.New("info", "json") + + client := NewWgerClient(cfg, log) + + assert.NotNil(t, client) + assert.Equal(t, cfg.BaseURL, client.baseURL) + assert.Equal(t, cfg.APIKey, client.apiKey) + assert.Equal(t, cfg.RetryCount, client.retryCount) + assert.NotNil(t, client.httpClient) + assert.NotNil(t, client.logger) +} + +func TestWgerClient_GetAllExercises(t *testing.T) { + exercises := []models.WgerExercise{ + { + ID: 1, + UUID: "test-uuid-1", + Name: "Bench Press", + Description: "A chest exercise", + Category: 1, + Muscles: []int{1, 2}, + Equipment: []int{1}, + }, + { + ID: 2, + UUID: "test-uuid-2", + Name: "Squat", + Description: "A leg exercise", + Category: 2, + Muscles: []int{3, 4}, + Equipment: []int{2}, + }, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/exerciseinfo/", r.URL.Path) + assert.Contains(t, r.URL.RawQuery, "limit=100") + + response := models.WgerExerciseListResponse{ + Count: 2, + Next: nil, + Previous: nil, + Results: exercises, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + }) + + client, server := setupWgerTestClient(t, handler) + defer server.Close() + + ctx := context.Background() + result, err := client.GetAllExercises(ctx) + + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, "Bench Press", result[0].Name) + assert.Equal(t, "Squat", result[1].Name) +} + +func TestWgerClient_GetExerciseByID(t *testing.T) { + exercise := models.WgerExercise{ + ID: 1, + UUID: "test-uuid", + Name: "Bench Press", + Description: "A chest exercise", + Category: 1, + Muscles: []int{1, 2}, + Equipment: []int{1}, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/exerciseinfo/1/", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(exercise) + }) + + client, server := setupWgerTestClient(t, handler) + defer server.Close() + + ctx := context.Background() + result, err := client.GetExerciseByID(ctx, 1) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, 1, result.ID) + assert.Equal(t, "Bench Press", result.Name) +} + +func TestWgerClient_GetMuscles(t *testing.T) { + muscles := []models.WgerMuscle{ + { + ID: 1, + Name: "Pectoralis major", + NameEn: "Pectoralis major", + IsFront: true, + }, + { + ID: 2, + Name: "Biceps brachii", + NameEn: "Biceps brachii", + IsFront: true, + }, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/muscle/", r.URL.Path) + + response := models.WgerMuscleListResponse{ + Count: 2, + Next: nil, + Previous: nil, + Results: muscles, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + }) + + client, server := setupWgerTestClient(t, handler) + defer server.Close() + + ctx := context.Background() + result, err := client.GetMuscles(ctx) + + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, "Pectoralis major", result[0].Name) + assert.Equal(t, "Biceps brachii", result[1].Name) +} + +func TestWgerClient_GetEquipment(t *testing.T) { + equipment := []models.WgerEquipment{ + {ID: 1, Name: "Barbell"}, + {ID: 2, Name: "Dumbbell"}, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/equipment/", r.URL.Path) + + response := models.WgerEquipmentListResponse{ + Count: 2, + Next: nil, + Previous: nil, + Results: equipment, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + }) + + client, server := setupWgerTestClient(t, handler) + defer server.Close() + + ctx := context.Background() + result, err := client.GetEquipment(ctx) + + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, "Barbell", result[0].Name) + assert.Equal(t, "Dumbbell", result[1].Name) +} + +func TestWgerClient_GetCategories(t *testing.T) { + categories := []models.WgerCategory{ + {ID: 1, Name: "Arms"}, + {ID: 2, Name: "Legs"}, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/exercisecategory/", r.URL.Path) + + response := models.WgerCategoryListResponse{ + Count: 2, + Next: nil, + Previous: nil, + Results: categories, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + }) + + client, server := setupWgerTestClient(t, handler) + defer server.Close() + + ctx := context.Background() + result, err := client.GetCategories(ctx) + + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, "Arms", result[0].Name) + assert.Equal(t, "Legs", result[1].Name) +} + +func TestWgerClient_HealthCheck(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/exerciseinfo/", r.URL.Path) + assert.Contains(t, r.URL.RawQuery, "limit=1") + + response := models.WgerExerciseListResponse{ + Count: 1, + Next: nil, + Previous: nil, + Results: []models.WgerExercise{}, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + }) + + client, server := setupWgerTestClient(t, handler) + defer server.Close() + + ctx := context.Background() + err := client.HealthCheck(ctx) + + require.NoError(t, err) +} + +func TestWgerClient_RetryLogic(t *testing.T) { + attemptCount := 0 + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attemptCount++ + if attemptCount < 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + + response := models.WgerExerciseListResponse{ + Count: 0, + Results: []models.WgerExercise{}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + }) + + client, server := setupWgerTestClient(t, handler) + defer server.Close() + + ctx := context.Background() + result, err := client.GetAllExercises(ctx) + + require.NoError(t, err) + assert.Empty(t, result) + assert.Equal(t, 2, attemptCount) +} + +func TestWgerClient_Pagination(t *testing.T) { + serverURL := "" + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("offset") + + var response models.WgerExerciseListResponse + + if page == "" || page == "0" { + nextURL := serverURL + "/exerciseinfo/?limit=100&offset=100" + response = models.WgerExerciseListResponse{ + Count: 200, + Next: &nextURL, + Results: []models.WgerExercise{ + {ID: 1, Name: "Exercise 1"}, + {ID: 2, Name: "Exercise 2"}, + }, + } + } else { + response = models.WgerExerciseListResponse{ + Count: 200, + Next: nil, + Previous: &serverURL, + Results: []models.WgerExercise{ + {ID: 3, Name: "Exercise 3"}, + }, + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + }) + + client, server := setupWgerTestClient(t, handler) + serverURL = server.URL + defer server.Close() + + ctx := context.Background() + result, err := client.GetAllExercises(ctx) + + require.NoError(t, err) + assert.Len(t, result, 3) + assert.Equal(t, "Exercise 1", result[0].Name) + assert.Equal(t, "Exercise 3", result[2].Name) +} diff --git a/internal/config/config.go b/internal/config/config.go index 67b2004..edda9c1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,7 +22,7 @@ type Config struct { CORS CORSConfig SecurityHeaders SecurityHeadersConfig Cookie CookieConfig - ExerciseDB ExerciseDBConfig + Wger WgerConfig } type ServerConfig struct { @@ -87,8 +87,9 @@ type CookieConfig struct { Domain string } -type ExerciseDBConfig struct { +type WgerConfig struct { BaseURL string + APIKey string Timeout time.Duration RetryCount int Enabled bool @@ -157,11 +158,12 @@ func Load() (*Config, error) { SameSite: parseSameSite(getEnv("COOKIE_SAMESITE", "Strict")), Domain: getEnv("COOKIE_DOMAIN", ""), }, - ExerciseDB: ExerciseDBConfig{ - BaseURL: getEnv("EXERCISEDB_BASE_URL", "https://exercise.hellogym.io"), - Timeout: getEnvDuration("EXERCISEDB_TIMEOUT", 30*time.Second), - RetryCount: getEnvInt("EXERCISEDB_RETRY_COUNT", 3), - Enabled: getEnv("EXERCISEDB_ENABLED", trueStr) == trueStr, + Wger: WgerConfig{ + BaseURL: getEnv("WGER_API_BASE_URL", "https://wger.de/api/v2"), + APIKey: getEnv("WGER_API_KEY", ""), + Timeout: getEnvDuration("WGER_API_TIMEOUT", 30*time.Second), + RetryCount: getEnvInt("WGER_API_RETRY_COUNT", 3), + Enabled: getEnv("WGER_API_ENABLED", trueStr) == trueStr, }, } diff --git a/internal/http/exercise_handlers.go b/internal/http/exercise_handlers.go index 3711006..26872b5 100644 --- a/internal/http/exercise_handlers.go +++ b/internal/http/exercise_handlers.go @@ -161,7 +161,7 @@ func (h *ExerciseHandlers) writeJSONResponse(w http.ResponseWriter, data interfa // GetExerciseByID returns detailed information about a specific exercise // @Summary Get exercise by ID -// @Description Returns detailed information about a specific exercise including muscle groups, equipment, and alternatives +// @Description Returns detailed information about a specific exercise including muscle groups, equipment, and variations // @Tags exercises // @Accept json // @Produce json @@ -288,7 +288,7 @@ func (h *ExerciseHandlers) GetCacheStatus(w http.ResponseWriter, r *http.Request // RefreshCache manually refreshes the exercise cache // @Summary Refresh cache -// @Description Manually refreshes the exercise cache from ExerciseDB API +// @Description Manually refreshes the exercise cache from wger API // @Tags exercises // @Accept json // @Produce json diff --git a/internal/models/exercise.go b/internal/models/exercise.go index 32946a1..0cc4ce0 100644 --- a/internal/models/exercise.go +++ b/internal/models/exercise.go @@ -7,41 +7,39 @@ import ( ) type MuscleGroup struct { - ID uuid.UUID `json:"id" db:"id"` - ExerciseDBID int `json:"-" db:"exercise_db_id"` - Name string `json:"name" db:"name"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID uuid.UUID `json:"id" db:"id"` + WgerID int `json:"-" db:"wger_id"` + Name string `json:"name" db:"name"` + NameEn string `json:"name_en" db:"name_en"` + IsFront bool `json:"is_front" db:"is_front"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } type Equipment struct { - ID uuid.UUID `json:"id" db:"id"` - ExerciseDBID int `json:"-" db:"exercise_db_id"` - Name string `json:"name" db:"name"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID uuid.UUID `json:"id" db:"id"` + WgerID int `json:"-" db:"wger_id"` + Name string `json:"name" db:"name"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } type Exercise struct { ID uuid.UUID `json:"id" db:"id"` - ExerciseDBID int `json:"-" db:"exercise_db_id"` + WgerID int `json:"-" db:"wger_id"` + WgerUUID string `json:"-" db:"wger_uuid"` Name string `json:"name" db:"name"` - Description *string `json:"description,omitempty" db:"description"` - Instructions *string `json:"instructions,omitempty" db:"instructions"` - Tips *string `json:"tips,omitempty" db:"tips"` - Category *int `json:"category,omitempty" db:"category"` - Language *int `json:"language,omitempty" db:"language"` - License *int `json:"license,omitempty" db:"license"` - LicenseAuthor *string `json:"license_author,omitempty" db:"license_author"` - Status *string `json:"status,omitempty" db:"status"` - NameOriginal *string `json:"name_original,omitempty" db:"name_original"` + Description string `json:"description,omitempty" db:"description"` + Category int `json:"category,omitempty" db:"category"` + Language int `json:"language,omitempty" db:"language"` + License int `json:"license,omitempty" db:"license"` + LicenseAuthor string `json:"license_author,omitempty" db:"license_author"` CreationDate *time.Time `json:"creation_date,omitempty" db:"creation_date"` - UUID *string `json:"uuid,omitempty" db:"uuid"` CachedAt time.Time `json:"cached_at" db:"cached_at"` ExpiresAt time.Time `json:"expires_at" db:"expires_at"` MuscleGroups []MuscleGroup `json:"muscle_groups,omitempty"` Equipment []Equipment `json:"equipment,omitempty"` - Alternatives []Exercise `json:"alternatives,omitempty"` + Variations []Exercise `json:"variations,omitempty"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } diff --git a/internal/models/wger_models.go b/internal/models/wger_models.go new file mode 100644 index 0000000..4c582d3 --- /dev/null +++ b/internal/models/wger_models.go @@ -0,0 +1,62 @@ +package models + +type WgerExercise struct { + ID int `json:"id"` + UUID string `json:"uuid"` + Name string `json:"name"` + Description string `json:"description"` + Category int `json:"category"` + Muscles []int `json:"muscles"` + MusclesSecondary []int `json:"muscles_secondary"` + Equipment []int `json:"equipment"` + Language int `json:"language"` + License int `json:"license"` + LicenseAuthor string `json:"license_author"` + Variations []int `json:"variations"` + CreationDate string `json:"creation_date"` +} + +type WgerExerciseListResponse struct { + Count int `json:"count"` + Next *string `json:"next"` + Previous *string `json:"previous"` + Results []WgerExercise `json:"results"` +} + +type WgerMuscle struct { + ID int `json:"id"` + Name string `json:"name"` + NameEn string `json:"name_en"` + IsFront bool `json:"is_front"` +} + +type WgerMuscleListResponse struct { + Count int `json:"count"` + Next *string `json:"next"` + Previous *string `json:"previous"` + Results []WgerMuscle `json:"results"` +} + +type WgerEquipment struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type WgerEquipmentListResponse struct { + Count int `json:"count"` + Next *string `json:"next"` + Previous *string `json:"previous"` + Results []WgerEquipment `json:"results"` +} + +type WgerCategory struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type WgerCategoryListResponse struct { + Count int `json:"count"` + Next *string `json:"next"` + Previous *string `json:"previous"` + Results []WgerCategory `json:"results"` +} diff --git a/internal/repositories/exercise_repository.go b/internal/repositories/exercise_repository.go index 9a5885f..fe9f444 100644 --- a/internal/repositories/exercise_repository.go +++ b/internal/repositories/exercise_repository.go @@ -14,9 +14,11 @@ import ( type ExerciseRepository interface { GetAll(ctx context.Context, filters *models.ExerciseFilters) (*models.ExerciseListResponse, error) GetByID(ctx context.Context, id uuid.UUID) (*models.Exercise, error) - GetByExerciseDBID(ctx context.Context, exerciseDBID int) (*models.Exercise, error) + GetByWgerID(ctx context.Context, wgerID int) (*models.Exercise, error) GetMuscleGroups(ctx context.Context) ([]models.MuscleGroup, error) + GetMuscleGroupByWgerID(ctx context.Context, wgerID int) (*models.MuscleGroup, error) GetEquipment(ctx context.Context) ([]models.Equipment, error) + GetEquipmentByWgerID(ctx context.Context, wgerID int) (*models.Equipment, error) GetCacheStatus(ctx context.Context) (*models.CacheStatus, error) IsCacheValid(ctx context.Context) (bool, error) ClearCache(ctx context.Context) error @@ -25,7 +27,7 @@ type ExerciseRepository interface { SaveEquipment(ctx context.Context, equipment *models.Equipment) error SaveExerciseMuscleGroup(ctx context.Context, exerciseID, muscleGroupID uuid.UUID, isPrimary bool) error SaveExerciseEquipment(ctx context.Context, exerciseID, equipmentID uuid.UUID) error - SaveExerciseAlternative(ctx context.Context, exerciseID, alternativeID uuid.UUID) error + SaveExerciseVariation(ctx context.Context, exerciseID, variationID uuid.UUID) error } type exerciseRepository struct { @@ -93,9 +95,9 @@ func (r *exerciseRepository) GetAll(ctx context.Context, filters *models.Exercis offset := (filters.Page - 1) * limit query := fmt.Sprintf(` - SELECT DISTINCT e.id, e.exercise_db_id, e.name, e.description, e.instructions, e.tips, - e.category, e.language, e.license, e.license_author, e.status, e.name_original, - e.creation_date, e.uuid, e.cached_at, e.expires_at, e.created_at, e.updated_at + SELECT DISTINCT e.id, e.wger_id, e.wger_uuid, e.name, e.description, + e.category, e.language, e.license, e.license_author, + e.creation_date, e.cached_at, e.expires_at, e.created_at, e.updated_at FROM exercises e %s ORDER BY e.name @@ -115,19 +117,15 @@ func (r *exerciseRepository) GetAll(ctx context.Context, filters *models.Exercis exercise := models.Exercise{} err := rows.Scan( &exercise.ID, - &exercise.ExerciseDBID, + &exercise.WgerID, + &exercise.WgerUUID, &exercise.Name, &exercise.Description, - &exercise.Instructions, - &exercise.Tips, &exercise.Category, &exercise.Language, &exercise.License, &exercise.LicenseAuthor, - &exercise.Status, - &exercise.NameOriginal, &exercise.CreationDate, - &exercise.UUID, &exercise.CachedAt, &exercise.ExpiresAt, &exercise.CreatedAt, @@ -149,11 +147,11 @@ func (r *exerciseRepository) GetAll(ctx context.Context, filters *models.Exercis } exercise.Equipment = equipment - alternatives, err := r.getAlternativesForExercise(ctx, exercise.ID) + variations, err := r.getVariationsForExercise(ctx, exercise.ID) if err != nil { - return nil, fmt.Errorf("failed to get alternatives for exercise: %w", err) + return nil, fmt.Errorf("failed to get variations for exercise: %w", err) } - exercise.Alternatives = alternatives + exercise.Variations = variations exercises = append(exercises, exercise) } @@ -168,9 +166,9 @@ func (r *exerciseRepository) GetAll(ctx context.Context, filters *models.Exercis func (r *exerciseRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Exercise, error) { query := ` - SELECT id, exercise_db_id, name, description, instructions, tips, - category, language, license, license_author, status, name_original, - creation_date, uuid, cached_at, expires_at, created_at, updated_at + SELECT id, wger_id, wger_uuid, name, description, + category, language, license, license_author, + creation_date, cached_at, expires_at, created_at, updated_at FROM exercises WHERE id = $1 ` @@ -179,19 +177,15 @@ func (r *exerciseRepository) GetByID(ctx context.Context, id uuid.UUID) (*models exercise := &models.Exercise{} err := row.Scan( &exercise.ID, - &exercise.ExerciseDBID, + &exercise.WgerID, + &exercise.WgerUUID, &exercise.Name, &exercise.Description, - &exercise.Instructions, - &exercise.Tips, &exercise.Category, &exercise.Language, &exercise.License, &exercise.LicenseAuthor, - &exercise.Status, - &exercise.NameOriginal, &exercise.CreationDate, - &exercise.UUID, &exercise.CachedAt, &exercise.ExpiresAt, &exercise.CreatedAt, @@ -213,48 +207,44 @@ func (r *exerciseRepository) GetByID(ctx context.Context, id uuid.UUID) (*models } exercise.Equipment = equipment - alternatives, err := r.getAlternativesForExercise(ctx, exercise.ID) + variations, err := r.getVariationsForExercise(ctx, exercise.ID) if err != nil { - return nil, fmt.Errorf("failed to get alternatives for exercise: %w", err) + return nil, fmt.Errorf("failed to get variations for exercise: %w", err) } - exercise.Alternatives = alternatives + exercise.Variations = variations return exercise, nil } -func (r *exerciseRepository) GetByExerciseDBID(ctx context.Context, exerciseDBID int) (*models.Exercise, error) { +func (r *exerciseRepository) GetByWgerID(ctx context.Context, wgerID int) (*models.Exercise, error) { query := ` - SELECT id, exercise_db_id, name, description, instructions, tips, - category, language, license, license_author, status, name_original, - creation_date, uuid, cached_at, expires_at, created_at, updated_at + SELECT id, wger_id, wger_uuid, name, description, + category, language, license, license_author, + creation_date, cached_at, expires_at, created_at, updated_at FROM exercises - WHERE exercise_db_id = $1 + WHERE wger_id = $1 ` - row := r.pool.QueryRow(ctx, query, exerciseDBID) + row := r.pool.QueryRow(ctx, query, wgerID) exercise := &models.Exercise{} err := row.Scan( &exercise.ID, - &exercise.ExerciseDBID, + &exercise.WgerID, + &exercise.WgerUUID, &exercise.Name, &exercise.Description, - &exercise.Instructions, - &exercise.Tips, &exercise.Category, &exercise.Language, &exercise.License, &exercise.LicenseAuthor, - &exercise.Status, - &exercise.NameOriginal, &exercise.CreationDate, - &exercise.UUID, &exercise.CachedAt, &exercise.ExpiresAt, &exercise.CreatedAt, &exercise.UpdatedAt, ) if err != nil { - return nil, fmt.Errorf("failed to get exercise by exercise_db_id: %w", err) + return nil, fmt.Errorf("failed to get exercise by wger_id: %w", err) } return exercise, nil @@ -262,7 +252,7 @@ func (r *exerciseRepository) GetByExerciseDBID(ctx context.Context, exerciseDBID func (r *exerciseRepository) GetMuscleGroups(ctx context.Context) ([]models.MuscleGroup, error) { query := ` - SELECT id, exercise_db_id, name, created_at, updated_at + SELECT id, wger_id, name, name_en, is_front, created_at, updated_at FROM muscle_groups ORDER BY name ` @@ -276,7 +266,7 @@ func (r *exerciseRepository) GetMuscleGroups(ctx context.Context) ([]models.Musc muscleGroups := []models.MuscleGroup{} for rows.Next() { mg := models.MuscleGroup{} - err := rows.Scan(&mg.ID, &mg.ExerciseDBID, &mg.Name, &mg.CreatedAt, &mg.UpdatedAt) + err := rows.Scan(&mg.ID, &mg.WgerID, &mg.Name, &mg.NameEn, &mg.IsFront, &mg.CreatedAt, &mg.UpdatedAt) if err != nil { return nil, fmt.Errorf("failed to scan muscle group: %w", err) } @@ -286,9 +276,26 @@ func (r *exerciseRepository) GetMuscleGroups(ctx context.Context) ([]models.Musc return muscleGroups, nil } +func (r *exerciseRepository) GetMuscleGroupByWgerID(ctx context.Context, wgerID int) (*models.MuscleGroup, error) { + query := ` + SELECT id, wger_id, name, name_en, is_front, created_at, updated_at + FROM muscle_groups + WHERE wger_id = $1 + ` + + row := r.pool.QueryRow(ctx, query, wgerID) + mg := &models.MuscleGroup{} + err := row.Scan(&mg.ID, &mg.WgerID, &mg.Name, &mg.NameEn, &mg.IsFront, &mg.CreatedAt, &mg.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to get muscle group by wger_id: %w", err) + } + + return mg, nil +} + func (r *exerciseRepository) GetEquipment(ctx context.Context) ([]models.Equipment, error) { query := ` - SELECT id, exercise_db_id, name, created_at, updated_at + SELECT id, wger_id, name, created_at, updated_at FROM equipment ORDER BY name ` @@ -302,7 +309,7 @@ func (r *exerciseRepository) GetEquipment(ctx context.Context) ([]models.Equipme equipment := []models.Equipment{} for rows.Next() { eq := models.Equipment{} - err := rows.Scan(&eq.ID, &eq.ExerciseDBID, &eq.Name, &eq.CreatedAt, &eq.UpdatedAt) + err := rows.Scan(&eq.ID, &eq.WgerID, &eq.Name, &eq.CreatedAt, &eq.UpdatedAt) if err != nil { return nil, fmt.Errorf("failed to scan equipment: %w", err) } @@ -312,6 +319,23 @@ func (r *exerciseRepository) GetEquipment(ctx context.Context) ([]models.Equipme return equipment, nil } +func (r *exerciseRepository) GetEquipmentByWgerID(ctx context.Context, wgerID int) (*models.Equipment, error) { + query := ` + SELECT id, wger_id, name, created_at, updated_at + FROM equipment + WHERE wger_id = $1 + ` + + row := r.pool.QueryRow(ctx, query, wgerID) + eq := &models.Equipment{} + err := row.Scan(&eq.ID, &eq.WgerID, &eq.Name, &eq.CreatedAt, &eq.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to get equipment by wger_id: %w", err) + } + + return eq, nil +} + func (r *exerciseRepository) GetCacheStatus(ctx context.Context) (*models.CacheStatus, error) { query := ` SELECT @@ -363,7 +387,7 @@ func (r *exerciseRepository) IsCacheValid(ctx context.Context) (bool, error) { func (r *exerciseRepository) ClearCache(ctx context.Context) error { queries := []string{ - "DELETE FROM exercise_alternatives", + "DELETE FROM exercise_variations", "DELETE FROM exercise_equipment", "DELETE FROM exercise_muscle_groups", "DELETE FROM exercises", @@ -384,25 +408,21 @@ func (r *exerciseRepository) ClearCache(ctx context.Context) error { func (r *exerciseRepository) SaveExercise(ctx context.Context, exercise *models.Exercise) error { query := ` INSERT INTO exercises ( - id, exercise_db_id, name, description, instructions, tips, - category, language, license, license_author, status, name_original, - creation_date, uuid, cached_at, expires_at, created_at, updated_at + id, wger_id, wger_uuid, name, description, + category, language, license, license_author, + creation_date, cached_at, expires_at, created_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 ) - ON CONFLICT (exercise_db_id) DO UPDATE SET + ON CONFLICT (wger_id) DO UPDATE SET + wger_uuid = EXCLUDED.wger_uuid, name = EXCLUDED.name, description = EXCLUDED.description, - instructions = EXCLUDED.instructions, - tips = EXCLUDED.tips, category = EXCLUDED.category, language = EXCLUDED.language, license = EXCLUDED.license, license_author = EXCLUDED.license_author, - status = EXCLUDED.status, - name_original = EXCLUDED.name_original, creation_date = EXCLUDED.creation_date, - uuid = EXCLUDED.uuid, cached_at = EXCLUDED.cached_at, expires_at = EXCLUDED.expires_at, updated_at = EXCLUDED.updated_at @@ -410,19 +430,15 @@ func (r *exerciseRepository) SaveExercise(ctx context.Context, exercise *models. _, err := r.pool.Exec(ctx, query, exercise.ID, - exercise.ExerciseDBID, + exercise.WgerID, + exercise.WgerUUID, exercise.Name, exercise.Description, - exercise.Instructions, - exercise.Tips, exercise.Category, exercise.Language, exercise.License, exercise.LicenseAuthor, - exercise.Status, - exercise.NameOriginal, exercise.CreationDate, - exercise.UUID, exercise.CachedAt, exercise.ExpiresAt, exercise.CreatedAt, @@ -437,17 +453,21 @@ func (r *exerciseRepository) SaveExercise(ctx context.Context, exercise *models. func (r *exerciseRepository) SaveMuscleGroup(ctx context.Context, muscleGroup *models.MuscleGroup) error { query := ` - INSERT INTO muscle_groups (id, exercise_db_id, name, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (exercise_db_id) DO UPDATE SET + INSERT INTO muscle_groups (id, wger_id, name, name_en, is_front, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (wger_id) DO UPDATE SET name = EXCLUDED.name, + name_en = EXCLUDED.name_en, + is_front = EXCLUDED.is_front, updated_at = EXCLUDED.updated_at ` _, err := r.pool.Exec(ctx, query, muscleGroup.ID, - muscleGroup.ExerciseDBID, + muscleGroup.WgerID, muscleGroup.Name, + muscleGroup.NameEn, + muscleGroup.IsFront, muscleGroup.CreatedAt, muscleGroup.UpdatedAt, ) @@ -460,16 +480,16 @@ func (r *exerciseRepository) SaveMuscleGroup(ctx context.Context, muscleGroup *m func (r *exerciseRepository) SaveEquipment(ctx context.Context, equipment *models.Equipment) error { query := ` - INSERT INTO equipment (id, exercise_db_id, name, created_at, updated_at) + INSERT INTO equipment (id, wger_id, name, created_at, updated_at) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (exercise_db_id) DO UPDATE SET + ON CONFLICT (wger_id) DO UPDATE SET name = EXCLUDED.name, updated_at = EXCLUDED.updated_at ` _, err := r.pool.Exec(ctx, query, equipment.ID, - equipment.ExerciseDBID, + equipment.WgerID, equipment.Name, equipment.CreatedAt, equipment.UpdatedAt, @@ -511,16 +531,16 @@ func (r *exerciseRepository) SaveExerciseEquipment(ctx context.Context, exercise return nil } -func (r *exerciseRepository) SaveExerciseAlternative(ctx context.Context, exerciseID, alternativeID uuid.UUID) error { +func (r *exerciseRepository) SaveExerciseVariation(ctx context.Context, exerciseID, variationID uuid.UUID) error { query := ` - INSERT INTO exercise_alternatives (exercise_id, alternative_exercise_id, created_at) + INSERT INTO exercise_variations (exercise_id, variation_exercise_id, created_at) VALUES ($1, $2, $3) - ON CONFLICT (exercise_id, alternative_exercise_id) DO NOTHING + ON CONFLICT (exercise_id, variation_exercise_id) DO NOTHING ` - _, err := r.pool.Exec(ctx, query, exerciseID, alternativeID, time.Now()) + _, err := r.pool.Exec(ctx, query, exerciseID, variationID, time.Now()) if err != nil { - return fmt.Errorf("failed to save exercise alternative: %w", err) + return fmt.Errorf("failed to save exercise variation: %w", err) } return nil @@ -528,7 +548,7 @@ func (r *exerciseRepository) SaveExerciseAlternative(ctx context.Context, exerci func (r *exerciseRepository) getMuscleGroupsForExercise(ctx context.Context, exerciseID uuid.UUID) ([]models.MuscleGroup, error) { query := ` - SELECT mg.id, mg.exercise_db_id, mg.name, mg.created_at, mg.updated_at + SELECT mg.id, mg.wger_id, mg.name, mg.name_en, mg.is_front, mg.created_at, mg.updated_at FROM muscle_groups mg JOIN exercise_muscle_groups emg ON mg.id = emg.muscle_group_id WHERE emg.exercise_id = $1 @@ -544,7 +564,7 @@ func (r *exerciseRepository) getMuscleGroupsForExercise(ctx context.Context, exe muscleGroups := []models.MuscleGroup{} for rows.Next() { mg := models.MuscleGroup{} - err := rows.Scan(&mg.ID, &mg.ExerciseDBID, &mg.Name, &mg.CreatedAt, &mg.UpdatedAt) + err := rows.Scan(&mg.ID, &mg.WgerID, &mg.Name, &mg.NameEn, &mg.IsFront, &mg.CreatedAt, &mg.UpdatedAt) if err != nil { return nil, fmt.Errorf("failed to scan muscle group: %w", err) } @@ -556,7 +576,7 @@ func (r *exerciseRepository) getMuscleGroupsForExercise(ctx context.Context, exe func (r *exerciseRepository) getEquipmentForExercise(ctx context.Context, exerciseID uuid.UUID) ([]models.Equipment, error) { query := ` - SELECT e.id, e.exercise_db_id, e.name, e.created_at, e.updated_at + SELECT e.id, e.wger_id, e.name, e.created_at, e.updated_at FROM equipment e JOIN exercise_equipment ee ON e.id = ee.equipment_id WHERE ee.exercise_id = $1 @@ -572,7 +592,7 @@ func (r *exerciseRepository) getEquipmentForExercise(ctx context.Context, exerci equipment := []models.Equipment{} for rows.Next() { eq := models.Equipment{} - err := rows.Scan(&eq.ID, &eq.ExerciseDBID, &eq.Name, &eq.CreatedAt, &eq.UpdatedAt) + err := rows.Scan(&eq.ID, &eq.WgerID, &eq.Name, &eq.CreatedAt, &eq.UpdatedAt) if err != nil { return nil, fmt.Errorf("failed to scan equipment: %w", err) } @@ -582,51 +602,47 @@ func (r *exerciseRepository) getEquipmentForExercise(ctx context.Context, exerci return equipment, nil } -func (r *exerciseRepository) getAlternativesForExercise(ctx context.Context, exerciseID uuid.UUID) ([]models.Exercise, error) { +func (r *exerciseRepository) getVariationsForExercise(ctx context.Context, exerciseID uuid.UUID) ([]models.Exercise, error) { query := ` - SELECT e.id, e.exercise_db_id, e.name, e.description, e.instructions, e.tips, - e.category, e.language, e.license, e.license_author, e.status, e.name_original, - e.creation_date, e.uuid, e.cached_at, e.expires_at, e.created_at, e.updated_at + SELECT e.id, e.wger_id, e.wger_uuid, e.name, e.description, + e.category, e.language, e.license, e.license_author, + e.creation_date, e.cached_at, e.expires_at, e.created_at, e.updated_at FROM exercises e - JOIN exercise_alternatives ea ON e.id = ea.alternative_exercise_id - WHERE ea.exercise_id = $1 + JOIN exercise_variations ev ON e.id = ev.variation_exercise_id + WHERE ev.exercise_id = $1 ORDER BY e.name ` rows, err := r.pool.Query(ctx, query, exerciseID) if err != nil { - return nil, fmt.Errorf("failed to get alternatives for exercise: %w", err) + return nil, fmt.Errorf("failed to get variations for exercise: %w", err) } defer rows.Close() - alternatives := []models.Exercise{} + variations := []models.Exercise{} for rows.Next() { - alt := models.Exercise{} + variation := models.Exercise{} err := rows.Scan( - &alt.ID, - &alt.ExerciseDBID, - &alt.Name, - &alt.Description, - &alt.Instructions, - &alt.Tips, - &alt.Category, - &alt.Language, - &alt.License, - &alt.LicenseAuthor, - &alt.Status, - &alt.NameOriginal, - &alt.CreationDate, - &alt.UUID, - &alt.CachedAt, - &alt.ExpiresAt, - &alt.CreatedAt, - &alt.UpdatedAt, + &variation.ID, + &variation.WgerID, + &variation.WgerUUID, + &variation.Name, + &variation.Description, + &variation.Category, + &variation.Language, + &variation.License, + &variation.LicenseAuthor, + &variation.CreationDate, + &variation.CachedAt, + &variation.ExpiresAt, + &variation.CreatedAt, + &variation.UpdatedAt, ) if err != nil { - return nil, fmt.Errorf("failed to scan alternative exercise: %w", err) + return nil, fmt.Errorf("failed to scan variation exercise: %w", err) } - alternatives = append(alternatives, alt) + variations = append(variations, variation) } - return alternatives, nil + return variations, nil } diff --git a/internal/repositories/exercise_repository_test.go b/internal/repositories/exercise_repository_test.go index f5d0cb3..6e5e46f 100644 --- a/internal/repositories/exercise_repository_test.go +++ b/internal/repositories/exercise_repository_test.go @@ -214,16 +214,19 @@ func TestExerciseRepository_SaveExercise(t *testing.T) { ctx := context.Background() exercise := &models.Exercise{ - ID: uuid.New(), - ExerciseDBID: 1, - Name: "Push-up", - Description: stringPtr("Basic push-up exercise"), - Instructions: stringPtr("Start in plank position"), - Tips: stringPtr("Keep your body straight"), - CachedAt: time.Now(), - ExpiresAt: time.Now().Add(24 * time.Hour), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: uuid.New(), + WgerID: 1, + WgerUUID: "test-uuid", + Name: "Push-up", + Description: "Basic push-up exercise", + Category: 1, + Language: 2, + License: 1, + LicenseAuthor: "Test Author", + CachedAt: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } repo.On("SaveExercise", ctx, exercise).Return(nil) @@ -239,11 +242,13 @@ func TestExerciseRepository_SaveMuscleGroup(t *testing.T) { ctx := context.Background() muscleGroup := &models.MuscleGroup{ - ID: uuid.New(), - ExerciseDBID: 1, - Name: "Chest", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: uuid.New(), + WgerID: 1, + Name: "Chest", + NameEn: "Chest", + IsFront: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } repo.On("SaveMuscleGroup", ctx, muscleGroup).Return(nil) @@ -259,11 +264,11 @@ func TestExerciseRepository_SaveEquipment(t *testing.T) { ctx := context.Background() equipment := &models.Equipment{ - ID: uuid.New(), - ExerciseDBID: 1, - Name: "Dumbbell", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: uuid.New(), + WgerID: 1, + Name: "Dumbbell", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } repo.On("SaveEquipment", ctx, equipment).Return(nil) @@ -285,7 +290,3 @@ func TestExerciseRepository_ClearCache(t *testing.T) { repo.AssertExpectations(t) } - -func stringPtr(s string) *string { - return &s -} diff --git a/internal/services/exercise_cache_service.go b/internal/services/exercise_cache_service.go index 83b2ad8..d76837d 100644 --- a/internal/services/exercise_cache_service.go +++ b/internal/services/exercise_cache_service.go @@ -20,28 +20,28 @@ type ExerciseCacheService interface { } type exerciseCacheService struct { - exerciseRepo repositories.ExerciseRepository - exerciseDBClient *clients.ExerciseDBClient - logger *logger.Logger - cacheTTL time.Duration + exerciseRepo repositories.ExerciseRepository + wgerClient *clients.WgerClient + logger *logger.Logger + cacheTTL time.Duration } func NewExerciseCacheService( exerciseRepo repositories.ExerciseRepository, - exerciseDBClient *clients.ExerciseDBClient, + wgerClient *clients.WgerClient, logger *logger.Logger, cacheTTL time.Duration, ) ExerciseCacheService { return &exerciseCacheService{ - exerciseRepo: exerciseRepo, - exerciseDBClient: exerciseDBClient, - logger: logger, - cacheTTL: cacheTTL, + exerciseRepo: exerciseRepo, + wgerClient: wgerClient, + logger: logger, + cacheTTL: cacheTTL, } } func (s *exerciseCacheService) RefreshCache(ctx context.Context) error { - s.logger.Info("Starting cache refresh from ExerciseDB") + s.logger.Info("Starting cache refresh from wger API") if err := s.exerciseRepo.ClearCache(ctx); err != nil { return fmt.Errorf("failed to clear existing cache: %w", err) @@ -77,47 +77,49 @@ func (s *exerciseCacheService) ClearCache(ctx context.Context) error { } func (s *exerciseCacheService) cacheMuscleGroups(ctx context.Context) error { - s.logger.Debug("Caching muscle groups from ExerciseDB") + s.logger.Debug("Caching muscle groups from wger API") - muscleGroups, err := s.exerciseDBClient.GetMuscleGroups(ctx) + muscles, err := s.wgerClient.GetMuscles(ctx) if err != nil { - return fmt.Errorf("failed to get muscle groups from ExerciseDB: %w", err) + return fmt.Errorf("failed to get muscles from wger API: %w", err) } - for _, mg := range muscleGroups { + for _, muscle := range muscles { muscleGroup := &models.MuscleGroup{ - ID: uuid.New(), - ExerciseDBID: mg.ID, - Name: mg.Name, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: uuid.New(), + WgerID: muscle.ID, + Name: muscle.Name, + NameEn: muscle.NameEn, + IsFront: muscle.IsFront, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } if err := s.exerciseRepo.SaveMuscleGroup(ctx, muscleGroup); err != nil { - s.logger.Error("Failed to save muscle group", "error", err, "muscle_group", mg.Name) + s.logger.Error("Failed to save muscle group", "error", err, "muscle_group", muscle.Name) continue } } - s.logger.Info("Successfully cached muscle groups", "count", len(muscleGroups)) + s.logger.Info("Successfully cached muscle groups", "count", len(muscles)) return nil } func (s *exerciseCacheService) cacheEquipment(ctx context.Context) error { - s.logger.Debug("Caching equipment from ExerciseDB") + s.logger.Debug("Caching equipment from wger API") - equipment, err := s.exerciseDBClient.GetEquipment(ctx) + equipment, err := s.wgerClient.GetEquipment(ctx) if err != nil { - return fmt.Errorf("failed to get equipment from ExerciseDB: %w", err) + return fmt.Errorf("failed to get equipment from wger API: %w", err) } for _, eq := range equipment { equipmentModel := &models.Equipment{ - ID: uuid.New(), - ExerciseDBID: eq.ID, - Name: eq.Name, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: uuid.New(), + WgerID: eq.ID, + Name: eq.Name, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } if err := s.exerciseRepo.SaveEquipment(ctx, equipmentModel); err != nil { @@ -131,11 +133,11 @@ func (s *exerciseCacheService) cacheEquipment(ctx context.Context) error { } func (s *exerciseCacheService) cacheExercises(ctx context.Context) error { - s.logger.Debug("Caching exercises from ExerciseDB") + s.logger.Debug("Caching exercises from wger API") - exercises, err := s.exerciseDBClient.GetAllExercises(ctx) + exercises, err := s.wgerClient.GetAllExercises(ctx) if err != nil { - return fmt.Errorf("failed to get exercises from ExerciseDB: %w", err) + return fmt.Errorf("failed to get exercises from wger API: %w", err) } now := time.Now() @@ -145,19 +147,15 @@ func (s *exerciseCacheService) cacheExercises(ctx context.Context) error { ex := &exercises[i] exercise := &models.Exercise{ ID: uuid.New(), - ExerciseDBID: ex.ID, + WgerID: ex.ID, + WgerUUID: ex.UUID, Name: ex.Name, - Description: stringPtr(ex.Description), - Instructions: stringPtr(ex.Description), - Tips: stringPtr(ex.Description), - Category: intPtr(ex.Category), - Language: intPtr(ex.Language), - License: intPtr(ex.License), - LicenseAuthor: stringPtr(ex.LicenseAuthor), - Status: stringPtr(ex.Status), - NameOriginal: stringPtr(ex.NameOriginal), + Description: ex.Description, + Category: ex.Category, + Language: ex.Language, + License: ex.License, + LicenseAuthor: ex.LicenseAuthor, CreationDate: parseDate(ex.CreationDate), - UUID: stringPtr(ex.UUID), CachedAt: now, ExpiresAt: expiresAt, CreatedAt: now, @@ -176,6 +174,8 @@ func (s *exerciseCacheService) cacheExercises(ctx context.Context) error { if err := s.cacheExerciseEquipment(ctx, exercise.ID, ex.Equipment); err != nil { s.logger.Error("Failed to cache exercise equipment", "error", err, "exercise", ex.Name) } + + s.cacheExerciseVariations(ctx, exercise.ID, ex.Variations) } s.logger.Info("Successfully cached exercises", "count", len(exercises)) @@ -194,7 +194,7 @@ func (s *exerciseCacheService) cacheExerciseMuscleGroups( muscleGroupMap := make(map[int]uuid.UUID) for _, mg := range muscleGroups { - muscleGroupMap[mg.ExerciseDBID] = mg.ID + muscleGroupMap[mg.WgerID] = mg.ID } for _, muscleID := range primaryMuscles { @@ -224,7 +224,7 @@ func (s *exerciseCacheService) cacheExerciseEquipment(ctx context.Context, exerc equipmentMap := make(map[int]uuid.UUID) for _, eq := range equipment { - equipmentMap[eq.ExerciseDBID] = eq.ID + equipmentMap[eq.WgerID] = eq.ID } for _, equipmentID := range equipmentIDs { @@ -238,18 +238,18 @@ func (s *exerciseCacheService) cacheExerciseEquipment(ctx context.Context, exerc return nil } -func stringPtr(s string) *string { - if s == "" { - return nil - } - return &s -} +func (s *exerciseCacheService) cacheExerciseVariations(ctx context.Context, exerciseID uuid.UUID, variationIDs []int) { + for _, variationWgerID := range variationIDs { + variation, err := s.exerciseRepo.GetByWgerID(ctx, variationWgerID) + if err != nil { + s.logger.Warn("Variation not found in cache", "wger_id", variationWgerID) + continue + } -func intPtr(i int) *int { - if i == 0 { - return nil + if err := s.exerciseRepo.SaveExerciseVariation(ctx, exerciseID, variation.ID); err != nil { + s.logger.Warn("Failed to save exercise variation", "error", err, "variation_id", variationWgerID) + } } - return &i } func parseDate(dateStr string) *time.Time { diff --git a/internal/services/exercise_service_test.go b/internal/services/exercise_service_test.go index 4832274..2757e31 100644 --- a/internal/services/exercise_service_test.go +++ b/internal/services/exercise_service_test.go @@ -26,11 +26,21 @@ func (m *MockExerciseRepository) GetByID(ctx context.Context, id uuid.UUID) (*mo return args.Get(0).(*models.Exercise), args.Error(1) } -func (m *MockExerciseRepository) GetByExerciseDBID(ctx context.Context, exerciseDBID int) (*models.Exercise, error) { - args := m.Called(ctx, exerciseDBID) +func (m *MockExerciseRepository) GetByWgerID(ctx context.Context, wgerID int) (*models.Exercise, error) { + args := m.Called(ctx, wgerID) return args.Get(0).(*models.Exercise), args.Error(1) } +func (m *MockExerciseRepository) GetMuscleGroupByWgerID(ctx context.Context, wgerID int) (*models.MuscleGroup, error) { + args := m.Called(ctx, wgerID) + return args.Get(0).(*models.MuscleGroup), args.Error(1) +} + +func (m *MockExerciseRepository) GetEquipmentByWgerID(ctx context.Context, wgerID int) (*models.Equipment, error) { + args := m.Called(ctx, wgerID) + return args.Get(0).(*models.Equipment), args.Error(1) +} + func (m *MockExerciseRepository) GetMuscleGroups(ctx context.Context) ([]models.MuscleGroup, error) { args := m.Called(ctx) return args.Get(0).([]models.MuscleGroup), args.Error(1) @@ -81,8 +91,8 @@ func (m *MockExerciseRepository) SaveExerciseEquipment(ctx context.Context, exer return args.Error(0) } -func (m *MockExerciseRepository) SaveExerciseAlternative(ctx context.Context, exerciseID, alternativeID uuid.UUID) error { - args := m.Called(ctx, exerciseID, alternativeID) +func (m *MockExerciseRepository) SaveExerciseVariation(ctx context.Context, exerciseID, variationID uuid.UUID) error { + args := m.Called(ctx, exerciseID, variationID) return args.Error(0) } diff --git a/migrations/000005_exercises.down.sql b/migrations/000005_exercises.down.sql index 4ff0102..711b267 100644 --- a/migrations/000005_exercises.down.sql +++ b/migrations/000005_exercises.down.sql @@ -1,12 +1,7 @@ --- Drop triggers -DROP TRIGGER IF EXISTS update_exercises_updated_at ON exercises; -DROP TRIGGER IF EXISTS update_equipment_updated_at ON equipment; -DROP TRIGGER IF EXISTS update_muscle_groups_updated_at ON muscle_groups; - --- Drop tables in reverse order -DROP TABLE IF EXISTS exercise_alternatives; +DROP TABLE IF EXISTS exercise_variations; DROP TABLE IF EXISTS exercise_equipment; DROP TABLE IF EXISTS exercise_muscle_groups; DROP TABLE IF EXISTS exercises; DROP TABLE IF EXISTS equipment; DROP TABLE IF EXISTS muscle_groups; + diff --git a/migrations/000005_exercises.up.sql b/migrations/000005_exercises.up.sql index e28a281..1644321 100644 --- a/migrations/000005_exercises.up.sql +++ b/migrations/000005_exercises.up.sql @@ -1,44 +1,38 @@ --- Create muscle_groups table for caching ExerciseDB muscle groups CREATE TABLE muscle_groups ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - exercise_db_id INTEGER NOT NULL UNIQUE, + wger_id INTEGER NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, + name_en VARCHAR(100) NOT NULL DEFAULT '', + is_front BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); --- Create equipment table for caching ExerciseDB equipment CREATE TABLE equipment ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - exercise_db_id INTEGER NOT NULL UNIQUE, + wger_id INTEGER NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); --- Create exercises table for caching ExerciseDB exercises CREATE TABLE exercises ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - exercise_db_id INTEGER NOT NULL UNIQUE, + wger_id INTEGER NOT NULL UNIQUE, + wger_uuid VARCHAR(255) NOT NULL DEFAULT '', name VARCHAR(255) NOT NULL, - description TEXT, - instructions TEXT, - tips TEXT, - category INTEGER, - language INTEGER, - license INTEGER, - license_author VARCHAR(255), - status VARCHAR(10), - name_original VARCHAR(255), + description TEXT NOT NULL DEFAULT '', + category INTEGER NOT NULL DEFAULT 0, + language INTEGER NOT NULL DEFAULT 0, + license INTEGER NOT NULL DEFAULT 0, + license_author VARCHAR(255) NOT NULL DEFAULT '', creation_date DATE, - uuid VARCHAR(36), cached_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), expires_at TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); --- Create exercise_muscle_groups junction table CREATE TABLE exercise_muscle_groups ( exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, muscle_group_id UUID NOT NULL REFERENCES muscle_groups(id) ON DELETE CASCADE, @@ -47,7 +41,6 @@ CREATE TABLE exercise_muscle_groups ( PRIMARY KEY (exercise_id, muscle_group_id, is_primary) ); --- Create exercise_equipment junction table CREATE TABLE exercise_equipment ( exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, equipment_id UUID NOT NULL REFERENCES equipment(id) ON DELETE CASCADE, @@ -55,44 +48,32 @@ CREATE TABLE exercise_equipment ( PRIMARY KEY (exercise_id, equipment_id) ); --- Create exercise_alternatives table for exercise alternatives -CREATE TABLE exercise_alternatives ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), +CREATE TABLE exercise_variations ( exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, - alternative_exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + variation_exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - UNIQUE(exercise_id, alternative_exercise_id) + PRIMARY KEY (exercise_id, variation_exercise_id), + CHECK (exercise_id != variation_exercise_id) ); --- Create indexes for performance -CREATE INDEX idx_muscle_groups_exercise_db_id ON muscle_groups(exercise_db_id); -CREATE INDEX idx_muscle_groups_name ON muscle_groups(name); - -CREATE INDEX idx_equipment_exercise_db_id ON equipment(exercise_db_id); -CREATE INDEX idx_equipment_name ON equipment(name); - -CREATE INDEX idx_exercises_exercise_db_id ON exercises(exercise_db_id); -CREATE INDEX idx_exercises_name ON exercises(name); +CREATE INDEX idx_exercises_wger_id ON exercises(wger_id); CREATE INDEX idx_exercises_category ON exercises(category); +CREATE INDEX idx_exercises_name ON exercises(name); CREATE INDEX idx_exercises_cached_at ON exercises(cached_at); CREATE INDEX idx_exercises_expires_at ON exercises(expires_at); +CREATE INDEX idx_muscle_groups_wger_id ON muscle_groups(wger_id); +CREATE INDEX idx_muscle_groups_name ON muscle_groups(name); + +CREATE INDEX idx_equipment_wger_id ON equipment(wger_id); +CREATE INDEX idx_equipment_name ON equipment(name); + CREATE INDEX idx_exercise_muscle_groups_exercise_id ON exercise_muscle_groups(exercise_id); CREATE INDEX idx_exercise_muscle_groups_muscle_group_id ON exercise_muscle_groups(muscle_group_id); -CREATE INDEX idx_exercise_muscle_groups_primary ON exercise_muscle_groups(is_primary); CREATE INDEX idx_exercise_equipment_exercise_id ON exercise_equipment(exercise_id); CREATE INDEX idx_exercise_equipment_equipment_id ON exercise_equipment(equipment_id); -CREATE INDEX idx_exercise_alternatives_exercise_id ON exercise_alternatives(exercise_id); -CREATE INDEX idx_exercise_alternatives_alternative_id ON exercise_alternatives(alternative_exercise_id); - --- Create triggers for updated_at -CREATE TRIGGER update_muscle_groups_updated_at BEFORE UPDATE ON muscle_groups - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_equipment_updated_at BEFORE UPDATE ON equipment - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE INDEX idx_exercise_variations_exercise_id ON exercise_variations(exercise_id); +CREATE INDEX idx_exercise_variations_variation_id ON exercise_variations(variation_exercise_id); -CREATE TRIGGER update_exercises_updated_at BEFORE UPDATE ON exercises - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();