// Package blockmember implements the `lobby.membership.block` message // type. It transitions an active membership to `blocked` while the // game is still on a non-terminal status. Race name reservations are // preserved; the capability evaluator at game finish // resolves them alongside `removed` memberships. package blockmember import ( "context" "errors" "fmt" "log/slog" "time" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/logging" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" "galaxy/lobby/internal/telemetry" ) // blockableGameStatuses enumerates the game statuses in which an // active membership may be blocked. Block is allowed in both pre- and // post-start statuses (README §Membership Model status table records // `active → blocked` without a post-start qualifier). Terminal game // statuses are rejected because no further roster mutation makes // sense after `finished`/`cancelled` and `draft` carries no // memberships at all. var blockableGameStatuses = map[game.Status]struct{}{ game.StatusEnrollmentOpen: {}, game.StatusReadyToStart: {}, game.StatusStarting: {}, game.StatusStartFailed: {}, game.StatusRunning: {}, game.StatusPaused: {}, } // Service executes the block-member use case. type Service struct { games ports.GameStore memberships ports.MembershipStore clock func() time.Time logger *slog.Logger telemetry *telemetry.Runtime } // Dependencies groups the collaborators used by Service. type Dependencies struct { // Games loads the game record so the service can authorize the // actor against the owner and gate the operation by game status. Games ports.GameStore // Memberships persists the active → blocked transition. Memberships ports.MembershipStore // Clock supplies the wall-clock used for the RemovedAt stamp. // Defaults to time.Now when nil. Clock func() time.Time // Logger records structured service-level events. Defaults to // slog.Default when nil. Logger *slog.Logger // Telemetry records the `lobby.membership.changes` counter on each // successful block. Optional; nil disables metric emission. Telemetry *telemetry.Runtime } // NewService constructs one Service with deps. func NewService(deps Dependencies) (*Service, error) { switch { case deps.Games == nil: return nil, errors.New("new block member service: nil game store") case deps.Memberships == nil: return nil, errors.New("new block member service: nil membership store") } clock := deps.Clock if clock == nil { clock = time.Now } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Service{ games: deps.Games, memberships: deps.Memberships, clock: clock, logger: logger.With("service", "lobby.blockmember"), telemetry: deps.Telemetry, }, nil } // Input stores the arguments required to block one membership. type Input struct { // Actor identifies the caller. Actor shared.Actor // GameID identifies the game referenced by the request path; it // must match the loaded membership's GameID. GameID common.GameID // MembershipID identifies the target membership. MembershipID common.MembershipID } // Handle authorizes the actor, asserts the membership belongs to the // game and is currently active, transitions the membership to // `blocked`, and returns the post-transition record. The race name // reservation is intentionally preserved. func (service *Service) Handle(ctx context.Context, input Input) (membership.Membership, error) { if service == nil { return membership.Membership{}, errors.New("block member: nil service") } if ctx == nil { return membership.Membership{}, errors.New("block member: nil context") } if err := input.Actor.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("block member: actor: %w", err) } if err := input.GameID.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("block member: %w", err) } if err := input.MembershipID.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("block member: %w", err) } member, err := service.memberships.Get(ctx, input.MembershipID) if err != nil { return membership.Membership{}, fmt.Errorf("block member: %w", err) } if member.GameID != input.GameID { return membership.Membership{}, fmt.Errorf( "block member: membership %q does not belong to game %q: %w", member.MembershipID, input.GameID, membership.ErrNotFound, ) } gameRecord, err := service.games.Get(ctx, member.GameID) if err != nil { return membership.Membership{}, fmt.Errorf("block member: %w", err) } if err := authorize(input.Actor, gameRecord); err != nil { return membership.Membership{}, err } if _, ok := blockableGameStatuses[gameRecord.Status]; !ok { return membership.Membership{}, fmt.Errorf( "block member: game status %q does not allow block: %w", gameRecord.Status, game.ErrConflict, ) } if member.Status != membership.StatusActive { return membership.Membership{}, fmt.Errorf( "block member: status %q is not %q: %w", member.Status, membership.StatusActive, membership.ErrConflict, ) } now := service.clock().UTC() if err := service.memberships.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{ MembershipID: member.MembershipID, ExpectedFrom: membership.StatusActive, To: membership.StatusBlocked, At: now, }); err != nil { return membership.Membership{}, fmt.Errorf("block member: %w", err) } updated, err := service.memberships.Get(ctx, member.MembershipID) if err != nil { return membership.Membership{}, fmt.Errorf("block member: %w", err) } service.telemetry.RecordMembershipChange(ctx, "blocked") logArgs := []any{ "game_id", gameRecord.GameID.String(), "game_status", string(gameRecord.Status), "membership_id", member.MembershipID.String(), "user_id", member.UserID, "actor_kind", string(input.Actor.Kind), } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) service.logger.InfoContext(ctx, "member blocked", logArgs...) return updated, nil } // authorize enforces admin OR private-owner access to the record. func authorize(actor shared.Actor, record game.Game) error { if actor.IsAdmin() { return nil } if record.GameType == game.GameTypePrivate && actor.UserID == record.OwnerUserID { return nil } return fmt.Errorf("%w: actor is not authorized to block members of game %q", shared.ErrForbidden, record.GameID.String()) }