feat(web): book reviews — markdown, sibling of ratings, full API + page surface

media_reviews table mirrors media_ratings' shape (unique per user+item,
cascade deletes, updated_at for LWW) but standalone: a review without a
rating must work. Body is RAW MARKDOWN — storage and every payload carry
the source; rendering happens only at display sites (same doctrine as
notes, same sanitization pipeline: markdown-it + DOMPurify).

API: GET/PUT/DELETE /api/media-items/:id/review (PUT upserts; an empty
body deletes — clearing via an editor is a delete, not an empty row).
Book-detail page payload now carries the review beside the rating.

Page: the review renders above the synopsis as a scrolling card (same
max-height box as the summary), hydrated client-side through
renderNoteHtml; 'Write review'/'Edit review' sits next to the rating
stars and opens a raw-markdown editor dialog (save / delete / cancel).

sqlc regenerated; schema applied to the dev DB. Live-verified: upsert,
get, empty-delete, page markup, and the app round-trip against the dev
server.
This commit is contained in:
John O'Keefe committed 2026-10-15 10:53:53 -04:00
1 parent fd6f0e3353
commit 2117ec6181
13 files changed
+751 -352

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.0
// sqlc v1.31.1
package database
+10 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.0
// sqlc v1.31.1
package database
@@ -352,6 +352,15 @@ type MediaRatings struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type MediaReviews struct {
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Review string `db:"review" json:"review"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type OpdsTokens struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
+4 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.0
// sqlc v1.31.1
package database
@@ -142,6 +142,7 @@ type Querier interface {
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
DeleteMediaNote(ctx context.Context, arg DeleteMediaNoteParams) error
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
@@ -299,6 +300,7 @@ type Querier interface {
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
GetMediaReview(ctx context.Context, arg GetMediaReviewParams) (MediaReviews, error)
GetNextRetryTime(ctx context.Context) (interface{}, error)
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
// Get OPDS token
@@ -573,6 +575,7 @@ type Querier interface {
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error)
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
+68 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.0
// sqlc v1.31.1
// source: queries.sql
package database
@@ -2262,6 +2262,20 @@ func (q *Queries) DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingPa
return err
}
const DeleteMediaReview = `-- name: DeleteMediaReview :exec
DELETE FROM media_reviews WHERE media_item_id = $1 AND user_id = $2
`
type DeleteMediaReviewParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error {
_, err := q.db.Exec(ctx, DeleteMediaReview, arg.MediaItemID, arg.UserID)
return err
}
const DeleteProcessingIssue = `-- name: DeleteProcessingIssue :one
DELETE FROM processing_issues
WHERE id = $1
@@ -6518,6 +6532,29 @@ func (q *Queries) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID)
return items, nil
}
const GetMediaReview = `-- name: GetMediaReview :one
SELECT id, media_item_id, user_id, review, created_at, updated_at FROM media_reviews WHERE media_item_id = $1 AND user_id = $2
`
type GetMediaReviewParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) GetMediaReview(ctx context.Context, arg GetMediaReviewParams) (MediaReviews, error) {
row := q.db.QueryRow(ctx, GetMediaReview, arg.MediaItemID, arg.UserID)
var i MediaReviews
err := row.Scan(
&i.ID,
&i.MediaItemID,
&i.UserID,
&i.Review,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetNextRetryTime = `-- name: GetNextRetryTime :one
SELECT
CASE
@@ -14976,6 +15013,36 @@ func (q *Queries) UpsertGlobalDashboardPreferences(ctx context.Context, arg Upse
return i, err
}
const UpsertMediaReview = `-- name: UpsertMediaReview :one
INSERT INTO media_reviews (media_item_id, user_id, review)
VALUES ($1, $2, $3)
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
review = EXCLUDED.review,
updated_at = NOW()
RETURNING id, media_item_id, user_id, review, created_at, updated_at
`
type UpsertMediaReviewParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Review string `db:"review" json:"review"`
}
func (q *Queries) UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error) {
row := q.db.QueryRow(ctx, UpsertMediaReview, arg.MediaItemID, arg.UserID, arg.Review)
var i MediaReviews
err := row.Scan(
&i.ID,
&i.MediaItemID,
&i.UserID,
&i.Review,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const UpsertPanelData = `-- name: UpsertPanelData :one
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
VALUES ($1, $2, $3, $4)
+15
View File
@@ -550,6 +550,21 @@ RETURNING *;
-- name: DeleteMediaRating :exec
DELETE FROM media_ratings WHERE media_item_id = $1 AND user_id = $2;
-- name: UpsertMediaReview :one
INSERT INTO media_reviews (media_item_id, user_id, review)
VALUES ($1, $2, $3)
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
review = EXCLUDED.review,
updated_at = NOW()
RETURNING *;
-- name: GetMediaReview :one
SELECT * FROM media_reviews WHERE media_item_id = $1 AND user_id = $2;
-- name: DeleteMediaReview :exec
DELETE FROM media_reviews WHERE media_item_id = $1 AND user_id = $2;
-- Search Media Items queries
-- name: SearchMediaItems :many
SELECT mi.*, l.name as library_name, lt.name as library_type_name
+105
View File
@@ -1012,6 +1012,111 @@ func (mh *MediaHandler) DeleteMediaRating(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "rating deleted"})
}
// UpsertMediaReview handles POST and PUT /api/media-items/:id/review.
// The review body is RAW MARKDOWN (same doctrine as notes): storage and
// every sync payload carry the source string; rendering happens only at
// display sites. An empty body deletes the review — clearing via the
// editor is a delete, not an empty row.
func (mh *MediaHandler) UpsertMediaReview(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req struct {
Review string `json:"review" validate:"max=50000"`
}
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()})
}
trimmed := strings.TrimSpace(req.Review)
if trimmed == "" {
err := mh.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, 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.JSON(http.StatusOK, map[string]interface{}{"review": nil})
}
review, err := mh.db.UpsertMediaReview(c.Request().Context(), database.UpsertMediaReviewParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Review: req.Review,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, review)
}
// GetMediaReview handles GET /api/media-items/:id/review
func (mh *MediaHandler) GetMediaReview(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
review, err := mh.db.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]interface{}{"review": nil})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, review)
}
// DeleteMediaReview handles DELETE /api/media-items/:id/review
func (mh *MediaHandler) DeleteMediaReview(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
err = mh.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, 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.JSON(http.StatusOK, map[string]string{"message": "review deleted"})
}
// GetMediaReadingProgress handles GET /api/media-items/:id/progress
func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
userID := c.Get("user_id").(string)
+1
View File
@@ -13,6 +13,7 @@ type MediaDetail struct {
// User-specific data
Rating *database.MediaRatings `json:"rating,omitempty"`
Review *database.MediaReviews `json:"review,omitempty"` // raw markdown
Collections []database.Collections `json:"collections"`
ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"`
+12
View File
@@ -1603,6 +1603,17 @@ func registerFrontendRoutes(cfg *Config) {
rating = &userRating
}
// Fetch the user's review (raw markdown; rendered client-side
// through the notes pipeline).
var review *database.MediaReviews
userReview, err := cfg.Queries.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
})
if err == nil {
review = &userReview
}
// Fetch collections
collections, _ := cfg.Queries.GetCollectionsForBook(c.Request().Context(), pgMediaUUID)
@@ -1676,6 +1687,7 @@ func registerFrontendRoutes(cfg *Config) {
MediaItems: mediaItem, // Embedded - ALL fields available
FileLocation: fileLocation,
Rating: rating,
Review: review,
Collections: collections,
ReadingProgress: progress,
BookStats: bookStats,
+7
View File
@@ -25,6 +25,13 @@ func registerMediaRoutes(cfg *Config) {
protected.PUT("/media-items/:id/rating", cfg.MediaHandler.UpdateMediaRating)
protected.DELETE("/media-items/:id/rating", cfg.MediaHandler.DeleteMediaRating)
// Media review routes (all authenticated users). Raw markdown in,
// raw markdown out — rendered only at display sites.
protected.POST("/media-items/:id/review", cfg.MediaHandler.UpsertMediaReview)
protected.GET("/media-items/:id/review", cfg.MediaHandler.GetMediaReview)
protected.PUT("/media-items/:id/review", cfg.MediaHandler.UpsertMediaReview)
protected.DELETE("/media-items/:id/review", cfg.MediaHandler.DeleteMediaReview)
// Progress routes (all authenticated users)
protected.GET("/media-items/:id/progress", cfg.MediaHandler.GetMediaReadingProgress)
protected.PUT("/media-items/:id/progress", cfg.MediaHandler.UpdateMediaReadingProgress)