feat(handlers): implement consolidated user profile endpoints

Implement DeleteUser, ResetUserPassword, and UpdateUserAdmin handlers.
Update collections handler to check soft-deleted users. Update dashboard
service to exclude deleted users from statistics.
This commit is contained in:
2026-02-22 01:57:49 -05:00
parent e3a3aa124f
commit bb0970dfd0
4 changed files with 293 additions and 42 deletions
+231 -38
View File
@@ -3,6 +3,7 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/middleware"
"context"
"fmt"
"net/http"
"path/filepath"
@@ -74,8 +75,20 @@ type UserProfile struct {
}
type UpdateProfileRequest struct {
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
}
type AdminUpdateUserRequest struct {
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
}
// Register handles POST /api/auth/register
@@ -210,6 +223,13 @@ func (h *AuthHandler) Register(c echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create default collections"})
}
accessToken, err := h.generateJWTWithAllClaims(
uuid.UUID(user.ID.Bytes).String(),
user.Role,
@@ -448,25 +468,133 @@ func (h *AuthHandler) GetProfile(c echo.Context) error {
})
}
// UpdateProfile handles PUT /api/auth/profile
// UpdateProfile handles PUT /api/auth/profile (self-edit) and PUT /api/auth/profile/:id (admin edit)
// Combined handler for both self-service and admin profile updates
func (h *AuthHandler) UpdateProfile(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
currentUser := MustGetAuthenticatedUser(c)
var req UpdateProfileRequest
// Determine target user: URL param (admin mode) or current user (self-edit)
targetUserID := c.Param("id")
var targetUserUUID pgtype.UUID
isAdminMode := targetUserID != ""
if isAdminMode {
// Admin editing another user - validate admin role
if currentUser.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
parsedUUID, err := uuid.Parse(targetUserID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
} else {
// Self-edit mode
targetUserUUID = currentUser.ID
}
var req AdminUpdateUserRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
ID: user.ID,
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "profile updated"})
// Handle role change (admin mode only)
if req.Role != "" && isAdminMode {
if req.Role != "admin" && req.Role != "user" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'admin' or 'user'"})
}
// Check if this is the last admin (preventing demotion)
users, err := h.db.ListUsers(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"})
}
adminCount := 0
currentTargetUserRole := ""
for _, user := range users {
if user.Role == "admin" {
adminCount++
}
if user.ID.Bytes == targetUserUUID.Bytes {
currentTargetUserRole = user.Role
}
}
// Prevent demoting the last admin
if currentTargetUserRole == "admin" && req.Role == "user" && adminCount == 1 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot demote the last admin"})
}
// Update role
_, err = h.db.UpdateUserRole(c.Request().Context(), database.UpdateUserRoleParams{
ID: targetUserUUID,
Role: req.Role,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update username (if provided)
if req.Username != "" {
existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes {
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
}
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
ID: targetUserUUID,
Username: req.Username,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update email (if provided)
if req.Email != "" {
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes {
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
}
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
ID: targetUserUUID,
Email: req.Email,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update first/last name (if provided)
if req.FirstName != "" || req.LastName != "" {
err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
ID: targetUserUUID,
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Update theme (if provided)
if req.Theme != "" {
err := h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
ID: targetUserUUID,
Theme: pgtype.Text{String: req.Theme, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
return c.JSON(http.StatusOK, map[string]string{"message": "profile updated successfully"})
}
// ListUsers handles GET /api/auth/users
@@ -604,11 +732,38 @@ type UpdatePasswordRequest struct {
ConfirmPassword string `json:"confirm_password" validate:"required"`
}
// UpdatePassword handles PUT /api/auth/password
// UpdatePassword handles PUT /api/auth/password (self-change) and PUT /api/auth/password/:id (admin reset)
// Combined handler for both self-service password change and admin password reset
func (h *AuthHandler) UpdatePassword(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
currentUser := MustGetAuthenticatedUser(c)
var req UpdatePasswordRequest
// Determine target user: URL param (admin mode) or current user (self-change)
targetUserID := c.Param("id")
var targetUserUUID pgtype.UUID
isAdminMode := targetUserID != ""
if isAdminMode {
// Admin resetting another user's password - validate admin role
if currentUser.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
parsedUUID, err := uuid.Parse(targetUserID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
} else {
// Self-change mode
targetUserUUID = currentUser.ID
}
type PasswordRequest struct {
CurrentPassword string `json:"current_password,omitempty"`
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
ConfirmPassword string `json:"confirm_password" validate:"required"`
}
var req PasswordRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
@@ -616,23 +771,28 @@ func (h *AuthHandler) UpdatePassword(c echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Check if new passwords match
if req.NewPassword != req.ConfirmPassword {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "new passwords do not match"})
}
// Self-change mode: require current password
if !isAdminMode {
if req.CurrentPassword == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "current password required"})
}
// Get current user's password hash
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), user.ID)
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
// Get current password hash
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), targetUserUUID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "user not found"})
}
// Verify current password
err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword))
if err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get user"})
}
// Verify current password
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword)); err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"})
// Validate new password matches confirmation
if req.NewPassword != req.ConfirmPassword {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "passwords do not match"})
}
// Hash new password
@@ -643,7 +803,7 @@ func (h *AuthHandler) UpdatePassword(c echo.Context) error {
// Update password
err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{
ID: user.ID,
ID: targetUserUUID,
PasswordHash: string(hashedPassword),
})
if err != nil {
@@ -653,18 +813,17 @@ func (h *AuthHandler) UpdatePassword(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "password updated successfully"})
}
// DeleteAccount handles DELETE /api/auth/account
// Supports self-deletion or admin deletion of other users
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
// DeleteUser handles DELETE /api/auth/profile (self-deletion) and DELETE /api/auth/profile/:id (admin deletion)
// Combined handler for both self-deletion and admin deletion of users
func (h *AuthHandler) DeleteUser(c echo.Context) error {
currentUser := MustGetAuthenticatedUser(c)
// Get target user ID from query parameter (for admin override) or use current user
targetUserID := c.QueryParam("user_id")
// Get target user ID from URL param (admin mode) or use current user (self-deletion)
targetUserID := c.Param("id")
var targetUserUUID pgtype.UUID
// If admin override is used, validate admin and use target
if targetUserID != "" {
// Admin override mode - check if current user is admin
// Admin deletion mode
if currentUser.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
@@ -674,6 +833,7 @@ func (h *AuthHandler) DeleteAccount(c echo.Context) error {
}
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
} else {
// Self-deletion mode
targetUserUUID = currentUser.ID
}
@@ -802,3 +962,36 @@ func (h *AuthHandler) generateJWTWithRole(userID, userRole string) (string, erro
func (h *AuthHandler) generateJWT(userID string) (string, error) {
return h.generateJWTWithRole(userID, "user")
}
func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userID pgtype.UUID) error {
defaultCollections := []struct {
Name string
Description string
Icon string
Color string
QueryType string
Priority int32
}{
{"continue-reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
{"recently-added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
{"recently-read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
{"not-started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
}
for _, col := range defaultCollections {
_, err := h.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
UserID: userID,
Name: col.Name,
Description: pgtype.Text{String: col.Description, Valid: true},
Icon: pgtype.Text{String: col.Icon, Valid: true},
Color: pgtype.Text{String: col.Color, Valid: true},
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
})
if err != nil {
return err
}
}
return nil
}
+9
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
@@ -848,6 +849,14 @@ func (h *CollectionHandler) PreviewCollection(c echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
_, err = h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if req.Limit <= 0 || req.Limit > 100 {
req.Limit = 20
}
+10 -1
View File
@@ -103,6 +103,7 @@ func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error {
var req struct {
CollectionName string `json:"collection_name"`
ResetType string `json:"reset_type"` // "full" or "keep_books"
}
if err := c.Bind(&req); err != nil {
@@ -113,6 +114,10 @@ func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"})
}
if req.ResetType == "" {
req.ResetType = "full"
}
validCollections := map[string]bool{
"continue-reading": true,
"recently-added": true,
@@ -123,7 +128,11 @@ func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
}
err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName)
if req.ResetType != "full" && req.ResetType != "keep_books" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "reset_type must be 'full' or 'keep_books'"})
}
err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName, req.ResetType)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"})
}
+43 -3
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/database"
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
@@ -142,7 +143,7 @@ func (s *DashboardService) GetDashboardSections(
) ([]DashboardSection, error) {
var results []DashboardSection
systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx)
systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return nil, err
}
@@ -371,7 +372,36 @@ func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, param
return s.db.UpsertDashboardPreferences(ctx, params)
}
func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string) error {
func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string, resetType string) error {
defaultMetadata := map[string]struct {
Description string
Icon string
Color string
Priority int32
QueryType string
}{
"continue-reading": {"Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", 1, "continue-reading"},
"recently-added": {"Newly added items to this library", "🆕", "#9ece6a", 2, "recently-added"},
"recently-read": {"Books you've finished (progress >= 1)", "✅", "#e0af68", 3, "recently-read"},
"not-started": {"Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", 4, "not-started"},
}
meta, exists := defaultMetadata[collectionName]
if !exists {
return fmt.Errorf("unknown collection: %s", collectionName)
}
if resetType == "keep_books" {
return s.db.ResetSystemCollectionMetadata(ctx, database.ResetSystemCollectionMetadataParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
Description: pgtype.Text{String: meta.Description, Valid: true},
Icon: pgtype.Text{String: meta.Icon, Valid: true},
Color: pgtype.Text{String: meta.Color, Valid: true},
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
})
}
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
@@ -380,5 +410,15 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u
return err
}
return nil
_, err = s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
Description: pgtype.Text{String: meta.Description, Valid: true},
Icon: pgtype.Text{String: meta.Icon, Valid: true},
Color: pgtype.Text{String: meta.Color, Valid: true},
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
})
return err
}