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"` }