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
+53 -3
View File
@@ -100,20 +100,70 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
// GetMessage returns the InboxEntry for messageID addressed to
// userID. ErrNotFound is returned when the caller is not a recipient
// of the message — handlers translate that to 404 so the existence
// of the message is not leaked.
// of the message is not leaked. The same sentinel is returned when
// the caller is no longer an active member of the game and the
// message is personal-kind: post-kick visibility is restricted to
// admin/system mail (item 8 of the spec).
func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID) (InboxEntry, error) {
entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID)
if err != nil {
return InboxEntry{}, err
}
allowed, err := s.allowedKinds(ctx, entry.GameID, userID)
if err != nil {
return InboxEntry{}, err
}
if !allowed[entry.Kind] {
return InboxEntry{}, ErrNotFound
}
return entry, nil
}
// ListInbox returns every non-deleted message addressed to userID in
// gameID, newest first. Read state is preserved per entry; the HTTP
// layer renders both the message and the recipient row.
// layer renders both the message and the recipient row. Personal
// messages are filtered out when the caller is no longer an active
// member of the game so a kicked player keeps read access to the
// admin/system explanation of the kick but not to historical
// player-to-player threads.
func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]InboxEntry, error) {
return s.deps.Store.ListInbox(ctx, gameID, userID)
entries, err := s.deps.Store.ListInbox(ctx, gameID, userID)
if err != nil {
return nil, err
}
allowed, err := s.allowedKinds(ctx, gameID, userID)
if err != nil {
return nil, err
}
if allowed[KindPersonal] && allowed[KindAdmin] {
return entries, nil
}
out := make([]InboxEntry, 0, len(entries))
for _, e := range entries {
if allowed[e.Kind] {
out = append(out, e)
}
}
return out, nil
}
// allowedKinds resolves the set of message kinds the caller may read
// in gameID. An active member can read everything; a former member
// (status removed or blocked) can read admin-kind only. A user who
// has never been a member of the game but is still listed as a
// recipient (legacy / system message) is granted the same admin-only
// view. The function never returns an empty set: even non-members
// keep their read access to admin mail.
func (s *Service) allowedKinds(ctx context.Context, gameID, userID uuid.UUID) (map[string]bool, error) {
if s.deps.Memberships == nil {
return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
}
if _, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, userID); err == nil {
return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
} else if !errors.Is(err, ErrNotFound) {
return nil, err
}
return map[string]bool{KindAdmin: true}, nil
}
// ListSent returns personal messages authored by senderUserID in