diplomail (Stage B): admin/owner sends + lifecycle hooks
Tests · Go / test (push) Successful in 1m52s
Tests · Go / test (pull_request) Successful in 1m53s
Tests · Integration / integration (pull_request) Successful in 1m36s

Item 7 of the spec wants game-state and membership-state changes to
land as durable inbox entries the affected players can re-read after
the fact — push alone times out of the 5-minute ring buffer. Stage B
adds the admin-kind send matrix (owner-driven via /user, site-admin
driven via /admin) plus the lobby lifecycle hooks: paused / cancelled
emit a broadcast system mail to active members, kick / ban emit a
single-recipient system mail to the affected user (which they keep
read access to even after the membership row is revoked, per item 8).

Migration relaxes diplomail_messages_kind_sender_chk so an owner
sending kind=admin keeps sender_kind=player; the new
LifecyclePublisher dep on lobby.Service is wired through a thin
adapter in cmd/backend/main, mirroring how lobby's notification
publisher is plumbed today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-15 18:47:54 +02:00
parent 535e27008f
commit b3f24cc440
17 changed files with 1398 additions and 23 deletions
+12
View File
@@ -155,6 +155,18 @@ var requestBodyStubs = map[string]map[string]any{
"subject": "Contract test subject",
"body": "Contract test body",
},
"userMailSendAdmin": {
"target": "user",
"recipient_user_id": pathParamStubs["user_id"],
"subject": "Contract test admin subject",
"body": "Contract test admin body",
},
"adminDiplomailSend": {
"target": "user",
"recipient_user_id": pathParamStubs["user_id"],
"subject": "Contract test admin subject",
"body": "Contract test admin body",
},
}
// TestOpenAPIContract is the top-level OpenAPI contract test. It
@@ -0,0 +1,101 @@
package server
import (
"net/http"
"galaxy/backend/internal/diplomail"
"galaxy/backend/internal/server/clientip"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/basicauth"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// AdminDiplomailHandlers groups the diplomatic-mail handlers exposed
// under `/api/v1/admin/games/{game_id}/mail` (per-game admin send /
// broadcast). The handler is intentionally separate from
// `AdminMailHandlers`, which owns the unrelated email outbox surface
// under `/api/v1/admin/mail/*`.
type AdminDiplomailHandlers struct {
svc *diplomail.Service
logger *zap.Logger
}
// NewAdminDiplomailHandlers constructs the handler set. svc may be
// nil — in that case every handler returns 501 not_implemented.
func NewAdminDiplomailHandlers(svc *diplomail.Service, logger *zap.Logger) *AdminDiplomailHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &AdminDiplomailHandlers{svc: svc, logger: logger.Named("http.admin.diplomail")}
}
// Send handles POST /api/v1/admin/games/{game_id}/mail. The body
// shape mirrors the owner route: `target="user"` requires
// `recipient_user_id`; `target="all"` accepts an optional
// `recipients` scope. The authenticated admin username is captured
// from the basicauth context and persisted as `sender_username`.
func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminDiplomailSend")
}
return func(c *gin.Context) {
username, ok := basicauth.UsernameFromContext(c.Request.Context())
if !ok || username == "" {
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
var req userMailSendAdminRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
ctx := c.Request.Context()
switch req.Target {
case "", "user":
recipientID, parseErr := uuid.Parse(req.RecipientUserID)
if parseErr != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
return
}
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
GameID: gameID,
CallerKind: diplomail.CallerKindAdmin,
CallerUsername: username,
RecipientUserID: recipientID,
Subject: req.Subject,
Body: req.Body,
SenderIP: clientip.ExtractSourceIP(c),
})
if sendErr != nil {
respondDiplomailError(c, h.logger, "admin mail send personal", ctx, sendErr)
return
}
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
case "all":
msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
GameID: gameID,
CallerKind: diplomail.CallerKindAdmin,
CallerUsername: username,
RecipientScope: req.Recipients,
Subject: req.Subject,
Body: req.Body,
SenderIP: clientip.ExtractSourceIP(c),
})
if sendErr != nil {
respondDiplomailError(c, h.logger, "admin mail send broadcast", ctx, sendErr)
return
}
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
default:
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'")
}
}
}
+155 -6
View File
@@ -6,11 +6,13 @@ import (
"net/http"
"galaxy/backend/internal/diplomail"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/server/clientip"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/userid"
"galaxy/backend/internal/telemetry"
"galaxy/backend/internal/user"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -19,20 +21,31 @@ import (
// UserMailHandlers groups the diplomatic-mail handlers under
// `/api/v1/user/games/{game_id}/mail/*` and the lobby-side
// `/api/v1/user/lobby/mail/unread-counts`. Stage A wires only the
// personal-message subset.
// `/api/v1/user/lobby/mail/unread-counts`. Stage A wires the
// personal subset; Stage B adds the owner-only admin send path,
// which needs `*lobby.Service` to confirm ownership and `*user.Service`
// to resolve the owner's `user_name` for the `sender_username` column.
type UserMailHandlers struct {
svc *diplomail.Service
svc *diplomail.Service
lobby *lobby.Service
users *user.Service
logger *zap.Logger
}
// NewUserMailHandlers constructs the handler set. svc may be nil — in
// that case every handler returns 501 not_implemented.
func NewUserMailHandlers(svc *diplomail.Service, logger *zap.Logger) *UserMailHandlers {
// that case every handler returns 501 not_implemented. lobby and
// users are optional: when either is nil the admin-send handler
// degrades to 501 (the personal-send and read paths stay functional).
func NewUserMailHandlers(svc *diplomail.Service, lobbySvc *lobby.Service, users *user.Service, logger *zap.Logger) *UserMailHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &UserMailHandlers{svc: svc, logger: logger.Named("http.user.mail")}
return &UserMailHandlers{
svc: svc,
lobby: lobbySvc,
users: users,
logger: logger.Named("http.user.mail"),
}
}
// SendPersonal handles POST /api/v1/user/games/{game_id}/mail/messages.
@@ -219,6 +232,96 @@ func (h *UserMailHandlers) Delete() gin.HandlerFunc {
}
}
// SendAdmin handles POST /api/v1/user/games/{game_id}/mail/admin.
//
// Owner-only: the caller must be the owner of the private game. The
// handler resolves the owner's `user_name` so the
// `sender_username` column carries a useful identity, then routes to
// SendAdminPersonal (for `target="user"`) or SendAdminBroadcast (for
// `target="all"`). Site administrators use the separate admin route
// in `handlers_admin_mail_send.go`.
func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
if h.svc == nil || h.lobby == nil || h.users == nil {
return handlers.NotImplemented("userMailSendAdmin")
}
return func(c *gin.Context) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
var req userMailSendAdminRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
ctx := c.Request.Context()
game, err := h.lobby.GetGame(ctx, gameID)
if err != nil {
respondLobbyError(c, h.logger, "user mail send admin: load game", ctx, err)
return
}
if game.OwnerUserID == nil || *game.OwnerUserID != userID {
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "caller is not the owner of this game")
return
}
account, err := h.users.GetAccount(ctx, userID)
if err != nil {
respondAccountError(c, h.logger, "user mail send admin: resolve user_name", ctx, err)
return
}
switch req.Target {
case "", "user":
recipientID, parseErr := uuid.Parse(req.RecipientUserID)
if parseErr != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
return
}
callerUserID := userID
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
GameID: gameID,
CallerKind: diplomail.CallerKindOwner,
CallerUserID: &callerUserID,
CallerUsername: account.UserName,
RecipientUserID: recipientID,
Subject: req.Subject,
Body: req.Body,
SenderIP: clientip.ExtractSourceIP(c),
})
if sendErr != nil {
respondDiplomailError(c, h.logger, "user mail send admin personal", ctx, sendErr)
return
}
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
case "all":
callerUserID := userID
msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
GameID: gameID,
CallerKind: diplomail.CallerKindOwner,
CallerUserID: &callerUserID,
CallerUsername: account.UserName,
RecipientScope: req.Recipients,
Subject: req.Subject,
Body: req.Body,
SenderIP: clientip.ExtractSourceIP(c),
})
if sendErr != nil {
respondDiplomailError(c, h.logger, "user mail send admin broadcast", ctx, sendErr)
return
}
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
default:
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'")
}
}
}
// UnreadCounts handles GET /api/v1/user/lobby/mail/unread-counts.
func (h *UserMailHandlers) UnreadCounts() gin.HandlerFunc {
if h.svc == nil {
@@ -289,6 +392,52 @@ type userMailSendRequestWire struct {
Body string `json:"body"`
}
// userMailSendAdminRequestWire mirrors the request body for the
// owner-only admin send. `target="user"` requires
// `recipient_user_id`; `target="all"` accepts the optional
// `recipients` scope (default `active`).
type userMailSendAdminRequestWire struct {
Target string `json:"target"`
RecipientUserID string `json:"recipient_user_id,omitempty"`
Recipients string `json:"recipients,omitempty"`
Subject string `json:"subject,omitempty"`
Body string `json:"body"`
}
// userMailBroadcastReceiptWire is the response shape returned after a
// successful broadcast. It carries the canonical message metadata
// together with the count of materialised recipient rows so the
// caller (UI, admin tool) can confirm the fan-out happened.
type userMailBroadcastReceiptWire struct {
MessageID string `json:"message_id"`
GameID string `json:"game_id"`
GameName string `json:"game_name,omitempty"`
Kind string `json:"kind"`
SenderKind string `json:"sender_kind"`
Subject string `json:"subject,omitempty"`
Body string `json:"body"`
BodyLang string `json:"body_lang"`
BroadcastScope string `json:"broadcast_scope"`
CreatedAt string `json:"created_at"`
RecipientCount int `json:"recipient_count"`
}
func mailBroadcastReceiptToWire(m diplomail.Message, recipients []diplomail.Recipient) userMailBroadcastReceiptWire {
return userMailBroadcastReceiptWire{
MessageID: m.MessageID.String(),
GameID: m.GameID.String(),
GameName: m.GameName,
Kind: m.Kind,
SenderKind: m.SenderKind,
Subject: m.Subject,
Body: m.Body,
BodyLang: m.BodyLang,
BroadcastScope: m.BroadcastScope,
CreatedAt: m.CreatedAt.UTC().Format(timestampLayout),
RecipientCount: len(recipients),
}
}
// userMailMessageDetailWire mirrors the unified response shape for
// inbox listings and per-message reads. Sender identifiers are
// optional: system messages carry neither user id nor username.
+7 -1
View File
@@ -76,6 +76,7 @@ type RouterDependencies struct {
AdminRuntimes *AdminRuntimesHandlers
AdminEngineVersions *AdminEngineVersionsHandlers
AdminMail *AdminMailHandlers
AdminDiplomail *AdminDiplomailHandlers
AdminNotifications *AdminNotificationsHandlers
AdminGeo *AdminGeoHandlers
InternalSessions *InternalSessionsHandlers
@@ -165,7 +166,7 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger)
}
if deps.UserMail == nil {
deps.UserMail = NewUserMailHandlers(nil, deps.Logger)
deps.UserMail = NewUserMailHandlers(nil, nil, nil, deps.Logger)
}
if deps.UserSessions == nil {
deps.UserSessions = NewUserSessionsHandlers(nil, deps.Logger)
@@ -188,6 +189,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
if deps.AdminMail == nil {
deps.AdminMail = NewAdminMailHandlers(nil, deps.Logger)
}
if deps.AdminDiplomail == nil {
deps.AdminDiplomail = NewAdminDiplomailHandlers(nil, deps.Logger)
}
if deps.AdminNotifications == nil {
deps.AdminNotifications = NewAdminNotificationsHandlers(nil, deps.Logger)
}
@@ -274,6 +278,7 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
userMail := userGames.Group("/:game_id/mail")
userMail.POST("/messages", deps.UserMail.SendPersonal())
userMail.POST("/admin", deps.UserMail.SendAdmin())
userMail.GET("/messages/:message_id", deps.UserMail.Get())
userMail.POST("/messages/:message_id/read", deps.UserMail.MarkRead())
userMail.DELETE("/messages/:message_id", deps.UserMail.Delete())
@@ -314,6 +319,7 @@ func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, d
games.POST("/:game_id/force-start", deps.AdminGames.ForceStart())
games.POST("/:game_id/force-stop", deps.AdminGames.ForceStop())
games.POST("/:game_id/ban-member", deps.AdminGames.BanMember())
games.POST("/:game_id/mail", deps.AdminDiplomail.Send())
runtimes := group.Group("/runtimes")
runtimes.GET("/:game_id", deps.AdminRuntimes.Get())