package userstore import ( "context" "database/sql" "errors" "fmt" "galaxy/user/internal/domain/account" "galaxy/user/internal/domain/authblock" "galaxy/user/internal/domain/common" "galaxy/user/internal/ports" ) // deletedAccountBlockReasonCode is returned to auth callers when the lookup // resolves to a soft-deleted account. Auth/Session treats this exactly like // a regular block: it refuses to mint a session for the subject. The code is // not a real sanction record; it lives only on the wire. const deletedAccountBlockReasonCode common.ReasonCode = "account_deleted" // ResolveByEmail returns the current coarse auth-facing resolution state for // email. The decision tree, in order: // // 1. blocked_emails has a row for this address → blocked. // 2. accounts has a non-soft-deleted row for this address → existing. // 3. accounts has a soft-deleted row for this address → blocked // (account_deleted). // 4. otherwise → creatable. // // The whole sequence is a read-only path; no transaction is required. func (store *Store) ResolveByEmail(ctx context.Context, email common.Email) (ports.ResolveByEmailResult, error) { if err := email.Validate(); err != nil { return ports.ResolveByEmailResult{}, fmt.Errorf("resolve by email in postgres: %w", err) } operationCtx, cancel, err := store.operationContext(ctx, "resolve by email in postgres") if err != nil { return ports.ResolveByEmailResult{}, err } defer cancel() blocked, err := scanBlockedEmail(operationCtx, store.db, email, false) switch { case err == nil: return ports.ResolveByEmailResult{ Kind: ports.AuthResolutionKindBlocked, BlockReasonCode: blocked.ReasonCode, }, nil case !errors.Is(err, ports.ErrNotFound): return ports.ResolveByEmailResult{}, fmt.Errorf("resolve by email %q in postgres: %w", email, err) } record, err := scanAccountByEmail(operationCtx, store.db, email) switch { case errors.Is(err, ports.ErrNotFound): return ports.ResolveByEmailResult{Kind: ports.AuthResolutionKindCreatable}, nil case err != nil: return ports.ResolveByEmailResult{}, fmt.Errorf("resolve by email %q in postgres: %w", email, err) } if record.IsDeleted() { return ports.ResolveByEmailResult{ Kind: ports.AuthResolutionKindBlocked, BlockReasonCode: deletedAccountBlockReasonCode, }, nil } return ports.ResolveByEmailResult{ Kind: ports.AuthResolutionKindExisting, UserID: record.UserID, }, nil } // EnsureByEmail atomically returns an existing user, creates a new one, or // reports a blocked outcome. The whole flow runs in one transaction with // row-level locks on `blocked_emails(email)` and `accounts(email)` so we // observe a consistent snapshot of the auth-facing state. // // On the create branch the transaction also INSERTs the initial // entitlement_records row and the entitlement_snapshots row. UNIQUE // violations on user_id or user_name surface as ports.ErrConflict (with // ports.ErrUserNameConflict for the user-name index). func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) { if err := input.Validate(); err != nil { return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email in postgres: %w", err) } var ( result ports.EnsureByEmailResult handled bool ) if err := store.withTx(ctx, "ensure by email in postgres", func(ctx context.Context, tx *sql.Tx) error { blocked, err := scanBlockedEmail(ctx, tx, input.Email, true) switch { case err == nil: result = ports.EnsureByEmailResult{ Outcome: ports.EnsureByEmailOutcomeBlocked, BlockReasonCode: blocked.ReasonCode, } handled = true return nil case !errors.Is(err, ports.ErrNotFound): return fmt.Errorf("ensure by email %q in postgres: %w", input.Email, err) } existing, err := scanAccountForUpdateByEmail(ctx, tx, input.Email) switch { case err == nil: if existing.IsDeleted() { result = ports.EnsureByEmailResult{ Outcome: ports.EnsureByEmailOutcomeBlocked, BlockReasonCode: deletedAccountBlockReasonCode, } handled = true return nil } result = ports.EnsureByEmailResult{ Outcome: ports.EnsureByEmailOutcomeExisting, UserID: existing.UserID, } handled = true return nil case !errors.Is(err, ports.ErrNotFound): return fmt.Errorf("ensure by email %q in postgres: %w", input.Email, err) } if err := insertAccount(ctx, tx, input.Account); err != nil { return err } if err := insertEntitlementPeriod(ctx, tx, input.EntitlementRecord); err != nil { return err } if err := upsertEntitlementSnapshot(ctx, tx, input.Entitlement); err != nil { return err } result = ports.EnsureByEmailResult{ Outcome: ports.EnsureByEmailOutcomeCreated, UserID: input.Account.UserID, } handled = true return nil }); err != nil { return ports.EnsureByEmailResult{}, err } if !handled { return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email %q in postgres: unhandled transaction outcome", input.Email) } return result, nil } // BlockByUserID applies a block to the account identified by userID. The // block is stored as a row in blocked_emails keyed on the user's e-mail with // resolved_user_id pointing back to the account. func (store *Store) BlockByUserID(ctx context.Context, input ports.BlockByUserIDInput) (ports.BlockResult, error) { if err := input.Validate(); err != nil { return ports.BlockResult{}, fmt.Errorf("block by user id in postgres: %w", err) } var ( result ports.BlockResult handled bool ) if err := store.withTx(ctx, "block by user id in postgres", func(ctx context.Context, tx *sql.Tx) error { acc, err := scanAccountForUpdate(ctx, tx, input.UserID) switch { case errors.Is(err, ports.ErrNotFound): return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, ports.ErrNotFound) case err != nil: return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, err) } if acc.IsDeleted() { return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, ports.ErrNotFound) } blocked, err := scanBlockedEmail(ctx, tx, acc.Email, true) switch { case err == nil: result = ports.BlockResult{ Outcome: ports.AuthBlockOutcomeAlreadyBlocked, UserID: input.UserID, } if !blocked.ResolvedUserID.IsZero() { result.UserID = blocked.ResolvedUserID } handled = true return nil case !errors.Is(err, ports.ErrNotFound): return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, err) } record := authblock.BlockedEmailSubject{ Email: acc.Email, ReasonCode: input.ReasonCode, BlockedAt: input.BlockedAt.UTC(), ResolvedUserID: input.UserID, } if err := upsertBlockedEmail(ctx, tx, record); err != nil { return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, err) } result = ports.BlockResult{ Outcome: ports.AuthBlockOutcomeBlocked, UserID: input.UserID, } handled = true return nil }); err != nil { return ports.BlockResult{}, err } if !handled { return ports.BlockResult{}, fmt.Errorf("block by user id %q in postgres: unhandled transaction outcome", input.UserID) } return result, nil } // BlockByEmail applies a block to email even when no account exists yet. If // an account does exist for the e-mail, its user_id is recorded as // resolved_user_id; soft-deleted accounts also count for this resolution. func (store *Store) BlockByEmail(ctx context.Context, input ports.BlockByEmailInput) (ports.BlockResult, error) { if err := input.Validate(); err != nil { return ports.BlockResult{}, fmt.Errorf("block by email in postgres: %w", err) } var ( result ports.BlockResult handled bool ) if err := store.withTx(ctx, "block by email in postgres", func(ctx context.Context, tx *sql.Tx) error { blocked, err := scanBlockedEmail(ctx, tx, input.Email, true) switch { case err == nil: result = ports.BlockResult{ Outcome: ports.AuthBlockOutcomeAlreadyBlocked, UserID: blocked.ResolvedUserID, } handled = true return nil case !errors.Is(err, ports.ErrNotFound): return fmt.Errorf("block by email %q in postgres: %w", input.Email, err) } var resolvedUserID common.UserID acc, err := scanAccountForUpdateByEmail(ctx, tx, input.Email) switch { case err == nil: resolvedUserID = acc.UserID case !errors.Is(err, ports.ErrNotFound): return fmt.Errorf("block by email %q in postgres: %w", input.Email, err) } record := authblock.BlockedEmailSubject{ Email: input.Email, ReasonCode: input.ReasonCode, BlockedAt: input.BlockedAt.UTC(), ResolvedUserID: resolvedUserID, } if err := upsertBlockedEmail(ctx, tx, record); err != nil { return fmt.Errorf("block by email %q in postgres: %w", input.Email, err) } result = ports.BlockResult{ Outcome: ports.AuthBlockOutcomeBlocked, UserID: resolvedUserID, } handled = true return nil }); err != nil { return ports.BlockResult{}, err } if !handled { return ports.BlockResult{}, fmt.Errorf("block by email %q in postgres: unhandled transaction outcome", input.Email) } return result, nil } // guard so external callers cannot mistake this file's helpers for a public // surface. var _ account.UserAccount = account.UserAccount{}