added ratings and updated ebook struct
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user