diff --git a/backend/internal/diplomail/README.md b/backend/internal/diplomail/README.md index 581d673..4e86b01 100644 --- a/backend/internal/diplomail/README.md +++ b/backend/internal/diplomail/README.md @@ -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 diff --git a/backend/internal/diplomail/admin_send.go b/backend/internal/diplomail/admin_send.go index 0b4c5c8..eb2eb9c 100644 --- a/backend/internal/diplomail/admin_send.go +++ b/backend/internal/diplomail/admin_send.go @@ -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 diff --git a/backend/internal/diplomail/diplomail_e2e_test.go b/backend/internal/diplomail/diplomail_e2e_test.go index 3f85830..71511b8 100644 --- a/backend/internal/diplomail/diplomail_e2e_test.go +++ b/backend/internal/diplomail/diplomail_e2e_test.go @@ -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() diff --git a/backend/internal/diplomail/service.go b/backend/internal/diplomail/service.go index b6365a7..7c350b5 100644 --- a/backend/internal/diplomail/service.go +++ b/backend/internal/diplomail/service.go @@ -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 diff --git a/backend/internal/diplomail/store.go b/backend/internal/diplomail/store.go index e6364cf..6d4f5c9 100644 --- a/backend/internal/diplomail/store.go +++ b/backend/internal/diplomail/store.go @@ -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 } diff --git a/backend/internal/diplomail/types.go b/backend/internal/diplomail/types.go index f1b29f7..52bfb45 100644 --- a/backend/internal/diplomail/types.go +++ b/backend/internal/diplomail/types.go @@ -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,16 +97,19 @@ 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 - Subject string - Body string - SenderIP string + GameID uuid.UUID + SenderUserID uuid.UUID + RecipientUserID uuid.UUID + RecipientRaceName string + Subject string + Body string + SenderIP string } // CallerKind enumerates the privileged sender roles for admin-kind @@ -116,17 +124,21 @@ 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 - Subject string - Body string - SenderIP string + GameID uuid.UUID + CallerKind string + CallerUserID *uuid.UUID + CallerUsername string + RecipientUserID uuid.UUID + RecipientRaceName string + Subject string + Body string + SenderIP string } // SendAdminBroadcastInput is the request payload for an owner / diff --git a/backend/internal/postgres/jet/backend/model/diplomail_messages.go b/backend/internal/postgres/jet/backend/model/diplomail_messages.go index c2939cc..8b82732 100644 --- a/backend/internal/postgres/jet/backend/model/diplomail_messages.go +++ b/backend/internal/postgres/jet/backend/model/diplomail_messages.go @@ -20,6 +20,7 @@ type DiplomailMessages struct { SenderKind string SenderUserID *uuid.UUID SenderUsername *string + SenderRaceName *string SenderIP string Subject string Body string diff --git a/backend/internal/postgres/jet/backend/table/diplomail_messages.go b/backend/internal/postgres/jet/backend/table/diplomail_messages.go index 902e05c..afd403f 100644 --- a/backend/internal/postgres/jet/backend/table/diplomail_messages.go +++ b/backend/internal/postgres/jet/backend/table/diplomail_messages.go @@ -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, diff --git a/backend/internal/postgres/migrations/00001_init.sql b/backend/internal/postgres/migrations/00001_init.sql index c749749..afbb939 100644 --- a/backend/internal/postgres/migrations/00001_init.sql +++ b/backend/internal/postgres/migrations/00001_init.sql @@ -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')) diff --git a/backend/internal/server/handlers_admin_diplomail.go b/backend/internal/server/handlers_admin_diplomail.go index 0e1dd0a..328b902 100644 --- a/backend/internal/server/handlers_admin_diplomail.go +++ b/backend/internal/server/handlers_admin_diplomail.go @@ -60,19 +60,24 @@ func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc { ctx := c.Request.Context() switch req.Target { case "", "user": - recipientID, parseErr := uuid.Parse(req.RecipientUserID) - if parseErr != nil { - httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") - return + 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, - Subject: req.Subject, - Body: req.Body, - SenderIP: clientip.ExtractSourceIP(c), + GameID: gameID, + CallerKind: diplomail.CallerKindAdmin, + CallerUsername: username, + RecipientUserID: recipientID, + RecipientRaceName: req.RecipientRaceName, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), }) if sendErr != nil { respondDiplomailError(c, h.logger, "admin mail send personal", ctx, sendErr) diff --git a/backend/internal/server/handlers_user_mail.go b/backend/internal/server/handlers_user_mail.go index 53960b6..04fd8f8 100644 --- a/backend/internal/server/handlers_user_mail.go +++ b/backend/internal/server/handlers_user_mail.go @@ -87,19 +87,24 @@ 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 { - httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") - return + 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, - Subject: req.Subject, - Body: req.Body, - SenderIP: clientip.ExtractSourceIP(c), + GameID: gameID, + SenderUserID: userID, + RecipientUserID: recipientID, + RecipientRaceName: req.RecipientRaceName, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), }) if err != nil { respondDiplomailError(c, h.logger, "user mail send personal", ctx, err) @@ -341,21 +346,26 @@ func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc { switch req.Target { case "", "user": - recipientID, parseErr := uuid.Parse(req.RecipientUserID) - if parseErr != nil { - httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") - return + 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, - CallerKind: diplomail.CallerKindOwner, - CallerUserID: &callerUserID, - CallerUsername: account.UserName, - RecipientUserID: recipientID, - Subject: req.Subject, - Body: req.Body, - SenderIP: clientip.ExtractSourceIP(c), + GameID: gameID, + CallerKind: diplomail.CallerKindOwner, + CallerUserID: &callerUserID, + CallerUsername: account.UserName, + RecipientUserID: recipientID, + RecipientRaceName: req.RecipientRaceName, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), }) if sendErr != nil { respondDiplomailError(c, h.logger, "user mail send admin personal", ctx, sendErr) @@ -449,10 +459,13 @@ 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"` - Subject string `json:"subject,omitempty"` - Body string `json:"body"` + RecipientUserID string `json:"recipient_user_id,omitempty"` + RecipientRaceName string `json:"recipient_race_name,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` } // userMailSendBroadcastRequestWire mirrors the request body for the @@ -464,15 +477,16 @@ 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"` - Recipients string `json:"recipients,omitempty"` - Subject string `json:"subject,omitempty"` - Body string `json:"body"` + 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"` } // userMailBroadcastReceiptWire is the response shape returned after a @@ -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 diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 251fb6d..ae89e0b 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -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: