feat(admin): Add processing issues management UI and API

- Add ProcessingIssuesHandler with List and GetStats methods
- Add AdminProcessingIssues template for issues dashboard
- Display error/warning/info stats cards
- Sort issues by severity and creation date
- Add dismiss functionality for warnings and info items
- Add navigate to media item functionality
- Show issue type, description, and media details
This commit is contained in:
2026-04-12 20:43:57 -04:00
parent 595072c50d
commit 492892097d
2 changed files with 234 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
package handlers
import (
"bookhoard/internal/database"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type ProcessingIssuesHandler struct {
db *database.Queries
}
func NewProcessingIssuesHandler(db *database.Queries) *ProcessingIssuesHandler {
return &ProcessingIssuesHandler{db: db}
}
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 {
libraryID, err := uuid.Parse(c.Param("libraryId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library 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 {
response[i] = ProcessingIssueResponse{
ID: issue.ID.String(),
MediaItemID: issue.MediaItemID.String(),
Title: issue.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 {
libraryID, err := uuid.Parse(c.Param("libraryId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library 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"})
}
_, err = h.db.ResolveProcessingIssue(
c.Request().Context(),
issueID,
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"})
}
_, err = h.db.DeleteProcessingIssue(c.Request().Context(), issueID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to delete issue"})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"success": true,
"message": "Issue deleted",
})
}
+95
View File
@@ -0,0 +1,95 @@
package templates
templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssueData, stats IssueStats) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Processing Issues - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body x-data="processingIssues" x-init="initializeProcessingIssues('{ libraryID }')" class="theme-{ user.Theme }">
@Header(user, "/admin/libraries/"+libraryID)
<main class="flex-1 p-8">
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-3xl font-bold mb-2">Processing Issues</h1>
<p class="text-gray-600">Items that couldn't be processed in this library</p>
</div>
<a href="/admin/libraries/{ libraryID }" class="btn-secondary px-4 py-2 rounded-lg">
Back to Library
</a>
</div>
</div>
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
if stats.ErrorCount > 0 {
<div class="card p-6 rounded-lg border-l-4 border-red-500">
<h3 class="text-lg font-semibold text-red-600 mb-2">Errors</h3>
<p class="text-3xl font-bold">{ stats.ErrorCount }</p>
</div>
}
if stats.WarningCount > 0 {
<div class="card p-6 rounded-lg border-l-4 border-yellow-500">
<h3 class="text-lg font-semibold text-yellow-600 mb-2">Warnings</h3>
<p class="text-3xl font-bold">{ stats.WarningCount }</p>
</div>
}
if stats.InfoCount > 0 {
<div class="card p-6 rounded-lg border-l-4 border-blue-500">
<h3 class="text-lg font-semibold text-blue-600 mb-2">Info</h3>
<p class="text-3xl font-bold">{ stats.InfoCount }</p>
</div>
}
</div>
}
if len(issues) == 0 {
<div class="card p-8 rounded-lg text-center">
<p class="text-gray-600">No processing issues found for this library.</p>
</div>
} else {
<!-- Issues List -->
<div class="space-y-4">
for _, issue := range issues {
<div class="card p-6 rounded-lg">
<div class="flex justify-between items-start mb-4">
<div class="flex-1">
<h4 class="text-lg font-semibold mb-2">{ issue.Title }</h4>
<p class="text-gray-700 mb-3">{ issue.IssueDescription }</p>
<div class="text-sm text-gray-500 space-y-1">
<p><strong>Type:</strong> { issue.IssueType }</p>
<p><strong>Format:</strong> { issue.FormatGroup }</p>
<p><strong>File:</strong> { issue.FilePath }</p>
<p><strong>Library:</strong> { issue.LibraryTypeName }</p>
</div>
</div>
<div class="ml-4">
<span
class="inline-block px-3 py-1 text-sm rounded-full font-medium"
style="background-color: var(--accent); color: white;"
>
{ issue.Severity }
</span>
</div>
</div>
<div class="flex gap-3 mt-4">
if issue.Severity == "warning" || issue.Severity == "info" {
<button
@click="dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')"
class="btn-secondary px-4 py-2 rounded text-sm"
>
Dismiss
</button>
}
</div>
</div>
}
</div>
}
</main>
</body>
</html>
}