diplomail (Stage A→D): backend in-game diplomatic mail #10
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
// 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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user