// Package accountview materializes the shared account aggregate view used by // self-service and trusted administrative reads. package accountview import ( "context" "errors" "fmt" "time" "galaxy/user/internal/domain/account" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/domain/policy" "galaxy/user/internal/ports" "galaxy/user/internal/service/shared" ) // ActorRefView stores transport-ready audit actor metadata. type ActorRefView struct { // Type stores the machine-readable actor type. Type string `json:"type"` // ID stores the optional stable actor identifier. ID string `json:"id,omitempty"` } // EntitlementSnapshotView stores the transport-ready current entitlement // snapshot of one account. type EntitlementSnapshotView struct { // PlanCode stores the effective entitlement plan code. PlanCode string `json:"plan_code"` // IsPaid reports whether the effective plan is paid. IsPaid bool `json:"is_paid"` // Source stores the machine-readable mutation source. Source string `json:"source"` // Actor stores the audit actor metadata attached to the snapshot. Actor ActorRefView `json:"actor"` // ReasonCode stores the machine-readable reason attached to the snapshot. ReasonCode string `json:"reason_code"` // StartsAt stores when the effective state started. StartsAt time.Time `json:"starts_at"` // EndsAt stores the optional finite effective expiry. EndsAt *time.Time `json:"ends_at,omitempty"` // UpdatedAt stores when the snapshot was last recomputed. UpdatedAt time.Time `json:"updated_at"` } // ActiveSanctionView stores one transport-ready active sanction. type ActiveSanctionView struct { // SanctionCode stores the active sanction code. SanctionCode string `json:"sanction_code"` // Scope stores the machine-readable sanction scope. Scope string `json:"scope"` // ReasonCode stores the machine-readable sanction reason. ReasonCode string `json:"reason_code"` // Actor stores the audit actor metadata attached to the sanction. Actor ActorRefView `json:"actor"` // AppliedAt stores when the sanction became active. AppliedAt time.Time `json:"applied_at"` // ExpiresAt stores the optional planned sanction expiry. ExpiresAt *time.Time `json:"expires_at,omitempty"` } // ActiveLimitView stores one transport-ready active user-specific limit. type ActiveLimitView struct { // LimitCode stores the active limit code. LimitCode string `json:"limit_code"` // Value stores the current override value. Value int `json:"value"` // ReasonCode stores the machine-readable limit reason. ReasonCode string `json:"reason_code"` // Actor stores the audit actor metadata attached to the limit. Actor ActorRefView `json:"actor"` // AppliedAt stores when the limit became active. AppliedAt time.Time `json:"applied_at"` // ExpiresAt stores the optional planned limit expiry. ExpiresAt *time.Time `json:"expires_at,omitempty"` } // AccountView stores the transport-ready account aggregate shared by // self-service and admin reads. type AccountView struct { // UserID stores the durable regular-user identifier. UserID string `json:"user_id"` // Email stores the exact normalized login e-mail address. Email string `json:"email"` // UserName stores the immutable `player-` handle assigned at // account creation. UserName string `json:"user_name"` // DisplayName stores the current optional free-text user label. An empty // value indicates no display name is set. DisplayName string `json:"display_name,omitempty"` // PreferredLanguage stores the current BCP 47 preferred language. PreferredLanguage string `json:"preferred_language"` // TimeZone stores the current IANA time-zone name. TimeZone string `json:"time_zone"` // DeclaredCountry stores the optional latest effective declared country. DeclaredCountry string `json:"declared_country,omitempty"` // Entitlement stores the current entitlement snapshot. Entitlement EntitlementSnapshotView `json:"entitlement"` // ActiveSanctions stores the current active sanctions sorted by code. ActiveSanctions []ActiveSanctionView `json:"active_sanctions"` // ActiveLimits stores the current active user-specific limits sorted by // code. ActiveLimits []ActiveLimitView `json:"active_limits"` // CreatedAt stores when the account was created. CreatedAt time.Time `json:"created_at"` // UpdatedAt stores when the account was last mutated. UpdatedAt time.Time `json:"updated_at"` } // Aggregate stores the raw domain state that backs one shared account view. type Aggregate struct { // AccountRecord stores the current editable account record. AccountRecord account.UserAccount // EntitlementSnapshot stores the current effective entitlement snapshot. EntitlementSnapshot entitlement.CurrentSnapshot // ActiveSanctions stores the active sanctions sorted by code. ActiveSanctions []policy.SanctionRecord // ActiveLimits stores the active user-specific limits sorted by code. ActiveLimits []policy.LimitRecord } // HasActiveSanction reports whether aggregate currently contains code in its // active sanction set. func (aggregate Aggregate) HasActiveSanction(code policy.SanctionCode) bool { for _, record := range aggregate.ActiveSanctions { if record.SanctionCode == code { return true } } return false } // HasActiveLimit reports whether aggregate currently contains code in its // active user-specific limit set. func (aggregate Aggregate) HasActiveLimit(code policy.LimitCode) bool { for _, record := range aggregate.ActiveLimits { if record.LimitCode == code { return true } } return false } // View materializes Aggregate into the shared transport-ready account view. func (aggregate Aggregate) View() AccountView { view := AccountView{ UserID: aggregate.AccountRecord.UserID.String(), Email: aggregate.AccountRecord.Email.String(), UserName: aggregate.AccountRecord.UserName.String(), DisplayName: aggregate.AccountRecord.DisplayName.String(), PreferredLanguage: aggregate.AccountRecord.PreferredLanguage.String(), TimeZone: aggregate.AccountRecord.TimeZone.String(), Entitlement: EntitlementSnapshotView{ PlanCode: string(aggregate.EntitlementSnapshot.PlanCode), IsPaid: aggregate.EntitlementSnapshot.IsPaid, Source: aggregate.EntitlementSnapshot.Source.String(), Actor: actorRefView(aggregate.EntitlementSnapshot.Actor), ReasonCode: aggregate.EntitlementSnapshot.ReasonCode.String(), StartsAt: aggregate.EntitlementSnapshot.StartsAt.UTC(), EndsAt: cloneOptionalTime(aggregate.EntitlementSnapshot.EndsAt), UpdatedAt: aggregate.EntitlementSnapshot.UpdatedAt.UTC(), }, ActiveSanctions: make([]ActiveSanctionView, 0, len(aggregate.ActiveSanctions)), ActiveLimits: make([]ActiveLimitView, 0, len(aggregate.ActiveLimits)), CreatedAt: aggregate.AccountRecord.CreatedAt.UTC(), UpdatedAt: aggregate.AccountRecord.UpdatedAt.UTC(), } if !aggregate.AccountRecord.DeclaredCountry.IsZero() { view.DeclaredCountry = aggregate.AccountRecord.DeclaredCountry.String() } for _, sanctionRecord := range aggregate.ActiveSanctions { view.ActiveSanctions = append(view.ActiveSanctions, ActiveSanctionView{ SanctionCode: string(sanctionRecord.SanctionCode), Scope: sanctionRecord.Scope.String(), ReasonCode: sanctionRecord.ReasonCode.String(), Actor: actorRefView(sanctionRecord.Actor), AppliedAt: sanctionRecord.AppliedAt.UTC(), ExpiresAt: cloneOptionalTime(sanctionRecord.ExpiresAt), }) } for _, limitRecord := range aggregate.ActiveLimits { view.ActiveLimits = append(view.ActiveLimits, ActiveLimitView{ LimitCode: string(limitRecord.LimitCode), Value: limitRecord.Value, ReasonCode: limitRecord.ReasonCode.String(), Actor: actorRefView(limitRecord.Actor), AppliedAt: limitRecord.AppliedAt.UTC(), ExpiresAt: cloneOptionalTime(limitRecord.ExpiresAt), }) } return view } type entitlementReader interface { GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) } // Loader materializes the shared current account aggregate for one user id. type Loader struct { accounts ports.UserAccountStore entitlements entitlementReader sanctions ports.SanctionStore limits ports.LimitStore clock ports.Clock } // NewLoader constructs one shared account-aggregate loader. func NewLoader( accounts ports.UserAccountStore, entitlements entitlementReader, sanctions ports.SanctionStore, limits ports.LimitStore, clock ports.Clock, ) (*Loader, error) { switch { case accounts == nil: return nil, fmt.Errorf("account view loader: user account store must not be nil") case entitlements == nil: return nil, fmt.Errorf("account view loader: entitlement reader must not be nil") case sanctions == nil: return nil, fmt.Errorf("account view loader: sanction store must not be nil") case limits == nil: return nil, fmt.Errorf("account view loader: limit store must not be nil") case clock == nil: return nil, fmt.Errorf("account view loader: clock must not be nil") default: return &Loader{ accounts: accounts, entitlements: entitlements, sanctions: sanctions, limits: limits, clock: clock, }, nil } } // Load materializes the shared account aggregate identified by userID. func (loader *Loader) Load(ctx context.Context, userID common.UserID) (Aggregate, error) { if loader == nil { return Aggregate{}, shared.InternalError(fmt.Errorf("account view loader must not be nil")) } accountRecord, err := loader.accounts.GetByUserID(ctx, userID) switch { case err == nil: case errors.Is(err, ports.ErrNotFound): return Aggregate{}, shared.SubjectNotFound() default: return Aggregate{}, shared.ServiceUnavailable(err) } if accountRecord.IsDeleted() { return Aggregate{}, shared.SubjectNotFound() } entitlementSnapshot, err := loader.entitlements.GetByUserID(ctx, userID) switch { case err == nil: case errors.Is(err, ports.ErrNotFound): return Aggregate{}, shared.InternalError(fmt.Errorf("user %q is missing entitlement snapshot", userID)) default: return Aggregate{}, shared.ServiceUnavailable(err) } sanctionRecords, err := loader.sanctions.ListByUserID(ctx, userID) if err != nil { return Aggregate{}, shared.ServiceUnavailable(err) } limitRecords, err := loader.limits.ListByUserID(ctx, userID) if err != nil { return Aggregate{}, shared.ServiceUnavailable(err) } now := loader.clock.Now().UTC() activeSanctions, err := policy.ActiveSanctionsAt(sanctionRecords, now) if err != nil { return Aggregate{}, shared.InternalError(fmt.Errorf("evaluate active sanctions for user %q: %w", userID, err)) } activeLimits, err := policy.ActiveLimitsAt(limitRecords, now) if err != nil { return Aggregate{}, shared.InternalError(fmt.Errorf("evaluate active limits for user %q: %w", userID, err)) } return Aggregate{ AccountRecord: accountRecord, EntitlementSnapshot: entitlementSnapshot, ActiveSanctions: activeSanctions, ActiveLimits: activeLimits, }, nil } func actorRefView(ref common.ActorRef) ActorRefView { return ActorRefView{ Type: ref.Type.String(), ID: ref.ID.String(), } } func cloneOptionalTime(value *time.Time) *time.Time { if value == nil { return nil } cloned := value.UTC() return &cloned }