346 lines
11 KiB
Go
346 lines
11 KiB
Go
// 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-<suffix>` 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
|
|
}
|