package handlers import ( "bookmann/internal/database" "bookmann/internal/services" "context" "fmt" "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 worker *services.Worker scheduler *services.Scheduler ctx context.Context cancel context.CancelFunc mu sync.Mutex watchModeCtx context.Context watchModeCancel context.CancelFunc watchingLibraries map[string]bool } func NewHandler(db *database.Queries) *Handler { ctx, cancel := context.WithCancel(context.Background()) worker := services.NewWorker(3) scheduler := services.NewScheduler(worker, db) watchCtx, watchCancel := context.WithCancel(context.Background()) return &Handler{ db: db, scanner: services.NewEbookScanner(db), worker: worker, scheduler: scheduler, ctx: ctx, cancel: cancel, watchModeCtx: watchCtx, watchModeCancel: watchCancel, watchingLibraries: make(map[string]bool), } } // 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) *Handler { h := NewHandler(db) // Public routes (all authenticated users) g.GET("/ebooks", h.ListEbooks) g.GET("/ebooks/:id", h.GetEbook) // User-specific routes g.GET("/ebooks/:id/progress", h.GetReadingProgress) g.PUT("/ebooks/:id/progress", h.UpdateReadingProgress) g.GET("/ebooks/:id/rating", h.GetEbookRating) g.POST("/ebooks/:id/rating", h.CreateOrUpdateEbookRating) g.PUT("/ebooks/:id/rating", h.CreateOrUpdateEbookRating) g.DELETE("/ebooks/:id/rating", h.DeleteEbookRating) g.GET("/ebooks/:id/ratings", h.GetEbookRatings) // Ebook notes routes (backward compatibility) g.GET("/ebooks/:id/notes", h.GetEbookNotes) g.POST("/ebooks/:id/notes", h.CreateEbookNote) g.GET("/ebooks/:id/notes/:noteId", h.GetEbookNote) g.PUT("/ebooks/:id/notes/:noteId", h.UpdateEbookNote) g.DELETE("/ebooks/:id/notes/:noteId", h.DeleteEbookNote) // Ebook highlights routes (backward compatibility) g.GET("/ebooks/:id/highlights", h.GetEbookHighlights) g.POST("/ebooks/:id/highlights", h.CreateEbookHighlight) g.GET("/ebooks/:id/highlights/:highlightId", h.GetEbookHighlight) g.PUT("/ebooks/:id/highlights/:highlightId", h.UpdateEbookHighlight) g.DELETE("/ebooks/:id/highlights/:highlightId", h.DeleteEbookHighlight) // Media item routes (new library system) 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) admin.GET("/scanner/status/:jobId", h.GetScanStatus) // Watch mode routes (admin only) admin.POST("/scanner/watch/start", h.StartWatchMode) admin.POST("/scanner/watch/stop", h.StopWatchMode) admin.GET("/scanner/watch/status", h.GetWatchModeStatus) return h } // ListEbooks handles GET /api/ebooks func (h *Handler) ListEbooks(c echo.Context) error { limitStr := c.QueryParam("limit") offsetStr := c.QueryParam("offset") limit := int32(20) // default if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil { limit = int32(l) // Enforce maximum limit if limit > maxPaginationLimit { limit = maxPaginationLimit } } } offset := int32(0) if offsetStr != "" { if o, err := strconv.Atoi(offsetStr); err == nil { if o < 0 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "offset cannot be negative"}) } offset = int32(o) } } ebooks, err := h.db.ListEbooks(c.Request().Context(), database.ListEbooksParams{ Limit: limit, Offset: offset, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, ebooks) } // GetEbook handles GET /api/ebooks/:id func (h *Handler) GetEbook(c echo.Context) error { idStr := c.Param("id") id, err := uuid.Parse(idStr) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"}) } ebook, err := h.db.GetEbook(c.Request().Context(), pgtype.UUID{Bytes: id, Valid: true}) if err != nil { if err == pgx.ErrNoRows { return c.JSON(http.StatusNotFound, map[string]string{"error": "ebook not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, ebook) } // CreateEbookRequest represents the request for creating an ebook type CreateEbookRequest struct { Title string `json:"title" validate:"required,min=1,max=500"` Author string `json:"author"` ISBN string `json:"isbn"` Description string `json:"description"` FilePath string `json:"file_path" validate:"required"` FileSize int64 `json:"file_size" validate:"required,min=1"` MimeType string `json:"mime_type" validate:"required"` CoverImagePath string `json:"cover_image_path"` Series string `json:"series"` SeriesNumber int32 `json:"series_number"` Tags string `json:"tags"` ASIN string `json:"asin"` DatePublished string `json:"date_published"` Publisher string `json:"publisher"` Contributors string `json:"contributors"` } // CreateEbook handles POST /api/ebooks func (h *Handler) CreateEbook(c echo.Context) error { var req CreateEbookRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // Get admin ID from JWT token userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } // Check if an ebook library exists before attempting to create an ebook _, err = h.db.GetEbookLibraryID(c.Request().Context()) if err != nil { if err == pgx.ErrNoRows { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "no ebook library found. Please create an ebook library first", }) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } ebook, err := h.db.CreateEbook(c.Request().Context(), database.CreateEbookParams{ Title: req.Title, Author: pgtype.Text{String: req.Author, Valid: req.Author != ""}, Isbn: req.ISBN, Description: pgtype.Text{String: req.Description, Valid: req.Description != ""}, FilePath: req.FilePath, FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0}, MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""}, CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""}, Series: pgtype.Text{String: req.Series, Valid: req.Series != ""}, SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0}, Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""}, Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""}, DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""}, Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""}, Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""}, AddedByAdminID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusCreated, ebook) } // UpdateEbookRequest represents the request for updating an ebook type UpdateEbookRequest struct { Title string `json:"title" validate:"required,min=1,max=500"` Author string `json:"author"` ISBN string `json:"isbn"` Description string `json:"description"` CoverImagePath string `json:"cover_image_path"` Series string `json:"series"` SeriesNumber int32 `json:"series_number"` Tags string `json:"tags"` ASIN string `json:"asin"` DatePublished string `json:"date_published"` Publisher string `json:"publisher"` Contributors string `json:"contributors"` } // UpdateEbook handles PUT /api/ebooks/:id func (h *Handler) UpdateEbook(c echo.Context) error { idStr := c.Param("id") id, err := uuid.Parse(idStr) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"}) } var req UpdateEbookRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } ebook, err := h.db.UpdateEbook(c.Request().Context(), database.UpdateEbookParams{ ID: pgtype.UUID{Bytes: id, Valid: true}, Title: req.Title, Author: pgtype.Text{String: req.Author, Valid: req.Author != ""}, Isbn: req.ISBN, Description: pgtype.Text{String: req.Description, Valid: req.Description != ""}, CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""}, Series: pgtype.Text{String: req.Series, Valid: req.Series != ""}, SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0}, Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""}, Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""}, DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""}, Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""}, Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, ebook) } // DeleteEbook handles DELETE /api/ebooks/:id func (h *Handler) DeleteEbook(c echo.Context) error { idStr := c.Param("id") id, err := uuid.Parse(idStr) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"}) } err = h.db.DeleteEbook(c.Request().Context(), pgtype.UUID{Bytes: id, Valid: true}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.NoContent(http.StatusNoContent) } // GetReadingProgress handles GET /api/ebooks/:id/progress func (h *Handler) GetReadingProgress(c echo.Context) error { ebookIdStr := c.Param("id") userID := c.Get("user_id").(string) ebookId, err := uuid.Parse(ebookIdStr) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"}) } userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } progress, err := h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{ MediaItemID: pgtype.UUID{Bytes: ebookId, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { if err == pgx.ErrNoRows { // If no progress found, return default return c.JSON(http.StatusOK, map[string]interface{}{ "ebook_id": ebookIdStr, "user_id": userID, "current_page": 0, "total_pages": nil, }) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, progress) } // UpdateReadingProgressRequest represents the request for updating reading progress type UpdateReadingProgressRequest struct { CurrentPage int32 `json:"current_page" validate:"required,min=0"` TotalPages int32 `json:"total_pages" validate:"omitempty,min=1"` } // UpdateReadingProgress handles PUT /api/ebooks/:id/progress func (h *Handler) UpdateReadingProgress(c echo.Context) error { ebookIdStr := c.Param("id") userID := c.Get("user_id").(string) ebookId, err := uuid.Parse(ebookIdStr) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"}) } userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } var req UpdateReadingProgressRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } progress, err := h.db.UpdateReadingProgress(c.Request().Context(), database.UpdateReadingProgressParams{ MediaItemID: pgtype.UUID{Bytes: ebookId, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true}, TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, progress) } // CreateOrUpdateEbookRatingRequest represents the request for creating/updating an ebook rating type CreateOrUpdateEbookRatingRequest struct { Rating int32 `json:"rating" validate:"required,min=1,max=10"` } // GetEbookRating handles GET /api/ebooks/:id/rating func (h *Handler) GetEbookRating(c echo.Context) error { ebookIdStr := c.Param("id") userID := c.Get("user_id").(string) ebookId, err := uuid.Parse(ebookIdStr) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"}) } userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } rating, err := h.db.GetEbookRating(c.Request().Context(), database.GetEbookRatingParams{ EbookID: pgtype.UUID{Bytes: ebookId, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { if err == pgx.ErrNoRows { // If no rating found, return rating 0 return c.JSON(http.StatusOK, map[string]interface{}{ "ebook_id": ebookIdStr, "user_id": userID, "rating": 0, }) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, rating) } // CreateOrUpdateEbookRating handles POST/PUT /api/ebooks/:id/rating func (h *Handler) CreateOrUpdateEbookRating(c echo.Context) error { ebookIdStr := c.Param("id") userID := c.Get("user_id").(string) ebookId, err := uuid.Parse(ebookIdStr) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"}) } userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } var req CreateOrUpdateEbookRatingRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } rating, err := h.db.CreateEbookRating(c.Request().Context(), database.CreateEbookRatingParams{ MediaItemID: pgtype.UUID{Bytes: ebookId, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Rating: req.Rating, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, rating) } // DeleteEbookRating handles DELETE /api/ebooks/:id/rating func (h *Handler) DeleteEbookRating(c echo.Context) error { ebookIdStr := c.Param("id") userID := c.Get("user_id").(string) ebookId, err := uuid.Parse(ebookIdStr) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"}) } userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } err = h.db.DeleteEbookRating(c.Request().Context(), database.DeleteEbookRatingParams{ MediaItemID: pgtype.UUID{Bytes: ebookId, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.NoContent(http.StatusNoContent) } // GetEbookRatings handles GET /api/ebooks/:id/ratings func (h *Handler) GetEbookRatings(c echo.Context) error { ebookIdStr := c.Param("id") ebookId, err := uuid.Parse(ebookIdStr) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"}) } ratings, err := h.db.GetEbookRatings(c.Request().Context(), pgtype.UUID{Bytes: ebookId, Valid: true}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, ratings) } // ScanEbooksRequest represents the request for scanning ebooks type ScanEbooksRequest struct { FolderPaths []string `json:"folder_paths,omitempty"` } // ScanEbooks handles POST /api/scanner/scan (now runs in background) 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"}) } jobID := uuid.New().String() job := &services.Job{ ID: jobID, Type: services.JobTypeScan, Params: map[string]interface{}{ "library_id": userID, "folders": folderPaths, "admin_id": userUUID.String(), "db": h.db, }, Status: services.JobStatusPending, Context: h.ctx, } if err := h.worker.EnqueueJob(job); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to enqueue scan job: " + err.Error()}) } return c.JSON(http.StatusAccepted, map[string]interface{}{ "message": "scan job enqueued", "job_id": jobID, "status": "pending", }) } // 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"}) } // GetScanStatus handles GET /api/scanner/status/:jobId func (h *Handler) GetScanStatus(c echo.Context) error { jobID := c.Param("jobId") result, exists := h.worker.GetJobStatus(jobID) if !exists { return c.JSON(http.StatusNotFound, map[string]string{"error": "job not found"}) } return c.JSON(http.StatusOK, map[string]interface{}{ "job_id": result.JobID, "status": result.Status, "error": result.Error, "result": result.Result, "progress": result.Progress, }) } // StartWatchModeForLibrary starts watching a specific library's folders func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype.UUID, adminID pgtype.UUID) error { h.mu.Lock() defer h.mu.Unlock() libraryIDStr := fmt.Sprintf("%x", libraryID.Bytes) if h.watchingLibraries[libraryIDStr] { return fmt.Errorf("already watching library %s", libraryIDStr) } folders, err := h.db.GetLibraryFolders(ctx, libraryID) if err != nil { return fmt.Errorf("failed to get library folders: %v", err) } if len(folders) == 0 { return fmt.Errorf("no folders configured for library") } folderPaths := make([]string, len(folders)) for i, folder := range folders { folderPaths[i] = folder.FolderPath } scanner := services.NewEbookScanner(h.db) if err := scanner.SetFolders(folderPaths); err != nil { return fmt.Errorf("failed to set scanner folders: %v", err) } scanner.SetAdminID(adminID) scanner.WatchChanges(h.watchModeCtx) h.watchingLibraries[libraryIDStr] = true return nil } // StopWatchModeForLibrary stops watching a specific library func (h *Handler) StopWatchModeForLibrary(libraryID pgtype.UUID) error { h.mu.Lock() defer h.mu.Unlock() libraryIDStr := fmt.Sprintf("%x", libraryID.Bytes) if !h.watchingLibraries[libraryIDStr] { return fmt.Errorf("not watching library %s", libraryIDStr) } delete(h.watchingLibraries, libraryIDStr) if len(h.watchingLibraries) == 0 { h.watchModeCancel() newCtx, newCancel := context.WithCancel(context.Background()) h.watchModeCtx = newCtx h.watchModeCancel = newCancel } return nil } // StartWatchMode handles POST /api/scanner/watch/start func (h *Handler) StartWatchMode(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 id"}) } var req struct { LibraryID string `json:"library_id"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if req.LibraryID == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"}) } libraryID, err := uuid.Parse(req.LibraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"}) } if err := h.StartWatchModeForLibrary(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(libraryID), Valid: true}, pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]interface{}{ "message": "watch mode started for library", "library_id": req.LibraryID, }) } // StopWatchMode handles POST /api/scanner/watch/stop func (h *Handler) StopWatchMode(c echo.Context) error { var req struct { LibraryID string `json:"library_id"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if req.LibraryID == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"}) } libraryID, err := uuid.Parse(req.LibraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"}) } if err := h.StopWatchModeForLibrary(pgtype.UUID{Bytes: [16]byte(libraryID), Valid: true}); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]interface{}{ "message": "watch mode stopped for library", "library_id": req.LibraryID, }) } // GetWatchModeStatus handles GET /api/scanner/watch/status func (h *Handler) GetWatchModeStatus(c echo.Context) error { h.mu.Lock() defer h.mu.Unlock() watchingLibraries := make([]string, 0, len(h.watchingLibraries)) for libID := range h.watchingLibraries { watchingLibraries = append(watchingLibraries, libID) } return c.JSON(http.StatusOK, map[string]interface{}{ "watching_libraries": watchingLibraries, "total_watching": len(watchingLibraries), }) } // StartWatchModeForAllLibraries starts watching all configured libraries func (h *Handler) StartWatchModeForAllLibraries(ctx context.Context) error { libraries, err := h.db.ListLibraries(ctx) if err != nil { return fmt.Errorf("failed to list libraries: %v", err) } for _, library := range libraries { libraryIDStr := fmt.Sprintf("%x", library.ID.Bytes) if err := h.StartWatchModeForLibrary(ctx, library.ID, library.ID); err != nil { fmt.Printf("Warning: failed to start watch mode for library %s: %v\n", libraryIDStr, err) continue } fmt.Printf("Started watch mode for library %s (%s)\n", library.Name, libraryIDStr) } return nil } // StartScheduler starts the auto-scan scheduler func (h *Handler) StartScheduler() { h.scheduler.Start() } // StopScheduler stops the auto-scan scheduler func (h *Handler) StopScheduler() { h.scheduler.Stop() } // 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, map[string]interface{}{"data": 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, map[string]interface{}{"data": 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) }