// Package approveapplication implements the `lobby.application.approve` // message type. It reserves the applicant's race name, transitions the // application from submitted to approved, creates the matching active // membership, opens the gap window when the new approved count equals // max_players, and publishes the lobby.membership.approved intent. package approveapplication import ( "context" "errors" "fmt" "log/slog" "time" "galaxy/lobby/internal/domain/application" "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" "galaxy/notificationintent" ) // Service executes the public-game application approval use case. type Service struct { games ports.GameStore memberships ports.MembershipStore applications ports.ApplicationStore directory ports.RaceNameDirectory gapStore ports.GapActivationStore intents ports.IntentPublisher ids ports.IDGenerator clock func() time.Time logger *slog.Logger telemetry *telemetry.Runtime } // Dependencies groups the collaborators used by Service. type Dependencies struct { // Games loads the target game record for status verification. Games ports.GameStore // Memberships persists the new active membership and supplies the // approved-count for the gap-window decision. Memberships ports.MembershipStore // Applications loads the target application and applies the // submitted → approved transition. Applications ports.ApplicationStore // Directory canonicalizes the race name and reserves it for the // applicant. Directory ports.RaceNameDirectory // GapStore records the gap-window activation timestamp when the // new approved count equals max_players. GapStore ports.GapActivationStore // Intents publishes the lobby.membership.approved intent. Intents ports.IntentPublisher // IDs mints the new opaque membership identifier. IDs ports.IDGenerator // Clock supplies the wall-clock used for DecidedAt, JoinedAt, // gap-activation time, and the notification's OccurredAt. Clock func() time.Time // Logger records structured service-level events. Logger *slog.Logger // Telemetry records the `lobby.application.outcomes` and // `lobby.membership.changes` counters. 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 approve application service: nil game store") case deps.Memberships == nil: return nil, errors.New("new approve application service: nil membership store") case deps.Applications == nil: return nil, errors.New("new approve application service: nil application store") case deps.Directory == nil: return nil, errors.New("new approve application service: nil race name directory") case deps.GapStore == nil: return nil, errors.New("new approve application service: nil gap activation store") case deps.Intents == nil: return nil, errors.New("new approve application service: nil intent publisher") case deps.IDs == nil: return nil, errors.New("new approve application service: nil id generator") } 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, applications: deps.Applications, directory: deps.Directory, gapStore: deps.GapStore, intents: deps.Intents, ids: deps.IDs, clock: clock, logger: logger.With("service", "lobby.approveapplication"), telemetry: deps.Telemetry, }, nil } // Input stores the arguments required to approve one application. type Input struct { // Actor identifies the caller. Must be ActorKindAdmin per the // README message catalog. Actor shared.Actor // GameID identifies the game referenced by the request path; it // must match the loaded application's GameID. GameID common.GameID // ApplicationID identifies the target application. ApplicationID common.ApplicationID } // Handle authorizes the actor, validates the application + game state, // reserves the race name, transitions the application, persists the // membership, opens the gap window if appropriate, and publishes the // lobby.membership.approved intent on success. func (service *Service) Handle(ctx context.Context, input Input) (membership.Membership, error) { if service == nil { return membership.Membership{}, errors.New("approve application: nil service") } if ctx == nil { return membership.Membership{}, errors.New("approve application: nil context") } if err := input.Actor.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("approve application: actor: %w", err) } if !input.Actor.IsAdmin() { return membership.Membership{}, fmt.Errorf( "%w: only admin callers may approve applications", shared.ErrForbidden, ) } if err := input.GameID.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("approve application: %w", err) } if err := input.ApplicationID.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("approve application: %w", err) } app, err := service.applications.Get(ctx, input.ApplicationID) if err != nil { return membership.Membership{}, fmt.Errorf("approve application: %w", err) } if app.GameID != input.GameID { // Defensive: opaque ids must not leak across games. return membership.Membership{}, fmt.Errorf( "approve application: application %q does not belong to game %q: %w", app.ApplicationID, input.GameID, application.ErrNotFound, ) } if app.Status != application.StatusSubmitted { return membership.Membership{}, fmt.Errorf( "approve application: status %q is not %q: %w", app.Status, application.StatusSubmitted, application.ErrConflict, ) } gameRecord, err := service.games.Get(ctx, input.GameID) if err != nil { return membership.Membership{}, fmt.Errorf("approve application: %w", err) } if gameRecord.GameType != game.GameTypePublic { return membership.Membership{}, fmt.Errorf( "approve application: game %q is not public: %w", gameRecord.GameID, game.ErrConflict, ) } if gameRecord.Status != game.StatusEnrollmentOpen { return membership.Membership{}, fmt.Errorf( "approve application: game %q is not in enrollment_open: %w", gameRecord.GameID, game.ErrConflict, ) } cap := gameRecord.MaxPlayers + gameRecord.StartGapPlayers currentActive, err := shared.CountActiveMemberships(ctx, service.memberships, gameRecord.GameID) if err != nil { return membership.Membership{}, fmt.Errorf("approve application: %w", err) } if currentActive >= cap { return membership.Membership{}, fmt.Errorf( "approve application: roster full (%d active >= %d cap): %w", currentActive, cap, game.ErrConflict, ) } canonical, err := service.directory.Canonicalize(app.RaceName) if err != nil { return membership.Membership{}, fmt.Errorf("approve application: %w", err) } if err := service.directory.Reserve(ctx, gameRecord.GameID.String(), app.ApplicantUserID, app.RaceName); err != nil { return membership.Membership{}, fmt.Errorf("approve application: %w", err) } now := service.clock().UTC() updateErr := service.applications.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{ ApplicationID: app.ApplicationID, ExpectedFrom: application.StatusSubmitted, To: application.StatusApproved, At: now, }) if updateErr != nil { // Concurrent mutation lost the CAS race. Release the // reservation we just made so it does not orphan; the user // may retry submission later under the same canonical key. if releaseErr := service.directory.ReleaseReservation(ctx, gameRecord.GameID.String(), app.ApplicantUserID, app.RaceName); releaseErr != nil { service.logger.WarnContext(ctx, "release reservation after approve cas failure", "application_id", app.ApplicationID.String(), "err", releaseErr.Error(), ) } return membership.Membership{}, fmt.Errorf("approve application: %w", updateErr) } membershipID, err := service.ids.NewMembershipID() if err != nil { return membership.Membership{}, fmt.Errorf("approve application: %w", err) } memberRecord, err := membership.New(membership.NewMembershipInput{ MembershipID: membershipID, GameID: gameRecord.GameID, UserID: app.ApplicantUserID, RaceName: app.RaceName, CanonicalKey: canonical, Now: now, }) if err != nil { return membership.Membership{}, fmt.Errorf("approve application: %w", err) } if err := service.memberships.Save(ctx, memberRecord); err != nil { return membership.Membership{}, fmt.Errorf("approve application: %w", err) } // Gap-window trigger: count after Save now includes the new // membership, so if it equals max_players we just crossed the // threshold. The worker reads gap_activated_at to time // out the gap; auto-transition to ready_to_start is its job. if currentActive+1 == gameRecord.MaxPlayers { if err := service.gapStore.MarkActivated(ctx, gameRecord.GameID, now); err != nil { service.logger.WarnContext(ctx, "mark gap activation", "game_id", gameRecord.GameID.String(), "err", err.Error(), ) } } intent, err := notificationintent.NewLobbyMembershipApprovedIntent( notificationintent.Metadata{ IdempotencyKey: "lobby.membership.approved:" + memberRecord.MembershipID.String(), OccurredAt: now, }, memberRecord.UserID, notificationintent.LobbyMembershipApprovedPayload{ GameID: gameRecord.GameID.String(), GameName: gameRecord.GameName, }, ) if err != nil { service.logger.ErrorContext(ctx, "build membership approved intent", "membership_id", memberRecord.MembershipID.String(), "err", err.Error(), ) } else if _, publishErr := service.intents.Publish(ctx, intent); publishErr != nil { service.logger.WarnContext(ctx, "publish membership approved intent", "membership_id", memberRecord.MembershipID.String(), "err", publishErr.Error(), ) } service.telemetry.RecordApplicationOutcome(ctx, "approved") service.telemetry.RecordMembershipChange(ctx, "activated") logArgs := []any{ "game_id", gameRecord.GameID.String(), "game_status", string(gameRecord.Status), "application_id", app.ApplicationID.String(), "membership_id", memberRecord.MembershipID.String(), "user_id", memberRecord.UserID, "race_name", memberRecord.RaceName, "canonical_key", string(canonical), } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) service.logger.InfoContext(ctx, "application approved", logArgs...) return memberRecord, nil }