internal/handler/user_workout_handler.go
Rama main · réplica de lectura del repositorio original.
package handler
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/johnzastrow/actalog/internal/domain"
"github.com/johnzastrow/actalog/internal/service"
"github.com/johnzastrow/actalog/pkg/logger"
"github.com/johnzastrow/actalog/pkg/middleware"
)
// UserWorkoutHandler handles logging workout instances
type UserWorkoutHandler struct {
userWorkoutService *service.UserWorkoutService
logger *logger.Logger
}
// NewUserWorkoutHandler creates a new user workout handler
func NewUserWorkoutHandler(userWorkoutService *service.UserWorkoutService, l *logger.Logger) *UserWorkoutHandler {
return &UserWorkoutHandler{
userWorkoutService: userWorkoutService,
logger: l,
}
}
// LogWorkoutRequest represents a request to log a workout instance
// @Description Request to log a workout (either from template or ad-hoc)
type LogWorkoutRequest struct {
WorkoutID *int64 `json:"workout_id,omitempty" example:"1"` // Template ID (optional for ad-hoc workouts)
WorkoutName *string `json:"workout_name,omitempty" example:"Fran"` // Name for ad-hoc workouts (required if workout_id is null)
WorkoutDate string `json:"workout_date" example:"2024-01-15"` // YYYY-MM-DD format
WorkoutType *string `json:"workout_type,omitempty" example:"AMRAP"`
TotalTime *int `json:"total_time,omitempty" example:"1200"` // Duration in seconds
Notes *string `json:"notes,omitempty" example:"Felt strong today"`
// Performance data
Movements []MovementPerformance `json:"movements,omitempty"`
WODs []WODPerformance `json:"wods,omitempty"`
}
// MovementPerformance represents performance data for a single movement
// @Description Performance data recorded for a movement in a workout
type MovementPerformance struct {
MovementID int64 `json:"movement_id" example:"1"`
Sets *int `json:"sets,omitempty" example:"5"`
Reps *int `json:"reps,omitempty" example:"10"`
Weight *float64 `json:"weight,omitempty" example:"135.5"`
Time *int `json:"time,omitempty" example:"300"` // in seconds
Distance *float64 `json:"distance,omitempty" example:"1000"`
Notes string `json:"notes,omitempty" example:"Used belt"`
RPE *int `json:"rpe,omitempty" example:"8"` // Rate of Perceived Exertion (2-10)
OrderIndex int `json:"order_index" example:"0"`
}
// WODPerformance represents performance data for a single WOD
// @Description Performance data recorded for a WOD in a workout
type WODPerformance struct {
WODID int64 `json:"wod_id" example:"1"`
ScoreType *string `json:"score_type,omitempty" example:"Time"` // Time, Rounds+Reps, Max Weight
ScoreValue *string `json:"score_value,omitempty" example:"12:30"` // Formatted score
TimeSeconds *int `json:"time_seconds,omitempty" example:"750"` // For time-based WODs
Rounds *int `json:"rounds,omitempty" example:"5"` // For AMRAP
Reps *int `json:"reps,omitempty" example:"12"` // Remaining reps in AMRAP
Weight *float64 `json:"weight,omitempty" example:"225.0"` // For max weight WODs
Notes string `json:"notes,omitempty" example:"PR attempt"`
RPE *int `json:"rpe,omitempty" example:"9"` // Rate of Perceived Exertion (2-10)
OrderIndex int `json:"order_index" example:"0"`
}
// UpdateLoggedWorkoutRequest represents a request to update a logged workout
// @Description Request to update an existing logged workout
type UpdateLoggedWorkoutRequest struct {
WorkoutName *string `json:"workout_name,omitempty" example:"Custom WOD"` // For ad-hoc workouts
WorkoutType *string `json:"workout_type,omitempty" example:"EMOM"`
TotalTime *int `json:"total_time,omitempty" example:"900"`
Notes *string `json:"notes,omitempty" example:"Updated notes"`
Movements []MovementPerformance `json:"movements,omitempty"`
WODs []WODPerformance `json:"wods,omitempty"`
}
// UserWorkoutResponse represents a logged workout instance
// @Description Complete logged workout with performance data
type UserWorkoutResponse struct {
ID int64 `json:"id" example:"1"`
UserID int64 `json:"user_id" example:"1"`
WorkoutID *int64 `json:"workout_id,omitempty" example:"1"` // Nullable for ad-hoc workouts
WorkoutName string `json:"workout_name" example:"Fran"`
WorkoutDate string `json:"workout_date" example:"2024-01-15"`
WorkoutType *string `json:"workout_type,omitempty" example:"For Time"`
TotalTime *int `json:"total_time,omitempty" example:"1200"`
Notes *string `json:"notes,omitempty" example:"Felt great"`
CreatedAt string `json:"created_at" example:"2024-01-15T10:30:00Z"`
UpdatedAt string `json:"updated_at" example:"2024-01-15T10:30:00Z"`
Movements []*domain.WorkoutMovement `json:"movements,omitempty"` // Template movements
WODs []*domain.WorkoutWODWithDetails `json:"wods,omitempty"` // Template WODs
PerformanceMovements []*domain.UserWorkoutMovement `json:"performance_movements,omitempty"` // Actual performance
PerformanceWODs []*domain.UserWorkoutWOD `json:"performance_wods,omitempty"` // Actual performance
WorkoutNotes *string `json:"workout_notes,omitempty" example:"Template notes"`
}
// LogWorkout logs a workout instance (user performs a workout template)
// @Summary Log a workout
// @Description Record a workout with performance data. Can be based on a template or ad-hoc.
// @Tags workouts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body LogWorkoutRequest true "Workout data with optional performance"
// @Success 201 {object} UserWorkoutResponse "Workout logged successfully"
// @Failure 400 {object} ErrorResponse "Invalid request body or missing required fields"
// @Failure 401 {object} ErrorResponse "Unauthorized"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /workouts [post]
func (h *UserWorkoutHandler) LogWorkout(w http.ResponseWriter, r *http.Request) {
// Extract user ID and email from JWT token in context
userID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
userEmail, _ := middleware.GetUserEmail(r.Context())
var req LogWorkoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if h.logger != nil {
h.logger.Error("action=log_workout outcome=failure error=json_decode_error details=%v", err)
}
respondError(w, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
// Validate required fields
// Either workout_id (template-based) OR workout_name (ad-hoc) must be provided
if (req.WorkoutID == nil || *req.WorkoutID == 0) && (req.WorkoutName == nil || *req.WorkoutName == "") {
if h.logger != nil {
h.logger.Warn("action=log_workout outcome=failure user_id=%d reason=missing_workout_id_and_name", userID)
}
respondError(w, http.StatusBadRequest, "Either workout_id or workout_name is required")
return
}
if req.WorkoutDate == "" {
respondError(w, http.StatusBadRequest, "Workout date is required")
return
}
// Parse workout date
workoutDate, err := time.Parse("2006-01-02", req.WorkoutDate)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid workout date format. Use YYYY-MM-DD")
return
}
if h.logger != nil {
if req.WorkoutID != nil {
h.logger.Info("action=log_workout_attempt user_id=%d workout_id=%d date=%s", userID, *req.WorkoutID, req.WorkoutDate)
} else {
h.logger.Info("action=log_adhoc_workout_attempt user_id=%d workout_name=%s date=%s", userID, *req.WorkoutName, req.WorkoutDate)
}
}
// Check if performance data was provided
var userWorkout *domain.UserWorkout
if len(req.Movements) > 0 || len(req.WODs) > 0 {
// Convert request movements to domain movements
movements := make([]*domain.UserWorkoutMovement, len(req.Movements))
for i, m := range req.Movements {
movements[i] = &domain.UserWorkoutMovement{
MovementID: m.MovementID,
Sets: m.Sets,
Reps: m.Reps,
Weight: m.Weight,
Time: m.Time,
Distance: m.Distance,
Notes: m.Notes,
RPE: m.RPE,
OrderIndex: m.OrderIndex,
}
}
// Convert request WODs to domain WODs
wods := make([]*domain.UserWorkoutWOD, len(req.WODs))
for i, w := range req.WODs {
wods[i] = &domain.UserWorkoutWOD{
WODID: w.WODID,
ScoreType: w.ScoreType,
ScoreValue: w.ScoreValue,
TimeSeconds: w.TimeSeconds,
Rounds: w.Rounds,
Reps: w.Reps,
Weight: w.Weight,
Notes: w.Notes,
RPE: w.RPE,
OrderIndex: w.OrderIndex,
}
}
// Log workout with performance data
userWorkout, err = h.userWorkoutService.LogWorkoutWithPerformance(
userID, userEmail, req.WorkoutID, req.WorkoutName, workoutDate,
req.Notes, req.TotalTime, req.WorkoutType,
movements, wods,
)
} else {
// Log workout without performance data
userWorkout, err = h.userWorkoutService.LogWorkout(userID, userEmail, req.WorkoutID, req.WorkoutName, workoutDate, req.Notes, req.TotalTime, req.WorkoutType)
}
if err != nil {
if h.logger != nil {
h.logger.Error("action=log_workout outcome=failure user_id=%d error=%v", userID, err)
}
respondError(w, http.StatusInternalServerError, "Failed to log workout: "+err.Error())
return
}
// Retrieve logged workout with details
logged, err := h.userWorkoutService.GetLoggedWorkout(userWorkout.ID, userID)
if err != nil {
if h.logger != nil {
h.logger.Error("action=log_workout outcome=failure user_id=%d workout_id=%d error=retrieval_failed %v", userID, req.WorkoutID, err)
}
respondError(w, http.StatusInternalServerError, "Failed to retrieve logged workout")
return
}
response := UserWorkoutResponse{
ID: logged.ID,
UserID: logged.UserID,
WorkoutID: logged.WorkoutID,
WorkoutName: logged.WorkoutName,
WorkoutDate: logged.WorkoutDate.Format("2006-01-02"),
WorkoutType: logged.WorkoutType,
TotalTime: logged.TotalTime,
Notes: logged.Notes,
CreatedAt: logged.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: logged.UpdatedAt.Format("2006-01-02T15:04:05Z"),
Movements: logged.Movements,
WODs: logged.WODs,
PerformanceMovements: logged.PerformanceMovements,
PerformanceWODs: logged.PerformanceWODs,
WorkoutNotes: logged.WorkoutDescription,
}
if h.logger != nil {
h.logger.Info("action=log_workout outcome=success user_id=%d logged_id=%d", userID, userWorkout.ID)
}
respondJSON(w, http.StatusCreated, response)
}
// GetLoggedWorkout retrieves a logged workout by ID
// @Summary Get a logged workout
// @Description Retrieve a specific logged workout by ID with all performance data
// @Tags workouts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Logged workout ID"
// @Success 200 {object} UserWorkoutResponse "Logged workout details"
// @Failure 400 {object} ErrorResponse "Invalid workout ID"
// @Failure 401 {object} ErrorResponse "Unauthorized"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /workouts/{id} [get]
func (h *UserWorkoutHandler) GetLoggedWorkout(w http.ResponseWriter, r *http.Request) {
// Extract user ID from JWT token in context
userID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid workout ID")
return
}
if h.logger != nil {
h.logger.Info("action=get_workout user_id=%d workout_id=%d", userID, id)
}
logged, err := h.userWorkoutService.GetLoggedWorkout(id, userID)
if err != nil {
if h.logger != nil {
h.logger.Error("action=get_workout outcome=failure user_id=%d workout_id=%d error=%v", userID, id, err)
}
respondError(w, http.StatusInternalServerError, "Failed to retrieve logged workout: "+err.Error())
return
}
response := UserWorkoutResponse{
ID: logged.ID,
UserID: logged.UserID,
WorkoutID: logged.WorkoutID,
WorkoutName: logged.WorkoutName,
WorkoutDate: logged.WorkoutDate.Format("2006-01-02"),
WorkoutType: logged.WorkoutType,
TotalTime: logged.TotalTime,
Notes: logged.Notes,
CreatedAt: logged.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: logged.UpdatedAt.Format("2006-01-02T15:04:05Z"),
Movements: logged.Movements,
WODs: logged.WODs,
PerformanceMovements: logged.PerformanceMovements,
PerformanceWODs: logged.PerformanceWODs,
WorkoutNotes: logged.WorkoutDescription,
}
respondJSON(w, http.StatusOK, response)
}
// ListLoggedWorkouts retrieves all workouts logged by the user
// @Summary List logged workouts
// @Description Retrieve all logged workouts for the user with pagination and optional date filtering
// @Tags workouts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param limit query int false "Max results (default 20)"
// @Param offset query int false "Skip N results (default 0)"
// @Param start_date query string false "Filter start date (YYYY-MM-DD)"
// @Param end_date query string false "Filter end date (YYYY-MM-DD)"
// @Success 200 {object} map[string]interface{} "List of workouts with pagination"
// @Failure 400 {object} ErrorResponse "Invalid date format"
// @Failure 401 {object} ErrorResponse "Unauthorized"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /workouts [get]
func (h *UserWorkoutHandler) ListLoggedWorkouts(w http.ResponseWriter, r *http.Request) {
// Extract user ID from JWT token in context
userID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
// Parse pagination parameters
limitStr := r.URL.Query().Get("limit")
offsetStr := r.URL.Query().Get("offset")
limit := 20 // default
offset := 0
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
}
// Check for date range filtering
startDateStr := r.URL.Query().Get("start_date")
endDateStr := r.URL.Query().Get("end_date")
var workouts []*domain.UserWorkoutWithDetails
var err error
if startDateStr != "" && endDateStr != "" {
startDate, err1 := time.Parse("2006-01-02", startDateStr)
endDate, err2 := time.Parse("2006-01-02", endDateStr)
if err1 != nil || err2 != nil {
if h.logger != nil {
h.logger.Warn("action=list_workouts outcome=failure user_id=%d reason=invalid_date_range start=%s end=%s", userID, startDateStr, endDateStr)
}
respondError(w, http.StatusBadRequest, "Invalid date format. Use YYYY-MM-DD")
return
}
// Get basic workouts in range
basicWorkouts, err := h.userWorkoutService.ListLoggedWorkoutsByDateRange(userID, startDate, endDate)
if err != nil {
if h.logger != nil {
h.logger.Error("action=list_workouts outcome=failure user_id=%d error=%v", userID, err)
}
respondError(w, http.StatusInternalServerError, "Failed to retrieve logged workouts")
return
}
// Get details for each
for _, uw := range basicWorkouts {
detailed, err := h.userWorkoutService.GetLoggedWorkout(uw.ID, userID)
if err != nil {
continue // Skip if error getting details
}
workouts = append(workouts, detailed)
}
} else {
if h.logger != nil {
h.logger.Info("action=list_workouts_attempt user_id=%d limit=%d offset=%d", userID, limit, offset)
}
workouts, err = h.userWorkoutService.ListLoggedWorkouts(userID, limit, offset)
if err != nil {
if h.logger != nil {
h.logger.Error("action=list_workouts outcome=failure user_id=%d error=%v", userID, err)
}
respondError(w, http.StatusInternalServerError, "Failed to retrieve logged workouts")
return
}
if h.logger != nil {
h.logger.Info("action=list_workouts outcome=success user_id=%d returned=%d", userID, len(workouts))
}
}
// Build response
var responses []UserWorkoutResponse
for _, logged := range workouts {
response := UserWorkoutResponse{
ID: logged.ID,
UserID: logged.UserID,
WorkoutID: logged.WorkoutID,
WorkoutName: logged.WorkoutName,
WorkoutDate: logged.WorkoutDate.Format("2006-01-02"),
WorkoutType: logged.WorkoutType,
TotalTime: logged.TotalTime,
Notes: logged.Notes,
CreatedAt: logged.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: logged.UpdatedAt.Format("2006-01-02T15:04:05Z"),
Movements: logged.Movements,
WODs: logged.WODs,
PerformanceMovements: logged.PerformanceMovements,
PerformanceWODs: logged.PerformanceWODs,
WorkoutNotes: logged.WorkoutDescription,
}
responses = append(responses, response)
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"workouts": responses,
"limit": limit,
"offset": offset,
})
}
// UpdateLoggedWorkout updates a logged workout
// @Summary Update a logged workout
// @Description Update an existing logged workout's details and performance data
// @Tags workouts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Logged workout ID"
// @Param request body UpdateLoggedWorkoutRequest true "Updated workout data"
// @Success 200 {object} UserWorkoutResponse "Updated workout details"
// @Failure 400 {object} ErrorResponse "Invalid request"
// @Failure 401 {object} ErrorResponse "Unauthorized"
// @Failure 403 {object} ErrorResponse "Forbidden - not your workout"
// @Failure 404 {object} ErrorResponse "Workout not found"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /workouts/{id} [put]
func (h *UserWorkoutHandler) UpdateLoggedWorkout(w http.ResponseWriter, r *http.Request) {
// Extract user ID and email from JWT token in context
userID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
userEmail, _ := middleware.GetUserEmail(r.Context())
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid workout ID")
return
}
var req UpdateLoggedWorkoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Update logged workout with individual fields
if h.logger != nil {
h.logger.Info("action=update_workout_attempt user_id=%d workout_id=%d", userID, id)
}
if err := h.userWorkoutService.UpdateLoggedWorkout(id, userID, userEmail, req.WorkoutName, req.Notes, req.TotalTime, req.WorkoutType); err != nil {
switch err {
case service.ErrUserWorkoutNotFound:
if h.logger != nil {
h.logger.Warn("action=update_workout outcome=failure user_id=%d workout_id=%d reason=not_found", userID, id)
}
respondError(w, http.StatusNotFound, "Logged workout not found")
case service.ErrUnauthorizedWorkoutAccess:
if h.logger != nil {
h.logger.Warn("action=update_workout outcome=failure user_id=%d workout_id=%d reason=unauthorized", userID, id)
}
respondError(w, http.StatusForbidden, "You don't have permission to update this workout")
default:
if h.logger != nil {
h.logger.Error("action=update_workout outcome=failure user_id=%d workout_id=%d error=%v", userID, id, err)
}
respondError(w, http.StatusInternalServerError, "Failed to update logged workout")
}
return
}
// Update movements if provided
if len(req.Movements) > 0 {
// Convert request movements to domain movements
movements := make([]domain.UserWorkoutMovement, len(req.Movements))
for i, m := range req.Movements {
movements[i] = domain.UserWorkoutMovement{
MovementID: m.MovementID,
Sets: m.Sets,
Reps: m.Reps,
Weight: m.Weight,
Time: m.Time,
Distance: m.Distance,
Notes: m.Notes,
RPE: m.RPE,
OrderIndex: m.OrderIndex,
}
}
if err := h.userWorkoutService.UpdateWorkoutMovements(id, userID, movements); err != nil {
if h.logger != nil {
h.logger.Error("action=update_workout_movements outcome=failure user_id=%d workout_id=%d error=%v", userID, id, err)
}
respondError(w, http.StatusInternalServerError, "Failed to update workout movements")
return
}
}
// Update WODs if provided
if len(req.WODs) > 0 {
// Convert request WODs to domain WODs
wods := make([]domain.UserWorkoutWOD, len(req.WODs))
for i, w := range req.WODs {
wods[i] = domain.UserWorkoutWOD{
WODID: w.WODID,
ScoreType: w.ScoreType,
ScoreValue: w.ScoreValue,
TimeSeconds: w.TimeSeconds,
Rounds: w.Rounds,
Reps: w.Reps,
Weight: w.Weight,
Notes: w.Notes,
RPE: w.RPE,
OrderIndex: w.OrderIndex,
}
}
if err := h.userWorkoutService.UpdateWorkoutWODs(id, userID, wods); err != nil {
if h.logger != nil {
h.logger.Error("action=update_workout_wods outcome=failure user_id=%d workout_id=%d error=%v", userID, id, err)
}
respondError(w, http.StatusInternalServerError, "Failed to update workout WODs")
return
}
}
// Retrieve updated logged workout
logged, err := h.userWorkoutService.GetLoggedWorkout(id, userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to retrieve updated workout")
return
}
response := UserWorkoutResponse{
ID: logged.ID,
UserID: logged.UserID,
WorkoutID: logged.WorkoutID,
WorkoutName: logged.WorkoutName,
WorkoutDate: logged.WorkoutDate.Format("2006-01-02"),
WorkoutType: logged.WorkoutType,
TotalTime: logged.TotalTime,
Notes: logged.Notes,
CreatedAt: logged.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: logged.UpdatedAt.Format("2006-01-02T15:04:05Z"),
Movements: logged.Movements,
WODs: logged.WODs,
PerformanceMovements: logged.PerformanceMovements,
PerformanceWODs: logged.PerformanceWODs,
WorkoutNotes: logged.WorkoutDescription,
}
respondJSON(w, http.StatusOK, response)
}
// DeleteLoggedWorkout deletes a logged workout
// @Summary Delete a logged workout
// @Description Delete a specific logged workout and all its performance data
// @Tags workouts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Logged workout ID"
// @Success 200 {object} MessageResponse "Workout deleted successfully"
// @Failure 400 {object} ErrorResponse "Invalid workout ID"
// @Failure 401 {object} ErrorResponse "Unauthorized"
// @Failure 403 {object} ErrorResponse "Forbidden - not your workout"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /workouts/{id} [delete]
func (h *UserWorkoutHandler) DeleteLoggedWorkout(w http.ResponseWriter, r *http.Request) {
// Extract user ID and email from JWT token in context
userID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
userEmail, _ := middleware.GetUserEmail(r.Context())
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid workout ID")
return
}
if h.logger != nil {
h.logger.Info("action=delete_workout_attempt user_id=%d workout_id=%d", userID, id)
}
if err := h.userWorkoutService.DeleteLoggedWorkout(id, userID, userEmail); err != nil {
if err == service.ErrUnauthorized {
if h.logger != nil {
h.logger.Warn("action=delete_workout outcome=failure user_id=%d workout_id=%d reason=unauthorized", userID, id)
}
respondError(w, http.StatusForbidden, "You don't have permission to delete this workout")
} else {
if h.logger != nil {
h.logger.Error("action=delete_workout outcome=failure user_id=%d workout_id=%d error=%v", userID, id, err)
}
respondError(w, http.StatusInternalServerError, "Failed to delete logged workout: "+err.Error())
}
return
}
if h.logger != nil {
h.logger.Info("action=delete_workout outcome=success user_id=%d workout_id=%d", userID, id)
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Logged workout deleted successfully"})
}
// GetMonthlyStats retrieves workout count for a specific month
// @Summary Get monthly workout stats
// @Description Retrieve the workout count for a specific month
// @Tags workouts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param year query int true "Year (e.g., 2024)"
// @Param month query int true "Month (1-12)"
// @Success 200 {object} map[string]interface{} "Monthly stats with workout count"
// @Failure 400 {object} ErrorResponse "Invalid year or month"
// @Failure 401 {object} ErrorResponse "Unauthorized"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /workouts/stats/monthly [get]
func (h *UserWorkoutHandler) GetMonthlyStats(w http.ResponseWriter, r *http.Request) {
// Extract user ID from JWT token in context
userID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
// Parse year and month
yearStr := r.URL.Query().Get("year")
monthStr := r.URL.Query().Get("month")
if yearStr == "" || monthStr == "" {
respondError(w, http.StatusBadRequest, "Year and month are required")
return
}
year, err1 := strconv.Atoi(yearStr)
month, err2 := strconv.Atoi(monthStr)
if err1 != nil || err2 != nil || month < 1 || month > 12 {
respondError(w, http.StatusBadRequest, "Invalid year or month")
return
}
count, err := h.userWorkoutService.GetWorkoutStatsForMonth(userID, year, month)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to retrieve monthly stats")
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"year": year,
"month": month,
"workout_count": count,
})
}
// GetPersonalRecords retrieves all personal records (movements and WODs) for the user
// @Summary Get personal records
// @Description Retrieve all personal records for movements and WODs
// @Tags workouts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param limit query int false "Max results per category (default 50)"
// @Success 200 {object} map[string]interface{} "PR movements and PR WODs"
// @Failure 401 {object} ErrorResponse "Unauthorized"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /workouts/personal-records [get]
func (h *UserWorkoutHandler) GetPersonalRecords(w http.ResponseWriter, r *http.Request) {
// Extract user ID from JWT token in context
userID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
// Parse limit parameter (default 50)
limitStr := r.URL.Query().Get("limit")
limit := 50
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
if h.logger != nil {
h.logger.Info("action=get_personal_records user_id=%d limit=%d", userID, limit)
}
// Get PR movements
prMovements, err := h.userWorkoutService.GetPRMovements(userID, limit)
if err != nil {
if h.logger != nil {
h.logger.Error("action=get_personal_records outcome=failure user_id=%d error=%v", userID, err)
}
respondError(w, http.StatusInternalServerError, "Failed to retrieve PR movements")
return
}
// Get PR WODs
prWODs, err := h.userWorkoutService.GetPRWODs(userID, limit)
if err != nil {
if h.logger != nil {
h.logger.Error("action=get_personal_records outcome=failure user_id=%d error=%v", userID, err)
}
respondError(w, http.StatusInternalServerError, "Failed to retrieve PR WODs")
return
}
if h.logger != nil {
h.logger.Info("action=get_personal_records outcome=success user_id=%d movements=%d wods=%d", userID, len(prMovements), len(prWODs))
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"pr_movements": prMovements,
"pr_wods": prWODs,
})
}
// RetroactiveFlagPRs analyzes all existing workouts and flags PRs based on historical data
// @Summary Retroactively flag PRs
// @Description Analyze all existing workouts and flag personal records based on historical data
// @Tags workouts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{} "Count of flagged PRs"
// @Failure 401 {object} ErrorResponse "Unauthorized"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /workouts/retroactive-prs [post]
func (h *UserWorkoutHandler) RetroactiveFlagPRs(w http.ResponseWriter, r *http.Request) {
// Extract user ID from JWT token in context
userID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
if h.logger != nil {
h.logger.Info("action=retroactive_flag_prs user_id=%d", userID)
}
// Run retroactive PR flagging
movementPRCount, wodPRCount, err := h.userWorkoutService.RetroactivelyFlagPRs(userID)
if err != nil {
if h.logger != nil {
h.logger.Error("action=retroactive_flag_prs outcome=failure user_id=%d error=%v", userID, err)
}
respondError(w, http.StatusInternalServerError, "Failed to retroactively flag PRs")
return
}
if h.logger != nil {
h.logger.Info("action=retroactive_flag_prs outcome=success user_id=%d movement_prs=%d wod_prs=%d", userID, movementPRCount, wodPRCount)
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "PRs flagged successfully",
"movement_pr_count": movementPRCount,
"wod_pr_count": wodPRCount,
})
}
// GetActiveUsersStats handles GET /api/stats/active-users-this-month
// @Summary Get active users this month
// @Description Retrieve stats about active users who have logged workouts this month
// @Tags workouts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{} "Active users stats"
// @Failure 401 {object} ErrorResponse "Unauthorized"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /stats/active-users-this-month [get]
func (h *UserWorkoutHandler) GetActiveUsersStats(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
stats, err := h.userWorkoutService.GetActiveUsersThisMonth(userID)
if err != nil {
if h.logger != nil {
h.logger.Error("Failed to get active users stats: user_id=%d error=%v", userID, err)
}
respondError(w, http.StatusInternalServerError, "Failed to get active users stats")
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"users": stats,
})
}
// ErrorResponse represents an error response