From b3f24cc440cb1116b6e881c59151b5fdd479f59b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 15 May 2026 18:47:54 +0200 Subject: [PATCH] diplomail (Stage B): admin/owner sends + lifecycle hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 7 of the spec wants game-state and membership-state changes to land as durable inbox entries the affected players can re-read after the fact — push alone times out of the 5-minute ring buffer. Stage B adds the admin-kind send matrix (owner-driven via /user, site-admin driven via /admin) plus the lobby lifecycle hooks: paused / cancelled emit a broadcast system mail to active members, kick / ban emit a single-recipient system mail to the affected user (which they keep read access to even after the membership row is revoked, per item 8). Migration relaxes diplomail_messages_kind_sender_chk so an owner sending kind=admin keeps sender_kind=player; the new LifecyclePublisher dep on lobby.Service is wired through a thin adapter in cmd/backend/main, mirroring how lobby's notification publisher is plumbed today. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/cmd/backend/main.go | 118 +++++- backend/internal/diplomail/README.md | 18 +- backend/internal/diplomail/admin_send.go | 348 ++++++++++++++++++ backend/internal/diplomail/deps.go | 36 +- .../internal/diplomail/diplomail_e2e_test.go | 211 ++++++++++- backend/internal/diplomail/service.go | 56 ++- backend/internal/diplomail/types.go | 69 ++++ backend/internal/lobby/deps.go | 54 +++ backend/internal/lobby/games.go | 35 ++ backend/internal/lobby/lobby.go | 4 + backend/internal/lobby/memberships.go | 36 ++ .../postgres/migrations/00001_init.sql | 2 +- backend/internal/server/contract_test.go | 12 + .../server/handlers_admin_diplomail.go | 101 +++++ backend/internal/server/handlers_user_mail.go | 161 +++++++- backend/internal/server/router.go | 8 +- backend/openapi.yaml | 152 ++++++++ 17 files changed, 1398 insertions(+), 23 deletions(-) create mode 100644 backend/internal/diplomail/admin_send.go create mode 100644 backend/internal/server/handlers_admin_diplomail.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index e132e96..afa827a 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -132,6 +132,7 @@ func run(ctx context.Context) (err error) { lobbyCascade := &lobbyCascadeAdapter{} userNotifyCascade := &userNotificationCascadeAdapter{} lobbyNotifyPublisher := &lobbyNotificationPublisherAdapter{} + lobbyDiplomailPublisher := &lobbyDiplomailPublisherAdapter{} runtimeNotifyPublisher := &runtimeNotificationPublisherAdapter{} userSvc := user.NewService(user.Deps{ @@ -198,6 +199,7 @@ func run(ctx context.Context) (err error) { Cache: lobbyCache, Runtime: runtimeGateway, Notification: lobbyNotifyPublisher, + Diplomail: lobbyDiplomailPublisher, Entitlement: &userEntitlementAdapter{svc: userSvc}, Config: cfg.Lobby, Logger: logger, @@ -311,6 +313,7 @@ func run(ctx context.Context) (err error) { Config: cfg.Diplomail, Logger: logger, }) + lobbyDiplomailPublisher.svc = diplomailSvc if email := cfg.Notification.AdminEmail; email == "" { logger.Info("notification admin email not configured (BACKEND_NOTIFICATION_ADMIN_EMAIL); admin-channel routes will be skipped") } else { @@ -335,10 +338,11 @@ func run(ctx context.Context) (err error) { adminEngineVersionsHandlers := backendserver.NewAdminEngineVersionsHandlers(engineVersionSvc, logger) adminRuntimesHandlers := backendserver.NewAdminRuntimesHandlers(runtimeSvc, logger) adminMailHandlers := backendserver.NewAdminMailHandlers(mailSvc, logger) + adminDiplomailHandlers := backendserver.NewAdminDiplomailHandlers(diplomailSvc, logger) adminNotificationsHandlers := backendserver.NewAdminNotificationsHandlers(notifSvc, logger) adminGeoHandlers := backendserver.NewAdminGeoHandlers(geoSvc, logger) userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger) - userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, logger) + userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, lobbySvc, userSvc, logger) ready := func() bool { return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready() @@ -367,6 +371,7 @@ func run(ctx context.Context) (err error) { AdminRuntimes: adminRuntimesHandlers, AdminEngineVersions: adminEngineVersionsHandlers, AdminMail: adminMailHandlers, + AdminDiplomail: adminDiplomailHandlers, AdminNotifications: adminNotificationsHandlers, AdminGeo: adminGeoHandlers, UserGames: userGamesHandlers, @@ -593,9 +598,9 @@ func (a *runtimeNotificationPublisherAdapter) PublishRuntimeEvent(ctx context.Co } // diplomailMembershipAdapter implements `diplomail.MembershipLookup` -// by walking the lobby cache for the active (game_id, user_id) row -// and stitching the snapshot fields together with the immutable -// `user_name` read through `*user.Service`. +// by walking the lobby cache (for active rows) and the lobby service +// (for any-status rows) and stitching each membership row to the +// immutable `accounts.user_name` resolved through `*user.Service`. type diplomailMembershipAdapter struct { lobby *lobby.Service users *user.Service @@ -637,6 +642,111 @@ func (a *diplomailMembershipAdapter) GetActiveMembership(ctx context.Context, ga }, nil } +func (a *diplomailMembershipAdapter) GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (diplomail.MemberSnapshot, error) { + if a == nil || a.lobby == nil || a.users == nil { + return diplomail.MemberSnapshot{}, diplomail.ErrNotFound + } + game, ok := a.lobby.Cache().GetGame(gameID) + if !ok { + return diplomail.MemberSnapshot{}, diplomail.ErrNotFound + } + members, err := a.lobby.ListMembershipsForGame(ctx, gameID) + if err != nil { + return diplomail.MemberSnapshot{}, err + } + var found *lobby.Membership + for _, m := range members { + if m.UserID == userID { + mm := m + found = &mm + break + } + } + if found == nil { + return diplomail.MemberSnapshot{}, diplomail.ErrNotFound + } + account, err := a.users.GetAccount(ctx, userID) + if err != nil { + return diplomail.MemberSnapshot{}, err + } + return diplomail.MemberSnapshot{ + UserID: userID, + GameID: gameID, + GameName: game.GameName, + UserName: account.UserName, + RaceName: found.RaceName, + Status: found.Status, + }, nil +} + +func (a *diplomailMembershipAdapter) ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]diplomail.MemberSnapshot, error) { + if a == nil || a.lobby == nil || a.users == nil { + return nil, diplomail.ErrNotFound + } + game, ok := a.lobby.Cache().GetGame(gameID) + if !ok { + return nil, diplomail.ErrNotFound + } + members, err := a.lobby.ListMembershipsForGame(ctx, gameID) + if err != nil { + return nil, err + } + matches := func(status string) bool { + switch scope { + case diplomail.RecipientScopeActive: + return status == lobby.MembershipStatusActive + case diplomail.RecipientScopeActiveAndRemoved: + return status == lobby.MembershipStatusActive || status == lobby.MembershipStatusRemoved + case diplomail.RecipientScopeAllMembers: + return true + default: + return status == lobby.MembershipStatusActive + } + } + out := make([]diplomail.MemberSnapshot, 0, len(members)) + for _, m := range members { + if !matches(m.Status) { + continue + } + account, err := a.users.GetAccount(ctx, m.UserID) + if err != nil { + return nil, fmt.Errorf("resolve user_name for %s: %w", m.UserID, err) + } + out = append(out, diplomail.MemberSnapshot{ + UserID: m.UserID, + GameID: gameID, + GameName: game.GameName, + UserName: account.UserName, + RaceName: m.RaceName, + Status: m.Status, + }) + } + return out, nil +} + +// lobbyDiplomailPublisherAdapter implements `lobby.DiplomailPublisher` +// by translating each lobby.LifecycleEvent into the diplomail +// vocabulary and delegating to `*diplomail.Service.PublishLifecycle`. +// The svc pointer is patched once diplomailSvc exists — diplomail +// depends on lobby through MembershipLookup, so the lobby service +// is constructed first and patched up. +type lobbyDiplomailPublisherAdapter struct { + svc *diplomail.Service +} + +func (a *lobbyDiplomailPublisherAdapter) PublishLifecycle(ctx context.Context, ev lobby.LifecycleEvent) error { + if a == nil || a.svc == nil { + return nil + } + return a.svc.PublishLifecycle(ctx, diplomail.LifecycleEvent{ + GameID: ev.GameID, + Kind: ev.Kind, + Actor: ev.Actor, + Reason: ev.Reason, + TargetUser: ev.TargetUser, + }) +} + // diplomailNotificationPublisherAdapter implements // `diplomail.NotificationPublisher` by translating each // DiplomailNotification into a notification.Intent and routing it diff --git a/backend/internal/diplomail/README.md b/backend/internal/diplomail/README.md index f35e360..024320e 100644 --- a/backend/internal/diplomail/README.md +++ b/backend/internal/diplomail/README.md @@ -15,7 +15,7 @@ purge, and the language-detection / translation cache. | Stage | Scope | Status | |-------|-------|--------| | A | Schema, personal single-recipient send / read / delete, unread badge, push event with body-language `und` | shipped | -| B | Owner / admin sends + lifecycle hooks (paused, cancelled, kick) | planned | +| B | Owner / admin sends + lifecycle hooks (paused, cancelled, kick); strict soft-access for kicked players | shipped | | C | Paid-tier personal broadcast + admin multi-game broadcast + bulk purge | planned | | D | Body-language detection (whatlanggo) + translation cache + async worker | planned | @@ -40,14 +40,20 @@ Three Postgres tables in the `backend` schema: | Action | Caller | Pre-conditions | |--------|--------|----------------| | Send personal | user | active membership in game; recipient is active member | -| Read message | the recipient | row exists in `diplomail_recipients(message_id, user_id)` | +| Send admin (single user) | game owner OR site admin | recipient is any-status member of the game | +| Send admin (broadcast) | game owner OR site admin | recipient scope ∈ `active` / `active_and_removed` / `all_members`; sender excluded | +| Read message | the recipient | row exists in `diplomail_recipients(message_id, user_id)`; non-active members see admin-kind only | | Mark read | the recipient | row exists; idempotent if already marked | | Soft delete | the recipient | `read_at IS NOT NULL` (open-then-delete, item 10) | -Stage B introduces the admin / owner send matrix and the strict -soft-access rule for kicked players (post-kick read access restricted -to `kind='admin'` rows). Stage C adds the paid-tier broadcast and the -bulk-purge admin endpoint. +Stage C will add the paid-tier player broadcast and the bulk-purge +admin endpoint. + +System mail is produced internally by lobby lifecycle hooks: +`Service.transition()` emits `game.paused` / `game.cancelled` system +mail to every active member; `Service.changeMembershipStatus` / +`Service.AdminBanMember` emit `membership.removed` / +`membership.blocked` system mail addressed to the affected user. ## Content rules diff --git a/backend/internal/diplomail/admin_send.go b/backend/internal/diplomail/admin_send.go new file mode 100644 index 0000000..3530cf9 --- /dev/null +++ b/backend/internal/diplomail/admin_send.go @@ -0,0 +1,348 @@ +package diplomail + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// SendAdminPersonal persists an admin-kind message addressed to a +// single recipient and fan-outs the push event. The HTTP layer is +// responsible for the owner-vs-admin authorisation decision; this +// function trusts the caller designation it receives. +// +// The recipient may be in any membership status, so the lookup goes +// through MembershipLookup.GetMembershipAnyStatus. This lets the +// owner / admin reach a kicked player to explain the kick or follow +// up after a removal. +func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInput) (Message, Recipient, error) { + subject, body, err := s.prepareContent(in.Subject, in.Body) + if err != nil { + return Message{}, Recipient{}, err + } + if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil { + return Message{}, Recipient{}, err + } + + recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, in.RecipientUserID) + if err != nil { + 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("diplomail: load admin recipient: %w", err) + } + + msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername, + recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle) + if err != nil { + return Message{}, Recipient{}, err + } + rcptInsert := buildRecipientInsert(msgInsert.MessageID, recipient) + + msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert}) + if err != nil { + return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: %w", err) + } + if len(recipients) != 1 { + return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: unexpected recipient count %d", len(recipients)) + } + + s.publishMessageReceived(ctx, msg, recipients[0]) + return msg, recipients[0], nil +} + +// SendAdminBroadcast persists an admin-kind broadcast addressed to +// every member matching `RecipientScope`, then emits one push event +// per recipient. The caller's own membership row, when present, is +// excluded from the recipient list — broadcasters do not get a copy +// of their own message. +func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastInput) (Message, []Recipient, error) { + subject, body, err := s.prepareContent(in.Subject, in.Body) + if err != nil { + return Message{}, nil, err + } + if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil { + return Message{}, nil, err + } + scope, err := normaliseScope(in.RecipientScope) + if err != nil { + return Message{}, nil, err + } + + members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, scope) + if err != nil { + return Message{}, nil, fmt.Errorf("diplomail: list members for broadcast: %w", err) + } + members = filterOutCaller(members, in.CallerUserID) + if len(members) == 0 { + return Message{}, nil, fmt.Errorf("%w: no recipients for broadcast", ErrInvalidInput) + } + + gameName := members[0].GameName + msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername, + in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast) + if err != nil { + return Message{}, nil, err + } + rcptInserts := make([]RecipientInsert, 0, len(members)) + for _, m := range members { + rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m)) + } + + msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts) + if err != nil { + return Message{}, nil, fmt.Errorf("diplomail: send admin broadcast: %w", err) + } + for _, r := range recipients { + s.publishMessageReceived(ctx, msg, r) + } + return msg, recipients, nil +} + +// PublishLifecycle persists a system-kind message in response to a +// lobby lifecycle transition and fan-outs push events to the +// affected recipients. Game-scoped transitions (`game.paused`, +// `game.cancelled`) reach every active member; membership-scoped +// transitions (`membership.removed`, `membership.blocked`) reach the +// kicked player only. Failures inside the function are logged at +// Warn level — lifecycle hooks must not block the lobby state +// machine on a downstream mail failure. +func (s *Service) PublishLifecycle(ctx context.Context, ev LifecycleEvent) error { + switch ev.Kind { + case LifecycleKindGamePaused, LifecycleKindGameCancelled: + return s.publishGameLifecycle(ctx, ev) + case LifecycleKindMembershipRemoved, LifecycleKindMembershipBlocked: + return s.publishMembershipLifecycle(ctx, ev) + default: + return fmt.Errorf("%w: unknown lifecycle kind %q", ErrInvalidInput, ev.Kind) + } +} + +func (s *Service) publishGameLifecycle(ctx context.Context, ev LifecycleEvent) error { + members, err := s.deps.Memberships.ListMembers(ctx, ev.GameID, RecipientScopeActive) + if err != nil { + return fmt.Errorf("diplomail lifecycle: list members for %s: %w", ev.GameID, err) + } + if len(members) == 0 { + s.deps.Logger.Debug("lifecycle skip: no active members", + zap.String("game_id", ev.GameID.String()), + zap.String("kind", ev.Kind)) + return nil + } + gameName := members[0].GameName + subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason) + + msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "", + ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast) + if err != nil { + return err + } + rcptInserts := make([]RecipientInsert, 0, len(members)) + for _, m := range members { + rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m)) + } + msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts) + if err != nil { + return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err) + } + for _, r := range recipients { + s.publishMessageReceived(ctx, msg, r) + } + return nil +} + +func (s *Service) publishMembershipLifecycle(ctx context.Context, ev LifecycleEvent) error { + if ev.TargetUser == nil { + return fmt.Errorf("%w: membership lifecycle requires TargetUser", ErrInvalidInput) + } + target, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, ev.GameID, *ev.TargetUser) + if err != nil { + return fmt.Errorf("diplomail lifecycle: load target membership: %w", err) + } + subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason) + + msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "", + ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle) + if err != nil { + return err + } + rcptInsert := buildRecipientInsert(msgInsert.MessageID, target) + msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert}) + if err != nil { + return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err) + } + if len(recipients) == 1 { + s.publishMessageReceived(ctx, msg, recipients[0]) + } + return nil +} + +// prepareContent normalises subject and body the same way SendPersonal +// does. Factored out so admin and lifecycle paths share the +// length-and-utf8 validation rules. +func (s *Service) prepareContent(subject, body string) (string, string, error) { + subj := strings.TrimRight(subject, " \t") + bod := strings.TrimRight(body, " \t\n") + if err := s.validateContent(subj, bod); err != nil { + return "", "", err + } + return subj, bod, nil +} + +// buildAdminMessageInsert encapsulates the message-row construction +// for every admin-kind send. The CHECK constraint maps sender +// shapes: +// +// sender_kind='player' → CallerKind owner; sender_user_id set +// 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, + gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) { + out := MessageInsert{ + MessageID: uuid.New(), + GameID: gameID, + GameName: gameName, + Kind: KindAdmin, + SenderIP: senderIP, + Subject: subject, + Body: body, + BodyLang: LangUndetermined, + BroadcastScope: scope, + } + switch callerKind { + case CallerKindOwner: + if callerUserID == nil { + return MessageInsert{}, fmt.Errorf("%w: owner send requires caller user id", ErrInvalidInput) + } + uid := *callerUserID + uname := callerUsername + out.SenderKind = SenderKindPlayer + out.SenderUserID = &uid + out.SenderUsername = &uname + case CallerKindAdmin: + uname := callerUsername + out.SenderKind = SenderKindAdmin + out.SenderUsername = &uname + case CallerKindSystem: + out.SenderKind = SenderKindSystem + default: + return MessageInsert{}, fmt.Errorf("%w: unknown caller kind %q", ErrInvalidInput, callerKind) + } + return out, nil +} + +// buildRecipientInsert turns a MemberSnapshot into a RecipientInsert. +// The race-name snapshot is nullable so a kicked player with no race +// name on file is still addressable. +func buildRecipientInsert(messageID uuid.UUID, m MemberSnapshot) RecipientInsert { + in := RecipientInsert{ + RecipientID: uuid.New(), + MessageID: messageID, + GameID: m.GameID, + UserID: m.UserID, + RecipientUserName: m.UserName, + } + if m.RaceName != "" { + race := m.RaceName + in.RecipientRaceName = &race + } + return in +} + +func validateCaller(callerKind string, callerUserID *uuid.UUID, callerUsername string) error { + switch callerKind { + case CallerKindOwner: + if callerUserID == nil { + return fmt.Errorf("%w: owner send requires caller_user_id", ErrInvalidInput) + } + if callerUsername == "" { + return fmt.Errorf("%w: owner send requires caller_username", ErrInvalidInput) + } + case CallerKindAdmin: + if callerUsername == "" { + return fmt.Errorf("%w: admin send requires caller_username", ErrInvalidInput) + } + case CallerKindSystem: + // no extra checks + default: + return fmt.Errorf("%w: unknown caller_kind %q", ErrInvalidInput, callerKind) + } + return nil +} + +func normaliseScope(scope string) (string, error) { + switch scope { + case "", RecipientScopeActive: + return RecipientScopeActive, nil + case RecipientScopeActiveAndRemoved, RecipientScopeAllMembers: + return scope, nil + default: + return "", fmt.Errorf("%w: unknown recipient scope %q", ErrInvalidInput, scope) + } +} + +func filterOutCaller(members []MemberSnapshot, callerUserID *uuid.UUID) []MemberSnapshot { + if callerUserID == nil { + return members + } + out := make([]MemberSnapshot, 0, len(members)) + for _, m := range members { + if m.UserID == *callerUserID { + continue + } + out = append(out, m) + } + return out +} + +// renderGameLifecycle returns the (subject, body) pair persisted for +// the `game.paused` / `game.cancelled` system message. Bodies are in +// English; Stage D will translate them on demand into each +// recipient's preferred_language and cache the result. +func renderGameLifecycle(kind, gameName, actor, reason string) (string, string) { + actor = strings.TrimSpace(actor) + if actor == "" { + actor = "the system" + } + reasonTail := "" + if r := strings.TrimSpace(reason); r != "" { + reasonTail = " Reason: " + r + "." + } + switch kind { + case LifecycleKindGamePaused: + return "Game paused", + fmt.Sprintf("The game %q has been paused by %s.%s", gameName, actor, reasonTail) + case LifecycleKindGameCancelled: + return "Game cancelled", + fmt.Sprintf("The game %q has been cancelled by %s.%s", gameName, actor, reasonTail) + } + return "Game lifecycle update", + fmt.Sprintf("The game %q has changed state.%s", gameName, reasonTail) +} + +// renderMembershipLifecycle returns the (subject, body) pair persisted +// for the `membership.removed` / `membership.blocked` system message. +func renderMembershipLifecycle(kind, gameName, actor, reason string) (string, string) { + actor = strings.TrimSpace(actor) + if actor == "" { + actor = "the system" + } + reasonTail := "" + if r := strings.TrimSpace(reason); r != "" { + reasonTail = " Reason: " + r + "." + } + switch kind { + case LifecycleKindMembershipRemoved: + return "Membership removed", + fmt.Sprintf("Your membership in %q has been removed by %s.%s", gameName, actor, reasonTail) + case LifecycleKindMembershipBlocked: + return "Membership blocked", + fmt.Sprintf("Your membership in %q has been blocked by %s.%s", gameName, actor, reasonTail) + } + return "Membership update", + fmt.Sprintf("Your membership in %q has changed.%s", gameName, reasonTail) +} diff --git a/backend/internal/diplomail/deps.go b/backend/internal/diplomail/deps.go index 0601cb6..a0afa75 100644 --- a/backend/internal/diplomail/deps.go +++ b/backend/internal/diplomail/deps.go @@ -43,11 +43,45 @@ type ActiveMembership struct { // roster metadata. The canonical implementation in `cmd/backend/main` // adapts the `*lobby.Service` membership cache to this interface. // -// Implementations must return ErrNotFound (the diplomail sentinel) +// GetActiveMembership returns ErrNotFound (the diplomail sentinel) // when the user is not an active member of the game; the service // boundary maps that to 403 forbidden. +// +// GetMembershipAnyStatus returns the same shape regardless of +// membership status (`active`, `removed`, `blocked`). Used by the +// inbox read path to check whether a kicked recipient still belongs +// to the game's roster; ErrNotFound is surfaced when the user has +// never been a member. +// +// ListMembers returns every roster row matching scope, in stable +// order. Scope values are `active`, `active_and_removed`, and +// `all_members` (the spec calls these out by name). Used by the +// broadcast composition step in admin / owner sends. type MembershipLookup interface { GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (ActiveMembership, error) + GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (MemberSnapshot, error) + ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]MemberSnapshot, error) +} + +// Recipient scope values accepted by ListMembers and by the +// `recipients` request field on admin / owner broadcasts. +const ( + RecipientScopeActive = "active" + RecipientScopeActiveAndRemoved = "active_and_removed" + RecipientScopeAllMembers = "all_members" +) + +// MemberSnapshot is the slim view of a membership row that survives +// all three status values. RaceName is the immutable string captured +// at registration time; an empty value is legal for rare cases where +// the row was inserted without one. +type MemberSnapshot struct { + UserID uuid.UUID + GameID uuid.UUID + GameName string + UserName string + RaceName string + Status string } // NotificationPublisher is the outbound surface diplomail uses to diff --git a/backend/internal/diplomail/diplomail_e2e_test.go b/backend/internal/diplomail/diplomail_e2e_test.go index 34c9d89..be2c8cf 100644 --- a/backend/internal/diplomail/diplomail_e2e_test.go +++ b/backend/internal/diplomail/diplomail_e2e_test.go @@ -126,8 +126,11 @@ func (p *recordingPublisher) snapshot() []diplomail.DiplomailNotification { // staticMembershipLookup serves an in-memory fixture. The test seeds // memberships up-front and the lookup is keyed on (gameID, userID). +// Inactive rows (status != "active") are encoded by populating +// `inactive` instead of `rows`. type staticMembershipLookup struct { - rows map[[2]uuid.UUID]diplomail.ActiveMembership + rows map[[2]uuid.UUID]diplomail.ActiveMembership + inactive map[[2]uuid.UUID]diplomail.MemberSnapshot } func (l *staticMembershipLookup) GetActiveMembership(_ context.Context, gameID, userID uuid.UUID) (diplomail.ActiveMembership, error) { @@ -141,6 +144,58 @@ func (l *staticMembershipLookup) GetActiveMembership(_ context.Context, gameID, return row, nil } +func (l *staticMembershipLookup) GetMembershipAnyStatus(_ context.Context, gameID, userID uuid.UUID) (diplomail.MemberSnapshot, error) { + if l == nil { + return diplomail.MemberSnapshot{}, diplomail.ErrNotFound + } + if row, ok := l.rows[[2]uuid.UUID{gameID, userID}]; ok { + return diplomail.MemberSnapshot{ + UserID: row.UserID, + GameID: row.GameID, + GameName: row.GameName, + UserName: row.UserName, + RaceName: row.RaceName, + Status: "active", + }, nil + } + if row, ok := l.inactive[[2]uuid.UUID{gameID, userID}]; ok { + return row, nil + } + return diplomail.MemberSnapshot{}, diplomail.ErrNotFound +} + +func (l *staticMembershipLookup) ListMembers(_ context.Context, gameID uuid.UUID, scope string) ([]diplomail.MemberSnapshot, error) { + if l == nil { + return nil, nil + } + var out []diplomail.MemberSnapshot + for key, row := range l.rows { + if key[0] != gameID { + continue + } + out = append(out, diplomail.MemberSnapshot{ + UserID: row.UserID, + GameID: row.GameID, + GameName: row.GameName, + UserName: row.UserName, + RaceName: row.RaceName, + Status: "active", + }) + } + if scope == diplomail.RecipientScopeActiveAndRemoved || scope == diplomail.RecipientScopeAllMembers { + for key, row := range l.inactive { + if key[0] != gameID { + continue + } + if scope == diplomail.RecipientScopeActiveAndRemoved && row.Status != "removed" { + continue + } + out = append(out, row) + } + } + return out, nil +} + // seedAccount inserts a minimal accounts row so memberships and mail // recipient FKs are satisfiable. func seedAccount(t *testing.T, db *sql.DB, userID uuid.UUID) { @@ -355,6 +410,160 @@ func TestDiplomailRejectsNonActiveSender(t *testing.T) { } } +func TestDiplomailAdminBroadcast(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + owner := uuid.New() + alice := uuid.New() + bob := uuid.New() + kickedCharlie := uuid.New() + seedAccount(t, db, owner) + seedAccount(t, db, alice) + seedAccount(t, db, bob) + seedAccount(t, db, kickedCharlie) + seedGame(t, db, gameID, "Broadcast Test Game") + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, alice}: { + UserID: alice, GameID: gameID, GameName: "Broadcast Test Game", + UserName: "alice", RaceName: "AliceRace", + }, + {gameID, bob}: { + UserID: bob, GameID: gameID, GameName: "Broadcast Test Game", + UserName: "bob", RaceName: "BobRace", + }, + }, + inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{ + {gameID, kickedCharlie}: { + UserID: kickedCharlie, GameID: gameID, GameName: "Broadcast Test Game", + UserName: "charlie", RaceName: "CharlieRace", Status: "removed", + }, + }, + } + publisher := &recordingPublisher{} + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + ownerID := owner + msg, recipients, err := svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{ + GameID: gameID, + CallerKind: diplomail.CallerKindOwner, + CallerUserID: &ownerID, + CallerUsername: "owner", + RecipientScope: diplomail.RecipientScopeActive, + Subject: "All hands", + Body: "Welcome to round two.", + SenderIP: "203.0.113.7", + }) + if err != nil { + t.Fatalf("admin broadcast: %v", err) + } + if msg.Kind != diplomail.KindAdmin || msg.SenderKind != diplomail.SenderKindPlayer { + t.Fatalf("kind=%q sender_kind=%q, want admin/player", msg.Kind, msg.SenderKind) + } + if len(recipients) != 2 { + t.Fatalf("broadcast hit %d recipients, want 2 (alice+bob, kicked charlie excluded by active scope)", len(recipients)) + } + if got := publisher.snapshot(); len(got) != 2 { + t.Fatalf("publisher captured %d events, want 2", len(got)) + } + + // active_and_removed should include the kicked recipient too. + msg2, recipients2, err := svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{ + GameID: gameID, + CallerKind: diplomail.CallerKindAdmin, + CallerUsername: "site-admin", + RecipientScope: diplomail.RecipientScopeActiveAndRemoved, + Body: "Post-game retrospective.", + }) + if err != nil { + t.Fatalf("admin broadcast active_and_removed: %v", err) + } + if msg2.SenderKind != diplomail.SenderKindAdmin { + t.Fatalf("sender_kind=%q, want admin", msg2.SenderKind) + } + if len(recipients2) != 3 { + t.Fatalf("active_and_removed broadcast hit %d, want 3", len(recipients2)) + } + + // Kicked charlie sees the admin message but not the personal mail + // that alice might have sent before the kick (none here — the + // store path itself is exercised; the soft-access filter belongs + // to a separate test below). + charlieInbox, err := svc.ListInbox(ctx, gameID, kickedCharlie) + if err != nil { + t.Fatalf("kicked inbox: %v", err) + } + if len(charlieInbox) != 1 { + t.Fatalf("kicked inbox = %d entries, want 1 (only the active_and_removed broadcast)", len(charlieInbox)) + } +} + +func TestDiplomailLifecycleMembershipKick(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + kicked := uuid.New() + seedAccount(t, db, kicked) + seedGame(t, db, gameID, "Lifecycle Test Game") + + lookup := &staticMembershipLookup{ + inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{ + {gameID, kicked}: { + UserID: kicked, GameID: gameID, GameName: "Lifecycle Test Game", + UserName: "kicked", RaceName: "KickedRace", Status: "blocked", + }, + }, + } + publisher := &recordingPublisher{} + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + target := kicked + if err := svc.PublishLifecycle(ctx, diplomail.LifecycleEvent{ + GameID: gameID, + Kind: diplomail.LifecycleKindMembershipBlocked, + Actor: "an administrator", + Reason: "rule violation", + TargetUser: &target, + }); err != nil { + t.Fatalf("publish lifecycle: %v", err) + } + if got := publisher.snapshot(); len(got) != 1 || got[0].Recipient != kicked { + t.Fatalf("publisher captured %+v, want one event addressed to kicked", got) + } + inbox, err := svc.ListInbox(ctx, gameID, kicked) + if err != nil { + t.Fatalf("kicked inbox: %v", err) + } + if len(inbox) != 1 { + t.Fatalf("kicked inbox = %d, want 1 system message", len(inbox)) + } + if inbox[0].Kind != diplomail.KindAdmin || inbox[0].SenderKind != diplomail.SenderKindSystem { + t.Fatalf("kind=%q sender_kind=%q, want admin/system", inbox[0].Kind, inbox[0].SenderKind) + } +} + func TestDiplomailRejectsOverlongBody(t *testing.T) { db := startPostgres(t) ctx := context.Background() diff --git a/backend/internal/diplomail/service.go b/backend/internal/diplomail/service.go index fe0e24e..e0f18ef 100644 --- a/backend/internal/diplomail/service.go +++ b/backend/internal/diplomail/service.go @@ -100,20 +100,70 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa // 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 -// of the message is not leaked. +// of the message is not leaked. The same sentinel is returned when +// the caller is no longer an active member of the game and the +// message is personal-kind: post-kick visibility is restricted to +// admin/system mail (item 8 of the spec). func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID) (InboxEntry, error) { entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID) if err != nil { return InboxEntry{}, err } + allowed, err := s.allowedKinds(ctx, entry.GameID, userID) + if err != nil { + return InboxEntry{}, err + } + if !allowed[entry.Kind] { + return InboxEntry{}, ErrNotFound + } return entry, nil } // ListInbox returns every non-deleted message addressed to userID in // gameID, newest first. Read state is preserved per entry; the HTTP -// layer renders both the message and the recipient row. +// layer renders both the message and the recipient row. Personal +// messages are filtered out when the caller is no longer an active +// member of the game so a kicked player keeps read access to the +// admin/system explanation of the kick but not to historical +// player-to-player threads. func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]InboxEntry, error) { - return s.deps.Store.ListInbox(ctx, gameID, userID) + entries, err := s.deps.Store.ListInbox(ctx, gameID, userID) + if err != nil { + return nil, err + } + allowed, err := s.allowedKinds(ctx, gameID, userID) + if err != nil { + return nil, err + } + if allowed[KindPersonal] && allowed[KindAdmin] { + return entries, nil + } + out := make([]InboxEntry, 0, len(entries)) + for _, e := range entries { + if allowed[e.Kind] { + out = append(out, e) + } + } + return out, nil +} + +// allowedKinds resolves the set of message kinds the caller may read +// in gameID. An active member can read everything; a former member +// (status removed or blocked) can read admin-kind only. A user who +// has never been a member of the game but is still listed as a +// recipient (legacy / system message) is granted the same admin-only +// view. The function never returns an empty set: even non-members +// keep their read access to admin mail. +func (s *Service) allowedKinds(ctx context.Context, gameID, userID uuid.UUID) (map[string]bool, error) { + if s.deps.Memberships == nil { + return map[string]bool{KindPersonal: true, KindAdmin: true}, nil + } + if _, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, userID); err == nil { + return map[string]bool{KindPersonal: true, KindAdmin: true}, nil + } else if !errors.Is(err, ErrNotFound) { + return nil, err + } + return map[string]bool{KindAdmin: true}, nil } // ListSent returns personal messages authored by senderUserID in diff --git a/backend/internal/diplomail/types.go b/backend/internal/diplomail/types.go index 849f54b..cdaae36 100644 --- a/backend/internal/diplomail/types.go +++ b/backend/internal/diplomail/types.go @@ -70,6 +70,75 @@ type SendPersonalInput struct { SenderIP string } +// CallerKind enumerates the privileged sender roles for admin-kind +// messages. Owners (`CallerKindOwner`) are players who own a private +// game; admins (`CallerKindAdmin`) hit the dedicated admin route; +// `CallerKindSystem` is reserved for internal lifecycle hooks. +const ( + CallerKindOwner = "owner" + CallerKindAdmin = "admin" + CallerKindSystem = "system" +) + +// 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. +type SendAdminPersonalInput struct { + GameID uuid.UUID + CallerKind string + CallerUserID *uuid.UUID + CallerUsername string + RecipientUserID uuid.UUID + Subject string + Body string + SenderIP string +} + +// SendAdminBroadcastInput is the request payload for an owner / +// admin / system broadcasting an admin-kind message inside a single +// game. RecipientScope selects the address book; the sender's own +// recipient row is never created (a broadcast author does not get a +// copy of their own message). +type SendAdminBroadcastInput struct { + GameID uuid.UUID + CallerKind string + CallerUserID *uuid.UUID + CallerUsername string + RecipientScope string + Subject string + Body string + SenderIP string +} + +// LifecycleEventKind enumerates the producer-side intents the lobby +// emits when a game-state or membership-state transition lands. +const ( + LifecycleKindGamePaused = "game.paused" + LifecycleKindGameCancelled = "game.cancelled" + LifecycleKindMembershipRemoved = "membership.removed" + LifecycleKindMembershipBlocked = "membership.blocked" +) + +// LifecycleEvent is the payload lobby hands to PublishLifecycle when +// a transition needs to be reflected as durable system mail. The +// recipient set is derived by the service: +// +// - For game.* events the message fans out to every active member +// of the game except the actor (the actor sees the action in +// their own UI through other channels). +// - For membership.* events the message addresses exactly +// `TargetUser` (the kicked player), regardless of their current +// membership status — this is how a kicked player retains read +// access to the explanation of the kick. +type LifecycleEvent struct { + GameID uuid.UUID + Kind string + Actor string + Reason string + TargetUser *uuid.UUID +} + // UnreadCount carries a per-game unread-count row returned by // UnreadCountsForUser. The lobby badge UI consumes the slice plus the // derived total. diff --git a/backend/internal/lobby/deps.go b/backend/internal/lobby/deps.go index f7622c8..e1c4259 100644 --- a/backend/internal/lobby/deps.go +++ b/backend/internal/lobby/deps.go @@ -51,6 +51,37 @@ type NotificationPublisher interface { PublishLobbyEvent(ctx context.Context, intent LobbyNotification) error } +// DiplomailPublisher is the outbound surface the lobby uses to drop a +// durable system mail entry whenever a game-state or +// membership-state transition needs to land in the affected players' +// inboxes. The real implementation in `cmd/backend/main` adapts the +// `*diplomail.Service.PublishLifecycle` call; tests and partial +// wiring fall back to `NewNoopDiplomailPublisher`. +type DiplomailPublisher interface { + PublishLifecycle(ctx context.Context, event LifecycleEvent) error +} + +// LifecycleEvent is the open shape carried by a system-mail intent. +// `Kind` is one of the lobby-internal constants +// (`LifecycleKindGamePaused`, etc.). `TargetUser` is populated only +// for membership-scoped events; the publisher derives the game-scoped +// recipient set itself. +type LifecycleEvent struct { + GameID uuid.UUID + Kind string + Actor string + Reason string + TargetUser *uuid.UUID +} + +// Lifecycle-event kinds the lobby emits. +const ( + LifecycleKindGamePaused = "game.paused" + LifecycleKindGameCancelled = "game.cancelled" + LifecycleKindMembershipRemoved = "membership.removed" + LifecycleKindMembershipBlocked = "membership.blocked" +) + // LobbyNotification is the open shape carried by a notification intent. // The implementation emits a small set of `Kind` values matching the catalog in // `backend/README.md` §10. The `Payload` map is the kind-specific data @@ -123,3 +154,26 @@ func (p *noopNotificationPublisher) PublishLobbyEvent(_ context.Context, intent ) return nil } + +// NewNoopDiplomailPublisher returns a DiplomailPublisher that logs +// every call at debug level and returns nil. Used by tests and by +// the lobby Service factory when the Deps.Diplomail field is left +// nil. +func NewNoopDiplomailPublisher(logger *zap.Logger) DiplomailPublisher { + if logger == nil { + logger = zap.NewNop() + } + return &noopDiplomailPublisher{logger: logger.Named("lobby.diplomail.noop")} +} + +type noopDiplomailPublisher struct { + logger *zap.Logger +} + +func (p *noopDiplomailPublisher) PublishLifecycle(_ context.Context, event LifecycleEvent) error { + p.logger.Debug("noop diplomail lifecycle", + zap.String("kind", event.Kind), + zap.String("game_id", event.GameID.String()), + ) + return nil +} diff --git a/backend/internal/lobby/games.go b/backend/internal/lobby/games.go index 2ac5241..18e4862 100644 --- a/backend/internal/lobby/games.go +++ b/backend/internal/lobby/games.go @@ -10,6 +10,7 @@ import ( "galaxy/cronutil" "github.com/google/uuid" + "go.uber.org/zap" ) // CreateGameInput is the parameter struct for Service.CreateGame. @@ -441,9 +442,43 @@ func (s *Service) transition(ctx context.Context, callerUserID *uuid.UUID, calle return updated, fmt.Errorf("post-commit %s: %w", rule.Reason, err) } } + s.emitGameLifecycleMail(ctx, updated, callerIsAdmin, rule) return updated, nil } +// emitGameLifecycleMail asks the diplomail publisher to drop a +// system-mail entry whenever a state change is user-visible. Only +// the `paused` and `cancelled` transitions emit mail today (the spec +// names them explicitly); `running`/`finished`/etc. are signalled by +// other channels and do not need a durable inbox entry. +func (s *Service) emitGameLifecycleMail(ctx context.Context, game GameRecord, callerIsAdmin bool, rule transitionRule) { + var kind string + switch rule.To { + case GameStatusPaused: + kind = LifecycleKindGamePaused + case GameStatusCancelled: + kind = LifecycleKindGameCancelled + default: + return + } + actor := "the game owner" + if callerIsAdmin { + actor = "an administrator" + } + ev := LifecycleEvent{ + GameID: game.GameID, + Kind: kind, + Actor: actor, + Reason: rule.Reason, + } + if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil { + s.deps.Logger.Warn("publish lifecycle mail failed", + zap.String("game_id", game.GameID.String()), + zap.String("kind", kind), + zap.Error(err)) + } +} + // checkOwner enforces ownership semantics: // // - callerIsAdmin == true → always allowed (admin force-start, etc.). diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index 25a0783..a798f0a 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -124,6 +124,7 @@ type Deps struct { Cache *Cache Runtime RuntimeGateway Notification NotificationPublisher + Diplomail DiplomailPublisher Entitlement EntitlementProvider Policy *Policy Config config.LobbyConfig @@ -156,6 +157,9 @@ func NewService(deps Deps) (*Service, error) { if deps.Notification == nil { deps.Notification = NewNoopNotificationPublisher(deps.Logger) } + if deps.Diplomail == nil { + deps.Diplomail = NewNoopDiplomailPublisher(deps.Logger) + } if deps.Policy == nil { policy, err := NewPolicy() if err != nil { diff --git a/backend/internal/lobby/memberships.go b/backend/internal/lobby/memberships.go index b6dbc7f..d7f5aa3 100644 --- a/backend/internal/lobby/memberships.go +++ b/backend/internal/lobby/memberships.go @@ -76,6 +76,7 @@ func (s *Service) AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, zap.String("membership_id", updated.MembershipID.String()), zap.Error(pubErr)) } + s.emitMembershipLifecycleMail(ctx, updated, MembershipStatusBlocked, true, reason) _ = game return updated, nil } @@ -142,9 +143,44 @@ func (s *Service) changeMembershipStatus( zap.String("kind", notificationKind), zap.Error(pubErr)) } + s.emitMembershipLifecycleMail(ctx, updated, newStatus, callerIsAdmin, "") return updated, nil } +// emitMembershipLifecycleMail asks the diplomail publisher to drop a +// durable explanation into the kicked player's inbox. The mail +// survives the membership row going to `removed` / `blocked` so the +// player keeps read access to it (soft-access rule, item 8). +func (s *Service) emitMembershipLifecycleMail(ctx context.Context, membership Membership, newStatus string, callerIsAdmin bool, reason string) { + var kind string + switch newStatus { + case MembershipStatusRemoved: + kind = LifecycleKindMembershipRemoved + case MembershipStatusBlocked: + kind = LifecycleKindMembershipBlocked + default: + return + } + actor := "the game owner" + if callerIsAdmin { + actor = "an administrator" + } + target := membership.UserID + ev := LifecycleEvent{ + GameID: membership.GameID, + Kind: kind, + Actor: actor, + Reason: reason, + TargetUser: &target, + } + if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil { + s.deps.Logger.Warn("publish membership lifecycle mail failed", + zap.String("membership_id", membership.MembershipID.String()), + zap.String("kind", kind), + zap.Error(err)) + } +} + func (s *Service) canManageMembership(game GameRecord, membership Membership, callerUserID *uuid.UUID, allowSelf bool) bool { if game.Visibility == VisibilityPublic { // Public-game membership management is admin-only. diff --git a/backend/internal/postgres/migrations/00001_init.sql b/backend/internal/postgres/migrations/00001_init.sql index 7368e0f..8762397 100644 --- a/backend/internal/postgres/migrations/00001_init.sql +++ b/backend/internal/postgres/migrations/00001_init.sql @@ -700,7 +700,7 @@ CREATE TABLE diplomail_messages ( ), CONSTRAINT diplomail_messages_kind_sender_chk CHECK ( (kind = 'personal' AND sender_kind = 'player') OR - (kind = 'admin' AND sender_kind IN ('admin', 'system')) + (kind = 'admin' AND sender_kind IN ('player', 'admin', 'system')) ), CONSTRAINT diplomail_messages_broadcast_scope_chk CHECK (broadcast_scope IN ('single', 'game_broadcast', 'multi_game_broadcast')) diff --git a/backend/internal/server/contract_test.go b/backend/internal/server/contract_test.go index 3afe389..1fd9233 100644 --- a/backend/internal/server/contract_test.go +++ b/backend/internal/server/contract_test.go @@ -155,6 +155,18 @@ var requestBodyStubs = map[string]map[string]any{ "subject": "Contract test subject", "body": "Contract test body", }, + "userMailSendAdmin": { + "target": "user", + "recipient_user_id": pathParamStubs["user_id"], + "subject": "Contract test admin subject", + "body": "Contract test admin body", + }, + "adminDiplomailSend": { + "target": "user", + "recipient_user_id": pathParamStubs["user_id"], + "subject": "Contract test admin subject", + "body": "Contract test admin body", + }, } // TestOpenAPIContract is the top-level OpenAPI contract test. It diff --git a/backend/internal/server/handlers_admin_diplomail.go b/backend/internal/server/handlers_admin_diplomail.go new file mode 100644 index 0000000..7edc2a1 --- /dev/null +++ b/backend/internal/server/handlers_admin_diplomail.go @@ -0,0 +1,101 @@ +package server + +import ( + "net/http" + + "galaxy/backend/internal/diplomail" + "galaxy/backend/internal/server/clientip" + "galaxy/backend/internal/server/handlers" + "galaxy/backend/internal/server/httperr" + "galaxy/backend/internal/server/middleware/basicauth" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// AdminDiplomailHandlers groups the diplomatic-mail handlers exposed +// under `/api/v1/admin/games/{game_id}/mail` (per-game admin send / +// broadcast). The handler is intentionally separate from +// `AdminMailHandlers`, which owns the unrelated email outbox surface +// under `/api/v1/admin/mail/*`. +type AdminDiplomailHandlers struct { + svc *diplomail.Service + logger *zap.Logger +} + +// NewAdminDiplomailHandlers constructs the handler set. svc may be +// nil — in that case every handler returns 501 not_implemented. +func NewAdminDiplomailHandlers(svc *diplomail.Service, logger *zap.Logger) *AdminDiplomailHandlers { + if logger == nil { + logger = zap.NewNop() + } + return &AdminDiplomailHandlers{svc: svc, logger: logger.Named("http.admin.diplomail")} +} + +// Send handles POST /api/v1/admin/games/{game_id}/mail. The body +// shape mirrors the owner route: `target="user"` requires +// `recipient_user_id`; `target="all"` accepts an optional +// `recipients` scope. The authenticated admin username is captured +// from the basicauth context and persisted as `sender_username`. +func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("adminDiplomailSend") + } + return func(c *gin.Context) { + username, ok := basicauth.UsernameFromContext(c.Request.Context()) + if !ok || username == "" { + httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + var req userMailSendAdminRequestWire + if err := c.ShouldBindJSON(&req); err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") + return + } + 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 + } + 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), + }) + if sendErr != nil { + respondDiplomailError(c, h.logger, "admin mail send personal", ctx, sendErr) + return + } + c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true)) + case "all": + msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{ + GameID: gameID, + CallerKind: diplomail.CallerKindAdmin, + CallerUsername: username, + RecipientScope: req.Recipients, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), + }) + if sendErr != nil { + respondDiplomailError(c, h.logger, "admin mail send broadcast", ctx, sendErr) + return + } + c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients)) + default: + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'") + } + } +} diff --git a/backend/internal/server/handlers_user_mail.go b/backend/internal/server/handlers_user_mail.go index 53ad97c..206139d 100644 --- a/backend/internal/server/handlers_user_mail.go +++ b/backend/internal/server/handlers_user_mail.go @@ -6,11 +6,13 @@ import ( "net/http" "galaxy/backend/internal/diplomail" + "galaxy/backend/internal/lobby" "galaxy/backend/internal/server/clientip" "galaxy/backend/internal/server/handlers" "galaxy/backend/internal/server/httperr" "galaxy/backend/internal/server/middleware/userid" "galaxy/backend/internal/telemetry" + "galaxy/backend/internal/user" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -19,20 +21,31 @@ import ( // UserMailHandlers groups the diplomatic-mail handlers under // `/api/v1/user/games/{game_id}/mail/*` and the lobby-side -// `/api/v1/user/lobby/mail/unread-counts`. Stage A wires only the -// personal-message subset. +// `/api/v1/user/lobby/mail/unread-counts`. Stage A wires the +// personal subset; Stage B adds the owner-only admin send path, +// which needs `*lobby.Service` to confirm ownership and `*user.Service` +// to resolve the owner's `user_name` for the `sender_username` column. type UserMailHandlers struct { - svc *diplomail.Service + svc *diplomail.Service + lobby *lobby.Service + users *user.Service logger *zap.Logger } // NewUserMailHandlers constructs the handler set. svc may be nil — in -// that case every handler returns 501 not_implemented. -func NewUserMailHandlers(svc *diplomail.Service, logger *zap.Logger) *UserMailHandlers { +// that case every handler returns 501 not_implemented. lobby and +// users are optional: when either is nil the admin-send handler +// degrades to 501 (the personal-send and read paths stay functional). +func NewUserMailHandlers(svc *diplomail.Service, lobbySvc *lobby.Service, users *user.Service, logger *zap.Logger) *UserMailHandlers { if logger == nil { logger = zap.NewNop() } - return &UserMailHandlers{svc: svc, logger: logger.Named("http.user.mail")} + return &UserMailHandlers{ + svc: svc, + lobby: lobbySvc, + users: users, + logger: logger.Named("http.user.mail"), + } } // SendPersonal handles POST /api/v1/user/games/{game_id}/mail/messages. @@ -219,6 +232,96 @@ func (h *UserMailHandlers) Delete() gin.HandlerFunc { } } +// SendAdmin handles POST /api/v1/user/games/{game_id}/mail/admin. +// +// Owner-only: the caller must be the owner of the private game. The +// handler resolves the owner's `user_name` so the +// `sender_username` column carries a useful identity, then routes to +// SendAdminPersonal (for `target="user"`) or SendAdminBroadcast (for +// `target="all"`). Site administrators use the separate admin route +// in `handlers_admin_mail_send.go`. +func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc { + if h.svc == nil || h.lobby == nil || h.users == nil { + return handlers.NotImplemented("userMailSendAdmin") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + var req userMailSendAdminRequestWire + if err := c.ShouldBindJSON(&req); err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") + return + } + ctx := c.Request.Context() + + game, err := h.lobby.GetGame(ctx, gameID) + if err != nil { + respondLobbyError(c, h.logger, "user mail send admin: load game", ctx, err) + return + } + if game.OwnerUserID == nil || *game.OwnerUserID != userID { + httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "caller is not the owner of this game") + return + } + account, err := h.users.GetAccount(ctx, userID) + if err != nil { + respondAccountError(c, h.logger, "user mail send admin: resolve user_name", ctx, err) + return + } + + 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 + } + 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), + }) + if sendErr != nil { + respondDiplomailError(c, h.logger, "user mail send admin personal", ctx, sendErr) + return + } + c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true)) + case "all": + callerUserID := userID + msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{ + GameID: gameID, + CallerKind: diplomail.CallerKindOwner, + CallerUserID: &callerUserID, + CallerUsername: account.UserName, + RecipientScope: req.Recipients, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), + }) + if sendErr != nil { + respondDiplomailError(c, h.logger, "user mail send admin broadcast", ctx, sendErr) + return + } + c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients)) + default: + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'") + } + } +} + // UnreadCounts handles GET /api/v1/user/lobby/mail/unread-counts. func (h *UserMailHandlers) UnreadCounts() gin.HandlerFunc { if h.svc == nil { @@ -289,6 +392,52 @@ type userMailSendRequestWire struct { Body string `json:"body"` } +// 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`). +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"` +} + +// userMailBroadcastReceiptWire is the response shape returned after a +// successful broadcast. It carries the canonical message metadata +// together with the count of materialised recipient rows so the +// caller (UI, admin tool) can confirm the fan-out happened. +type userMailBroadcastReceiptWire struct { + MessageID string `json:"message_id"` + GameID string `json:"game_id"` + GameName string `json:"game_name,omitempty"` + Kind string `json:"kind"` + SenderKind string `json:"sender_kind"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + BodyLang string `json:"body_lang"` + BroadcastScope string `json:"broadcast_scope"` + CreatedAt string `json:"created_at"` + RecipientCount int `json:"recipient_count"` +} + +func mailBroadcastReceiptToWire(m diplomail.Message, recipients []diplomail.Recipient) userMailBroadcastReceiptWire { + return userMailBroadcastReceiptWire{ + MessageID: m.MessageID.String(), + GameID: m.GameID.String(), + GameName: m.GameName, + Kind: m.Kind, + SenderKind: m.SenderKind, + Subject: m.Subject, + Body: m.Body, + BodyLang: m.BodyLang, + BroadcastScope: m.BroadcastScope, + CreatedAt: m.CreatedAt.UTC().Format(timestampLayout), + RecipientCount: len(recipients), + } +} + // userMailMessageDetailWire mirrors the unified response shape for // inbox listings and per-message reads. Sender identifiers are // optional: system messages carry neither user id nor username. diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 36ccc7e..c93f8dc 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -76,6 +76,7 @@ type RouterDependencies struct { AdminRuntimes *AdminRuntimesHandlers AdminEngineVersions *AdminEngineVersionsHandlers AdminMail *AdminMailHandlers + AdminDiplomail *AdminDiplomailHandlers AdminNotifications *AdminNotificationsHandlers AdminGeo *AdminGeoHandlers InternalSessions *InternalSessionsHandlers @@ -165,7 +166,7 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies { deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger) } if deps.UserMail == nil { - deps.UserMail = NewUserMailHandlers(nil, deps.Logger) + deps.UserMail = NewUserMailHandlers(nil, nil, nil, deps.Logger) } if deps.UserSessions == nil { deps.UserSessions = NewUserSessionsHandlers(nil, deps.Logger) @@ -188,6 +189,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies { if deps.AdminMail == nil { deps.AdminMail = NewAdminMailHandlers(nil, deps.Logger) } + if deps.AdminDiplomail == nil { + deps.AdminDiplomail = NewAdminDiplomailHandlers(nil, deps.Logger) + } if deps.AdminNotifications == nil { deps.AdminNotifications = NewAdminNotificationsHandlers(nil, deps.Logger) } @@ -274,6 +278,7 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de userMail := userGames.Group("/:game_id/mail") userMail.POST("/messages", deps.UserMail.SendPersonal()) + userMail.POST("/admin", deps.UserMail.SendAdmin()) userMail.GET("/messages/:message_id", deps.UserMail.Get()) userMail.POST("/messages/:message_id/read", deps.UserMail.MarkRead()) userMail.DELETE("/messages/:message_id", deps.UserMail.Delete()) @@ -314,6 +319,7 @@ func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, d games.POST("/:game_id/force-start", deps.AdminGames.ForceStart()) games.POST("/:game_id/force-stop", deps.AdminGames.ForceStop()) games.POST("/:game_id/ban-member", deps.AdminGames.BanMember()) + games.POST("/:game_id/mail", deps.AdminDiplomail.Send()) runtimes := group.Group("/runtimes") runtimes.GET("/:game_id", deps.AdminRuntimes.Get()) diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 2fc7d0e..b2f640e 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -1183,6 +1183,47 @@ paths: $ref: "#/components/responses/NotImplementedError" "500": $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/admin: + post: + tags: [User] + operationId: userMailSendAdmin + summary: Send a non-replyable admin notification (owner only) + description: | + Owner-only: the caller must be the owner of the private game. + `target="user"` requires `recipient_user_id`; `target="all"` + accepts an optional `recipients` scope (`active` by default, + plus `active_and_removed` and `all_members`). The message + carries `kind="admin"` and is therefore non-replyable. + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailSendAdminRequest" + responses: + "201": + description: Admin message persisted; broadcasts return a fan-out receipt. + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/UserMailMessageDetail" + - $ref: "#/components/schemas/UserMailBroadcastReceipt" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" /api/v1/user/games/{game_id}/mail/messages/{message_id}: get: tags: [User] @@ -1913,6 +1954,49 @@ paths: $ref: "#/components/responses/NotImplementedError" "500": $ref: "#/components/responses/InternalError" + /api/v1/admin/games/{game_id}/mail: + post: + tags: [Admin] + operationId: adminDiplomailSend + summary: Send a diplomatic-mail admin notification to one game + description: | + Site-admin send for the diplomatic-mail subsystem. Body shape + mirrors the owner-only `POST /api/v1/user/games/{game_id}/mail/admin` + endpoint. `target="user"` requires `recipient_user_id`; + `target="all"` accepts an optional `recipients` scope + (`active` / `active_and_removed` / `all_members`). The + authenticated admin username is persisted as `sender_username`. + security: + - AdminBasicAuth: [] + parameters: + - $ref: "#/components/parameters/GameID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailSendAdminRequest" + responses: + "201": + description: Admin message persisted; broadcasts return a fan-out receipt. + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/UserMailMessageDetail" + - $ref: "#/components/schemas/UserMailBroadcastReceipt" + "400": + $ref: "#/components/responses/InvalidRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" /api/v1/admin/runtimes/{game_id}: get: tags: [Admin] @@ -3831,6 +3915,74 @@ components: body: type: string description: Plain UTF-8 body. HTML is not parsed on the server. + UserMailSendAdminRequest: + type: object + additionalProperties: false + required: [target, body] + properties: + target: + type: string + enum: [user, all] + recipient_user_id: + 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). + recipients: + type: string + enum: [active, active_and_removed, all_members] + description: | + Optional scope when `target="all"`. Defaults to `active`. + subject: + type: string + body: + type: string + UserMailBroadcastReceipt: + type: object + additionalProperties: false + required: + - message_id + - game_id + - kind + - sender_kind + - body + - body_lang + - broadcast_scope + - created_at + - recipient_count + properties: + message_id: + type: string + format: uuid + game_id: + type: string + format: uuid + game_name: + type: string + kind: + type: string + enum: [personal, admin] + sender_kind: + type: string + enum: [player, admin, system] + subject: + type: string + body: + type: string + body_lang: + type: string + broadcast_scope: + type: string + enum: [single, game_broadcast, multi_game_broadcast] + created_at: + type: string + format: date-time + recipient_count: + type: integer + minimum: 0 UserMailMessageDetail: type: object additionalProperties: false