Ctrl K
ComunidadGuías…

internal/handler/user_handler.go

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

Archivo original ↗Preguntar a KiBot
package handler

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

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

// UserHandler handles user profile endpoints
type UserHandler struct {
	userService       *service.UserService
	schedulingService *service.SchedulingService
	logger            *logger.Logger
}

// NewUserHandler creates a new user handler
func NewUserHandler(userService *service.UserService, schedulingService *service.SchedulingService, l *logger.Logger) *UserHandler {
	return &UserHandler{
		userService:       userService,
		schedulingService: schedulingService,
		logger:            l,
	}
}

// UpdateProfileRequest represents a profile update request
// @Description User profile update request
type UpdateProfileRequest struct {
	Name     string `json:"name,omitempty" example:"John Doe"`
	Email    string `json:"email,omitempty" example:"john@example.com"`
	Birthday string `json:"birthday,omitempty" example:"1990-05-15"` // Format: "YYYY-MM-DD" or empty
}

// ProfileResponse represents a profile response
// @Description User profile data
type ProfileResponse struct {
	User interface{} `json:"user"`
}

// ChangePasswordRequest represents a password change request
// @Description Password change request
type ChangePasswordRequest struct {
	OldPassword string `json:"old_password" example:"currentPassword123"`
	NewPassword string `json:"new_password" example:"newSecurePassword456"`
}

// UpdateProfile handles profile update requests
// @Summary      Update user profile
// @Description  Update the authenticated user's profile information
// @Tags         users
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        request body UpdateProfileRequest true "Profile updates"
// @Success      200 {object} ProfileResponse "Profile updated successfully"
// @Failure      400 {object} ErrorResponse "Invalid request body or birthday format"
// @Failure      401 {object} ErrorResponse "Unauthorized"
// @Failure      404 {object} ErrorResponse "User not found"
// @Failure      409 {object} ErrorResponse "Email already in use"
// @Failure      500 {object} ErrorResponse "Internal server error"
// @Router       /users/profile [put]
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
	// Get user ID from context (set by auth middleware)
	userID, ok := middleware.GetUserID(r.Context())
	if !ok {
		respondError(w, http.StatusUnauthorized, "Unauthorized")
		return
	}

	var req UpdateProfileRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		respondError(w, http.StatusBadRequest, "Invalid request body")
		return
	}

	// Parse birthday if provided
	var birthday *time.Time
	if req.Birthday != "" {
		parsedBirthday, err := time.Parse("2006-01-02", req.Birthday)
		if err != nil {
			respondError(w, http.StatusBadRequest, "Invalid birthday format. Use YYYY-MM-DD")
			return
		}
		birthday = &parsedBirthday
	}

	if h.logger != nil {
		h.logger.Info("action=update_profile_attempt user_id=%d name=%s email=%s", userID, req.Name, req.Email)
	}

	// Update profile
	user, err := h.userService.UpdateProfile(userID, req.Name, req.Email, birthday)
	if err != nil {
		switch err {
		case service.ErrEmailAlreadyExists:
			if h.logger != nil {
				h.logger.Warn("action=update_profile outcome=failure user_id=%d reason=email_exists email=%s", userID, req.Email)
			}
			respondError(w, http.StatusConflict, "Email already in use")
		case service.ErrUserNotFound:
			if h.logger != nil {
				h.logger.Warn("action=update_profile outcome=failure user_id=%d reason=not_found", userID)
			}
			respondError(w, http.StatusNotFound, "User not found")
		default:
			if h.logger != nil {
				h.logger.Error("action=update_profile outcome=failure user_id=%d error=%v", userID, err)
			}
			respondError(w, http.StatusInternalServerError, "Failed to update profile")
		}
		return
	}

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

	respondJSON(w, http.StatusOK, ProfileResponse{
		User: user,
	})
}

// GetProfile retrieves the current user's profile
// @Summary      Get user profile
// @Description  Retrieve the authenticated user's profile information
// @Tags         users
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Success      200 {object} ProfileResponse "User profile"
// @Failure      401 {object} ErrorResponse "Unauthorized"
// @Failure      404 {object} ErrorResponse "User not found"
// @Failure      500 {object} ErrorResponse "Internal server error"
// @Router       /users/profile [get]
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
	// Get user ID from context (set by auth middleware)
	userID, ok := middleware.GetUserID(r.Context())
	if !ok {
		respondError(w, http.StatusUnauthorized, "Unauthorized")
		return
	}

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

	// Get user from service
	user, err := h.userService.GetByID(userID)
	if err != nil {
		if err == service.ErrUserNotFound {
			if h.logger != nil {
				h.logger.Warn("action=get_profile outcome=failure user_id=%d reason=not_found", userID)
			}
			respondError(w, http.StatusNotFound, "User not found")
		} else {
			if h.logger != nil {
				h.logger.Error("action=get_profile outcome=failure user_id=%d error=%v", userID, err)
			}
			respondError(w, http.StatusInternalServerError, "Failed to get profile")
		}
		return
	}

	// Check if user is a coach (for any organization)
	isCoach := false
	if h.schedulingService != nil {
		isCoach, _ = h.schedulingService.IsUserCoach(userID)
	}

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

