Ctrl K
ComunidadGuías…

internal/handler/pr_handler.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package handler

import (
	"database/sql"
	"net/http"
	"strconv"

	"github.com/johnzastrow/actalog/internal/repository"
	"github.com/johnzastrow/actalog/pkg/logger"
	"github.com/johnzastrow/actalog/pkg/middleware"
	"github.com/johnzastrow/actalog/pkg/prmath"
)

// PRHandler handles PR (Personal Record) endpoints
type PRHandler struct {
	db     *sql.DB
	logger *logger.Logger
}

// NewPRHandler creates a new PR handler
func NewPRHandler(db *sql.DB, l *logger.Logger) *PRHandler {
	return &PRHandler{
		db:     db,
		logger: l,
	}
}

// PersonalRecord represents a unified PR from either movements or WODs
// @Description Personal record from movements or WODs
type PersonalRecord struct {
	Type          string   `json:"type" example:"movement"` // "movement" or "wod"
	ID            int64    `json:"id" example:"1"`
	UserWorkoutID int64    `json:"user_workout_id" example:"100"`
	WorkoutDate   string   `json:"workout_date" example:"2024-01-15"`
	Name          string   `json:"name" example:"Back Squat"`                       // Movement name or WOD name
	MovementType  *string  `json:"movement_type,omitempty" example:"weightlifting"` // For movements: weightlifting, gymnastics, etc.
	Weight        *float64 `json:"weight,omitempty" example:"315.0"`                // For movements (actual weight lifted)
	Sets          *int     `json:"sets,omitempty" example:"5"`                      // For movements
	Reps          *int     `json:"reps,omitempty" example:"5"`                      // For movements
	Time          *int     `json:"time,omitempty" example:"120"`                    // For movements (seconds) OR WOD time
	Distance      *float64 `json:"distance,omitempty"`                              // For movements
	Calculated1RM *float64 `json:"calculated_1rm,omitempty" example:"354.4"`        // Calculated one-rep max for movements
	Formula       *string  `json:"formula,omitempty" example:"Epley (2-10 reps)"`   // Which formula was used
	ScoreValue    *string  `json:"score_value,omitempty" example:"12:30"`           // For WODs
	Division      *string  `json:"division,omitempty" example:"rx"`                 // For WODs (rx, scaled, etc.)
	WODType       *string  `json:"wod_type,omitempty" example:"For Time"`           // For WODs
	WODScoreType  *string  `json:"wod_score_type,omitempty" example:"time"`         // For WODs
}

// MovementPRSummary represents PR summary for a specific movement
type MovementPRSummary struct {
	MovementID   int64    `json:"movement_id"`
	MovementName string   `json:"movement_name"`
	MovementType string   `json:"movement_type"`
	PRCount      int      `json:"pr_count"`
	Best1RM      *float64 `json:"best_1rm,omitempty"`     // Highest calculated 1RM
	BestFormula  *string  `json:"best_formula,omitempty"` // Formula used for best 1RM
	BestWeight   *float64 `json:"best_weight,omitempty"`  // Actual weight lifted for best PR
	BestSets     *int     `json:"best_sets,omitempty"`    // Sets for best PR
	BestReps     *int     `json:"best_reps,omitempty"`    // Reps for best PR
	LastPRDate   string   `json:"last_pr_date"`
}

