535e27008f
Phase 28 of ui/PLAN.md needs a persistent player-to-player mail channel; the existing `mail` package is a transactional email outbox and the `notification` catalog is one-way platform events. Stage A lands the schema (diplomail_messages / _recipients / _translations), a single-recipient personal send/read/delete service path, a `diplomail.message.received` push kind plumbed through the notification pipeline, and an unread-counts endpoint that drives the lobby badge. Admin / system mail, lifecycle hooks, paid-tier broadcast, multi-game broadcast, bulk purge and language detection / translation cache come in stages B–D. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
425 lines
14 KiB
Go
425 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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/userid"
|
|
"galaxy/backend/internal/telemetry"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// 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.
|
|
type UserMailHandlers struct {
|
|
svc *diplomail.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 {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &UserMailHandlers{svc: svc, logger: logger.Named("http.user.mail")}
|
|
}
|
|
|
|
// SendPersonal handles POST /api/v1/user/games/{game_id}/mail/messages.
|
|
func (h *UserMailHandlers) SendPersonal() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("userMailSendPersonal")
|
|
}
|
|
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 userMailSendRequestWire
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
|
return
|
|
}
|
|
recipientID, err := uuid.Parse(req.RecipientUserID)
|
|
if err != nil {
|
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
|
GameID: gameID,
|
|
SenderUserID: userID,
|
|
RecipientUserID: recipientID,
|
|
Subject: req.Subject,
|
|
Body: req.Body,
|
|
SenderIP: clientip.ExtractSourceIP(c),
|
|
})
|
|
if err != nil {
|
|
respondDiplomailError(c, h.logger, "user mail send personal", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
|
|
}
|
|
}
|
|
|
|
// Get handles GET /api/v1/user/games/{game_id}/mail/messages/{message_id}.
|
|
func (h *UserMailHandlers) Get() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("userMailGet")
|
|
}
|
|
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
|
|
}
|
|
if _, ok := parseGameIDParam(c); !ok {
|
|
return
|
|
}
|
|
messageID, ok := parseMessageIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
entry, err := h.svc.GetMessage(ctx, userID, messageID)
|
|
if err != nil {
|
|
respondDiplomailError(c, h.logger, "user mail get", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, mailMessageDetailToWire(entry, false))
|
|
}
|
|
}
|
|
|
|
// Inbox handles GET /api/v1/user/games/{game_id}/mail/inbox.
|
|
func (h *UserMailHandlers) Inbox() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("userMailInbox")
|
|
}
|
|
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
|
|
}
|
|
ctx := c.Request.Context()
|
|
items, err := h.svc.ListInbox(ctx, gameID, userID)
|
|
if err != nil {
|
|
respondDiplomailError(c, h.logger, "user mail inbox", ctx, err)
|
|
return
|
|
}
|
|
out := userMailInboxListWire{Items: make([]userMailMessageDetailWire, 0, len(items))}
|
|
for _, e := range items {
|
|
out.Items = append(out.Items, mailMessageDetailToWire(e, false))
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
// Sent handles GET /api/v1/user/games/{game_id}/mail/sent.
|
|
func (h *UserMailHandlers) Sent() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("userMailSent")
|
|
}
|
|
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
|
|
}
|
|
ctx := c.Request.Context()
|
|
items, err := h.svc.ListSent(ctx, gameID, userID)
|
|
if err != nil {
|
|
respondDiplomailError(c, h.logger, "user mail sent", ctx, err)
|
|
return
|
|
}
|
|
out := userMailSentListWire{Items: make([]userMailSentSummaryWire, 0, len(items))}
|
|
for _, m := range items {
|
|
out.Items = append(out.Items, mailMessageSummaryToWire(m))
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
// MarkRead handles POST /api/v1/user/games/{game_id}/mail/messages/{message_id}/read.
|
|
func (h *UserMailHandlers) MarkRead() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("userMailMarkRead")
|
|
}
|
|
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
|
|
}
|
|
if _, ok := parseGameIDParam(c); !ok {
|
|
return
|
|
}
|
|
messageID, ok := parseMessageIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
rcpt, err := h.svc.MarkRead(ctx, userID, messageID)
|
|
if err != nil {
|
|
respondDiplomailError(c, h.logger, "user mail mark read", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, mailRecipientStateToWire(rcpt))
|
|
}
|
|
}
|
|
|
|
// Delete handles DELETE /api/v1/user/games/{game_id}/mail/messages/{message_id}.
|
|
func (h *UserMailHandlers) Delete() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("userMailDelete")
|
|
}
|
|
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
|
|
}
|
|
if _, ok := parseGameIDParam(c); !ok {
|
|
return
|
|
}
|
|
messageID, ok := parseMessageIDParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
rcpt, err := h.svc.DeleteMessage(ctx, userID, messageID)
|
|
if err != nil {
|
|
respondDiplomailError(c, h.logger, "user mail delete", ctx, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, mailRecipientStateToWire(rcpt))
|
|
}
|
|
}
|
|
|
|
// UnreadCounts handles GET /api/v1/user/lobby/mail/unread-counts.
|
|
func (h *UserMailHandlers) UnreadCounts() gin.HandlerFunc {
|
|
if h.svc == nil {
|
|
return handlers.NotImplemented("userMailUnreadCounts")
|
|
}
|
|
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
|
|
}
|
|
ctx := c.Request.Context()
|
|
items, err := h.svc.UnreadCountsForUser(ctx, userID)
|
|
if err != nil {
|
|
respondDiplomailError(c, h.logger, "user mail unread counts", ctx, err)
|
|
return
|
|
}
|
|
out := userMailUnreadCountsResponseWire{Items: make([]userMailUnreadCountWire, 0, len(items))}
|
|
total := 0
|
|
for _, u := range items {
|
|
out.Items = append(out.Items, userMailUnreadCountWire{
|
|
GameID: u.GameID.String(),
|
|
GameName: u.GameName,
|
|
Unread: u.Unread,
|
|
})
|
|
total += u.Unread
|
|
}
|
|
out.Total = total
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
// respondDiplomailError maps diplomail-package sentinels to the
|
|
// standard JSON error envelope. Unknown errors land on a 500.
|
|
func respondDiplomailError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
|
switch {
|
|
case errors.Is(err, diplomail.ErrInvalidInput):
|
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
|
case errors.Is(err, diplomail.ErrNotFound):
|
|
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found")
|
|
case errors.Is(err, diplomail.ErrForbidden):
|
|
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, err.Error())
|
|
case errors.Is(err, diplomail.ErrConflict):
|
|
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
|
|
default:
|
|
logger.Error(op+" failed",
|
|
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
|
)
|
|
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
|
}
|
|
}
|
|
|
|
// parseMessageIDParam reads `message_id` from the path. Writes a 400
|
|
// envelope on invalid input and returns false in that case.
|
|
func parseMessageIDParam(c *gin.Context) (uuid.UUID, bool) {
|
|
parsed, err := uuid.Parse(c.Param("message_id"))
|
|
if err != nil {
|
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "message_id must be a valid UUID")
|
|
return uuid.Nil, false
|
|
}
|
|
return parsed, true
|
|
}
|
|
|
|
// userMailSendRequestWire mirrors the request body for SendPersonal.
|
|
type userMailSendRequestWire struct {
|
|
RecipientUserID string `json:"recipient_user_id"`
|
|
Subject string `json:"subject,omitempty"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// 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.
|
|
type userMailMessageDetailWire 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"`
|
|
Subject string `json:"subject,omitempty"`
|
|
Body string `json:"body"`
|
|
BodyLang string `json:"body_lang"`
|
|
BroadcastScope string `json:"broadcast_scope"`
|
|
CreatedAt string `json:"created_at"`
|
|
RecipientUserID string `json:"recipient_user_id"`
|
|
RecipientUserName string `json:"recipient_user_name,omitempty"`
|
|
RecipientRaceName *string `json:"recipient_race_name,omitempty"`
|
|
ReadAt *string `json:"read_at,omitempty"`
|
|
DeletedAt *string `json:"deleted_at,omitempty"`
|
|
}
|
|
|
|
// userMailSentSummaryWire mirrors the response shape for the
|
|
// sender-side listing. Recipient state is intentionally omitted (one
|
|
// author may have N recipients per broadcast in later stages).
|
|
type userMailSentSummaryWire struct {
|
|
MessageID string `json:"message_id"`
|
|
GameID string `json:"game_id"`
|
|
GameName string `json:"game_name,omitempty"`
|
|
Kind string `json:"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"`
|
|
}
|
|
|
|
type userMailInboxListWire struct {
|
|
Items []userMailMessageDetailWire `json:"items"`
|
|
}
|
|
|
|
type userMailSentListWire struct {
|
|
Items []userMailSentSummaryWire `json:"items"`
|
|
}
|
|
|
|
type userMailUnreadCountWire struct {
|
|
GameID string `json:"game_id"`
|
|
GameName string `json:"game_name,omitempty"`
|
|
Unread int `json:"unread"`
|
|
}
|
|
|
|
type userMailUnreadCountsResponseWire struct {
|
|
Total int `json:"total"`
|
|
Items []userMailUnreadCountWire `json:"items"`
|
|
}
|
|
|
|
func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userMailMessageDetailWire {
|
|
out := userMailMessageDetailWire{
|
|
MessageID: entry.MessageID.String(),
|
|
GameID: entry.GameID.String(),
|
|
GameName: entry.GameName,
|
|
Kind: entry.Kind,
|
|
SenderKind: entry.SenderKind,
|
|
Subject: entry.Subject,
|
|
Body: entry.Body,
|
|
BodyLang: entry.BodyLang,
|
|
BroadcastScope: entry.BroadcastScope,
|
|
CreatedAt: entry.CreatedAt.UTC().Format(timestampLayout),
|
|
RecipientUserID: entry.Recipient.UserID.String(),
|
|
RecipientUserName: entry.Recipient.RecipientUserName,
|
|
}
|
|
if entry.SenderUserID != nil {
|
|
s := entry.SenderUserID.String()
|
|
out.SenderUserID = &s
|
|
}
|
|
if entry.SenderUsername != nil {
|
|
s := *entry.SenderUsername
|
|
out.SenderUsername = &s
|
|
}
|
|
if entry.Recipient.RecipientRaceName != nil {
|
|
s := *entry.Recipient.RecipientRaceName
|
|
out.RecipientRaceName = &s
|
|
}
|
|
if entry.Recipient.ReadAt != nil {
|
|
s := entry.Recipient.ReadAt.UTC().Format(timestampLayout)
|
|
out.ReadAt = &s
|
|
}
|
|
if entry.Recipient.DeletedAt != nil {
|
|
s := entry.Recipient.DeletedAt.UTC().Format(timestampLayout)
|
|
out.DeletedAt = &s
|
|
}
|
|
_ = justCreated
|
|
return out
|
|
}
|
|
|
|
func mailMessageSummaryToWire(m diplomail.Message) userMailSentSummaryWire {
|
|
return userMailSentSummaryWire{
|
|
MessageID: m.MessageID.String(),
|
|
GameID: m.GameID.String(),
|
|
GameName: m.GameName,
|
|
Kind: m.Kind,
|
|
Subject: m.Subject,
|
|
Body: m.Body,
|
|
BodyLang: m.BodyLang,
|
|
BroadcastScope: m.BroadcastScope,
|
|
CreatedAt: m.CreatedAt.UTC().Format(timestampLayout),
|
|
}
|
|
}
|
|
|
|
// mailRecipientStateToWire renders the recipient row after a
|
|
// mark-read or soft-delete call. The caller only needs the per-user
|
|
// state, not the full message body again.
|
|
func mailRecipientStateToWire(r diplomail.Recipient) userMailRecipientStateWire {
|
|
out := userMailRecipientStateWire{
|
|
MessageID: r.MessageID.String(),
|
|
}
|
|
if r.ReadAt != nil {
|
|
s := r.ReadAt.UTC().Format(timestampLayout)
|
|
out.ReadAt = &s
|
|
}
|
|
if r.DeletedAt != nil {
|
|
s := r.DeletedAt.UTC().Format(timestampLayout)
|
|
out.DeletedAt = &s
|
|
}
|
|
return out
|
|
}
|
|
|
|
type userMailRecipientStateWire struct {
|
|
MessageID string `json:"message_id"`
|
|
ReadAt *string `json:"read_at,omitempty"`
|
|
DeletedAt *string `json:"deleted_at,omitempty"`
|
|
}
|