Files
bookhoard/internal/handlers/queue.go
T
john-okeefe 030d30a225 Add queue management API and database queries
- Add 7 new database queries for queue management
- GetStuckSyncQueueItems - Detect stuck items
- GetSyncQueueStats - Queue statistics
- GetNextRetryTime - Exponential backoff calc
- ListAllSyncQueueItems - Admin view
- IncrementSyncQueueAttempts - Retry counter
- Add queue handler with 7 REST endpoints
- GET /api/queue/devices/:id/stats - Queue statistics
- GET /api/queue/devices/:id/items - List device queue
- POST /api/queue/items/:id/retry - Retry failed item
- DELETE /api/queue/items/:id - Delete queue item
- DELETE /api/queue/devices/:id/clear - Clear device queue
- GET /api/queue/items - List all items (admin)
- Add full user/admin access control
2026-01-31 13:06:12 -05:00

330 lines
9.8 KiB
Go

package handlers
import (
"bookmann/internal/database"
"context"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
type QueueHandler struct {
db *database.Queries
queue interface {
GetQueueStats(ctx context.Context, deviceID pgtype.UUID) (database.GetSyncQueueStatsRow, error)
}
}
func NewQueueHandler(db *database.Queries, queue interface {
GetQueueStats(ctx context.Context, deviceID pgtype.UUID) (database.GetSyncQueueStatsRow, error)
}) *QueueHandler {
return &QueueHandler{db: db, queue: queue}
}
type QueueStatsResponse struct {
PendingCount int64 `json:"pending_count"`
ProcessingCount int64 `json:"processing_count"`
FailedCount int64 `json:"failed_count"`
CompletedCount int64 `json:"completed_count"`
TotalCount int64 `json:"total_count"`
}
type QueueItemResponse struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType string `json:"device_type"`
MediaItemID *string `json:"media_item_id,omitempty"`
MediaTitle *string `json:"media_title,omitempty"`
UserEmail string `json:"user_email"`
SyncType string `json:"sync_type"`
Priority int32 `json:"priority"`
Attempts int32 `json:"attempts"`
MaxAttempts int32 `json:"max_attempts"`
Status string `json:"status"`
ErrorMessage *string `json:"error_message,omitempty"`
CreatedAt string `json:"created_at"`
ProcessedAt *string `json:"processed_at,omitempty"`
}
func (h *QueueHandler) GetDeviceQueueStats(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
deviceID, err := uuid.Parse(c.Param("device_id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid device ID")
}
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "device not found")
}
if device.UserID.Bytes != user.ID.Bytes && user.Role != "admin" {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
stats, err := h.queue.GetQueueStats(c.Request().Context(), device.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get queue stats")
}
return c.JSON(http.StatusOK, QueueStatsResponse{
PendingCount: stats.PendingCount,
ProcessingCount: stats.ProcessingCount,
FailedCount: stats.FailedCount,
CompletedCount: stats.CompletedCount,
TotalCount: stats.TotalCount,
})
}
func (h *QueueHandler) ListDeviceQueueItems(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
deviceID, err := uuid.Parse(c.Param("device_id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid device ID")
}
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "device not found")
}
if device.UserID.Bytes != user.ID.Bytes && user.Role != "admin" {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
status := c.QueryParam("status")
limit := 50
if l := c.QueryParam("limit"); l != "" {
if parsedLimit, err := strconv.Atoi(l); err == nil && parsedLimit > 0 && parsedLimit <= 100 {
limit = parsedLimit
}
}
var items []database.SyncQueue
if status != "" {
items, err = h.db.ListPendingSyncQueueItems(c.Request().Context(), database.ListPendingSyncQueueItemsParams{
DeviceID: device.ID,
Limit: int32(limit),
})
} else {
items, err = h.db.ListPendingSyncQueueItems(c.Request().Context(), database.ListPendingSyncQueueItemsParams{
DeviceID: device.ID,
Limit: int32(limit),
})
}
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list queue items")
}
response := make([]QueueItemResponse, 0, len(items))
for _, item := range items {
response = append(response, QueueItemResponse{
ID: uuid.UUID(item.ID.Bytes).String(),
DeviceID: uuid.UUID(item.DeviceID.Bytes).String(),
MediaItemID: uuidPtrToString(item.MediaItemID),
SyncType: item.SyncType,
Priority: item.Priority.Int32,
Attempts: item.Attempts.Int32,
MaxAttempts: item.MaxAttempts.Int32,
Status: item.Status.String,
ErrorMessage: textPtrToString(item.ErrorMessage),
CreatedAt: item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"items": response,
"count": len(response),
})
}
func (h *QueueHandler) ListAllQueueItems(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return echo.NewHTTPError(http.StatusForbidden, "admin access required")
}
limit := 50
if l := c.QueryParam("limit"); l != "" {
if parsedLimit, err := strconv.Atoi(l); err == nil && parsedLimit > 0 && parsedLimit <= 100 {
limit = parsedLimit
}
}
offset := 0
if o := c.QueryParam("offset"); o != "" {
if parsedOffset, err := strconv.Atoi(o); err == nil && parsedOffset >= 0 {
offset = parsedOffset
}
}
items, err := h.db.ListAllSyncQueueItems(c.Request().Context(), database.ListAllSyncQueueItemsParams{
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list queue items")
}
response := make([]QueueItemResponse, 0, len(items))
for _, item := range items {
mediaTitle := textPtrToString(item.MediaTitle)
response = append(response, QueueItemResponse{
ID: uuid.UUID(item.ID.Bytes).String(),
DeviceID: uuid.UUID(item.DeviceID.Bytes).String(),
DeviceName: item.DeviceName,
DeviceType: item.DeviceType,
MediaItemID: uuidPtrToString(item.MediaItemID),
MediaTitle: mediaTitle,
UserEmail: item.UserEmail,
SyncType: item.SyncType,
Priority: item.Priority.Int32,
Attempts: item.Attempts.Int32,
MaxAttempts: item.MaxAttempts.Int32,
Status: item.Status.String,
ErrorMessage: textPtrToString(item.ErrorMessage),
CreatedAt: item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"items": response,
"count": len(response),
"limit": limit,
"offset": offset,
})
}
func (h *QueueHandler) RetryQueueItem(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
itemID, err := uuid.Parse(c.Param("item_id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid item ID")
}
item, err := h.db.GetSyncQueueItem(c.Request().Context(), pgtype.UUID{Bytes: itemID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "queue item not found")
}
device, err := h.db.GetDevice(c.Request().Context(), item.DeviceID)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "device not found")
}
if device.UserID.Bytes != user.ID.Bytes && user.Role != "admin" {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
updatedItem, err := h.db.UpdateSyncQueueItemStatus(c.Request().Context(), database.UpdateSyncQueueItemStatusParams{
ID: item.ID,
Status: pgtype.Text{String: "pending", Valid: true},
ErrorMessage: pgtype.Text{},
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to retry item")
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "item queued for retry",
"item_id": uuid.UUID(updatedItem.ID.Bytes).String(),
"status": updatedItem.Status.String,
"attempts": updatedItem.Attempts.Int32,
"max_attempts": updatedItem.MaxAttempts.Int32,
})
}
func (h *QueueHandler) DeleteQueueItem(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
itemID, err := uuid.Parse(c.Param("item_id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid item ID")
}
item, err := h.db.GetSyncQueueItem(c.Request().Context(), pgtype.UUID{Bytes: itemID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "queue item not found")
}
device, err := h.db.GetDevice(c.Request().Context(), item.DeviceID)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "device not found")
}
if device.UserID.Bytes != user.ID.Bytes && user.Role != "admin" {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
err = h.db.DeleteSyncQueueItem(c.Request().Context(), item.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to delete item")
}
return c.NoContent(http.StatusNoContent)
}
func (h *QueueHandler) ClearDeviceQueue(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
deviceID, err := uuid.Parse(c.Param("device_id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid device ID")
}
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "device not found")
}
if device.UserID.Bytes != user.ID.Bytes && user.Role != "admin" {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
err = h.db.ClearDeviceSyncQueue(c.Request().Context(), device.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to clear queue")
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "queue cleared",
})
}
func uuidPtrToString(u pgtype.UUID) *string {
if !u.Valid {
return nil
}
s := uuid.UUID(u.Bytes).String()
return &s
}
func textPtrToString(t pgtype.Text) *string {
if !t.Valid {
return nil
}
return &t.String
}
func timestamptzPtrToString(t pgtype.Timestamptz) *string {
if !t.Valid {
return nil
}
s := t.Time.Format("2006-01-02T15:04:05Z07:00")
return &s
}