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)) } _ = 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)) } return updated, nil } 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 }