286 lines
8.5 KiB
Go
286 lines
8.5 KiB
Go
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,
|
|
}
|
|
}
|