// GetPersonalRecords retrieves all PRs for the authenticated user
// @Summary      Get all personal records
// @Description  Retrieve all personal records (PRs) for movements and WODs
// @Tags         prs
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        limit query int false "Max results (default 50, max 200)"
// @Success      200 {object} map[string]interface{} "List of personal records"
// @Failure      401 {object} ErrorResponse "Unauthorized"
// @Failure      500 {object} ErrorResponse "Internal server error"
// @Router       /prs [get]
func (h *PRHandler) GetPersonalRecords(w http.ResponseWriter, r *http.Request) {
	userID, ok := middleware.GetUserID(r.Context())
	if !ok {
		respondError(w, http.StatusUnauthorized, "Unauthorized")
		return
	}

	// Parse optional limit parameter
	limitStr := r.URL.Query().Get("limit")
	limit := 50 // default
	if limitStr != "" {
		if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
			limit = parsedLimit
			if limit > 200 {
				limit = 200 // cap at 200
			}
		}
	}

	if h.logger != nil {
		h.logger.Info("action=get_prs user_id=%d limit=%d", userID, limit)
	}

	// Query to get PRs from workout_movements
	movementQuery := `
		SELECT
			'movement' as type,
			wm.id,
			uw.id as user_workout_id,
			uw.workout_date,
			m.name,
			m.type as movement_type,
			wm.weight,
			wm.sets,
			wm.reps,
			wm.time,
			wm.distance,
			NULL as score_value,
			NULL as division,
			NULL as wod_type,
			NULL as wod_score_type
		FROM workout_movements wm
		JOIN workouts w ON wm.workout_id = w.id
		JOIN user_workouts uw ON uw.workout_id = w.id
		JOIN movements m ON wm.movement_id = m.id
		WHERE uw.user_id = ? AND wm.is_pr = true
		ORDER BY uw.workout_date DESC
		LIMIT ?
	`

	// Query to get PRs from workout_wods
	wodQuery := `
		SELECT
			'wod' as type,
			ww.id,
			uw.id as user_workout_id,
			uw.workout_date,
			wod.name,
			NULL as movement_type,
			NULL as weight,
			NULL as sets,
			NULL as reps,
			NULL as time,
			NULL as distance,
			ww.score_value,
			ww.division,
			wod.type as wod_type,
			wod.score_type as wod_score_type
		FROM workout_wods ww
		JOIN workouts w ON ww.workout_id = w.id
		JOIN user_workouts uw ON uw.workout_id = w.id
		JOIN wods wod ON ww.wod_id = wod.id
		WHERE uw.user_id = ? AND ww.is_pr = true
		ORDER BY uw.workout_date DESC
		LIMIT ?
	`

	// Execute both queries
	var prs []PersonalRecord

	// Get movement PRs
	movementRows, err := h.db.Query(repository.RebindQuery(movementQuery), userID, limit)
	if err != nil {
		if h.logger != nil {
			h.logger.Error("action=get_prs outcome=failure user_id=%d error=query_movements: %v", userID, err)
		}
		respondError(w, http.StatusInternalServerError, "Failed to retrieve PRs")
		return
	}
	defer movementRows.Close()

	for movementRows.Next() {
		var pr PersonalRecord
		err := movementRows.Scan(
			&pr.Type,
			&pr.ID,
			&pr.UserWorkoutID,
			&pr.WorkoutDate,
			&pr.Name,
			&pr.MovementType,
			&pr.Weight,
			&pr.Sets,
			&pr.Reps,
			&pr.Time,
			&pr.Distance,
			&pr.ScoreValue,
			&pr.Division,
			&pr.WODType,
			&pr.WODScoreType,
		)
		if err != nil {
			if h.logger != nil {
				h.logger.Error("action=get_prs outcome=failure user_id=%d error=scan_movement: %v", userID, err)
			}
			continue
		}

		// Calculate 1RM for movements with weight and reps
		if pr.Weight != nil && pr.Reps != nil && *pr.Weight > 0 && *pr.Reps > 0 {
			oneRM, formula := prmath.Calculate1RM(*pr.Weight, *pr.Reps)
			pr.Calculated1RM = &oneRM
			formulaStr := string(formula)
			pr.Formula = &formulaStr
		}

		prs = append(prs, pr)
	}

	// Get WOD PRs
	wodRows, err := h.db.Query(repository.RebindQuery(wodQuery), userID, limit)
	if err != nil {
		if h.logger != nil {
			h.logger.Error("action=get_prs outcome=failure user_id=%d error=query_wods: %v", userID, err)
		}
		respondError(w, http.StatusInternalServerError, "Failed to retrieve PRs")
		return
	}
	defer wodRows.Close()

	for wodRows.Next() {
		var pr PersonalRecord
		err := wodRows.Scan(
			&pr.Type,
			&pr.ID,
			&pr.UserWorkoutID,
			&pr.WorkoutDate,
			&pr.Name,
			&pr.MovementType,
			&pr.Weight,
			&pr.Sets,
			&pr.Reps,
			&pr.Time,
			&pr.Distance,
			&pr.ScoreValue,
			&pr.Division,
			&pr.WODType,
			&pr.WODScoreType,
		)
		if err != nil {
			if h.logger != nil {
				h.logger.Error("action=get_prs outcome=failure user_id=%d error=scan_wod: %v", userID, err)
			}
			continue
		}
		prs = append(prs, pr)
	}

	if h.logger != nil {
		h.logger.Info("action=get_prs outcome=success user_id=%d count=%d", userID, len(prs))
	}

	respondJSON(w, http.StatusOK, map[string]interface{}{
		"prs": prs,
	})
}

