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
+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)
}
}