diplomail (Stage C): paid-tier broadcast + multi-game + cleanup
Closes out the producer-side of the diplomail surface. Paid-tier players can fan out one personal message to the rest of the active roster (gated on entitlement_snapshots.is_paid). Site admins gain a multi-game broadcast (POST /admin/mail/broadcast with `selected` / `all_running` scopes) and the bulk-purge endpoint that wipes diplomail rows tied to games finished more than N years ago. An admin listing (GET /admin/mail/messages) rounds out the observability surface. EntitlementReader and GameLookup are new narrow deps wired from `*user.Service` and `*lobby.Service` in cmd/backend/main; the lobby service grows a one-off `ListFinishedGamesBefore` helper for the cleanup path (the cache evicts terminal-state games so the cache walk is not enough). Stage D will swap LangUndetermined for an actual body-language detector and add the translation cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -167,6 +167,18 @@ var requestBodyStubs = map[string]map[string]any{
|
||||
"subject": "Contract test admin subject",
|
||||
"body": "Contract test admin body",
|
||||
},
|
||||
"userMailSendBroadcast": {
|
||||
"subject": "Contract test paid broadcast",
|
||||
"body": "Contract test paid broadcast body",
|
||||
},
|
||||
"adminDiplomailBroadcast": {
|
||||
"scope": "all_running",
|
||||
"subject": "Contract test multi-game broadcast",
|
||||
"body": "Contract test multi-game broadcast body",
|
||||
},
|
||||
"adminDiplomailCleanup": {
|
||||
"older_than_years": 1,
|
||||
},
|
||||
}
|
||||
|
||||
// TestOpenAPIContract is the top-level OpenAPI contract test. It
|
||||
|
||||
@@ -99,3 +99,228 @@ func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast handles POST /api/v1/admin/mail/broadcast. Body:
|
||||
//
|
||||
// {
|
||||
// "scope": "selected" | "all_running",
|
||||
// "game_ids": ["..."],
|
||||
// "recipients": "active" | "active_and_removed" | "all_members",
|
||||
// "subject": "...",
|
||||
// "body": "..."
|
||||
// }
|
||||
//
|
||||
// The handler routes through SendAdminMultiGameBroadcast and returns
|
||||
// a fan-out receipt describing the message ids created and the
|
||||
// total recipient count.
|
||||
func (h *AdminDiplomailHandlers) Broadcast() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminDiplomailBroadcast")
|
||||
}
|
||||
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
|
||||
}
|
||||
var req adminDiplomailBroadcastRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
gameIDs := make([]uuid.UUID, 0, len(req.GameIDs))
|
||||
for _, raw := range req.GameIDs {
|
||||
parsed, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_ids must be valid UUIDs")
|
||||
return
|
||||
}
|
||||
gameIDs = append(gameIDs, parsed)
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
msgs, total, err := h.svc.SendAdminMultiGameBroadcast(ctx, diplomail.SendMultiGameBroadcastInput{
|
||||
CallerUsername: username,
|
||||
Scope: req.Scope,
|
||||
GameIDs: gameIDs,
|
||||
RecipientScope: req.Recipients,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "admin mail broadcast", ctx, err)
|
||||
return
|
||||
}
|
||||
out := adminDiplomailBroadcastResponseWire{
|
||||
RecipientCount: total,
|
||||
Messages: make([]adminDiplomailBroadcastMessageWire, 0, len(msgs)),
|
||||
}
|
||||
for _, m := range msgs {
|
||||
out.Messages = append(out.Messages, adminDiplomailBroadcastMessageWire{
|
||||
MessageID: m.MessageID.String(),
|
||||
GameID: m.GameID.String(),
|
||||
GameName: m.GameName,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup handles POST /api/v1/admin/mail/cleanup. Body:
|
||||
//
|
||||
// { "older_than_years": 1 }
|
||||
//
|
||||
// The endpoint removes every diplomail_messages row whose game
|
||||
// finished more than the supplied number of years ago. The cascade
|
||||
// on the recipient and translation tables prunes the per-user state
|
||||
// in the same transaction. Returns a CleanupResult envelope.
|
||||
func (h *AdminDiplomailHandlers) Cleanup() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminDiplomailCleanup")
|
||||
}
|
||||
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
|
||||
}
|
||||
_ = username
|
||||
var req adminDiplomailCleanupRequestWire
|
||||
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()
|
||||
result, err := h.svc.BulkCleanup(ctx, diplomail.BulkCleanupInput{OlderThanYears: req.OlderThanYears})
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "admin mail cleanup", ctx, err)
|
||||
return
|
||||
}
|
||||
out := adminDiplomailCleanupResponseWire{
|
||||
MessagesDeleted: result.MessagesDeleted,
|
||||
GameIDs: make([]string, 0, len(result.GameIDs)),
|
||||
}
|
||||
for _, id := range result.GameIDs {
|
||||
out.GameIDs = append(out.GameIDs, id.String())
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/admin/mail/messages. Supports pagination
|
||||
// via `page` and `page_size`, plus optional `game_id`, `kind`, and
|
||||
// `sender_kind` filters.
|
||||
func (h *AdminDiplomailHandlers) List() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminDiplomailList")
|
||||
}
|
||||
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
|
||||
}
|
||||
filter := diplomail.AdminMessageListing{
|
||||
Page: parsePositiveQueryInt(c.Query("page"), 1),
|
||||
PageSize: parsePositiveQueryInt(c.Query("page_size"), 50),
|
||||
Kind: c.Query("kind"),
|
||||
SenderKind: c.Query("sender_kind"),
|
||||
}
|
||||
if raw := c.Query("game_id"); raw != "" {
|
||||
parsed, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
filter.GameID = &parsed
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
page, err := h.svc.ListMessagesForAdmin(ctx, filter)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "admin mail list", ctx, err)
|
||||
return
|
||||
}
|
||||
out := adminDiplomailListResponseWire{
|
||||
Total: page.Total,
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Items: make([]adminDiplomailMessageWire, 0, len(page.Items)),
|
||||
}
|
||||
for _, m := range page.Items {
|
||||
entry := adminDiplomailMessageWire{
|
||||
MessageID: m.MessageID.String(),
|
||||
GameID: m.GameID.String(),
|
||||
GameName: m.GameName,
|
||||
Kind: m.Kind,
|
||||
SenderKind: m.SenderKind,
|
||||
SenderIP: m.SenderIP,
|
||||
Subject: m.Subject,
|
||||
Body: m.Body,
|
||||
BodyLang: m.BodyLang,
|
||||
BroadcastScope: m.BroadcastScope,
|
||||
CreatedAt: m.CreatedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if m.SenderUserID != nil {
|
||||
s := m.SenderUserID.String()
|
||||
entry.SenderUserID = &s
|
||||
}
|
||||
if m.SenderUsername != nil {
|
||||
s := *m.SenderUsername
|
||||
entry.SenderUsername = &s
|
||||
}
|
||||
out.Items = append(out.Items, entry)
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
type adminDiplomailBroadcastRequestWire struct {
|
||||
Scope string `json:"scope"`
|
||||
GameIDs []string `json:"game_ids,omitempty"`
|
||||
Recipients string `json:"recipients,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
type adminDiplomailBroadcastMessageWire struct {
|
||||
MessageID string `json:"message_id"`
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name,omitempty"`
|
||||
}
|
||||
|
||||
type adminDiplomailBroadcastResponseWire struct {
|
||||
RecipientCount int `json:"recipient_count"`
|
||||
Messages []adminDiplomailBroadcastMessageWire `json:"messages"`
|
||||
}
|
||||
|
||||
type adminDiplomailCleanupRequestWire struct {
|
||||
OlderThanYears int `json:"older_than_years"`
|
||||
}
|
||||
|
||||
type adminDiplomailCleanupResponseWire struct {
|
||||
MessagesDeleted int `json:"messages_deleted"`
|
||||
GameIDs []string `json:"game_ids"`
|
||||
}
|
||||
|
||||
type adminDiplomailMessageWire 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"`
|
||||
SenderUserID *string `json:"sender_user_id,omitempty"`
|
||||
SenderUsername *string `json:"sender_username,omitempty"`
|
||||
SenderIP string `json:"sender_ip,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
BodyLang string `json:"body_lang"`
|
||||
BroadcastScope string `json:"broadcast_scope"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type adminDiplomailListResponseWire struct {
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []adminDiplomailMessageWire `json:"items"`
|
||||
}
|
||||
|
||||
@@ -232,6 +232,48 @@ func (h *UserMailHandlers) Delete() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// SendBroadcast handles POST /api/v1/user/games/{game_id}/mail/broadcast.
|
||||
//
|
||||
// The endpoint is the paid-tier player broadcast: any player on a
|
||||
// non-`free` entitlement tier may send one personal message that
|
||||
// fans out to every other active member of the game. The result
|
||||
// rows carry `kind="personal"`, `sender_kind="player"`,
|
||||
// `broadcast_scope="game_broadcast"`. Free-tier callers see a 403.
|
||||
func (h *UserMailHandlers) SendBroadcast() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailSendBroadcast")
|
||||
}
|
||||
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 userMailSendBroadcastRequestWire
|
||||
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()
|
||||
msg, recipients, err := h.svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: userID,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail send broadcast", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
|
||||
}
|
||||
}
|
||||
|
||||
// SendAdmin handles POST /api/v1/user/games/{game_id}/mail/admin.
|
||||
//
|
||||
// Owner-only: the caller must be the owner of the private game. The
|
||||
@@ -392,6 +434,14 @@ type userMailSendRequestWire struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// userMailSendBroadcastRequestWire mirrors the request body for the
|
||||
// paid-tier player broadcast. There is no `target` discriminator —
|
||||
// the recipient set is always "every other active member".
|
||||
type userMailSendBroadcastRequestWire struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
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
|
||||
|
||||
@@ -278,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("/broadcast", deps.UserMail.SendBroadcast())
|
||||
userMail.POST("/admin", deps.UserMail.SendAdmin())
|
||||
userMail.GET("/messages/:message_id", deps.UserMail.Get())
|
||||
userMail.POST("/messages/:message_id/read", deps.UserMail.MarkRead())
|
||||
@@ -339,6 +340,9 @@ func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, d
|
||||
mail.GET("/deliveries/:delivery_id/attempts", deps.AdminMail.ListDeliveryAttempts())
|
||||
mail.POST("/deliveries/:delivery_id/resend", deps.AdminMail.ResendDelivery())
|
||||
mail.GET("/dead-letters", deps.AdminMail.ListDeadLetters())
|
||||
mail.GET("/messages", deps.AdminDiplomail.List())
|
||||
mail.POST("/broadcast", deps.AdminDiplomail.Broadcast())
|
||||
mail.POST("/cleanup", deps.AdminDiplomail.Cleanup())
|
||||
|
||||
notifications := group.Group("/notifications")
|
||||
notifications.GET("", deps.AdminNotifications.List())
|
||||
|
||||
Reference in New Issue
Block a user