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:
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user