// Package listmemberships implements the `lobby.memberships.list` and // the internal-port admin/GM mirror endpoints. It returns every // membership of one game, paginated, after enforcing the access rule // frozen in `lobby/README.md` §«Trusted Surfaces»: admin, owner, or // active member of the game. Admin actors bypass the access check, so // the same service serves the internal-port `internalListMemberships` // and `adminListMemberships` routes by passing `shared.NewAdminActor()`. package listmemberships import ( "context" "errors" "fmt" "log/slog" "sort" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" ) // Service executes the list-memberships use case. type Service struct { games ports.GameStore memberships ports.MembershipStore logger *slog.Logger } // Dependencies groups the collaborators used by Service. type Dependencies struct { // Games is loaded to authorize private-game owners and to surface // game.ErrNotFound when the requested game does not exist. Games ports.GameStore // Memberships supplies the GetByGame index for the response and the // GetByUser index used to confirm the active-member access path. Memberships ports.MembershipStore // 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 list memberships service: nil game store") case deps.Memberships == nil: return nil, errors.New("new list memberships service: nil membership store") } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Service{ games: deps.Games, memberships: deps.Memberships, logger: logger.With("service", "lobby.listmemberships"), }, nil } // Input stores the arguments required to list memberships of one game. type Input struct { // Actor identifies the caller. Admin callers bypass the access // check; user callers must be the private-game owner or an active // member of the game. Actor shared.Actor // GameID identifies the target game record. GameID common.GameID // Page stores the pagination request. Page shared.Page } // Output stores the page returned by Handle. type Output struct { // Items stores the membership records included in the current page. Items []membership.Membership // NextPageToken stores the opaque continuation token; empty when // the current page is the last one. NextPageToken string } // Handle authorizes the actor, loads the membership list, and returns // the requested page. It returns game.ErrNotFound when the game does // not exist, and shared.ErrForbidden when a user actor is not the // owner and holds no active membership in the game. func (service *Service) Handle(ctx context.Context, input Input) (Output, error) { if service == nil { return Output{}, errors.New("list memberships: nil service") } if ctx == nil { return Output{}, errors.New("list memberships: nil context") } if err := input.Actor.Validate(); err != nil { return Output{}, fmt.Errorf("list memberships: actor: %w", err) } if err := input.GameID.Validate(); err != nil { return Output{}, fmt.Errorf("list memberships: %w", err) } record, err := service.games.Get(ctx, input.GameID) if err != nil { return Output{}, fmt.Errorf("list memberships: %w", err) } if !input.Actor.IsAdmin() { if err := service.authorizeUser(ctx, input.Actor.UserID, record); err != nil { return Output{}, err } } items, err := service.memberships.GetByGame(ctx, input.GameID) if err != nil { return Output{}, fmt.Errorf("list memberships: %w", err) } sortMemberships(items) start, end, nextOffset, hasMore := shared.Window(len(items), input.Page) out := Output{Items: append([]membership.Membership(nil), items[start:end]...)} if hasMore { out.NextPageToken = shared.EncodeToken(nextOffset) } return out, nil } // authorizeUser permits the caller when they own the game or hold an // active membership in it. Otherwise it returns shared.ErrForbidden. func (service *Service) authorizeUser( ctx context.Context, userID string, record game.Game, ) error { if record.GameType == game.GameTypePrivate && record.OwnerUserID == userID { return nil } memberships, err := service.memberships.GetByUser(ctx, userID) if err != nil { return err } for _, mb := range memberships { if mb.GameID == record.GameID && mb.Status == membership.StatusActive { return nil } } return fmt.Errorf( "%w: actor is not authorized to list memberships of game %q", shared.ErrForbidden, record.GameID.String(), ) } // sortMemberships sorts items by JoinedAt ascending with MembershipID // as the tiebreaker so the wire output is stable for tests and clients. func sortMemberships(items []membership.Membership) { sort.Slice(items, func(i, j int) bool { if !items[i].JoinedAt.Equal(items[j].JoinedAt) { return items[i].JoinedAt.Before(items[j].JoinedAt) } return items[i].MembershipID < items[j].MembershipID }) }