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
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user