// UploadAvatar handles avatar image uploads
// @Summary      Upload avatar image
// @Description  Upload a new avatar image for the authenticated user (max 5MB)
// @Tags         users
// @Accept       multipart/form-data
// @Produce      json
// @Security     BearerAuth
// @Param        avatar formData file true "Avatar image file"
// @Success      200 {object} ProfileResponse "Avatar uploaded successfully"
// @Failure      400 {object} ErrorResponse "No file provided, file too large, or invalid file type"
// @Failure      401 {object} ErrorResponse "Unauthorized"
// @Failure      500 {object} ErrorResponse "Failed to save avatar"
// @Router       /users/avatar [post]
func (h *UserHandler) UploadAvatar(w http.ResponseWriter, r *http.Request) {
	// Get user ID from context
	userID, ok := middleware.GetUserID(r.Context())
	if !ok {
		respondError(w, http.StatusUnauthorized, "Unauthorized")
		return
	}

	// Parse multipart form (max 5MB)
	if err := r.ParseMultipartForm(5 << 20); err != nil {
		respondError(w, http.StatusBadRequest, "File too large (max 5MB)")
		return
	}

	// Get file from form
	file, header, err := r.FormFile("avatar")
	if err != nil {
		respondError(w, http.StatusBadRequest, "No file provided")
		return
	}
	defer file.Close()

	// Validate file type by sniffing the actual bytes — never trust the
	// client-supplied Content-Type header. http.DetectContentType implements
	// the WHATWG MIME Sniffing algorithm and reads up to 512 bytes.
	sniff := make([]byte, 512)
	n, err := file.Read(sniff)
	if err != nil && err != io.EOF {
		respondError(w, http.StatusBadRequest, "Failed to read uploaded file")
		return
	}
	detectedType := http.DetectContentType(sniff[:n])
	if !strings.HasPrefix(detectedType, "image/") {
		respondError(w, http.StatusBadRequest, "File must be an image")
		return
	}
	// Rewind so the subsequent io.Copy writes the whole file, not just the
	// bytes after the sniff buffer.
	if _, err := file.Seek(0, io.SeekStart); err != nil {
		respondError(w, http.StatusInternalServerError, "Failed to process file")
		return
	}

	// Validate extension against an allowlist. Magic-byte sniffing alone does
	// not stop a JPEG payload uploaded under a .html filename, which the
	// static file server would later serve with a text/html content-type.
	allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
	if !allowedExts[strings.ToLower(filepath.Ext(header.Filename))] {
		respondError(w, http.StatusBadRequest, "Only jpg, png, gif, and webp images are allowed")
		return
	}

	// Create avatars directory if it doesn't exist
	avatarDir := "uploads/avatars"
	if err := os.MkdirAll(avatarDir, 0755); err != nil {
		if h.logger != nil {
			h.logger.Error("action=upload_avatar outcome=failure user_id=%d error=failed_to_create_directory: %v", userID, err)
		}
		respondError(w, http.StatusInternalServerError, "Failed to save avatar")
		return
	}

	// Generate unique filename
	ext := filepath.Ext(header.Filename)
	filename := fmt.Sprintf("user_%d_%d%s", userID, time.Now().Unix(), ext)
	filePath := filepath.Join(avatarDir, filename)

	// Create destination file
	dst, err := os.Create(filePath)
	if err != nil {
		if h.logger != nil {
			h.logger.Error("action=upload_avatar outcome=failure user_id=%d error=failed_to_create_file: %v", userID, err)
		}
		respondError(w, http.StatusInternalServerError, "Failed to save avatar")
		return
	}
	defer dst.Close()

	// Copy file data
	if _, err := io.Copy(dst, file); err != nil {
		if h.logger != nil {
			h.logger.Error("action=upload_avatar outcome=failure user_id=%d error=failed_to_copy_file: %v", userID, err)
		}
		respondError(w, http.StatusInternalServerError, "Failed to save avatar")
		return
	}

	// Get current user to check for old avatar
	user, err := h.userService.GetByID(userID)
	if err != nil {
		if h.logger != nil {
			h.logger.Error("action=upload_avatar outcome=failure user_id=%d error=failed_to_get_user: %v", userID, err)
		}
		respondError(w, http.StatusInternalServerError, "Failed to update profile")
		return
	}

	// Delete old avatar if it exists
	if user.ProfileImage != nil && *user.ProfileImage != "" {
		oldPath := *user.ProfileImage
		if strings.HasPrefix(oldPath, "/uploads/") {
			oldPath = "." + oldPath
		}
		if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
			if h.logger != nil {
				h.logger.Warn("action=upload_avatar outcome=warning user_id=%d error=failed_to_delete_old_avatar: %v", userID, err)
			}
		}
	}

	// Update user profile with new avatar URL
	avatarURL := "/uploads/avatars/" + filename
	user.ProfileImage = &avatarURL

	if err := h.userService.UpdateAvatar(userID, avatarURL); err != nil {
		if h.logger != nil {
			h.logger.Error("action=upload_avatar outcome=failure user_id=%d error=failed_to_update_avatar: %v", userID, err)
		}
		// Try to clean up uploaded file
		os.Remove(filePath)
		respondError(w, http.StatusInternalServerError, "Failed to update profile")
		return
	}

	if h.logger != nil {
		h.logger.Info("action=upload_avatar outcome=success user_id=%d avatar_url=%s", userID, avatarURL)
	}

	respondJSON(w, http.StatusOK, ProfileResponse{
		User: user,
	})
}

