feat: user service
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
// 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"`
|
||||
|
||||
// RaceName stores the current user-facing race name.
|
||||
RaceName string `json:"race_name"`
|
||||
|
||||
// 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(),
|
||||
RaceName: aggregate.AccountRecord.RaceName.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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user