feat(discovery): add HTTP handlers and route registration

This commit is contained in:
2026-04-18 07:43:38 +02:00
parent 4a9d1ff908
commit f0ba134514
2 changed files with 140 additions and 0 deletions

View File

@@ -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
}
}

View File

@@ -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)
}
}