diplomail (Stage B): admin/owner sends + lifecycle hooks
Tests · Go / test (push) Successful in 1m52s
Tests · Go / test (pull_request) Successful in 1m53s
Tests · Integration / integration (pull_request) Successful in 1m36s

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:
Ilia Denisov
2026-05-15 18:47:54 +02:00
parent 535e27008f
commit b3f24cc440
17 changed files with 1398 additions and 23 deletions
+36
View File
@@ -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.