package lobby import ( "context" "fmt" "github.com/google/uuid" "go.uber.org/zap" ) // SubmitApplicationInput is the parameter struct for // Service.SubmitApplication. type SubmitApplicationInput struct { GameID uuid.UUID ApplicantUserID uuid.UUID RaceName string } // SubmitApplication creates a new application bound to (gameID, // applicantUserID, raceName). The game must be `enrollment_open`. The // race name is recorded for context but the per-game canonical // reservation is created at approval time. func (s *Service) SubmitApplication(ctx context.Context, in SubmitApplicationInput) (Application, error) { displayName, err := ValidateDisplayName(in.RaceName) if err != nil { return Application{}, err } game, err := s.GetGame(ctx, in.GameID) if err != nil { return Application{}, err } if game.Visibility != VisibilityPublic { return Application{}, fmt.Errorf("%w: only public games accept applications", ErrConflict) } if game.Status != GameStatusEnrollmentOpen { return Application{}, fmt.Errorf("%w: game is not in enrollment_open", ErrConflict) } app, err := s.deps.Store.InsertApplication(ctx, applicationInsert{ ApplicationID: uuid.New(), GameID: in.GameID, ApplicantUserID: in.ApplicantUserID, RaceName: displayName, }) if err != nil { return Application{}, err } intent := LobbyNotification{ Kind: NotificationLobbyApplicationSubmitted, IdempotencyKey: "application:" + app.ApplicationID.String(), Payload: map[string]any{ "game_id": game.GameID.String(), "application_id": app.ApplicationID.String(), }, } if game.OwnerUserID != nil { intent.Recipients = []uuid.UUID{*game.OwnerUserID} } if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil { // Notification failures never roll back the canonical write. s.deps.Logger.Warn("application submitted notification failed", zap.String("application_id", app.ApplicationID.String()), zap.Error(pubErr)) } return app, nil } // ApproveApplication transitions a pending application to `approved`, // creates the matching membership, and reserves the race-name canonical // in the Race Name Directory. func (s *Service) ApproveApplication(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, applicationID uuid.UUID) (Application, error) { app, err := s.deps.Store.LoadApplication(ctx, applicationID) if err != nil { return Application{}, err } if app.GameID != gameID { return Application{}, ErrNotFound } game, err := s.GetGame(ctx, gameID) if err != nil { return Application{}, err } if err := s.checkGameAdminOrOwner(game, callerUserID, callerIsAdmin); err != nil { return Application{}, err } if app.Status != ApplicationStatusPending { return Application{}, fmt.Errorf("%w: application status is %q", ErrConflict, app.Status) } if game.Status != GameStatusEnrollmentOpen { return Application{}, fmt.Errorf("%w: game is not in enrollment_open", ErrConflict) } canonical, err := s.deps.Policy.Canonical(app.RaceName) if err != nil { return Application{}, err } if err := s.assertRaceNameAvailable(ctx, canonical, app.ApplicantUserID, gameID); err != nil { return Application{}, err } now := s.deps.Now().UTC() if _, err := s.deps.Store.InsertRaceName(ctx, raceNameInsert{ Name: app.RaceName, Canonical: canonical, Status: RaceNameStatusReservation, OwnerUserID: app.ApplicantUserID, GameID: gameID, ReservedAt: &now, }); err != nil { return Application{}, err } membership, err := s.deps.Store.InsertMembership(ctx, membershipInsert{ MembershipID: uuid.New(), GameID: gameID, UserID: app.ApplicantUserID, RaceName: app.RaceName, CanonicalKey: canonical, }) if err != nil { // Best-effort cleanup of the race-name reservation if the // membership insert lost the race; the cascade still records // the rejection. _ = s.deps.Store.DeleteRaceName(ctx, canonical, gameID) return Application{}, err } updated, err := s.deps.Store.UpdateApplicationStatus(ctx, applicationID, ApplicationStatusApproved, now) if err != nil { return Application{}, err } s.deps.Cache.PutMembership(membership) s.deps.Cache.PutRaceName(RaceNameEntry{ Name: app.RaceName, Canonical: canonical, Status: RaceNameStatusReservation, OwnerUserID: app.ApplicantUserID, GameID: gameID, ReservedAt: &now, }) intent := LobbyNotification{ Kind: NotificationLobbyApplicationApproved, IdempotencyKey: "application-approved:" + applicationID.String(), Recipients: []uuid.UUID{app.ApplicantUserID}, Payload: map[string]any{ "game_id": gameID.String(), }, } if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil { s.deps.Logger.Warn("application approved notification failed", zap.String("application_id", updated.ApplicationID.String()), zap.Error(pubErr)) } return updated, nil } // RejectApplication transitions a pending application to `rejected`. func (s *Service) RejectApplication(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, applicationID uuid.UUID) (Application, error) { app, err := s.deps.Store.LoadApplication(ctx, applicationID) if err != nil { return Application{}, err } if app.GameID != gameID { return Application{}, ErrNotFound } game, err := s.GetGame(ctx, gameID) if err != nil { return Application{}, err } if err := s.checkGameAdminOrOwner(game, callerUserID, callerIsAdmin); err != nil { return Application{}, err } if app.Status != ApplicationStatusPending { return Application{}, fmt.Errorf("%w: application status is %q", ErrConflict, app.Status) } now := s.deps.Now().UTC() updated, err := s.deps.Store.UpdateApplicationStatus(ctx, applicationID, ApplicationStatusRejected, now) if err != nil { return Application{}, err } intent := LobbyNotification{ Kind: NotificationLobbyApplicationRejected, IdempotencyKey: "application-rejected:" + applicationID.String(), Recipients: []uuid.UUID{app.ApplicantUserID}, Payload: map[string]any{ "game_id": gameID.String(), }, } if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil { s.deps.Logger.Warn("application rejected notification failed", zap.String("application_id", updated.ApplicationID.String()), zap.Error(pubErr)) } return updated, nil } // ListMyApplications returns every application owned by userID. func (s *Service) ListMyApplications(ctx context.Context, userID uuid.UUID) ([]Application, error) { return s.deps.Store.ListMyApplications(ctx, userID) } // checkGameAdminOrOwner enforces that the caller is either an admin or // (for private games) the owner. Public games admin-only — same rule as // transition(). func (s *Service) checkGameAdminOrOwner(game GameRecord, callerUserID *uuid.UUID, callerIsAdmin bool) error { return s.checkOwner(game, callerUserID, callerIsAdmin) } // assertRaceNameAvailable returns nil when canonical is free for // userID inside gameID. Free means: no `registered` / `reservation` / // `pending_registration` owned by anyone else. func (s *Service) assertRaceNameAvailable(ctx context.Context, canonical CanonicalKey, userID, gameID uuid.UUID) error { _ = gameID rows, err := s.deps.Store.FindRaceNameByCanonical(ctx, canonical) if err != nil { return err } for _, r := range rows { if r.OwnerUserID == userID { // Same user already binds this canonical — the per-game PK // handles same-game collisions, and a user is allowed to // hold the same canonical across multiple active games. continue } switch r.Status { case RaceNameStatusRegistered, RaceNameStatusReservation, RaceNameStatusPendingRegistration: return fmt.Errorf("%w: race name held by another user", ErrRaceNameTaken) } } return nil }