feat(admin-console): Stage 6 — mail & notifications domain
Add the mail, notifications, and broadcast pages over the mail, notification,
and diplomail services (no new business logic), completing the operator console.
- GET /_gm/mail deliveries (paginated) + dead-letters
- GET /_gm/mail/deliveries/{id} delivery detail + attempts
- POST /_gm/mail/deliveries/{id}/resend re-enqueue a non-sent delivery
- GET /_gm/notifications notifications + dead-letters + malformed
- GET/POST /_gm/broadcast multi-game admin diplomatic broadcast
Console depends on MailAdmin / NotificationAdmin / DiplomailAdmin interfaces
(satisfied by the concrete services); pages render in tests without a database.
Delivery detail and dead-letters live under /_gm/mail/deliveries/* and
/_gm/mail/... static segments to avoid a param/static route conflict. Resend
and broadcast flow through the CSRF guard.
Tests: mail page, delivery detail (+ not-found), resend (+ bad-CSRF),
notifications overview, broadcast form + send (input assertions) + bad game
ids, and unavailable. Plus an integration test that drives /_gm end to end
through the real gateway → backend (401 challenge + authenticated dashboard).
Docs: backend/docs/admin-console.md page inventory completed.
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/diplomail"
|
||||
"galaxy/backend/internal/mail"
|
||||
"galaxy/backend/internal/notification"
|
||||
"galaxy/backend/internal/server/clientip"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// MailAdmin is the subset of the mail service the console uses.
|
||||
type MailAdmin interface {
|
||||
AdminListDeliveries(ctx context.Context, page, pageSize int) (mail.AdminListDeliveriesPage, error)
|
||||
AdminGetDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
|
||||
AdminListAttempts(ctx context.Context, deliveryID uuid.UUID) ([]mail.Attempt, error)
|
||||
AdminResendDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
|
||||
AdminListDeadLetters(ctx context.Context, page, pageSize int) (mail.AdminListDeadLettersPage, error)
|
||||
}
|
||||
|
||||
// NotificationAdmin is the subset of the notification service the console uses.
|
||||
type NotificationAdmin interface {
|
||||
AdminListNotifications(ctx context.Context, page, pageSize int) (notification.AdminListNotificationsPage, error)
|
||||
AdminListDeadLetters(ctx context.Context, page, pageSize int) (notification.AdminListDeadLettersPage, error)
|
||||
AdminListMalformed(ctx context.Context, page, pageSize int) (notification.AdminListMalformedPage, error)
|
||||
}
|
||||
|
||||
// DiplomailAdmin is the subset of the diplomail service the console uses.
|
||||
type DiplomailAdmin interface {
|
||||
SendAdminMultiGameBroadcast(ctx context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error)
|
||||
}
|
||||
|
||||
const consoleSnapshotPageSize = 50
|
||||
|
||||
// MailPage renders GET /_gm/mail — paginated deliveries plus a dead-letter snapshot.
|
||||
func (h *AdminConsoleHandlers) MailPage() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.mail == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
deliveries, err := h.mail.AdminListDeliveries(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list deliveries", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load deliveries.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
dead, err := h.mail.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list mail dead-letters", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load dead-letters.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "mail", "mail", "Mail", toMailData(deliveries, dead))
|
||||
}
|
||||
}
|
||||
|
||||
// MailDeliveryDetail renders GET /_gm/mail/deliveries/:delivery_id.
|
||||
func (h *AdminConsoleHandlers) MailDeliveryDetail() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.mail == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
deliveryID, ok := parseConsoleDeliveryID(c, h)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
delivery, err := h.mail.AdminGetDelivery(ctx, deliveryID)
|
||||
if err != nil {
|
||||
if errors.Is(err, mail.ErrDeliveryNotFound) {
|
||||
h.renderMessage(c, http.StatusNotFound, "mail", "Delivery not found", "No such delivery.", "bad", "/_gm/mail")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: get delivery", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load the delivery.", "bad", "/_gm/mail")
|
||||
return
|
||||
}
|
||||
attempts, err := h.mail.AdminListAttempts(ctx, deliveryID)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list attempts", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load attempts.", "bad", "/_gm/mail")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "mail_delivery", "mail", "Delivery", toMailDeliveryDetail(delivery, attempts))
|
||||
}
|
||||
}
|
||||
|
||||
// MailResend handles POST /_gm/mail/deliveries/:delivery_id/resend.
|
||||
func (h *AdminConsoleHandlers) MailResend() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.mail == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
deliveryID, ok := parseConsoleDeliveryID(c, h)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/mail/deliveries/" + deliveryID.String()
|
||||
if _, err := h.mail.AdminResendDelivery(c.Request.Context(), deliveryID); err != nil {
|
||||
h.logger.Error("admin console: resend delivery", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Resend failed", "Failed to resend the delivery (it may already be sent).", "bad", back)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, back)
|
||||
}
|
||||
}
|
||||
|
||||
// NotificationsPage renders GET /_gm/notifications — notifications, dead-letters,
|
||||
// and malformed intents on one overview page.
|
||||
func (h *AdminConsoleHandlers) NotificationsPage() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.notifications == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Notifications", "Notification administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
notifications, err := h.notifications.AdminListNotifications(ctx, 1, consoleSnapshotPageSize)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list notifications", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load notifications.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
dead, err := h.notifications.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list notification dead-letters", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load dead-letters.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
malformed, err := h.notifications.AdminListMalformed(ctx, 1, consoleSnapshotPageSize)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list malformed intents", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load malformed intents.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "notifications", "mail", "Notifications", toNotificationsData(notifications, dead, malformed))
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastForm renders GET /_gm/broadcast.
|
||||
func (h *AdminConsoleHandlers) BroadcastForm() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.diplomail == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "broadcast", "mail", "Broadcast", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastSend handles POST /_gm/broadcast — multi-game admin broadcast.
|
||||
func (h *AdminConsoleHandlers) BroadcastSend() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.diplomail == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||
gameIDs, err := parseGameIDList(c.PostForm("game_ids"))
|
||||
if err != nil {
|
||||
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "Game IDs must be valid UUIDs.", "bad", "/_gm/broadcast")
|
||||
return
|
||||
}
|
||||
_, total, err := h.diplomail.SendAdminMultiGameBroadcast(c.Request.Context(), diplomail.SendMultiGameBroadcastInput{
|
||||
CallerUsername: username,
|
||||
Scope: strings.TrimSpace(c.PostForm("scope")),
|
||||
GameIDs: gameIDs,
|
||||
RecipientScope: strings.TrimSpace(c.PostForm("recipients")),
|
||||
Subject: strings.TrimSpace(c.PostForm("subject")),
|
||||
Body: c.PostForm("body"),
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, diplomail.ErrInvalidInput) {
|
||||
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "The broadcast was rejected: check the scope, recipients, and body.", "bad", "/_gm/broadcast")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: broadcast", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Broadcast failed", "Failed to send the broadcast.", "bad", "/_gm/broadcast")
|
||||
return
|
||||
}
|
||||
h.renderMessage(c, http.StatusOK, "mail", "Broadcast sent", fmt.Sprintf("Broadcast delivered to %d recipients.", total), "ok", "/_gm/broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
// parseConsoleDeliveryID parses the delivery_id path parameter, rendering a
|
||||
// console message page on failure.
|
||||
func parseConsoleDeliveryID(c *gin.Context, h *AdminConsoleHandlers) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("delivery_id"))
|
||||
if err != nil {
|
||||
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "delivery_id must be a valid UUID.", "bad", "/_gm/mail")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// parseGameIDList parses a comma-separated list of UUIDs, ignoring blanks.
|
||||
func parseGameIDList(raw string) ([]uuid.UUID, error) {
|
||||
fields := strings.Split(raw, ",")
|
||||
ids := make([]uuid.UUID, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := uuid.Parse(field)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, parsed)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func toMailData(deliveries mail.AdminListDeliveriesPage, dead mail.AdminListDeadLettersPage) adminconsole.MailData {
|
||||
data := adminconsole.MailData{
|
||||
Deliveries: make([]adminconsole.MailDeliveryRow, 0, len(deliveries.Items)),
|
||||
DeadLetters: make([]adminconsole.MailDeadLetterRow, 0, len(dead.Items)),
|
||||
Page: deliveries.Page,
|
||||
PageSize: deliveries.PageSize,
|
||||
Total: deliveries.Total,
|
||||
PrevPage: deliveries.Page - 1,
|
||||
NextPage: deliveries.Page + 1,
|
||||
HasPrev: deliveries.Page > 1,
|
||||
HasNext: int64(deliveries.Page*deliveries.PageSize) < deliveries.Total,
|
||||
}
|
||||
for _, d := range deliveries.Items {
|
||||
data.Deliveries = append(data.Deliveries, adminconsole.MailDeliveryRow{
|
||||
DeliveryID: d.DeliveryID.String(),
|
||||
Template: d.TemplateID,
|
||||
Status: d.Status,
|
||||
Attempts: d.Attempts,
|
||||
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
|
||||
Created: fmtConsoleTime(d.CreatedAt),
|
||||
})
|
||||
}
|
||||
for _, d := range dead.Items {
|
||||
data.DeadLetters = append(data.DeadLetters, adminconsole.MailDeadLetterRow{
|
||||
DeliveryID: d.DeliveryID.String(),
|
||||
Reason: d.Reason,
|
||||
Archived: fmtConsoleTime(d.ArchivedAt),
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func toMailDeliveryDetail(d mail.Delivery, attempts []mail.Attempt) adminconsole.MailDeliveryDetail {
|
||||
detail := adminconsole.MailDeliveryDetail{
|
||||
DeliveryID: d.DeliveryID.String(),
|
||||
Template: d.TemplateID,
|
||||
Status: d.Status,
|
||||
Attempts: d.Attempts,
|
||||
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
|
||||
LastError: d.LastError,
|
||||
Created: fmtConsoleTime(d.CreatedAt),
|
||||
Sent: fmtConsoleTimePtr(d.SentAt),
|
||||
DeadLettered: fmtConsoleTimePtr(d.DeadLetteredAt),
|
||||
CanResend: d.Status != mail.StatusSent,
|
||||
AttemptRows: make([]adminconsole.MailAttemptRow, 0, len(attempts)),
|
||||
}
|
||||
for _, a := range attempts {
|
||||
detail.AttemptRows = append(detail.AttemptRows, adminconsole.MailAttemptRow{
|
||||
AttemptNo: a.AttemptNo,
|
||||
Outcome: a.Outcome,
|
||||
Started: fmtConsoleTime(a.StartedAt),
|
||||
Finished: fmtConsoleTimePtr(a.FinishedAt),
|
||||
Error: a.Error,
|
||||
})
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func toNotificationsData(notifications notification.AdminListNotificationsPage, dead notification.AdminListDeadLettersPage, malformed notification.AdminListMalformedPage) adminconsole.NotificationsData {
|
||||
data := adminconsole.NotificationsData{
|
||||
Notifications: make([]adminconsole.NotificationRow, 0, len(notifications.Items)),
|
||||
DeadLetters: make([]adminconsole.NotificationDeadLetterRow, 0, len(dead.Items)),
|
||||
Malformed: make([]adminconsole.MalformedRow, 0, len(malformed.Items)),
|
||||
}
|
||||
for _, n := range notifications.Items {
|
||||
data.Notifications = append(data.Notifications, adminconsole.NotificationRow{
|
||||
NotificationID: n.NotificationID.String(),
|
||||
Kind: n.Kind,
|
||||
UserID: optionalUUID(n.UserID),
|
||||
Created: fmtConsoleTime(n.CreatedAt),
|
||||
})
|
||||
}
|
||||
for _, d := range dead.Items {
|
||||
data.DeadLetters = append(data.DeadLetters, adminconsole.NotificationDeadLetterRow{
|
||||
NotificationID: d.NotificationID.String(),
|
||||
RouteID: d.RouteID.String(),
|
||||
Reason: d.Reason,
|
||||
Archived: fmtConsoleTime(d.ArchivedAt),
|
||||
})
|
||||
}
|
||||
for _, m := range malformed.Items {
|
||||
data.Malformed = append(data.Malformed, adminconsole.MalformedRow{
|
||||
ID: m.ID.String(),
|
||||
Reason: m.Reason,
|
||||
Received: fmtConsoleTime(m.ReceivedAt),
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// optionalUUID renders a nullable user id; system-scoped rows have none.
|
||||
func optionalUUID(id *uuid.UUID) string {
|
||||
if id == nil {
|
||||
return "—"
|
||||
}
|
||||
return id.String()
|
||||
}
|
||||
Reference in New Issue
Block a user