b3f24cc440
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>
102 lines
3.5 KiB
Go
102 lines
3.5 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":
|
|
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'")
|
|
}
|
|
}
|
|
}
|