diplomail (Stage B): admin/owner sends + lifecycle hooks
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:
@@ -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'")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user