added ratings and updated ebook struct

This commit is contained in:
2026-01-22 20:54:39 -05:00
parent e32fda6b65
commit e5ca12117c
15 changed files with 668 additions and 26 deletions
+7
View File
@@ -83,6 +83,13 @@ The application uses Go HTML templates for server-side rendering with HTMX for d
- `GET /api/ebooks/:id/progress` - Get reading progress - `GET /api/ebooks/:id/progress` - Get reading progress
- `PUT /api/ebooks/:id/progress` - Update reading progress - `PUT /api/ebooks/:id/progress` - Update reading progress
### Ratings (Protected)
- `GET /api/ebooks/:id/rating` - Get user's rating for ebook
- `POST /api/ebooks/:id/rating` - Create or update ebook rating
- `PUT /api/ebooks/:id/rating` - Create or update ebook rating
- `DELETE /api/ebooks/:id/rating` - Delete user's rating
- `GET /api/ebooks/:id/ratings` - Get all ratings for ebook
## API Testing ## API Testing
Use the included Bruno collection in the `bruno/` directory for testing the API: Use the included Bruno collection in the `bruno/` directory for testing the API:
+16
View File
@@ -8,6 +8,15 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
type EbookRatings struct {
ID pgtype.UUID `db:"id" json:"id"`
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Rating int32 `db:"rating" json:"rating"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type Ebooks struct { type Ebooks struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"` Title string `db:"title" json:"title"`
@@ -20,6 +29,13 @@ type Ebooks struct {
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags pgtype.Text `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors pgtype.Text `db:"contributors" json:"contributors"`
} }
type ReadingProgress struct { type ReadingProgress struct {
+5
View File
@@ -12,10 +12,14 @@ import (
type Querier interface { type Querier interface {
CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebooks, error) CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebooks, error)
CreateEbookRating(ctx context.Context, arg CreateEbookRatingParams) (EbookRatings, error)
CreateUser(ctx context.Context, arg CreateUserParams) (Users, error) CreateUser(ctx context.Context, arg CreateUserParams) (Users, error)
DeleteEbook(ctx context.Context, id pgtype.UUID) error DeleteEbook(ctx context.Context, id pgtype.UUID) error
DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error) GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
GetEbookRating(ctx context.Context, arg GetEbookRatingParams) (EbookRatings, error)
GetEbookRatings(ctx context.Context, ebookID pgtype.UUID) ([]GetEbookRatingsRow, error)
GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error) GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
GetUserByEmail(ctx context.Context, email string) (Users, error) GetUserByEmail(ctx context.Context, email string) (Users, error)
@@ -24,6 +28,7 @@ type Querier interface {
ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error) ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error)
UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error)
UpdateEbookRating(ctx context.Context, arg UpdateEbookRatingParams) (EbookRatings, error)
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error) UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
} }
+210 -6
View File
@@ -12,9 +12,9 @@ import (
) )
const CreateEbook = `-- name: CreateEbook :one const CreateEbook = `-- name: CreateEbook :one
INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path) INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at RETURNING 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
` `
type CreateEbookParams struct { type CreateEbookParams struct {
@@ -26,6 +26,13 @@ type CreateEbookParams struct {
FileSize pgtype.Int8 `db:"file_size" json:"file_size"` FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"` MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags pgtype.Text `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors pgtype.Text `db:"contributors" json:"contributors"`
} }
func (q *Queries) CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebooks, error) { func (q *Queries) CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebooks, error) {
@@ -38,6 +45,13 @@ func (q *Queries) CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebook
arg.FileSize, arg.FileSize,
arg.MimeType, arg.MimeType,
arg.CoverImagePath, arg.CoverImagePath,
arg.Series,
arg.SeriesNumber,
arg.Tags,
arg.Asin,
arg.DatePublished,
arg.Publisher,
arg.Contributors,
) )
var i Ebooks var i Ebooks
err := row.Scan( err := row.Scan(
@@ -52,6 +66,43 @@ func (q *Queries) CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebook
&i.CoverImagePath, &i.CoverImagePath,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
)
return i, err
}
const CreateEbookRating = `-- name: CreateEbookRating :one
INSERT INTO ebook_ratings (ebook_id, user_id, rating)
VALUES ($1, $2, $3)
ON CONFLICT (ebook_id, user_id)
DO UPDATE SET
rating = EXCLUDED.rating,
updated_at = NOW()
RETURNING id, ebook_id, user_id, rating, created_at, updated_at
`
type CreateEbookRatingParams struct {
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Rating int32 `db:"rating" json:"rating"`
}
func (q *Queries) CreateEbookRating(ctx context.Context, arg CreateEbookRatingParams) (EbookRatings, error) {
row := q.db.QueryRow(ctx, CreateEbookRating, arg.EbookID, arg.UserID, arg.Rating)
var i EbookRatings
err := row.Scan(
&i.ID,
&i.EbookID,
&i.UserID,
&i.Rating,
&i.CreatedAt,
&i.UpdatedAt,
) )
return i, err return i, err
} }
@@ -98,6 +149,20 @@ func (q *Queries) DeleteEbook(ctx context.Context, id pgtype.UUID) error {
return err return err
} }
const DeleteEbookRating = `-- name: DeleteEbookRating :exec
DELETE FROM ebook_ratings WHERE ebook_id = $1 AND user_id = $2
`
type DeleteEbookRatingParams struct {
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error {
_, err := q.db.Exec(ctx, DeleteEbookRating, arg.EbookID, arg.UserID)
return err
}
const DeleteReadingProgress = `-- name: DeleteReadingProgress :exec const DeleteReadingProgress = `-- name: DeleteReadingProgress :exec
DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2 DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2
` `
@@ -113,7 +178,7 @@ func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingPr
} }
const GetEbook = `-- name: GetEbook :one const GetEbook = `-- name: GetEbook :one
SELECT id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at FROM ebooks WHERE id = $1 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 WHERE id = $1
` `
func (q *Queries) GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error) { func (q *Queries) GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error) {
@@ -131,10 +196,86 @@ func (q *Queries) GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
&i.CoverImagePath, &i.CoverImagePath,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
) )
return i, err return i, err
} }
const GetEbookRating = `-- name: GetEbookRating :one
SELECT id, ebook_id, user_id, rating, created_at, updated_at FROM ebook_ratings WHERE ebook_id = $1 AND user_id = $2
`
type GetEbookRatingParams struct {
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) GetEbookRating(ctx context.Context, arg GetEbookRatingParams) (EbookRatings, error) {
row := q.db.QueryRow(ctx, GetEbookRating, arg.EbookID, arg.UserID)
var i EbookRatings
err := row.Scan(
&i.ID,
&i.EbookID,
&i.UserID,
&i.Rating,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetEbookRatings = `-- name: GetEbookRatings :many
SELECT er.id, er.ebook_id, er.user_id, er.rating, er.created_at, er.updated_at, u.username
FROM ebook_ratings er
JOIN users u ON er.user_id = u.id
WHERE er.ebook_id = $1
ORDER BY er.created_at DESC
`
type GetEbookRatingsRow struct {
ID pgtype.UUID `db:"id" json:"id"`
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Rating int32 `db:"rating" json:"rating"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
Username string `db:"username" json:"username"`
}
func (q *Queries) GetEbookRatings(ctx context.Context, ebookID pgtype.UUID) ([]GetEbookRatingsRow, error) {
rows, err := q.db.Query(ctx, GetEbookRatings, ebookID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetEbookRatingsRow{}
for rows.Next() {
var i GetEbookRatingsRow
if err := rows.Scan(
&i.ID,
&i.EbookID,
&i.UserID,
&i.Rating,
&i.CreatedAt,
&i.UpdatedAt,
&i.Username,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetReadingProgress = `-- name: GetReadingProgress :one const GetReadingProgress = `-- name: GetReadingProgress :one
SELECT id, ebook_id, user_id, current_page, total_pages, last_read_at FROM reading_progress WHERE ebook_id = $1 AND user_id = $2 SELECT id, ebook_id, user_id, current_page, total_pages, last_read_at FROM reading_progress WHERE ebook_id = $1 AND user_id = $2
` `
@@ -243,7 +384,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (Users
} }
const ListEbooks = `-- name: ListEbooks :many const ListEbooks = `-- name: ListEbooks :many
SELECT id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2 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
` `
type ListEbooksParams struct { type ListEbooksParams struct {
@@ -272,6 +413,13 @@ func (q *Queries) ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebook
&i.CoverImagePath, &i.CoverImagePath,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -330,9 +478,16 @@ UPDATE ebooks SET
isbn = $4, isbn = $4,
description = $5, description = $5,
cover_image_path = $6, cover_image_path = $6,
series = $7,
series_number = $8,
tags = $9,
asin = $10,
date_published = $11,
publisher = $12,
contributors = $13,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at RETURNING 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
` `
type UpdateEbookParams struct { type UpdateEbookParams struct {
@@ -342,6 +497,13 @@ type UpdateEbookParams struct {
Isbn pgtype.Text `db:"isbn" json:"isbn"` Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"` Description pgtype.Text `db:"description" json:"description"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags pgtype.Text `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors pgtype.Text `db:"contributors" json:"contributors"`
} }
func (q *Queries) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error) { func (q *Queries) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error) {
@@ -352,6 +514,13 @@ func (q *Queries) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebook
arg.Isbn, arg.Isbn,
arg.Description, arg.Description,
arg.CoverImagePath, arg.CoverImagePath,
arg.Series,
arg.SeriesNumber,
arg.Tags,
arg.Asin,
arg.DatePublished,
arg.Publisher,
arg.Contributors,
) )
var i Ebooks var i Ebooks
err := row.Scan( err := row.Scan(
@@ -366,6 +535,41 @@ func (q *Queries) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebook
&i.CoverImagePath, &i.CoverImagePath,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
)
return i, err
}
const UpdateEbookRating = `-- name: UpdateEbookRating :one
UPDATE ebook_ratings SET
rating = $3,
updated_at = NOW()
WHERE ebook_id = $1 AND user_id = $2
RETURNING id, ebook_id, user_id, rating, created_at, updated_at
`
type UpdateEbookRatingParams struct {
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Rating int32 `db:"rating" json:"rating"`
}
func (q *Queries) UpdateEbookRating(ctx context.Context, arg UpdateEbookRatingParams) (EbookRatings, error) {
row := q.db.QueryRow(ctx, UpdateEbookRating, arg.EbookID, arg.UserID, arg.Rating)
var i EbookRatings
err := row.Scan(
&i.ID,
&i.EbookID,
&i.UserID,
&i.Rating,
&i.CreatedAt,
&i.UpdatedAt,
) )
return i, err return i, err
} }
+39 -3
View File
@@ -25,8 +25,8 @@ SELECT * FROM ebooks WHERE id = $1;
SELECT * FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2; SELECT * FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2;
-- name: CreateEbook :one -- name: CreateEbook :one
INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path) INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING *; RETURNING *;
-- name: UpdateEbook :one -- name: UpdateEbook :one
@@ -36,6 +36,13 @@ UPDATE ebooks SET
isbn = $4, isbn = $4,
description = $5, description = $5,
cover_image_path = $6, cover_image_path = $6,
series = $7,
series_number = $8,
tags = $9,
asin = $10,
date_published = $11,
publisher = $12,
contributors = $13,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
@@ -60,4 +67,33 @@ RETURNING *;
DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2; DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2;
-- name: UpdateUserTheme :exec -- name: UpdateUserTheme :exec
UPDATE users SET theme = $2, updated_at = NOW() WHERE id = $1; UPDATE users SET theme = $2, updated_at = NOW() WHERE id = $1;
-- name: CreateEbookRating :one
INSERT INTO ebook_ratings (ebook_id, user_id, rating)
VALUES ($1, $2, $3)
ON CONFLICT (ebook_id, user_id)
DO UPDATE SET
rating = EXCLUDED.rating,
updated_at = NOW()
RETURNING *;
-- name: GetEbookRating :one
SELECT * FROM ebook_ratings WHERE ebook_id = $1 AND user_id = $2;
-- name: GetEbookRatings :many
SELECT er.*, u.username
FROM ebook_ratings er
JOIN users u ON er.user_id = u.id
WHERE er.ebook_id = $1
ORDER BY er.created_at DESC;
-- name: UpdateEbookRating :one
UPDATE ebook_ratings SET
rating = $3,
updated_at = NOW()
WHERE ebook_id = $1 AND user_id = $2
RETURNING *;
-- name: DeleteEbookRating :exec
DELETE FROM ebook_ratings WHERE ebook_id = $1 AND user_id = $2;
+157
View File
@@ -4,6 +4,7 @@ import (
"bookmann/internal/database" "bookmann/internal/database"
"net/http" "net/http"
"strconv" "strconv"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
@@ -20,6 +21,17 @@ func NewHandler(db *database.Queries) *Handler {
} }
} }
// parseDate parses a date string in YYYY-MM-DD format
func parseDate(dateStr string) time.Time {
if dateStr == "" {
return time.Time{}
}
if t, err := time.Parse("2006-01-02", dateStr); err == nil {
return t
}
return time.Time{}
}
func SetupRoutes(g *echo.Group, db *database.Queries) { func SetupRoutes(g *echo.Group, db *database.Queries) {
h := NewHandler(db) h := NewHandler(db)
@@ -31,6 +43,12 @@ func SetupRoutes(g *echo.Group, db *database.Queries) {
g.GET("/ebooks/:id/progress", h.GetReadingProgress) g.GET("/ebooks/:id/progress", h.GetReadingProgress)
g.PUT("/ebooks/:id/progress", h.UpdateReadingProgress) g.PUT("/ebooks/:id/progress", h.UpdateReadingProgress)
g.GET("/ebooks/:id/rating", h.GetEbookRating)
g.POST("/ebooks/:id/rating", h.CreateOrUpdateEbookRating)
g.PUT("/ebooks/:id/rating", h.CreateOrUpdateEbookRating)
g.DELETE("/ebooks/:id/rating", h.DeleteEbookRating)
g.GET("/ebooks/:id/ratings", h.GetEbookRatings)
} }
// ListEbooks handles GET /api/ebooks // ListEbooks handles GET /api/ebooks
@@ -89,6 +107,13 @@ type CreateEbookRequest struct {
FileSize int64 `json:"file_size" validate:"required,min=1"` FileSize int64 `json:"file_size" validate:"required,min=1"`
MimeType string `json:"mime_type" validate:"required"` MimeType string `json:"mime_type" validate:"required"`
CoverImagePath string `json:"cover_image_path"` CoverImagePath string `json:"cover_image_path"`
Series string `json:"series"`
SeriesNumber int32 `json:"series_number"`
Tags string `json:"tags"`
ASIN string `json:"asin"`
DatePublished string `json:"date_published"`
Publisher string `json:"publisher"`
Contributors string `json:"contributors"`
} }
// CreateEbook handles POST /api/ebooks // CreateEbook handles POST /api/ebooks
@@ -111,6 +136,13 @@ func (h *Handler) CreateEbook(c echo.Context) error {
FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0}, FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0},
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""}, MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""}, CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""},
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""},
}) })
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()})
@@ -126,6 +158,13 @@ type UpdateEbookRequest struct {
ISBN string `json:"isbn"` ISBN string `json:"isbn"`
Description string `json:"description"` Description string `json:"description"`
CoverImagePath string `json:"cover_image_path"` CoverImagePath string `json:"cover_image_path"`
Series string `json:"series"`
SeriesNumber int32 `json:"series_number"`
Tags string `json:"tags"`
ASIN string `json:"asin"`
DatePublished string `json:"date_published"`
Publisher string `json:"publisher"`
Contributors string `json:"contributors"`
} }
// UpdateEbook handles PUT /api/ebooks/:id // UpdateEbook handles PUT /api/ebooks/:id
@@ -152,6 +191,13 @@ func (h *Handler) UpdateEbook(c echo.Context) error {
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""}, Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""}, Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""}, CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""},
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""},
}) })
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()})
@@ -250,3 +296,114 @@ func (h *Handler) UpdateReadingProgress(c echo.Context) error {
return c.JSON(http.StatusOK, progress) return c.JSON(http.StatusOK, progress)
} }
// CreateOrUpdateEbookRatingRequest represents the request for creating/updating an ebook rating
type CreateOrUpdateEbookRatingRequest struct {
Rating int32 `json:"rating" validate:"required,min=1,max=5"`
}
// GetEbookRating handles GET /api/ebooks/:id/rating
func (h *Handler) GetEbookRating(c echo.Context) error {
ebookIdStr := c.Param("id")
userID := c.Get("user_id").(string)
ebookId, err := uuid.Parse(ebookIdStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
rating, err := h.db.GetEbookRating(c.Request().Context(), database.GetEbookRatingParams{
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
// If no rating found, return 404
return c.JSON(http.StatusNotFound, map[string]string{"error": "rating not found"})
}
return c.JSON(http.StatusOK, rating)
}
// CreateOrUpdateEbookRating handles POST/PUT /api/ebooks/:id/rating
func (h *Handler) CreateOrUpdateEbookRating(c echo.Context) error {
ebookIdStr := c.Param("id")
userID := c.Get("user_id").(string)
ebookId, err := uuid.Parse(ebookIdStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
var req CreateOrUpdateEbookRatingRequest
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()})
}
rating, err := h.db.CreateEbookRating(c.Request().Context(), database.CreateEbookRatingParams{
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Rating: req.Rating,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, rating)
}
// DeleteEbookRating handles DELETE /api/ebooks/:id/rating
func (h *Handler) DeleteEbookRating(c echo.Context) error {
ebookIdStr := c.Param("id")
userID := c.Get("user_id").(string)
ebookId, err := uuid.Parse(ebookIdStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
err = h.db.DeleteEbookRating(c.Request().Context(), database.DeleteEbookRatingParams{
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// GetEbookRatings handles GET /api/ebooks/:id/ratings
func (h *Handler) GetEbookRatings(c echo.Context) error {
ebookIdStr := c.Param("id")
ebookId, err := uuid.Parse(ebookIdStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
}
ratings, err := h.db.GetEbookRatings(c.Request().Context(), pgtype.UUID{Bytes: ebookId, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, ratings)
}
+14
View File
@@ -0,0 +1,14 @@
-- Add ebook ratings functionality
CREATE TABLE ebook_ratings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ebook_id UUID NOT NULL REFERENCES ebooks(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(ebook_id, user_id)
);
-- Create indexes for better query performance
CREATE INDEX idx_ebook_ratings_ebook_id ON ebook_ratings(ebook_id);
CREATE INDEX idx_ebook_ratings_user_id ON ebook_ratings(user_id);
@@ -0,0 +1,8 @@
-- Add additional metadata fields to ebooks table
ALTER TABLE ebooks ADD COLUMN series VARCHAR(255);
ALTER TABLE ebooks ADD COLUMN series_number INTEGER;
ALTER TABLE ebooks ADD COLUMN tags TEXT;
ALTER TABLE ebooks ADD COLUMN asin VARCHAR(20);
ALTER TABLE ebooks ADD COLUMN date_published DATE;
ALTER TABLE ebooks ADD COLUMN publisher VARCHAR(255);
ALTER TABLE ebooks ADD COLUMN contributors TEXT;
+6
View File
@@ -28,6 +28,12 @@ This directory contains Bruno collection for testing the Bookmann API with compr
- **Get Reading Progress**: GET /api/ebooks/:id/progress - User's progress - **Get Reading Progress**: GET /api/ebooks/:id/progress - User's progress
- **Update Reading Progress**: PUT /api/ebooks/:id/progress - Update progress - **Update Reading Progress**: PUT /api/ebooks/:id/progress - Update progress
### Ratings (Protected - JWT Required)
- **Get Ebook Rating**: GET /api/ebooks/:id/rating - User's rating for ebook
- **Create/Update Rating**: POST /api/ebooks/:id/rating - Rate ebook (1-5 stars)
- **Delete Rating**: DELETE /api/ebooks/:id/rating - Remove user's rating
- **Get All Ratings**: GET /api/ebooks/:id/ratings - All ratings for ebook
## Documentation Features ## Documentation Features
Each request includes: Each request includes:
+24 -10
View File
@@ -19,7 +19,14 @@ body:json {
"file_path": "/uploads/sample.epub", "file_path": "/uploads/sample.epub",
"file_size": 1024000, "file_size": 1024000,
"mime_type": "application/epub+zip", "mime_type": "application/epub+zip",
"cover_image_path": "/uploads/cover.jpg" "cover_image_path": "/uploads/cover.jpg",
"series": "Sample Series",
"series_number": 1,
"tags": "fiction,adventure",
"asin": "B0123456789",
"date_published": "2023-01-15",
"publisher": "Sample Publisher",
"contributors": "Editor Name, Illustrator Name"
} }
} }
@@ -43,15 +50,22 @@ docs {
**Authentication:** Required (Bearer token) **Authentication:** Required (Bearer token)
**Request Body:** **Request Body:**
- `title` (string, required): Book title - `title` (string, required): Book title
- `author` (string, optional): Book author - `author` (string, optional): Book author
- `isbn` (string, optional): ISBN number - `isbn` (string, optional): ISBN number
- `description` (string, optional): Book description - `description` (string, optional): Book description
- `file_path` (string, required): Path to ebook file - `file_path` (string, required): Path to ebook file
- `file_size` (number, optional): File size in bytes - `file_size` (number, optional): File size in bytes
- `mime_type` (string, optional): MIME type - `mime_type` (string, optional): MIME type
- `cover_image_path` (string, optional): Path to cover image - `cover_image_path` (string, optional): Path to cover image
- `series` (string, optional): Book series name
- `series_number` (number, optional): Position in series
- `tags` (string, optional): Comma-separated tags
- `asin` (string, optional): Amazon ASIN
- `date_published` (string, optional): Publication date (YYYY-MM-DD)
- `publisher` (string, optional): Publisher name
- `contributors` (string, optional): Additional contributors
**Response:** Created ebook object **Response:** Created ebook object
@@ -0,0 +1,49 @@
meta {
name: Create/Update Ebook Rating
type: http
seq: 7
}
post {
url: http://localhost:8765/api/ebooks/{{ebookid}}/rating
body: json
auth: inherit
}
body: json {
{
"rating": 4
}
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Create/Update Ebook Rating
Creates or updates the authenticated user's rating for a specific ebook.
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `id` (string): Ebook UUID
**Request Body:**
- `rating` (number, required): Rating value (1-5)
**Response:**
- `id` (string): Rating UUID
- `ebook_id` (string): Ebook UUID
- `user_id` (string): User UUID
- `rating` (number): Rating value (1-5)
- `created_at` (string): Creation timestamp
- `updated_at` (string): Last update timestamp
**Error Responses:**
- 400: Invalid rating value
- 401: Invalid authentication
- 404: Ebook not found
}
+33
View File
@@ -0,0 +1,33 @@
meta {
name: Delete Ebook Rating
type: http
seq: 8
}
delete {
url: http://localhost:8765/api/ebooks/{{ebookid}}/rating
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Delete Ebook Rating
Deletes the authenticated user's rating for a specific ebook.
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `id` (string): Ebook UUID
**Response:** No content (204)
**Error Responses:**
- 401: Invalid authentication
- 404: Rating not found
}
+39
View File
@@ -0,0 +1,39 @@
meta {
name: Get Ebook Rating
type: http
seq: 6
}
get {
url: http://localhost:8765/api/ebooks/{{ebookid}}/rating
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Get Ebook Rating
Retrieves the authenticated user's rating for a specific ebook.
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `id` (string): Ebook UUID
**Response:**
- `id` (string): Rating UUID
- `ebook_id` (string): Ebook UUID
- `user_id` (string): User UUID
- `rating` (number): Rating value (1-5)
- `created_at` (string): Creation timestamp
- `updated_at` (string): Last update timestamp
**Error Responses:**
- 401: Invalid authentication
- 404: Rating not found
}
+40
View File
@@ -0,0 +1,40 @@
meta {
name: Get Ebook Ratings
type: http
seq: 9
}
get {
url: http://localhost:8765/api/ebooks/{{ebookid}}/ratings
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Get Ebook Ratings
Retrieves all ratings for a specific ebook, including usernames.
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `id` (string): Ebook UUID
**Response:** Array of rating objects
- `id` (string): Rating UUID
- `ebook_id` (string): Ebook UUID
- `user_id` (string): User UUID
- `rating` (number): Rating value (1-5)
- `created_at` (string): Creation timestamp
- `updated_at` (string): Last update timestamp
- `username` (string): Username of the rating user
**Error Responses:**
- 401: Invalid authentication
- 404: Ebook not found
}
+21 -7
View File
@@ -14,7 +14,14 @@ body:json {
{ {
"title": "Updated Book Title", "title": "Updated Book Title",
"author": "Updated Author", "author": "Updated Author",
"description": "Updated description" "description": "Updated description",
"series": "Updated Series",
"series_number": 2,
"tags": "fiction,drama",
"asin": "B0987654321",
"date_published": "2023-06-15",
"publisher": "Updated Publisher",
"contributors": "Updated Editor"
} }
} }
@@ -33,12 +40,19 @@ docs {
**Path Parameters:** **Path Parameters:**
- `id` (string): Ebook UUID - `id` (string): Ebook UUID
**Request Body:** (all fields optional) **Request Body:** (all fields optional)
- `title` (string): Book title - `title` (string): Book title
- `author` (string): Book author - `author` (string): Book author
- `isbn` (string): ISBN number - `isbn` (string): ISBN number
- `description` (string): Book description - `description` (string): Book description
- `cover_image_path` (string): Path to cover image - `cover_image_path` (string): Path to cover image
- `series` (string): Book series name
- `series_number` (number): Position in series
- `tags` (string): Comma-separated tags
- `asin` (string): Amazon ASIN
- `date_published` (string): Publication date (YYYY-MM-DD)
- `publisher` (string): Publisher name
- `contributors` (string): Additional contributors
**Response:** Updated ebook object **Response:** Updated ebook object