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", }) }