// Package getgame implements the `lobby.game.get` message type. It loads // one game record and enforces the visibility rules frozen in // `lobby/README.md` §«User-Facing Lists»: // // - admin actors see every record without filtering; // - public non-draft games are visible to any authenticated user; // - public draft games are visible to admin only; // - private draft games are visible to the owner only; // - private non-draft games are visible to the owner, to users with an // active membership in the game, or to users with a non-expired // invite (`invite.StatusCreated`). // // The service returns the full domain record so the transport layer can // serialize the denormalized runtime snapshot already attached to it // (`game.Game.RuntimeSnapshot` / `RuntimeBinding`); no extra fan-out // to Game Master is required. package getgame import ( "context" "errors" "fmt" "log/slog" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/invite" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" ) // Service executes the get-game use case. type Service struct { games ports.GameStore memberships ports.MembershipStore invites ports.InviteStore logger *slog.Logger } // Dependencies groups the collaborators used by Service. type Dependencies struct { // Games returns the game record and provides the source of truth for // game type, owner, and status used by the visibility filter. Games ports.GameStore // Memberships is consulted only for user actors against private // non-draft games to confirm the actor holds an active membership in // the requested game. Memberships ports.MembershipStore // Invites is consulted only for user actors against private non-draft // games to confirm the actor holds an invite in `created` status. Invites ports.InviteStore // Logger records structured 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.Games == nil: return nil, errors.New("new get game service: nil game store") case deps.Memberships == nil: return nil, errors.New("new get game service: nil membership store") case deps.Invites == nil: return nil, errors.New("new get game service: nil invite store") } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Service{ games: deps.Games, memberships: deps.Memberships, invites: deps.Invites, logger: logger.With("service", "lobby.getgame"), }, nil } // Input stores the arguments required to load one game record. type Input struct { // Actor identifies the caller. Admin actors bypass the visibility // filter; user actors are subject to the README §«User-Facing Lists» // rules. Actor shared.Actor // GameID identifies the target game record. GameID common.GameID } // Handle authorizes the actor, loads the record, and applies the // visibility filter. It returns game.ErrNotFound when no record exists // for input.GameID, and shared.ErrForbidden when the actor is not // allowed to see the record. func (service *Service) Handle(ctx context.Context, input Input) (game.Game, error) { if service == nil { return game.Game{}, errors.New("get game: nil service") } if ctx == nil { return game.Game{}, errors.New("get game: nil context") } if err := input.Actor.Validate(); err != nil { return game.Game{}, fmt.Errorf("get game: actor: %w", err) } if err := input.GameID.Validate(); err != nil { return game.Game{}, fmt.Errorf("get game: %w", err) } record, err := service.games.Get(ctx, input.GameID) if err != nil { return game.Game{}, fmt.Errorf("get game: %w", err) } if input.Actor.IsAdmin() { return record, nil } allowed, err := service.userCanSee(ctx, input.Actor.UserID, record) if err != nil { return game.Game{}, fmt.Errorf("get game: %w", err) } if !allowed { return game.Game{}, fmt.Errorf( "%w: actor is not authorized to view game %q", shared.ErrForbidden, record.GameID.String(), ) } return record, nil } // userCanSee returns true when the user-actor identified by userID is // permitted to see the supplied record per README §«User-Facing Lists». func (service *Service) userCanSee( ctx context.Context, userID string, record game.Game, ) (bool, error) { switch record.GameType { case game.GameTypePublic: return record.Status != game.StatusDraft, nil case game.GameTypePrivate: if record.OwnerUserID == userID { return true, nil } if record.Status == game.StatusDraft { return false, nil } hasMembership, err := service.userHasActiveMembership(ctx, userID, record.GameID) if err != nil { return false, err } if hasMembership { return true, nil } return service.userHasOpenInvite(ctx, userID, record.GameID) default: return false, nil } } func (service *Service) userHasActiveMembership( ctx context.Context, userID string, gameID common.GameID, ) (bool, error) { records, err := service.memberships.GetByUser(ctx, userID) if err != nil { return false, err } for _, record := range records { if record.GameID == gameID && record.Status == membership.StatusActive { return true, nil } } return false, nil } func (service *Service) userHasOpenInvite( ctx context.Context, userID string, gameID common.GameID, ) (bool, error) { records, err := service.invites.GetByUser(ctx, userID) if err != nil { return false, err } for _, record := range records { if record.GameID == gameID && record.Status == invite.StatusCreated { return true, nil } } return false, nil }