diplomail (Stage A→D): backend in-game diplomatic mail #10

Merged
developer merged 6 commits from feature/diplomail-backend into development 2026-05-15 18:43:27 +00:00
14 changed files with 1423 additions and 4 deletions
Showing only changes of commit 362f92e520 - Show all commits
+95
View File
@@ -12,6 +12,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
// time/tzdata embeds the IANA timezone database so time.LoadLocation // time/tzdata embeds the IANA timezone database so time.LoadLocation
// works in container images without /usr/share/zoneinfo (distroless // works in container images without /usr/share/zoneinfo (distroless
@@ -310,6 +311,8 @@ func run(ctx context.Context) (err error) {
Store: diplomailStore, Store: diplomailStore,
Memberships: &diplomailMembershipAdapter{lobby: lobbySvc, users: userSvc}, Memberships: &diplomailMembershipAdapter{lobby: lobbySvc, users: userSvc},
Notification: &diplomailNotificationPublisherAdapter{svc: notifSvc}, Notification: &diplomailNotificationPublisherAdapter{svc: notifSvc},
Entitlements: &diplomailEntitlementAdapter{users: userSvc},
Games: &diplomailGameAdapter{lobby: lobbySvc},
Config: cfg.Diplomail, Config: cfg.Diplomail,
Logger: logger, 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 // diplomailNotificationPublisherAdapter implements
// `diplomail.NotificationPublisher` by translating each // `diplomail.NotificationPublisher` by translating each
// DiplomailNotification into a notification.Intent and routing it // DiplomailNotification into a notification.Intent and routing it
+6 -3
View File
@@ -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 | | 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 | | 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 | | D | Body-language detection (whatlanggo) + translation cache + async worker | planned |
## Tables ## Tables
@@ -40,14 +40,17 @@ Three Postgres tables in the `backend` schema:
| Action | Caller | Pre-conditions | | Action | Caller | Pre-conditions |
|--------|--------|----------------| |--------|--------|----------------|
| Send personal | user | active membership in game; recipient is active member | | 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 (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 | | 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 | | 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 | | Mark read | the recipient | row exists; idempotent if already marked |
| Soft delete | the recipient | `read_at IS NOT NULL` (open-then-delete, item 10) | | 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 Stage D will add body-language detection (whatlanggo) and the
admin endpoint. translation cache + async worker.
System mail is produced internally by lobby lifecycle hooks: System mail is produced internally by lobby lifecycle hooks:
`Service.transition()` emits `game.paused` / `game.cancelled` system `Service.transition()` emits `game.paused` / `game.cancelled` system
+219
View File
@@ -103,6 +103,225 @@ func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastI
return msg, recipients, nil 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 // PublishLifecycle persists a system-kind message in response to a
// lobby lifecycle transition and fan-outs push events to the // lobby lifecycle transition and fan-outs push events to the
// affected recipients. Game-scoped transitions (`game.paused`, // affected recipients. Game-scoped transitions (`game.paused`,
+46 -1
View File
@@ -15,16 +15,61 @@ import (
// Store and Memberships are required. Logger and Now default to // Store and Memberships are required. Logger and Now default to
// zap.NewNop / time.Now when nil. Notification falls back to a no-op // zap.NewNop / time.Now when nil. Notification falls back to a no-op
// publisher so unit tests can construct a Service with only the // 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 { type Deps struct {
Store *Store Store *Store
Memberships MembershipLookup Memberships MembershipLookup
Notification NotificationPublisher Notification NotificationPublisher
Entitlements EntitlementReader
Games GameLookup
Config config.DiplomailConfig Config config.DiplomailConfig
Logger *zap.Logger Logger *zap.Logger
Now func() time.Time 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 // ActiveMembership is the slim view of a single (user, game) roster
// row the diplomail package needs at send time: it confirms the // row the diplomail package needs at send time: it confirms the
// participant is active in the game and captures the snapshot fields // participant is active in the game and captures the snapshot fields
@@ -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) { func TestDiplomailLifecycleMembershipKick(t *testing.T) {
db := startPostgres(t) db := startPostgres(t)
ctx := context.Background() ctx := context.Background()
+93
View File
@@ -358,6 +358,99 @@ func (s *Store) UnreadCountForUserGame(ctx context.Context, gameID, userID uuid.
return int(dest.Count), nil 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 // UnreadCountsForUser returns a per-game breakdown of unread messages
// addressed to userID, plus the matching game names so the lobby // addressed to userID, plus the matching game names so the lobby
// badge UI can render entries even after the recipient's membership // badge UI can render entries even after the recipient's membership
+72
View File
@@ -120,6 +120,78 @@ const (
LifecycleKindMembershipBlocked = "membership.blocked" 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 // LifecycleEvent is the payload lobby hands to PublishLifecycle when
// a transition needs to be reflected as durable system mail. The // a transition needs to be reflected as durable system mail. The
// recipient set is derived by the service: // recipient set is derived by the service:
+18
View File
@@ -117,6 +117,24 @@ func (c *Cache) GetGame(gameID uuid.UUID) (GameRecord, bool) {
return g, ok 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; // PutGame stores game in the cache when its status is cacheable;
// terminal statuses (finished, cancelled) cause the entry to be evicted. // terminal statuses (finished, cancelled) cause the entry to be evicted.
func (c *Cache) PutGame(game GameRecord) { func (c *Cache) PutGame(game GameRecord) {
+35
View File
@@ -234,6 +234,41 @@ func (s *Service) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameReco
return s.deps.Store.ListMyGames(ctx, userID) 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, // DeleteGame removes the game and every referencing row (memberships,
// applications, invites, runtime_records, player_mappings) via the // applications, invites, runtime_records, player_mappings) via the
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`. // `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
+12
View File
@@ -167,6 +167,18 @@ var requestBodyStubs = map[string]map[string]any{
"subject": "Contract test admin subject", "subject": "Contract test admin subject",
"body": "Contract test admin body", "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 // TestOpenAPIContract is the top-level OpenAPI contract test. It
@@ -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"`
}
@@ -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. // SendAdmin handles POST /api/v1/user/games/{game_id}/mail/admin.
// //
// Owner-only: the caller must be the owner of the private game. The // Owner-only: the caller must be the owner of the private game. The
@@ -392,6 +434,14 @@ type userMailSendRequestWire struct {
Body string `json:"body"` 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 // userMailSendAdminRequestWire mirrors the request body for the
// owner-only admin send. `target="user"` requires // owner-only admin send. `target="user"` requires
// `recipient_user_id`; `target="all"` accepts the optional // `recipient_user_id`; `target="all"` accepts the optional
+4
View File
@@ -278,6 +278,7 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
userMail := userGames.Group("/:game_id/mail") userMail := userGames.Group("/:game_id/mail")
userMail.POST("/messages", deps.UserMail.SendPersonal()) userMail.POST("/messages", deps.UserMail.SendPersonal())
userMail.POST("/broadcast", deps.UserMail.SendBroadcast())
userMail.POST("/admin", deps.UserMail.SendAdmin()) userMail.POST("/admin", deps.UserMail.SendAdmin())
userMail.GET("/messages/:message_id", deps.UserMail.Get()) userMail.GET("/messages/:message_id", deps.UserMail.Get())
userMail.POST("/messages/:message_id/read", deps.UserMail.MarkRead()) 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.GET("/deliveries/:delivery_id/attempts", deps.AdminMail.ListDeliveryAttempts())
mail.POST("/deliveries/:delivery_id/resend", deps.AdminMail.ResendDelivery()) mail.POST("/deliveries/:delivery_id/resend", deps.AdminMail.ResendDelivery())
mail.GET("/dead-letters", deps.AdminMail.ListDeadLetters()) 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 := group.Group("/notifications")
notifications.GET("", deps.AdminNotifications.List()) notifications.GET("", deps.AdminNotifications.List())
+305
View File
@@ -1183,6 +1183,45 @@ paths:
$ref: "#/components/responses/NotImplementedError" $ref: "#/components/responses/NotImplementedError"
"500": "500":
$ref: "#/components/responses/InternalError" $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: /api/v1/user/games/{game_id}/mail/admin:
post: post:
tags: [User] tags: [User]
@@ -1954,6 +1993,133 @@ paths:
$ref: "#/components/responses/NotImplementedError" $ref: "#/components/responses/NotImplementedError"
"500": "500":
$ref: "#/components/responses/InternalError" $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: /api/v1/admin/games/{game_id}/mail:
post: post:
tags: [Admin] tags: [Admin]
@@ -3983,6 +4149,145 @@ components:
recipient_count: recipient_count:
type: integer type: integer
minimum: 0 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: UserMailMessageDetail:
type: object type: object
additionalProperties: false additionalProperties: false