256 lines
8.0 KiB
Go
256 lines
8.0 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"galaxy/backend/internal/notification"
|
|
"galaxy/backend/internal/server/handlers"
|
|
"galaxy/backend/internal/server/httperr"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// AdminNotificationsHandlers groups the admin-side notification handlers
|
|
// under `/api/v1/admin/notifications/*`. The wiring connects real bodies
|
|
// backed by `*notification.Service`; tests that supply a nil service
|
|
// fall back to the Stage-3 placeholder body so the contract test
|
|
// continues to validate the OpenAPI envelope without booting Postgres.
|
|
type AdminNotificationsHandlers struct {
|
|
svc *notification.Service
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewAdminNotificationsHandlers constructs the handler set. svc may be
|
|
// nil — in that case every handler returns 501 not_implemented,
|
|
// matching the pre-Stage-5.7 placeholder. logger may also be nil;
|
|
// zap.NewNop is used in that case.
|
|
func NewAdminNotificationsHandlers(svc *notification.Service, logger *zap.Logger) *AdminNotificationsHandlers {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &AdminNotificationsHandlers{svc: svc, logger: logger.Named("http.admin.notifications")}
|
|
}
|
|
|
|
// List handles GET /api/v1/admin/notifications.
|
|
func (h *AdminNotificationsHandlers) List() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("adminNotificationsList")
|
|
}
|
|
return func(c *gin.Context) {
|
|
page := parsePositiveQueryInt(c.Query("page"), 1)
|
|
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
|
ctx := c.Request.Context()
|
|
out, err := h.svc.AdminListNotifications(ctx, page, pageSize)
|
|
if err != nil {
|
|
respondNotificationError(c, h.logger, "admin notifications list", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, notificationListToWire(out))
|
|
}
|
|
}
|
|
|
|
// Get handles GET /api/v1/admin/notifications/{notification_id}.
|
|
func (h *AdminNotificationsHandlers) Get() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("adminNotificationsGet")
|
|
}
|
|
return func(c *gin.Context) {
|
|
id, ok := parseNotificationIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
n, err := h.svc.AdminGetNotification(ctx, id)
|
|
if err != nil {
|
|
respondNotificationError(c, h.logger, "admin notifications get", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, notificationToWire(n))
|
|
}
|
|
}
|
|
|
|
// ListDeadLetters handles GET /api/v1/admin/notifications/dead-letters.
|
|
func (h *AdminNotificationsHandlers) ListDeadLetters() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("adminNotificationsListDeadLetters")
|
|
}
|
|
return func(c *gin.Context) {
|
|
page := parsePositiveQueryInt(c.Query("page"), 1)
|
|
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
|
ctx := c.Request.Context()
|
|
out, err := h.svc.AdminListDeadLetters(ctx, page, pageSize)
|
|
if err != nil {
|
|
respondNotificationError(c, h.logger, "admin notifications list dead-letters", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, notificationDeadLetterListToWire(out))
|
|
}
|
|
}
|
|
|
|
// ListMalformed handles GET /api/v1/admin/notifications/malformed.
|
|
func (h *AdminNotificationsHandlers) ListMalformed() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("adminNotificationsListMalformed")
|
|
}
|
|
return func(c *gin.Context) {
|
|
page := parsePositiveQueryInt(c.Query("page"), 1)
|
|
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
|
ctx := c.Request.Context()
|
|
out, err := h.svc.AdminListMalformed(ctx, page, pageSize)
|
|
if err != nil {
|
|
respondNotificationError(c, h.logger, "admin notifications list malformed", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, notificationMalformedListToWire(out))
|
|
}
|
|
}
|
|
|
|
// parseNotificationIDParam reads `notification_id` from the path. On
|
|
// invalid input it writes the standard 400 envelope and returns
|
|
// (uuid.Nil, false).
|
|
func parseNotificationIDParam(c *gin.Context) (uuid.UUID, bool) {
|
|
parsed, err := uuid.Parse(c.Param("notification_id"))
|
|
if err != nil {
|
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "notification_id must be a valid UUID")
|
|
return uuid.Nil, false
|
|
}
|
|
return parsed, true
|
|
}
|
|
|
|
// respondNotificationError translates the notification-domain sentinels
|
|
// to HTTP. Any other error is logged and surfaced as 500 internal_error.
|
|
func respondNotificationError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
|
switch {
|
|
case errors.Is(err, notification.ErrNotificationNotFound):
|
|
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "notification not found")
|
|
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
|
httperr.Abort(c, http.StatusServiceUnavailable, httperr.CodeServiceUnavailable, "request cancelled")
|
|
default:
|
|
logger.Error(op+" failed", zap.Error(err))
|
|
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal server error")
|
|
}
|
|
_ = ctx
|
|
}
|
|
|
|
// Wire DTOs mirror the OpenAPI schemas in `backend/openapi.yaml`.
|
|
|
|
type notificationWire struct {
|
|
NotificationID string `json:"notification_id"`
|
|
Kind string `json:"kind"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
Payload map[string]any `json:"payload,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
type notificationListWire struct {
|
|
Items []notificationWire `json:"items"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"page_size"`
|
|
Total int64 `json:"total"`
|
|
}
|
|
|
|
type notificationDeadLetterWire struct {
|
|
DeadLetterID string `json:"dead_letter_id"`
|
|
NotificationID string `json:"notification_id"`
|
|
ArchivedAt string `json:"archived_at"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type notificationDeadLetterListWire struct {
|
|
Items []notificationDeadLetterWire `json:"items"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"page_size"`
|
|
Total int64 `json:"total"`
|
|
}
|
|
|
|
type notificationMalformedWire struct {
|
|
ID string `json:"id"`
|
|
ReceivedAt string `json:"received_at"`
|
|
Payload map[string]any `json:"payload,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type notificationMalformedListWire struct {
|
|
Items []notificationMalformedWire `json:"items"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"page_size"`
|
|
Total int64 `json:"total"`
|
|
}
|
|
|
|
func notificationToWire(n notification.Notification) notificationWire {
|
|
out := notificationWire{
|
|
NotificationID: n.NotificationID.String(),
|
|
Kind: n.Kind,
|
|
IdempotencyKey: n.IdempotencyKey,
|
|
Payload: n.Payload,
|
|
CreatedAt: n.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if n.UserID != nil {
|
|
out.UserID = n.UserID.String()
|
|
}
|
|
return out
|
|
}
|
|
|
|
func notificationListToWire(p notification.AdminListNotificationsPage) notificationListWire {
|
|
items := make([]notificationWire, 0, len(p.Items))
|
|
for _, n := range p.Items {
|
|
items = append(items, notificationToWire(n))
|
|
}
|
|
return notificationListWire{
|
|
Items: items,
|
|
Page: p.Page,
|
|
PageSize: p.PageSize,
|
|
Total: p.Total,
|
|
}
|
|
}
|
|
|
|
func notificationDeadLetterToWire(dl notification.DeadLetter) notificationDeadLetterWire {
|
|
return notificationDeadLetterWire{
|
|
DeadLetterID: dl.DeadLetterID.String(),
|
|
NotificationID: dl.NotificationID.String(),
|
|
ArchivedAt: dl.ArchivedAt.UTC().Format(time.RFC3339Nano),
|
|
Reason: dl.Reason,
|
|
}
|
|
}
|
|
|
|
func notificationDeadLetterListToWire(p notification.AdminListDeadLettersPage) notificationDeadLetterListWire {
|
|
items := make([]notificationDeadLetterWire, 0, len(p.Items))
|
|
for _, dl := range p.Items {
|
|
items = append(items, notificationDeadLetterToWire(dl))
|
|
}
|
|
return notificationDeadLetterListWire{
|
|
Items: items,
|
|
Page: p.Page,
|
|
PageSize: p.PageSize,
|
|
Total: p.Total,
|
|
}
|
|
}
|
|
|
|
func notificationMalformedToWire(m notification.MalformedIntent) notificationMalformedWire {
|
|
return notificationMalformedWire{
|
|
ID: m.ID.String(),
|
|
ReceivedAt: m.ReceivedAt.UTC().Format(time.RFC3339Nano),
|
|
Payload: m.Payload,
|
|
Reason: m.Reason,
|
|
}
|
|
}
|
|
|
|
func notificationMalformedListToWire(p notification.AdminListMalformedPage) notificationMalformedListWire {
|
|
items := make([]notificationMalformedWire, 0, len(p.Items))
|
|
for _, m := range p.Items {
|
|
items = append(items, notificationMalformedToWire(m))
|
|
}
|
|
return notificationMalformedListWire{
|
|
Items: items,
|
|
Page: p.Page,
|
|
PageSize: p.PageSize,
|
|
Total: p.Total,
|
|
}
|
|
}
|