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