251 lines
7.7 KiB
Go
251 lines
7.7 KiB
Go
// Package listmyracenames implements the `lobby.race_names.list`
|
|
// message type. It returns the acting user's view of the Race Name
|
|
// Directory across all three levels of binding: permanent registered
|
|
// names, pending_registration entries waiting for their 30-day window
|
|
// to elapse, and active per-game reservations.
|
|
//
|
|
// The service is intentionally thin: it consults the user-keyed RND
|
|
// indexes through the port (no full scan of the directory) and joins
|
|
// each active reservation with the current `game.Status` from the
|
|
// Game Store so callers can render the reservation alongside the
|
|
// game it belongs to. No User Service eligibility lookup is required —
|
|
// reading one's own name set is unconditional.
|
|
package listmyracenames
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
|
|
"galaxy/lobby/internal/domain/common"
|
|
"galaxy/lobby/internal/domain/game"
|
|
"galaxy/lobby/internal/ports"
|
|
"galaxy/lobby/internal/service/shared"
|
|
)
|
|
|
|
// Service executes the race-name self-service read use case.
|
|
type Service struct {
|
|
directory ports.RaceNameDirectory
|
|
games ports.GameStore
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// Dependencies groups the collaborators used by Service.
|
|
type Dependencies struct {
|
|
// Directory exposes the user-keyed RND indexes (registered,
|
|
// pending_registration, reservation) that satisfy the no-full-scan
|
|
// requirement of `lobby.race_names.list`.
|
|
Directory ports.RaceNameDirectory
|
|
|
|
// Games supplies the current `game.Status` of each active
|
|
// reservation so the response can render it next to the
|
|
// game id (README §Race Name self-service).
|
|
Games ports.GameStore
|
|
|
|
// Logger records structural diagnostics. 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 list my race names service: nil race name directory")
|
|
case deps.Games == nil:
|
|
return nil, errors.New("new list my race names service: nil game store")
|
|
}
|
|
logger := deps.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &Service{
|
|
directory: deps.Directory,
|
|
games: deps.Games,
|
|
logger: logger.With("service", "lobby.listmyracenames"),
|
|
}, nil
|
|
}
|
|
|
|
// Input stores the arguments required to compute the acting user's
|
|
// race-name view.
|
|
type Input struct {
|
|
// Actor identifies the caller. Must be ActorKindUser; the
|
|
// `lobby.race_names.list` route is exclusively self-service and
|
|
// admin actors are rejected.
|
|
Actor shared.Actor
|
|
}
|
|
|
|
// RegisteredName describes one permanent race name owned by the
|
|
// acting user. Mirrors `ports.RegisteredName` field-for-field so the
|
|
// transport can return them as-is.
|
|
type RegisteredName struct {
|
|
CanonicalKey string
|
|
RaceName string
|
|
SourceGameID string
|
|
RegisteredAtMs int64
|
|
}
|
|
|
|
// PendingRegistration describes one promoted reservation waiting for
|
|
// the acting user to convert it into a registered name.
|
|
type PendingRegistration struct {
|
|
CanonicalKey string
|
|
RaceName string
|
|
SourceGameID string
|
|
ReservedAtMs int64
|
|
EligibleUntilMs int64
|
|
}
|
|
|
|
// Reservation describes one active per-game reservation owned by the
|
|
// acting user. GameStatus carries the current `game.Status` of the
|
|
// hosting game (empty when the game record could not be loaded).
|
|
type Reservation struct {
|
|
CanonicalKey string
|
|
RaceName string
|
|
GameID string
|
|
ReservedAtMs int64
|
|
GameStatus string
|
|
}
|
|
|
|
// Output stores the three slices returned by Handle. Each slice is
|
|
// non-nil but possibly empty. Items inside one slice are ordered by
|
|
// the relevant timestamp ascending, then by canonical key, so output
|
|
// is stable for tests and UI alike.
|
|
type Output struct {
|
|
Registered []RegisteredName
|
|
Pending []PendingRegistration
|
|
Reservations []Reservation
|
|
}
|
|
|
|
// Handle authorizes the actor as a user, fetches the three RND views
|
|
// for that user, and joins each reservation with the current game
|
|
// status.
|
|
func (service *Service) Handle(ctx context.Context, input Input) (Output, error) {
|
|
if service == nil {
|
|
return Output{}, errors.New("list my race names: nil service")
|
|
}
|
|
if ctx == nil {
|
|
return Output{}, errors.New("list my race names: nil context")
|
|
}
|
|
if err := input.Actor.Validate(); err != nil {
|
|
return Output{}, fmt.Errorf("list my race names: actor: %w", err)
|
|
}
|
|
if !input.Actor.IsUser() {
|
|
return Output{}, fmt.Errorf(
|
|
"%w: only authenticated user actors may list their race names",
|
|
shared.ErrForbidden,
|
|
)
|
|
}
|
|
|
|
registered, err := service.directory.ListRegistered(ctx, input.Actor.UserID)
|
|
if err != nil {
|
|
return Output{}, fmt.Errorf("list my race names: %w", err)
|
|
}
|
|
pending, err := service.directory.ListPendingRegistrations(ctx, input.Actor.UserID)
|
|
if err != nil {
|
|
return Output{}, fmt.Errorf("list my race names: %w", err)
|
|
}
|
|
reservations, err := service.directory.ListReservations(ctx, input.Actor.UserID)
|
|
if err != nil {
|
|
return Output{}, fmt.Errorf("list my race names: %w", err)
|
|
}
|
|
|
|
out := Output{
|
|
Registered: make([]RegisteredName, 0, len(registered)),
|
|
Pending: make([]PendingRegistration, 0, len(pending)),
|
|
Reservations: make([]Reservation, 0, len(reservations)),
|
|
}
|
|
for _, entry := range registered {
|
|
out.Registered = append(out.Registered, RegisteredName{
|
|
CanonicalKey: entry.CanonicalKey,
|
|
RaceName: entry.RaceName,
|
|
SourceGameID: entry.SourceGameID,
|
|
RegisteredAtMs: entry.RegisteredAtMs,
|
|
})
|
|
}
|
|
for _, entry := range pending {
|
|
out.Pending = append(out.Pending, PendingRegistration{
|
|
CanonicalKey: entry.CanonicalKey,
|
|
RaceName: entry.RaceName,
|
|
SourceGameID: entry.GameID,
|
|
ReservedAtMs: entry.ReservedAtMs,
|
|
EligibleUntilMs: entry.EligibleUntilMs,
|
|
})
|
|
}
|
|
for _, entry := range reservations {
|
|
status := service.lookupGameStatus(ctx, common.GameID(entry.GameID), input.Actor.UserID)
|
|
out.Reservations = append(out.Reservations, Reservation{
|
|
CanonicalKey: entry.CanonicalKey,
|
|
RaceName: entry.RaceName,
|
|
GameID: entry.GameID,
|
|
ReservedAtMs: entry.ReservedAtMs,
|
|
GameStatus: status,
|
|
})
|
|
}
|
|
|
|
sortRegistered(out.Registered)
|
|
sortPending(out.Pending)
|
|
sortReservations(out.Reservations)
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// lookupGameStatus returns the current game status for gameID. When
|
|
// the game record cannot be loaded — including the defensive
|
|
// game.ErrNotFound case — the empty string is returned and the miss
|
|
// is logged so operators can investigate dangling RND state.
|
|
func (service *Service) lookupGameStatus(
|
|
ctx context.Context,
|
|
gameID common.GameID,
|
|
userID string,
|
|
) string {
|
|
record, err := service.games.Get(ctx, gameID)
|
|
if err == nil {
|
|
return string(record.Status)
|
|
}
|
|
level := slog.LevelWarn
|
|
if errors.Is(err, game.ErrNotFound) {
|
|
service.logger.Log(ctx, level,
|
|
"reservation references missing game",
|
|
"user_id", userID,
|
|
"game_id", gameID.String(),
|
|
)
|
|
return ""
|
|
}
|
|
service.logger.Log(ctx, level,
|
|
"load reservation game status",
|
|
"user_id", userID,
|
|
"game_id", gameID.String(),
|
|
"err", err.Error(),
|
|
)
|
|
return ""
|
|
}
|
|
|
|
func sortRegistered(items []RegisteredName) {
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].RegisteredAtMs != items[j].RegisteredAtMs {
|
|
return items[i].RegisteredAtMs < items[j].RegisteredAtMs
|
|
}
|
|
return items[i].CanonicalKey < items[j].CanonicalKey
|
|
})
|
|
}
|
|
|
|
func sortPending(items []PendingRegistration) {
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].EligibleUntilMs != items[j].EligibleUntilMs {
|
|
return items[i].EligibleUntilMs < items[j].EligibleUntilMs
|
|
}
|
|
return items[i].CanonicalKey < items[j].CanonicalKey
|
|
})
|
|
}
|
|
|
|
func sortReservations(items []Reservation) {
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].ReservedAtMs != items[j].ReservedAtMs {
|
|
return items[i].ReservedAtMs < items[j].ReservedAtMs
|
|
}
|
|
return items[i].CanonicalKey < items[j].CanonicalKey
|
|
})
|
|
}
|