feat: use postgres

This commit is contained in:
Ilia Denisov
2026-04-26 20:34:39 +02:00
committed by GitHub
parent 48b0056b49
commit fe829285a6
365 changed files with 29223 additions and 24049 deletions
@@ -0,0 +1,280 @@
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{}