internal/handler/pr_handler.go
Rama main · réplica de lectura del repositorio original.
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,
})
}