From 362f92e520e98ee8933163999f7bde8926f8558f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 15 May 2026 19:02:46 +0200 Subject: [PATCH] diplomail (Stage C): paid-tier broadcast + multi-game + cleanup Closes out the producer-side of the diplomail surface. Paid-tier players can fan out one personal message to the rest of the active roster (gated on entitlement_snapshots.is_paid). Site admins gain a multi-game broadcast (POST /admin/mail/broadcast with `selected` / `all_running` scopes) and the bulk-purge endpoint that wipes diplomail rows tied to games finished more than N years ago. An admin listing (GET /admin/mail/messages) rounds out the observability surface. EntitlementReader and GameLookup are new narrow deps wired from `*user.Service` and `*lobby.Service` in cmd/backend/main; the lobby service grows a one-off `ListFinishedGamesBefore` helper for the cleanup path (the cache evicts terminal-state games so the cache walk is not enough). Stage D will swap LangUndetermined for an actual body-language detector and add the translation cache. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/cmd/backend/main.go | 95 ++++++ backend/internal/diplomail/README.md | 9 +- backend/internal/diplomail/admin_send.go | 219 +++++++++++++ backend/internal/diplomail/deps.go | 47 ++- .../internal/diplomail/diplomail_e2e_test.go | 243 ++++++++++++++ backend/internal/diplomail/store.go | 93 ++++++ backend/internal/diplomail/types.go | 72 +++++ backend/internal/lobby/cache.go | 18 ++ backend/internal/lobby/games.go | 35 ++ backend/internal/server/contract_test.go | 12 + .../server/handlers_admin_diplomail.go | 225 +++++++++++++ backend/internal/server/handlers_user_mail.go | 50 +++ backend/internal/server/router.go | 4 + backend/openapi.yaml | 305 ++++++++++++++++++ 14 files changed, 1423 insertions(+), 4 deletions(-) diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index afa827a..bc22532 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -12,6 +12,7 @@ import ( "os" "os/signal" "syscall" + "time" // time/tzdata embeds the IANA timezone database so time.LoadLocation // works in container images without /usr/share/zoneinfo (distroless @@ -310,6 +311,8 @@ func run(ctx context.Context) (err error) { Store: diplomailStore, Memberships: &diplomailMembershipAdapter{lobby: lobbySvc, users: userSvc}, Notification: &diplomailNotificationPublisherAdapter{svc: notifSvc}, + Entitlements: &diplomailEntitlementAdapter{users: userSvc}, + Games: &diplomailGameAdapter{lobby: lobbySvc}, Config: cfg.Diplomail, Logger: logger, }) @@ -747,6 +750,98 @@ func (a *lobbyDiplomailPublisherAdapter) PublishLifecycle(ctx context.Context, e }) } +// diplomailEntitlementAdapter implements +// `diplomail.EntitlementReader` by reading the user-service +// entitlement snapshot. The IsPaid flag mirrors the per-tier policy +// defined in `internal/user`, so updates to the tier set (monthly, +// yearly, permanent, …) flow through without changes here. +type diplomailEntitlementAdapter struct { + users *user.Service +} + +func (a *diplomailEntitlementAdapter) IsPaidTier(ctx context.Context, userID uuid.UUID) (bool, error) { + if a == nil || a.users == nil { + return false, nil + } + snap, err := a.users.GetEntitlementSnapshot(ctx, userID) + if err != nil { + return false, err + } + return snap.IsPaid, nil +} + +// diplomailGameAdapter implements `diplomail.GameLookup`. The +// running-games and finished-games queries walk the lobby cache so +// the admin multi-game broadcast and bulk-purge endpoints do not +// fan out a per-game DB query each time. GetGame falls back to the +// cache; an unknown id is surfaced as ErrNotFound (the diplomail +// sentinel). +type diplomailGameAdapter struct { + lobby *lobby.Service +} + +func (a *diplomailGameAdapter) ListRunningGames(_ context.Context) ([]diplomail.GameSnapshot, error) { + if a == nil || a.lobby == nil || a.lobby.Cache() == nil { + return nil, nil + } + var out []diplomail.GameSnapshot + for _, game := range a.lobby.Cache().ListGames() { + if !isRunningStatus(game.Status) { + continue + } + out = append(out, gameSnapshot(game)) + } + return out, nil +} + +func (a *diplomailGameAdapter) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]diplomail.GameSnapshot, error) { + if a == nil || a.lobby == nil { + return nil, nil + } + games, err := a.lobby.ListFinishedGamesBefore(ctx, cutoff) + if err != nil { + return nil, err + } + out := make([]diplomail.GameSnapshot, 0, len(games)) + for _, g := range games { + out = append(out, gameSnapshot(g)) + } + return out, nil +} + +func (a *diplomailGameAdapter) GetGame(_ context.Context, gameID uuid.UUID) (diplomail.GameSnapshot, error) { + if a == nil || a.lobby == nil || a.lobby.Cache() == nil { + return diplomail.GameSnapshot{}, diplomail.ErrNotFound + } + game, ok := a.lobby.Cache().GetGame(gameID) + if !ok { + return diplomail.GameSnapshot{}, diplomail.ErrNotFound + } + return gameSnapshot(game), nil +} + +func gameSnapshot(g lobby.GameRecord) diplomail.GameSnapshot { + out := diplomail.GameSnapshot{ + GameID: g.GameID, + GameName: g.GameName, + Status: g.Status, + } + if g.FinishedAt != nil { + f := *g.FinishedAt + out.FinishedAt = &f + } + return out +} + +func isRunningStatus(status string) bool { + switch status { + case lobby.GameStatusReadyToStart, lobby.GameStatusStarting, lobby.GameStatusRunning, lobby.GameStatusPaused: + return true + default: + return false + } +} + // 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 024320e..7ea3e8c 100644 --- a/backend/internal/diplomail/README.md +++ b/backend/internal/diplomail/README.md @@ -16,7 +16,7 @@ purge, and the language-detection / translation cache. |-------|-------|--------| | 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); strict soft-access for kicked players | shipped | -| C | Paid-tier personal broadcast + admin multi-game broadcast + bulk purge | planned | +| C | Paid-tier personal broadcast + admin multi-game broadcast + bulk purge + admin observability | shipped | | D | Body-language detection (whatlanggo) + translation cache + async worker | planned | ## Tables @@ -40,14 +40,17 @@ Three Postgres tables in the `backend` schema: | Action | Caller | Pre-conditions | |--------|--------|----------------| | Send personal | user | active membership in game; recipient is active member | +| Paid-tier broadcast | paid-tier user | active membership; recipients = every other active member | | 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 | +| Multi-game admin broadcast | site admin | scope `selected` (with `game_ids`) or `all_running` | +| Bulk purge | site admin | `older_than_years >= 1`; targets games with terminal status finished more than N years ago | | 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 C will add the paid-tier player broadcast and the bulk-purge -admin endpoint. +Stage D will add body-language detection (whatlanggo) and the +translation cache + async worker. System mail is produced internally by lobby lifecycle hooks: `Service.transition()` emits `game.paused` / `game.cancelled` system diff --git a/backend/internal/diplomail/admin_send.go b/backend/internal/diplomail/admin_send.go index 3530cf9..cb8793f 100644 --- a/backend/internal/diplomail/admin_send.go +++ b/backend/internal/diplomail/admin_send.go @@ -103,6 +103,225 @@ func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastI return msg, recipients, nil } +// SendPlayerBroadcast persists a paid-tier player broadcast and +// fans out the push event to every other active member of the game. +// The send is `kind="personal"`, `sender_kind="player"`, +// `broadcast_scope="game_broadcast"` — recipients reply to it as if +// it were a single-recipient personal send, and the reply targets +// only the broadcaster. The caller's entitlement tier is checked +// against `EntitlementReader`; free-tier callers are rejected with +// ErrForbidden. +func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcastInput) (Message, []Recipient, error) { + subject, body, err := s.prepareContent(in.Subject, in.Body) + if err != nil { + return Message{}, nil, err + } + if s.deps.Entitlements == nil { + return Message{}, nil, fmt.Errorf("%w: entitlement reader is not wired", ErrForbidden) + } + paid, err := s.deps.Entitlements.IsPaidTier(ctx, in.SenderUserID) + if err != nil { + return Message{}, nil, fmt.Errorf("diplomail: entitlement lookup: %w", err) + } + if !paid { + return Message{}, nil, fmt.Errorf("%w: in-game broadcast requires a paid tier", ErrForbidden) + } + + sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Message{}, nil, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden) + } + return Message{}, nil, fmt.Errorf("diplomail: load sender membership: %w", err) + } + + members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, RecipientScopeActive) + if err != nil { + return Message{}, nil, fmt.Errorf("diplomail: list active members: %w", err) + } + callerID := in.SenderUserID + members = filterOutCaller(members, &callerID) + if len(members) == 0 { + return Message{}, nil, fmt.Errorf("%w: no other active members in this game", ErrInvalidInput) + } + + username := sender.UserName + msgInsert := MessageInsert{ + MessageID: uuid.New(), + GameID: in.GameID, + GameName: sender.GameName, + Kind: KindPersonal, + SenderKind: SenderKindPlayer, + SenderUserID: &callerID, + SenderUsername: &username, + SenderIP: in.SenderIP, + Subject: subject, + Body: body, + BodyLang: LangUndetermined, + BroadcastScope: BroadcastScopeGameBroadcast, + } + 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 player broadcast: %w", err) + } + for _, r := range recipients { + s.publishMessageReceived(ctx, msg, r) + } + return msg, recipients, nil +} + +// SendAdminMultiGameBroadcast emits one admin-kind message per game +// resolved from the input scope and fans out the push events. A +// recipient who plays in multiple addressed games receives one +// independently-deletable inbox entry per game; this avoids cross- +// game leakage of admin context and keeps the per-game unread badge +// honest. +func (s *Service) SendAdminMultiGameBroadcast(ctx context.Context, in SendMultiGameBroadcastInput) ([]Message, int, error) { + subject, body, err := s.prepareContent(in.Subject, in.Body) + if err != nil { + return nil, 0, err + } + if err := validateCaller(CallerKindAdmin, nil, in.CallerUsername); err != nil { + return nil, 0, err + } + scope, err := normaliseScope(in.RecipientScope) + if err != nil { + return nil, 0, err + } + if s.deps.Games == nil { + return nil, 0, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput) + } + games, err := s.resolveMultiGameTargets(ctx, in) + if err != nil { + return nil, 0, err + } + if len(games) == 0 { + return nil, 0, fmt.Errorf("%w: no games match the broadcast scope", ErrInvalidInput) + } + + totalRecipients := 0 + out := make([]Message, 0, len(games)) + for _, game := range games { + members, err := s.deps.Memberships.ListMembers(ctx, game.GameID, scope) + if err != nil { + return nil, 0, fmt.Errorf("diplomail: list members for %s: %w", game.GameID, err) + } + if len(members) == 0 { + s.deps.Logger.Debug("multi-game broadcast skips empty game", + zap.String("game_id", game.GameID.String()), + zap.String("scope", scope)) + continue + } + msgInsert, err := s.buildAdminMessageInsert(CallerKindAdmin, nil, in.CallerUsername, + game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast) + if err != nil { + return nil, 0, 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 nil, 0, fmt.Errorf("diplomail: insert multi-game broadcast for %s: %w", game.GameID, err) + } + for _, r := range recipients { + s.publishMessageReceived(ctx, msg, r) + } + out = append(out, msg) + totalRecipients += len(recipients) + } + return out, totalRecipients, nil +} + +func (s *Service) resolveMultiGameTargets(ctx context.Context, in SendMultiGameBroadcastInput) ([]GameSnapshot, error) { + switch in.Scope { + case MultiGameScopeAllRunning: + games, err := s.deps.Games.ListRunningGames(ctx) + if err != nil { + return nil, fmt.Errorf("diplomail: list running games: %w", err) + } + return games, nil + case MultiGameScopeSelected, "": + if len(in.GameIDs) == 0 { + return nil, fmt.Errorf("%w: selected scope requires game_ids", ErrInvalidInput) + } + out := make([]GameSnapshot, 0, len(in.GameIDs)) + for _, id := range in.GameIDs { + game, err := s.deps.Games.GetGame(ctx, id) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("%w: game %s not found", ErrInvalidInput, id) + } + return nil, fmt.Errorf("diplomail: load game %s: %w", id, err) + } + out = append(out, game) + } + return out, nil + default: + return nil, fmt.Errorf("%w: unknown multi-game scope %q", ErrInvalidInput, in.Scope) + } +} + +// BulkCleanup deletes every diplomail_messages row tied to games that +// finished more than `OlderThanYears` years ago. Returns the affected +// game ids and the count of removed messages. The minimum allowed +// value is 1 year — finer-grained pruning would risk wiping live +// arbitration evidence. +func (s *Service) BulkCleanup(ctx context.Context, in BulkCleanupInput) (CleanupResult, error) { + if in.OlderThanYears < 1 { + return CleanupResult{}, fmt.Errorf("%w: older_than_years must be >= 1", ErrInvalidInput) + } + if s.deps.Games == nil { + return CleanupResult{}, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput) + } + cutoff := s.nowUTC().AddDate(-in.OlderThanYears, 0, 0) + games, err := s.deps.Games.ListFinishedGamesBefore(ctx, cutoff) + if err != nil { + return CleanupResult{}, fmt.Errorf("diplomail: list finished games: %w", err) + } + if len(games) == 0 { + return CleanupResult{}, nil + } + gameIDs := make([]uuid.UUID, 0, len(games)) + for _, g := range games { + gameIDs = append(gameIDs, g.GameID) + } + deleted, err := s.deps.Store.DeleteMessagesForGames(ctx, gameIDs) + if err != nil { + return CleanupResult{}, fmt.Errorf("diplomail: bulk delete: %w", err) + } + return CleanupResult{GameIDs: gameIDs, MessagesDeleted: deleted}, nil +} + +// ListMessagesForAdmin returns a paginated, optionally-filtered view +// of every persisted message. Used by the admin observability +// endpoint to inspect what has been sent and trace abuse reports. +func (s *Service) ListMessagesForAdmin(ctx context.Context, filter AdminMessageListing) (AdminMessagePage, error) { + rows, total, err := s.deps.Store.ListMessagesForAdmin(ctx, filter) + if err != nil { + return AdminMessagePage{}, err + } + page := filter.Page + if page < 1 { + page = 1 + } + pageSize := filter.PageSize + if pageSize < 1 { + pageSize = 50 + } + return AdminMessagePage{ + Items: rows, + Total: total, + Page: page, + PageSize: pageSize, + }, 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`, diff --git a/backend/internal/diplomail/deps.go b/backend/internal/diplomail/deps.go index a0afa75..66c0a29 100644 --- a/backend/internal/diplomail/deps.go +++ b/backend/internal/diplomail/deps.go @@ -15,16 +15,61 @@ import ( // Store and Memberships are required. Logger and Now default to // zap.NewNop / time.Now when nil. Notification falls back to a no-op // publisher so unit tests can construct a Service with only the -// required collaborators populated. +// required collaborators populated. Entitlements and Games are +// optional — they are used by Stage C surfaces (paid-tier player +// broadcast, multi-game admin broadcast, bulk cleanup). Wiring may +// pass nil for tests that do not exercise those paths. type Deps struct { Store *Store Memberships MembershipLookup Notification NotificationPublisher + Entitlements EntitlementReader + Games GameLookup Config config.DiplomailConfig Logger *zap.Logger Now func() time.Time } +// EntitlementReader is the read-only surface diplomail uses to gate +// the paid-tier player broadcast. The canonical implementation in +// `cmd/backend/main` reads +// `*user.Service.GetEntitlementSnapshot(userID).IsPaid`. +type EntitlementReader interface { + IsPaidTier(ctx context.Context, userID uuid.UUID) (bool, error) +} + +// GameLookup exposes the slim view of `games` the multi-game admin +// broadcast and bulk-cleanup paths consume. The canonical +// implementation walks the lobby cache plus an explicit store call +// for finished-game pruning. +type GameLookup interface { + // ListRunningGames returns every game whose `status` is one of + // the still-active values (running, paused, starting, …). The + // admin `all_running` broadcast scope iterates over the result. + ListRunningGames(ctx context.Context) ([]GameSnapshot, error) + + // ListFinishedGamesBefore returns every game whose `finished_at` + // is older than `cutoff`. The bulk-purge admin endpoint reads + // this to compose the cascade-delete IN list. + ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameSnapshot, error) + + // GetGame returns one game snapshot identified by id, or + // ErrNotFound. Used by the multi-game broadcast to verify the + // caller-supplied id list before enqueuing fan-out work. + GetGame(ctx context.Context, gameID uuid.UUID) (GameSnapshot, error) +} + +// GameSnapshot is the trim view of `games` consumed by the multi-game +// admin broadcast and the cleanup paths. The struct intentionally +// avoids the full `lobby.GameRecord` so the diplomail package stays +// decoupled from the lobby domain. +type GameSnapshot struct { + GameID uuid.UUID + GameName string + Status string + FinishedAt *time.Time +} + // ActiveMembership is the slim view of a single (user, game) roster // row the diplomail package needs at send time: it confirms the // participant is active in the game and captures the snapshot fields diff --git a/backend/internal/diplomail/diplomail_e2e_test.go b/backend/internal/diplomail/diplomail_e2e_test.go index be2c8cf..250b71d 100644 --- a/backend/internal/diplomail/diplomail_e2e_test.go +++ b/backend/internal/diplomail/diplomail_e2e_test.go @@ -510,6 +510,249 @@ func TestDiplomailAdminBroadcast(t *testing.T) { } } +// staticEntitlement satisfies diplomail.EntitlementReader by reading +// a fixed map keyed on user_id. +type staticEntitlement struct { + paid map[uuid.UUID]bool +} + +func (s *staticEntitlement) IsPaidTier(_ context.Context, userID uuid.UUID) (bool, error) { + if s == nil { + return false, nil + } + return s.paid[userID], nil +} + +// staticGameLookup satisfies diplomail.GameLookup by walking a fixed +// list of GameSnapshot fixtures. Tests prepend rows via the New +// helper. +type staticGameLookup struct { + games map[uuid.UUID]diplomail.GameSnapshot +} + +func (l *staticGameLookup) ListRunningGames(_ context.Context) ([]diplomail.GameSnapshot, error) { + if l == nil { + return nil, nil + } + out := make([]diplomail.GameSnapshot, 0, len(l.games)) + for _, g := range l.games { + switch g.Status { + case "running", "paused", "ready_to_start", "starting": + out = append(out, g) + } + } + return out, nil +} + +func (l *staticGameLookup) ListFinishedGamesBefore(_ context.Context, cutoff time.Time) ([]diplomail.GameSnapshot, error) { + if l == nil { + return nil, nil + } + out := make([]diplomail.GameSnapshot, 0, len(l.games)) + for _, g := range l.games { + if g.Status != "finished" && g.Status != "cancelled" { + continue + } + if g.FinishedAt == nil || !g.FinishedAt.Before(cutoff) { + continue + } + out = append(out, g) + } + return out, nil +} + +func (l *staticGameLookup) GetGame(_ context.Context, gameID uuid.UUID) (diplomail.GameSnapshot, error) { + if l == nil { + return diplomail.GameSnapshot{}, diplomail.ErrNotFound + } + g, ok := l.games[gameID] + if !ok { + return diplomail.GameSnapshot{}, diplomail.ErrNotFound + } + return g, nil +} + +func TestDiplomailPaidTierBroadcast(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + paidPlayer := uuid.New() + freePlayer := uuid.New() + alice := uuid.New() + bob := uuid.New() + seedAccount(t, db, paidPlayer) + seedAccount(t, db, freePlayer) + seedAccount(t, db, alice) + seedAccount(t, db, bob) + seedGame(t, db, gameID, "Paid Broadcast Game") + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, paidPlayer}: { + UserID: paidPlayer, GameID: gameID, GameName: "Paid Broadcast Game", + UserName: "paid", RaceName: "PaidRace", + }, + {gameID, freePlayer}: { + UserID: freePlayer, GameID: gameID, GameName: "Paid Broadcast Game", + UserName: "free", RaceName: "FreeRace", + }, + {gameID, alice}: { + UserID: alice, GameID: gameID, GameName: "Paid Broadcast Game", + UserName: "alice", RaceName: "AliceRace", + }, + {gameID, bob}: { + UserID: bob, GameID: gameID, GameName: "Paid Broadcast Game", + UserName: "bob", RaceName: "BobRace", + }, + }, + } + publisher := &recordingPublisher{} + entitlements := &staticEntitlement{paid: map[uuid.UUID]bool{paidPlayer: true}} + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Entitlements: entitlements, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + // Paid sender: broadcast succeeds. + msg, recipients, err := svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{ + GameID: gameID, + SenderUserID: paidPlayer, + Subject: "Alliance", + Body: "Let us form a coalition.", + }) + if err != nil { + t.Fatalf("paid broadcast: %v", err) + } + if msg.Kind != diplomail.KindPersonal || msg.BroadcastScope != diplomail.BroadcastScopeGameBroadcast { + t.Fatalf("kind=%q scope=%q, want personal/game_broadcast", msg.Kind, msg.BroadcastScope) + } + if len(recipients) != 3 { + t.Fatalf("broadcast recipients=%d, want 3 (everyone but sender)", len(recipients)) + } + + // Free-tier sender: 403. + _, _, err = svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{ + GameID: gameID, + SenderUserID: freePlayer, + Body: "Should not be allowed.", + }) + if !errors.Is(err, diplomail.ErrForbidden) { + t.Fatalf("free broadcast: %v, want ErrForbidden", err) + } +} + +func TestDiplomailMultiGameBroadcastAndCleanup(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + game1 := uuid.New() + game2 := uuid.New() + finished := uuid.New() + alice := uuid.New() + bob := uuid.New() + carol := uuid.New() + for _, id := range []uuid.UUID{alice, bob, carol} { + seedAccount(t, db, id) + } + seedGame(t, db, game1, "Active Game 1") + seedGame(t, db, game2, "Active Game 2") + seedGame(t, db, finished, "Finished Game") + // Mark `finished` terminal with a long-past finished_at. + if _, err := db.ExecContext(ctx, ` + UPDATE backend.games + SET status='finished', finished_at = now() - interval '3 years' + WHERE game_id = $1 + `, finished); err != nil { + t.Fatalf("backdate finished: %v", err) + } + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {game1, alice}: {UserID: alice, GameID: game1, GameName: "Active Game 1", UserName: "alice", RaceName: "AliceRace"}, + {game1, bob}: {UserID: bob, GameID: game1, GameName: "Active Game 1", UserName: "bob", RaceName: "BobRace"}, + {game2, carol}: {UserID: carol, GameID: game2, GameName: "Active Game 2", UserName: "carol", RaceName: "CarolRace"}, + {finished, alice}: {UserID: alice, GameID: finished, GameName: "Finished Game", UserName: "alice", RaceName: "AliceRace"}, + }, + } + publisher := &recordingPublisher{} + finAt := time.Now().UTC().AddDate(-3, 0, 0) + games := &staticGameLookup{games: map[uuid.UUID]diplomail.GameSnapshot{ + game1: {GameID: game1, GameName: "Active Game 1", Status: "running"}, + game2: {GameID: game2, GameName: "Active Game 2", Status: "running"}, + finished: {GameID: finished, GameName: "Finished Game", Status: "finished", FinishedAt: &finAt}, + }} + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Games: games, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + // First, drop a personal message into the finished game so cleanup + // has something to remove. + if _, _, err := svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{ + GameID: finished, + CallerKind: diplomail.CallerKindAdmin, + CallerUsername: "ops", + RecipientUserID: alice, + Body: "Audit ping", + }); err != nil { + t.Fatalf("seed finished-game mail: %v", err) + } + + // Multi-game broadcast across all running games. + msgs, total, err := svc.SendAdminMultiGameBroadcast(ctx, diplomail.SendMultiGameBroadcastInput{ + CallerUsername: "ops", + Scope: diplomail.MultiGameScopeAllRunning, + RecipientScope: diplomail.RecipientScopeActive, + Subject: "Maintenance", + Body: "Brief turn-engine restart in 10 minutes.", + }) + if err != nil { + t.Fatalf("multi-game broadcast: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("multi-game messages=%d, want 2 (game1 + game2)", len(msgs)) + } + if total != 3 { + t.Fatalf("multi-game recipient count=%d, want 3 (alice+bob in g1, carol in g2)", total) + } + + // Bulk cleanup with 1-year cutoff should sweep the finished game. + result, err := svc.BulkCleanup(ctx, diplomail.BulkCleanupInput{OlderThanYears: 1}) + if err != nil { + t.Fatalf("bulk cleanup: %v", err) + } + if len(result.GameIDs) != 1 || result.GameIDs[0] != finished { + t.Fatalf("cleanup game_ids=%v, want [%s]", result.GameIDs, finished) + } + if result.MessagesDeleted < 1 { + t.Fatalf("cleanup messages_deleted=%d, want >=1", result.MessagesDeleted) + } + + // Admin listing sees the multi-game messages. + page, err := svc.ListMessagesForAdmin(ctx, diplomail.AdminMessageListing{Page: 1, PageSize: 50}) + if err != nil { + t.Fatalf("list admin messages: %v", err) + } + if page.Total < 2 { + t.Fatalf("list total=%d, want >=2 after cleanup", page.Total) + } +} + func TestDiplomailLifecycleMembershipKick(t *testing.T) { db := startPostgres(t) ctx := context.Background() diff --git a/backend/internal/diplomail/store.go b/backend/internal/diplomail/store.go index 2d15a05..73af93b 100644 --- a/backend/internal/diplomail/store.go +++ b/backend/internal/diplomail/store.go @@ -358,6 +358,99 @@ func (s *Store) UnreadCountForUserGame(ctx context.Context, gameID, userID uuid. return int(dest.Count), nil } +// DeleteMessagesForGames removes every diplomail_messages row whose +// game_id falls in the supplied set. The cascade defined on the +// `diplomail_recipients` and `diplomail_translations` foreign keys +// removes the per-recipient state and the cached translations in +// the same transaction. Returns the count of messages removed. +// +// Used by the admin bulk-purge endpoint; callers are expected to +// have already filtered the input set to terminal-state games. +func (s *Store) DeleteMessagesForGames(ctx context.Context, gameIDs []uuid.UUID) (int, error) { + if len(gameIDs) == 0 { + return 0, nil + } + args := make([]postgres.Expression, 0, len(gameIDs)) + for _, id := range gameIDs { + args = append(args, postgres.UUID(id)) + } + m := table.DiplomailMessages + stmt := m.DELETE().WHERE(m.GameID.IN(args...)) + res, err := stmt.ExecContext(ctx, s.db) + if err != nil { + return 0, fmt.Errorf("diplomail store: bulk delete messages: %w", err) + } + affected, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("diplomail store: rows affected: %w", err) + } + return int(affected), nil +} + +// ListMessagesForAdmin returns a paginated slice of messages +// matching filter. The result is ordered by created_at DESC, +// message_id DESC. Total is the count without pagination so the +// caller can render a "page X of N" envelope. +func (s *Store) ListMessagesForAdmin(ctx context.Context, filter AdminMessageListing) ([]Message, int, error) { + m := table.DiplomailMessages + page := filter.Page + if page < 1 { + page = 1 + } + pageSize := filter.PageSize + if pageSize < 1 { + pageSize = 50 + } + + conditions := postgres.BoolExpression(nil) + addCondition := func(cond postgres.BoolExpression) { + if conditions == nil { + conditions = cond + return + } + conditions = conditions.AND(cond) + } + if filter.GameID != nil { + addCondition(m.GameID.EQ(postgres.UUID(*filter.GameID))) + } + if filter.Kind != "" { + addCondition(m.Kind.EQ(postgres.String(filter.Kind))) + } + if filter.SenderKind != "" { + addCondition(m.SenderKind.EQ(postgres.String(filter.SenderKind))) + } + + countStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(m) + if conditions != nil { + countStmt = countStmt.WHERE(conditions) + } + var countDest struct { + Count int64 `alias:"count"` + } + if err := countStmt.QueryContext(ctx, s.db, &countDest); err != nil { + return nil, 0, fmt.Errorf("diplomail store: count admin messages: %w", err) + } + + listStmt := postgres.SELECT(messageColumns()).FROM(m) + if conditions != nil { + listStmt = listStmt.WHERE(conditions) + } + listStmt = listStmt. + ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC()). + LIMIT(int64(pageSize)). + OFFSET(int64((page - 1) * pageSize)) + + var rows []model.DiplomailMessages + if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, 0, fmt.Errorf("diplomail store: list admin messages: %w", err) + } + out := make([]Message, 0, len(rows)) + for _, row := range rows { + out = append(out, messageFromModel(row)) + } + return out, int(countDest.Count), nil +} + // UnreadCountsForUser returns a per-game breakdown of unread messages // addressed to userID, plus the matching game names so the lobby // badge UI can render entries even after the recipient's membership diff --git a/backend/internal/diplomail/types.go b/backend/internal/diplomail/types.go index cdaae36..cfed844 100644 --- a/backend/internal/diplomail/types.go +++ b/backend/internal/diplomail/types.go @@ -120,6 +120,78 @@ const ( LifecycleKindMembershipBlocked = "membership.blocked" ) +// SendPlayerBroadcastInput is the request payload for the paid-tier +// player broadcast. The sender is a player; recipients are the +// active members of the game minus the sender. The resulting message +// is `kind="personal"`, `sender_kind="player"`, +// `broadcast_scope="game_broadcast"` — recipients may reply as if it +// were a personal send, but the reply goes back to the broadcaster +// only. +type SendPlayerBroadcastInput struct { + GameID uuid.UUID + SenderUserID uuid.UUID + Subject string + Body string + SenderIP string +} + +// MultiGameBroadcastScope enumerates the admin multi-game broadcast +// modes. `selected` requires `GameIDs`; `all_running` enumerates +// every game whose status is non-terminal through GameLookup. +const ( + MultiGameScopeSelected = "selected" + MultiGameScopeAllRunning = "all_running" +) + +// SendMultiGameBroadcastInput is the request payload for the admin +// multi-game broadcast. The service materialises one message row per +// addressed game (so a recipient who plays in two games receives two +// independently-deletable inbox entries), then fan-outs the push +// events. +type SendMultiGameBroadcastInput struct { + CallerUsername string + Scope string + GameIDs []uuid.UUID + RecipientScope string + Subject string + Body string + SenderIP string +} + +// BulkCleanupInput selects messages eligible for purge. OlderThanYears +// must be >= 1; the service translates the value into a cutoff +// expressed in years and walks `GameLookup.ListFinishedGamesBefore`. +type BulkCleanupInput struct { + OlderThanYears int +} + +// CleanupResult summarises a bulk-cleanup run for the admin response +// envelope. +type CleanupResult struct { + GameIDs []uuid.UUID + MessagesDeleted int +} + +// AdminMessageListing is the filter passed to ListMessagesForAdmin. +// Pagination uses (Page, PageSize) consistent with the rest of the +// admin surface. Filters are AND-combined; the empty filter returns +// every persisted row. +type AdminMessageListing struct { + Page int + PageSize int + GameID *uuid.UUID + Kind string + SenderKind string +} + +// AdminMessagePage is the canonical pagination envelope. +type AdminMessagePage struct { + Items []Message + Total int + Page int + PageSize int +} + // 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: diff --git a/backend/internal/lobby/cache.go b/backend/internal/lobby/cache.go index d155b9e..9523965 100644 --- a/backend/internal/lobby/cache.go +++ b/backend/internal/lobby/cache.go @@ -117,6 +117,24 @@ func (c *Cache) GetGame(gameID uuid.UUID) (GameRecord, bool) { return g, ok } +// ListGames returns a snapshot copy of every cached game. Terminal- +// state games (finished, cancelled) are evicted from the cache on +// `PutGame`, so the result reflects the live roster of running / +// paused / draft / starting / etc. games. The slice is freshly +// allocated and safe for the caller to mutate. +func (c *Cache) ListGames() []GameRecord { + if c == nil { + return nil + } + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]GameRecord, 0, len(c.games)) + for _, g := range c.games { + out = append(out, g) + } + return out +} + // PutGame stores game in the cache when its status is cacheable; // terminal statuses (finished, cancelled) cause the entry to be evicted. func (c *Cache) PutGame(game GameRecord) { diff --git a/backend/internal/lobby/games.go b/backend/internal/lobby/games.go index 18e4862..ad98f4f 100644 --- a/backend/internal/lobby/games.go +++ b/backend/internal/lobby/games.go @@ -234,6 +234,41 @@ func (s *Service) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameReco return s.deps.Store.ListMyGames(ctx, userID) } +// ListFinishedGamesBefore returns every game whose status is +// `finished` or `cancelled` and whose `finished_at` is strictly older +// than cutoff. The result walks the store through the admin-paged +// query with a 200-row batch size; the caller is expected to invoke +// this from rare admin workflows (diplomail bulk cleanup) rather +// than hot-path reads. +func (s *Service) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameRecord, error) { + const pageSize = 200 + page := 1 + var out []GameRecord + for { + batch, _, err := s.deps.Store.ListAdminGames(ctx, page, pageSize) + if err != nil { + return nil, fmt.Errorf("lobby: list finished games before %s: %w", cutoff, err) + } + if len(batch) == 0 { + break + } + for _, g := range batch { + if g.Status != GameStatusFinished && g.Status != GameStatusCancelled { + continue + } + if g.FinishedAt == nil || !g.FinishedAt.Before(cutoff) { + continue + } + out = append(out, g) + } + if len(batch) < pageSize { + break + } + page++ + } + return out, nil +} + // DeleteGame removes the game and every referencing row (memberships, // applications, invites, runtime_records, player_mappings) via the // `ON DELETE CASCADE` constraints declared in `00001_init.sql`. diff --git a/backend/internal/server/contract_test.go b/backend/internal/server/contract_test.go index 1fd9233..17d775a 100644 --- a/backend/internal/server/contract_test.go +++ b/backend/internal/server/contract_test.go @@ -167,6 +167,18 @@ var requestBodyStubs = map[string]map[string]any{ "subject": "Contract test admin subject", "body": "Contract test admin body", }, + "userMailSendBroadcast": { + "subject": "Contract test paid broadcast", + "body": "Contract test paid broadcast body", + }, + "adminDiplomailBroadcast": { + "scope": "all_running", + "subject": "Contract test multi-game broadcast", + "body": "Contract test multi-game broadcast body", + }, + "adminDiplomailCleanup": { + "older_than_years": 1, + }, } // 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 index 7edc2a1..0e1dd0a 100644 --- a/backend/internal/server/handlers_admin_diplomail.go +++ b/backend/internal/server/handlers_admin_diplomail.go @@ -99,3 +99,228 @@ func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc { } } } + +// Broadcast handles POST /api/v1/admin/mail/broadcast. Body: +// +// { +// "scope": "selected" | "all_running", +// "game_ids": ["..."], +// "recipients": "active" | "active_and_removed" | "all_members", +// "subject": "...", +// "body": "..." +// } +// +// The handler routes through SendAdminMultiGameBroadcast and returns +// a fan-out receipt describing the message ids created and the +// total recipient count. +func (h *AdminDiplomailHandlers) Broadcast() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("adminDiplomailBroadcast") + } + 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 + } + var req adminDiplomailBroadcastRequestWire + if err := c.ShouldBindJSON(&req); err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") + return + } + gameIDs := make([]uuid.UUID, 0, len(req.GameIDs)) + for _, raw := range req.GameIDs { + parsed, err := uuid.Parse(raw) + if err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_ids must be valid UUIDs") + return + } + gameIDs = append(gameIDs, parsed) + } + ctx := c.Request.Context() + msgs, total, err := h.svc.SendAdminMultiGameBroadcast(ctx, diplomail.SendMultiGameBroadcastInput{ + CallerUsername: username, + Scope: req.Scope, + GameIDs: gameIDs, + RecipientScope: req.Recipients, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), + }) + if err != nil { + respondDiplomailError(c, h.logger, "admin mail broadcast", ctx, err) + return + } + out := adminDiplomailBroadcastResponseWire{ + RecipientCount: total, + Messages: make([]adminDiplomailBroadcastMessageWire, 0, len(msgs)), + } + for _, m := range msgs { + out.Messages = append(out.Messages, adminDiplomailBroadcastMessageWire{ + MessageID: m.MessageID.String(), + GameID: m.GameID.String(), + GameName: m.GameName, + }) + } + c.JSON(http.StatusCreated, out) + } +} + +// Cleanup handles POST /api/v1/admin/mail/cleanup. Body: +// +// { "older_than_years": 1 } +// +// The endpoint removes every diplomail_messages row whose game +// finished more than the supplied number of years ago. The cascade +// on the recipient and translation tables prunes the per-user state +// in the same transaction. Returns a CleanupResult envelope. +func (h *AdminDiplomailHandlers) Cleanup() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("adminDiplomailCleanup") + } + 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 + } + _ = username + var req adminDiplomailCleanupRequestWire + 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() + result, err := h.svc.BulkCleanup(ctx, diplomail.BulkCleanupInput{OlderThanYears: req.OlderThanYears}) + if err != nil { + respondDiplomailError(c, h.logger, "admin mail cleanup", ctx, err) + return + } + out := adminDiplomailCleanupResponseWire{ + MessagesDeleted: result.MessagesDeleted, + GameIDs: make([]string, 0, len(result.GameIDs)), + } + for _, id := range result.GameIDs { + out.GameIDs = append(out.GameIDs, id.String()) + } + c.JSON(http.StatusOK, out) + } +} + +// List handles GET /api/v1/admin/mail/messages. Supports pagination +// via `page` and `page_size`, plus optional `game_id`, `kind`, and +// `sender_kind` filters. +func (h *AdminDiplomailHandlers) List() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("adminDiplomailList") + } + 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 + } + filter := diplomail.AdminMessageListing{ + Page: parsePositiveQueryInt(c.Query("page"), 1), + PageSize: parsePositiveQueryInt(c.Query("page_size"), 50), + Kind: c.Query("kind"), + SenderKind: c.Query("sender_kind"), + } + if raw := c.Query("game_id"); raw != "" { + parsed, err := uuid.Parse(raw) + if err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_id must be a valid UUID") + return + } + filter.GameID = &parsed + } + ctx := c.Request.Context() + page, err := h.svc.ListMessagesForAdmin(ctx, filter) + if err != nil { + respondDiplomailError(c, h.logger, "admin mail list", ctx, err) + return + } + out := adminDiplomailListResponseWire{ + Total: page.Total, + Page: page.Page, + PageSize: page.PageSize, + Items: make([]adminDiplomailMessageWire, 0, len(page.Items)), + } + for _, m := range page.Items { + entry := adminDiplomailMessageWire{ + MessageID: m.MessageID.String(), + GameID: m.GameID.String(), + GameName: m.GameName, + Kind: m.Kind, + SenderKind: m.SenderKind, + SenderIP: m.SenderIP, + Subject: m.Subject, + Body: m.Body, + BodyLang: m.BodyLang, + BroadcastScope: m.BroadcastScope, + CreatedAt: m.CreatedAt.UTC().Format(timestampLayout), + } + if m.SenderUserID != nil { + s := m.SenderUserID.String() + entry.SenderUserID = &s + } + if m.SenderUsername != nil { + s := *m.SenderUsername + entry.SenderUsername = &s + } + out.Items = append(out.Items, entry) + } + c.JSON(http.StatusOK, out) + } +} + +type adminDiplomailBroadcastRequestWire struct { + Scope string `json:"scope"` + GameIDs []string `json:"game_ids,omitempty"` + Recipients string `json:"recipients,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` +} + +type adminDiplomailBroadcastMessageWire struct { + MessageID string `json:"message_id"` + GameID string `json:"game_id"` + GameName string `json:"game_name,omitempty"` +} + +type adminDiplomailBroadcastResponseWire struct { + RecipientCount int `json:"recipient_count"` + Messages []adminDiplomailBroadcastMessageWire `json:"messages"` +} + +type adminDiplomailCleanupRequestWire struct { + OlderThanYears int `json:"older_than_years"` +} + +type adminDiplomailCleanupResponseWire struct { + MessagesDeleted int `json:"messages_deleted"` + GameIDs []string `json:"game_ids"` +} + +type adminDiplomailMessageWire 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"` + SenderUserID *string `json:"sender_user_id,omitempty"` + SenderUsername *string `json:"sender_username,omitempty"` + SenderIP string `json:"sender_ip,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + BodyLang string `json:"body_lang"` + BroadcastScope string `json:"broadcast_scope"` + CreatedAt string `json:"created_at"` +} + +type adminDiplomailListResponseWire struct { + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Items []adminDiplomailMessageWire `json:"items"` +} diff --git a/backend/internal/server/handlers_user_mail.go b/backend/internal/server/handlers_user_mail.go index 206139d..58a8bf3 100644 --- a/backend/internal/server/handlers_user_mail.go +++ b/backend/internal/server/handlers_user_mail.go @@ -232,6 +232,48 @@ func (h *UserMailHandlers) Delete() gin.HandlerFunc { } } +// SendBroadcast handles POST /api/v1/user/games/{game_id}/mail/broadcast. +// +// The endpoint is the paid-tier player broadcast: any player on a +// non-`free` entitlement tier may send one personal message that +// fans out to every other active member of the game. The result +// rows carry `kind="personal"`, `sender_kind="player"`, +// `broadcast_scope="game_broadcast"`. Free-tier callers see a 403. +func (h *UserMailHandlers) SendBroadcast() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailSendBroadcast") + } + 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 userMailSendBroadcastRequestWire + 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() + msg, recipients, err := h.svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{ + GameID: gameID, + SenderUserID: userID, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), + }) + if err != nil { + respondDiplomailError(c, h.logger, "user mail send broadcast", ctx, err) + return + } + c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients)) + } +} + // SendAdmin handles POST /api/v1/user/games/{game_id}/mail/admin. // // Owner-only: the caller must be the owner of the private game. The @@ -392,6 +434,14 @@ type userMailSendRequestWire struct { Body string `json:"body"` } +// userMailSendBroadcastRequestWire mirrors the request body for the +// paid-tier player broadcast. There is no `target` discriminator — +// the recipient set is always "every other active member". +type userMailSendBroadcastRequestWire struct { + Subject string `json:"subject,omitempty"` + 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 diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index c93f8dc..bef1433 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -278,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("/broadcast", deps.UserMail.SendBroadcast()) userMail.POST("/admin", deps.UserMail.SendAdmin()) userMail.GET("/messages/:message_id", deps.UserMail.Get()) userMail.POST("/messages/:message_id/read", deps.UserMail.MarkRead()) @@ -339,6 +340,9 @@ func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, d mail.GET("/deliveries/:delivery_id/attempts", deps.AdminMail.ListDeliveryAttempts()) mail.POST("/deliveries/:delivery_id/resend", deps.AdminMail.ResendDelivery()) mail.GET("/dead-letters", deps.AdminMail.ListDeadLetters()) + mail.GET("/messages", deps.AdminDiplomail.List()) + mail.POST("/broadcast", deps.AdminDiplomail.Broadcast()) + mail.POST("/cleanup", deps.AdminDiplomail.Cleanup()) notifications := group.Group("/notifications") notifications.GET("", deps.AdminNotifications.List()) diff --git a/backend/openapi.yaml b/backend/openapi.yaml index b2f640e..3cd04a0 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -1183,6 +1183,45 @@ paths: $ref: "#/components/responses/NotImplementedError" "500": $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/broadcast: + post: + tags: [User] + operationId: userMailSendBroadcast + summary: Send a paid-tier personal broadcast to a game's active members + description: | + Paid-tier players (`entitlement.is_paid == true`) may send one + personal message that fans out to every other active member of + the game. Free-tier callers receive 403. The resulting rows + carry `kind="personal"`, `sender_kind="player"`, + `broadcast_scope="game_broadcast"`. Recipients reply through + the regular personal-send endpoint; the reply targets the + broadcaster only. + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailSendBroadcastRequest" + responses: + "201": + description: Personal broadcast accepted; receipt carries the recipient count. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailBroadcastReceipt" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" /api/v1/user/games/{game_id}/mail/admin: post: tags: [User] @@ -1954,6 +1993,133 @@ paths: $ref: "#/components/responses/NotImplementedError" "500": $ref: "#/components/responses/InternalError" + /api/v1/admin/mail/broadcast: + post: + tags: [Admin] + operationId: adminDiplomailBroadcast + summary: Multi-game admin broadcast + description: | + Fans out one admin-kind broadcast across the games selected + by `scope`. `scope="selected"` requires `game_ids`; + `scope="all_running"` enumerates every game whose status is + non-terminal. Recipients are resolved per-game via the same + scope vocabulary as the per-game admin send. A recipient + appearing in multiple addressed games receives one + independently-deletable inbox entry per game. + security: + - AdminBasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminDiplomailBroadcastRequest" + responses: + "201": + description: Broadcast accepted; per-game message ids and total recipient count. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminDiplomailBroadcastResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/admin/mail/cleanup: + post: + tags: [Admin] + operationId: adminDiplomailCleanup + summary: Bulk-purge diplomail messages from old finished games + description: | + Removes every `diplomail_messages` row whose game finished + more than `older_than_years` years ago. Cascading FKs prune + the recipient and translation tables in the same transaction. + `older_than_years` must be >= 1. + security: + - AdminBasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminDiplomailCleanupRequest" + responses: + "200": + description: Cleanup result. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminDiplomailCleanupResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/admin/mail/messages: + get: + tags: [Admin] + operationId: adminDiplomailList + summary: Paginated admin view of diplomail messages + description: | + Returns the canonical message rows for admin observability. + Optional filters: `game_id`, `kind` (personal / admin), + `sender_kind` (player / admin / system). Pagination via + `page` and `page_size`. + security: + - AdminBasicAuth: [] + parameters: + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + - name: page_size + in: query + required: false + schema: + type: integer + minimum: 1 + - name: game_id + in: query + required: false + schema: + type: string + format: uuid + - name: kind + in: query + required: false + schema: + type: string + enum: [personal, admin] + - name: sender_kind + in: query + required: false + schema: + type: string + enum: [player, admin, system] + responses: + "200": + description: Paginated diplomail messages. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminDiplomailListResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" /api/v1/admin/games/{game_id}/mail: post: tags: [Admin] @@ -3983,6 +4149,145 @@ components: recipient_count: type: integer minimum: 0 + UserMailSendBroadcastRequest: + type: object + additionalProperties: false + required: [body] + properties: + subject: + type: string + body: + type: string + AdminDiplomailBroadcastRequest: + type: object + additionalProperties: false + required: [scope, body] + properties: + scope: + type: string + enum: [selected, all_running] + game_ids: + type: array + items: + type: string + format: uuid + recipients: + type: string + enum: [active, active_and_removed, all_members] + subject: + type: string + body: + type: string + AdminDiplomailBroadcastResponse: + type: object + additionalProperties: false + required: [recipient_count, messages] + properties: + recipient_count: + type: integer + minimum: 0 + messages: + type: array + items: + type: object + additionalProperties: false + required: [message_id, game_id] + properties: + message_id: + type: string + format: uuid + game_id: + type: string + format: uuid + game_name: + type: string + AdminDiplomailCleanupRequest: + type: object + additionalProperties: false + required: [older_than_years] + properties: + older_than_years: + type: integer + minimum: 1 + AdminDiplomailCleanupResponse: + type: object + additionalProperties: false + required: [messages_deleted, game_ids] + properties: + messages_deleted: + type: integer + minimum: 0 + game_ids: + type: array + items: + type: string + format: uuid + AdminDiplomailMessage: + type: object + additionalProperties: false + required: + - message_id + - game_id + - kind + - sender_kind + - body + - body_lang + - broadcast_scope + - created_at + 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] + sender_user_id: + type: string + format: uuid + nullable: true + sender_username: + type: string + nullable: true + sender_ip: + type: string + 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 + AdminDiplomailListResponse: + type: object + additionalProperties: false + required: [total, page, page_size, items] + properties: + total: + type: integer + minimum: 0 + page: + type: integer + minimum: 1 + page_size: + type: integer + minimum: 1 + items: + type: array + items: + $ref: "#/components/schemas/AdminDiplomailMessage" UserMailMessageDetail: type: object additionalProperties: false