diplomail (Stage C): paid-tier broadcast + multi-game + cleanup
Closes out the producer-side of the diplomail surface. Paid-tier players can fan out one personal message to the rest of the active roster (gated on entitlement_snapshots.is_paid). Site admins gain a multi-game broadcast (POST /admin/mail/broadcast with `selected` / `all_running` scopes) and the bulk-purge endpoint that wipes diplomail rows tied to games finished more than N years ago. An admin listing (GET /admin/mail/messages) rounds out the observability surface. EntitlementReader and GameLookup are new narrow deps wired from `*user.Service` and `*lobby.Service` in cmd/backend/main; the lobby service grows a one-off `ListFinishedGamesBefore` helper for the cleanup path (the cache evicts terminal-state games so the cache walk is not enough). Stage D will swap LangUndetermined for an actual body-language detector and add the translation cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user