feat: user service
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user