e22f4b7800
Replaces the LangUndetermined placeholder with whatlanggo-backed body detection on every send path, then adds a translation cache keyed on (message_id, target_lang) populated lazily on the per-message read endpoint. The noop translator that ships with Stage D returns engine="noop", which the service treats as "translation unavailable" — wiring a real backend (LibreTranslate HTTP client is the documented next step) is a one-file swap. GetMessage and ListInbox now accept a targetLang argument; the HTTP layer resolves the caller's accounts.preferred_language and forwards it. Inbox uses the cache only (never calls the translator) so bulk reads stay fast under future SaaS backends. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
664 lines
22 KiB
Go
664 lines
22 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"galaxy/backend/internal/diplomail"
|
|
"galaxy/backend/internal/lobby"
|
|
"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"
|
|
"galaxy/backend/internal/user"
|
|
|
|
"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 the
|
|
// personal subset; Stage B adds the owner-only admin send path,
|
|
// which needs `*lobby.Service` to confirm ownership and `*user.Service`
|
|
// to resolve the owner's `user_name` for the `sender_username` column.
|
|
type UserMailHandlers struct {
|
|
svc *diplomail.Service
|
|
lobby *lobby.Service
|
|
users *user.Service
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewUserMailHandlers constructs the handler set. svc may be nil — in
|
|
// that case every handler returns 501 not_implemented. lobby and
|
|
// users are optional: when either is nil the admin-send handler
|
|
// degrades to 501 (the personal-send and read paths stay functional).
|
|
func NewUserMailHandlers(svc *diplomail.Service, lobbySvc *lobby.Service, users *user.Service, logger *zap.Logger) *UserMailHandlers {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &UserMailHandlers{
|
|
svc: svc,
|
|
lobby: lobbySvc,
|
|
users: users,
|
|
logger: logger.Named("http.user.mail"),
|
|
}
|
|
}
|
|
|
|
// preferredLanguage looks up the caller's `accounts.preferred_language`
|
|
// so the per-message read can attach the cached translation when
|
|
// available. Failures are logged at debug level and the function
|
|
// returns an empty string — translation is best-effort and the
|
|
// caller still receives the original body.
|
|
func (h *UserMailHandlers) preferredLanguage(ctx context.Context, userID uuid.UUID) string {
|
|
if h.users == nil {
|
|
return ""
|
|
}
|
|
account, err := h.users.GetAccount(ctx, userID)
|
|
if err != nil {
|
|
h.logger.Debug("resolve preferred_language failed",
|
|
zap.String("user_id", userID.String()),
|
|
zap.Error(err))
|
|
return ""
|
|
}
|
|
return account.PreferredLanguage
|
|
}
|
|
|
|
// 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()
|
|
targetLang := h.preferredLanguage(ctx, userID)
|
|
entry, err := h.svc.GetMessage(ctx, userID, messageID, targetLang)
|
|
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()
|
|
targetLang := h.preferredLanguage(ctx, userID)
|
|
items, err := h.svc.ListInbox(ctx, gameID, userID, targetLang)
|
|
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))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// handler resolves the owner's `user_name` so the
|
|
// `sender_username` column carries a useful identity, then routes to
|
|
// SendAdminPersonal (for `target="user"`) or SendAdminBroadcast (for
|
|
// `target="all"`). Site administrators use the separate admin route
|
|
// in `handlers_admin_mail_send.go`.
|
|
func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
|
|
if h.svc == nil || h.lobby == nil || h.users == nil {
|
|
return handlers.NotImplemented("userMailSendAdmin")
|
|
}
|
|
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 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()
|
|
|
|
game, err := h.lobby.GetGame(ctx, gameID)
|
|
if err != nil {
|
|
respondLobbyError(c, h.logger, "user mail send admin: load game", ctx, err)
|
|
return
|
|
}
|
|
if game.OwnerUserID == nil || *game.OwnerUserID != userID {
|
|
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "caller is not the owner of this game")
|
|
return
|
|
}
|
|
account, err := h.users.GetAccount(ctx, userID)
|
|
if err != nil {
|
|
respondAccountError(c, h.logger, "user mail send admin: resolve user_name", ctx, err)
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
callerUserID := userID
|
|
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
|
GameID: gameID,
|
|
CallerKind: diplomail.CallerKindOwner,
|
|
CallerUserID: &callerUserID,
|
|
CallerUsername: account.UserName,
|
|
RecipientUserID: recipientID,
|
|
Subject: req.Subject,
|
|
Body: req.Body,
|
|
SenderIP: clientip.ExtractSourceIP(c),
|
|
})
|
|
if sendErr != nil {
|
|
respondDiplomailError(c, h.logger, "user mail send admin personal", ctx, sendErr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
|
|
case "all":
|
|
callerUserID := userID
|
|
msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
|
|
GameID: gameID,
|
|
CallerKind: diplomail.CallerKindOwner,
|
|
CallerUserID: &callerUserID,
|
|
CallerUsername: account.UserName,
|
|
RecipientScope: req.Recipients,
|
|
Subject: req.Subject,
|
|
Body: req.Body,
|
|
SenderIP: clientip.ExtractSourceIP(c),
|
|
})
|
|
if sendErr != nil {
|
|
respondDiplomailError(c, h.logger, "user mail send admin 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'")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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
|
|
// `recipients` scope (default `active`).
|
|
type userMailSendAdminRequestWire struct {
|
|
Target string `json:"target"`
|
|
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
|
Recipients string `json:"recipients,omitempty"`
|
|
Subject string `json:"subject,omitempty"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// userMailBroadcastReceiptWire is the response shape returned after a
|
|
// successful broadcast. It carries the canonical message metadata
|
|
// together with the count of materialised recipient rows so the
|
|
// caller (UI, admin tool) can confirm the fan-out happened.
|
|
type userMailBroadcastReceiptWire 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"`
|
|
Subject string `json:"subject,omitempty"`
|
|
Body string `json:"body"`
|
|
BodyLang string `json:"body_lang"`
|
|
BroadcastScope string `json:"broadcast_scope"`
|
|
CreatedAt string `json:"created_at"`
|
|
RecipientCount int `json:"recipient_count"`
|
|
}
|
|
|
|
func mailBroadcastReceiptToWire(m diplomail.Message, recipients []diplomail.Recipient) userMailBroadcastReceiptWire {
|
|
return userMailBroadcastReceiptWire{
|
|
MessageID: m.MessageID.String(),
|
|
GameID: m.GameID.String(),
|
|
GameName: m.GameName,
|
|
Kind: m.Kind,
|
|
SenderKind: m.SenderKind,
|
|
Subject: m.Subject,
|
|
Body: m.Body,
|
|
BodyLang: m.BodyLang,
|
|
BroadcastScope: m.BroadcastScope,
|
|
CreatedAt: m.CreatedAt.UTC().Format(timestampLayout),
|
|
RecipientCount: len(recipients),
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// Translation fields are populated when a cached rendering exists
|
|
// for the caller's `preferred_language`; the UI renders
|
|
// `body_translated` and surfaces the original through a
|
|
// "show original" toggle.
|
|
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"`
|
|
TranslatedSubject *string `json:"translated_subject,omitempty"`
|
|
TranslatedBody *string `json:"translated_body,omitempty"`
|
|
TranslationLang *string `json:"translation_lang,omitempty"`
|
|
Translator *string `json:"translator,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
|
|
}
|
|
if entry.Translation != nil {
|
|
tr := entry.Translation
|
|
subj := tr.TranslatedSubject
|
|
body := tr.TranslatedBody
|
|
lang := tr.TargetLang
|
|
engine := tr.Translator
|
|
out.TranslatedSubject = &subj
|
|
out.TranslatedBody = &body
|
|
out.TranslationLang = &lang
|
|
out.Translator = &engine
|
|
}
|
|
_ = 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"`
|
|
}
|