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

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