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:
+221
-28
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/middleware"
|
"bookhoard/internal/middleware"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -74,8 +75,20 @@ type UserProfile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProfileRequest struct {
|
type UpdateProfileRequest struct {
|
||||||
FirstName string `json:"first_name,omitempty"`
|
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
||||||
LastName string `json:"last_name,omitempty"`
|
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
|
// 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()})
|
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(
|
accessToken, err := h.generateJWTWithAllClaims(
|
||||||
uuid.UUID(user.ID.Bytes).String(),
|
uuid.UUID(user.ID.Bytes).String(),
|
||||||
user.Role,
|
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 {
|
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 {
|
if err := c.Bind(&req); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
}
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{
|
err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
|
||||||
ID: user.ID,
|
ID: targetUserUUID,
|
||||||
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
|
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
|
||||||
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
|
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]string{"message": "profile updated"})
|
// 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
|
// ListUsers handles GET /api/auth/users
|
||||||
@@ -604,11 +732,38 @@ type UpdatePasswordRequest struct {
|
|||||||
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
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 {
|
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 {
|
if err := c.Bind(&req); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
}
|
}
|
||||||
@@ -616,24 +771,29 @@ func (h *AuthHandler) UpdatePassword(c echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if new passwords match
|
// Self-change mode: require current password
|
||||||
if req.NewPassword != req.ConfirmPassword {
|
if !isAdminMode {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "new passwords do not match"})
|
if req.CurrentPassword == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "current password required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current user's password hash
|
// Get current password hash
|
||||||
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), user.ID)
|
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), targetUserUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "user not found"})
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get user"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify current password
|
// Verify current password
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword)); err != nil {
|
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.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
|
// Hash new password
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||||
@@ -643,7 +803,7 @@ func (h *AuthHandler) UpdatePassword(c echo.Context) error {
|
|||||||
|
|
||||||
// Update password
|
// Update password
|
||||||
err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{
|
err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{
|
||||||
ID: user.ID,
|
ID: targetUserUUID,
|
||||||
PasswordHash: string(hashedPassword),
|
PasswordHash: string(hashedPassword),
|
||||||
})
|
})
|
||||||
if err != nil {
|
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"})
|
return c.JSON(http.StatusOK, map[string]string{"message": "password updated successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAccount handles DELETE /api/auth/account
|
// DeleteUser handles DELETE /api/auth/profile (self-deletion) and DELETE /api/auth/profile/:id (admin deletion)
|
||||||
// Supports self-deletion or admin deletion of other users
|
// Combined handler for both self-deletion and admin deletion of users
|
||||||
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
|
func (h *AuthHandler) DeleteUser(c echo.Context) error {
|
||||||
currentUser := MustGetAuthenticatedUser(c)
|
currentUser := MustGetAuthenticatedUser(c)
|
||||||
|
|
||||||
// Get target user ID from query parameter (for admin override) or use current user
|
// Get target user ID from URL param (admin mode) or use current user (self-deletion)
|
||||||
targetUserID := c.QueryParam("user_id")
|
targetUserID := c.Param("id")
|
||||||
var targetUserUUID pgtype.UUID
|
var targetUserUUID pgtype.UUID
|
||||||
|
|
||||||
// If admin override is used, validate admin and use target
|
|
||||||
if targetUserID != "" {
|
if targetUserID != "" {
|
||||||
// Admin override mode - check if current user is admin
|
// Admin deletion mode
|
||||||
if currentUser.Role != "admin" {
|
if currentUser.Role != "admin" {
|
||||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
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}
|
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
|
||||||
} else {
|
} else {
|
||||||
|
// Self-deletion mode
|
||||||
targetUserUUID = currentUser.ID
|
targetUserUUID = currentUser.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,3 +962,36 @@ func (h *AuthHandler) generateJWTWithRole(userID, userRole string) (string, erro
|
|||||||
func (h *AuthHandler) generateJWT(userID string) (string, error) {
|
func (h *AuthHandler) generateJWT(userID string) (string, error) {
|
||||||
return h.generateJWTWithRole(userID, "user")
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v4"
|
"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"})
|
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 {
|
if req.Limit <= 0 || req.Limit > 100 {
|
||||||
req.Limit = 20
|
req.Limit = 20
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error {
|
|||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
CollectionName string `json:"collection_name"`
|
CollectionName string `json:"collection_name"`
|
||||||
|
ResetType string `json:"reset_type"` // "full" or "keep_books"
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.Bind(&req); err != nil {
|
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"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.ResetType == "" {
|
||||||
|
req.ResetType = "full"
|
||||||
|
}
|
||||||
|
|
||||||
validCollections := map[string]bool{
|
validCollections := map[string]bool{
|
||||||
"continue-reading": true,
|
"continue-reading": true,
|
||||||
"recently-added": 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"})
|
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 {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -142,7 +143,7 @@ func (s *DashboardService) GetDashboardSections(
|
|||||||
) ([]DashboardSection, error) {
|
) ([]DashboardSection, error) {
|
||||||
var results []DashboardSection
|
var results []DashboardSection
|
||||||
|
|
||||||
systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx)
|
systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -371,7 +372,36 @@ func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, param
|
|||||||
return s.db.UpsertDashboardPreferences(ctx, params)
|
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{
|
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
Name: collectionName,
|
Name: collectionName,
|
||||||
@@ -380,5 +410,15 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u
|
|||||||
return err
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user