Files
galaxy-game/backend/internal/server/handlers_admin_notifications.go
T
2026-05-06 10:14:55 +03:00

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