// DeleteAvatar handles avatar image deletion
// @Summary      Delete avatar image
// @Description  Remove the authenticated user's avatar image
// @Tags         users
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Success      200 {object} ProfileResponse "Avatar deleted successfully"
// @Failure      401 {object} ErrorResponse "Unauthorized"
// @Failure      500 {object} ErrorResponse "Failed to delete avatar"
// @Router       /users/avatar [delete]
func (h *UserHandler) DeleteAvatar(w http.ResponseWriter, r *http.Request) {
	// Get user ID from context
	userID, ok := middleware.GetUserID(r.Context())
	if !ok {
		respondError(w, http.StatusUnauthorized, "Unauthorized")
		return
	}

	// Get current user
	user, err := h.userService.GetByID(userID)
	if err != nil {
		if h.logger != nil {
			h.logger.Error("action=delete_avatar outcome=failure user_id=%d error=failed_to_get_user: %v", userID, err)
		}
		respondError(w, http.StatusInternalServerError, "Failed to delete avatar")
		return
	}

	// Delete avatar file if it exists
	if user.ProfileImage != nil && *user.ProfileImage != "" {
		oldPath := *user.ProfileImage
		if strings.HasPrefix(oldPath, "/uploads/") {
			oldPath = "." + oldPath
		}
		if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
			if h.logger != nil {
				h.logger.Warn("action=delete_avatar outcome=warning user_id=%d error=failed_to_delete_file: %v", userID, err)
			}
		}
	}

	// Update user profile to remove avatar
	if err := h.userService.UpdateAvatar(userID, ""); err != nil {
		if h.logger != nil {
			h.logger.Error("action=delete_avatar outcome=failure user_id=%d error=failed_to_update_profile: %v", userID, err)
		}
		respondError(w, http.StatusInternalServerError, "Failed to delete avatar")
		return
	}

	user.ProfileImage = nil

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

	respondJSON(w, http.StatusOK, ProfileResponse{
		User: user,
	})
}

// ChangePassword handles password change requests
// @Summary      Change password
// @Description  Change the authenticated user's password
// @Tags         users
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        request body ChangePasswordRequest true "Current and new password"
// @Success      200 {object} MessageResponse "Password changed successfully"
// @Failure      400 {object} ErrorResponse "Invalid request or weak password"
// @Failure      401 {object} ErrorResponse "Unauthorized or incorrect current password"
// @Failure      500 {object} ErrorResponse "Failed to change password"
// @Router       /users/change-password [post]
func (h *UserHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
	userID, ok := middleware.GetUserID(r.Context())
	if !ok {
		respondError(w, http.StatusUnauthorized, "Unauthorized")
		return
	}

	var req struct {
		OldPassword string `json:"old_password"`
		NewPassword string `json:"new_password"`
	}

	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		respondError(w, http.StatusBadRequest, "Invalid request body")
		return
	}

	if req.OldPassword == "" || req.NewPassword == "" {
		respondError(w, http.StatusBadRequest, "Both old_password and new_password are required")
		return
	}

	if len(req.NewPassword) < 8 {
		respondError(w, http.StatusBadRequest, "New password must be at least 8 characters")
		return
	}

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

	if err := h.userService.ChangePassword(userID, req.OldPassword, req.NewPassword); err != nil {
		if err == service.ErrInvalidCredentials {
			if h.logger != nil {
				h.logger.Warn("action=change_password outcome=failure user_id=%d reason=invalid_old_password", userID)
			}
			respondError(w, http.StatusUnauthorized, "Current password is incorrect")
			return
		}
		if h.logger != nil {
			h.logger.Error("action=change_password outcome=failure user_id=%d error=%v", userID, err)
		}
		respondError(w, http.StatusInternalServerError, "Failed to change password")
		return
	}

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

	respondJSON(w, http.StatusOK, map[string]string{
		"message": "Password changed successfully",
	})
}