processing_issues only recorded cross-library move rejections; files that failed to import vanished into an error counter and stdout. The ledger now records every failure class and heals itself: Schema (in place, pre-release boundary): - media_item_id NULLABLE + file_path TEXT (deliberately unbounded — an over-long path is itself a failure mode; a VARCHAR(500) ledger column died on the exact 22001 it existed to report — caught by the new test). Row-less failures key on (library, file_path); item-backed on (item, type). Widened dedup = COALESCE-expression unique index; the old table UNIQUE stays (NULLs pairwise-distinct for row-less). - Conflict-target upsert: repeat failures UPDATE the same row and REOPEN resolved ones — never duplicates. Wirings: - CreateMediaItem failure (single-file + multifile createAudioBookItem) → row-less import_failed (error). - Extraction hard-fail with fallback import → item-backed metadata_extraction_failed (warning). - Change-path re-extraction failure (the ev/sidecar guard) → item-backed metadata_extraction_failed; LogProcessingIssue keeps its signature, a row-less helper joins it. Auto-resolution: - Successful import → ResolveOpenProcessingIssuesForPath (library, file_path). - Successful (re)extraction → ResolveOpenProcessingIssuesForItem (single-file change path, multifile editorial apply). - Unchanged-skip scans do NOT resolve: extraction never re-ran, so the degraded metadata is still degraded — the page heals exactly when the book does. Family lens on ALL issues surfaces (list, stats, resolve, delete, SSR page): explicit out-of-set library id → 403, never silent. This tightens one pre-lens behavior — a nonexistent library id returned 200-empty, now 403 (pinned, changelog entry in docs/contract_pins.md). List is LEFT-JOINed so row-less rows appear (media_item_id "", title falls back to the path). Tests: full lifecycle state machine through real scans (fail → appears, repeat → no dups, success-at-path → resolved, LEFT-JOIN list shape); family lens 403s with a fresh non-shared admin; all 7 pre-existing issue suites green (two expectations widened to the lens, changelogged); adjacent suites (CrossLibraryMove, Audiobook, Calibre, ExtractorVersion, SchemaIdempotency) green; unit suites green.
225 lines
7.5 KiB
Go
225 lines
7.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"context"
|
|
"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
|
|
}
|
|
|
|
// 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)
|
|
}
|