refactor: remove ebook system, unify on media-items

Phase 1-3: Database layer cleanup
- Remove 5 backward compatibility VIEWs (ebooks, ebook_ratings, etc.)
- Remove all ebook-specific database queries
- Add new admin media-items queries (Create, Update, Delete)
- Fix sqlc.yaml to point to schema.sql file
- Regenerate database code successfully

Phase 4: Remove old ebook handlers
- Remove all 23 ebook handler functions:
  * ListEbooks, GetEbook, CreateEbook, UpdateEbook, DeleteEbook
  * GetEbookRating, CreateOrUpdateEbookRating, DeleteEbookRating, GetEbookRatings
  * GetEbookNotes, CreateEbookNote, GetEbookNote, UpdateEbookNote, DeleteEbookNote
  * GetEbookHighlights, CreateEbookHighlight, GetEbookHighlight, UpdateEbookHighlight, DeleteEbookHighlight
  * GetReadingProgress, UpdateReadingProgress
- Remove ebook request types (CreateEbookRequest, UpdateEbookRequest, etc.)

Phase 5: Add new admin media-items handlers
- CreateMediaItem (admin only, requires library_id)
- UpdateMediaItem (admin only)
- DeleteMediaItem (admin only)
- Add CreateMediaItemRequest, UpdateMediaItemRequest types
- All use MustGetAuthenticatedUser for safe context access
- Validate admin role before allowing operations
- Validate library exists before creating items

Phase 6: Update routes
- Remove ALL /api/ebooks routes from SetupRoutes()
- Remove ebook progress, rating, notes, highlights routes
- Add admin.POST/PUT/DELETE /api/media-items routes
- Keep all media-items, scanner, and watch mode routes intact

Result: Unified API with only /api/media-items endpoints
- All features preserved (filtering, sorting, searching)
- Better features than old ebook system (more fields, library scoping)
- Cleaner codebase with single system
- All code compiles successfully

Breaking Change: /api/ebooks endpoints removed (use /api/media-items instead)
Status: 85% complete (Phases 1-6 done, Phases 7-8 pending: tests + rebuild)

Tests: Need update (rename Ebooks → MediaItems, update API paths)
Build: Need rebuild with clean cache
This commit is contained in:
2026-01-30 10:03:13 -05:00
parent 420af7978a
commit f96044b6c7
8 changed files with 991 additions and 969 deletions
+168 -707
View File
@@ -67,34 +67,7 @@ func parseDate(dateStr string) time.Time {
func SetupRoutes(g *echo.Group, db *database.Queries) *Handler {
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)
// Media item routes (all authenticated users)
g.GET("/media-items", h.ListMediaItems)
g.GET("/media-items/filtered", h.ListMediaItemsFiltered)
g.GET("/media-items/search", h.SearchMediaItems)
@@ -123,9 +96,9 @@ func SetupRoutes(g *echo.Group, db *database.Queries) *Handler {
// Admin-only routes
admin := g.Group("", AdminMiddleware)
admin.POST("/ebooks", h.CreateEbook)
admin.PUT("/ebooks/:id", h.UpdateEbook)
admin.DELETE("/ebooks/:id", h.DeleteEbook)
admin.POST("/media-items", h.CreateMediaItem)
admin.PUT("/media-items/:id", h.UpdateMediaItem)
admin.DELETE("/media-items/:id", h.DeleteMediaItem)
// Scanner routes (admin only)
admin.POST("/scanner/scan", h.ScanEbooks)
@@ -141,402 +114,6 @@ func SetupRoutes(g *echo.Group, db *database.Queries) *Handler {
return h
}
// 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"})
}
// Check if an ebook library exists before attempting to create an ebook
_, err = h.db.GetEbookLibraryID(c.Request().Context())
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "no ebook library found. Please create an ebook library first",
})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
ebook, err := h.db.CreateEbook(c.Request().Context(), database.CreateEbookParams{
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Isbn: 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: 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"`
@@ -1166,6 +743,170 @@ func (h *Handler) DeleteMediaReadingProgress(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "reading progress deleted"})
}
// Admin Media Items handlers
// CreateMediaItemRequest represents the request for creating a media item
type CreateMediaItemRequest struct {
LibraryID uuid.UUID `json:"library_id" validate:"required"`
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"`
}
// CreateMediaItem handles POST /api/media-items (admin only)
func (h *Handler) CreateMediaItem(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
// Verify user is admin
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
var req CreateMediaItemRequest
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()})
}
// Validate library exists
_, err := h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
mediaItemID, err := h.db.CreateMediaItem(c.Request().Context(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Isbn: 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: user.ID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Return the created item info
item, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaItemID.ID.Bytes, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, item)
}
// UpdateMediaItemRequest represents the request for updating a media item
type UpdateMediaItemRequest 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"`
}
// UpdateMediaItem handles PUT /api/media-items/:id (admin only)
func (h *Handler) UpdateMediaItem(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
// Verify user is admin
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
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 UpdateMediaItemRequest
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()})
}
item, err := h.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Isbn: 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, item)
}
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
func (h *Handler) DeleteMediaItem(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
// Verify user is admin
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
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.DeleteMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// Media Notes handlers
// GetMediaNotes handles GET /api/media-items/:id/notes
@@ -1483,287 +1224,7 @@ func (h *Handler) DeleteMediaHighlight(c echo.Context) error {
// 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)
}
// SearchMediaItems handles GET /api/media-items/search
// Performs partial matching search with fuzzy fallback if no results found
func (h *Handler) SearchMediaItems(c echo.Context) error {