feat: user service

This commit is contained in:
Ilia Denisov
2026-04-10 19:05:02 +02:00
committed by GitHub
parent 710bad712e
commit 23ffcb7535
140 changed files with 33418 additions and 952 deletions
+130
View File
@@ -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
}
+369
View File
@@ -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)
}
}
+18
View File
@@ -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
}
+9
View File
@@ -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
}
+230
View File
@@ -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
}
+31
View File
@@ -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)
)
+29
View File
@@ -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)
}
+188
View File
@@ -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
}
+14
View File
@@ -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)
}
+129
View File
@@ -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)
}