feat(issues): the honest processing-issues ledger — row-less import failures, self-healing resolution, family lens (faceted plan Phase 11)
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.
This commit is contained in:
1 parent
19b9c7c9b8
commit
9b5defcb31
50 files changed
+1244
-681
No files matched your search
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
|
||||
"bookhoard/internal/services"
|
||||
)
|
||||
|
||||
type ProcessingIssuesHandler struct {
|
||||
@@ -19,6 +21,45 @@ 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"`
|
||||
@@ -40,10 +81,10 @@ type ProcessingIssueStats struct {
|
||||
|
||||
// ListProcessingIssues returns all processing issues for a library
|
||||
func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
||||
libraryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
|
||||
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(),
|
||||
@@ -55,10 +96,21 @@ func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
||||
|
||||
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: issue.MediaItemID.String(),
|
||||
Title: issue.Title,
|
||||
MediaItemID: itemID,
|
||||
Title: title,
|
||||
FilePath: issue.FilePath,
|
||||
FormatGroup: issue.FormatGroup,
|
||||
LibraryTypeName: issue.LibraryTypeName,
|
||||
@@ -74,10 +126,10 @@ func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
||||
|
||||
// GetProcessingIssueStats returns statistics about processing issues
|
||||
func (h *ProcessingIssuesHandler) GetProcessingIssueStats(c *echo.Context) error {
|
||||
libraryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
|
||||
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(),
|
||||
@@ -106,6 +158,10 @@ func (h *ProcessingIssuesHandler) ResolveProcessingIssue(c *echo.Context) error
|
||||
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{
|
||||
@@ -130,6 +186,10 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
|
||||
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"})
|
||||
@@ -141,6 +201,23 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
Reference in new issue
Block a user