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