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
- `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
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"
)
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 {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
@@ -20,6 +29,13 @@ type Ebooks struct {
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_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 {
+5
View File
@@ -12,10 +12,14 @@ import (
type Querier interface {
CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebooks, error)
CreateEbookRating(ctx context.Context, arg CreateEbookRatingParams) (EbookRatings, error)
CreateUser(ctx context.Context, arg CreateUserParams) (Users, error)
DeleteEbook(ctx context.Context, id pgtype.UUID) error
DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) 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)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
GetUserByEmail(ctx context.Context, email string) (Users, error)
@@ -24,6 +28,7 @@ type Querier interface {
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)
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
}
+210 -6
View File
@@ -12,9 +12,9 @@ import (
)
const CreateEbook = `-- name: CreateEbook :one
INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at
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, $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, series, series_number, tags, asin, date_published, publisher, contributors
`
type CreateEbookParams struct {
@@ -26,6 +26,13 @@ type CreateEbookParams struct {
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
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) {
@@ -38,6 +45,13 @@ func (q *Queries) CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebook
arg.FileSize,
arg.MimeType,
arg.CoverImagePath,
arg.Series,
arg.SeriesNumber,
arg.Tags,
arg.Asin,
arg.DatePublished,
arg.Publisher,
arg.Contributors,
)
var i Ebooks
err := row.Scan(
@@ -52,6 +66,43 @@ func (q *Queries) CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebook
&i.CoverImagePath,
&i.CreatedAt,
&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
}
@@ -98,6 +149,20 @@ func (q *Queries) DeleteEbook(ctx context.Context, id pgtype.UUID) error {
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
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
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) {
@@ -131,10 +196,86 @@ func (q *Queries) GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
&i.CoverImagePath,
&i.CreatedAt,
&i.UpdatedAt,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
)
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
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
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 {
@@ -272,6 +413,13 @@ func (q *Queries) ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebook
&i.CoverImagePath,
&i.CreatedAt,
&i.UpdatedAt,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
); err != nil {
return nil, err
}
@@ -330,9 +478,16 @@ UPDATE ebooks SET
isbn = $4,
description = $5,
cover_image_path = $6,
series = $7,
series_number = $8,
tags = $9,
asin = $10,
date_published = $11,
publisher = $12,
contributors = $13,
updated_at = NOW()
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 {
@@ -342,6 +497,13 @@ type UpdateEbookParams struct {
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
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) {
@@ -352,6 +514,13 @@ func (q *Queries) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebook
arg.Isbn,
arg.Description,
arg.CoverImagePath,
arg.Series,
arg.SeriesNumber,
arg.Tags,
arg.Asin,
arg.DatePublished,
arg.Publisher,
arg.Contributors,
)
var i Ebooks
err := row.Scan(
@@ -366,6 +535,41 @@ func (q *Queries) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebook
&i.CoverImagePath,
&i.CreatedAt,
&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
}
+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;
-- name: CreateEbook :one
INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
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, $9, $10, $11, $12, $13, $14, $15)
RETURNING *;
-- name: UpdateEbook :one
@@ -36,6 +36,13 @@ UPDATE ebooks SET
isbn = $4,
description = $5,
cover_image_path = $6,
series = $7,
series_number = $8,
tags = $9,
asin = $10,
date_published = $11,
publisher = $12,
contributors = $13,
updated_at = NOW()
WHERE id = $1
RETURNING *;
@@ -60,4 +67,33 @@ RETURNING *;
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;
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"
"net/http"
"strconv"
"time"
"github.com/google/uuid"
"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) {
h := NewHandler(db)
@@ -31,6 +43,12 @@ func SetupRoutes(g *echo.Group, db *database.Queries) {
g.GET("/ebooks/:id/progress", h.GetReadingProgress)
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
@@ -89,6 +107,13 @@ type CreateEbookRequest struct {
FileSize int64 `json:"file_size" validate:"required,min=1"`
MimeType string `json:"mime_type" validate:"required"`
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
@@ -111,6 +136,13 @@ func (h *Handler) CreateEbook(c echo.Context) error {
FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0},
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
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 {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -126,6 +158,13 @@ type UpdateEbookRequest struct {
ISBN string `json:"isbn"`
Description string `json:"description"`
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
@@ -152,6 +191,13 @@ func (h *Handler) UpdateEbook(c echo.Context) error {
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
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 {
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)
}
// 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
- **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
Each request includes:
+24 -10
View File
@@ -19,7 +19,14 @@ body:json {
"file_path": "/uploads/sample.epub",
"file_size": 1024000,
"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)
**Request Body:**
- `title` (string, required): Book title
- `author` (string, optional): Book author
- `isbn` (string, optional): ISBN number
- `description` (string, optional): Book description
- `file_path` (string, required): Path to ebook file
- `file_size` (number, optional): File size in bytes
- `mime_type` (string, optional): MIME type
- `cover_image_path` (string, optional): Path to cover image
**Request Body:**
- `title` (string, required): Book title
- `author` (string, optional): Book author
- `isbn` (string, optional): ISBN number
- `description` (string, optional): Book description
- `file_path` (string, required): Path to ebook file
- `file_size` (number, optional): File size in bytes
- `mime_type` (string, optional): MIME type
- `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
@@ -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",
"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:**
- `id` (string): Ebook UUID
**Request Body:** (all fields optional)
- `title` (string): Book title
- `author` (string): Book author
- `isbn` (string): ISBN number
- `description` (string): Book description
- `cover_image_path` (string): Path to cover image
**Request Body:** (all fields optional)
- `title` (string): Book title
- `author` (string): Book author
- `isbn` (string): ISBN number
- `description` (string): Book description
- `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