From f4e06e60c6bd84697ad7d259b01a51c935680713 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 7 Feb 2026 19:49:35 -0500 Subject: [PATCH] refactor(handlers): Phase 2 - create MediaHandler with CRUD operations - Add worker field to MediaHandler struct - Make NewMediaHandler accept optional worker parameter - Move 24 media CRUD methods from ebook.go to MediaHandler: * Media CRUD: ListMediaItems, GetMediaItem, ListMediaItemsFiltered, CreateMediaItem, UpdateMediaItem, DeleteMediaItem, SearchMediaItems * Ratings: CreateMediaRating, GetMediaRating, UpdateMediaRating, DeleteMediaRating * Progress: GetMediaReadingProgress, UpdateMediaReadingProgress, DeleteMediaReadingProgress * Notes: GetMediaNotes, CreateMediaNote, GetMediaNote, UpdateMediaNote, DeleteMediaNote * Highlights: GetMediaHighlights, CreateMediaHighlight, GetMediaHighlight, UpdateMediaHighlight, DeleteMediaHighlight Methods are copied (not moved) to maintain backward compatibility during refactoring. Duplicates will be removed in Phase 7. This is Phase 2 of the ebook.go refactoring plan. --- internal/handlers/media.go | 814 ++++++++++++++++++++++++++++++++++++- 1 file changed, 811 insertions(+), 3 deletions(-) diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 9263fa8..a1956ea 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -2,6 +2,7 @@ package handlers import ( "bookhoard/internal/database" + "bookhoard/internal/services" "io" "mime" "net/http" @@ -10,16 +11,24 @@ import ( "strconv" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" ) type MediaHandler struct { - db *database.Queries + db *database.Queries + worker *services.Worker } -func NewMediaHandler(db *database.Queries) *MediaHandler { - return &MediaHandler{db: db} +func NewMediaHandler(db *database.Queries, worker ...*services.Worker) *MediaHandler { + mh := &MediaHandler{ + db: db, + } + if len(worker) > 0 && worker[0] != nil { + mh.worker = worker[0] + } + return mh } func (h *MediaHandler) DownloadBook(c echo.Context) error { @@ -454,3 +463,802 @@ func (h *MediaHandler) HandleBulkUpdate(c echo.Context) error { "failed": failedCount, }) } + +// ListMediaItems handles GET /api/media-items +func (mh *MediaHandler) ListMediaItems(c echo.Context) error { + libraryID := c.QueryParam("library_id") + sort := c.QueryParam("sort") + limit, _ := strconv.Atoi(c.QueryParam("limit")) + offset, _ := strconv.Atoi(c.QueryParam("offset")) + + if limit == 0 { + limit = 50 + } + + if sort == "" { + sort = "created_at DESC" + } + + allowedSorts := map[string]bool{ + "created_at ASC": true, + "created_at DESC": true, + "title ASC": true, + "title DESC": true, + "author ASC": true, + "author DESC": true, + "series ASC": true, + "series DESC": true, + "date_published ASC": true, + "date_published DESC": true, + "copyright_year ASC": true, + "copyright_year DESC": true, + "page_count ASC": true, + "page_count DESC": true, + "genre ASC": true, + "genre DESC": true, + } + + if !allowedSorts[sort] { + sort = "created_at DESC" + } + + 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 := mh.db.ListMediaItemsSorted(c.Request().Context(), database.ListMediaItemsSortedParams{ + LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, + Sort: pgtype.Text{String: sort, Valid: true}, + Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, + Offset: pgtype.Int4{Int32: int32(offset), Valid: true}, + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{"data": items}) + } + + items, err := mh.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, map[string]interface{}{"data": items}) +} + +// GetMediaItem handles GET /api/media-items/:id +func (mh *MediaHandler) 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 := mh.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) +} + +// ListMediaItemsFiltered handles GET /api/media-items/filtered +func (mh *MediaHandler) ListMediaItemsFiltered(c echo.Context) error { + userID := c.Get("user_id").(string) + libraryID := c.QueryParam("library_id") + sort := c.QueryParam("sort") + limit, _ := strconv.Atoi(c.QueryParam("limit")) + offset, _ := strconv.Atoi(c.QueryParam("offset")) + + authorFilter := c.QueryParam("author_filter") + seriesFilter := c.QueryParam("series_filter") + genreFilter := c.QueryParam("genre_filter") + languageFilter := c.QueryParam("language_filter") + yearMin, _ := strconv.Atoi(c.QueryParam("year_min")) + yearMax, _ := strconv.Atoi(c.QueryParam("year_max")) + hasCover, _ := strconv.ParseBool(c.QueryParam("has_cover")) + + if limit == 0 { + limit = 50 + } + + if sort == "" { + sort = "created_at DESC" + } + + if limit > maxPaginationLimit { + limit = maxPaginationLimit + } + + libUUID, err := uuid.Parse(libraryID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"}) + } + + userUUID, err := uuid.Parse(userID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) + } + + items, err := mh.db.ListMediaItemsFiltered(c.Request().Context(), database.ListMediaItemsFilteredParams{ + LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + AuthorFilter: pgtype.Text{String: authorFilter, Valid: true}, + SeriesFilter: pgtype.Text{String: seriesFilter, Valid: true}, + GenreFilter: pgtype.Text{String: genreFilter, Valid: true}, + LanguageFilter: pgtype.Text{String: languageFilter, Valid: true}, + YearMin: pgtype.Int4{Int32: int32(yearMin), Valid: true}, + YearMax: pgtype.Int4{Int32: int32(yearMax), Valid: true}, + HasCover: pgtype.Bool{Bool: hasCover, Valid: true}, + Sort: pgtype.Text{String: sort, Valid: true}, + Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, + Offset: pgtype.Int4{Int32: int32(offset), Valid: true}, + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{"data": items}) +} + +// CreateMediaRating handles POST /api/media-items/:id/rating +func (mh *MediaHandler) 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 := mh.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 (mh *MediaHandler) 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 := mh.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 (mh *MediaHandler) UpdateMediaRating(c echo.Context) error { + return mh.CreateMediaRating(c) +} + +// DeleteMediaRating handles DELETE /api/media-items/:id/rating +func (mh *MediaHandler) 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 = mh.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 (mh *MediaHandler) 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 := mh.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 (mh *MediaHandler) 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 := mh.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 (mh *MediaHandler) 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 = mh.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"}) +} + +// CreateMediaItem handles POST /api/media-items (admin only) +func (mh *MediaHandler) CreateMediaItem(c echo.Context) error { + user := MustGetAuthenticatedUser(c) + + if user.Role != "admin" { + return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) + } + + var req CreateMediaItemRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + _, err := mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true}) + if err != nil { + if err == pgx.ErrNoRows { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + mediaItemID, err := mh.db.CreateMediaItem(c.Request().Context(), database.CreateMediaItemParams{ + LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true}, + Title: req.Title, + Author: pgtype.Text{String: req.Author, Valid: req.Author != ""}, + Isbn: 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: user.ID, + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaItemID.ID.Bytes, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusCreated, item) +} + +// UpdateMediaItem handles PUT /api/media-items/:id (admin only) +func (mh *MediaHandler) UpdateMediaItem(c echo.Context) error { + user := MustGetAuthenticatedUser(c) + + if user.Role != "admin" { + return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) + } + + mediaID := c.Param("id") + mediaUUID, err := uuid.Parse(mediaID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) + } + + var req UpdateMediaItemRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{ + ID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, + Title: req.Title, + Author: pgtype.Text{String: req.Author, Valid: req.Author != ""}, + Isbn: 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, item) +} + +// DeleteMediaItem handles DELETE /api/media-items/:id (admin only) +func (mh *MediaHandler) DeleteMediaItem(c echo.Context) error { + user := MustGetAuthenticatedUser(c) + + if user.Role != "admin" { + return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) + } + + mediaID := c.Param("id") + mediaUUID, err := uuid.Parse(mediaID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) + } + + err = mh.db.DeleteMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.NoContent(http.StatusNoContent) +} + +// GetMediaNotes handles GET /api/media-items/:id/notes +func (mh *MediaHandler) 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 := mh.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) +} + +// CreateMediaNote handles POST /api/media-items/:id/notes +func (mh *MediaHandler) 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 := mh.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 (mh *MediaHandler) 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 := mh.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) +} + +// UpdateMediaNote handles PUT /api/media-items/:id/notes/:noteId +func (mh *MediaHandler) 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 := mh.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 (mh *MediaHandler) 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 = mh.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) +} + +// GetMediaHighlights handles GET /api/media-items/:id/highlights +func (mh *MediaHandler) 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 := mh.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) +} + +// CreateMediaHighlight handles POST /api/media-items/:id/highlights +func (mh *MediaHandler) 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" + if req.Color != "" { + color = req.Color + } + + highlight, err := mh.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 (mh *MediaHandler) 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 := mh.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) +} + +// UpdateMediaHighlight handles PUT /api/media-items/:id/highlights/:highlightId +func (mh *MediaHandler) 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" + if req.Color != "" { + color = req.Color + } + + highlight, err := mh.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 (mh *MediaHandler) 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 = mh.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) +} + +// SearchMediaItems handles GET /api/media-items/search +func (mh *MediaHandler) SearchMediaItems(c echo.Context) error { + query := c.QueryParam("q") + userID := c.Get("user_id").(string) + + if query == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "query parameter 'q' is required"}) + } + + userUUID, err := uuid.Parse(userID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) + } + + limit := int32(50) + offset := int32(0) + + searchPattern := "%" + query + "%" + + partialResults, err := mh.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{ + SearchPattern: pgtype.Text{String: searchPattern, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + Offset: pgtype.Int4{Int32: offset, Valid: true}, + }) + + if err != nil && err != pgx.ErrNoRows { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + if len(partialResults) > 0 { + return c.JSON(http.StatusOK, partialResults) + } + + fuzzyResults, err := mh.db.SearchMediaItemsFuzzy(c.Request().Context(), database.SearchMediaItemsFuzzyParams{ + SearchQuery: pgtype.Text{String: query, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + Offset: pgtype.Int4{Int32: offset, Valid: true}, + }) + + if err != nil && err != pgx.ErrNoRows { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + if len(fuzzyResults) == 0 { + return c.JSON(http.StatusNotFound, map[string]interface{}{ + "error": "no results found", + "query": query, + "results": []interface{}{}, + }) + } + + return c.JSON(http.StatusOK, fuzzyResults) +}