package lobby import ( "context" "errors" "fmt" "github.com/google/uuid" "go.uber.org/zap" ) // RegisterRaceName promotes a `pending_registration` row owned by // userID into a `registered` row. The promotion succeeds when: // // - the user has a `pending_registration` row matching the supplied // display name (canonical key); // - the row is still inside its 30-day window (expires_at > now); // - the user owns fewer than `entitlement.max_registered_race_names` // `registered` rows. func (s *Service) RegisterRaceName(ctx context.Context, userID uuid.UUID, displayName string) (RaceNameEntry, error) { displayName, err := ValidateDisplayName(displayName) if err != nil { return RaceNameEntry{}, err } canonical, err := s.deps.Policy.Canonical(displayName) if err != nil { return RaceNameEntry{}, err } rows, err := s.deps.Store.FindRaceNameByCanonical(ctx, canonical) if err != nil { return RaceNameEntry{}, err } var pending *RaceNameEntry for i := range rows { row := rows[i] if row.OwnerUserID != userID { if row.Status == RaceNameStatusRegistered || row.Status == RaceNameStatusReservation || row.Status == RaceNameStatusPendingRegistration { return RaceNameEntry{}, fmt.Errorf("%w: race name held by another user", ErrRaceNameTaken) } continue } if row.Status == RaceNameStatusRegistered { return RaceNameEntry{}, fmt.Errorf("%w: race name already registered by caller", ErrConflict) } if row.Status == RaceNameStatusPendingRegistration { pending = &rows[i] } } if pending == nil { return RaceNameEntry{}, fmt.Errorf("%w: no pending_registration row for caller", ErrNotFound) } now := s.deps.Now().UTC() if pending.ExpiresAt != nil && !pending.ExpiresAt.After(now) { return RaceNameEntry{}, fmt.Errorf("%w: pending_registration window closed at %s", ErrPendingExpired, pending.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z07:00")) } maxAllowed := int32(1) if s.deps.Entitlement != nil { got, eerr := s.deps.Entitlement.GetMaxRegisteredRaceNames(ctx, userID) if eerr != nil { return RaceNameEntry{}, fmt.Errorf("lobby: read entitlement: %w", eerr) } maxAllowed = got } currentCount, err := s.deps.Store.CountRegisteredRaceNamesByUser(ctx, userID) if err != nil { return RaceNameEntry{}, err } if int32(currentCount) >= maxAllowed { return RaceNameEntry{}, fmt.Errorf("%w: %d registered race names of %d allowed", ErrEntitlementExceeded, currentCount, maxAllowed) } entry, err := s.deps.Store.PromotePendingToRegistered(ctx, canonical, userID, pending.GameID, displayName, now) if err != nil { if errors.Is(err, ErrNotFound) { return RaceNameEntry{}, fmt.Errorf("%w: pending row vanished concurrently", ErrConflict) } return RaceNameEntry{}, err } s.deps.Cache.RemoveRaceName(canonical) s.deps.Cache.PutRaceName(entry) intent := LobbyNotification{ Kind: NotificationLobbyRaceNameRegistered, IdempotencyKey: "racename-registered:" + string(canonical), Recipients: []uuid.UUID{userID}, Payload: map[string]any{ "race_name": displayName, }, } if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil { s.deps.Logger.Warn("race-name registered notification failed", zap.String("canonical", string(canonical)), zap.Error(pubErr)) } return entry, nil } // ListMyRaceNames returns every race-name row owned by userID. func (s *Service) ListMyRaceNames(ctx context.Context, userID uuid.UUID) ([]RaceNameEntry, error) { return s.deps.Store.ListRaceNamesForUser(ctx, userID) }