// Package registerracename implements the `lobby.race_name.register` // message type. It converts an active `pending_registration` Race Name // Directory entry, produced by capability evaluation at game // finish, into a permanent registered race name owned by the calling // user. The service enforces the eligibility snapshot // (`PermanentBlocked`, `MaxRegisteredRaceNames`), delegates atomic // commit to `RaceNameDirectory.Register`, and publishes a // `lobby.race_name.registered` notification intent on success. // // The 30-day pending window, capable-finish requirement, and one-name // idempotency are all the directory's responsibility (see // `lobby/README.md` §Reservation lifecycle and capability). This // service only adds the user-facing layer: actor authorization, quota // enforcement, and intent publication. package registerracename import ( "context" "errors" "fmt" "log/slog" "strings" "time" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" "galaxy/notificationintent" ) // Service executes the race-name registration use case. type Service struct { directory ports.RaceNameDirectory users ports.UserService intents ports.IntentPublisher clock func() time.Time logger *slog.Logger } // Dependencies groups the collaborators used by Service. type Dependencies struct { // Directory atomically converts the pending entry into a registered // race name. The service consults ListRegistered for the quota // pre-check and idempotent-retry detection. Directory ports.RaceNameDirectory // Users supplies the lobby-relevant eligibility snapshot used to // gate `PermanentBlocked` and `MaxRegisteredRaceNames`. Users ports.UserService // Intents publishes the lobby.race_name.registered intent on // successful commit. The service treats publication failure as a // notification-degradation signal: the intent is logged at warn but // the registration result is returned successfully (README // §Notification Contracts). Intents ports.IntentPublisher // Clock supplies the wall-clock used to anchor the published // intent's `OccurredAt` and the fallback `RegisteredAtMs` of the // returned record. 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 } // NewService constructs one Service with deps. func NewService(deps Dependencies) (*Service, error) { switch { case deps.Directory == nil: return nil, errors.New("new register race name service: nil race name directory") case deps.Users == nil: return nil, errors.New("new register race name service: nil user service") case deps.Intents == nil: return nil, errors.New("new register race name service: nil intent publisher") } clock := deps.Clock if clock == nil { clock = time.Now } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Service{ directory: deps.Directory, users: deps.Users, intents: deps.Intents, clock: clock, logger: logger.With("service", "lobby.registerracename"), }, nil } // Input stores the arguments required to register one race name. type Input struct { // Actor identifies the caller. Must be ActorKindUser; admin actors // are rejected because the README catalog scopes // `lobby.race_name.register` to the authenticated user. Actor shared.Actor // SourceGameID identifies the game whose capable finish produced // the pending registration that the caller wants to convert. SourceGameID common.GameID // RaceName is the original-casing name to register. The directory // canonicalizes and validates it. RaceName string } // Output stores the post-commit registration record returned to the // caller. Fields mirror ports.RegisteredName so the HTTP layer can // expose the same shape. type Output struct { CanonicalKey string RaceName string SourceGameID string RegisteredAtMs int64 } // Handle authorizes the actor, gates the eligibility snapshot, // short-circuits idempotent retries, enforces the per-tariff quota, // commits the registration through the Race Name Directory, and // publishes the `lobby.race_name.registered` intent on success. func (service *Service) Handle(ctx context.Context, input Input) (Output, error) { if service == nil { return Output{}, errors.New("register race name: nil service") } if ctx == nil { return Output{}, errors.New("register race name: nil context") } if err := input.Actor.Validate(); err != nil { return Output{}, fmt.Errorf("register race name: actor: %w", err) } if !input.Actor.IsUser() { return Output{}, fmt.Errorf( "%w: only authenticated user actors may register a race name", shared.ErrForbidden, ) } if err := input.SourceGameID.Validate(); err != nil { return Output{}, fmt.Errorf("register race name: %w", err) } raceName := strings.TrimSpace(input.RaceName) if raceName == "" { return Output{}, fmt.Errorf("register race name: race name must not be empty") } canonical, err := service.directory.Canonicalize(raceName) if err != nil { return Output{}, fmt.Errorf("register race name: %w", err) } eligibility, err := service.users.GetEligibility(ctx, input.Actor.UserID) if err != nil { return Output{}, fmt.Errorf("register race name: %w", err) } if !eligibility.Exists { return Output{}, fmt.Errorf("register race name: user %q: %w", input.Actor.UserID, shared.ErrSubjectNotFound) } if eligibility.PermanentBlocked { return Output{}, fmt.Errorf( "%w: user %q carries an active permanent_block sanction", shared.ErrForbidden, input.Actor.UserID, ) } registered, err := service.directory.ListRegistered(ctx, input.Actor.UserID) if err != nil { return Output{}, fmt.Errorf("register race name: %w", err) } sourceGameIDStr := input.SourceGameID.String() if existing, ok := findExistingRegistration(registered, sourceGameIDStr, canonical); ok { // Idempotent retry: the same caller is replaying the same // register call after a previous success. Re-publish the // intent (consumer dedupes on the stable idempotency key) and // return the existing record. out := Output{ CanonicalKey: existing.CanonicalKey, RaceName: existing.RaceName, SourceGameID: existing.SourceGameID, RegisteredAtMs: existing.RegisteredAtMs, } service.publishRegisteredIntent(ctx, sourceGameIDStr, input.Actor.UserID, existing.RaceName, out.RegisteredAtMs) return out, nil } if eligibility.MaxRegisteredRaceNames > 0 && len(registered) >= eligibility.MaxRegisteredRaceNames { return Output{}, fmt.Errorf( "register race name: user %q has %d of %d registered: %w", input.Actor.UserID, len(registered), eligibility.MaxRegisteredRaceNames, ports.ErrQuotaExceeded, ) } if err := service.directory.Register(ctx, sourceGameIDStr, input.Actor.UserID, raceName); err != nil { return Output{}, fmt.Errorf("register race name: %w", err) } registeredAfter, err := service.directory.ListRegistered(ctx, input.Actor.UserID) if err != nil { return Output{}, fmt.Errorf("register race name: load post-commit: %w", err) } out := Output{ CanonicalKey: canonical, RaceName: raceName, SourceGameID: sourceGameIDStr, } if record, ok := findExistingRegistration(registeredAfter, sourceGameIDStr, canonical); ok { out.RaceName = record.RaceName out.RegisteredAtMs = record.RegisteredAtMs } else { // Defensive: the directory should always expose the entry we // just registered. Fall back to the wall-clock so the response // still carries a usable timestamp. out.RegisteredAtMs = service.clock().UTC().UnixMilli() } service.publishRegisteredIntent(ctx, sourceGameIDStr, input.Actor.UserID, out.RaceName, out.RegisteredAtMs) service.logger.InfoContext(ctx, "race name registered", "user_id", input.Actor.UserID, "source_game_id", sourceGameIDStr, "race_name", out.RaceName, "canonical_key", out.CanonicalKey, ) return out, nil } // findExistingRegistration returns the registered entry that matches // (sourceGameID, canonicalKey) when one exists. func findExistingRegistration( registered []ports.RegisteredName, sourceGameID, canonical string, ) (ports.RegisteredName, bool) { for _, entry := range registered { if entry.SourceGameID == sourceGameID && entry.CanonicalKey == canonical { return entry, true } } return ports.RegisteredName{}, false } // publishRegisteredIntent builds and publishes the // `lobby.race_name.registered` notification intent. Failures are // logged at warn level and do not roll back the registration commit // (README §Notification Contracts). func (service *Service) publishRegisteredIntent( ctx context.Context, sourceGameID, userID, raceName string, registeredAtMs int64, ) { occurredAt := service.clock().UTC() if registeredAtMs > 0 { occurredAt = time.UnixMilli(registeredAtMs).UTC() } intent, err := notificationintent.NewLobbyRaceNameRegisteredIntent( notificationintent.Metadata{ IdempotencyKey: "lobby.race_name.registered:" + sourceGameID + ":" + userID, OccurredAt: occurredAt, }, userID, notificationintent.LobbyRaceNameRegisteredPayload{ RaceName: raceName, }, ) if err != nil { service.logger.ErrorContext(ctx, "build race name registered intent", "user_id", userID, "source_game_id", sourceGameID, "err", err.Error(), ) return } if _, err := service.intents.Publish(ctx, intent); err != nil { service.logger.WarnContext(ctx, "publish race name registered intent", "user_id", userID, "source_game_id", sourceGameID, "err", err.Error(), ) } }