diff --git a/serv_nginx/api_bb/internal/handlers/handlers.go b/serv_nginx/api_bb/internal/handlers/handlers.go index aac5482..215ac2f 100644 --- a/serv_nginx/api_bb/internal/handlers/handlers.go +++ b/serv_nginx/api_bb/internal/handlers/handlers.go @@ -18,6 +18,7 @@ type Handler struct { newsHandler *NewsHandler reviewHandler *ReviewHandler userStatsHandler *UserStatsHandler + userWorkoutHandler *UserWorkoutHandler // Здесь будут добавлены другие обработчики // userHandler *UserHandler // eventHandler *EventHandler @@ -31,6 +32,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler { commentRepo := repository.NewCommentRepository(db) reviewRepo := repository.NewReviewRepository(db) userStatsRepo := repository.NewUserStatsRepository(db) + userWorkoutRepo := repository.NewWorkoutRepository(db) // Initialize logger baseLogger := logger.NewWrapper(logger.Get()) // Создаем базовый логгер @@ -43,6 +45,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler { newsService := service.NewNewsService(newsRepo, commentRepo, baseLogger) reviewService := service.NewReviewService(reviewRepo, baseLogger) userStatsService := service.NewUserStatsService(userStatsRepo) + userWorkoutService := service.NewWorkoutService(userWorkoutRepo) // Инициализация обработчиков healthHandler := NewHealthHandler() @@ -52,6 +55,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler { avatarHandler := NewAvatarHandler(avatarService) reviewHandler := NewReviewHandler(reviewService, baseLogger) userStatsHandler := NewUserStatsHandler(userStatsService) + userWorkoutHandler := NewUserWorkoutHandler(userWorkoutService) return &Handler{ healthHandler: healthHandler, @@ -61,6 +65,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler { avatarHandler: avatarHandler, reviewHandler: reviewHandler, userStatsHandler: userStatsHandler, + userWorkoutHandler: userWorkoutHandler, } } @@ -92,3 +97,7 @@ func (h *Handler) ReviewHandler() *ReviewHandler { func (h *Handler) UserStatsHandler() *UserStatsHandler { return h.userStatsHandler } + +func (h *Handler) UserWorkoutHandler() *UserWorkoutHandler { + return h.userWorkoutHandler +} diff --git a/serv_nginx/api_bb/internal/handlers/user_workout_handler.go b/serv_nginx/api_bb/internal/handlers/user_workout_handler.go new file mode 100644 index 0000000..9c32ff6 --- /dev/null +++ b/serv_nginx/api_bb/internal/handlers/user_workout_handler.go @@ -0,0 +1,374 @@ +// handlers/user_workout_handler.go +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "api_bb/internal/models" + "api_bb/internal/service" + "api_bb/pkg/logger" + "api_bb/pkg/middleware" + "api_bb/pkg/utils" + + "go.uber.org/zap" + "github.com/go-chi/chi/v5" +) + +type UserWorkoutHandler struct { + logger logger.LoggerInterface + workoutService service.WorkoutService +} + +func NewUserWorkoutHandler(workoutService service.WorkoutService) *UserWorkoutHandler { + return &UserWorkoutHandler{ + logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "user_workout"))), + workoutService: workoutService, + } +} + +// CreateWorkout создает новую тренировку +func (h *UserWorkoutHandler) CreateWorkout(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling create workout request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + // Получаем пользователя из контекста + user, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("create workout failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required") + return + } + + var req models.WorkoutCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.logger.Error("failed to decode JSON payload", zap.Error(err)) + utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error()) + return + } + + // Валидация + if err := utils.ValidateStruct(req); err != nil { + h.logger.Warn("create workout failed - validation error", zap.Error(err)) + utils.RespondWithError(w, http.StatusBadRequest, "Validation error: "+err.Error()) + return + } + + h.logger.Info("creating new workout", + zap.Uint("user_id", user.ID), + zap.String("type", string(req.Type)), + zap.Float64("distance", req.Distance), + zap.Int("duration", req.Duration), + ) + + // Создаем тренировку + workout, err := h.workoutService.CreateWorkout(user.ID, &req) + if err != nil { + h.logger.Error("failed to create workout in service", + zap.Uint("user_id", user.ID), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to create workout: "+err.Error()) + return + } + + h.logger.Info("workout created successfully", + zap.Uint("workout_id", workout.ID), + zap.Uint("user_id", user.ID), + ) + + utils.RespondWithJSON(w, http.StatusCreated, map[string]interface{}{ + "message": "Workout created successfully", + "workout": workout, + }) +} + +// GetWorkouts возвращает список тренировок пользователя +func (h *UserWorkoutHandler) GetWorkouts(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling get workouts request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + // Получаем пользователя из контекста + user, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("get workouts failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required") + return + } + + workouts, err := h.workoutService.GetUserWorkouts(user.ID) + if err != nil { + h.logger.Error("failed to get user workouts from service", + zap.Uint("user_id", user.ID), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get workouts: "+err.Error()) + return + } + + h.logger.Info("user workouts retrieved successfully", + zap.Uint("user_id", user.ID), + zap.Int("workouts_count", len(workouts)), + ) + + utils.RespondWithJSON(w, http.StatusOK, workouts) +} + +// GetWorkoutByID возвращает тренировку по ID +func (h *UserWorkoutHandler) GetWorkoutByID(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling get workout by ID request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + // Получаем пользователя из контекста + user, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("get workout failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required") + return + } + + // Получаем ID тренировки из URL параметров + workoutIDStr := chi.URLParam(r, "id") + workoutID, err := strconv.ParseUint(workoutIDStr, 10, 32) + if err != nil { + h.logger.Warn("invalid workout ID", zap.String("workout_id", workoutIDStr)) + utils.RespondWithError(w, http.StatusBadRequest, "Invalid workout ID") + return + } + + workout, err := h.workoutService.GetWorkoutByID(user.ID, uint(workoutID)) + if err != nil { + h.logger.Error("failed to get workout from service", + zap.Uint("user_id", user.ID), + zap.Uint("workout_id", uint(workoutID)), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusNotFound, "Workout not found: "+err.Error()) + return + } + + h.logger.Info("workout retrieved successfully", + zap.Uint("user_id", user.ID), + zap.Uint("workout_id", uint(workoutID)), + ) + + utils.RespondWithJSON(w, http.StatusOK, workout) +} + +// UpdateWorkout обновляет тренировку +func (h *UserWorkoutHandler) UpdateWorkout(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling update workout request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + // Получаем пользователя из контекста + user, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("update workout failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required") + return + } + + // Получаем ID тренировки из URL параметров + workoutIDStr := chi.URLParam(r, "id") + workoutID, err := strconv.ParseUint(workoutIDStr, 10, 32) + if err != nil { + h.logger.Warn("invalid workout ID", zap.String("workout_id", workoutIDStr)) + utils.RespondWithError(w, http.StatusBadRequest, "Invalid workout ID") + return + } + + var req models.WorkoutUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.logger.Error("failed to decode JSON payload", zap.Error(err)) + utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error()) + return + } + + // Валидация + if err := utils.ValidateStruct(req); err != nil { + h.logger.Warn("update workout failed - validation error", zap.Error(err)) + utils.RespondWithError(w, http.StatusBadRequest, "Validation error: "+err.Error()) + return + } + + h.logger.Info("updating workout", + zap.Uint("user_id", user.ID), + zap.Uint("workout_id", uint(workoutID)), + zap.String("type", string(req.Type)), + ) + + // Обновляем тренировку + workout, err := h.workoutService.UpdateWorkout(user.ID, uint(workoutID), &req) + if err != nil { + h.logger.Error("failed to update workout in service", + zap.Uint("user_id", user.ID), + zap.Uint("workout_id", uint(workoutID)), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update workout: "+err.Error()) + return + } + + h.logger.Info("workout updated successfully", + zap.Uint("user_id", user.ID), + zap.Uint("workout_id", uint(workoutID)), + ) + + utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{ + "message": "Workout updated successfully", + "workout": workout, + }) +} + +// DeleteWorkout удаляет тренировку +func (h *UserWorkoutHandler) DeleteWorkout(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling delete workout request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + // Получаем пользователя из контекста + user, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("delete workout failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required") + return + } + + // Получаем ID тренировки из URL параметров + workoutIDStr := chi.URLParam(r, "id") + workoutID, err := strconv.ParseUint(workoutIDStr, 10, 32) + if err != nil { + h.logger.Warn("invalid workout ID", zap.String("workout_id", workoutIDStr)) + utils.RespondWithError(w, http.StatusBadRequest, "Invalid workout ID") + return + } + + h.logger.Info("deleting workout", + zap.Uint("user_id", user.ID), + zap.Uint("workout_id", uint(workoutID)), + ) + + // Удаляем тренировку + if err := h.workoutService.DeleteWorkout(user.ID, uint(workoutID)); err != nil { + h.logger.Error("failed to delete workout in service", + zap.Uint("user_id", user.ID), + zap.Uint("workout_id", uint(workoutID)), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to delete workout: "+err.Error()) + return + } + + h.logger.Info("workout deleted successfully", + zap.Uint("user_id", user.ID), + zap.Uint("workout_id", uint(workoutID)), + ) + + utils.RespondWithJSON(w, http.StatusOK, map[string]string{ + "message": "Workout deleted successfully", + }) +} + +// GetWorkoutStats возвращает статистику тренировок +func (h *UserWorkoutHandler) GetWorkoutStats(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling get workout stats request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + // Получаем пользователя из контекста + user, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("get workout stats failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required") + return + } + + stats, err := h.workoutService.GetWorkoutStats(user.ID) + if err != nil { + h.logger.Error("failed to get workout stats from service", + zap.Uint("user_id", user.ID), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get workout stats: "+err.Error()) + return + } + + h.logger.Info("workout stats retrieved successfully", + zap.Uint("user_id", user.ID), + zap.Int("total_workouts", stats.TotalWorkouts), + zap.Float64("total_distance", stats.TotalDistance), + ) + + utils.RespondWithJSON(w, http.StatusOK, stats) +} + +// GetWorkoutsByType возвращает тренировки по типу +func (h *UserWorkoutHandler) GetWorkoutsByType(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling get workouts by type request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + // Получаем пользователя из контекста + user, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("get workouts by type failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required") + return + } + + // Получаем тип тренировки из URL параметров + workoutType := models.WorkoutType(chi.URLParam(r, "type")) + + // Валидация типа тренировки + validTypes := map[models.WorkoutType]bool{ + models.WorkoutTypeEasy: true, + models.WorkoutTypeTempo: true, + models.WorkoutTypeInterval: true, + models.WorkoutTypeLong: true, + models.WorkoutTypeRecovery: true, + } + + if !validTypes[workoutType] { + h.logger.Warn("invalid workout type", zap.String("type", string(workoutType))) + utils.RespondWithError(w, http.StatusBadRequest, "Invalid workout type") + return + } + + workouts, err := h.workoutService.GetWorkoutsByType(user.ID, workoutType) + if err != nil { + h.logger.Error("failed to get workouts by type from service", + zap.Uint("user_id", user.ID), + zap.String("type", string(workoutType)), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get workouts: "+err.Error()) + return + } + + h.logger.Info("workouts by type retrieved successfully", + zap.Uint("user_id", user.ID), + zap.String("type", string(workoutType)), + zap.Int("workouts_count", len(workouts)), + ) + + utils.RespondWithJSON(w, http.StatusOK, workouts) +} \ No newline at end of file diff --git a/serv_nginx/api_bb/internal/models/workout.go b/serv_nginx/api_bb/internal/models/workout.go index 3efecbf..93f63a6 100644 --- a/serv_nginx/api_bb/internal/models/workout.go +++ b/serv_nginx/api_bb/internal/models/workout.go @@ -56,20 +56,20 @@ type WorkoutCreateRequest struct { Type WorkoutType `json:"type" validate:"required,oneof=easy tempo interval long recovery"` Distance float64 `json:"distance_km" validate:"required,min=0.1,max=1000"` Duration int `json:"duration_min" validate:"required,min=1,max=1440"` - Pace string `json:"pace" validate:"max=20"` - Calories int `json:"calories" validate:"min=0,max=5000"` - Notes string `json:"notes" validate:"max=1000"` + Pace string `json:"pace" validate:"maxlen=20"` + Calories int `json:"calories" validate:"minint=0,maxint=5000"` + Notes string `json:"notes" validate:"maxlen=1000"` Date time.Time `json:"date" validate:"required"` } // DTO для обновления тренировки type WorkoutUpdateRequest struct { - Type WorkoutType `json:"type" validate:"omitempty,oneof=easy tempo interval long recovery"` - Distance float64 `json:"distance_km" validate:"omitempty,min=0.1,max=1000"` - Duration int `json:"duration_min" validate:"omitempty,min=1,max=1440"` - Pace string `json:"pace" validate:"omitempty,max=20"` - Calories int `json:"calories" validate:"omitempty,min=0,max=5000"` - Notes string `json:"notes" validate:"omitempty,max=1000"` + Type WorkoutType `json:"type" validate:"oneof=easy tempo interval long recovery"` + Distance float64 `json:"distance_km" validate:"min=0.1,max=1000"` + Duration int `json:"duration_min" validate:"min=1,max=1440"` + Pace string `json:"pace" validate:"maxlen=20"` + Calories int `json:"calories" validate:"minint=0,maxint=5000"` + Notes string `json:"notes" validate:"maxlen=1000"` Date time.Time `json:"date"` } diff --git a/serv_nginx/api_bb/internal/repository/workout_repository.go b/serv_nginx/api_bb/internal/repository/workout_repository.go index e58a336..bec388e 100644 --- a/serv_nginx/api_bb/internal/repository/workout_repository.go +++ b/serv_nginx/api_bb/internal/repository/workout_repository.go @@ -3,6 +3,7 @@ package repository import ( "api_bb/internal/models" + "errors" "fmt" "time" @@ -26,6 +27,10 @@ type workoutRepository struct { db *gorm.DB } +var ( + ErrNotFound = errors.New("record not found") +) + func NewWorkoutRepository(db *gorm.DB) WorkoutRepository { return &workoutRepository{db: db} } diff --git a/serv_nginx/api_bb/internal/routes/routes.go b/serv_nginx/api_bb/internal/routes/routes.go index ec7ee97..eb7c77c 100644 --- a/serv_nginx/api_bb/internal/routes/routes.go +++ b/serv_nginx/api_bb/internal/routes/routes.go @@ -83,6 +83,20 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler { r.Post("/weekly/reset", allHandler.UserStatsHandler().ResetWeeklyDistance) r.Post("/monthly/reset", allHandler.UserStatsHandler().ResetMonthlyDistance) }) + + // Маршруты для тренировок + r.Route("/workouts", func(r chi.Router) { + r.Post("/", allHandler.UserWorkoutHandler().CreateWorkout) + r.Get("/", allHandler.UserWorkoutHandler().GetWorkouts) + r.Get("/stats", allHandler.UserWorkoutHandler().GetWorkoutStats) + r.Get("/type/{type}", allHandler.UserWorkoutHandler().GetWorkoutsByType) + + r.Route("/{id}", func(r chi.Router) { + r.Get("/", allHandler.UserWorkoutHandler().GetWorkoutByID) + r.Put("/", allHandler.UserWorkoutHandler().UpdateWorkout) + r.Delete("/", allHandler.UserWorkoutHandler().DeleteWorkout) + }) + }) // Здесь будут другие защищенные маршруты пользователя }) diff --git a/serv_nginx/api_bb/internal/service/user_workout_service.go b/serv_nginx/api_bb/internal/service/user_workout_service.go new file mode 100644 index 0000000..c099832 --- /dev/null +++ b/serv_nginx/api_bb/internal/service/user_workout_service.go @@ -0,0 +1,285 @@ +// service/user_workout_service.go +package service + +import ( + "api_bb/internal/models" + "api_bb/internal/repository" + "api_bb/pkg/logger" + + "go.uber.org/zap" +) + +type WorkoutService interface { + CreateWorkout(userID uint, req *models.WorkoutCreateRequest) (*models.Workout, error) + GetUserWorkouts(userID uint) ([]models.Workout, error) + GetWorkoutByID(userID uint, workoutID uint) (*models.Workout, error) + UpdateWorkout(userID uint, workoutID uint, req *models.WorkoutUpdateRequest) (*models.Workout, error) + DeleteWorkout(userID uint, workoutID uint) error + GetWorkoutStats(userID uint) (*models.WorkoutStatsResponse, error) + GetWorkoutsByType(userID uint, workoutType models.WorkoutType) ([]models.Workout, error) + GetLatestWorkouts(userID uint, limit int) ([]models.Workout, error) +} + +type workoutService struct { + workoutRepo repository.WorkoutRepository + logger logger.LoggerInterface +} + +func NewWorkoutService(workoutRepo repository.WorkoutRepository) WorkoutService { + return &workoutService{ + workoutRepo: workoutRepo, + logger: logger.NewWrapper(logger.Get().With(zap.String("service", "workout"))), + } +} + +// CreateWorkout создает новую тренировку +func (s *workoutService) CreateWorkout(userID uint, req *models.WorkoutCreateRequest) (*models.Workout, error) { + s.logger.Info("creating new workout", + zap.Uint("user_id", userID), + zap.String("type", string(req.Type)), + zap.Float64("distance", req.Distance), + ) + + // Создаем модель тренировки + workout := &models.Workout{ + UserID: userID, + Type: req.Type, + Distance: req.Distance, + Duration: req.Duration, + Pace: req.Pace, + Calories: req.Calories, + Notes: req.Notes, + Date: req.Date, + } + + // Сохраняем в репозитории + if err := s.workoutRepo.Create(workout); err != nil { + s.logger.Error("failed to create workout in repository", + zap.Uint("user_id", userID), + zap.Error(err), + ) + return nil, err + } + + s.logger.Info("workout created successfully", + zap.Uint("workout_id", workout.ID), + zap.Uint("user_id", userID), + ) + + return workout, nil +} + +// GetUserWorkouts возвращает все тренировки пользователя +func (s *workoutService) GetUserWorkouts(userID uint) ([]models.Workout, error) { + s.logger.Debug("getting user workouts", zap.Uint("user_id", userID)) + + workouts, err := s.workoutRepo.FindByUserID(userID) + if err != nil { + s.logger.Error("failed to get user workouts from repository", + zap.Uint("user_id", userID), + zap.Error(err), + ) + return nil, err + } + + s.logger.Debug("retrieved user workouts", + zap.Uint("user_id", userID), + zap.Int("count", len(workouts)), + ) + + return workouts, nil +} + +// GetWorkoutByID возвращает тренировку по ID +func (s *workoutService) GetWorkoutByID(userID uint, workoutID uint) (*models.Workout, error) { + s.logger.Debug("getting workout by ID", + zap.Uint("user_id", userID), + zap.Uint("workout_id", workoutID), + ) + + workout, err := s.workoutRepo.FindByID(workoutID) + if err != nil { + s.logger.Error("failed to get workout from repository", + zap.Uint("user_id", userID), + zap.Uint("workout_id", workoutID), + zap.Error(err), + ) + return nil, err + } + + // Проверяем, что тренировка принадлежит пользователю + if workout.UserID != userID { + s.logger.Warn("workout access denied - user mismatch", + zap.Uint("user_id", userID), + zap.Uint("workout_user_id", workout.UserID), + zap.Uint("workout_id", workoutID), + ) + return nil, repository.ErrNotFound + } + + s.logger.Debug("workout retrieved successfully", + zap.Uint("user_id", userID), + zap.Uint("workout_id", workoutID), + ) + + return workout, nil +} + +// UpdateWorkout обновляет тренировку +func (s *workoutService) UpdateWorkout(userID uint, workoutID uint, req *models.WorkoutUpdateRequest) (*models.Workout, error) { + s.logger.Info("updating workout", + zap.Uint("user_id", userID), + zap.Uint("workout_id", workoutID), + ) + + // Сначала получаем существующую тренировку + workout, err := s.GetWorkoutByID(userID, workoutID) + if err != nil { + return nil, err + } + + // Обновляем только переданные поля + if req.Type != "" { + workout.Type = req.Type + } + if req.Distance > 0 { + workout.Distance = req.Distance + } + if req.Duration > 0 { + workout.Duration = req.Duration + } + if req.Pace != "" { + workout.Pace = req.Pace + } + if req.Calories > 0 { + workout.Calories = req.Calories + } + if req.Notes != "" { + workout.Notes = req.Notes + } + if !req.Date.IsZero() { + workout.Date = req.Date + } + + // Сохраняем обновления + if err := s.workoutRepo.Update(workout); err != nil { + s.logger.Error("failed to update workout in repository", + zap.Uint("user_id", userID), + zap.Uint("workout_id", workoutID), + zap.Error(err), + ) + return nil, err + } + + s.logger.Info("workout updated successfully", + zap.Uint("user_id", userID), + zap.Uint("workout_id", workoutID), + ) + + return workout, nil +} + +// DeleteWorkout удаляет тренировку +func (s *workoutService) DeleteWorkout(userID uint, workoutID uint) error { + s.logger.Info("deleting workout", + zap.Uint("user_id", userID), + zap.Uint("workout_id", workoutID), + ) + + // Проверяем, что тренировка существует и принадлежит пользователю + workout, err := s.GetWorkoutByID(userID, workoutID) + if err != nil { + return err + } + + // Удаляем тренировку + if err := s.workoutRepo.Delete(workout.ID); err != nil { + s.logger.Error("failed to delete workout from repository", + zap.Uint("user_id", userID), + zap.Uint("workout_id", workoutID), + zap.Error(err), + ) + return err + } + + s.logger.Info("workout deleted successfully", + zap.Uint("user_id", userID), + zap.Uint("workout_id", workoutID), + ) + + return nil +} + +// GetWorkoutStats возвращает статистику тренировок +func (s *workoutService) GetWorkoutStats(userID uint) (*models.WorkoutStatsResponse, error) { + s.logger.Debug("getting workout stats", zap.Uint("user_id", userID)) + + stats, err := s.workoutRepo.GetWorkoutStats(userID) + if err != nil { + s.logger.Error("failed to get workout stats from repository", + zap.Uint("user_id", userID), + zap.Error(err), + ) + return nil, err + } + + s.logger.Debug("workout stats retrieved successfully", + zap.Uint("user_id", userID), + zap.Int("total_workouts", stats.TotalWorkouts), + zap.Float64("total_distance", stats.TotalDistance), + ) + + return stats, nil +} + +// GetWorkoutsByType возвращает тренировки по типу +func (s *workoutService) GetWorkoutsByType(userID uint, workoutType models.WorkoutType) ([]models.Workout, error) { + s.logger.Debug("getting workouts by type", + zap.Uint("user_id", userID), + zap.String("type", string(workoutType)), + ) + + workouts, err := s.workoutRepo.GetByType(userID, workoutType) + if err != nil { + s.logger.Error("failed to get workouts by type from repository", + zap.Uint("user_id", userID), + zap.String("type", string(workoutType)), + zap.Error(err), + ) + return nil, err + } + + s.logger.Debug("workouts by type retrieved successfully", + zap.Uint("user_id", userID), + zap.String("type", string(workoutType)), + zap.Int("count", len(workouts)), + ) + + return workouts, nil +} + +// GetLatestWorkouts возвращает последние тренировки +func (s *workoutService) GetLatestWorkouts(userID uint, limit int) ([]models.Workout, error) { + s.logger.Debug("getting latest workouts", + zap.Uint("user_id", userID), + zap.Int("limit", limit), + ) + + workouts, err := s.workoutRepo.GetLatestWorkouts(userID, limit) + if err != nil { + s.logger.Error("failed to get latest workouts from repository", + zap.Uint("user_id", userID), + zap.Int("limit", limit), + zap.Error(err), + ) + return nil, err + } + + s.logger.Debug("latest workouts retrieved successfully", + zap.Uint("user_id", userID), + zap.Int("limit", limit), + zap.Int("count", len(workouts)), + ) + + return workouts, nil +} \ No newline at end of file diff --git a/serv_nginx/api_bb/pkg/utils/response.go b/serv_nginx/api_bb/pkg/utils/response.go new file mode 100644 index 0000000..2067796 --- /dev/null +++ b/serv_nginx/api_bb/pkg/utils/response.go @@ -0,0 +1,20 @@ +// pkg/utils/response.go (дополнение) +package utils + +import ( + "encoding/json" + "net/http" +) + +// RespondWithValidationError отправляет ответ с ошибками валидации +func RespondWithValidationError(w http.ResponseWriter, validationError error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + + response := map[string]interface{}{ + "error": "Validation failed", + "details": GetValidationErrors(validationError), + } + + json.NewEncoder(w).Encode(response) +} diff --git a/serv_nginx/api_bb/pkg/utils/validation.go b/serv_nginx/api_bb/pkg/utils/validation.go new file mode 100644 index 0000000..4e958b1 --- /dev/null +++ b/serv_nginx/api_bb/pkg/utils/validation.go @@ -0,0 +1,361 @@ +// pkg/utils/validation.go +package utils + +import ( + "fmt" + "reflect" + "regexp" + "strconv" + "strings" + "time" + + "go.uber.org/zap" +) + +// ValidationError представляет ошибку валидации +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +func (e ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Field, e.Message) +} + +// ValidationResult содержит результат валидации +type ValidationResult struct { + IsValid bool + Errors []ValidationError +} + +// TagOptions содержит опции из тега validate +type TagOptions struct { + Required bool + Min *float64 + Max *float64 + MinInt *int64 + MaxInt *int64 + OneOf []string + Email bool + MaxLength *int + MinLength *int + Custom string +} + +// ValidateStruct валидирует структуру на основе тегов validate +func ValidateStruct(s interface{}) error { + val := reflect.ValueOf(s) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + if val.Kind() != reflect.Struct { + return fmt.Errorf("ValidateStruct expects a struct, got %T", s) + } + + var errors []ValidationError + typ := val.Type() + + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + + // Пропускаем неэкспортируемые поля + if !field.CanInterface() { + continue + } + + tag := fieldType.Tag.Get("validate") + if tag == "" { + continue + } + + options := parseTagOptions(tag) + fieldName := getFieldName(fieldType) + + // Валидация поля + if err := validateField(field, fieldName, options); err != nil { + errors = append(errors, err...) + } + } + + if len(errors) > 0 { + return &ValidationResult{ + IsValid: false, + Errors: errors, + } + } + + return nil +} + +// parseTagOptions парсит тег validate и возвращает опции +func parseTagOptions(tag string) TagOptions { + options := TagOptions{} + parts := strings.Split(tag, ",") + + for _, part := range parts { + part = strings.TrimSpace(part) + + switch { + case part == "required": + options.Required = true + case part == "email": + options.Email = true + case strings.HasPrefix(part, "min="): + if val, err := strconv.ParseFloat(part[4:], 64); err == nil { + options.Min = &val + } + case strings.HasPrefix(part, "max="): + if val, err := strconv.ParseFloat(part[4:], 64); err == nil { + options.Max = &val + } + case strings.HasPrefix(part, "minint="): + if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil { + options.MinInt = &val + } + case strings.HasPrefix(part, "maxint="): + if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil { + options.MaxInt = &val + } + case strings.HasPrefix(part, "oneof="): + options.OneOf = strings.Split(part[6:], " ") + case strings.HasPrefix(part, "maxlen="): + if val, err := strconv.Atoi(part[7:]); err == nil { + options.MaxLength = &val + } + case strings.HasPrefix(part, "minlen="): + if val, err := strconv.Atoi(part[7:]); err == nil { + options.MinLength = &val + } + case strings.HasPrefix(part, "custom="): + options.Custom = part[7:] + } + } + + return options +} + +// getFieldName возвращает имя поля для сообщений об ошибках +func getFieldName(field reflect.StructField) string { + jsonTag := field.Tag.Get("json") + if jsonTag != "" { + parts := strings.Split(jsonTag, ",") + if parts[0] != "" { + return parts[0] + } + } + return field.Name +} + +// validateField валидирует отдельное поле +func validateField(field reflect.Value, fieldName string, options TagOptions) []ValidationError { + var errors []ValidationError + + // Проверка required + if options.Required { + if isEmptyValue(field) { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: "field is required", + }) + return errors // Если поле обязательно и пустое, дальше не проверяем + } + } + + // Если поле пустое и не обязательное, дальше не проверяем + if isEmptyValue(field) { + return errors + } + + // Валидация в зависимости от типа поля + switch field.Kind() { + case reflect.String: + errors = append(errors, validateString(field.String(), fieldName, options)...) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + errors = append(errors, validateInt(field.Int(), fieldName, options)...) + case reflect.Float32, reflect.Float64: + errors = append(errors, validateFloat(field.Float(), fieldName, options)...) + case reflect.Struct: + // Для time.Time и других структур + if field.Type().String() == "time.Time" { + errors = append(errors, validateTime(field.Interface().(time.Time), fieldName, options)...) + } + } + + return errors +} + +// validateString валидирует строковые поля +func validateString(value, fieldName string, options TagOptions) []ValidationError { + var errors []ValidationError + + // Проверка email + if options.Email { + if !isValidEmail(value) { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: "invalid email format", + }) + } + } + + // Проверка длины строки + if options.MinLength != nil && len(value) < *options.MinLength { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("minimum length is %d characters", *options.MinLength), + }) + } + + if options.MaxLength != nil && len(value) > *options.MaxLength { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("maximum length is %d characters", *options.MaxLength), + }) + } + + // Проверка oneof + if len(options.OneOf) > 0 { + valid := false + for _, allowed := range options.OneOf { + if value == allowed { + valid = true + break + } + } + if !valid { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("must be one of: %s", strings.Join(options.OneOf, ", ")), + }) + } + } + + return errors +} + +// validateInt валидирует целочисленные поля +func validateInt(value int64, fieldName string, options TagOptions) []ValidationError { + var errors []ValidationError + + if options.MinInt != nil && value < *options.MinInt { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("minimum value is %d", *options.MinInt), + }) + } + + if options.MaxInt != nil && value > *options.MaxInt { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("maximum value is %d", *options.MaxInt), + }) + } + + return errors +} + +// validateFloat валидирует поля с плавающей точкой +func validateFloat(value float64, fieldName string, options TagOptions) []ValidationError { + var errors []ValidationError + + if options.Min != nil && value < *options.Min { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("minimum value is %.2f", *options.Min), + }) + } + + if options.Max != nil && value > *options.Max { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("maximum value is %.2f", *options.Max), + }) + } + + return errors +} + +// validateTime валидирует временные поля +func validateTime(value time.Time, fieldName string, options TagOptions) []ValidationError { + var errors []ValidationError + + // Проверка, что дата не нулевая + if value.IsZero() && options.Required { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: "date is required", + }) + } + + // Проверка, что дата не в будущем (пример кастомной валидации) + if options.Custom == "not_future" && value.After(time.Now()) { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: "date cannot be in the future", + }) + } + + return errors +} + +// isEmptyValue проверяет, является ли значение пустым +func isEmptyValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.String: + return v.String() == "" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Struct: + if v.Type().String() == "time.Time" { + return v.Interface().(time.Time).IsZero() + } + case reflect.Ptr, reflect.Interface: + return v.IsNil() + case reflect.Slice, reflect.Map, reflect.Array: + return v.Len() == 0 + } + return false +} + +// isValidEmail проверяет валидность email +func isValidEmail(email string) bool { + emailRegex := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` + matched, _ := regexp.MatchString(emailRegex, email) + return matched +} + +// Error возвращает строковое представление ошибок валидации +func (vr *ValidationResult) Error() string { + var errorMessages []string + for _, err := range vr.Errors { + errorMessages = append(errorMessages, err.Error()) + } + return strings.Join(errorMessages, "; ") +} + +// GetValidationErrors возвращает ошибки валидации в структурированном виде +func GetValidationErrors(err error) []ValidationError { + if vr, ok := err.(*ValidationResult); ok { + return vr.Errors + } + return nil +} + +// LogValidationErrors логирует ошибки валидации +func LogValidationErrors(logger *zap.Logger, err error, context string) { + if vr, ok := err.(*ValidationResult); ok { + for _, validationErr := range vr.Errors { + logger.Warn("validation error", + zap.String("context", context), + zap.String("field", validationErr.Field), + zap.String("error", validationErr.Message), + ) + } + } +}