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.
298 lines
11 KiB
Go
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)
|
|
}
|