Files
galaxy-game/user/internal/service/accountview/service.go
T
2026-04-25 23:20:55 +02:00

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
}