From f0ba134514cbb620992ef3bff9474d71a1e5a3fa Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 18 Apr 2026 07:43:38 +0200 Subject: [PATCH] feat(discovery): add HTTP handlers and route registration --- backend/internal/domain/discovery/handler.go | 118 +++++++++++++++++++ backend/internal/domain/discovery/routes.go | 22 ++++ 2 files changed, 140 insertions(+) create mode 100644 backend/internal/domain/discovery/handler.go create mode 100644 backend/internal/domain/discovery/routes.go diff --git a/backend/internal/domain/discovery/handler.go b/backend/internal/domain/discovery/handler.go new file mode 100644 index 0000000..90e978c --- /dev/null +++ b/backend/internal/domain/discovery/handler.go @@ -0,0 +1,118 @@ +package discovery + +import ( + "log/slog" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "marktvogt.de/backend/internal/pkg/apierror" +) + +type Handler struct { + service *Service +} + +func NewHandler(s *Service) *Handler { + return &Handler{service: s} +} + +func (h *Handler) Tick(c *gin.Context) { + summary, err := h.service.Tick(c.Request.Context()) + if err != nil { + slog.ErrorContext(c.Request.Context(), "discovery tick failed", "error", err) + apiErr := apierror.Internal("tick failed") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + c.JSON(http.StatusOK, gin.H{"data": summary}) +} + +func (h *Handler) ListQueue(c *gin.Context) { + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + if limit < 1 || limit > 200 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + rows, err := h.service.ListPendingQueue(c.Request.Context(), limit, offset) + if err != nil { + apiErr := apierror.Internal("list queue failed") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + c.JSON(http.StatusOK, gin.H{"data": rows}) +} + +func (h *Handler) Accept(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + apiErr := apierror.BadRequest("invalid_id", "invalid queue id") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + reviewer, ok := currentUserID(c) + if !ok { + apiErr := apierror.Unauthorized("no user in context") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + seriesID, editionID, err := h.service.Accept(c.Request.Context(), id, reviewer) + if err != nil { + slog.WarnContext(c.Request.Context(), "accept failed", "queue_id", id, "error", err) + apiErr := apierror.Internal("accept failed") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + c.JSON(http.StatusOK, gin.H{"data": gin.H{"series_id": seriesID, "edition_id": editionID}}) +} + +type rejectRequest struct { + Reason string `json:"reason" validate:"max=2000"` +} + +func (h *Handler) Reject(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + apiErr := apierror.BadRequest("invalid_id", "invalid queue id") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + var req rejectRequest + // Empty body is OK (reason is optional). Non-EOF parse errors are tolerated; + // we fall through with zero-value req in all error cases. + _ = c.ShouldBindJSON(&req) + reviewer, ok := currentUserID(c) + if !ok { + apiErr := apierror.Unauthorized("no user in context") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + if err := h.service.Reject(c.Request.Context(), id, reviewer, req.Reason); err != nil { + slog.WarnContext(c.Request.Context(), "reject failed", "queue_id", id, "error", err) + apiErr := apierror.Internal("reject failed") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + c.Status(http.StatusNoContent) +} + +func currentUserID(c *gin.Context) (uuid.UUID, bool) { + raw, exists := c.Get("user_id") + if !exists { + return uuid.Nil, false + } + switch v := raw.(type) { + case uuid.UUID: + return v, true + case string: + id, err := uuid.Parse(v) + return id, err == nil + default: + return uuid.Nil, false + } +} diff --git a/backend/internal/domain/discovery/routes.go b/backend/internal/domain/discovery/routes.go new file mode 100644 index 0000000..6aba4ce --- /dev/null +++ b/backend/internal/domain/discovery/routes.go @@ -0,0 +1,22 @@ +package discovery + +import "github.com/gin-gonic/gin" + +// RegisterRoutes mounts both the admin-session routes (queue mgmt) and the +// bearer-token route (tick). The two middlewares are passed in separately. +func RegisterRoutes( + rg *gin.RouterGroup, + h *Handler, + requireAuth, requireAdmin, requireTickToken gin.HandlerFunc, +) { + // Machine-driven tick (bearer token). + rg.POST("/admin/discovery/tick", requireTickToken, h.Tick) + + // Admin-session queue routes. + admin := rg.Group("/admin/discovery", requireAuth, requireAdmin) + { + admin.GET("/queue", h.ListQueue) + admin.POST("/queue/:id/accept", h.Accept) + admin.POST("/queue/:id/reject", h.Reject) + } +}