feat: user service
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// CreateAccountInput stores the atomic account-create state that must commit
|
||||
// together.
|
||||
type CreateAccountInput struct {
|
||||
// Account stores the durable user-account state.
|
||||
Account account.UserAccount
|
||||
|
||||
// Reservation stores the canonical race-name reservation linked to Account.
|
||||
Reservation account.RaceNameReservation
|
||||
}
|
||||
|
||||
// Validate reports whether CreateAccountInput is structurally complete.
|
||||
func (input CreateAccountInput) Validate() error {
|
||||
if err := input.Account.Validate(); err != nil {
|
||||
return fmt.Errorf("create account input account: %w", err)
|
||||
}
|
||||
if err := input.Reservation.Validate(); err != nil {
|
||||
return fmt.Errorf("create account input reservation: %w", err)
|
||||
}
|
||||
if input.Account.UserID != input.Reservation.UserID {
|
||||
return fmt.Errorf("create account input reservation user id must match account user id")
|
||||
}
|
||||
if input.Account.RaceName != input.Reservation.RaceName {
|
||||
return fmt.Errorf("create account input reservation race name must match account race name")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameRaceNameInput stores the atomic state required to replace one stored
|
||||
// race name and its canonical reservation.
|
||||
type RenameRaceNameInput struct {
|
||||
// UserID identifies the account that must be updated.
|
||||
UserID common.UserID
|
||||
|
||||
// CurrentCanonicalKey stores the currently owned canonical reservation key.
|
||||
CurrentCanonicalKey account.RaceNameCanonicalKey
|
||||
|
||||
// NewRaceName stores the replacement exact stored race name.
|
||||
NewRaceName common.RaceName
|
||||
|
||||
// NewReservation stores the replacement canonical reservation.
|
||||
NewReservation account.RaceNameReservation
|
||||
|
||||
// UpdatedAt stores the account mutation timestamp.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether RenameRaceNameInput is structurally complete.
|
||||
func (input RenameRaceNameInput) Validate() error {
|
||||
if err := input.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("rename race name input user id: %w", err)
|
||||
}
|
||||
if err := input.CurrentCanonicalKey.Validate(); err != nil {
|
||||
return fmt.Errorf("rename race name input current canonical key: %w", err)
|
||||
}
|
||||
if err := input.NewRaceName.Validate(); err != nil {
|
||||
return fmt.Errorf("rename race name input race name: %w", err)
|
||||
}
|
||||
if err := input.NewReservation.Validate(); err != nil {
|
||||
return fmt.Errorf("rename race name input reservation: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("rename race name input updated at", input.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if input.NewReservation.UserID != input.UserID {
|
||||
return fmt.Errorf("rename race name input reservation user id must match user id")
|
||||
}
|
||||
if input.NewReservation.RaceName != input.NewRaceName {
|
||||
return fmt.Errorf("rename race name input reservation race name must match new race name")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserAccountStore persists source-of-truth user-account records and their
|
||||
// exact lookup mappings.
|
||||
type UserAccountStore interface {
|
||||
// Create stores one new account record. Implementations must wrap
|
||||
// ErrConflict when the user id, e-mail, or exact race-name lookup already
|
||||
// exists.
|
||||
Create(ctx context.Context, input CreateAccountInput) error
|
||||
|
||||
// GetByUserID returns the stored account identified by userID.
|
||||
GetByUserID(ctx context.Context, userID common.UserID) (account.UserAccount, error)
|
||||
|
||||
// GetByEmail returns the stored account identified by the normalized e-mail
|
||||
// address.
|
||||
GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error)
|
||||
|
||||
// GetByRaceName returns the stored account identified by the exact stored
|
||||
// race name.
|
||||
GetByRaceName(ctx context.Context, raceName common.RaceName) (account.UserAccount, error)
|
||||
|
||||
// ExistsByUserID reports whether userID currently identifies a stored
|
||||
// account.
|
||||
ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error)
|
||||
|
||||
// RenameRaceName replaces the stored race name of userID and swaps the
|
||||
// exact race-name lookup atomically. Implementations must wrap ErrConflict
|
||||
// when newRaceName is already owned by another account.
|
||||
RenameRaceName(ctx context.Context, input RenameRaceNameInput) error
|
||||
|
||||
// Update replaces the stored account state for record.UserID.
|
||||
Update(ctx context.Context, record account.UserAccount) error
|
||||
}
|
||||
|
||||
// RaceNameReservationStore persists source-of-truth race-name reservations.
|
||||
type RaceNameReservationStore interface {
|
||||
// Create stores one new race-name reservation keyed by its canonical
|
||||
// uniqueness key. Implementations must wrap ErrConflict when the canonical
|
||||
// key is already reserved.
|
||||
Create(ctx context.Context, record account.RaceNameReservation) error
|
||||
|
||||
// GetByCanonicalKey returns the stored reservation identified by key.
|
||||
GetByCanonicalKey(ctx context.Context, key account.RaceNameCanonicalKey) (account.RaceNameReservation, error)
|
||||
|
||||
// DeleteByCanonicalKey removes the reservation identified by key.
|
||||
DeleteByCanonicalKey(ctx context.Context, key account.RaceNameCanonicalKey) error
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
)
|
||||
|
||||
// AuthResolutionKind identifies the coarse auth-facing resolution state of one
|
||||
// e-mail subject.
|
||||
type AuthResolutionKind string
|
||||
|
||||
const (
|
||||
// AuthResolutionKindExisting reports that the e-mail belongs to an existing
|
||||
// account.
|
||||
AuthResolutionKindExisting AuthResolutionKind = "existing"
|
||||
|
||||
// AuthResolutionKindCreatable reports that the e-mail is not blocked and no
|
||||
// account exists yet.
|
||||
AuthResolutionKindCreatable AuthResolutionKind = "creatable"
|
||||
|
||||
// AuthResolutionKindBlocked reports that the e-mail subject is blocked.
|
||||
AuthResolutionKindBlocked AuthResolutionKind = "blocked"
|
||||
)
|
||||
|
||||
// IsKnown reports whether AuthResolutionKind belongs to the supported
|
||||
// auth-facing vocabulary.
|
||||
func (kind AuthResolutionKind) IsKnown() bool {
|
||||
switch kind {
|
||||
case AuthResolutionKindExisting, AuthResolutionKindCreatable, AuthResolutionKindBlocked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveByEmailResult stores the coarse auth-facing state of one e-mail
|
||||
// subject.
|
||||
type ResolveByEmailResult struct {
|
||||
// Kind stores the coarse resolution state.
|
||||
Kind AuthResolutionKind
|
||||
|
||||
// UserID is present only when Kind is AuthResolutionKindExisting.
|
||||
UserID common.UserID
|
||||
|
||||
// BlockReasonCode is present only when Kind is AuthResolutionKindBlocked.
|
||||
BlockReasonCode common.ReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether ResolveByEmailResult satisfies the auth-facing
|
||||
// invariant set.
|
||||
func (result ResolveByEmailResult) Validate() error {
|
||||
if !result.Kind.IsKnown() {
|
||||
return fmt.Errorf("resolve-by-email result kind %q is unsupported", result.Kind)
|
||||
}
|
||||
|
||||
switch result.Kind {
|
||||
case AuthResolutionKindExisting:
|
||||
if err := result.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("resolve-by-email result user id: %w", err)
|
||||
}
|
||||
if !result.BlockReasonCode.IsZero() {
|
||||
return fmt.Errorf("resolve-by-email result block reason code must be empty for existing outcome")
|
||||
}
|
||||
case AuthResolutionKindCreatable:
|
||||
if !result.UserID.IsZero() {
|
||||
return fmt.Errorf("resolve-by-email result user id must be empty for creatable outcome")
|
||||
}
|
||||
if !result.BlockReasonCode.IsZero() {
|
||||
return fmt.Errorf("resolve-by-email result block reason code must be empty for creatable outcome")
|
||||
}
|
||||
case AuthResolutionKindBlocked:
|
||||
if !result.UserID.IsZero() {
|
||||
return fmt.Errorf("resolve-by-email result user id must be empty for blocked outcome")
|
||||
}
|
||||
if err := result.BlockReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("resolve-by-email result block reason code: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureByEmailOutcome identifies the coarse auth-facing ensure result.
|
||||
type EnsureByEmailOutcome string
|
||||
|
||||
const (
|
||||
// EnsureByEmailOutcomeExisting reports that the e-mail already belongs to an
|
||||
// existing account.
|
||||
EnsureByEmailOutcomeExisting EnsureByEmailOutcome = "existing"
|
||||
|
||||
// EnsureByEmailOutcomeCreated reports that a new account was created.
|
||||
EnsureByEmailOutcomeCreated EnsureByEmailOutcome = "created"
|
||||
|
||||
// EnsureByEmailOutcomeBlocked reports that creation or reuse is blocked by
|
||||
// policy.
|
||||
EnsureByEmailOutcomeBlocked EnsureByEmailOutcome = "blocked"
|
||||
)
|
||||
|
||||
// IsKnown reports whether EnsureByEmailOutcome belongs to the supported
|
||||
// auth-facing vocabulary.
|
||||
func (outcome EnsureByEmailOutcome) IsKnown() bool {
|
||||
switch outcome {
|
||||
case EnsureByEmailOutcomeExisting, EnsureByEmailOutcomeCreated, EnsureByEmailOutcomeBlocked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureByEmailInput stores the complete create payload required for atomic
|
||||
// ensure-by-email behavior.
|
||||
type EnsureByEmailInput struct {
|
||||
// Email stores the exact normalized e-mail subject addressed by the ensure
|
||||
// call.
|
||||
Email common.Email
|
||||
|
||||
// Account stores the fully initialized account that should be persisted when
|
||||
// the e-mail does not yet exist and is not blocked.
|
||||
Account account.UserAccount
|
||||
|
||||
// Entitlement stores the initial current entitlement snapshot for the new
|
||||
// account.
|
||||
Entitlement entitlement.CurrentSnapshot
|
||||
|
||||
// EntitlementRecord stores the initial entitlement history record that must
|
||||
// be created atomically with Entitlement.
|
||||
EntitlementRecord entitlement.PeriodRecord
|
||||
|
||||
// Reservation stores the canonical race-name reservation for Account.
|
||||
Reservation account.RaceNameReservation
|
||||
}
|
||||
|
||||
// Validate reports whether EnsureByEmailInput is structurally complete.
|
||||
func (input EnsureByEmailInput) Validate() error {
|
||||
if err := input.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email input email: %w", err)
|
||||
}
|
||||
if err := input.Account.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email input account: %w", err)
|
||||
}
|
||||
if err := input.Entitlement.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email input entitlement snapshot: %w", err)
|
||||
}
|
||||
if err := input.EntitlementRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record: %w", err)
|
||||
}
|
||||
if err := input.Reservation.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email input reservation: %w", err)
|
||||
}
|
||||
if input.Account.Email != input.Email {
|
||||
return fmt.Errorf("ensure-by-email input account email must match request email")
|
||||
}
|
||||
if input.Account.UserID != input.Entitlement.UserID {
|
||||
return fmt.Errorf("ensure-by-email input account user id must match entitlement user id")
|
||||
}
|
||||
if input.Account.UserID != input.EntitlementRecord.UserID {
|
||||
return fmt.Errorf("ensure-by-email input account user id must match entitlement record user id")
|
||||
}
|
||||
if input.Account.UserID != input.Reservation.UserID {
|
||||
return fmt.Errorf("ensure-by-email input account user id must match reservation user id")
|
||||
}
|
||||
if input.Account.RaceName != input.Reservation.RaceName {
|
||||
return fmt.Errorf("ensure-by-email input account race name must match reservation race name")
|
||||
}
|
||||
if input.EntitlementRecord.PlanCode != input.Entitlement.PlanCode {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record plan code must match entitlement snapshot plan code")
|
||||
}
|
||||
if input.EntitlementRecord.Source != input.Entitlement.Source {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record source must match entitlement snapshot source")
|
||||
}
|
||||
if input.EntitlementRecord.Actor != input.Entitlement.Actor {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record actor must match entitlement snapshot actor")
|
||||
}
|
||||
if input.EntitlementRecord.ReasonCode != input.Entitlement.ReasonCode {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record reason code must match entitlement snapshot reason code")
|
||||
}
|
||||
if !input.EntitlementRecord.StartsAt.Equal(input.Entitlement.StartsAt) {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record starts at must match entitlement snapshot starts at")
|
||||
}
|
||||
if !equalOptionalTimes(input.EntitlementRecord.EndsAt, input.Entitlement.EndsAt) {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record ends at must match entitlement snapshot ends at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureByEmailResult stores the coarse auth-facing outcome of an atomic
|
||||
// ensure-by-email call.
|
||||
type EnsureByEmailResult struct {
|
||||
// Outcome stores the coarse ensure result.
|
||||
Outcome EnsureByEmailOutcome
|
||||
|
||||
// UserID is present only for existing or created outcomes.
|
||||
UserID common.UserID
|
||||
|
||||
// BlockReasonCode is present only for the blocked outcome.
|
||||
BlockReasonCode common.ReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether EnsureByEmailResult satisfies the auth-facing
|
||||
// invariant set.
|
||||
func (result EnsureByEmailResult) Validate() error {
|
||||
if !result.Outcome.IsKnown() {
|
||||
return fmt.Errorf("ensure-by-email result outcome %q is unsupported", result.Outcome)
|
||||
}
|
||||
|
||||
switch result.Outcome {
|
||||
case EnsureByEmailOutcomeExisting, EnsureByEmailOutcomeCreated:
|
||||
if err := result.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email result user id: %w", err)
|
||||
}
|
||||
if !result.BlockReasonCode.IsZero() {
|
||||
return fmt.Errorf("ensure-by-email result block reason code must be empty for existing or created outcome")
|
||||
}
|
||||
case EnsureByEmailOutcomeBlocked:
|
||||
if !result.UserID.IsZero() {
|
||||
return fmt.Errorf("ensure-by-email result user id must be empty for blocked outcome")
|
||||
}
|
||||
if err := result.BlockReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email result block reason code: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthBlockOutcome identifies the coarse result of blocking one auth subject.
|
||||
type AuthBlockOutcome string
|
||||
|
||||
const (
|
||||
// AuthBlockOutcomeBlocked reports that the current mutation created a new
|
||||
// block record.
|
||||
AuthBlockOutcomeBlocked AuthBlockOutcome = "blocked"
|
||||
|
||||
// AuthBlockOutcomeAlreadyBlocked reports that the block already existed.
|
||||
AuthBlockOutcomeAlreadyBlocked AuthBlockOutcome = "already_blocked"
|
||||
)
|
||||
|
||||
// IsKnown reports whether AuthBlockOutcome belongs to the supported
|
||||
// auth-facing vocabulary.
|
||||
func (outcome AuthBlockOutcome) IsKnown() bool {
|
||||
switch outcome {
|
||||
case AuthBlockOutcomeBlocked, AuthBlockOutcomeAlreadyBlocked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// BlockByUserIDInput stores one auth-facing block request addressed by stable
|
||||
// user identifier.
|
||||
type BlockByUserIDInput struct {
|
||||
// UserID identifies the account that must be blocked.
|
||||
UserID common.UserID
|
||||
|
||||
// ReasonCode stores the machine-readable block reason.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// BlockedAt stores the timestamp applied to the blocked e-mail subject
|
||||
// record when a new block is created.
|
||||
BlockedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether BlockByUserIDInput is structurally complete.
|
||||
func (input BlockByUserIDInput) Validate() error {
|
||||
if err := input.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("block-by-user-id input user id: %w", err)
|
||||
}
|
||||
if err := input.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("block-by-user-id input reason code: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("block-by-user-id input blocked at", input.BlockedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlockByEmailInput stores one auth-facing block request addressed by exact
|
||||
// normalized e-mail subject.
|
||||
type BlockByEmailInput struct {
|
||||
// Email identifies the e-mail subject that must be blocked.
|
||||
Email common.Email
|
||||
|
||||
// ReasonCode stores the machine-readable block reason.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// BlockedAt stores the timestamp applied to the blocked e-mail subject
|
||||
// record when a new block is created.
|
||||
BlockedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether BlockByEmailInput is structurally complete.
|
||||
func (input BlockByEmailInput) Validate() error {
|
||||
if err := input.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("block-by-email input email: %w", err)
|
||||
}
|
||||
if err := input.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("block-by-email input reason code: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("block-by-email input blocked at", input.BlockedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlockResult stores the coarse auth-facing result of a block mutation.
|
||||
type BlockResult struct {
|
||||
// Outcome reports whether a new block was applied or already existed.
|
||||
Outcome AuthBlockOutcome
|
||||
|
||||
// UserID stores the resolved account when the blocked subject belongs to one
|
||||
// existing user.
|
||||
UserID common.UserID
|
||||
}
|
||||
|
||||
// Validate reports whether BlockResult satisfies the auth-facing invariant
|
||||
// set.
|
||||
func (result BlockResult) Validate() error {
|
||||
if !result.Outcome.IsKnown() {
|
||||
return fmt.Errorf("block result outcome %q is unsupported", result.Outcome)
|
||||
}
|
||||
if !result.UserID.IsZero() {
|
||||
if err := result.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("block result user id: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthDirectoryStore performs the narrow set of atomic auth-facing reads and
|
||||
// mutations that must not observe inconsistent cross-key Redis state.
|
||||
type AuthDirectoryStore interface {
|
||||
// ResolveByEmail returns the current coarse auth-facing resolution state for
|
||||
// email.
|
||||
ResolveByEmail(ctx context.Context, email common.Email) (ResolveByEmailResult, error)
|
||||
|
||||
// ExistsByUserID reports whether userID currently identifies a stored
|
||||
// account.
|
||||
ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error)
|
||||
|
||||
// EnsureByEmail returns an existing user, creates a new one, or reports a
|
||||
// blocked outcome atomically for one e-mail subject.
|
||||
EnsureByEmail(ctx context.Context, input EnsureByEmailInput) (EnsureByEmailResult, error)
|
||||
|
||||
// BlockByUserID applies a block to the account identified by userID.
|
||||
BlockByUserID(ctx context.Context, input BlockByUserIDInput) (BlockResult, error)
|
||||
|
||||
// BlockByEmail applies a block to email even when no account exists yet.
|
||||
BlockByEmail(ctx context.Context, input BlockByEmailInput) (BlockResult, error)
|
||||
}
|
||||
|
||||
func equalOptionalTimes(left *time.Time, right *time.Time) bool {
|
||||
switch {
|
||||
case left == nil && right == nil:
|
||||
return true
|
||||
case left == nil || right == nil:
|
||||
return false
|
||||
default:
|
||||
return left.Equal(*right)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/user/internal/domain/authblock"
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// BlockedEmailStore persists the dedicated blocked-email-subject model used by
|
||||
// auth-facing flows.
|
||||
type BlockedEmailStore interface {
|
||||
// GetByEmail returns the blocked-email subject for email.
|
||||
GetByEmail(ctx context.Context, email common.Email) (authblock.BlockedEmailSubject, error)
|
||||
|
||||
// Upsert stores or replaces the blocked-email subject for record.Email.
|
||||
Upsert(ctx context.Context, record authblock.BlockedEmailSubject) error
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ports
|
||||
|
||||
import "time"
|
||||
|
||||
// Clock returns the current wall-clock time used by timestamped mutations.
|
||||
type Clock interface {
|
||||
// Now returns the current time.
|
||||
Now() time.Time
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
const (
|
||||
// DeclaredCountryChangedEventType identifies declared-country change events
|
||||
// in the shared auxiliary event stream.
|
||||
DeclaredCountryChangedEventType = "user.declared_country.changed"
|
||||
)
|
||||
|
||||
// DeclaredCountryChangedEvent stores one auxiliary declared-country change
|
||||
// notification emitted after a successful source-of-truth update.
|
||||
type DeclaredCountryChangedEvent struct {
|
||||
// UserID identifies the user whose current declared country changed.
|
||||
UserID common.UserID
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
|
||||
// DeclaredCountry stores the latest effective declared country.
|
||||
DeclaredCountry common.CountryCode
|
||||
|
||||
// UpdatedAt stores the persisted account mutation timestamp.
|
||||
UpdatedAt time.Time
|
||||
|
||||
// Source stores the machine-readable upstream mutation source.
|
||||
Source common.Source
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event DeclaredCountryChangedEvent) Validate() error {
|
||||
if err := validateEventEnvelope("declared-country changed event", event.UserID, event.UpdatedAt, event.Source, event.TraceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := event.DeclaredCountry.Validate(); err != nil {
|
||||
return fmt.Errorf("declared-country changed event declared country: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeclaredCountryChangedPublisher publishes auxiliary declared-country change
|
||||
// notifications after source-of-truth account updates.
|
||||
type DeclaredCountryChangedPublisher interface {
|
||||
// PublishDeclaredCountryChanged propagates one committed declared-country
|
||||
// change event.
|
||||
PublishDeclaredCountryChanged(ctx context.Context, event DeclaredCountryChangedEvent) error
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
const (
|
||||
// ProfileChangedEventType identifies profile-change events in the shared
|
||||
// auxiliary event stream.
|
||||
ProfileChangedEventType = "user.profile.changed"
|
||||
|
||||
// SettingsChangedEventType identifies settings-change events in the shared
|
||||
// auxiliary event stream.
|
||||
SettingsChangedEventType = "user.settings.changed"
|
||||
|
||||
// EntitlementChangedEventType identifies entitlement-change events in the
|
||||
// shared auxiliary event stream.
|
||||
EntitlementChangedEventType = "user.entitlement.changed"
|
||||
|
||||
// SanctionChangedEventType identifies sanction-change events in the shared
|
||||
// auxiliary event stream.
|
||||
SanctionChangedEventType = "user.sanction.changed"
|
||||
|
||||
// LimitChangedEventType identifies limit-change events in the shared
|
||||
// auxiliary event stream.
|
||||
LimitChangedEventType = "user.limit.changed"
|
||||
)
|
||||
|
||||
// ProfileChangedOperation identifies one profile-change event kind.
|
||||
type ProfileChangedOperation string
|
||||
|
||||
const (
|
||||
// ProfileChangedOperationInitialized reports the initial account
|
||||
// materialization performed during auth-driven user creation.
|
||||
ProfileChangedOperationInitialized ProfileChangedOperation = "initialized"
|
||||
|
||||
// ProfileChangedOperationUpdated reports a later self-service profile
|
||||
// update.
|
||||
ProfileChangedOperationUpdated ProfileChangedOperation = "updated"
|
||||
)
|
||||
|
||||
// IsKnown reports whether operation belongs to the frozen profile-change
|
||||
// event vocabulary.
|
||||
func (operation ProfileChangedOperation) IsKnown() bool {
|
||||
switch operation {
|
||||
case ProfileChangedOperationInitialized, ProfileChangedOperationUpdated:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SettingsChangedOperation identifies one settings-change event kind.
|
||||
type SettingsChangedOperation string
|
||||
|
||||
const (
|
||||
// SettingsChangedOperationInitialized reports the initial account settings
|
||||
// materialization performed during auth-driven user creation.
|
||||
SettingsChangedOperationInitialized SettingsChangedOperation = "initialized"
|
||||
|
||||
// SettingsChangedOperationUpdated reports a later self-service settings
|
||||
// update.
|
||||
SettingsChangedOperationUpdated SettingsChangedOperation = "updated"
|
||||
)
|
||||
|
||||
// IsKnown reports whether operation belongs to the frozen settings-change
|
||||
// event vocabulary.
|
||||
func (operation SettingsChangedOperation) IsKnown() bool {
|
||||
switch operation {
|
||||
case SettingsChangedOperationInitialized, SettingsChangedOperationUpdated:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// EntitlementChangedOperation identifies one entitlement-change event kind.
|
||||
type EntitlementChangedOperation string
|
||||
|
||||
const (
|
||||
// EntitlementChangedOperationInitialized reports the initial free snapshot
|
||||
// created for a new user.
|
||||
EntitlementChangedOperationInitialized EntitlementChangedOperation = "initialized"
|
||||
|
||||
// EntitlementChangedOperationGranted reports an explicit paid grant.
|
||||
EntitlementChangedOperationGranted EntitlementChangedOperation = "granted"
|
||||
|
||||
// EntitlementChangedOperationExtended reports an explicit paid extension.
|
||||
EntitlementChangedOperationExtended EntitlementChangedOperation = "extended"
|
||||
|
||||
// EntitlementChangedOperationRevoked reports an explicit paid revoke.
|
||||
EntitlementChangedOperationRevoked EntitlementChangedOperation = "revoked"
|
||||
|
||||
// EntitlementChangedOperationExpiredRepaired reports lazy repair of a
|
||||
// naturally expired finite paid snapshot.
|
||||
EntitlementChangedOperationExpiredRepaired EntitlementChangedOperation = "expired_repaired"
|
||||
)
|
||||
|
||||
// IsKnown reports whether operation belongs to the frozen entitlement-change
|
||||
// event vocabulary.
|
||||
func (operation EntitlementChangedOperation) IsKnown() bool {
|
||||
switch operation {
|
||||
case EntitlementChangedOperationInitialized,
|
||||
EntitlementChangedOperationGranted,
|
||||
EntitlementChangedOperationExtended,
|
||||
EntitlementChangedOperationRevoked,
|
||||
EntitlementChangedOperationExpiredRepaired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SanctionChangedOperation identifies one sanction-change event kind.
|
||||
type SanctionChangedOperation string
|
||||
|
||||
const (
|
||||
// SanctionChangedOperationApplied reports a new active sanction.
|
||||
SanctionChangedOperationApplied SanctionChangedOperation = "applied"
|
||||
|
||||
// SanctionChangedOperationRemoved reports explicit removal of an active
|
||||
// sanction.
|
||||
SanctionChangedOperationRemoved SanctionChangedOperation = "removed"
|
||||
)
|
||||
|
||||
// IsKnown reports whether operation belongs to the frozen sanction-change
|
||||
// event vocabulary.
|
||||
func (operation SanctionChangedOperation) IsKnown() bool {
|
||||
switch operation {
|
||||
case SanctionChangedOperationApplied, SanctionChangedOperationRemoved:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// LimitChangedOperation identifies one limit-change event kind.
|
||||
type LimitChangedOperation string
|
||||
|
||||
const (
|
||||
// LimitChangedOperationSet reports a new or replacement active limit.
|
||||
LimitChangedOperationSet LimitChangedOperation = "set"
|
||||
|
||||
// LimitChangedOperationRemoved reports explicit removal of an active limit.
|
||||
LimitChangedOperationRemoved LimitChangedOperation = "removed"
|
||||
)
|
||||
|
||||
// IsKnown reports whether operation belongs to the frozen limit-change event
|
||||
// vocabulary.
|
||||
func (operation LimitChangedOperation) IsKnown() bool {
|
||||
switch operation {
|
||||
case LimitChangedOperationSet, LimitChangedOperationRemoved:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ProfileChangedEvent stores one post-commit auxiliary profile-change event.
|
||||
type ProfileChangedEvent struct {
|
||||
// UserID identifies the changed user.
|
||||
UserID common.UserID
|
||||
|
||||
// OccurredAt stores the mutation timestamp emitted into the shared event
|
||||
// envelope.
|
||||
OccurredAt time.Time
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source common.Source
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
|
||||
// Operation stores the profile-change event kind.
|
||||
Operation ProfileChangedOperation
|
||||
|
||||
// RaceName stores the latest exact race name after the commit.
|
||||
RaceName common.RaceName
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event ProfileChangedEvent) Validate() error {
|
||||
if err := validateEventEnvelope("profile changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !event.Operation.IsKnown() {
|
||||
return fmt.Errorf("profile changed event operation %q is unsupported", event.Operation)
|
||||
}
|
||||
if err := event.RaceName.Validate(); err != nil {
|
||||
return fmt.Errorf("profile changed event race name: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SettingsChangedEvent stores one post-commit auxiliary settings-change event.
|
||||
type SettingsChangedEvent struct {
|
||||
// UserID identifies the changed user.
|
||||
UserID common.UserID
|
||||
|
||||
// OccurredAt stores the mutation timestamp emitted into the shared event
|
||||
// envelope.
|
||||
OccurredAt time.Time
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source common.Source
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
|
||||
// Operation stores the settings-change event kind.
|
||||
Operation SettingsChangedOperation
|
||||
|
||||
// PreferredLanguage stores the latest preferred language after the commit.
|
||||
PreferredLanguage common.LanguageTag
|
||||
|
||||
// TimeZone stores the latest time-zone name after the commit.
|
||||
TimeZone common.TimeZoneName
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event SettingsChangedEvent) Validate() error {
|
||||
if err := validateEventEnvelope("settings changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !event.Operation.IsKnown() {
|
||||
return fmt.Errorf("settings changed event operation %q is unsupported", event.Operation)
|
||||
}
|
||||
if err := event.PreferredLanguage.Validate(); err != nil {
|
||||
return fmt.Errorf("settings changed event preferred language: %w", err)
|
||||
}
|
||||
if err := event.TimeZone.Validate(); err != nil {
|
||||
return fmt.Errorf("settings changed event time zone: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EntitlementChangedEvent stores one post-commit auxiliary entitlement-change
|
||||
// event.
|
||||
type EntitlementChangedEvent struct {
|
||||
// UserID identifies the changed user.
|
||||
UserID common.UserID
|
||||
|
||||
// OccurredAt stores the mutation timestamp emitted into the shared event
|
||||
// envelope.
|
||||
OccurredAt time.Time
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source common.Source
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
|
||||
// Operation stores the entitlement-change event kind.
|
||||
Operation EntitlementChangedOperation
|
||||
|
||||
// PlanCode stores the effective plan after the commit.
|
||||
PlanCode entitlement.PlanCode
|
||||
|
||||
// IsPaid stores the effective paid/free flag after the commit.
|
||||
IsPaid bool
|
||||
|
||||
// StartsAt stores when the effective entitlement state started.
|
||||
StartsAt time.Time
|
||||
|
||||
// EndsAt stores the optional finite paid expiry.
|
||||
EndsAt *time.Time
|
||||
|
||||
// ReasonCode stores the mutation reason.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// Actor stores the audit actor metadata attached to the mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// UpdatedAt stores when the current entitlement snapshot was recomputed.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event EntitlementChangedEvent) Validate() error {
|
||||
if err := validateEventEnvelope("entitlement changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !event.Operation.IsKnown() {
|
||||
return fmt.Errorf("entitlement changed event operation %q is unsupported", event.Operation)
|
||||
}
|
||||
if !event.PlanCode.IsKnown() {
|
||||
return fmt.Errorf("entitlement changed event plan code %q is unsupported", event.PlanCode)
|
||||
}
|
||||
if event.IsPaid != event.PlanCode.IsPaid() {
|
||||
return fmt.Errorf("entitlement changed event paid flag must match plan code %q", event.PlanCode)
|
||||
}
|
||||
if err := common.ValidateTimestamp("entitlement changed event starts at", event.StartsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if event.PlanCode.HasFiniteExpiry() {
|
||||
if event.EndsAt == nil {
|
||||
return fmt.Errorf("entitlement changed event ends at must be present for plan code %q", event.PlanCode)
|
||||
}
|
||||
if !event.EndsAt.After(event.StartsAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
} else if event.EndsAt != nil {
|
||||
return fmt.Errorf("entitlement changed event ends at must be empty for plan code %q", event.PlanCode)
|
||||
}
|
||||
if err := event.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement changed event reason code: %w", err)
|
||||
}
|
||||
if err := event.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement changed event actor: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("entitlement changed event updated at", event.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SanctionChangedEvent stores one post-commit auxiliary sanction-change event.
|
||||
type SanctionChangedEvent struct {
|
||||
// UserID identifies the changed user.
|
||||
UserID common.UserID
|
||||
|
||||
// OccurredAt stores the mutation timestamp emitted into the shared event
|
||||
// envelope.
|
||||
OccurredAt time.Time
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source common.Source
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
|
||||
// Operation stores the sanction-change event kind.
|
||||
Operation SanctionChangedOperation
|
||||
|
||||
// SanctionCode stores the affected sanction code.
|
||||
SanctionCode policy.SanctionCode
|
||||
|
||||
// Scope stores the machine-readable sanction scope.
|
||||
Scope common.Scope
|
||||
|
||||
// ReasonCode stores the mutation reason.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// Actor stores the audit actor metadata attached to the mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// AppliedAt stores when the sanction became effective.
|
||||
AppliedAt time.Time
|
||||
|
||||
// ExpiresAt stores the optional planned sanction expiry.
|
||||
ExpiresAt *time.Time
|
||||
|
||||
// RemovedAt stores the optional sanction removal timestamp.
|
||||
RemovedAt *time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event SanctionChangedEvent) Validate() error {
|
||||
if err := validateEventEnvelope("sanction changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !event.Operation.IsKnown() {
|
||||
return fmt.Errorf("sanction changed event operation %q is unsupported", event.Operation)
|
||||
}
|
||||
if !event.SanctionCode.IsKnown() {
|
||||
return fmt.Errorf("sanction changed event sanction code %q is unsupported", event.SanctionCode)
|
||||
}
|
||||
if err := event.Scope.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction changed event scope: %w", err)
|
||||
}
|
||||
if err := event.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction changed event reason code: %w", err)
|
||||
}
|
||||
if err := event.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction changed event actor: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("sanction changed event applied at", event.AppliedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if event.ExpiresAt != nil && !event.ExpiresAt.After(event.AppliedAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
if event.RemovedAt != nil && event.RemovedAt.Before(event.AppliedAt) {
|
||||
return fmt.Errorf("sanction changed event removed at must not be before applied at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LimitChangedEvent stores one post-commit auxiliary limit-change event.
|
||||
type LimitChangedEvent struct {
|
||||
// UserID identifies the changed user.
|
||||
UserID common.UserID
|
||||
|
||||
// OccurredAt stores the mutation timestamp emitted into the shared event
|
||||
// envelope.
|
||||
OccurredAt time.Time
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source common.Source
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
|
||||
// Operation stores the limit-change event kind.
|
||||
Operation LimitChangedOperation
|
||||
|
||||
// LimitCode stores the affected limit code.
|
||||
LimitCode policy.LimitCode
|
||||
|
||||
// Value stores the active limit value when the operation is `set`.
|
||||
Value *int
|
||||
|
||||
// ReasonCode stores the mutation reason.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// Actor stores the audit actor metadata attached to the mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// AppliedAt stores when the limit became effective.
|
||||
AppliedAt time.Time
|
||||
|
||||
// ExpiresAt stores the optional planned limit expiry.
|
||||
ExpiresAt *time.Time
|
||||
|
||||
// RemovedAt stores the optional explicit limit removal timestamp.
|
||||
RemovedAt *time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event LimitChangedEvent) Validate() error {
|
||||
if err := validateEventEnvelope("limit changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !event.Operation.IsKnown() {
|
||||
return fmt.Errorf("limit changed event operation %q is unsupported", event.Operation)
|
||||
}
|
||||
if !event.LimitCode.IsSupported() {
|
||||
return fmt.Errorf("limit changed event limit code %q is unsupported", event.LimitCode)
|
||||
}
|
||||
switch event.Operation {
|
||||
case LimitChangedOperationSet:
|
||||
if event.Value == nil {
|
||||
return fmt.Errorf("limit changed event value must be present for operation %q", event.Operation)
|
||||
}
|
||||
if *event.Value < 0 {
|
||||
return fmt.Errorf("limit changed event value must not be negative")
|
||||
}
|
||||
case LimitChangedOperationRemoved:
|
||||
if event.Value != nil && *event.Value < 0 {
|
||||
return fmt.Errorf("limit changed event value must not be negative")
|
||||
}
|
||||
}
|
||||
if err := event.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("limit changed event reason code: %w", err)
|
||||
}
|
||||
if err := event.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("limit changed event actor: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("limit changed event applied at", event.AppliedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if event.ExpiresAt != nil && !event.ExpiresAt.After(event.AppliedAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
if event.RemovedAt != nil && event.RemovedAt.Before(event.AppliedAt) {
|
||||
return fmt.Errorf("limit changed event removed at must not be before applied at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProfileChangedPublisher publishes auxiliary profile-change notifications.
|
||||
type ProfileChangedPublisher interface {
|
||||
// PublishProfileChanged propagates one committed profile-change event.
|
||||
PublishProfileChanged(ctx context.Context, event ProfileChangedEvent) error
|
||||
}
|
||||
|
||||
// SettingsChangedPublisher publishes auxiliary settings-change notifications.
|
||||
type SettingsChangedPublisher interface {
|
||||
// PublishSettingsChanged propagates one committed settings-change event.
|
||||
PublishSettingsChanged(ctx context.Context, event SettingsChangedEvent) error
|
||||
}
|
||||
|
||||
// EntitlementChangedPublisher publishes auxiliary entitlement-change
|
||||
// notifications.
|
||||
type EntitlementChangedPublisher interface {
|
||||
// PublishEntitlementChanged propagates one committed entitlement-change
|
||||
// event.
|
||||
PublishEntitlementChanged(ctx context.Context, event EntitlementChangedEvent) error
|
||||
}
|
||||
|
||||
// SanctionChangedPublisher publishes auxiliary sanction-change notifications.
|
||||
type SanctionChangedPublisher interface {
|
||||
// PublishSanctionChanged propagates one committed sanction-change event.
|
||||
PublishSanctionChanged(ctx context.Context, event SanctionChangedEvent) error
|
||||
}
|
||||
|
||||
// LimitChangedPublisher publishes auxiliary limit-change notifications.
|
||||
type LimitChangedPublisher interface {
|
||||
// PublishLimitChanged propagates one committed limit-change event.
|
||||
PublishLimitChanged(ctx context.Context, event LimitChangedEvent) error
|
||||
}
|
||||
|
||||
func validateEventEnvelope(name string, userID common.UserID, occurredAt time.Time, source common.Source, traceID string) error {
|
||||
if err := userID.Validate(); err != nil {
|
||||
return fmt.Errorf("%s user id: %w", name, err)
|
||||
}
|
||||
if err := common.ValidateTimestamp(name+" occurred at", occurredAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := source.Validate(); err != nil {
|
||||
return fmt.Errorf("%s source: %w", name, err)
|
||||
}
|
||||
if traceID != "" {
|
||||
if strings.TrimSpace(traceID) != traceID {
|
||||
return fmt.Errorf("%s trace id must not contain surrounding whitespace", name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
)
|
||||
|
||||
// EntitlementHistoryStore persists immutable entitlement period records and
|
||||
// later close-state updates.
|
||||
type EntitlementHistoryStore interface {
|
||||
// Create stores one new entitlement period history record. Implementations
|
||||
// must wrap ErrConflict when record.RecordID already exists.
|
||||
Create(ctx context.Context, record entitlement.PeriodRecord) error
|
||||
|
||||
// GetByRecordID returns the entitlement period history record identified by
|
||||
// recordID.
|
||||
GetByRecordID(ctx context.Context, recordID entitlement.EntitlementRecordID) (entitlement.PeriodRecord, error)
|
||||
|
||||
// ListByUserID returns every entitlement period history record owned by
|
||||
// userID.
|
||||
ListByUserID(ctx context.Context, userID common.UserID) ([]entitlement.PeriodRecord, error)
|
||||
|
||||
// Update replaces one stored entitlement period history record.
|
||||
Update(ctx context.Context, record entitlement.PeriodRecord) error
|
||||
}
|
||||
|
||||
// EntitlementSnapshotStore persists the read-optimized current entitlement
|
||||
// snapshot.
|
||||
type EntitlementSnapshotStore interface {
|
||||
// GetByUserID returns the current entitlement snapshot for userID.
|
||||
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
|
||||
|
||||
// Put stores the current entitlement snapshot for record.UserID.
|
||||
Put(ctx context.Context, record entitlement.CurrentSnapshot) error
|
||||
}
|
||||
|
||||
// GrantEntitlementInput stores one atomic transition from a current free
|
||||
// entitlement state to a current paid state.
|
||||
type GrantEntitlementInput struct {
|
||||
// ExpectedCurrentSnapshot stores the exact snapshot that must still be
|
||||
// current before the mutation commits.
|
||||
ExpectedCurrentSnapshot entitlement.CurrentSnapshot
|
||||
|
||||
// ExpectedCurrentRecord stores the current effective free period that must
|
||||
// still be current before the mutation commits.
|
||||
ExpectedCurrentRecord entitlement.PeriodRecord
|
||||
|
||||
// UpdatedCurrentRecord stores ExpectedCurrentRecord after the close metadata
|
||||
// is applied.
|
||||
UpdatedCurrentRecord entitlement.PeriodRecord
|
||||
|
||||
// NewRecord stores the new paid entitlement history segment.
|
||||
NewRecord entitlement.PeriodRecord
|
||||
|
||||
// NewSnapshot stores the new current effective entitlement snapshot.
|
||||
NewSnapshot entitlement.CurrentSnapshot
|
||||
}
|
||||
|
||||
// Validate reports whether GrantEntitlementInput is structurally complete.
|
||||
func (input GrantEntitlementInput) Validate() error {
|
||||
if err := input.ExpectedCurrentSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("grant entitlement input expected current snapshot: %w", err)
|
||||
}
|
||||
if err := input.ExpectedCurrentRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("grant entitlement input expected current record: %w", err)
|
||||
}
|
||||
if err := input.UpdatedCurrentRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("grant entitlement input updated current record: %w", err)
|
||||
}
|
||||
if err := input.NewRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("grant entitlement input new record: %w", err)
|
||||
}
|
||||
if err := input.NewSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("grant entitlement input new snapshot: %w", err)
|
||||
}
|
||||
if input.ExpectedCurrentSnapshot.UserID != input.ExpectedCurrentRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.UpdatedCurrentRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.NewRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.NewSnapshot.UserID {
|
||||
return fmt.Errorf("grant entitlement input all records must belong to the same user id")
|
||||
}
|
||||
if input.ExpectedCurrentRecord.RecordID != input.UpdatedCurrentRecord.RecordID {
|
||||
return fmt.Errorf("grant entitlement input updated current record must preserve record id")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtendEntitlementInput stores one atomic extension of a current finite paid
|
||||
// entitlement state.
|
||||
type ExtendEntitlementInput struct {
|
||||
// ExpectedCurrentSnapshot stores the exact snapshot that must still be
|
||||
// current before the mutation commits.
|
||||
ExpectedCurrentSnapshot entitlement.CurrentSnapshot
|
||||
|
||||
// NewRecord stores the appended entitlement history segment that extends the
|
||||
// current paid state.
|
||||
NewRecord entitlement.PeriodRecord
|
||||
|
||||
// NewSnapshot stores the replacement current effective entitlement snapshot.
|
||||
NewSnapshot entitlement.CurrentSnapshot
|
||||
}
|
||||
|
||||
// Validate reports whether ExtendEntitlementInput is structurally complete.
|
||||
func (input ExtendEntitlementInput) Validate() error {
|
||||
if err := input.ExpectedCurrentSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("extend entitlement input expected current snapshot: %w", err)
|
||||
}
|
||||
if err := input.NewRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("extend entitlement input new record: %w", err)
|
||||
}
|
||||
if err := input.NewSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("extend entitlement input new snapshot: %w", err)
|
||||
}
|
||||
if input.ExpectedCurrentSnapshot.UserID != input.NewRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.NewSnapshot.UserID {
|
||||
return fmt.Errorf("extend entitlement input all records must belong to the same user id")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeEntitlementInput stores one atomic transition from a current paid
|
||||
// entitlement state to a new free state.
|
||||
type RevokeEntitlementInput struct {
|
||||
// ExpectedCurrentSnapshot stores the exact snapshot that must still be
|
||||
// current before the mutation commits.
|
||||
ExpectedCurrentSnapshot entitlement.CurrentSnapshot
|
||||
|
||||
// ExpectedCurrentRecord stores the current effective paid period that must
|
||||
// still be current before the mutation commits.
|
||||
ExpectedCurrentRecord entitlement.PeriodRecord
|
||||
|
||||
// UpdatedCurrentRecord stores ExpectedCurrentRecord after the close metadata
|
||||
// is applied.
|
||||
UpdatedCurrentRecord entitlement.PeriodRecord
|
||||
|
||||
// NewRecord stores the newly created free entitlement period.
|
||||
NewRecord entitlement.PeriodRecord
|
||||
|
||||
// NewSnapshot stores the replacement current effective free snapshot.
|
||||
NewSnapshot entitlement.CurrentSnapshot
|
||||
}
|
||||
|
||||
// Validate reports whether RevokeEntitlementInput is structurally complete.
|
||||
func (input RevokeEntitlementInput) Validate() error {
|
||||
if err := input.ExpectedCurrentSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke entitlement input expected current snapshot: %w", err)
|
||||
}
|
||||
if err := input.ExpectedCurrentRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke entitlement input expected current record: %w", err)
|
||||
}
|
||||
if err := input.UpdatedCurrentRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke entitlement input updated current record: %w", err)
|
||||
}
|
||||
if err := input.NewRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke entitlement input new record: %w", err)
|
||||
}
|
||||
if err := input.NewSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke entitlement input new snapshot: %w", err)
|
||||
}
|
||||
if input.ExpectedCurrentSnapshot.UserID != input.ExpectedCurrentRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.UpdatedCurrentRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.NewRecord.UserID ||
|
||||
input.ExpectedCurrentSnapshot.UserID != input.NewSnapshot.UserID {
|
||||
return fmt.Errorf("revoke entitlement input all records must belong to the same user id")
|
||||
}
|
||||
if input.ExpectedCurrentRecord.RecordID != input.UpdatedCurrentRecord.RecordID {
|
||||
return fmt.Errorf("revoke entitlement input updated current record must preserve record id")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RepairExpiredEntitlementInput stores one atomic lazy-repair transition from
|
||||
// an expired finite paid snapshot to a materialized free state.
|
||||
type RepairExpiredEntitlementInput struct {
|
||||
// ExpectedExpiredSnapshot stores the exact expired snapshot that must still
|
||||
// be current before the repair commits.
|
||||
ExpectedExpiredSnapshot entitlement.CurrentSnapshot
|
||||
|
||||
// NewRecord stores the newly created free entitlement period.
|
||||
NewRecord entitlement.PeriodRecord
|
||||
|
||||
// NewSnapshot stores the replacement current effective free snapshot.
|
||||
NewSnapshot entitlement.CurrentSnapshot
|
||||
}
|
||||
|
||||
// Validate reports whether RepairExpiredEntitlementInput is structurally
|
||||
// complete.
|
||||
func (input RepairExpiredEntitlementInput) Validate() error {
|
||||
if err := input.ExpectedExpiredSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("repair expired entitlement input expected expired snapshot: %w", err)
|
||||
}
|
||||
if err := input.NewRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("repair expired entitlement input new record: %w", err)
|
||||
}
|
||||
if err := input.NewSnapshot.Validate(); err != nil {
|
||||
return fmt.Errorf("repair expired entitlement input new snapshot: %w", err)
|
||||
}
|
||||
if input.ExpectedExpiredSnapshot.UserID != input.NewRecord.UserID ||
|
||||
input.ExpectedExpiredSnapshot.UserID != input.NewSnapshot.UserID {
|
||||
return fmt.Errorf("repair expired entitlement input all records must belong to the same user id")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EntitlementLifecycleStore persists atomic entitlement timeline transitions
|
||||
// that must keep history and current snapshot consistent.
|
||||
type EntitlementLifecycleStore interface {
|
||||
// Grant atomically closes the current free period, creates a new paid
|
||||
// period, and replaces the current snapshot.
|
||||
Grant(ctx context.Context, input GrantEntitlementInput) error
|
||||
|
||||
// Extend atomically appends one paid-history segment and replaces the
|
||||
// current snapshot.
|
||||
Extend(ctx context.Context, input ExtendEntitlementInput) error
|
||||
|
||||
// Revoke atomically closes the current paid period, creates a new free
|
||||
// period, and replaces the current snapshot.
|
||||
Revoke(ctx context.Context, input RevokeEntitlementInput) error
|
||||
|
||||
// RepairExpired atomically replaces one expired finite paid snapshot with a
|
||||
// materialized free state.
|
||||
RepairExpired(ctx context.Context, input RepairExpiredEntitlementInput) error
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Package ports defines the storage-agnostic boundaries used by the user
|
||||
// service.
|
||||
package ports
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotFound reports that a requested source-of-truth record does not
|
||||
// exist in the dependency behind the port.
|
||||
ErrNotFound = errors.New("ports: record not found")
|
||||
|
||||
// ErrConflict reports that a create or update cannot be applied because the
|
||||
// dependency state conflicts with the requested mutation.
|
||||
ErrConflict = errors.New("ports: conflict")
|
||||
|
||||
// ErrInvalidPageToken reports that a supplied pagination token cannot be
|
||||
// decoded or does not match the expected filter set.
|
||||
ErrInvalidPageToken = errors.New("ports: invalid page token")
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrRaceNameConflict reports that a mutation specifically failed because a
|
||||
// race-name lookup or canonical reservation is already owned by another
|
||||
// user. The sentinel still matches ErrConflict via errors.Is so callers can
|
||||
// preserve the stable public conflict semantics while collecting more
|
||||
// precise observability.
|
||||
ErrRaceNameConflict = fmt.Errorf("%w: race name conflict", ErrConflict)
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
// IDGenerator creates new user identifiers and generated initial race names.
|
||||
type IDGenerator interface {
|
||||
// NewUserID returns one newly generated stable user identifier.
|
||||
NewUserID() (common.UserID, error)
|
||||
|
||||
// NewInitialRaceName returns one generated initial race name in the
|
||||
// `player-<shortid>` form.
|
||||
NewInitialRaceName() (common.RaceName, error)
|
||||
|
||||
// NewEntitlementRecordID returns one newly generated entitlement history
|
||||
// record identifier.
|
||||
NewEntitlementRecordID() (entitlement.EntitlementRecordID, error)
|
||||
|
||||
// NewSanctionRecordID returns one newly generated sanction history record
|
||||
// identifier.
|
||||
NewSanctionRecordID() (policy.SanctionRecordID, error)
|
||||
|
||||
// NewLimitRecordID returns one newly generated limit history record
|
||||
// identifier.
|
||||
NewLimitRecordID() (policy.LimitRecordID, error)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
// SanctionStore persists sanction history records and later remove-state
|
||||
// updates.
|
||||
type SanctionStore interface {
|
||||
// Create stores one new sanction history record. Implementations must wrap
|
||||
// ErrConflict when record.RecordID already exists.
|
||||
Create(ctx context.Context, record policy.SanctionRecord) error
|
||||
|
||||
// GetByRecordID returns the sanction history record identified by recordID.
|
||||
GetByRecordID(ctx context.Context, recordID policy.SanctionRecordID) (policy.SanctionRecord, error)
|
||||
|
||||
// ListByUserID returns every sanction history record owned by userID.
|
||||
ListByUserID(ctx context.Context, userID common.UserID) ([]policy.SanctionRecord, error)
|
||||
|
||||
// Update replaces one stored sanction history record.
|
||||
Update(ctx context.Context, record policy.SanctionRecord) error
|
||||
}
|
||||
|
||||
// LimitStore persists user-specific limit history records and later
|
||||
// remove-state updates.
|
||||
type LimitStore interface {
|
||||
// Create stores one new limit history record. Implementations must wrap
|
||||
// ErrConflict when record.RecordID already exists.
|
||||
Create(ctx context.Context, record policy.LimitRecord) error
|
||||
|
||||
// GetByRecordID returns the limit history record identified by recordID.
|
||||
GetByRecordID(ctx context.Context, recordID policy.LimitRecordID) (policy.LimitRecord, error)
|
||||
|
||||
// ListByUserID returns every limit history record owned by userID.
|
||||
ListByUserID(ctx context.Context, userID common.UserID) ([]policy.LimitRecord, error)
|
||||
|
||||
// Update replaces one stored limit history record.
|
||||
Update(ctx context.Context, record policy.LimitRecord) error
|
||||
}
|
||||
|
||||
// ApplySanctionInput stores one atomic creation of a new active sanction.
|
||||
type ApplySanctionInput struct {
|
||||
// NewRecord stores the sanction history record that must become active.
|
||||
NewRecord policy.SanctionRecord
|
||||
}
|
||||
|
||||
// Validate reports whether ApplySanctionInput is structurally complete.
|
||||
func (input ApplySanctionInput) Validate() error {
|
||||
if err := input.NewRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("apply sanction input new record: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveSanctionInput stores one atomic removal of the current active
|
||||
// sanction for one `user_id + sanction_code`.
|
||||
type RemoveSanctionInput struct {
|
||||
// ExpectedActiveRecord stores the exact sanction record that must still be
|
||||
// active before the mutation commits.
|
||||
ExpectedActiveRecord policy.SanctionRecord
|
||||
|
||||
// UpdatedRecord stores ExpectedActiveRecord after remove metadata is
|
||||
// applied.
|
||||
UpdatedRecord policy.SanctionRecord
|
||||
}
|
||||
|
||||
// Validate reports whether RemoveSanctionInput is structurally complete.
|
||||
func (input RemoveSanctionInput) Validate() error {
|
||||
if err := input.ExpectedActiveRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("remove sanction input expected active record: %w", err)
|
||||
}
|
||||
if err := input.UpdatedRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("remove sanction input updated record: %w", err)
|
||||
}
|
||||
if input.ExpectedActiveRecord.RecordID != input.UpdatedRecord.RecordID {
|
||||
return fmt.Errorf("remove sanction input updated record must preserve record id")
|
||||
}
|
||||
if input.ExpectedActiveRecord.UserID != input.UpdatedRecord.UserID {
|
||||
return fmt.Errorf("remove sanction input records must belong to the same user id")
|
||||
}
|
||||
if input.ExpectedActiveRecord.SanctionCode != input.UpdatedRecord.SanctionCode {
|
||||
return fmt.Errorf("remove sanction input records must preserve sanction code")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLimitInput stores one atomic creation or replacement of the current
|
||||
// active limit for one `user_id + limit_code`.
|
||||
type SetLimitInput struct {
|
||||
// ExpectedActiveRecord stores the currently active limit that must still be
|
||||
// active before replacement commits. It stays nil when no active limit
|
||||
// exists yet.
|
||||
ExpectedActiveRecord *policy.LimitRecord
|
||||
|
||||
// UpdatedActiveRecord stores ExpectedActiveRecord after remove metadata is
|
||||
// applied. It stays nil when no active limit exists yet.
|
||||
UpdatedActiveRecord *policy.LimitRecord
|
||||
|
||||
// NewRecord stores the limit history record that must become active.
|
||||
NewRecord policy.LimitRecord
|
||||
}
|
||||
|
||||
// Validate reports whether SetLimitInput is structurally complete.
|
||||
func (input SetLimitInput) Validate() error {
|
||||
if err := input.NewRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("set limit input new record: %w", err)
|
||||
}
|
||||
switch {
|
||||
case input.ExpectedActiveRecord == nil && input.UpdatedActiveRecord == nil:
|
||||
return nil
|
||||
case input.ExpectedActiveRecord == nil || input.UpdatedActiveRecord == nil:
|
||||
return fmt.Errorf("set limit input active replacement records must both be present or absent")
|
||||
}
|
||||
if err := input.ExpectedActiveRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("set limit input expected active record: %w", err)
|
||||
}
|
||||
if err := input.UpdatedActiveRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("set limit input updated active record: %w", err)
|
||||
}
|
||||
if input.ExpectedActiveRecord.RecordID != input.UpdatedActiveRecord.RecordID {
|
||||
return fmt.Errorf("set limit input updated active record must preserve record id")
|
||||
}
|
||||
if input.ExpectedActiveRecord.UserID != input.UpdatedActiveRecord.UserID ||
|
||||
input.ExpectedActiveRecord.UserID != input.NewRecord.UserID {
|
||||
return fmt.Errorf("set limit input records must belong to the same user id")
|
||||
}
|
||||
if input.ExpectedActiveRecord.LimitCode != input.UpdatedActiveRecord.LimitCode ||
|
||||
input.ExpectedActiveRecord.LimitCode != input.NewRecord.LimitCode {
|
||||
return fmt.Errorf("set limit input records must preserve limit code")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveLimitInput stores one atomic removal of the current active limit for
|
||||
// one `user_id + limit_code`.
|
||||
type RemoveLimitInput struct {
|
||||
// ExpectedActiveRecord stores the exact limit record that must still be
|
||||
// active before the mutation commits.
|
||||
ExpectedActiveRecord policy.LimitRecord
|
||||
|
||||
// UpdatedRecord stores ExpectedActiveRecord after remove metadata is
|
||||
// applied.
|
||||
UpdatedRecord policy.LimitRecord
|
||||
}
|
||||
|
||||
// Validate reports whether RemoveLimitInput is structurally complete.
|
||||
func (input RemoveLimitInput) Validate() error {
|
||||
if err := input.ExpectedActiveRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("remove limit input expected active record: %w", err)
|
||||
}
|
||||
if err := input.UpdatedRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("remove limit input updated record: %w", err)
|
||||
}
|
||||
if input.ExpectedActiveRecord.RecordID != input.UpdatedRecord.RecordID {
|
||||
return fmt.Errorf("remove limit input updated record must preserve record id")
|
||||
}
|
||||
if input.ExpectedActiveRecord.UserID != input.UpdatedRecord.UserID {
|
||||
return fmt.Errorf("remove limit input records must belong to the same user id")
|
||||
}
|
||||
if input.ExpectedActiveRecord.LimitCode != input.UpdatedRecord.LimitCode {
|
||||
return fmt.Errorf("remove limit input records must preserve limit code")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PolicyLifecycleStore persists atomic sanction and limit transitions that
|
||||
// must keep history and active-slot state consistent.
|
||||
type PolicyLifecycleStore interface {
|
||||
// ApplySanction atomically creates one new active sanction record.
|
||||
ApplySanction(ctx context.Context, input ApplySanctionInput) error
|
||||
|
||||
// RemoveSanction atomically removes one active sanction record.
|
||||
RemoveSanction(ctx context.Context, input RemoveSanctionInput) error
|
||||
|
||||
// SetLimit atomically creates or replaces one active limit record.
|
||||
SetLimit(ctx context.Context, input SetLimitInput) error
|
||||
|
||||
// RemoveLimit atomically removes one active limit record.
|
||||
RemoveLimit(ctx context.Context, input RemoveLimitInput) error
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// RaceNamePolicy produces the canonical uniqueness key used to reserve one
|
||||
// replaceable race-name slot.
|
||||
type RaceNamePolicy interface {
|
||||
// CanonicalKey returns the stable reservation key for raceName. Callers are
|
||||
// expected to pass a validated raceName value.
|
||||
CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultUserListPageSize stores the frozen default page size used by the
|
||||
// trusted admin listing surface when the caller omits `page_size`.
|
||||
DefaultUserListPageSize = 50
|
||||
|
||||
// MaxUserListPageSize stores the frozen maximum page size accepted by the
|
||||
// trusted admin listing surface.
|
||||
MaxUserListPageSize = 200
|
||||
)
|
||||
|
||||
// UserListFilters stores the frozen admin-listing filter set.
|
||||
type UserListFilters struct {
|
||||
// PaidState stores the optional coarse free-versus-paid filter.
|
||||
PaidState entitlement.PaidState
|
||||
|
||||
// PaidExpiresBefore stores the optional strict upper bound for finite paid
|
||||
// expiry.
|
||||
PaidExpiresBefore *time.Time
|
||||
|
||||
// PaidExpiresAfter stores the optional strict lower bound for finite paid
|
||||
// expiry.
|
||||
PaidExpiresAfter *time.Time
|
||||
|
||||
// DeclaredCountry stores the optional current declared-country filter.
|
||||
DeclaredCountry common.CountryCode
|
||||
|
||||
// SanctionCode stores the optional active-sanction filter.
|
||||
SanctionCode policy.SanctionCode
|
||||
|
||||
// LimitCode stores the optional active user-specific limit filter.
|
||||
LimitCode policy.LimitCode
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Validate reports whether filters is structurally valid.
|
||||
func (filters UserListFilters) Validate() error {
|
||||
if !filters.PaidState.IsKnown() {
|
||||
return fmt.Errorf("paid state %q is unsupported", filters.PaidState)
|
||||
}
|
||||
if filters.PaidExpiresBefore != nil && filters.PaidExpiresBefore.IsZero() {
|
||||
return fmt.Errorf("paid expires before must not be zero")
|
||||
}
|
||||
if filters.PaidExpiresAfter != nil && filters.PaidExpiresAfter.IsZero() {
|
||||
return fmt.Errorf("paid expires after must not be zero")
|
||||
}
|
||||
if !filters.DeclaredCountry.IsZero() {
|
||||
if err := filters.DeclaredCountry.Validate(); err != nil {
|
||||
return fmt.Errorf("declared country: %w", err)
|
||||
}
|
||||
}
|
||||
if filters.SanctionCode != "" && !filters.SanctionCode.IsKnown() {
|
||||
return fmt.Errorf("sanction code %q is unsupported", filters.SanctionCode)
|
||||
}
|
||||
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
|
||||
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListUsersInput stores one trusted admin-listing read request.
|
||||
type ListUsersInput struct {
|
||||
// PageSize stores the maximum number of ordered user identifiers returned
|
||||
// in one storage page.
|
||||
PageSize int
|
||||
|
||||
// PageToken stores the optional opaque continuation cursor.
|
||||
PageToken string
|
||||
|
||||
// Filters stores the normalized filter set bound into PageToken.
|
||||
Filters UserListFilters
|
||||
}
|
||||
|
||||
// Validate reports whether input is structurally complete.
|
||||
func (input ListUsersInput) Validate() error {
|
||||
switch {
|
||||
case input.PageSize < 1:
|
||||
return fmt.Errorf("page size must be at least 1")
|
||||
case input.PageSize > MaxUserListPageSize:
|
||||
return fmt.Errorf("page size must be at most %d", MaxUserListPageSize)
|
||||
case strings.TrimSpace(input.PageToken) != input.PageToken:
|
||||
return fmt.Errorf("page token must not contain surrounding whitespace")
|
||||
}
|
||||
if err := input.Filters.Validate(); err != nil {
|
||||
return fmt.Errorf("filters: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListUsersResult stores one deterministic ordered storage page of user ids.
|
||||
type ListUsersResult struct {
|
||||
// UserIDs stores the ordered user identifiers returned for the requested
|
||||
// page.
|
||||
UserIDs []common.UserID
|
||||
|
||||
// NextPageToken stores the optional opaque continuation cursor for the next
|
||||
// page.
|
||||
NextPageToken string
|
||||
}
|
||||
|
||||
// UserListStore provides deterministic ordered admin-listing pagination over
|
||||
// stored user identifiers.
|
||||
type UserListStore interface {
|
||||
// ListUserIDs returns one deterministic storage page of user identifiers.
|
||||
ListUserIDs(ctx context.Context, input ListUsersInput) (ListUsersResult, error)
|
||||
}
|
||||
Reference in New Issue
Block a user