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) }