internal/handler/movement_handler.go
Rama main · réplica de lectura del repositorio original.
package handler
import (
"encoding/json"
"net/http"
"strconv"
"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"
)
// MovementHandler handles movement-related endpoints
type MovementHandler struct {
movementRepo domain.MovementRepository
movementService *service.MovementService
logger *logger.Logger
}
// NewMovementHandler creates a new movement handler
func NewMovementHandler(movementRepo domain.MovementRepository, movementService *service.MovementService, l *logger.Logger) *MovementHandler {
return &MovementHandler{
movementRepo: movementRepo,
movementService: movementService,
logger: l,
}
}
// CreateMovementRequest represents a movement creation request
// @Description Request to create a new custom movement
type CreateMovementRequest struct {
Name string `json:"name" example:"Kettlebell Swing"`
Description string `json:"description" example:"A hip hinge movement using a kettlebell"`
Type string `json:"type" example:"strength"`
}
// MovementListResponse represents a list of movements
// @Description List of movements
type MovementListResponse struct {
Movements []*domain.Movement `json:"movements"`
}
// ListAll returns all movements (both standard and custom)
// @Summary List all movements
// @Description Retrieve all movements including standard and custom
// @Tags movements
// @Accept json
// @Produce json
// @Success 200 {object} MovementListResponse "List of all movements"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /movements [get]
func (h *MovementHandler) ListAll(w http.ResponseWriter, r *http.Request) {
movements, err := h.movementRepo.ListAll()
if err != nil {
if h.logger != nil {
h.logger.Error("action=list_all_movements outcome=failure error=%v", err)
}
respondError(w, http.StatusInternalServerError, "Failed to retrieve movements")
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"movements": movements,
})
}
// ListStandard returns all standard movements
// @Summary List standard movements
// @Description Retrieve only standard (built-in) movements
// @Tags movements
// @Accept json
// @Produce json
// @Success 200 {object} MovementListResponse "List of standard movements"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /movements/standard [get]
func (h *MovementHandler) ListStandard(w http.ResponseWriter, r *http.Request) {
movements, err := h.movementRepo.ListStandard()
if err != nil {
if h.logger != nil {
h.logger.Error("action=list_movements outcome=failure error=%v", err)
}
respondError(w, http.StatusInternalServerError, "Failed to retrieve movements")
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"movements": movements,
})
}
// Search searches for movements by name
// @Summary Search movements
// @Description Search for movements by name
// @Tags movements
// @Accept json
// @Produce json
// @Param q query string true "Search query"
// @Param limit query int false "Max results (default 20)"
// @Success 200 {object} MovementListResponse "Matching movements"
// @Failure 400 {object} ErrorResponse "Missing query parameter"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /movements/search [get]
func (h *MovementHandler) Search(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
respondError(w, http.StatusBadRequest, "Query parameter 'q' is required")
return
}
limitStr := r.URL.Query().Get("limit")
limit := 20 // default
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
if h.logger != nil {
h.logger.Info("action=search_movements query=%s limit=%d", query, limit)
}
movements, err := h.movementRepo.Search(query, limit)
if err != nil {
if h.logger != nil {
h.logger.Error("action=search_movements outcome=failure query=%s error=%v", query, err)
}
respondError(w, http.StatusInternalServerError, "Failed to search movements")
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"movements": movements,
})
}
// GetByID returns a single movement by ID
// @Summary Get movement by ID
// @Description Retrieve a single movement by its ID
// @Tags movements
// @Accept json
// @Produce json
// @Param id path int true "Movement ID"
// @Success 200 {object} domain.Movement "Movement details"
// @Failure 400 {object} ErrorResponse "Invalid movement ID"
// @Failure 404 {object} ErrorResponse "Movement not found"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /movements/{id} [get]
func (h *MovementHandler) GetByID(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid movement ID")
return
}
movement, err := h.movementRepo.GetByID(id)
if err != nil {
if h.logger != nil {
h.logger.Error("action=get_movement outcome=failure id=%d error=%v", id, err)
}
respondError(w, http.StatusInternalServerError, "Failed to retrieve movement")
return
}
if movement == nil {
respondError(w, http.StatusNotFound, "Movement not found")
return
}
respondJSON(w, http.StatusOK, movement)
}
// Create creates a new custom movement
// @Summary Create a custom movement
// @Description Create a new custom movement (non-standard)
// @Tags movements
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body CreateMovementRequest true "Movement details"
// @Success 201 {object} domain.Movement "Created movement"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 401 {object} ErrorResponse "Unauthorized"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /movements [post]
func (h *MovementHandler) Create(w http.ResponseWriter, r *http.Request) {
// Extract user ID and email from context for audit logging
userID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
userEmail, _ := middleware.GetUserEmail(r.Context())
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.Name == "" || req.Type == "" {
respondError(w, http.StatusBadRequest, "Name and type are required")
return
}
movement := &domain.Movement{
Name: req.Name,
Description: req.Description,
Type: domain.MovementType(req.Type),
IsStandard: false,
}
if h.logger != nil {
h.logger.Info("action=create_movement_attempt name=%s type=%s", req.Name, req.Type)
}
if err := h.movementService.Create(movement, userID, userEmail); err != nil {
if h.logger != nil {
h.logger.Error("action=create_movement outcome=failure name=%s error=%v", req.Name, err)
}
respondError(w, http.StatusInternalServerError, "Failed to create movement")
return
}
if h.logger != nil {
h.logger.Info("action=create_movement outcome=success id=%d name=%s", movement.ID, movement.Name)
}
respondJSON(w, http.StatusCreated, movement)
}
// Update updates an existing custom movement
// @Summary Update a movement
// @Description Update an existing movement (admin can update any, users only custom)
// @Tags movements
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Movement ID"
// @Param request body CreateMovementRequest true "Updated movement details"
// @Success 200 {object} domain.Movement "Updated movement"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 401 {object} ErrorResponse "Unauthorized"
// @Failure 403 {object} ErrorResponse "Cannot modify standard movement"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /movements/{id} [put]
func (h *MovementHandler) Update(w http.ResponseWriter, r *http.Request) {
// Extract user ID and email from context for audit logging
userID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
userEmail, _ := middleware.GetUserEmail(r.Context())
// Check if user is admin
role, _ := middleware.GetUserRole(r.Context())
isAdmin := role == "admin"
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid movement ID")
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.Name == "" || req.Type == "" {
respondError(w, http.StatusBadRequest, "Name and type are required")
return
}
movement := &domain.Movement{
ID: id,
Name: req.Name,
Description: req.Description,
Type: domain.MovementType(req.Type),
}
if h.logger != nil {
h.logger.Info("action=update_movement_attempt id=%d name=%s", id, req.Name)
}
// Use admin update if admin, otherwise regular update
if isAdmin {
err = h.movementService.UpdateAsAdmin(movement, userID, userEmail)
} else {
err = h.movementService.Update(movement, userID, userEmail)
}
if err != nil {
if h.logger != nil {
h.logger.Error("action=update_movement outcome=failure id=%d error=%v", id, err)
}
if err == service.ErrMovementUnauthorized {
respondError(w, http.StatusForbidden, "Cannot modify standard movement")
} else {
respondError(w, http.StatusInternalServerError, "Failed to update movement: "+err.Error())
}
return
}
if h.logger != nil {
h.logger.Info("action=update_movement outcome=success id=%d", id)
}
// Retrieve updated movement
updated, _ := h.movementService.GetByID(id)
if updated != nil {
respondJSON(w, http.StatusOK, updated)
} else {
respondJSON(w, http.StatusOK, movement)
}
}
// Delete deletes a custom movement
// @Summary Delete a movement
// @Description Delete a custom movement (cannot delete standard movements)
// @Tags movements
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Movement ID"
// @Success 200 {object} MessageResponse "Movement deleted successfully"
// @Failure 400 {object} ErrorResponse "Invalid movement ID"
// @Failure 401 {object} ErrorResponse "Unauthorized"
// @Failure 403 {object} ErrorResponse "Cannot delete standard movement"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /movements/{id} [delete]
func (h *MovementHandler) Delete(w http.ResponseWriter, r *http.Request) {
// Extract user ID and email from context for audit logging
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 movement ID")
return
}
if h.logger != nil {
h.logger.Info("action=delete_movement_attempt id=%d", id)
}
if err := h.movementService.Delete(id, userID, userEmail); err != nil {
if h.logger != nil {
h.logger.Error("action=delete_movement outcome=failure id=%d error=%v", id, err)
}
if err == service.ErrMovementUnauthorized {
respondError(w, http.StatusForbidden, "Cannot delete standard movement")
} else {
respondError(w, http.StatusInternalServerError, "Failed to delete movement: "+err.Error())
}
return
}
if h.logger != nil {
h.logger.Info("action=delete_movement outcome=success id=%d", id)
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "Movement deleted successfully",
})
}