diplomail (Stage B): admin/owner sends + lifecycle hooks
Item 7 of the spec wants game-state and membership-state changes to land as durable inbox entries the affected players can re-read after the fact — push alone times out of the 5-minute ring buffer. Stage B adds the admin-kind send matrix (owner-driven via /user, site-admin driven via /admin) plus the lobby lifecycle hooks: paused / cancelled emit a broadcast system mail to active members, kick / ban emit a single-recipient system mail to the affected user (which they keep read access to even after the membership row is revoked, per item 8). Migration relaxes diplomail_messages_kind_sender_chk so an owner sending kind=admin keeps sender_kind=player; the new LifecyclePublisher dep on lobby.Service is wired through a thin adapter in cmd/backend/main, mirroring how lobby's notification publisher is plumbed today. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,37 @@ type NotificationPublisher interface {
|
||||
PublishLobbyEvent(ctx context.Context, intent LobbyNotification) error
|
||||
}
|
||||
|
||||
// DiplomailPublisher is the outbound surface the lobby uses to drop a
|
||||
// durable system mail entry whenever a game-state or
|
||||
// membership-state transition needs to land in the affected players'
|
||||
// inboxes. The real implementation in `cmd/backend/main` adapts the
|
||||
// `*diplomail.Service.PublishLifecycle` call; tests and partial
|
||||
// wiring fall back to `NewNoopDiplomailPublisher`.
|
||||
type DiplomailPublisher interface {
|
||||
PublishLifecycle(ctx context.Context, event LifecycleEvent) error
|
||||
}
|
||||
|
||||
// LifecycleEvent is the open shape carried by a system-mail intent.
|
||||
// `Kind` is one of the lobby-internal constants
|
||||
// (`LifecycleKindGamePaused`, etc.). `TargetUser` is populated only
|
||||
// for membership-scoped events; the publisher derives the game-scoped
|
||||
// recipient set itself.
|
||||
type LifecycleEvent struct {
|
||||
GameID uuid.UUID
|
||||
Kind string
|
||||
Actor string
|
||||
Reason string
|
||||
TargetUser *uuid.UUID
|
||||
}
|
||||
|
||||
// Lifecycle-event kinds the lobby emits.
|
||||
const (
|
||||
LifecycleKindGamePaused = "game.paused"
|
||||
LifecycleKindGameCancelled = "game.cancelled"
|
||||
LifecycleKindMembershipRemoved = "membership.removed"
|
||||
LifecycleKindMembershipBlocked = "membership.blocked"
|
||||
)
|
||||
|
||||
// LobbyNotification is the open shape carried by a notification intent.
|
||||
// The implementation emits a small set of `Kind` values matching the catalog in
|
||||
// `backend/README.md` §10. The `Payload` map is the kind-specific data
|
||||
@@ -123,3 +154,26 @@ func (p *noopNotificationPublisher) PublishLobbyEvent(_ context.Context, intent
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewNoopDiplomailPublisher returns a DiplomailPublisher that logs
|
||||
// every call at debug level and returns nil. Used by tests and by
|
||||
// the lobby Service factory when the Deps.Diplomail field is left
|
||||
// nil.
|
||||
func NewNoopDiplomailPublisher(logger *zap.Logger) DiplomailPublisher {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &noopDiplomailPublisher{logger: logger.Named("lobby.diplomail.noop")}
|
||||
}
|
||||
|
||||
type noopDiplomailPublisher struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func (p *noopDiplomailPublisher) PublishLifecycle(_ context.Context, event LifecycleEvent) error {
|
||||
p.logger.Debug("noop diplomail lifecycle",
|
||||
zap.String("kind", event.Kind),
|
||||
zap.String("game_id", event.GameID.String()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"galaxy/cronutil"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CreateGameInput is the parameter struct for Service.CreateGame.
|
||||
@@ -441,9 +442,43 @@ func (s *Service) transition(ctx context.Context, callerUserID *uuid.UUID, calle
|
||||
return updated, fmt.Errorf("post-commit %s: %w", rule.Reason, err)
|
||||
}
|
||||
}
|
||||
s.emitGameLifecycleMail(ctx, updated, callerIsAdmin, rule)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// emitGameLifecycleMail asks the diplomail publisher to drop a
|
||||
// system-mail entry whenever a state change is user-visible. Only
|
||||
// the `paused` and `cancelled` transitions emit mail today (the spec
|
||||
// names them explicitly); `running`/`finished`/etc. are signalled by
|
||||
// other channels and do not need a durable inbox entry.
|
||||
func (s *Service) emitGameLifecycleMail(ctx context.Context, game GameRecord, callerIsAdmin bool, rule transitionRule) {
|
||||
var kind string
|
||||
switch rule.To {
|
||||
case GameStatusPaused:
|
||||
kind = LifecycleKindGamePaused
|
||||
case GameStatusCancelled:
|
||||
kind = LifecycleKindGameCancelled
|
||||
default:
|
||||
return
|
||||
}
|
||||
actor := "the game owner"
|
||||
if callerIsAdmin {
|
||||
actor = "an administrator"
|
||||
}
|
||||
ev := LifecycleEvent{
|
||||
GameID: game.GameID,
|
||||
Kind: kind,
|
||||
Actor: actor,
|
||||
Reason: rule.Reason,
|
||||
}
|
||||
if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil {
|
||||
s.deps.Logger.Warn("publish lifecycle mail failed",
|
||||
zap.String("game_id", game.GameID.String()),
|
||||
zap.String("kind", kind),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// checkOwner enforces ownership semantics:
|
||||
//
|
||||
// - callerIsAdmin == true → always allowed (admin force-start, etc.).
|
||||
|
||||
@@ -124,6 +124,7 @@ type Deps struct {
|
||||
Cache *Cache
|
||||
Runtime RuntimeGateway
|
||||
Notification NotificationPublisher
|
||||
Diplomail DiplomailPublisher
|
||||
Entitlement EntitlementProvider
|
||||
Policy *Policy
|
||||
Config config.LobbyConfig
|
||||
@@ -156,6 +157,9 @@ func NewService(deps Deps) (*Service, error) {
|
||||
if deps.Notification == nil {
|
||||
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
|
||||
}
|
||||
if deps.Diplomail == nil {
|
||||
deps.Diplomail = NewNoopDiplomailPublisher(deps.Logger)
|
||||
}
|
||||
if deps.Policy == nil {
|
||||
policy, err := NewPolicy()
|
||||
if err != nil {
|
||||
|
||||
@@ -76,6 +76,7 @@ func (s *Service) AdminBanMember(ctx context.Context, gameID, userID uuid.UUID,
|
||||
zap.String("membership_id", updated.MembershipID.String()),
|
||||
zap.Error(pubErr))
|
||||
}
|
||||
s.emitMembershipLifecycleMail(ctx, updated, MembershipStatusBlocked, true, reason)
|
||||
_ = game
|
||||
return updated, nil
|
||||
}
|
||||
@@ -142,9 +143,44 @@ func (s *Service) changeMembershipStatus(
|
||||
zap.String("kind", notificationKind),
|
||||
zap.Error(pubErr))
|
||||
}
|
||||
s.emitMembershipLifecycleMail(ctx, updated, newStatus, callerIsAdmin, "")
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// emitMembershipLifecycleMail asks the diplomail publisher to drop a
|
||||
// durable explanation into the kicked player's inbox. The mail
|
||||
// survives the membership row going to `removed` / `blocked` so the
|
||||
// player keeps read access to it (soft-access rule, item 8).
|
||||
func (s *Service) emitMembershipLifecycleMail(ctx context.Context, membership Membership, newStatus string, callerIsAdmin bool, reason string) {
|
||||
var kind string
|
||||
switch newStatus {
|
||||
case MembershipStatusRemoved:
|
||||
kind = LifecycleKindMembershipRemoved
|
||||
case MembershipStatusBlocked:
|
||||
kind = LifecycleKindMembershipBlocked
|
||||
default:
|
||||
return
|
||||
}
|
||||
actor := "the game owner"
|
||||
if callerIsAdmin {
|
||||
actor = "an administrator"
|
||||
}
|
||||
target := membership.UserID
|
||||
ev := LifecycleEvent{
|
||||
GameID: membership.GameID,
|
||||
Kind: kind,
|
||||
Actor: actor,
|
||||
Reason: reason,
|
||||
TargetUser: &target,
|
||||
}
|
||||
if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil {
|
||||
s.deps.Logger.Warn("publish membership lifecycle mail failed",
|
||||
zap.String("membership_id", membership.MembershipID.String()),
|
||||
zap.String("kind", kind),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) canManageMembership(game GameRecord, membership Membership, callerUserID *uuid.UUID, allowSelf bool) bool {
|
||||
if game.Visibility == VisibilityPublic {
|
||||
// Public-game membership management is admin-only.
|
||||
|
||||
Reference in New Issue
Block a user