505 lines
14 KiB
Go
505 lines
14 KiB
Go
// Package adminusers implements the trusted administrative user-read surface
|
|
// owned by User Service.
|
|
package adminusers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"galaxy/user/internal/domain/common"
|
|
"galaxy/user/internal/domain/entitlement"
|
|
"galaxy/user/internal/domain/policy"
|
|
"galaxy/user/internal/ports"
|
|
"galaxy/user/internal/service/accountview"
|
|
"galaxy/user/internal/service/shared"
|
|
)
|
|
|
|
// LookupResult stores one exact trusted admin user lookup result.
|
|
type LookupResult struct {
|
|
// User stores the shared account aggregate of the resolved user.
|
|
User accountview.AccountView `json:"user"`
|
|
}
|
|
|
|
// GetUserByIDInput stores one exact trusted lookup by stable user identifier.
|
|
type GetUserByIDInput struct {
|
|
// UserID stores the stable regular-user identifier to resolve.
|
|
UserID string
|
|
}
|
|
|
|
// GetUserByEmailInput stores one exact trusted lookup by normalized e-mail.
|
|
type GetUserByEmailInput struct {
|
|
// Email stores the normalized login/contact e-mail to resolve.
|
|
Email string
|
|
}
|
|
|
|
// GetUserByRaceNameInput stores one exact trusted lookup by exact stored race
|
|
// name.
|
|
type GetUserByRaceNameInput struct {
|
|
// RaceName stores the exact current race name to resolve.
|
|
RaceName string
|
|
}
|
|
|
|
// ListUsersInput stores one trusted administrative user-list request.
|
|
type ListUsersInput struct {
|
|
// PageSize stores the requested maximum number of returned users. The zero
|
|
// value selects the frozen default page size.
|
|
PageSize int
|
|
|
|
// PageToken stores the optional opaque continuation cursor.
|
|
PageToken string
|
|
|
|
// PaidState stores the optional coarse free-versus-paid filter.
|
|
PaidState string
|
|
|
|
// PaidExpiresBefore stores the optional strict finite paid-expiry upper
|
|
// bound.
|
|
PaidExpiresBefore *time.Time
|
|
|
|
// PaidExpiresAfter stores the optional strict finite paid-expiry lower
|
|
// bound.
|
|
PaidExpiresAfter *time.Time
|
|
|
|
// DeclaredCountry stores the optional current declared-country filter.
|
|
DeclaredCountry string
|
|
|
|
// SanctionCode stores the optional active-sanction filter.
|
|
SanctionCode string
|
|
|
|
// LimitCode stores the optional active user-specific limit filter.
|
|
LimitCode string
|
|
|
|
// CanLogin stores the optional derived login-eligibility filter.
|
|
CanLogin *bool
|
|
|
|
// CanCreatePrivateGame stores the optional derived private-game-create
|
|
// eligibility filter.
|
|
CanCreatePrivateGame *bool
|
|
|
|
// CanJoinGame stores the optional derived game-join eligibility filter.
|
|
CanJoinGame *bool
|
|
}
|
|
|
|
// ListUsersResult stores one trusted administrative page of user aggregates.
|
|
type ListUsersResult struct {
|
|
// Items stores the returned user aggregates in deterministic order.
|
|
Items []accountview.AccountView `json:"items"`
|
|
|
|
// NextPageToken stores the optional continuation cursor for the next page.
|
|
NextPageToken string `json:"next_page_token,omitempty"`
|
|
}
|
|
|
|
type entitlementReader interface {
|
|
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
|
|
}
|
|
|
|
type readSupport struct {
|
|
accounts ports.UserAccountStore
|
|
loader *accountview.Loader
|
|
}
|
|
|
|
func newReadSupport(
|
|
accounts ports.UserAccountStore,
|
|
entitlements entitlementReader,
|
|
sanctions ports.SanctionStore,
|
|
limits ports.LimitStore,
|
|
clock ports.Clock,
|
|
) (readSupport, error) {
|
|
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
|
|
if err != nil {
|
|
return readSupport{}, fmt.Errorf("account view loader: %w", err)
|
|
}
|
|
|
|
return readSupport{
|
|
accounts: accounts,
|
|
loader: loader,
|
|
}, nil
|
|
}
|
|
|
|
// ByIDGetter executes exact trusted lookups by stable user identifier.
|
|
type ByIDGetter struct {
|
|
support readSupport
|
|
}
|
|
|
|
// NewByIDGetter constructs one exact admin lookup by user id.
|
|
func NewByIDGetter(
|
|
accounts ports.UserAccountStore,
|
|
entitlements entitlementReader,
|
|
sanctions ports.SanctionStore,
|
|
limits ports.LimitStore,
|
|
clock ports.Clock,
|
|
) (*ByIDGetter, error) {
|
|
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("admin users by-id getter: %w", err)
|
|
}
|
|
|
|
return &ByIDGetter{support: support}, nil
|
|
}
|
|
|
|
// Execute resolves one exact user by stable user identifier.
|
|
func (service *ByIDGetter) Execute(ctx context.Context, input GetUserByIDInput) (LookupResult, error) {
|
|
if ctx == nil {
|
|
return LookupResult{}, shared.InvalidRequest("context must not be nil")
|
|
}
|
|
|
|
userID, err := shared.ParseUserID(input.UserID)
|
|
if err != nil {
|
|
return LookupResult{}, err
|
|
}
|
|
|
|
aggregate, err := service.support.loader.Load(ctx, userID)
|
|
if err != nil {
|
|
return LookupResult{}, err
|
|
}
|
|
|
|
return LookupResult{User: aggregate.View()}, nil
|
|
}
|
|
|
|
// ByEmailGetter executes exact trusted lookups by normalized e-mail.
|
|
type ByEmailGetter struct {
|
|
support readSupport
|
|
}
|
|
|
|
// NewByEmailGetter constructs one exact admin lookup by normalized e-mail.
|
|
func NewByEmailGetter(
|
|
accounts ports.UserAccountStore,
|
|
entitlements entitlementReader,
|
|
sanctions ports.SanctionStore,
|
|
limits ports.LimitStore,
|
|
clock ports.Clock,
|
|
) (*ByEmailGetter, error) {
|
|
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("admin users by-email getter: %w", err)
|
|
}
|
|
|
|
return &ByEmailGetter{support: support}, nil
|
|
}
|
|
|
|
// Execute resolves one exact user by normalized e-mail.
|
|
func (service *ByEmailGetter) Execute(ctx context.Context, input GetUserByEmailInput) (LookupResult, error) {
|
|
if ctx == nil {
|
|
return LookupResult{}, shared.InvalidRequest("context must not be nil")
|
|
}
|
|
|
|
email, err := shared.ParseEmail(input.Email)
|
|
if err != nil {
|
|
return LookupResult{}, err
|
|
}
|
|
|
|
record, err := service.support.accounts.GetByEmail(ctx, email)
|
|
switch {
|
|
case err == nil:
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return LookupResult{}, shared.SubjectNotFound()
|
|
default:
|
|
return LookupResult{}, shared.ServiceUnavailable(err)
|
|
}
|
|
|
|
aggregate, err := service.support.loader.Load(ctx, record.UserID)
|
|
if err != nil {
|
|
return LookupResult{}, err
|
|
}
|
|
|
|
return LookupResult{User: aggregate.View()}, nil
|
|
}
|
|
|
|
// ByRaceNameGetter executes exact trusted lookups by exact stored race name.
|
|
type ByRaceNameGetter struct {
|
|
support readSupport
|
|
}
|
|
|
|
// NewByRaceNameGetter constructs one exact admin lookup by exact stored race
|
|
// name.
|
|
func NewByRaceNameGetter(
|
|
accounts ports.UserAccountStore,
|
|
entitlements entitlementReader,
|
|
sanctions ports.SanctionStore,
|
|
limits ports.LimitStore,
|
|
clock ports.Clock,
|
|
) (*ByRaceNameGetter, error) {
|
|
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("admin users by-race-name getter: %w", err)
|
|
}
|
|
|
|
return &ByRaceNameGetter{support: support}, nil
|
|
}
|
|
|
|
// Execute resolves one exact user by exact stored race name.
|
|
func (service *ByRaceNameGetter) Execute(ctx context.Context, input GetUserByRaceNameInput) (LookupResult, error) {
|
|
if ctx == nil {
|
|
return LookupResult{}, shared.InvalidRequest("context must not be nil")
|
|
}
|
|
|
|
raceName, err := shared.ParseRaceName(input.RaceName)
|
|
if err != nil {
|
|
return LookupResult{}, err
|
|
}
|
|
|
|
record, err := service.support.accounts.GetByRaceName(ctx, raceName)
|
|
switch {
|
|
case err == nil:
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return LookupResult{}, shared.SubjectNotFound()
|
|
default:
|
|
return LookupResult{}, shared.ServiceUnavailable(err)
|
|
}
|
|
|
|
aggregate, err := service.support.loader.Load(ctx, record.UserID)
|
|
if err != nil {
|
|
return LookupResult{}, err
|
|
}
|
|
|
|
return LookupResult{User: aggregate.View()}, nil
|
|
}
|
|
|
|
// Lister executes the trusted administrative filtered user listing.
|
|
type Lister struct {
|
|
support readSupport
|
|
listStore ports.UserListStore
|
|
}
|
|
|
|
// NewLister constructs one trusted administrative filtered user lister.
|
|
func NewLister(
|
|
accounts ports.UserAccountStore,
|
|
entitlements entitlementReader,
|
|
sanctions ports.SanctionStore,
|
|
limits ports.LimitStore,
|
|
clock ports.Clock,
|
|
listStore ports.UserListStore,
|
|
) (*Lister, error) {
|
|
if listStore == nil {
|
|
return nil, fmt.Errorf("admin users lister: user list store must not be nil")
|
|
}
|
|
|
|
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("admin users lister: %w", err)
|
|
}
|
|
|
|
return &Lister{
|
|
support: support,
|
|
listStore: listStore,
|
|
}, nil
|
|
}
|
|
|
|
// Execute lists users in deterministic newest-first order and combines all
|
|
// supplied filters with logical AND semantics.
|
|
func (service *Lister) Execute(ctx context.Context, input ListUsersInput) (ListUsersResult, error) {
|
|
if ctx == nil {
|
|
return ListUsersResult{}, shared.InvalidRequest("context must not be nil")
|
|
}
|
|
if strings.TrimSpace(input.PageToken) != input.PageToken {
|
|
return ListUsersResult{}, shared.InvalidRequest("page_token must not contain surrounding whitespace")
|
|
}
|
|
|
|
pageSize, err := normalizePageSize(input.PageSize)
|
|
if err != nil {
|
|
return ListUsersResult{}, err
|
|
}
|
|
filters, err := parseListFilters(input)
|
|
if err != nil {
|
|
return ListUsersResult{}, err
|
|
}
|
|
|
|
result := ListUsersResult{
|
|
Items: make([]accountview.AccountView, 0, pageSize),
|
|
}
|
|
currentToken := input.PageToken
|
|
|
|
for len(result.Items) < pageSize {
|
|
candidatePage, err := service.listStore.ListUserIDs(ctx, ports.ListUsersInput{
|
|
PageSize: 1,
|
|
PageToken: currentToken,
|
|
Filters: filters,
|
|
})
|
|
switch {
|
|
case err == nil:
|
|
case errors.Is(err, ports.ErrInvalidPageToken):
|
|
return ListUsersResult{}, shared.InvalidRequest("page_token is invalid or does not match current filters")
|
|
default:
|
|
return ListUsersResult{}, shared.ServiceUnavailable(err)
|
|
}
|
|
if len(candidatePage.UserIDs) == 0 {
|
|
result.NextPageToken = ""
|
|
return result, nil
|
|
}
|
|
|
|
nextToken := candidatePage.NextPageToken
|
|
candidateID := candidatePage.UserIDs[0]
|
|
|
|
aggregate, err := service.support.loader.Load(ctx, candidateID)
|
|
if err != nil {
|
|
return ListUsersResult{}, err
|
|
}
|
|
if matchesFilters(aggregate, filters) {
|
|
result.Items = append(result.Items, aggregate.View())
|
|
result.NextPageToken = nextToken
|
|
}
|
|
|
|
if nextToken == "" {
|
|
result.NextPageToken = ""
|
|
return result, nil
|
|
}
|
|
|
|
currentToken = nextToken
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func normalizePageSize(value int) (int, error) {
|
|
switch {
|
|
case value == 0:
|
|
return ports.DefaultUserListPageSize, nil
|
|
case value < 0:
|
|
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
|
|
case value > ports.MaxUserListPageSize:
|
|
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
|
|
default:
|
|
return value, nil
|
|
}
|
|
}
|
|
|
|
func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
|
|
paidState, err := parsePaidState(input.PaidState)
|
|
if err != nil {
|
|
return ports.UserListFilters{}, err
|
|
}
|
|
declaredCountry, err := parseCountryCode(input.DeclaredCountry)
|
|
if err != nil {
|
|
return ports.UserListFilters{}, err
|
|
}
|
|
sanctionCode, err := parseSanctionCode(input.SanctionCode)
|
|
if err != nil {
|
|
return ports.UserListFilters{}, err
|
|
}
|
|
limitCode, err := parseLimitCode(input.LimitCode)
|
|
if err != nil {
|
|
return ports.UserListFilters{}, err
|
|
}
|
|
|
|
filters := ports.UserListFilters{
|
|
PaidState: paidState,
|
|
PaidExpiresBefore: input.PaidExpiresBefore,
|
|
PaidExpiresAfter: input.PaidExpiresAfter,
|
|
DeclaredCountry: declaredCountry,
|
|
SanctionCode: sanctionCode,
|
|
LimitCode: limitCode,
|
|
CanLogin: input.CanLogin,
|
|
CanCreatePrivateGame: input.CanCreatePrivateGame,
|
|
CanJoinGame: input.CanJoinGame,
|
|
}
|
|
if err := filters.Validate(); err != nil {
|
|
return ports.UserListFilters{}, shared.InvalidRequest(err.Error())
|
|
}
|
|
|
|
return filters, nil
|
|
}
|
|
|
|
func parsePaidState(value string) (entitlement.PaidState, error) {
|
|
state := entitlement.PaidState(shared.NormalizeString(value))
|
|
if !state.IsKnown() {
|
|
return "", shared.InvalidRequest(fmt.Sprintf("paid_state %q is unsupported", state))
|
|
}
|
|
|
|
return state, nil
|
|
}
|
|
|
|
func parseCountryCode(value string) (common.CountryCode, error) {
|
|
code := common.CountryCode(shared.NormalizeString(value))
|
|
if code.IsZero() {
|
|
return "", nil
|
|
}
|
|
if err := code.Validate(); err != nil {
|
|
return "", shared.InvalidRequest(fmt.Sprintf("declared_country: %s", err.Error()))
|
|
}
|
|
|
|
return code, nil
|
|
}
|
|
|
|
func parseSanctionCode(value string) (policy.SanctionCode, error) {
|
|
code := policy.SanctionCode(shared.NormalizeString(value))
|
|
if code == "" {
|
|
return "", nil
|
|
}
|
|
if !code.IsKnown() {
|
|
return "", shared.InvalidRequest(fmt.Sprintf("sanction_code %q is unsupported", code))
|
|
}
|
|
|
|
return code, nil
|
|
}
|
|
|
|
func parseLimitCode(value string) (policy.LimitCode, error) {
|
|
code := policy.LimitCode(shared.NormalizeString(value))
|
|
if code == "" {
|
|
return "", nil
|
|
}
|
|
if !code.IsKnown() {
|
|
return "", shared.InvalidRequest(fmt.Sprintf("limit_code %q is unsupported", code))
|
|
}
|
|
|
|
return code, nil
|
|
}
|
|
|
|
func matchesFilters(aggregate accountview.Aggregate, filters ports.UserListFilters) bool {
|
|
switch filters.PaidState {
|
|
case entitlement.PaidStateFree:
|
|
if aggregate.EntitlementSnapshot.IsPaid {
|
|
return false
|
|
}
|
|
case entitlement.PaidStatePaid:
|
|
if !aggregate.EntitlementSnapshot.IsPaid {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if filters.PaidExpiresBefore != nil {
|
|
if !aggregate.EntitlementSnapshot.HasFiniteExpiry() || !aggregate.EntitlementSnapshot.EndsAt.Before(filters.PaidExpiresBefore.UTC()) {
|
|
return false
|
|
}
|
|
}
|
|
if filters.PaidExpiresAfter != nil {
|
|
if !aggregate.EntitlementSnapshot.HasFiniteExpiry() || !aggregate.EntitlementSnapshot.EndsAt.After(filters.PaidExpiresAfter.UTC()) {
|
|
return false
|
|
}
|
|
}
|
|
if !filters.DeclaredCountry.IsZero() && aggregate.AccountRecord.DeclaredCountry != filters.DeclaredCountry {
|
|
return false
|
|
}
|
|
if filters.SanctionCode != "" && !aggregate.HasActiveSanction(filters.SanctionCode) {
|
|
return false
|
|
}
|
|
if filters.LimitCode != "" && !aggregate.HasActiveLimit(filters.LimitCode) {
|
|
return false
|
|
}
|
|
|
|
canLogin, canCreatePrivateGame, canJoinGame := deriveFilterEligibility(aggregate)
|
|
if filters.CanLogin != nil && canLogin != *filters.CanLogin {
|
|
return false
|
|
}
|
|
if filters.CanCreatePrivateGame != nil && canCreatePrivateGame != *filters.CanCreatePrivateGame {
|
|
return false
|
|
}
|
|
if filters.CanJoinGame != nil && canJoinGame != *filters.CanJoinGame {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func deriveFilterEligibility(aggregate accountview.Aggregate) (bool, bool, bool) {
|
|
canLogin := !aggregate.HasActiveSanction(policy.SanctionCodeLoginBlock)
|
|
canCreatePrivateGame := canLogin &&
|
|
aggregate.EntitlementSnapshot.IsPaid &&
|
|
!aggregate.HasActiveSanction(policy.SanctionCodePrivateGameCreateBlock)
|
|
canJoinGame := canLogin &&
|
|
!aggregate.HasActiveSanction(policy.SanctionCodeGameJoinBlock)
|
|
|
|
return canLogin, canCreatePrivateGame, canJoinGame
|
|
}
|