Files
bookhoard/internal/handlers/processing_issues.go
T
John O'Keefe 6f37be59fc feat(issues): retry a processing issue through the individual scanner
POST /api/libraries/:id/issues/:issueId/retry (admin, family-lens gated
via the existing gateIssue) re-runs the work that produced an open issue
— deliberately NOT a library-wide rescan. Two shapes mirror the two
ledger entry kinds:

- item-backed issues rescan their media item through the book-detail
  RescanMediaItem mechanism (self-resolving folders, multi-file
  audiobook routing, write-after-success guards) and then close the
  issue (RetryMediaItemIssue);
- row-less import_failed issues re-import the single file through the
  watcher's standalone processMediaFile path (RetryFailedImport: folders
  resolved RescanMediaItem-style so paths stay library-root-relative),
  whose create-path self-heal plus an explicit path resolve closes the
  issue even when the content was already imported by a prior attempt.

Failures return the REAL underlying error so the UI can show why the
issue still exists; a vanished file is a 409 (ErrRetryFileMissing);
already-resolved issues reject re-retry.

Docs + bruno mirrors regenerated for the new route (the generator's seq
renumbering touches three unrelated bruno files as fallout; openapi
gains the path and the date bump), and the docs schema registry gains
the retry entry so TestDocsMatchRoutes passes.
2026-10-27 13:22:31 -04:00

298 lines
11 KiB
Go

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