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
+35 -1
View File
@@ -43,11 +43,45 @@ type ActiveMembership struct {
// roster metadata. The canonical implementation in `cmd/backend/main`
// adapts the `*lobby.Service` membership cache to this interface.
//
// Implementations must return ErrNotFound (the diplomail sentinel)
// GetActiveMembership returns ErrNotFound (the diplomail sentinel)
// when the user is not an active member of the game; the service
// boundary maps that to 403 forbidden.
//
// GetMembershipAnyStatus returns the same shape regardless of
// membership status (`active`, `removed`, `blocked`). Used by the
// inbox read path to check whether a kicked recipient still belongs
// to the game's roster; ErrNotFound is surfaced when the user has
// never been a member.
//
// ListMembers returns every roster row matching scope, in stable
// order. Scope values are `active`, `active_and_removed`, and
// `all_members` (the spec calls these out by name). Used by the
// broadcast composition step in admin / owner sends.
type MembershipLookup interface {
GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (ActiveMembership, error)
GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (MemberSnapshot, error)
ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]MemberSnapshot, error)
}
// Recipient scope values accepted by ListMembers and by the
// `recipients` request field on admin / owner broadcasts.
const (
RecipientScopeActive = "active"
RecipientScopeActiveAndRemoved = "active_and_removed"
RecipientScopeAllMembers = "all_members"
)
// MemberSnapshot is the slim view of a membership row that survives
// all three status values. RaceName is the immutable string captured
// at registration time; an empty value is legal for rare cases where
// the row was inserted without one.
type MemberSnapshot struct {
UserID uuid.UUID
GameID uuid.UUID
GameName string
UserName string
RaceName string
Status string
}
// NotificationPublisher is the outbound surface diplomail uses to