Files
bookhoard/internal/handlers/ebook.go
T
john-okeefe 7f8b898105 fix: add pagination limits and validation
- Enforce maximum pagination limit of 1000 items per request
- Prevent negative offset values in pagination
- Apply limits to both /api/ebooks and /api/media-items endpoints
- Protect against DoS attacks from large limit values

Fixes security issue: No maximum pagination limit
2026-01-29 09:23:33 -05:00

1442 lines
49 KiB
Go

package handlers
import (
"bookmann/internal/database"
"bookmann/internal/services"
"context"
"net/http"
"strconv"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
const (
maxPaginationLimit = 1000
)
type Handler struct {
db *database.Queries
scanner *services.EbookScanner
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
}
func NewHandler(db *database.Queries) *Handler {
ctx, cancel := context.WithCancel(context.Background())
return &Handler{
db: db,
scanner: services.NewEbookScanner(db),
ctx: ctx,
cancel: cancel,
}
}
// 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)
// Public routes (all authenticated users)
g.GET("/ebooks", h.ListEbooks)
g.GET("/ebooks/:id", h.GetEbook)
// User-specific routes
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)
// Ebook notes routes (backward compatibility)
g.GET("/ebooks/:id/notes", h.GetEbookNotes)
g.POST("/ebooks/:id/notes", h.CreateEbookNote)
g.GET("/ebooks/:id/notes/:noteId", h.GetEbookNote)
g.PUT("/ebooks/:id/notes/:noteId", h.UpdateEbookNote)
g.DELETE("/ebooks/:id/notes/:noteId", h.DeleteEbookNote)
// Ebook highlights routes (backward compatibility)
g.GET("/ebooks/:id/highlights", h.GetEbookHighlights)
g.POST("/ebooks/:id/highlights", h.CreateEbookHighlight)
g.GET("/ebooks/:id/highlights/:highlightId", h.GetEbookHighlight)
g.PUT("/ebooks/:id/highlights/:highlightId", h.UpdateEbookHighlight)
g.DELETE("/ebooks/:id/highlights/:highlightId", h.DeleteEbookHighlight)
// Media item routes (new library system)
g.GET("/media-items", h.ListMediaItems)
g.GET("/media-items/:id", h.GetMediaItem)
g.POST("/media-items/:id/rating", h.CreateMediaRating)
g.GET("/media-items/:id/rating", h.GetMediaRating)
g.PUT("/media-items/:id/rating", h.UpdateMediaRating)
g.DELETE("/media-items/:id/rating", h.DeleteMediaRating)
g.GET("/media-items/:id/progress", h.GetMediaReadingProgress)
g.PUT("/media-items/:id/progress", h.UpdateMediaReadingProgress)
g.DELETE("/media-items/:id/progress", h.DeleteMediaReadingProgress)
// Notes routes
g.GET("/media-items/:id/notes", h.GetMediaNotes)
g.POST("/media-items/:id/notes", h.CreateMediaNote)
g.GET("/media-items/:id/notes/:noteId", h.GetMediaNote)
g.PUT("/media-items/:id/notes/:noteId", h.UpdateMediaNote)
g.DELETE("/media-items/:id/notes/:noteId", h.DeleteMediaNote)
// Highlights routes
g.GET("/media-items/:id/highlights", h.GetMediaHighlights)
g.POST("/media-items/:id/highlights", h.CreateMediaHighlight)
g.GET("/media-items/:id/highlights/:highlightId", h.GetMediaHighlight)
g.PUT("/media-items/:id/highlights/:highlightId", h.UpdateMediaHighlight)
g.DELETE("/media-items/:id/highlights/:highlightId", h.DeleteMediaHighlight)
// Admin-only routes
admin := g.Group("", AdminMiddleware)
admin.POST("/ebooks", h.CreateEbook)
admin.PUT("/ebooks/:id", h.UpdateEbook)
admin.DELETE("/ebooks/:id", h.DeleteEbook)
// Scanner routes (admin only)
admin.POST("/scanner/scan", h.ScanEbooks)
admin.POST("/scanner/start", h.StartScanner)
admin.POST("/scanner/stop", h.StopScanner)
}
// ListEbooks handles GET /api/ebooks
func (h *Handler) ListEbooks(c echo.Context) error {
limitStr := c.QueryParam("limit")
offsetStr := c.QueryParam("offset")
limit := int32(20) // default
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
limit = int32(l)
// Enforce maximum limit
if limit > maxPaginationLimit {
limit = maxPaginationLimit
}
}
}
offset := int32(0)
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil {
if o < 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "offset cannot be negative"})
}
offset = int32(o)
}
}
ebooks, err := h.db.ListEbooks(c.Request().Context(), database.ListEbooksParams{
Limit: limit,
Offset: offset,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, ebooks)
}
// GetEbook handles GET /api/ebooks/:id
func (h *Handler) GetEbook(c echo.Context) error {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
ebook, err := h.db.GetEbook(c.Request().Context(), pgtype.UUID{Bytes: id, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "ebook not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, ebook)
}
// CreateEbookRequest represents the request for creating an ebook
type CreateEbookRequest struct {
Title string `json:"title" validate:"required,min=1,max=500"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Description string `json:"description"`
FilePath string `json:"file_path" validate:"required"`
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
func (h *Handler) CreateEbook(c echo.Context) error {
var req CreateEbookRequest
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()})
}
// Get admin ID from JWT token
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 id"})
}
ebook, err := h.db.CreateEbook(c.Request().Context(), database.CreateEbookParams{
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
FilePath: req.FilePath,
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 != ""},
AddedByAdminID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, ebook)
}
// UpdateEbookRequest represents the request for updating an ebook
type UpdateEbookRequest struct {
Title string `json:"title" validate:"required,min=1,max=500"`
Author string `json:"author"`
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
func (h *Handler) UpdateEbook(c echo.Context) error {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
var req UpdateEbookRequest
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()})
}
ebook, err := h.db.UpdateEbook(c.Request().Context(), database.UpdateEbookParams{
ID: pgtype.UUID{Bytes: id, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
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()})
}
return c.JSON(http.StatusOK, ebook)
}
// DeleteEbook handles DELETE /api/ebooks/:id
func (h *Handler) DeleteEbook(c echo.Context) error {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
err = h.db.DeleteEbook(c.Request().Context(), pgtype.UUID{Bytes: id, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// GetReadingProgress handles GET /api/ebooks/:id/progress
func (h *Handler) GetReadingProgress(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"})
}
progress, err := h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: ebookId, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
if err == pgx.ErrNoRows {
// If no progress found, return default
return c.JSON(http.StatusOK, map[string]interface{}{
"ebook_id": ebookIdStr,
"user_id": userID,
"current_page": 0,
"total_pages": nil,
})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, progress)
}
// UpdateReadingProgressRequest represents the request for updating reading progress
type UpdateReadingProgressRequest struct {
CurrentPage int32 `json:"current_page" validate:"required,min=0"`
TotalPages int32 `json:"total_pages" validate:"omitempty,min=1"`
}
// UpdateReadingProgress handles PUT /api/ebooks/:id/progress
func (h *Handler) UpdateReadingProgress(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 UpdateReadingProgressRequest
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()})
}
progress, err := h.db.UpdateReadingProgress(c.Request().Context(), database.UpdateReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: ebookId, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.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=10"`
}
// 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 err == pgx.ErrNoRows {
// If no rating found, return rating 0
return c.JSON(http.StatusOK, map[string]interface{}{
"ebook_id": ebookIdStr,
"user_id": userID,
"rating": 0,
})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
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{
MediaItemID: 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{
MediaItemID: 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)
}
// ScanEbooksRequest represents the request for scanning ebooks
type ScanEbooksRequest struct {
FolderPaths []string `json:"folder_paths,omitempty"`
}
// ScanEbooks handles POST /api/scanner/scan
func (h *Handler) ScanEbooks(c echo.Context) error {
var req ScanEbooksRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
// Get user ID from JWT token
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 id"})
}
var folderPaths []string
// If folder paths provided in request, use them
// Otherwise, use user's saved folders
if len(req.FolderPaths) > 0 {
folderPaths = req.FolderPaths
} else {
// TODO: Replace with library-based folder scanning
// For now, require folder paths in request
return c.JSON(http.StatusBadRequest, map[string]string{"error": "folder_paths required for scanning"})
}
// Set the folder paths for scanning
if err := h.scanner.SetFolders(folderPaths); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
}
// Set the admin ID for ebook association
h.scanner.SetAdminID(pgtype.UUID{Bytes: userUUID, Valid: true})
// Perform the scan
if err := h.scanner.ScanFolders(h.ctx); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "scan failed: " + err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "scan completed"})
}
// StartScanner handles POST /api/scanner/start
func (h *Handler) StartScanner(c echo.Context) error {
h.mu.Lock()
defer h.mu.Unlock()
var req ScanEbooksRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
// Get admin ID from JWT token
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 id"})
}
// Set the folder paths
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
}
// Set the admin ID for ebook association
h.scanner.SetAdminID(pgtype.UUID{Bytes: userUUID, Valid: true})
// Start watching for changes
h.scanner.WatchChanges(h.ctx)
return c.JSON(http.StatusOK, map[string]string{"message": "scanner started"})
}
// StopScanner handles POST /api/scanner/stop
func (h *Handler) StopScanner(c echo.Context) error {
h.mu.Lock()
defer h.mu.Unlock()
h.cancel()
h.ctx, h.cancel = context.WithCancel(context.Background())
return c.JSON(http.StatusOK, map[string]string{"message": "scanner stopped"})
}
// Media Item handlers for new library system
// ListMediaItems handles GET /api/media-items
func (h *Handler) ListMediaItems(c echo.Context) error {
libraryID := c.QueryParam("library_id")
limit, _ := strconv.Atoi(c.QueryParam("limit"))
offset, _ := strconv.Atoi(c.QueryParam("offset"))
if limit == 0 {
limit = 50
}
// Enforce maximum limit
if limit > maxPaginationLimit {
limit = maxPaginationLimit
}
if offset < 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "offset cannot be negative"})
}
if libraryID != "" {
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
}
items, err := h.db.ListMediaItemsByLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, items)
}
items, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, items)
}
// GetMediaItem handles GET /api/media-items/:id
func (h *Handler) GetMediaItem(c echo.Context) error {
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"})
}
item, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, item)
}
// CreateMediaRating handles POST /api/media-items/:id/rating
func (h *Handler) CreateMediaRating(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 {
Rating int32 `json:"rating" validate:"required,min=1,max=10"`
}
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.CreateMediaRating(c.Request().Context(), database.CreateMediaRatingParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, 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.StatusCreated, rating)
}
// GetMediaRating handles GET /api/media-items/:id/rating
func (h *Handler) GetMediaRating(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"})
}
rating, err := h.db.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, rating)
}
// UpdateMediaRating handles PUT /api/media-items/:id/rating
func (h *Handler) UpdateMediaRating(c echo.Context) error {
return h.CreateMediaRating(c) // Same logic as create due to upsert
}
// DeleteMediaRating handles DELETE /api/media-items/:id/rating
func (h *Handler) DeleteMediaRating(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 = h.db.DeleteMediaRating(c.Request().Context(), database.DeleteMediaRatingParams{
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": "rating deleted"})
}
// GetMediaReadingProgress handles GET /api/media-items/:id/progress
func (h *Handler) GetMediaReadingProgress(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"})
}
progress, err := h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusOK, map[string]interface{}{
"current_page": 0,
"total_pages": nil,
})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, progress)
}
// UpdateMediaReadingProgress handles PUT /api/media-items/:id/progress
func (h *Handler) UpdateMediaReadingProgress(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 {
CurrentPage int32 `json:"current_page"`
TotalPages int32 `json:"total_pages"`
}
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()})
}
progress, err := h.db.UpdateReadingProgress(c.Request().Context(), database.UpdateReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, progress)
}
// DeleteMediaReadingProgress handles DELETE /api/media-items/:id/progress
func (h *Handler) DeleteMediaReadingProgress(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 = h.db.DeleteReadingProgress(c.Request().Context(), database.DeleteReadingProgressParams{
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": "reading progress deleted"})
}
// Media Notes handlers
// GetMediaNotes handles GET /api/media-items/:id/notes
func (h *Handler) GetMediaNotes(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"})
}
notes, err := h.db.GetMediaNotes(c.Request().Context(), database.GetMediaNotesParams{
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, notes)
}
// CreateMediaNoteRequest represents the request for creating a media note
type CreateMediaNoteRequest struct {
Content string `json:"content" validate:"required,min=1,max=10000"`
Position string `json:"position"`
}
// CreateMediaNote handles POST /api/media-items/:id/notes
func (h *Handler) CreateMediaNote(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 CreateMediaNoteRequest
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()})
}
note, err := h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Content: req.Content,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, note)
}
// GetMediaNote handles GET /api/media-items/:id/notes/:noteId
func (h *Handler) GetMediaNote(c echo.Context) error {
noteID := c.Param("noteId")
noteUUID, err := uuid.Parse(noteID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
note, err := h.db.GetMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "note not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, note)
}
// UpdateMediaNoteRequest represents the request for updating a media note
type UpdateMediaNoteRequest struct {
Content string `json:"content" validate:"required,min=1,max=10000"`
Position string `json:"position"`
}
// UpdateMediaNote handles PUT /api/media-items/:id/notes/:noteId
func (h *Handler) UpdateMediaNote(c echo.Context) error {
noteID := c.Param("noteId")
noteUUID, err := uuid.Parse(noteID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
var req UpdateMediaNoteRequest
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()})
}
note, err := h.db.UpdateMediaNote(c.Request().Context(), database.UpdateMediaNoteParams{
ID: pgtype.UUID{Bytes: noteUUID, Valid: true},
Content: req.Content,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, note)
}
// DeleteMediaNote handles DELETE /api/media-items/:id/notes/:noteId
func (h *Handler) DeleteMediaNote(c echo.Context) error {
noteID := c.Param("noteId")
noteUUID, err := uuid.Parse(noteID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
err = h.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// Media Highlights handlers
// GetMediaHighlights handles GET /api/media-items/:id/highlights
func (h *Handler) GetMediaHighlights(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"})
}
highlights, err := h.db.GetMediaHighlights(c.Request().Context(), database.GetMediaHighlightsParams{
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, highlights)
}
// CreateMediaHighlightRequest represents the request for creating a media highlight
type CreateMediaHighlightRequest struct {
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
StartPosition string `json:"start_position" validate:"required,max=100"`
EndPosition string `json:"end_position" validate:"required,max=100"`
Color string `json:"color" validate:"omitempty,len=7"` // hex color
NoteID string `json:"note_id"`
}
// CreateMediaHighlight handles POST /api/media-items/:id/highlights
func (h *Handler) CreateMediaHighlight(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 CreateMediaHighlightRequest
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()})
}
var noteUUID pgtype.UUID
if req.NoteID != "" {
if noteID, err := uuid.Parse(req.NoteID); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
} else {
noteUUID = pgtype.UUID{Bytes: noteID, Valid: true}
}
}
color := "#ffff00" // default yellow
if req.Color != "" {
color = req.Color
}
highlight, err := h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
SelectionText: req.SelectionText,
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
Color: pgtype.Text{String: color, Valid: true},
NoteID: noteUUID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, highlight)
}
// GetMediaHighlight handles GET /api/media-items/:id/highlights/:highlightId
func (h *Handler) GetMediaHighlight(c echo.Context) error {
highlightID := c.Param("highlightId")
highlightUUID, err := uuid.Parse(highlightID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
}
highlight, err := h.db.GetMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, highlight)
}
// UpdateMediaHighlightRequest represents the request for updating a media highlight
type UpdateMediaHighlightRequest struct {
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
StartPosition string `json:"start_position" validate:"required,max=100"`
EndPosition string `json:"end_position" validate:"required,max=100"`
Color string `json:"color" validate:"omitempty,len=7"` // hex color
NoteID string `json:"note_id"`
}
// UpdateMediaHighlight handles PUT /api/media-items/:id/highlights/:highlightId
func (h *Handler) UpdateMediaHighlight(c echo.Context) error {
highlightID := c.Param("highlightId")
highlightUUID, err := uuid.Parse(highlightID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
}
var req UpdateMediaHighlightRequest
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()})
}
var noteUUID pgtype.UUID
if req.NoteID != "" {
if noteID, err := uuid.Parse(req.NoteID); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
} else {
noteUUID = pgtype.UUID{Bytes: noteID, Valid: true}
}
}
color := "#ffff00" // default yellow
if req.Color != "" {
color = req.Color
}
highlight, err := h.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
SelectionText: req.SelectionText,
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
Color: pgtype.Text{String: color, Valid: true},
NoteID: noteUUID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, highlight)
}
// DeleteMediaHighlight handles DELETE /api/media-items/:id/highlights/:highlightId
func (h *Handler) DeleteMediaHighlight(c echo.Context) error {
highlightID := c.Param("highlightId")
highlightUUID, err := uuid.Parse(highlightID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
}
err = h.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// Ebook notes handlers (backward compatibility using views)
// GetEbookNotes handles GET /api/ebooks/:id/notes
func (h *Handler) GetEbookNotes(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"})
}
ebookID := c.Param("id")
ebookUUID, err := uuid.Parse(ebookID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
}
notes, err := h.db.GetEbookNotes(c.Request().Context(), database.GetEbookNotesParams{
EbookID: pgtype.UUID{Bytes: ebookUUID, 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, notes)
}
// CreateEbookNote handles POST /api/ebooks/:id/notes
func (h *Handler) CreateEbookNote(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"})
}
ebookID := c.Param("id")
ebookUUID, err := uuid.Parse(ebookID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
}
var req CreateMediaNoteRequest // reuse: same request struct
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()})
}
note, err := h.db.CreateEbookNote(c.Request().Context(), database.CreateEbookNoteParams{
MediaItemID: pgtype.UUID{Bytes: ebookUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Content: req.Content,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, note)
}
// GetEbookNote handles GET /api/ebooks/:id/notes/:noteId
func (h *Handler) GetEbookNote(c echo.Context) error {
noteID := c.Param("noteId")
noteUUID, err := uuid.Parse(noteID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
note, err := h.db.GetEbookNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "note not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, note)
}
// UpdateEbookNote handles PUT /api/ebooks/:id/notes/:noteId
func (h *Handler) UpdateEbookNote(c echo.Context) error {
noteID := c.Param("noteId")
noteUUID, err := uuid.Parse(noteID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
var req UpdateMediaNoteRequest // reuse: same request struct
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()})
}
note, err := h.db.UpdateEbookNote(c.Request().Context(), database.UpdateEbookNoteParams{
ID: pgtype.UUID{Bytes: noteUUID, Valid: true},
Content: req.Content,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, note)
}
// DeleteEbookNote handles DELETE /api/ebooks/:id/notes/:noteId
func (h *Handler) DeleteEbookNote(c echo.Context) error {
noteID := c.Param("noteId")
noteUUID, err := uuid.Parse(noteID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
err = h.db.DeleteEbookNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// Ebook highlights handlers (backward compatibility using views)
// GetEbookHighlights handles GET /api/ebooks/:id/highlights
func (h *Handler) GetEbookHighlights(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"})
}
ebookID := c.Param("id")
ebookUUID, err := uuid.Parse(ebookID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
}
highlights, err := h.db.GetEbookHighlights(c.Request().Context(), database.GetEbookHighlightsParams{
EbookID: pgtype.UUID{Bytes: ebookUUID, 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, highlights)
}
// CreateEbookHighlight handles POST /api/ebooks/:id/highlights
func (h *Handler) CreateEbookHighlight(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"})
}
ebookID := c.Param("id")
ebookUUID, err := uuid.Parse(ebookID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
}
var req CreateMediaHighlightRequest // reuse: same request struct
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()})
}
var noteUUID pgtype.UUID
if req.NoteID != "" {
if noteID, err := uuid.Parse(req.NoteID); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
} else {
noteUUID = pgtype.UUID{Bytes: noteID, Valid: true}
}
}
color := "#ffff00" // default yellow
if req.Color != "" {
color = req.Color
}
highlight, err := h.db.CreateEbookHighlight(c.Request().Context(), database.CreateEbookHighlightParams{
MediaItemID: pgtype.UUID{Bytes: ebookUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
SelectionText: req.SelectionText,
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
Color: pgtype.Text{String: color, Valid: true},
NoteID: noteUUID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, highlight)
}
// GetEbookHighlight handles GET /api/ebooks/:id/highlights/:highlightId
func (h *Handler) GetEbookHighlight(c echo.Context) error {
highlightID := c.Param("highlightId")
highlightUUID, err := uuid.Parse(highlightID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
}
highlight, err := h.db.GetEbookHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, highlight)
}
// UpdateEbookHighlight handles PUT /api/ebooks/:id/highlights/:highlightId
func (h *Handler) UpdateEbookHighlight(c echo.Context) error {
highlightID := c.Param("highlightId")
highlightUUID, err := uuid.Parse(highlightID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
}
var req UpdateMediaHighlightRequest // reuse: same request struct
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()})
}
var noteUUID pgtype.UUID
if req.NoteID != "" {
if noteID, err := uuid.Parse(req.NoteID); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
} else {
noteUUID = pgtype.UUID{Bytes: noteID, Valid: true}
}
}
color := "#ffff00" // default yellow
if req.Color != "" {
color = req.Color
}
highlight, err := h.db.UpdateEbookHighlight(c.Request().Context(), database.UpdateEbookHighlightParams{
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
SelectionText: req.SelectionText,
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
Color: pgtype.Text{String: color, Valid: true},
NoteID: noteUUID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, highlight)
}
// DeleteEbookHighlight handles DELETE /api/ebooks/:id/highlights/:highlightId
func (h *Handler) DeleteEbookHighlight(c echo.Context) error {
highlightID := c.Param("highlightId")
highlightUUID, err := uuid.Parse(highlightID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
}
err = h.db.DeleteEbookHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}