package handlers import ( "bookhoard/internal/database" "context" "errors" "fmt" "net/http" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" "bookhoard/internal/services" ) type ProcessingIssuesHandler struct { db *database.Queries } func NewProcessingIssuesHandler(db *database.Queries) *ProcessingIssuesHandler { return &ProcessingIssuesHandler{db: db} } // visibleLibrarySetFor resolves the requesting user's visible-library // set (family-lens) from the request context identity. The issues // endpoints leak media metadata (titles, paths) — a hidden library's // rows must never reach a user it's hidden from, same rule as every // media surface. Identity follows the router's visibleLibraryIDsFor // (the JWT middleware sets "user_id"; AdminMiddleware only reads the // role — there is no "user" object on this chain). func (h *ProcessingIssuesHandler) visibleLibrarySetFor(c *echo.Context) (map[string]bool, error) { userUUIDStr, ok := c.Get("user_id").(string) if !ok { return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user") } userU, err := uuid.Parse(userUUIDStr) if err != nil { return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user") } set, _, err := services.NewLibraryService(h.db).VisibleLibrarySet( c.Request().Context(), pgtype.UUID{Bytes: userU, Valid: true}) return set, err } // gateLibrary enforces the family lens on a library-scoped request: // an explicit library id outside the requester's visible set is a 403, // never a silent pass (faceted plan family-lens rule). func (h *ProcessingIssuesHandler) gateLibrary(c *echo.Context, libraryIDStr string) error { libUUID, err := uuid.Parse(libraryIDStr) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Invalid library ID") } visibleSet, visErr := h.visibleLibrarySetFor(c) if visErr != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to check library visibility") } if !visibleSet[libUUID.String()] { return echo.NewHTTPError(http.StatusForbidden, "Library not accessible") } return nil } type ProcessingIssueResponse struct { ID string `json:"id"` MediaItemID string `json:"media_item_id"` Title string `json:"title"` FilePath string `json:"file_path"` FormatGroup string `json:"format_group"` LibraryTypeName string `json:"library_type_name"` IssueType string `json:"issue_type"` IssueDescription string `json:"issue_description"` Severity string `json:"severity"` CreatedAt time.Time `json:"created_at"` } type ProcessingIssueStats struct { ErrorCount int64 `json:"error_count"` WarningCount int64 `json:"warning_count"` InfoCount int64 `json:"info_count"` } // ListProcessingIssues returns all processing issues for a library func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error { if err := h.gateLibrary(c, c.Param("id")); err != nil { return err } libraryID, _ := uuid.Parse(c.Param("id")) issues, err := h.db.ListProcessingIssuesByLibrary( c.Request().Context(), pgtype.UUID{Bytes: libraryID, Valid: true}, ) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch issues"}) } response := make([]ProcessingIssueResponse, len(issues)) for i, issue := range issues { // Row-less import failures have no media item — emit an empty id // rather than the zero UUID, and fall back the display title to // the file path so the ledger reads like itself. itemID := "" if issue.MediaItemID.Valid { itemID = issue.MediaItemID.String() } title := issue.Title if title == "" { title = issue.FilePath } response[i] = ProcessingIssueResponse{ ID: issue.ID.String(), MediaItemID: itemID, Title: title, FilePath: issue.FilePath, FormatGroup: issue.FormatGroup, LibraryTypeName: issue.LibraryTypeName, IssueType: issue.IssueType, IssueDescription: issue.IssueDescription, Severity: issue.Severity, CreatedAt: issue.CreatedAt.Time, } } return c.JSON(http.StatusOK, response) } // GetProcessingIssueStats returns statistics about processing issues func (h *ProcessingIssuesHandler) GetProcessingIssueStats(c *echo.Context) error { if err := h.gateLibrary(c, c.Param("id")); err != nil { return err } libraryID, _ := uuid.Parse(c.Param("id")) stats, err := h.db.GetProcessingIssueStats( c.Request().Context(), pgtype.UUID{Bytes: libraryID, Valid: true}, ) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch stats"}) } return c.JSON(http.StatusOK, ProcessingIssueStats{ ErrorCount: stats.ErrorCount, WarningCount: stats.WarningCount, InfoCount: stats.InfoCount, }) } // ResolveProcessingIssue marks an issue as resolved func (h *ProcessingIssuesHandler) ResolveProcessingIssue(c *echo.Context) error { issueID, err := uuid.Parse(c.Param("issueId")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"}) } mediaItemID, err := uuid.Parse(c.Param("mediaItemId")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) } if err := h.gateIssue(c, issueID); err != nil { return err } _, err = h.db.ResolveProcessingIssue( c.Request().Context(), database.ResolveProcessingIssueParams{ ID: pgtype.UUID{Bytes: issueID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, }, ) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to resolve issue"}) } return c.JSON(http.StatusOK, map[string]interface{}{ "success": true, "message": "Issue marked as resolved", }) } // DeleteProcessingIssue permanently deletes an issue record func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error { issueID, err := uuid.Parse(c.Param("issueId")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"}) } if err := h.gateIssue(c, issueID); err != nil { return err } _, err = h.db.DeleteProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to delete issue"}) } return c.JSON(http.StatusOK, map[string]any{ "success": true, "message": "Issue deleted", }) } // gateIssue enforces the family lens on an issue-addressed request: the // issue's library must be in the requester's visible set (403 otherwise). func (h *ProcessingIssuesHandler) gateIssue(c *echo.Context, issueID uuid.UUID) error { issue, err := h.db.GetProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true}) if err != nil { return echo.NewHTTPError(http.StatusNotFound, "Issue not found") } visibleSet, visErr := h.visibleLibrarySetFor(c) if visErr != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to check library visibility") } if !issue.LibraryID.Valid || !visibleSet[uuid.UUID(issue.LibraryID.Bytes).String()] { return echo.NewHTTPError(http.StatusForbidden, "Library not accessible") } return nil } // RetryProcessingIssue re-runs the work that produced an open issue // through the individual scanner — NOT a library-wide rescan: // // - item-backed issues rescan their media item (the book-detail // RescanMediaItem mechanism: self-resolving folders, multi-file // audiobook routing, write-after-success guards); // - row-less import_failed issues re-import the single file through // the watcher's standalone processMediaFile path. // // Success closes the issue (explicitly for the item shape; the create // path's self-heal plus the explicit path resolve below for row-less). // Failure returns the REAL error so the UI can say why it still fails. func (h *ProcessingIssuesHandler) RetryProcessingIssue(c *echo.Context) error { issueID, err := uuid.Parse(c.Param("issueId")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"}) } issue, err := h.db.GetProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true}) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "Issue not found"}) } if !issue.LibraryID.Valid { return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has no library attribution"}) } if err := h.gateIssue(c, issueID); err != nil { return err } if issue.Resolved.Valid && issue.Resolved.Bool { return c.JSON(http.StatusConflict, map[string]string{"error": "Issue is already resolved"}) } scanner := services.NewMediaScanner(h.db) defer scanner.Close() if issue.MediaItemID.Valid { if err := scanner.RetryMediaItemIssue(c.Request().Context(), issue.MediaItemID); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]any{ "success": true, "message": "Item rescanned; issue resolved", }) } // Row-less import failure: the file never became a row. if !issue.FilePath.Valid || issue.FilePath.String == "" { return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has neither a media item nor a file path"}) } relativePath := issue.FilePath.String if err := scanner.RetryFailedImport(c.Request().Context(), issue.LibraryID, relativePath); err != nil { if errors.Is(err, services.ErrRetryFileMissing) { return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } // The import chain's self-heal closes path-keyed issues on create; // an already-imported duplicate (a previous partial retry) skips // that path, so close explicitly here too. Idempotent. if _, err := h.db.ResolveOpenProcessingIssuesForPath(c.Request().Context(), database.ResolveOpenProcessingIssuesForPathParams{ LibraryID: issue.LibraryID, FilePath: pgtype.Text{String: relativePath, Valid: true}, }); err != nil { fmt.Printf("Warning: retry imported %s but failed to close its issues: %v\n", relativePath, err) } return c.JSON(http.StatusOK, map[string]any{ "success": true, "message": "File imported; issue resolved", }) } // GetProcessingIssueStatsData returns stats for SSR (not JSON response) func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) { return h.db.GetProcessingIssueStats(ctx, libraryID) }