From e5ca12117cb3047a28797ee8ee9d2707212afaeb Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 22 Jan 2026 20:54:39 -0500 Subject: [PATCH] added ratings and updated ebook struct --- README.md | 7 + backend/internal/database/models.go | 16 ++ backend/internal/database/querier.go | 5 + backend/internal/database/queries.sql.go | 216 +++++++++++++++++- backend/internal/database/queries/queries.sql | 42 +++- backend/internal/handlers/ebook.go | 157 +++++++++++++ backend/migrations/002_add_ratings.up.sql | 14 ++ .../003_add_ebook_metadata_fields.up.sql | 8 + bruno/README.md | 6 + bruno/ebooks/Create Ebook.bru | 34 ++- bruno/ebooks/Create-Update Ebook Rating.bru | 49 ++++ bruno/ebooks/Delete Ebook Rating.bru | 33 +++ bruno/ebooks/Get Ebook Rating.bru | 39 ++++ bruno/ebooks/Get Ebook Ratings.bru | 40 ++++ bruno/ebooks/Update Ebook.bru | 28 ++- 15 files changed, 668 insertions(+), 26 deletions(-) create mode 100644 backend/migrations/002_add_ratings.up.sql create mode 100644 backend/migrations/003_add_ebook_metadata_fields.up.sql create mode 100644 bruno/ebooks/Create-Update Ebook Rating.bru create mode 100644 bruno/ebooks/Delete Ebook Rating.bru create mode 100644 bruno/ebooks/Get Ebook Rating.bru create mode 100644 bruno/ebooks/Get Ebook Ratings.bru diff --git a/README.md b/README.md index cb4475c..7288029 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/backend/internal/database/models.go b/backend/internal/database/models.go index 8200527..1ebfcbf 100644 --- a/backend/internal/database/models.go +++ b/backend/internal/database/models.go @@ -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 { diff --git a/backend/internal/database/querier.go b/backend/internal/database/querier.go index 0dd6a6b..ed819ce 100644 --- a/backend/internal/database/querier.go +++ b/backend/internal/database/querier.go @@ -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 } diff --git a/backend/internal/database/queries.sql.go b/backend/internal/database/queries.sql.go index 6b9122e..c5d2c8e 100644 --- a/backend/internal/database/queries.sql.go +++ b/backend/internal/database/queries.sql.go @@ -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 } diff --git a/backend/internal/database/queries/queries.sql b/backend/internal/database/queries/queries.sql index 3ed4c93..7811837 100644 --- a/backend/internal/database/queries/queries.sql +++ b/backend/internal/database/queries/queries.sql @@ -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; \ No newline at end of file +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; \ No newline at end of file diff --git a/backend/internal/handlers/ebook.go b/backend/internal/handlers/ebook.go index 03dfc2f..008053f 100644 --- a/backend/internal/handlers/ebook.go +++ b/backend/internal/handlers/ebook.go @@ -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) +} diff --git a/backend/migrations/002_add_ratings.up.sql b/backend/migrations/002_add_ratings.up.sql new file mode 100644 index 0000000..a966dda --- /dev/null +++ b/backend/migrations/002_add_ratings.up.sql @@ -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); \ No newline at end of file diff --git a/backend/migrations/003_add_ebook_metadata_fields.up.sql b/backend/migrations/003_add_ebook_metadata_fields.up.sql new file mode 100644 index 0000000..d5bdbaf --- /dev/null +++ b/backend/migrations/003_add_ebook_metadata_fields.up.sql @@ -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; \ No newline at end of file diff --git a/bruno/README.md b/bruno/README.md index 6feec72..f68840f 100644 --- a/bruno/README.md +++ b/bruno/README.md @@ -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: diff --git a/bruno/ebooks/Create Ebook.bru b/bruno/ebooks/Create Ebook.bru index 9b4db60..509a013 100644 --- a/bruno/ebooks/Create Ebook.bru +++ b/bruno/ebooks/Create Ebook.bru @@ -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 diff --git a/bruno/ebooks/Create-Update Ebook Rating.bru b/bruno/ebooks/Create-Update Ebook Rating.bru new file mode 100644 index 0000000..92df6e3 --- /dev/null +++ b/bruno/ebooks/Create-Update Ebook Rating.bru @@ -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 +} \ No newline at end of file diff --git a/bruno/ebooks/Delete Ebook Rating.bru b/bruno/ebooks/Delete Ebook Rating.bru new file mode 100644 index 0000000..4ec828b --- /dev/null +++ b/bruno/ebooks/Delete Ebook Rating.bru @@ -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 +} \ No newline at end of file diff --git a/bruno/ebooks/Get Ebook Rating.bru b/bruno/ebooks/Get Ebook Rating.bru new file mode 100644 index 0000000..0bf59e1 --- /dev/null +++ b/bruno/ebooks/Get Ebook Rating.bru @@ -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 +} \ No newline at end of file diff --git a/bruno/ebooks/Get Ebook Ratings.bru b/bruno/ebooks/Get Ebook Ratings.bru new file mode 100644 index 0000000..e853ef2 --- /dev/null +++ b/bruno/ebooks/Get Ebook Ratings.bru @@ -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 +} \ No newline at end of file diff --git a/bruno/ebooks/Update Ebook.bru b/bruno/ebooks/Update Ebook.bru index 2293b86..a99dda3 100644 --- a/bruno/ebooks/Update Ebook.bru +++ b/bruno/ebooks/Update Ebook.bru @@ -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