// Package rejectapplication implements the `lobby.application.reject` // message type. It transitions the application from submitted to // rejected, defensively releases any race name reservation held for // the applicant in the game, and publishes the // `lobby.membership.rejected` notification intent. package rejectapplication import ( "context" "errors" "fmt" "log/slog" "time" "galaxy/lobby/internal/domain/application" "galaxy/lobby/internal/domain/common" "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 rejection use case. type Service struct { games ports.GameStore applications ports.ApplicationStore directory ports.RaceNameDirectory intents ports.IntentPublisher 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 rejection intent can carry // the human-readable game_name for display. Games ports.GameStore // Applications applies the submitted → rejected transition. Applications ports.ApplicationStore // Directory releases any reservation held for the applicant in // the game (no-op when none exists). Directory ports.RaceNameDirectory // Intents publishes the lobby.membership.rejected intent. Intents ports.IntentPublisher // Clock supplies the wall-clock used for DecidedAt and the // notification's OccurredAt. Clock func() time.Time // Logger records structured service-level events. Logger *slog.Logger // Telemetry records the `lobby.application.outcomes` counter on // each rejection. 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 reject application service: nil game store") case deps.Applications == nil: return nil, errors.New("new reject application service: nil application store") case deps.Directory == nil: return nil, errors.New("new reject application service: nil race name directory") case deps.Intents == nil: return nil, errors.New("new reject application service: nil intent publisher") } clock := deps.Clock if clock == nil { clock = time.Now } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Service{ games: deps.Games, applications: deps.Applications, directory: deps.Directory, intents: deps.Intents, clock: clock, logger: logger.With("service", "lobby.rejectapplication"), telemetry: deps.Telemetry, }, nil } // Input stores the arguments required to reject one application. type Input struct { // Actor identifies the caller. Must be ActorKindAdmin. 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 state, // transitions the application to rejected, releases any held // reservation, and publishes the lobby.membership.rejected intent on // success. func (service *Service) Handle(ctx context.Context, input Input) (application.Application, error) { if service == nil { return application.Application{}, errors.New("reject application: nil service") } if ctx == nil { return application.Application{}, errors.New("reject application: nil context") } if err := input.Actor.Validate(); err != nil { return application.Application{}, fmt.Errorf("reject application: actor: %w", err) } if !input.Actor.IsAdmin() { return application.Application{}, fmt.Errorf( "%w: only admin callers may reject applications", shared.ErrForbidden, ) } if err := input.GameID.Validate(); err != nil { return application.Application{}, fmt.Errorf("reject application: %w", err) } if err := input.ApplicationID.Validate(); err != nil { return application.Application{}, fmt.Errorf("reject application: %w", err) } app, err := service.applications.Get(ctx, input.ApplicationID) if err != nil { return application.Application{}, fmt.Errorf("reject application: %w", err) } if app.GameID != input.GameID { return application.Application{}, fmt.Errorf( "reject application: application %q does not belong to game %q: %w", app.ApplicationID, input.GameID, application.ErrNotFound, ) } if app.Status != application.StatusSubmitted { return application.Application{}, fmt.Errorf( "reject 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 application.Application{}, fmt.Errorf("reject application: %w", err) } now := service.clock().UTC() if err := service.applications.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{ ApplicationID: app.ApplicationID, ExpectedFrom: application.StatusSubmitted, To: application.StatusRejected, At: now, }); err != nil { return application.Application{}, fmt.Errorf("reject application: %w", err) } if err := service.directory.ReleaseReservation(ctx, gameRecord.GameID.String(), app.ApplicantUserID, app.RaceName); err != nil { // The directory contract states ReleaseReservation is a no-op // for missing / mismatched / invalid records, so a non-nil // error here is unexpected. Log and proceed — the // application is already rejected. service.logger.WarnContext(ctx, "release reservation on reject", "application_id", app.ApplicationID.String(), "err", err.Error(), ) } intent, err := notificationintent.NewLobbyMembershipRejectedIntent( notificationintent.Metadata{ IdempotencyKey: "lobby.membership.rejected:" + app.ApplicationID.String(), OccurredAt: now, }, app.ApplicantUserID, notificationintent.LobbyMembershipRejectedPayload{ GameID: gameRecord.GameID.String(), GameName: gameRecord.GameName, }, ) if err != nil { service.logger.ErrorContext(ctx, "build membership rejected intent", "application_id", app.ApplicationID.String(), "err", err.Error(), ) } else if _, publishErr := service.intents.Publish(ctx, intent); publishErr != nil { service.logger.WarnContext(ctx, "publish membership rejected intent", "application_id", app.ApplicationID.String(), "err", publishErr.Error(), ) } rejected, err := service.applications.Get(ctx, app.ApplicationID) if err != nil { return application.Application{}, fmt.Errorf("reject application: %w", err) } service.telemetry.RecordApplicationOutcome(ctx, "rejected") logArgs := []any{ "game_id", gameRecord.GameID.String(), "game_status", string(gameRecord.Status), "application_id", app.ApplicationID.String(), "user_id", app.ApplicantUserID, } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) service.logger.InfoContext(ctx, "application rejected", logArgs...) return rejected, nil }