feat: backend service
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user