7b43ce5844
Phase 28's in-game mail UI groups personal threads by the other party's race. To support that without an extra membership-listing RPC, the diplomail subsystem now: - accepts `recipient_race_name` on `POST /messages` and `POST /admin` (target=user) as an alternative to `recipient_user_id`; the service resolves it via the existing `Memberships.ListMembers(gameID, "active")` and rejects with `forbidden` when the matching member is no longer active; - snapshots `diplomail_messages.sender_race_name` at send time for every player sender (admin / system rows stay NULL). The UI keys per-race threading on this column. Schema, openapi, README, and a focused e2e test for the new path (happy path + dual / missing / unknown / kicked errors) land in this commit; the gateway + UI legs follow in subsequent commits on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
332 lines
11 KiB
Go
332 lines
11 KiB
Go
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":
|
|
var recipientID uuid.UUID
|
|
if req.RecipientUserID != "" {
|
|
parsed, parseErr := uuid.Parse(req.RecipientUserID)
|
|
if parseErr != nil {
|
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
|
return
|
|
}
|
|
recipientID = parsed
|
|
}
|
|
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
|
GameID: gameID,
|
|
CallerKind: diplomail.CallerKindAdmin,
|
|
CallerUsername: username,
|
|
RecipientUserID: recipientID,
|
|
RecipientRaceName: req.RecipientRaceName,
|
|
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'")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
}
|