Files
galaxy-game/backend/internal/lobby/memberships.go
T
Ilia Denisov b3f24cc440
Tests · Go / test (push) Successful in 1m52s
Tests · Go / test (pull_request) Successful in 1m53s
Tests · Integration / integration (pull_request) Successful in 1m36s
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>
2026-05-15 18:47:54 +02:00

197 lines
6.9 KiB
Go

package lobby
import (
"context"
"fmt"
"github.com/google/uuid"
"go.uber.org/zap"
)
// ListMembershipsForGame returns every membership row for gameID
// ordered by joined_at ASC. Reads always go to the store (the cache
// holds only active rows and would skip removed/blocked entries).
func (s *Service) ListMembershipsForGame(ctx context.Context, gameID uuid.UUID) ([]Membership, error) {
if _, err := s.GetGame(ctx, gameID); err != nil {
return nil, err
}
return s.deps.Store.ListMembershipsForGame(ctx, gameID)
}
// RemoveMembership transitions an active membership to `removed`. The
// caller must be the membership's user (self-leave) or the owner of
// the game (owner removal). Removing a membership releases its race
// name reservation in the same flow.
func (s *Service) RemoveMembership(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, membershipID uuid.UUID) (Membership, error) {
return s.changeMembershipStatus(ctx, callerUserID, callerIsAdmin, gameID, membershipID, MembershipStatusRemoved, NotificationLobbyMembershipRemoved, true)
}
// BlockMembership transitions an active membership to `blocked`. Only
// the owner of the game (or admin) may block.
func (s *Service) BlockMembership(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, membershipID uuid.UUID) (Membership, error) {
return s.changeMembershipStatus(ctx, callerUserID, callerIsAdmin, gameID, membershipID, MembershipStatusBlocked, NotificationLobbyMembershipBlocked, false)
}
// AdminBanMember is the admin-only variant of BlockMembership: targets
// a user_id directly (the request body carries it instead of a
// membership_id) and emits the same intent as BlockMembership.
func (s *Service) AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, reason string) (Membership, error) {
game, err := s.GetGame(ctx, gameID)
if err != nil {
return Membership{}, err
}
memberships, err := s.deps.Store.ListMembershipsForGame(ctx, gameID)
if err != nil {
return Membership{}, err
}
var target Membership
found := false
for _, m := range memberships {
if m.UserID == userID && m.Status == MembershipStatusActive {
target = m
found = true
break
}
}
if !found {
return Membership{}, ErrNotFound
}
now := s.deps.Now().UTC()
updated, err := s.deps.Store.UpdateMembershipStatus(ctx, target.MembershipID, MembershipStatusBlocked, now)
if err != nil {
return Membership{}, err
}
s.deps.Cache.PutMembership(updated)
intent := LobbyNotification{
Kind: NotificationLobbyMembershipBlocked,
IdempotencyKey: "membership-blocked:" + updated.MembershipID.String(),
Recipients: []uuid.UUID{userID},
Payload: map[string]any{
"game_id": gameID.String(),
"reason": reason,
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("admin ban notification failed",
zap.String("membership_id", updated.MembershipID.String()),
zap.Error(pubErr))
}
s.emitMembershipLifecycleMail(ctx, updated, MembershipStatusBlocked, true, reason)
_ = game
return updated, nil
}
// changeMembershipStatus is the shared implementation for Remove /
// Block. allowSelf controls whether the caller's own membership_id is
// an authorised target (true for Remove → "leave the game"; false for
// Block → owner-only).
func (s *Service) changeMembershipStatus(
ctx context.Context,
callerUserID *uuid.UUID,
callerIsAdmin bool,
gameID, membershipID uuid.UUID,
newStatus, notificationKind string,
allowSelf bool,
) (Membership, error) {
membership, err := s.deps.Store.LoadMembership(ctx, membershipID)
if err != nil {
return Membership{}, err
}
if membership.GameID != gameID {
return Membership{}, ErrNotFound
}
if membership.Status != MembershipStatusActive {
return Membership{}, fmt.Errorf("%w: membership is %q", ErrConflict, membership.Status)
}
game, err := s.GetGame(ctx, gameID)
if err != nil {
return Membership{}, err
}
if !callerIsAdmin {
if !s.canManageMembership(game, membership, callerUserID, allowSelf) {
return Membership{}, fmt.Errorf("%w: caller is not authorised to manage this membership", ErrForbidden)
}
}
now := s.deps.Now().UTC()
updated, err := s.deps.Store.UpdateMembershipStatus(ctx, membershipID, newStatus, now)
if err != nil {
return Membership{}, err
}
s.deps.Cache.PutMembership(updated)
if newStatus != MembershipStatusActive {
// Release the race-name reservation tied to this game.
if err := s.deps.Store.DeleteRaceName(ctx, CanonicalKey(membership.CanonicalKey), gameID); err != nil {
s.deps.Logger.Warn("release race name on membership change failed",
zap.String("membership_id", membershipID.String()),
zap.String("canonical_key", membership.CanonicalKey),
zap.Error(err))
} else {
s.deps.Cache.RemoveRaceName(CanonicalKey(membership.CanonicalKey))
}
}
intent := LobbyNotification{
Kind: notificationKind,
IdempotencyKey: notificationKind + ":" + updated.MembershipID.String(),
Recipients: []uuid.UUID{updated.UserID},
Payload: map[string]any{
"game_id": gameID.String(),
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("membership notification failed",
zap.String("membership_id", updated.MembershipID.String()),
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.
return false
}
if game.OwnerUserID != nil && callerUserID != nil && *game.OwnerUserID == *callerUserID {
return true
}
if allowSelf && callerUserID != nil && membership.UserID == *callerUserID {
return true
}
return false
}