// Package submitapplication implements the `lobby.application.submit` // message type. It validates the request against the public-game // enrollment rules, calls UserService for eligibility, checks race name // availability through the Race Name Directory, persists the new // submitted application via ApplicationStore, and publishes the // `lobby.application.submitted` notification intent. package submitapplication 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/logging" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" "galaxy/lobby/internal/telemetry" "galaxy/notificationintent" ) // Service executes the public-game application submission use case. type Service struct { games ports.GameStore memberships ports.MembershipStore applications ports.ApplicationStore users ports.UserService directory ports.RaceNameDirectory 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 enrollment validation. Games ports.GameStore // Memberships is consulted for the active-member roster count used // to enforce the max_players + start_gap_players capacity guard. Memberships ports.MembershipStore // Applications persists the new submitted record. Applications ports.ApplicationStore // Users resolves the synchronous eligibility snapshot. Users ports.UserService // Directory checks race name availability. Directory ports.RaceNameDirectory // Intents publishes the lobby.application.submitted intent. Intents ports.IntentPublisher // IDs mints the new opaque application identifier. IDs ports.IDGenerator // Clock supplies the wall-clock used for CreatedAt and the // notification's OccurredAt. 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.application.outcomes` counter on each // successful submission. 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 submit application service: nil game store") case deps.Memberships == nil: return nil, errors.New("new submit application service: nil membership store") case deps.Applications == nil: return nil, errors.New("new submit application service: nil application store") case deps.Users == nil: return nil, errors.New("new submit application service: nil user service") case deps.Directory == nil: return nil, errors.New("new submit application service: nil race name directory") case deps.Intents == nil: return nil, errors.New("new submit application service: nil intent publisher") case deps.IDs == nil: return nil, errors.New("new submit 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, users: deps.Users, directory: deps.Directory, intents: deps.Intents, ids: deps.IDs, clock: clock, logger: logger.With("service", "lobby.submitapplication"), telemetry: deps.Telemetry, }, nil } // Input stores the arguments required to submit one application. type Input struct { // Actor identifies the caller. Must be ActorKindUser. Actor shared.Actor // GameID identifies the target game. GameID common.GameID // RaceName stores the desired in-game name in original casing. RaceName string } // Handle validates input, authorizes the actor, verifies eligibility and // race name availability, persists the new submitted application, and // publishes the lobby.application.submitted intent on success. func (service *Service) Handle(ctx context.Context, input Input) (application.Application, error) { if service == nil { return application.Application{}, errors.New("submit application: nil service") } if ctx == nil { return application.Application{}, errors.New("submit application: nil context") } if err := input.Actor.Validate(); err != nil { return application.Application{}, fmt.Errorf("submit application: actor: %w", err) } if !input.Actor.IsUser() { return application.Application{}, fmt.Errorf( "%w: only authenticated users may submit applications", shared.ErrForbidden, ) } if err := input.GameID.Validate(); err != nil { return application.Application{}, fmt.Errorf("submit application: %w", err) } gameRecord, err := service.games.Get(ctx, input.GameID) if err != nil { return application.Application{}, fmt.Errorf("submit application: %w", err) } if gameRecord.GameType != game.GameTypePublic { return application.Application{}, fmt.Errorf( "submit application: game %q is not public: %w", gameRecord.GameID, game.ErrConflict, ) } if gameRecord.Status != game.StatusEnrollmentOpen { return application.Application{}, fmt.Errorf( "submit application: game %q is not in enrollment_open: %w", gameRecord.GameID, game.ErrConflict, ) } eligibility, err := service.users.GetEligibility(ctx, input.Actor.UserID) if err != nil { return application.Application{}, fmt.Errorf( "submit application: %w: %w", shared.ErrServiceUnavailable, err, ) } if !eligibility.Exists || !eligibility.CanJoinGame { return application.Application{}, fmt.Errorf( "%w: user is not eligible to join games", shared.ErrEligibilityDenied, ) } if err := service.checkRosterCapacity(ctx, gameRecord); err != nil { return application.Application{}, err } availability, err := service.directory.Check(ctx, input.RaceName, input.Actor.UserID) if err != nil { return application.Application{}, fmt.Errorf("submit application: %w", err) } if availability.Taken { return application.Application{}, fmt.Errorf( "submit application: race name held by another user: %w", ports.ErrNameTaken, ) } applicationID, err := service.ids.NewApplicationID() if err != nil { return application.Application{}, fmt.Errorf("submit application: %w", err) } now := service.clock().UTC() record, err := application.New(application.NewApplicationInput{ ApplicationID: applicationID, GameID: input.GameID, ApplicantUserID: input.Actor.UserID, RaceName: input.RaceName, Now: now, }) if err != nil { return application.Application{}, fmt.Errorf("submit application: %w", err) } if err := service.applications.Save(ctx, record); err != nil { return application.Application{}, fmt.Errorf("submit application: %w", err) } intent, err := notificationintent.NewPublicLobbyApplicationSubmittedIntent( notificationintent.Metadata{ IdempotencyKey: "lobby.application.submitted:" + record.ApplicationID.String(), OccurredAt: now, }, notificationintent.LobbyApplicationSubmittedPayload{ GameID: gameRecord.GameID.String(), GameName: gameRecord.GameName, ApplicantUserID: record.ApplicantUserID, ApplicantName: record.RaceName, }, ) if err != nil { // Building the intent failed: this is a programmer error // (mismatched payload contract), not a transport failure. // Log and proceed — the application is already committed. service.logger.ErrorContext(ctx, "build application submitted intent", "application_id", record.ApplicationID.String(), "err", err.Error(), ) } else if _, publishErr := service.intents.Publish(ctx, intent); publishErr != nil { // Notification degradation per README §Notification Contracts: // do not roll back business state. service.logger.WarnContext(ctx, "publish application submitted intent", "application_id", record.ApplicationID.String(), "err", publishErr.Error(), ) } service.telemetry.RecordApplicationOutcome(ctx, "submitted") logArgs := []any{ "game_id", record.GameID.String(), "game_type", string(gameRecord.GameType), "game_status", string(gameRecord.Status), "application_id", record.ApplicationID.String(), "user_id", record.ApplicantUserID, "race_name", record.RaceName, } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) service.logger.InfoContext(ctx, "application submitted", logArgs...) return record, nil } // checkRosterCapacity counts active memberships for gameRecord and // returns game.ErrConflict when the roster has reached the gap-window // upper bound (max_players + start_gap_players). func (service *Service) checkRosterCapacity(ctx context.Context, gameRecord game.Game) error { cap := gameRecord.MaxPlayers + gameRecord.StartGapPlayers count, err := shared.CountActiveMemberships(ctx, service.memberships, gameRecord.GameID) if err != nil { return fmt.Errorf("submit application: %w", err) } if count >= cap { return fmt.Errorf( "submit application: roster full (%d active >= %d cap): %w", count, cap, game.ErrConflict, ) } return nil }