feat: add user preferences dashboard

- Add /preferences route and preferences.html template for user settings
- Implement username, email, password, and theme update functionality
- Add account deletion feature with confirmation
- Add navigation link to preferences from dashboard
- Create API endpoints:
  - PUT /api/user/username - Update username
  - PUT /api/user/email - Update email address
  - PUT /api/user/password - Change password with verification
  - DELETE /api/user/account - Delete user account
- Add database queries for user updates and account deletion
- Create Bruno API testing files for all user preference endpoints
- Add proper validation, error handling, and security checks
This commit is contained in:
2026-01-23 09:30:15 -05:00
parent c29183a14f
commit 2dba1d6eb9
11 changed files with 628 additions and 15 deletions
+3 -2
View File
@@ -7,12 +7,13 @@ import (
)
func NewConnection(databaseURL string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(context.Background(), databaseURL)
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, err
}
if err := pool.Ping(context.Background()); err != nil {
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
return nil, err
}
+5
View File
@@ -18,6 +18,7 @@ type Querier interface {
DeleteEbook(ctx context.Context, id pgtype.UUID) error
DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
DeleteUser(ctx context.Context, id pgtype.UUID) error
DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) error
GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
GetEbookByFilePath(ctx context.Context, filePath string) (Ebooks, error)
@@ -29,12 +30,16 @@ type Querier interface {
GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error)
GetUserByUsername(ctx context.Context, username string) (Users, error)
GetUserEbookFolders(ctx context.Context, userID pgtype.UUID) ([]UserEbookFolders, error)
GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error)
ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error)
UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error)
UpdateEbookRating(ctx context.Context, arg UpdateEbookRatingParams) (EbookRatings, error)
UpdateEmail(ctx context.Context, arg UpdateEmailParams) error
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
}
var _ Querier = (*Queries)(nil)
+62
View File
@@ -198,6 +198,15 @@ func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingPr
return err
}
const DeleteUser = `-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1
`
func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, DeleteUser, id)
return err
}
const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :exec
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2
`
@@ -477,6 +486,17 @@ func (q *Queries) GetUserEbookFolders(ctx context.Context, userID pgtype.UUID) (
return items, nil
}
const GetUserPasswordHash = `-- name: GetUserPasswordHash :one
SELECT password_hash FROM users WHERE id = $1
`
func (q *Queries) GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error) {
row := q.db.QueryRow(ctx, GetUserPasswordHash, id)
var password_hash string
err := row.Scan(&password_hash)
return password_hash, err
}
const ListEbooks = `-- name: ListEbooks :many
SELECT id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at, series, series_number, tags, asin, date_published, publisher, contributors FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2
`
@@ -668,6 +688,34 @@ func (q *Queries) UpdateEbookRating(ctx context.Context, arg UpdateEbookRatingPa
return i, err
}
const UpdateEmail = `-- name: UpdateEmail :exec
UPDATE users SET email = $2, updated_at = NOW() WHERE id = $1
`
type UpdateEmailParams struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
}
func (q *Queries) UpdateEmail(ctx context.Context, arg UpdateEmailParams) error {
_, err := q.db.Exec(ctx, UpdateEmail, arg.ID, arg.Email)
return err
}
const UpdatePassword = `-- name: UpdatePassword :exec
UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1
`
type UpdatePasswordParams struct {
ID pgtype.UUID `db:"id" json:"id"`
PasswordHash string `db:"password_hash" json:"password_hash"`
}
func (q *Queries) UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error {
_, err := q.db.Exec(ctx, UpdatePassword, arg.ID, arg.PasswordHash)
return err
}
const UpdateReadingProgress = `-- name: UpdateReadingProgress :one
INSERT INTO reading_progress (ebook_id, user_id, current_page, total_pages, last_read_at)
VALUES ($1, $2, $3, $4, NOW())
@@ -718,3 +766,17 @@ func (q *Queries) UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams
_, err := q.db.Exec(ctx, UpdateUserTheme, arg.ID, arg.Theme)
return err
}
const UpdateUsername = `-- name: UpdateUsername :exec
UPDATE users SET username = $2, updated_at = NOW() WHERE id = $1
`
type UpdateUsernameParams struct {
ID pgtype.UUID `db:"id" json:"id"`
Username string `db:"username" json:"username"`
}
func (q *Queries) UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error {
_, err := q.db.Exec(ctx, UpdateUsername, arg.ID, arg.Username)
return err
}
+15
View File
@@ -15,6 +15,9 @@ SELECT * FROM users WHERE email = $1 OR username = $1;
-- name: GetUser :one
SELECT id, email, username, theme, created_at, updated_at FROM users WHERE id = $1;
-- name: GetUserPasswordHash :one
SELECT password_hash FROM users WHERE id = $1;
-- name: ListUsers :many
SELECT id, email, username, theme, created_at, updated_at FROM users ORDER BY created_at DESC;
@@ -69,6 +72,18 @@ DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2;
-- name: UpdateUserTheme :exec
UPDATE users SET theme = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUsername :exec
UPDATE users SET username = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateEmail :exec
UPDATE users SET email = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdatePassword :exec
UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1;
-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1;
-- name: CreateEbookRating :one
INSERT INTO ebook_ratings (ebook_id, user_id, rating)
VALUES ($1, $2, $3)
+180
View File
@@ -408,6 +408,186 @@ func (h *AuthHandler) DeleteEbookFolder(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "ebook folder removed"})
}
type UpdateThemeRequest struct {
Theme string `json:"theme" validate:"required"`
}
// UpdateTheme handles PUT /api/auth/theme
func (h *AuthHandler) UpdateTheme(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
var req UpdateThemeRequest
if err := c.Bind(&req); err != nil {
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()})
}
err = h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Theme: pgtype.Text{String: req.Theme, Valid: req.Theme != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "theme updated successfully"})
}
type UpdateUsernameRequest struct {
Username string `json:"username" validate:"required,min=3,max=50"`
}
// UpdateUsername handles PUT /api/user/username
func (h *AuthHandler) UpdateUsername(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
var req UpdateUsernameRequest
if err := c.Bind(&req); err != nil {
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()})
}
// Check if username is already taken by another user
existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
if err == nil && uuid.UUID(existingUser.ID.Bytes) != userUUID {
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
}
// Update username
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Username: req.Username,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "username updated successfully"})
}
type UpdateEmailRequest struct {
Email string `json:"email" validate:"required,email"`
}
// UpdateEmail handles PUT /api/user/email
func (h *AuthHandler) UpdateEmail(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
var req UpdateEmailRequest
if err := c.Bind(&req); err != nil {
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()})
}
// Check if email is already taken by another user
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
if err == nil && uuid.UUID(existingUser.ID.Bytes) != userUUID {
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
}
// Update email
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Email: req.Email,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "email updated successfully"})
}
type UpdatePasswordRequest struct {
CurrentPassword string `json:"current_password" validate:"required"`
NewPassword string `json:"new_password" validate:"required,min=6"`
ConfirmPassword string `json:"confirm_password" validate:"required"`
}
// UpdatePassword handles PUT /api/user/password
func (h *AuthHandler) UpdatePassword(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
var req UpdatePasswordRequest
if err := c.Bind(&req); err != nil {
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()})
}
// Check if new passwords match
if req.NewPassword != req.ConfirmPassword {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "new passwords do not match"})
}
// Get current user's password hash
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
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"})
}
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
}
// Update password
err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
PasswordHash: string(hashedPassword),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "password updated successfully"})
}
// DeleteAccount handles DELETE /api/user/account
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
// Delete user (this will cascade to delete all related data)
err = h.db.DeleteUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, 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": "account deleted successfully"})
}
func (h *AuthHandler) generateJWT(userID string) (string, error) {
claims := jwtgo.MapClaims{
"user_id": userID,