feat: backend service
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/mail"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminMailHandlers groups the admin-side mail-outbox handlers under
|
||||
// `/api/v1/admin/mail/*`. The wiring connects real bodies backed by
|
||||
// `*mail.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 AdminMailHandlers struct {
|
||||
svc *mail.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminMailHandlers constructs the handler set. svc may be nil — in
|
||||
// that case every handler returns 501 not_implemented, matching the
|
||||
// pre-Stage-5.6 placeholder. logger may also be nil; zap.NewNop is
|
||||
// used in that case.
|
||||
func NewAdminMailHandlers(svc *mail.Service, logger *zap.Logger) *AdminMailHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &AdminMailHandlers{svc: svc, logger: logger.Named("http.admin.mail")}
|
||||
}
|
||||
|
||||
// ListDeliveries handles GET /api/v1/admin/mail/deliveries.
|
||||
func (h *AdminMailHandlers) ListDeliveries() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminMailListDeliveries")
|
||||
}
|
||||
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.AdminListDeliveries(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
respondMailError(c, h.logger, "admin mail list deliveries", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailDeliveryListToWire(out))
|
||||
}
|
||||
}
|
||||
|
||||
// GetDelivery handles GET /api/v1/admin/mail/deliveries/{delivery_id}.
|
||||
func (h *AdminMailHandlers) GetDelivery() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminMailGetDelivery")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
deliveryID, ok := parseDeliveryIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
d, err := h.svc.AdminGetDelivery(ctx, deliveryID)
|
||||
if err != nil {
|
||||
respondMailError(c, h.logger, "admin mail get delivery", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailDeliveryToWire(d))
|
||||
}
|
||||
}
|
||||
|
||||
// ListDeliveryAttempts handles GET /api/v1/admin/mail/deliveries/{delivery_id}/attempts.
|
||||
func (h *AdminMailHandlers) ListDeliveryAttempts() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminMailListDeliveryAttempts")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
deliveryID, ok := parseDeliveryIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
attempts, err := h.svc.AdminListAttempts(ctx, deliveryID)
|
||||
if err != nil {
|
||||
respondMailError(c, h.logger, "admin mail list attempts", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailAttemptListToWire(attempts))
|
||||
}
|
||||
}
|
||||
|
||||
// ResendDelivery handles POST /api/v1/admin/mail/deliveries/{delivery_id}/resend.
|
||||
func (h *AdminMailHandlers) ResendDelivery() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminMailResendDelivery")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
deliveryID, ok := parseDeliveryIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
d, err := h.svc.AdminResendDelivery(ctx, deliveryID)
|
||||
if err != nil {
|
||||
respondMailError(c, h.logger, "admin mail resend delivery", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, mailDeliveryToWire(d))
|
||||
}
|
||||
}
|
||||
|
||||
// ListDeadLetters handles GET /api/v1/admin/mail/dead-letters.
|
||||
func (h *AdminMailHandlers) ListDeadLetters() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminMailListDeadLetters")
|
||||
}
|
||||
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 {
|
||||
respondMailError(c, h.logger, "admin mail list dead-letters", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailDeadLetterListToWire(out))
|
||||
}
|
||||
}
|
||||
|
||||
// parseDeliveryIDParam reads `delivery_id` from the path. On invalid
|
||||
// input it writes the standard 400 envelope and returns
|
||||
// (uuid.Nil, false).
|
||||
func parseDeliveryIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("delivery_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "delivery_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// respondMailError translates the mail-domain sentinels to HTTP. Any
|
||||
// other error is logged and surfaced as 500 internal_error so the
|
||||
// handler always emits the documented envelope.
|
||||
func respondMailError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, mail.ErrDeliveryNotFound):
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "mail delivery not found")
|
||||
case errors.Is(err, mail.ErrResendOnSent):
|
||||
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, "delivery already sent")
|
||||
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 schemas in `backend/openapi.yaml`.
|
||||
|
||||
type mailDeliveryWire struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
TemplateID string `json:"template_id"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Attempts int32 `json:"attempts"`
|
||||
NextAttemptAt *string `json:"next_attempt_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type mailDeliveryListWire struct {
|
||||
Items []mailDeliveryWire `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type mailAttemptWire struct {
|
||||
AttemptID string `json:"attempt_id"`
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
AttemptNo int32 `json:"attempt_no"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt *string `json:"finished_at,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type mailAttemptListWire struct {
|
||||
Items []mailAttemptWire `json:"items"`
|
||||
}
|
||||
|
||||
type mailDeadLetterWire struct {
|
||||
DeadLetterID string `json:"dead_letter_id"`
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
ArchivedAt string `json:"archived_at"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type mailDeadLetterListWire struct {
|
||||
Items []mailDeadLetterWire `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func mailDeliveryToWire(d mail.Delivery) mailDeliveryWire {
|
||||
out := mailDeliveryWire{
|
||||
DeliveryID: d.DeliveryID.String(),
|
||||
TemplateID: d.TemplateID,
|
||||
IdempotencyKey: d.IdempotencyKey,
|
||||
Status: d.Status,
|
||||
Attempts: d.Attempts,
|
||||
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if d.NextAttemptAt != nil {
|
||||
s := d.NextAttemptAt.UTC().Format(time.RFC3339Nano)
|
||||
out.NextAttemptAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mailDeliveryListToWire(p mail.AdminListDeliveriesPage) mailDeliveryListWire {
|
||||
items := make([]mailDeliveryWire, 0, len(p.Items))
|
||||
for _, d := range p.Items {
|
||||
items = append(items, mailDeliveryToWire(d))
|
||||
}
|
||||
return mailDeliveryListWire{
|
||||
Items: items,
|
||||
Page: p.Page,
|
||||
PageSize: p.PageSize,
|
||||
Total: p.Total,
|
||||
}
|
||||
}
|
||||
|
||||
func mailAttemptToWire(a mail.Attempt) mailAttemptWire {
|
||||
out := mailAttemptWire{
|
||||
AttemptID: a.AttemptID.String(),
|
||||
DeliveryID: a.DeliveryID.String(),
|
||||
AttemptNo: a.AttemptNo,
|
||||
StartedAt: a.StartedAt.UTC().Format(time.RFC3339Nano),
|
||||
Outcome: a.Outcome,
|
||||
Error: a.Error,
|
||||
}
|
||||
if a.FinishedAt != nil {
|
||||
s := a.FinishedAt.UTC().Format(time.RFC3339Nano)
|
||||
out.FinishedAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mailAttemptListToWire(items []mail.Attempt) mailAttemptListWire {
|
||||
out := mailAttemptListWire{Items: make([]mailAttemptWire, 0, len(items))}
|
||||
for _, a := range items {
|
||||
out.Items = append(out.Items, mailAttemptToWire(a))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mailDeadLetterToWire(dl mail.DeadLetter) mailDeadLetterWire {
|
||||
return mailDeadLetterWire{
|
||||
DeadLetterID: dl.DeadLetterID.String(),
|
||||
DeliveryID: dl.DeliveryID.String(),
|
||||
ArchivedAt: dl.ArchivedAt.UTC().Format(time.RFC3339Nano),
|
||||
Reason: dl.Reason,
|
||||
}
|
||||
}
|
||||
|
||||
func mailDeadLetterListToWire(p mail.AdminListDeadLettersPage) mailDeadLetterListWire {
|
||||
items := make([]mailDeadLetterWire, 0, len(p.Items))
|
||||
for _, dl := range p.Items {
|
||||
items = append(items, mailDeadLetterToWire(dl))
|
||||
}
|
||||
return mailDeadLetterListWire{
|
||||
Items: items,
|
||||
Page: p.Page,
|
||||
PageSize: p.PageSize,
|
||||
Total: p.Total,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user