- 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
1442 lines
49 KiB
Go
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)
|
|
}
|