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:
@@ -26,7 +26,10 @@ Three Postgres tables in the `backend` schema:
|
||||
|
||||
- `diplomail_messages` — one row per send (personal, admin, or
|
||||
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
|
||||
per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at`
|
||||
state. Snapshot fields (`recipient_user_name`,
|
||||
@@ -72,6 +75,24 @@ mail to every active member; `Service.changeMembershipStatus` /
|
||||
`detector.LanguageDetector` (default: `whatlanggo`, body-only,
|
||||
≥ 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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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) {
|
||||
db := startPostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,7 +31,7 @@ func messageColumns() postgres.ColumnList {
|
||||
m := table.DiplomailMessages
|
||||
return postgres.ColumnList{
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ type MessageInsert struct {
|
||||
SenderKind string
|
||||
SenderUserID *uuid.UUID
|
||||
SenderUsername *string
|
||||
SenderRaceName *string
|
||||
SenderIP string
|
||||
Subject string
|
||||
Body string
|
||||
@@ -101,7 +102,7 @@ func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInse
|
||||
m := table.DiplomailMessages
|
||||
msgStmt := m.INSERT(
|
||||
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,
|
||||
).VALUES(
|
||||
msg.MessageID,
|
||||
@@ -111,6 +112,7 @@ func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInse
|
||||
msg.SenderKind,
|
||||
uuidPtrArg(msg.SenderUserID),
|
||||
stringPtrArg(msg.SenderUsername),
|
||||
stringPtrArg(msg.SenderRaceName),
|
||||
msg.SenderIP,
|
||||
msg.Subject,
|
||||
msg.Body,
|
||||
@@ -737,6 +739,10 @@ func messageFromModel(row model.DiplomailMessages) Message {
|
||||
name := *row.SenderUsername
|
||||
out.SenderUsername = &name
|
||||
}
|
||||
if row.SenderRaceName != nil {
|
||||
name := *row.SenderRaceName
|
||||
out.SenderRaceName = &name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ type Message struct {
|
||||
SenderKind string
|
||||
SenderUserID *uuid.UUID
|
||||
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
|
||||
Subject string
|
||||
Body string
|
||||
@@ -92,13 +97,16 @@ type Translation struct {
|
||||
}
|
||||
|
||||
// SendPersonalInput is the request payload for SendPersonal: the
|
||||
// caller sending a single-recipient personal message. Validation
|
||||
// (active membership, body length, etc.) is performed inside the
|
||||
// service.
|
||||
// caller sending a single-recipient personal message. Exactly one of
|
||||
// RecipientUserID and RecipientRaceName must be non-zero; the
|
||||
// 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 {
|
||||
GameID uuid.UUID
|
||||
SenderUserID uuid.UUID
|
||||
RecipientUserID uuid.UUID
|
||||
RecipientRaceName string
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
@@ -116,14 +124,18 @@ const (
|
||||
|
||||
// SendAdminPersonalInput is the request payload for an owner /
|
||||
// admin / system sending an admin-kind message to a single
|
||||
// recipient. Authorization (owner-vs-admin distinction) is enforced
|
||||
// by the HTTP layer; the service trusts the caller designation.
|
||||
// recipient. Exactly one of RecipientUserID and RecipientRaceName
|
||||
// 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 {
|
||||
GameID uuid.UUID
|
||||
CallerKind string
|
||||
CallerUserID *uuid.UUID
|
||||
CallerUsername string
|
||||
RecipientUserID uuid.UUID
|
||||
RecipientRaceName string
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
|
||||
@@ -20,6 +20,7 @@ type DiplomailMessages struct {
|
||||
SenderKind string
|
||||
SenderUserID *uuid.UUID
|
||||
SenderUsername *string
|
||||
SenderRaceName *string
|
||||
SenderIP string
|
||||
Subject string
|
||||
Body string
|
||||
|
||||
@@ -24,6 +24,7 @@ type diplomailMessagesTable struct {
|
||||
SenderKind postgres.ColumnString
|
||||
SenderUserID postgres.ColumnString
|
||||
SenderUsername postgres.ColumnString
|
||||
SenderRaceName postgres.ColumnString
|
||||
SenderIP postgres.ColumnString
|
||||
Subject postgres.ColumnString
|
||||
Body postgres.ColumnString
|
||||
@@ -78,14 +79,15 @@ func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomai
|
||||
SenderKindColumn = postgres.StringColumn("sender_kind")
|
||||
SenderUserIDColumn = postgres.StringColumn("sender_user_id")
|
||||
SenderUsernameColumn = postgres.StringColumn("sender_username")
|
||||
SenderRaceNameColumn = postgres.StringColumn("sender_race_name")
|
||||
SenderIPColumn = postgres.StringColumn("sender_ip")
|
||||
SubjectColumn = postgres.StringColumn("subject")
|
||||
BodyColumn = postgres.StringColumn("body")
|
||||
BodyLangColumn = postgres.StringColumn("body_lang")
|
||||
BroadcastScopeColumn = postgres.StringColumn("broadcast_scope")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{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, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{SenderIPColumn, SubjectColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
@@ -100,6 +102,7 @@ func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomai
|
||||
SenderKind: SenderKindColumn,
|
||||
SenderUserID: SenderUserIDColumn,
|
||||
SenderUsername: SenderUsernameColumn,
|
||||
SenderRaceName: SenderRaceNameColumn,
|
||||
SenderIP: SenderIPColumn,
|
||||
Subject: SubjectColumn,
|
||||
Body: BodyColumn,
|
||||
|
||||
@@ -683,6 +683,11 @@ CREATE TABLE diplomail_messages (
|
||||
sender_kind text NOT NULL,
|
||||
sender_user_id uuid,
|
||||
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 '',
|
||||
subject text NOT NULL DEFAULT '',
|
||||
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 = '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 (
|
||||
(kind = 'personal' AND sender_kind = 'player') OR
|
||||
(kind = 'admin' AND sender_kind IN ('player', 'admin', 'system'))
|
||||
|
||||
@@ -60,16 +60,21 @@ func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc {
|
||||
ctx := c.Request.Context()
|
||||
switch req.Target {
|
||||
case "", "user":
|
||||
recipientID, parseErr := uuid.Parse(req.RecipientUserID)
|
||||
var recipientID uuid.UUID
|
||||
if req.RecipientUserID != "" {
|
||||
parsed, parseErr := uuid.Parse(req.RecipientUserID)
|
||||
if parseErr != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
recipientID = parsed
|
||||
}
|
||||
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||
GameID: gameID,
|
||||
CallerKind: diplomail.CallerKindAdmin,
|
||||
CallerUsername: username,
|
||||
RecipientUserID: recipientID,
|
||||
RecipientRaceName: req.RecipientRaceName,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
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")
|
||||
return
|
||||
}
|
||||
recipientID, err := uuid.Parse(req.RecipientUserID)
|
||||
if err != nil {
|
||||
var recipientID uuid.UUID
|
||||
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")
|
||||
return
|
||||
}
|
||||
recipientID = parsed
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: userID,
|
||||
RecipientUserID: recipientID,
|
||||
RecipientRaceName: req.RecipientRaceName,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
@@ -341,11 +346,15 @@ func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
|
||||
|
||||
switch req.Target {
|
||||
case "", "user":
|
||||
recipientID, parseErr := uuid.Parse(req.RecipientUserID)
|
||||
var recipientID uuid.UUID
|
||||
if req.RecipientUserID != "" {
|
||||
parsed, parseErr := uuid.Parse(req.RecipientUserID)
|
||||
if parseErr != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
recipientID = parsed
|
||||
}
|
||||
callerUserID := userID
|
||||
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||
GameID: gameID,
|
||||
@@ -353,6 +362,7 @@ func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
|
||||
CallerUserID: &callerUserID,
|
||||
CallerUsername: account.UserName,
|
||||
RecipientUserID: recipientID,
|
||||
RecipientRaceName: req.RecipientRaceName,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
@@ -449,8 +459,11 @@ func parseMessageIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
@@ -464,12 +477,13 @@ type userMailSendBroadcastRequestWire struct {
|
||||
}
|
||||
|
||||
// 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`).
|
||||
// owner-only admin send. `target="user"` requires exactly one of
|
||||
// `recipient_user_id` and `recipient_race_name`; `target="all"`
|
||||
// accepts the optional `recipients` scope (default `active`).
|
||||
type userMailSendAdminRequestWire struct {
|
||||
Target string `json:"target"`
|
||||
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||
RecipientRaceName string `json:"recipient_race_name,omitempty"`
|
||||
Recipients string `json:"recipients,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
@@ -524,6 +538,7 @@ type userMailMessageDetailWire struct {
|
||||
SenderKind string `json:"sender_kind"`
|
||||
SenderUserID *string `json:"sender_user_id,omitempty"`
|
||||
SenderUsername *string `json:"sender_username,omitempty"`
|
||||
SenderRaceName *string `json:"sender_race_name,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
BodyLang string `json:"body_lang"`
|
||||
@@ -597,6 +612,10 @@ func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userM
|
||||
s := *entry.SenderUsername
|
||||
out.SenderUsername = &s
|
||||
}
|
||||
if entry.SenderRaceName != nil {
|
||||
s := *entry.SenderRaceName
|
||||
out.SenderRaceName = &s
|
||||
}
|
||||
if entry.Recipient.RecipientRaceName != nil {
|
||||
s := *entry.Recipient.RecipientRaceName
|
||||
out.RecipientRaceName = &s
|
||||
|
||||
+35
-5
@@ -4068,11 +4068,22 @@ components:
|
||||
UserMailSendRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [recipient_user_id, body]
|
||||
required: [body]
|
||||
properties:
|
||||
recipient_user_id:
|
||||
type: string
|
||||
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:
|
||||
type: string
|
||||
description: |
|
||||
@@ -4093,10 +4104,18 @@ components:
|
||||
type: string
|
||||
format: uuid
|
||||
description: |
|
||||
Required when `target="user"`. Identifies the recipient
|
||||
of the personal admin message; the recipient may be in
|
||||
any membership status (admin notifications can reach
|
||||
kicked players).
|
||||
One of `recipient_user_id` and `recipient_race_name` is
|
||||
required when `target="user"`. Identifies the recipient
|
||||
of the personal admin message by uuid; the recipient may
|
||||
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:
|
||||
type: string
|
||||
enum: [active, active_and_removed, all_members]
|
||||
@@ -4323,6 +4342,17 @@ components:
|
||||
sender_username:
|
||||
type: string
|
||||
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:
|
||||
type: string
|
||||
body:
|
||||
|
||||
Reference in New Issue
Block a user