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:
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user