Phase 28: diplomatic mail UI (work in progress) #11
@@ -26,7 +26,10 @@ Three Postgres tables in the `backend` schema:
|
|||||||
|
|
||||||
- `diplomail_messages` — one row per send (personal, admin, or
|
- `diplomail_messages` — one row per send (personal, admin, or
|
||||||
system). Captures `game_name` and IP at insert time so audit
|
system). Captures `game_name` and IP at insert time so audit
|
||||||
rendering survives renames and purges.
|
rendering survives renames and purges. The `sender_race_name`
|
||||||
|
column snapshots the sender's race in the game at send time when
|
||||||
|
the sender is a player with an active membership; the in-game UI
|
||||||
|
keys per-race thread grouping on this column.
|
||||||
- `diplomail_recipients` — one row per (message, recipient). Holds
|
- `diplomail_recipients` — one row per (message, recipient). Holds
|
||||||
per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at`
|
per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at`
|
||||||
state. Snapshot fields (`recipient_user_name`,
|
state. Snapshot fields (`recipient_user_name`,
|
||||||
@@ -72,6 +75,24 @@ mail to every active member; `Service.changeMembershipStatus` /
|
|||||||
`detector.LanguageDetector` (default: `whatlanggo`, body-only,
|
`detector.LanguageDetector` (default: `whatlanggo`, body-only,
|
||||||
≥ 25 runes; shorter bodies stay `und`).
|
≥ 25 runes; shorter bodies stay `und`).
|
||||||
|
|
||||||
|
## Recipient selection
|
||||||
|
|
||||||
|
`POST /messages` and `POST /admin` (when `target="user"`) accept the
|
||||||
|
recipient identifier in one of two shapes:
|
||||||
|
|
||||||
|
- `recipient_user_id` (uuid) — explicit user lookup; the recipient
|
||||||
|
may be any active member of the game.
|
||||||
|
- `recipient_race_name` (string) — resolves to the active member
|
||||||
|
with this race name in the game. Race names are unique by lobby
|
||||||
|
invariant; lobby-removed and blocked members cannot be reached
|
||||||
|
through the race-name shortcut (they no longer appear in the
|
||||||
|
active scope). Exactly one of the two fields must be supplied;
|
||||||
|
supplying both, or neither, returns `invalid_request`.
|
||||||
|
|
||||||
|
The race-name path lets the in-game UI compose mail directly off
|
||||||
|
the engine's `report.races[]` view without an extra membership
|
||||||
|
round-trip.
|
||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
Stage D adds a lazy translation cache. When a recipient reads a
|
Stage D adds a lazy translation cache. When a recipient reads a
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInp
|
|||||||
return Message{}, Recipient{}, err
|
return Message{}, Recipient{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, in.RecipientUserID)
|
recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, Recipient{}, err
|
||||||
|
}
|
||||||
|
recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, recipientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden)
|
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden)
|
||||||
@@ -37,7 +41,7 @@ func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInp
|
|||||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err)
|
return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername,
|
msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
|
||||||
recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle)
|
recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, Recipient{}, err
|
return Message{}, Recipient{}, err
|
||||||
@@ -84,7 +88,7 @@ func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastI
|
|||||||
}
|
}
|
||||||
|
|
||||||
gameName := members[0].GameName
|
gameName := members[0].GameName
|
||||||
msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername,
|
msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
|
||||||
in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast)
|
in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, nil, err
|
return Message{}, nil, err
|
||||||
@@ -147,6 +151,7 @@ func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcas
|
|||||||
}
|
}
|
||||||
|
|
||||||
username := sender.UserName
|
username := sender.UserName
|
||||||
|
senderRace := sender.RaceName
|
||||||
msgInsert := MessageInsert{
|
msgInsert := MessageInsert{
|
||||||
MessageID: uuid.New(),
|
MessageID: uuid.New(),
|
||||||
GameID: in.GameID,
|
GameID: in.GameID,
|
||||||
@@ -155,6 +160,7 @@ func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcas
|
|||||||
SenderKind: SenderKindPlayer,
|
SenderKind: SenderKindPlayer,
|
||||||
SenderUserID: &callerID,
|
SenderUserID: &callerID,
|
||||||
SenderUsername: &username,
|
SenderUsername: &username,
|
||||||
|
SenderRaceName: &senderRace,
|
||||||
SenderIP: in.SenderIP,
|
SenderIP: in.SenderIP,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Body: body,
|
Body: body,
|
||||||
@@ -217,7 +223,7 @@ func (s *Service) SendAdminMultiGameBroadcast(ctx context.Context, in SendMultiG
|
|||||||
zap.String("scope", scope))
|
zap.String("scope", scope))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
msgInsert, err := s.buildAdminMessageInsert(CallerKindAdmin, nil, in.CallerUsername,
|
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindAdmin, nil, in.CallerUsername,
|
||||||
game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast)
|
game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@@ -356,7 +362,7 @@ func (s *Service) publishGameLifecycle(ctx context.Context, ev LifecycleEvent) e
|
|||||||
gameName := members[0].GameName
|
gameName := members[0].GameName
|
||||||
subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason)
|
subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason)
|
||||||
|
|
||||||
msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "",
|
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
|
||||||
ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast)
|
ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -385,7 +391,7 @@ func (s *Service) publishMembershipLifecycle(ctx context.Context, ev LifecycleEv
|
|||||||
}
|
}
|
||||||
subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason)
|
subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason)
|
||||||
|
|
||||||
msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "",
|
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
|
||||||
ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle)
|
ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -417,10 +423,12 @@ func (s *Service) prepareContent(subject, body string) (string, string, error) {
|
|||||||
// for every admin-kind send. The CHECK constraint maps sender
|
// for every admin-kind send. The CHECK constraint maps sender
|
||||||
// shapes:
|
// shapes:
|
||||||
//
|
//
|
||||||
// sender_kind='player' → CallerKind owner; sender_user_id set
|
// sender_kind='player' → CallerKind owner; sender_user_id set,
|
||||||
|
// sender_race_name resolved from
|
||||||
|
// Memberships.GetActiveMembership
|
||||||
// sender_kind='admin' → CallerKind admin; sender_user_id nil
|
// sender_kind='admin' → CallerKind admin; sender_user_id nil
|
||||||
// sender_kind='system' → CallerKind system; sender_username nil
|
// sender_kind='system' → CallerKind system; sender_username nil
|
||||||
func (s *Service) buildAdminMessageInsert(callerKind string, callerUserID *uuid.UUID, callerUsername string,
|
func (s *Service) buildAdminMessageInsert(ctx context.Context, callerKind string, callerUserID *uuid.UUID, callerUsername string,
|
||||||
gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) {
|
gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) {
|
||||||
out := MessageInsert{
|
out := MessageInsert{
|
||||||
MessageID: uuid.New(),
|
MessageID: uuid.New(),
|
||||||
@@ -443,6 +451,17 @@ func (s *Service) buildAdminMessageInsert(callerKind string, callerUserID *uuid.
|
|||||||
out.SenderKind = SenderKindPlayer
|
out.SenderKind = SenderKindPlayer
|
||||||
out.SenderUserID = &uid
|
out.SenderUserID = &uid
|
||||||
out.SenderUsername = &uname
|
out.SenderUsername = &uname
|
||||||
|
// Owner race snapshot is best-effort: a private-game owner who
|
||||||
|
// has an active membership in their own game contributes a
|
||||||
|
// race name; an owner who is not a current member (or whose
|
||||||
|
// membership is removed/blocked) leaves the field nil. The
|
||||||
|
// CHECK constraint accepts both shapes for sender_kind='player'.
|
||||||
|
if ownerMember, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, uid); err == nil {
|
||||||
|
race := ownerMember.RaceName
|
||||||
|
out.SenderRaceName = &race
|
||||||
|
} else if !errors.Is(err, ErrNotFound) {
|
||||||
|
return MessageInsert{}, fmt.Errorf("diplomail: load owner membership: %w", err)
|
||||||
|
}
|
||||||
case CallerKindAdmin:
|
case CallerKindAdmin:
|
||||||
uname := callerUsername
|
uname := callerUsername
|
||||||
out.SenderKind = SenderKindAdmin
|
out.SenderKind = SenderKindAdmin
|
||||||
|
|||||||
@@ -369,6 +369,114 @@ func TestDiplomailPersonalFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDiplomailPersonalByRaceName exercises the Phase 28 contract: the
|
||||||
|
// UI passes a recipient race name (read out of the game report); the
|
||||||
|
// service resolves it to the active member with that race name and
|
||||||
|
// snapshots the sender's race onto the message row. Error cases cover
|
||||||
|
// the validation rules baked into the wire schema.
|
||||||
|
func TestDiplomailPersonalByRaceName(t *testing.T) {
|
||||||
|
db := startPostgres(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
gameID := uuid.New()
|
||||||
|
sender := uuid.New()
|
||||||
|
recipient := uuid.New()
|
||||||
|
kicked := uuid.New()
|
||||||
|
seedAccount(t, db, sender)
|
||||||
|
seedAccount(t, db, recipient)
|
||||||
|
seedAccount(t, db, kicked)
|
||||||
|
seedGame(t, db, gameID, "Race-Name Resolution Game")
|
||||||
|
|
||||||
|
lookup := &staticMembershipLookup{
|
||||||
|
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
|
||||||
|
{gameID, sender}: {
|
||||||
|
UserID: sender, GameID: gameID, GameName: "Race-Name Resolution Game",
|
||||||
|
UserName: "sender", RaceName: "Senders",
|
||||||
|
},
|
||||||
|
{gameID, recipient}: {
|
||||||
|
UserID: recipient, GameID: gameID, GameName: "Race-Name Resolution Game",
|
||||||
|
UserName: "recipient", RaceName: "Receivers",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{
|
||||||
|
{gameID, kicked}: {
|
||||||
|
UserID: kicked, GameID: gameID, GameName: "Race-Name Resolution Game",
|
||||||
|
UserName: "kicked", RaceName: "Departed", Status: "removed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := diplomail.NewService(diplomail.Deps{
|
||||||
|
Store: diplomail.NewStore(db),
|
||||||
|
Memberships: lookup,
|
||||||
|
Notification: &recordingPublisher{},
|
||||||
|
Config: config.DiplomailConfig{
|
||||||
|
MaxBodyBytes: 4096,
|
||||||
|
MaxSubjectBytes: 256,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Happy path: race name resolves and sender_race_name is snapshotted.
|
||||||
|
msg, rcpt, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||||
|
GameID: gameID,
|
||||||
|
SenderUserID: sender,
|
||||||
|
RecipientRaceName: "Receivers",
|
||||||
|
Subject: "Trade",
|
||||||
|
Body: "Care to talk?",
|
||||||
|
SenderIP: "203.0.113.4",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("send by race name: %v", err)
|
||||||
|
}
|
||||||
|
if rcpt.UserID != recipient {
|
||||||
|
t.Fatalf("recipient = %s, want %s", rcpt.UserID, recipient)
|
||||||
|
}
|
||||||
|
if msg.SenderRaceName == nil || *msg.SenderRaceName != "Senders" {
|
||||||
|
t.Fatalf("sender_race_name = %v, want \"Senders\"", msg.SenderRaceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both identifiers supplied → invalid_request.
|
||||||
|
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||||
|
GameID: gameID,
|
||||||
|
SenderUserID: sender,
|
||||||
|
RecipientUserID: recipient,
|
||||||
|
RecipientRaceName: "Receivers",
|
||||||
|
Body: "x",
|
||||||
|
}); !errors.Is(err, diplomail.ErrInvalidInput) {
|
||||||
|
t.Fatalf("dual identifier: %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither identifier supplied → invalid_request.
|
||||||
|
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||||
|
GameID: gameID,
|
||||||
|
SenderUserID: sender,
|
||||||
|
Body: "x",
|
||||||
|
}); !errors.Is(err, diplomail.ErrInvalidInput) {
|
||||||
|
t.Fatalf("no identifier: %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Race name with no matching active member → invalid_request.
|
||||||
|
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||||
|
GameID: gameID,
|
||||||
|
SenderUserID: sender,
|
||||||
|
RecipientRaceName: "Strangers",
|
||||||
|
Body: "x",
|
||||||
|
}); !errors.Is(err, diplomail.ErrInvalidInput) {
|
||||||
|
t.Fatalf("unknown race: %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Race name of a lobby-removed member → invalid_request (the
|
||||||
|
// active-only scope filters them out; the lookup never returns
|
||||||
|
// them).
|
||||||
|
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||||
|
GameID: gameID,
|
||||||
|
SenderUserID: sender,
|
||||||
|
RecipientRaceName: "Departed",
|
||||||
|
Body: "x",
|
||||||
|
}); !errors.Is(err, diplomail.ErrInvalidInput) {
|
||||||
|
t.Fatalf("kicked race: %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDiplomailRejectsNonActiveSender(t *testing.T) {
|
func TestDiplomailRejectsNonActiveSender(t *testing.T) {
|
||||||
db := startPostgres(t)
|
db := startPostgres(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -32,15 +32,20 @@ const previewMaxRunes = 120
|
|||||||
// ErrForbidden; the inserted Message is never persisted in those
|
// ErrForbidden; the inserted Message is never persisted in those
|
||||||
// cases.
|
// cases.
|
||||||
func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) {
|
func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) {
|
||||||
if in.SenderUserID == in.RecipientUserID {
|
|
||||||
return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput)
|
|
||||||
}
|
|
||||||
subject := strings.TrimRight(in.Subject, " \t")
|
subject := strings.TrimRight(in.Subject, " \t")
|
||||||
body := strings.TrimRight(in.Body, " \t\n")
|
body := strings.TrimRight(in.Body, " \t\n")
|
||||||
if err := s.validateContent(subject, body); err != nil {
|
if err := s.validateContent(subject, body); err != nil {
|
||||||
return Message{}, Recipient{}, err
|
return Message{}, Recipient{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, Recipient{}, err
|
||||||
|
}
|
||||||
|
if in.SenderUserID == recipientID {
|
||||||
|
return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
|
||||||
sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
|
sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
@@ -48,7 +53,7 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
|
|||||||
}
|
}
|
||||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err)
|
return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err)
|
||||||
}
|
}
|
||||||
recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.RecipientUserID)
|
recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, recipientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not an active member of the game", ErrForbidden)
|
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not an active member of the game", ErrForbidden)
|
||||||
@@ -57,14 +62,17 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
|
|||||||
}
|
}
|
||||||
|
|
||||||
username := sender.UserName
|
username := sender.UserName
|
||||||
|
senderRace := sender.RaceName
|
||||||
|
senderUserID := in.SenderUserID
|
||||||
msgInsert := MessageInsert{
|
msgInsert := MessageInsert{
|
||||||
MessageID: uuid.New(),
|
MessageID: uuid.New(),
|
||||||
GameID: in.GameID,
|
GameID: in.GameID,
|
||||||
GameName: sender.GameName,
|
GameName: sender.GameName,
|
||||||
Kind: KindPersonal,
|
Kind: KindPersonal,
|
||||||
SenderKind: SenderKindPlayer,
|
SenderKind: SenderKindPlayer,
|
||||||
SenderUserID: &in.SenderUserID,
|
SenderUserID: &senderUserID,
|
||||||
SenderUsername: &username,
|
SenderUsername: &username,
|
||||||
|
SenderRaceName: &senderRace,
|
||||||
SenderIP: in.SenderIP,
|
SenderIP: in.SenderIP,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Body: body,
|
Body: body,
|
||||||
@@ -75,7 +83,7 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
|
|||||||
rcptInsert := buildRecipientInsert(
|
rcptInsert := buildRecipientInsert(
|
||||||
msgInsert.MessageID,
|
msgInsert.MessageID,
|
||||||
MemberSnapshot{
|
MemberSnapshot{
|
||||||
UserID: in.RecipientUserID,
|
UserID: recipientID,
|
||||||
GameID: in.GameID,
|
GameID: in.GameID,
|
||||||
GameName: recipient.GameName,
|
GameName: recipient.GameName,
|
||||||
UserName: recipient.UserName,
|
UserName: recipient.UserName,
|
||||||
@@ -101,6 +109,47 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
|
|||||||
return msg, recipients[0], nil
|
return msg, recipients[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveActiveRecipient turns a (user_id, race_name) pair into the
|
||||||
|
// canonical user id of an active member of gameID. Exactly one of the
|
||||||
|
// two inputs must be set; both-set or both-empty returns
|
||||||
|
// ErrInvalidInput. Race-name resolution is restricted to the active
|
||||||
|
// scope so lobby-removed and blocked members cannot be reached
|
||||||
|
// through the race-name shortcut. ErrInvalidInput is also returned
|
||||||
|
// when the race name matches zero members; ErrForbidden when the
|
||||||
|
// race name matches more than one active row (defence in depth — race
|
||||||
|
// names are unique within a game by lobby invariant).
|
||||||
|
func (s *Service) resolveActiveRecipient(ctx context.Context, gameID uuid.UUID, byUserID uuid.UUID, byRaceName string) (uuid.UUID, error) {
|
||||||
|
byRaceName = strings.TrimSpace(byRaceName)
|
||||||
|
hasUser := byUserID != uuid.Nil
|
||||||
|
hasRace := byRaceName != ""
|
||||||
|
switch {
|
||||||
|
case hasUser && hasRace:
|
||||||
|
return uuid.Nil, fmt.Errorf("%w: only one of recipient_user_id, recipient_race_name may be supplied", ErrInvalidInput)
|
||||||
|
case !hasUser && !hasRace:
|
||||||
|
return uuid.Nil, fmt.Errorf("%w: recipient_user_id or recipient_race_name must be supplied", ErrInvalidInput)
|
||||||
|
case hasUser:
|
||||||
|
return byUserID, nil
|
||||||
|
}
|
||||||
|
members, err := s.deps.Memberships.ListMembers(ctx, gameID, RecipientScopeActive)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("diplomail: list active members for race lookup: %w", err)
|
||||||
|
}
|
||||||
|
var found []MemberSnapshot
|
||||||
|
for _, m := range members {
|
||||||
|
if m.RaceName == byRaceName {
|
||||||
|
found = append(found, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch len(found) {
|
||||||
|
case 0:
|
||||||
|
return uuid.Nil, fmt.Errorf("%w: no active member with race %q in this game", ErrInvalidInput, byRaceName)
|
||||||
|
case 1:
|
||||||
|
return found[0].UserID, nil
|
||||||
|
default:
|
||||||
|
return uuid.Nil, fmt.Errorf("%w: race %q matches multiple active members", ErrForbidden, byRaceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetMessage returns the InboxEntry for messageID addressed to
|
// GetMessage returns the InboxEntry for messageID addressed to
|
||||||
// userID. ErrNotFound is returned when the caller is not a recipient
|
// userID. ErrNotFound is returned when the caller is not a recipient
|
||||||
// of the message — handlers translate that to 404 so the existence
|
// of the message — handlers translate that to 404 so the existence
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func messageColumns() postgres.ColumnList {
|
|||||||
m := table.DiplomailMessages
|
m := table.DiplomailMessages
|
||||||
return postgres.ColumnList{
|
return postgres.ColumnList{
|
||||||
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
|
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
|
||||||
m.SenderUserID, m.SenderUsername, m.SenderIP,
|
m.SenderUserID, m.SenderUsername, m.SenderRaceName, m.SenderIP,
|
||||||
m.Subject, m.Body, m.BodyLang, m.BroadcastScope, m.CreatedAt,
|
m.Subject, m.Body, m.BodyLang, m.BroadcastScope, m.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,7 @@ type MessageInsert struct {
|
|||||||
SenderKind string
|
SenderKind string
|
||||||
SenderUserID *uuid.UUID
|
SenderUserID *uuid.UUID
|
||||||
SenderUsername *string
|
SenderUsername *string
|
||||||
|
SenderRaceName *string
|
||||||
SenderIP string
|
SenderIP string
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
@@ -101,7 +102,7 @@ func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInse
|
|||||||
m := table.DiplomailMessages
|
m := table.DiplomailMessages
|
||||||
msgStmt := m.INSERT(
|
msgStmt := m.INSERT(
|
||||||
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
|
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
|
||||||
m.SenderUserID, m.SenderUsername, m.SenderIP,
|
m.SenderUserID, m.SenderUsername, m.SenderRaceName, m.SenderIP,
|
||||||
m.Subject, m.Body, m.BodyLang, m.BroadcastScope,
|
m.Subject, m.Body, m.BodyLang, m.BroadcastScope,
|
||||||
).VALUES(
|
).VALUES(
|
||||||
msg.MessageID,
|
msg.MessageID,
|
||||||
@@ -111,6 +112,7 @@ func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInse
|
|||||||
msg.SenderKind,
|
msg.SenderKind,
|
||||||
uuidPtrArg(msg.SenderUserID),
|
uuidPtrArg(msg.SenderUserID),
|
||||||
stringPtrArg(msg.SenderUsername),
|
stringPtrArg(msg.SenderUsername),
|
||||||
|
stringPtrArg(msg.SenderRaceName),
|
||||||
msg.SenderIP,
|
msg.SenderIP,
|
||||||
msg.Subject,
|
msg.Subject,
|
||||||
msg.Body,
|
msg.Body,
|
||||||
@@ -737,6 +739,10 @@ func messageFromModel(row model.DiplomailMessages) Message {
|
|||||||
name := *row.SenderUsername
|
name := *row.SenderUsername
|
||||||
out.SenderUsername = &name
|
out.SenderUsername = &name
|
||||||
}
|
}
|
||||||
|
if row.SenderRaceName != nil {
|
||||||
|
name := *row.SenderRaceName
|
||||||
|
out.SenderRaceName = &name
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ type Message struct {
|
|||||||
SenderKind string
|
SenderKind string
|
||||||
SenderUserID *uuid.UUID
|
SenderUserID *uuid.UUID
|
||||||
SenderUsername *string
|
SenderUsername *string
|
||||||
|
// SenderRaceName carries the snapshot of the sender's race in the
|
||||||
|
// game at send time. Non-nil for sender_kind='player' rows, nil
|
||||||
|
// for admin and system. The in-game mail UI groups personal
|
||||||
|
// threads by this name (Phase 28).
|
||||||
|
SenderRaceName *string
|
||||||
SenderIP string
|
SenderIP string
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
@@ -92,13 +97,16 @@ type Translation struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendPersonalInput is the request payload for SendPersonal: the
|
// SendPersonalInput is the request payload for SendPersonal: the
|
||||||
// caller sending a single-recipient personal message. Validation
|
// caller sending a single-recipient personal message. Exactly one of
|
||||||
// (active membership, body length, etc.) is performed inside the
|
// RecipientUserID and RecipientRaceName must be non-zero; the
|
||||||
// service.
|
// service resolves a non-empty RecipientRaceName to the active
|
||||||
|
// member with that race in the game. Other validation (active
|
||||||
|
// membership, body length, etc.) is performed inside the service.
|
||||||
type SendPersonalInput struct {
|
type SendPersonalInput struct {
|
||||||
GameID uuid.UUID
|
GameID uuid.UUID
|
||||||
SenderUserID uuid.UUID
|
SenderUserID uuid.UUID
|
||||||
RecipientUserID uuid.UUID
|
RecipientUserID uuid.UUID
|
||||||
|
RecipientRaceName string
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
SenderIP string
|
SenderIP string
|
||||||
@@ -116,14 +124,18 @@ const (
|
|||||||
|
|
||||||
// SendAdminPersonalInput is the request payload for an owner /
|
// SendAdminPersonalInput is the request payload for an owner /
|
||||||
// admin / system sending an admin-kind message to a single
|
// admin / system sending an admin-kind message to a single
|
||||||
// recipient. Authorization (owner-vs-admin distinction) is enforced
|
// recipient. Exactly one of RecipientUserID and RecipientRaceName
|
||||||
// by the HTTP layer; the service trusts the caller designation.
|
// must be non-zero; the service resolves a non-empty
|
||||||
|
// RecipientRaceName to the active member with that race in the
|
||||||
|
// game. Authorization (owner-vs-admin distinction) is enforced by
|
||||||
|
// the HTTP layer; the service trusts the caller designation.
|
||||||
type SendAdminPersonalInput struct {
|
type SendAdminPersonalInput struct {
|
||||||
GameID uuid.UUID
|
GameID uuid.UUID
|
||||||
CallerKind string
|
CallerKind string
|
||||||
CallerUserID *uuid.UUID
|
CallerUserID *uuid.UUID
|
||||||
CallerUsername string
|
CallerUsername string
|
||||||
RecipientUserID uuid.UUID
|
RecipientUserID uuid.UUID
|
||||||
|
RecipientRaceName string
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
SenderIP string
|
SenderIP string
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type DiplomailMessages struct {
|
|||||||
SenderKind string
|
SenderKind string
|
||||||
SenderUserID *uuid.UUID
|
SenderUserID *uuid.UUID
|
||||||
SenderUsername *string
|
SenderUsername *string
|
||||||
|
SenderRaceName *string
|
||||||
SenderIP string
|
SenderIP string
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type diplomailMessagesTable struct {
|
|||||||
SenderKind postgres.ColumnString
|
SenderKind postgres.ColumnString
|
||||||
SenderUserID postgres.ColumnString
|
SenderUserID postgres.ColumnString
|
||||||
SenderUsername postgres.ColumnString
|
SenderUsername postgres.ColumnString
|
||||||
|
SenderRaceName postgres.ColumnString
|
||||||
SenderIP postgres.ColumnString
|
SenderIP postgres.ColumnString
|
||||||
Subject postgres.ColumnString
|
Subject postgres.ColumnString
|
||||||
Body postgres.ColumnString
|
Body postgres.ColumnString
|
||||||
@@ -78,14 +79,15 @@ func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomai
|
|||||||
SenderKindColumn = postgres.StringColumn("sender_kind")
|
SenderKindColumn = postgres.StringColumn("sender_kind")
|
||||||
SenderUserIDColumn = postgres.StringColumn("sender_user_id")
|
SenderUserIDColumn = postgres.StringColumn("sender_user_id")
|
||||||
SenderUsernameColumn = postgres.StringColumn("sender_username")
|
SenderUsernameColumn = postgres.StringColumn("sender_username")
|
||||||
|
SenderRaceNameColumn = postgres.StringColumn("sender_race_name")
|
||||||
SenderIPColumn = postgres.StringColumn("sender_ip")
|
SenderIPColumn = postgres.StringColumn("sender_ip")
|
||||||
SubjectColumn = postgres.StringColumn("subject")
|
SubjectColumn = postgres.StringColumn("subject")
|
||||||
BodyColumn = postgres.StringColumn("body")
|
BodyColumn = postgres.StringColumn("body")
|
||||||
BodyLangColumn = postgres.StringColumn("body_lang")
|
BodyLangColumn = postgres.StringColumn("body_lang")
|
||||||
BroadcastScopeColumn = postgres.StringColumn("broadcast_scope")
|
BroadcastScopeColumn = postgres.StringColumn("broadcast_scope")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||||
mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||||
defaultColumns = postgres.ColumnList{SenderIPColumn, SubjectColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
defaultColumns = postgres.ColumnList{SenderIPColumn, SubjectColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,6 +102,7 @@ func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomai
|
|||||||
SenderKind: SenderKindColumn,
|
SenderKind: SenderKindColumn,
|
||||||
SenderUserID: SenderUserIDColumn,
|
SenderUserID: SenderUserIDColumn,
|
||||||
SenderUsername: SenderUsernameColumn,
|
SenderUsername: SenderUsernameColumn,
|
||||||
|
SenderRaceName: SenderRaceNameColumn,
|
||||||
SenderIP: SenderIPColumn,
|
SenderIP: SenderIPColumn,
|
||||||
Subject: SubjectColumn,
|
Subject: SubjectColumn,
|
||||||
Body: BodyColumn,
|
Body: BodyColumn,
|
||||||
|
|||||||
@@ -683,6 +683,11 @@ CREATE TABLE diplomail_messages (
|
|||||||
sender_kind text NOT NULL,
|
sender_kind text NOT NULL,
|
||||||
sender_user_id uuid,
|
sender_user_id uuid,
|
||||||
sender_username text,
|
sender_username text,
|
||||||
|
-- sender_race_name is the immutable snapshot of the sender's race
|
||||||
|
-- in this game, captured at insert time when sender_kind='player'.
|
||||||
|
-- Admin and system messages carry NULL. The Phase 28 mail UI keys
|
||||||
|
-- per-race threading on this column.
|
||||||
|
sender_race_name text,
|
||||||
sender_ip text NOT NULL DEFAULT '',
|
sender_ip text NOT NULL DEFAULT '',
|
||||||
subject text NOT NULL DEFAULT '',
|
subject text NOT NULL DEFAULT '',
|
||||||
body text NOT NULL,
|
body text NOT NULL,
|
||||||
@@ -698,6 +703,13 @@ CREATE TABLE diplomail_messages (
|
|||||||
(sender_kind = 'admin' AND sender_user_id IS NULL AND sender_username IS NOT NULL) OR
|
(sender_kind = 'admin' AND sender_user_id IS NULL AND sender_username IS NOT NULL) OR
|
||||||
(sender_kind = 'system' AND sender_user_id IS NULL AND sender_username IS NULL)
|
(sender_kind = 'system' AND sender_user_id IS NULL AND sender_username IS NULL)
|
||||||
),
|
),
|
||||||
|
-- sender_race_name is only meaningful for player senders. Admin
|
||||||
|
-- and system rows never carry a race; player rows carry one when
|
||||||
|
-- the sender has an active membership at send time (a non-playing
|
||||||
|
-- private-game owner may legitimately have none).
|
||||||
|
CONSTRAINT diplomail_messages_sender_race_chk CHECK (
|
||||||
|
sender_kind = 'player' OR sender_race_name IS NULL
|
||||||
|
),
|
||||||
CONSTRAINT diplomail_messages_kind_sender_chk CHECK (
|
CONSTRAINT diplomail_messages_kind_sender_chk CHECK (
|
||||||
(kind = 'personal' AND sender_kind = 'player') OR
|
(kind = 'personal' AND sender_kind = 'player') OR
|
||||||
(kind = 'admin' AND sender_kind IN ('player', 'admin', 'system'))
|
(kind = 'admin' AND sender_kind IN ('player', 'admin', 'system'))
|
||||||
|
|||||||
@@ -60,16 +60,21 @@ func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc {
|
|||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
switch req.Target {
|
switch req.Target {
|
||||||
case "", "user":
|
case "", "user":
|
||||||
recipientID, parseErr := uuid.Parse(req.RecipientUserID)
|
var recipientID uuid.UUID
|
||||||
|
if req.RecipientUserID != "" {
|
||||||
|
parsed, parseErr := uuid.Parse(req.RecipientUserID)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
recipientID = parsed
|
||||||
|
}
|
||||||
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||||
GameID: gameID,
|
GameID: gameID,
|
||||||
CallerKind: diplomail.CallerKindAdmin,
|
CallerKind: diplomail.CallerKindAdmin,
|
||||||
CallerUsername: username,
|
CallerUsername: username,
|
||||||
RecipientUserID: recipientID,
|
RecipientUserID: recipientID,
|
||||||
|
RecipientRaceName: req.RecipientRaceName,
|
||||||
Subject: req.Subject,
|
Subject: req.Subject,
|
||||||
Body: req.Body,
|
Body: req.Body,
|
||||||
SenderIP: clientip.ExtractSourceIP(c),
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
|||||||
@@ -87,16 +87,21 @@ func (h *UserMailHandlers) SendPersonal() gin.HandlerFunc {
|
|||||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recipientID, err := uuid.Parse(req.RecipientUserID)
|
var recipientID uuid.UUID
|
||||||
if err != nil {
|
if req.RecipientUserID != "" {
|
||||||
|
parsed, perr := uuid.Parse(req.RecipientUserID)
|
||||||
|
if perr != nil {
|
||||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
recipientID = parsed
|
||||||
|
}
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||||
GameID: gameID,
|
GameID: gameID,
|
||||||
SenderUserID: userID,
|
SenderUserID: userID,
|
||||||
RecipientUserID: recipientID,
|
RecipientUserID: recipientID,
|
||||||
|
RecipientRaceName: req.RecipientRaceName,
|
||||||
Subject: req.Subject,
|
Subject: req.Subject,
|
||||||
Body: req.Body,
|
Body: req.Body,
|
||||||
SenderIP: clientip.ExtractSourceIP(c),
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
@@ -341,11 +346,15 @@ func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
|
|||||||
|
|
||||||
switch req.Target {
|
switch req.Target {
|
||||||
case "", "user":
|
case "", "user":
|
||||||
recipientID, parseErr := uuid.Parse(req.RecipientUserID)
|
var recipientID uuid.UUID
|
||||||
|
if req.RecipientUserID != "" {
|
||||||
|
parsed, parseErr := uuid.Parse(req.RecipientUserID)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
recipientID = parsed
|
||||||
|
}
|
||||||
callerUserID := userID
|
callerUserID := userID
|
||||||
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||||
GameID: gameID,
|
GameID: gameID,
|
||||||
@@ -353,6 +362,7 @@ func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
|
|||||||
CallerUserID: &callerUserID,
|
CallerUserID: &callerUserID,
|
||||||
CallerUsername: account.UserName,
|
CallerUsername: account.UserName,
|
||||||
RecipientUserID: recipientID,
|
RecipientUserID: recipientID,
|
||||||
|
RecipientRaceName: req.RecipientRaceName,
|
||||||
Subject: req.Subject,
|
Subject: req.Subject,
|
||||||
Body: req.Body,
|
Body: req.Body,
|
||||||
SenderIP: clientip.ExtractSourceIP(c),
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
@@ -449,8 +459,11 @@ func parseMessageIDParam(c *gin.Context) (uuid.UUID, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// userMailSendRequestWire mirrors the request body for SendPersonal.
|
// userMailSendRequestWire mirrors the request body for SendPersonal.
|
||||||
|
// Exactly one of `recipient_user_id` and `recipient_race_name` must
|
||||||
|
// be supplied; the service rejects ambiguous and empty inputs.
|
||||||
type userMailSendRequestWire struct {
|
type userMailSendRequestWire struct {
|
||||||
RecipientUserID string `json:"recipient_user_id"`
|
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||||
|
RecipientRaceName string `json:"recipient_race_name,omitempty"`
|
||||||
Subject string `json:"subject,omitempty"`
|
Subject string `json:"subject,omitempty"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
@@ -464,12 +477,13 @@ type userMailSendBroadcastRequestWire struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// userMailSendAdminRequestWire mirrors the request body for the
|
// userMailSendAdminRequestWire mirrors the request body for the
|
||||||
// owner-only admin send. `target="user"` requires
|
// owner-only admin send. `target="user"` requires exactly one of
|
||||||
// `recipient_user_id`; `target="all"` accepts the optional
|
// `recipient_user_id` and `recipient_race_name`; `target="all"`
|
||||||
// `recipients` scope (default `active`).
|
// accepts the optional `recipients` scope (default `active`).
|
||||||
type userMailSendAdminRequestWire struct {
|
type userMailSendAdminRequestWire struct {
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||||
|
RecipientRaceName string `json:"recipient_race_name,omitempty"`
|
||||||
Recipients string `json:"recipients,omitempty"`
|
Recipients string `json:"recipients,omitempty"`
|
||||||
Subject string `json:"subject,omitempty"`
|
Subject string `json:"subject,omitempty"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
@@ -524,6 +538,7 @@ type userMailMessageDetailWire struct {
|
|||||||
SenderKind string `json:"sender_kind"`
|
SenderKind string `json:"sender_kind"`
|
||||||
SenderUserID *string `json:"sender_user_id,omitempty"`
|
SenderUserID *string `json:"sender_user_id,omitempty"`
|
||||||
SenderUsername *string `json:"sender_username,omitempty"`
|
SenderUsername *string `json:"sender_username,omitempty"`
|
||||||
|
SenderRaceName *string `json:"sender_race_name,omitempty"`
|
||||||
Subject string `json:"subject,omitempty"`
|
Subject string `json:"subject,omitempty"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
BodyLang string `json:"body_lang"`
|
BodyLang string `json:"body_lang"`
|
||||||
@@ -597,6 +612,10 @@ func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userM
|
|||||||
s := *entry.SenderUsername
|
s := *entry.SenderUsername
|
||||||
out.SenderUsername = &s
|
out.SenderUsername = &s
|
||||||
}
|
}
|
||||||
|
if entry.SenderRaceName != nil {
|
||||||
|
s := *entry.SenderRaceName
|
||||||
|
out.SenderRaceName = &s
|
||||||
|
}
|
||||||
if entry.Recipient.RecipientRaceName != nil {
|
if entry.Recipient.RecipientRaceName != nil {
|
||||||
s := *entry.Recipient.RecipientRaceName
|
s := *entry.Recipient.RecipientRaceName
|
||||||
out.RecipientRaceName = &s
|
out.RecipientRaceName = &s
|
||||||
|
|||||||
+35
-5
@@ -4068,11 +4068,22 @@ components:
|
|||||||
UserMailSendRequest:
|
UserMailSendRequest:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [recipient_user_id, body]
|
required: [body]
|
||||||
properties:
|
properties:
|
||||||
recipient_user_id:
|
recipient_user_id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
description: |
|
||||||
|
Either `recipient_user_id` or `recipient_race_name` must
|
||||||
|
be supplied; supplying both is rejected as
|
||||||
|
`invalid_request`.
|
||||||
|
recipient_race_name:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Resolves to the active member with this race name in the
|
||||||
|
game. Mutually exclusive with `recipient_user_id`. The
|
||||||
|
server returns `forbidden` when the matching member is no
|
||||||
|
longer active (lobby-removed / blocked).
|
||||||
subject:
|
subject:
|
||||||
type: string
|
type: string
|
||||||
description: |
|
description: |
|
||||||
@@ -4093,10 +4104,18 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
description: |
|
description: |
|
||||||
Required when `target="user"`. Identifies the recipient
|
One of `recipient_user_id` and `recipient_race_name` is
|
||||||
of the personal admin message; the recipient may be in
|
required when `target="user"`. Identifies the recipient
|
||||||
any membership status (admin notifications can reach
|
of the personal admin message by uuid; the recipient may
|
||||||
kicked players).
|
be in any membership status (admin notifications can
|
||||||
|
reach kicked players when addressed by user_id).
|
||||||
|
recipient_race_name:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Optional alternative to `recipient_user_id` when
|
||||||
|
`target="user"`. Resolves to the active member with this
|
||||||
|
race name in the game; lobby-removed and blocked members
|
||||||
|
cannot be reached through the race-name shortcut.
|
||||||
recipients:
|
recipients:
|
||||||
type: string
|
type: string
|
||||||
enum: [active, active_and_removed, all_members]
|
enum: [active, active_and_removed, all_members]
|
||||||
@@ -4323,6 +4342,17 @@ components:
|
|||||||
sender_username:
|
sender_username:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
sender_race_name:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: |
|
||||||
|
Snapshot of the sender's race name in this game at send
|
||||||
|
time. Populated when `sender_kind="player"` and the
|
||||||
|
sender had an active membership at send time; nil for
|
||||||
|
admin and system messages, and for player messages sent
|
||||||
|
by a private-game owner who was not an active member at
|
||||||
|
send time. The in-game UI keys per-race threading on this
|
||||||
|
field.
|
||||||
subject:
|
subject:
|
||||||
type: string
|
type: string
|
||||||
body:
|
body:
|
||||||
|
|||||||
Reference in New Issue
Block a user