// GetPRMovements retrieves movements with PR counts for the authenticated user
// @Summary      Get movements with PRs
// @Description  Retrieve movements that have personal records with summary stats
// @Tags         prs
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        limit query int false "Max results (default 20, max 100)"
// @Success      200 {object} map[string]interface{} "Movement PR summaries"
// @Failure      401 {object} ErrorResponse "Unauthorized"
// @Failure      500 {object} ErrorResponse "Internal server error"
// @Router       /prs/movements [get]
func (h *PRHandler) GetPRMovements(w http.ResponseWriter, r *http.Request) {
	userID, ok := middleware.GetUserID(r.Context())
	if !ok {
		respondError(w, http.StatusUnauthorized, "Unauthorized")
		return
	}

	// Parse optional limit parameter
	limitStr := r.URL.Query().Get("limit")
	limit := 20 // default
	if limitStr != "" {
		if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
			limit = parsedLimit
			if limit > 100 {
				limit = 100 // cap at 100
			}
		}
	}

	if h.logger != nil {
		h.logger.Info("action=get_pr_movements user_id=%d limit=%d", userID, limit)
	}

	query := `
		SELECT
			m.id as movement_id,
			m.name as movement_name,
			m.type as movement_type,
			COUNT(wm.id) as pr_count,
			MAX(wm.weight) as best_weight,
			MAX(wm.sets) as best_sets,
			MAX(wm.reps) as best_reps,
			MAX(uw.workout_date) as last_pr_date
		FROM workout_movements wm
		JOIN workouts w ON wm.workout_id = w.id
		JOIN user_workouts uw ON uw.workout_id = w.id
		JOIN movements m ON wm.movement_id = m.id
		WHERE uw.user_id = ? AND wm.is_pr = true
		GROUP BY m.id, m.name, m.type
		ORDER BY pr_count DESC, last_pr_date DESC
		LIMIT ?
	`

	rows, err := h.db.Query(repository.RebindQuery(query), userID, limit)
	if err != nil {
		if h.logger != nil {
			h.logger.Error("action=get_pr_movements outcome=failure user_id=%d error=%v", userID, err)
		}
		respondError(w, http.StatusInternalServerError, "Failed to retrieve PR movements")
		return
	}
	defer rows.Close()

	var movements []MovementPRSummary
	for rows.Next() {
		var m MovementPRSummary
		err := rows.Scan(
			&m.MovementID,
			&m.MovementName,
			&m.MovementType,
			&m.PRCount,
			&m.BestWeight,
			&m.BestSets,
			&m.BestReps,
			&m.LastPRDate,
		)
		if err != nil {
			if h.logger != nil {
				h.logger.Error("action=get_pr_movements outcome=failure user_id=%d error=scan: %v", userID, err)
			}
			continue
		}

		// Calculate 1RM for the best lift (this is simplified - ideally we'd check all PRs for this movement)
		if m.BestWeight != nil && m.BestReps != nil && *m.BestWeight > 0 && *m.BestReps > 0 {
			oneRM, formula := prmath.Calculate1RM(*m.BestWeight, *m.BestReps)
			m.Best1RM = &oneRM
			formulaStr := string(formula)
			m.BestFormula = &formulaStr
		}

		movements = append(movements, m)
	}

	if h.logger != nil {
		h.logger.Info("action=get_pr_movements outcome=success user_id=%d count=%d", userID, len(movements))
	}

	respondJSON(w, http.StatusOK, map[string]interface{}{
		"movements": movements,
	})
}

