internal/handler/admin_user_handler.go
Rama main · réplica de lectura del repositorio original.
package handler
import (
"encoding/json"
"net/http"
"strconv"
"time"
"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"
)
// adminUserServiceIface is the package-private surface AdminUserHandler depends
// on. The concrete *service.AdminUserService satisfies it; tests use a stub.
// Keep this list narrow: only methods this handler actually invokes.
type adminUserServiceIface interface {
CreateUser(actorID int64, fields service.CreateUserFields) (*domain.User, error)
UpdateProfile(actorID, targetID int64, fields service.ProfileUpdateFields, ifMatchUpdatedAt time.Time) (*domain.User, error)
ForcePasswordReset(actorID, targetID int64) error
SetPassword(actorID, targetID int64, newPassword string) error
}
// AdminUserHandler handles admin user management operations
type AdminUserHandler struct {
userService *service.UserService
adminUserService adminUserServiceIface
logger *logger.Logger
}
// NewAdminUserHandler creates a new admin user handler
func NewAdminUserHandler(userService *service.UserService, adminUserService adminUserServiceIface, logger *logger.Logger) *AdminUserHandler {
return &AdminUserHandler{
userService: userService,
adminUserService: adminUserService,
logger: logger,
}
}
// ListUsers handles GET /api/admin/users
// @Summary List users (Admin)
// @Description Get a paginated list of all users
// @Tags admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param limit query int false "Max results (default 50)"
// @Param offset query int false "Skip N results (default 0)"
// @Success 200 {object} map[string]interface{} "Users list with total count"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /admin/users [get]
func (h *AdminUserHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
// Parse pagination parameters
limit := 50
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
limit = parsedLimit
}
}
offset := 0
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 {
offset = parsedOffset
}
}
// Get users
users, total, err := h.userService.ListUsers(limit, offset)
if err != nil {
h.logger.Error("Failed to list users: %v", err)
http.Error(w, "Failed to retrieve users", http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"users": users,
"total": total,
"limit": limit,
"offset": offset,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// UnlockUser handles POST /api/admin/users/:id/unlock
// @Summary Unlock user account (Admin)
// @Description Unlock a user account that was locked due to failed login attempts
// @Tags admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "User ID"
// @Success 200 {object} MessageResponse "Success message"
// @Failure 400 {object} ErrorResponse "Invalid user ID"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /admin/users/{id}/unlock [post]
func (h *AdminUserHandler) UnlockUser(w http.ResponseWriter, r *http.Request) {
// Get admin user ID from context
adminUserID, ok := middleware.GetUserID(r.Context())
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Get target user ID from URL
targetUserIDStr := chi.URLParam(r, "id")
targetUserID, err := strconv.ParseInt(targetUserIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
// Unlock the account
if err := h.userService.UnlockAccount(adminUserID, targetUserID); err != nil {
h.logger.Error("Failed to unlock user account: admin_user_id=%d target_user_id=%d error=%v", adminUserID, targetUserID, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.logger.Info("User account unlocked: admin_user_id=%d target_user_id=%d", adminUserID, targetUserID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Account unlocked successfully",
})
}
// DisableUser handles POST /api/admin/users/:id/disable
// @Summary Disable user account (Admin)
// @Description Disable a user account with optional reason
// @Tags admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "User ID"
// @Param request body object false "Optional disable reason"
// @Success 200 {object} MessageResponse "Success message"
// @Failure 400 {object} ErrorResponse "Invalid user ID or cannot disable"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /admin/users/{id}/disable [post]
func (h *AdminUserHandler) DisableUser(w http.ResponseWriter, r *http.Request) {
// Get admin user ID from context
adminUserID, ok := middleware.GetUserID(r.Context())
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Get target user ID from URL
targetUserIDStr := chi.URLParam(r, "id")
targetUserID, err := strconv.ParseInt(targetUserIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
// Parse request body for reason
var request struct {
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
// Reason is optional
request.Reason = ""
}
// Disable the account
if err := h.userService.DisableAccount(adminUserID, targetUserID, request.Reason); err != nil {
h.logger.Error("Failed to disable user account: admin_user_id=%d target_user_id=%d error=%v", adminUserID, targetUserID, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
h.logger.Info("User account disabled: admin_user_id=%d target_user_id=%d reason=%s", adminUserID, targetUserID, request.Reason)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Account disabled successfully",
})
}
// EnableUser handles POST /api/admin/users/:id/enable
// @Summary Enable user account (Admin)
// @Description Re-enable a previously disabled user account
// @Tags admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "User ID"
// @Success 200 {object} MessageResponse "Success message"
// @Failure 400 {object} ErrorResponse "Invalid user ID"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /admin/users/{id}/enable [post]
func (h *AdminUserHandler) EnableUser(w http.ResponseWriter, r *http.Request) {
// Get admin user ID from context
adminUserID, ok := middleware.GetUserID(r.Context())
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Get target user ID from URL
targetUserIDStr := chi.URLParam(r, "id")
targetUserID, err := strconv.ParseInt(targetUserIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
// Enable the account
if err := h.userService.EnableAccount(adminUserID, targetUserID); err != nil {
h.logger.Error("Failed to enable user account: admin_user_id=%d target_user_id=%d error=%v", adminUserID, targetUserID, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.logger.Info("User account enabled: admin_user_id=%d target_user_id=%d", adminUserID, targetUserID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Account enabled successfully",
})
}
// ChangeUserRole handles PUT /api/admin/users/:id/role
// @Summary Change user role (Admin)
// @Description Change a user's role to 'user' or 'admin'
// @Tags admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "User ID"
// @Param request body object true "New role (user or admin)"
// @Success 200 {object} MessageResponse "Success message"
// @Failure 400 {object} ErrorResponse "Invalid user ID or role"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /admin/users/{id}/role [put]
func (h *AdminUserHandler) ChangeUserRole(w http.ResponseWriter, r *http.Request) {
// Get admin user ID from context
adminUserID, ok := middleware.GetUserID(r.Context())
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Get target user ID from URL
targetUserIDStr := chi.URLParam(r, "id")
targetUserID, err := strconv.ParseInt(targetUserIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
// Parse request body
var request struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate role
if request.Role != "athlete" && request.Role != "coach" && request.Role != "admin" {
http.Error(w, "Role must be 'athlete', 'coach', or 'admin'", http.StatusBadRequest)
return
}
// Change the role
if err := h.userService.ChangeUserRole(adminUserID, targetUserID, request.Role); err != nil {
h.logger.Error("Failed to change user role: admin_user_id=%d target_user_id=%d new_role=%s error=%v", adminUserID, targetUserID, request.Role, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
h.logger.Info("User role changed: admin_user_id=%d target_user_id=%d new_role=%s", adminUserID, targetUserID, request.Role)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "User role changed successfully",
})
}
// ToggleEmailVerification handles POST /api/admin/users/:id/toggle-email-verification
// @Summary Toggle email verification (Admin)
// @Description Manually set a user's email verification status
// @Tags admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "User ID"
// @Param request body object true "Verified status (true/false)"
// @Success 200 {object} MessageResponse "Success message"
// @Failure 400 {object} ErrorResponse "Invalid user ID"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /admin/users/{id}/toggle-email-verification [post]
func (h *AdminUserHandler) ToggleEmailVerification(w http.ResponseWriter, r *http.Request) {
// Get admin user ID from context
adminUserID, ok := middleware.GetUserID(r.Context())
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Get target user ID from URL
targetUserIDStr := chi.URLParam(r, "id")
targetUserID, err := strconv.ParseInt(targetUserIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
// Parse request body
var request struct {
Verified bool `json:"verified"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Toggle email verification
if err := h.userService.SetEmailVerification(adminUserID, targetUserID, request.Verified); err != nil {
h.logger.Error("Failed to toggle email verification: admin_user_id=%d target_user_id=%d verified=%v error=%v", adminUserID, targetUserID, request.Verified, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.logger.Info("Email verification toggled: admin_user_id=%d target_user_id=%d verified=%v", adminUserID, targetUserID, request.Verified)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Email verification updated successfully",
})
}
// GetUserDetails handles GET /api/admin/users/:id
// @Summary Get user details (Admin)
// @Description Get detailed information about a specific user
// @Tags admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "User ID"
// @Success 200 {object} map[string]interface{} "User details"
// @Failure 400 {object} ErrorResponse "Invalid user ID"
// @Failure 404 {object} ErrorResponse "User not found"
// @Router /admin/users/{id} [get]
func (h *AdminUserHandler) GetUserDetails(w http.ResponseWriter, r *http.Request) {
// Get target user ID from URL
targetUserIDStr := chi.URLParam(r, "id")
targetUserID, err := strconv.ParseInt(targetUserIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
// Get user with admin details
user, err := h.userService.GetUserByIDWithAdminDetails(targetUserID)
if err != nil {
h.logger.Error("Failed to get user details: target_user_id=%d error=%v", targetUserID, err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
// DeleteUser handles DELETE /api/admin/users/:id
// @Summary Delete user (Admin)
// @Description Permanently delete a user and all their data
// @Tags admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "User ID"
// @Success 200 {object} MessageResponse "Success message"
// @Failure 400 {object} ErrorResponse "Invalid user ID or cannot delete"
// @Router /admin/users/{id} [delete]
func (h *AdminUserHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
// Get admin user ID from context
adminUserID, ok := middleware.GetUserID(r.Context())
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Get target user ID from URL
targetUserIDStr := chi.URLParam(r, "id")
targetUserID, err := strconv.ParseInt(targetUserIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
// Delete the user
if err := h.userService.DeleteUser(adminUserID, targetUserID); err != nil {
h.logger.Error("Failed to delete user: admin_user_id=%d target_user_id=%d error=%v", adminUserID, targetUserID, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
h.logger.Info("User deleted: admin_user_id=%d target_user_id=%d", adminUserID, targetUserID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "User deleted successfully",
})
}
// updateProfileRequest is the PATCH /api/admin/users/{id} request body.
// UpdatedAt is required; it is compared against the stored row timestamp for
// optimistic concurrency control. Only present fields are applied.
type updateProfileRequest struct {
Name *string `json:"name,omitempty"`
Email *string `json:"email,omitempty"`
Birthday *time.Time `json:"birthday,omitempty"`
EmailVerified *bool `json:"email_verified,omitempty"`
UpdatedAt time.Time `json:"updated_at"` // required (precondition)
}
// UpdateProfile handles PATCH /api/admin/users/{id}.
//
// - Optimistic concurrency: client must send updated_at matching current row.
// - Partial: only fields present in body are updated.
// - L2 enforcement: service returns ErrProtectedUser → 403 via WriteError.
// - On success: 200 with the updated User JSON.
func (h *AdminUserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
actorID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
targetID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respondError(w, http.StatusNotFound, "Not found")
return
}
var req updateProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid JSON")
return
}
if req.UpdatedAt.IsZero() {
respondError(w, http.StatusBadRequest, "updated_at must be a valid non-zero timestamp")
return
}
user, err := h.adminUserService.UpdateProfile(actorID, targetID,
service.ProfileUpdateFields{
Name: req.Name,
Email: req.Email,
Birthday: req.Birthday,
EmailVerified: req.EmailVerified,
},
req.UpdatedAt,
)
if err != nil {
WriteError(w, err)
return
}
respondJSON(w, http.StatusOK, user)
}
// ForcePasswordReset handles POST /api/admin/users/{id}/force-password-reset.
//
// - Calls service.ForcePasswordReset (sends email + revokes refresh tokens).
// - L2 enforcement: protected target → 403.
// - On success: 204 No Content.
func (h *AdminUserHandler) ForcePasswordReset(w http.ResponseWriter, r *http.Request) {
actorID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
targetID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respondError(w, http.StatusNotFound, "Not found")
return
}
if err := h.adminUserService.ForcePasswordReset(actorID, targetID); err != nil {
WriteError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// createUserRequest is the POST /api/admin/users request body.
type createUserRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Role string `json:"role"`
EmailVerified *bool `json:"email_verified,omitempty"` // nil → default true
}
// CreateUser handles POST /api/admin/users — admin creates a new user account.
//
// - 201 with the created user JSON on success
// - 400 invalid_input on validation failure
// - 409 duplicate_email if email already exists
// - 401/403 from auth middleware
func (h *AdminUserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
actorID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid JSON")
return
}
// Default EmailVerified=true when the caller omits the field.
emailVerified := true
if req.EmailVerified != nil {
emailVerified = *req.EmailVerified
}
user, err := h.adminUserService.CreateUser(actorID, service.CreateUserFields{
Email: req.Email,
Password: req.Password,
Name: req.Name,
Role: req.Role,
EmailVerified: emailVerified,
})
if err != nil {
WriteError(w, err)
return
}
respondJSON(w, http.StatusCreated, user)
}
// setPasswordRequest is the POST /api/admin/users/{id}/password request body.
type setPasswordRequest struct {
NewPassword string `json:"new_password"`
}
// SetPassword handles POST /api/admin/users/{id}/password — admin sets a
// specific password on a user account.
//
// - 204 on success (empty body)
// - 400 on invalid JSON or password-policy failure
// - 403 from L1 ProtectedUserGuard if target is protected
// - 404 if {id} is not a valid integer
func (h *AdminUserHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
actorID, ok := middleware.GetUserID(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
targetID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respondError(w, http.StatusNotFound, "Not found")
return
}
var req setPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := h.adminUserService.SetPassword(actorID, targetID, req.NewPassword); err != nil {
WriteError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}