161 lines
5.7 KiB
Go
161 lines
5.7 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))
|
|
}
|
|
_ = 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
|
|
}
|