From ee4c4faff7d672326d4d802b2ea3bd1be0eade92 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 28 Jan 2026 15:42:52 -0500 Subject: [PATCH] feat: implement notes and highlights API endpoints - Add complete CRUD API for media items notes (/api/media-items/:id/notes/*) - Add complete CRUD API for media highlights (/api/media-items/:id/highlights/*) - Add backward compatibility endpoints for ebooks (/api/ebooks/:id/notes/*, /api/ebooks/:id/highlights/*) - Implement proper validation for request payloads and UUIDs - Support hex color codes for highlights with default yellow (#ffff00) - Support position tracking (page:offset or CFI formats) - Support optional note association with highlights --- internal/handlers/ebook.go | 626 +++++++++++++++++++++++++++++++++++++ 1 file changed, 626 insertions(+) diff --git a/internal/handlers/ebook.go b/internal/handlers/ebook.go index c65ab1d..56f7aba 100644 --- a/internal/handlers/ebook.go +++ b/internal/handlers/ebook.go @@ -60,6 +60,20 @@ func SetupRoutes(g *echo.Group, db *database.Queries) { 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) @@ -71,6 +85,20 @@ func SetupRoutes(g *echo.Group, db *database.Queries) { 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) @@ -793,3 +821,601 @@ func (h *Handler) DeleteMediaReadingProgress(c echo.Context) 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) +}