Phase 28 (Step 1): backend support for race-name mail send
Tests · Go / test (push) Successful in 1m56s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m2s

Phase 28's in-game mail UI groups personal threads by the other
party's race. To support that without an extra membership-listing
RPC, the diplomail subsystem now:

- accepts `recipient_race_name` on `POST /messages` and
  `POST /admin` (target=user) as an alternative to
  `recipient_user_id`; the service resolves it via the existing
  `Memberships.ListMembers(gameID, "active")` and rejects with
  `forbidden` when the matching member is no longer active;
- snapshots `diplomail_messages.sender_race_name` at send time for
  every player sender (admin / system rows stay NULL). The UI keys
  per-race threading on this column.

Schema, openapi, README, and a focused e2e test for the new path
(happy path + dual / missing / unknown / kicked errors) land in
this commit; the gateway + UI legs follow in subsequent commits on
this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-15 22:07:48 +02:00
parent 74c1e7ab24
commit 7b43ce5844
12 changed files with 372 additions and 87 deletions
+27 -8
View File
@@ -29,7 +29,11 @@ func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInp
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 errors.Is(err, ErrNotFound) {
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)
}
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)
if err != nil {
return Message{}, Recipient{}, err
@@ -84,7 +88,7 @@ func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastI
}
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)
if err != nil {
return Message{}, nil, err
@@ -147,6 +151,7 @@ func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcas
}
username := sender.UserName
senderRace := sender.RaceName
msgInsert := MessageInsert{
MessageID: uuid.New(),
GameID: in.GameID,
@@ -155,6 +160,7 @@ func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcas
SenderKind: SenderKindPlayer,
SenderUserID: &callerID,
SenderUsername: &username,
SenderRaceName: &senderRace,
SenderIP: in.SenderIP,
Subject: subject,
Body: body,
@@ -217,7 +223,7 @@ func (s *Service) SendAdminMultiGameBroadcast(ctx context.Context, in SendMultiG
zap.String("scope", scope))
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)
if err != nil {
return nil, 0, err
@@ -356,7 +362,7 @@ func (s *Service) publishGameLifecycle(ctx context.Context, ev LifecycleEvent) e
gameName := members[0].GameName
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)
if err != nil {
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)
msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "",
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle)
if err != nil {
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
// 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='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) {
out := MessageInsert{
MessageID: uuid.New(),
@@ -443,6 +451,17 @@ func (s *Service) buildAdminMessageInsert(callerKind string, callerUserID *uuid.
out.SenderKind = SenderKindPlayer
out.SenderUserID = &uid
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:
uname := callerUsername
out.SenderKind = SenderKindAdmin