Phase 28 (Step 1): backend support for race-name mail send
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:
@@ -32,15 +32,20 @@ const previewMaxRunes = 120
|
||||
// ErrForbidden; the inserted Message is never persisted in those
|
||||
// cases.
|
||||
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")
|
||||
body := strings.TrimRight(in.Body, " \t\n")
|
||||
if err := s.validateContent(subject, body); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
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 errors.Is(err, ErrNotFound) {
|
||||
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
|
||||
senderRace := sender.RaceName
|
||||
senderUserID := in.SenderUserID
|
||||
msgInsert := MessageInsert{
|
||||
MessageID: uuid.New(),
|
||||
GameID: in.GameID,
|
||||
GameName: sender.GameName,
|
||||
Kind: KindPersonal,
|
||||
SenderKind: SenderKindPlayer,
|
||||
SenderUserID: &in.SenderUserID,
|
||||
SenderUserID: &senderUserID,
|
||||
SenderUsername: &username,
|
||||
SenderRaceName: &senderRace,
|
||||
SenderIP: in.SenderIP,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
@@ -75,7 +83,7 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
|
||||
rcptInsert := buildRecipientInsert(
|
||||
msgInsert.MessageID,
|
||||
MemberSnapshot{
|
||||
UserID: in.RecipientUserID,
|
||||
UserID: recipientID,
|
||||
GameID: in.GameID,
|
||||
GameName: recipient.GameName,
|
||||
UserName: recipient.UserName,
|
||||
@@ -101,6 +109,47 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
|
||||
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
|
||||
// userID. ErrNotFound is returned when the caller is not a recipient
|
||||
// of the message — handlers translate that to 404 so the existence
|
||||
|
||||
Reference in New Issue
Block a user