284 lines
9.4 KiB
Go
284 lines
9.4 KiB
Go
// 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(),
|
|
)
|
|
}
|
|
}
|