// ToggleMovementPR toggles the PR flag for a specific workout_movement
// @Summary      Toggle movement PR flag
// @Description  Toggle the personal record flag on a specific workout movement
// @Tags         prs
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        id query int true "Workout movement ID"
// @Success      200 {object} map[string]interface{} "New PR state"
// @Failure      400 {object} ErrorResponse "Missing or invalid movement ID"
// @Failure      401 {object} ErrorResponse "Unauthorized"
// @Failure      404 {object} ErrorResponse "Movement not found"
// @Failure      500 {object} ErrorResponse "Internal server error"
// @Router       /prs/toggle [post]
func (h *PRHandler) ToggleMovementPR(w http.ResponseWriter, r *http.Request) {
	userID, ok := middleware.GetUserID(r.Context())
	if !ok {
		respondError(w, http.StatusUnauthorized, "Unauthorized")
		return
	}

	// Get workout_movement ID from URL parameter
	movementIDStr := r.URL.Query().Get("id")
	if movementIDStr == "" {
		respondError(w, http.StatusBadRequest, "Missing movement ID")
		return
	}

	movementID, err := strconv.ParseInt(movementIDStr, 10, 64)
	if err != nil {
		respondError(w, http.StatusBadRequest, "Invalid movement ID")
		return
	}

	if h.logger != nil {
		h.logger.Info("action=toggle_movement_pr_attempt user_id=%d movement_id=%d", userID, movementID)
	}

	// Verify user owns this workout
	verifyQuery := `
		SELECT 1
		FROM workout_movements wm
		JOIN workouts w ON wm.workout_id = w.id
		JOIN user_workouts uw ON uw.workout_id = w.id
		WHERE wm.id = ? AND uw.user_id = ?
	`

	var exists int
	err = h.db.QueryRow(repository.RebindQuery(verifyQuery), movementID, userID).Scan(&exists)
	if err != nil {
		if err == sql.ErrNoRows {
			if h.logger != nil {
				h.logger.Warn("action=toggle_movement_pr outcome=failure user_id=%d movement_id=%d reason=not_found", userID, movementID)
			}
			respondError(w, http.StatusNotFound, "Movement not found")
			return
		}
		if h.logger != nil {
			h.logger.Error("action=toggle_movement_pr outcome=failure user_id=%d movement_id=%d error=%v", userID, movementID, err)
		}
		respondError(w, http.StatusInternalServerError, "Failed to verify ownership")
		return
	}

	// Toggle the PR flag
	toggleQuery := `
		UPDATE workout_movements
		SET is_pr = NOT is_pr,
		    updated_at = CURRENT_TIMESTAMP
		WHERE id = ?
	`

	result, err := h.db.Exec(repository.RebindQuery(toggleQuery), movementID)
	if err != nil {
		if h.logger != nil {
			h.logger.Error("action=toggle_movement_pr outcome=failure user_id=%d movement_id=%d error=%v", userID, movementID, err)
		}
		respondError(w, http.StatusInternalServerError, "Failed to toggle PR flag")
		return
	}

	rowsAffected, _ := result.RowsAffected()
	if rowsAffected == 0 {
		respondError(w, http.StatusNotFound, "Movement not found")
		return
	}

	// Get the new state
	var newState bool
	err = h.db.QueryRow(repository.RebindQuery("SELECT is_pr FROM workout_movements WHERE id = ?"), movementID).Scan(&newState)
	if err != nil {
		if h.logger != nil {
			h.logger.Error("action=toggle_movement_pr outcome=failure user_id=%d movement_id=%d error=get_state: %v", userID, movementID, err)
		}
		respondError(w, http.StatusInternalServerError, "Failed to get PR state")
		return
	}

	if h.logger != nil {
		h.logger.Info("action=toggle_movement_pr outcome=success user_id=%d movement_id=%d new_state=%t", userID, movementID, newState)
	}

	respondJSON(w, http.StatusOK, map[string]interface{}{
		"message": "PR flag toggled successfully",
		"is_pr":   newState,
	})
}