package userstore import ( "context" "database/sql" "errors" "fmt" "time" pgtable "galaxy/user/internal/adapters/postgres/jet/user/table" "galaxy/user/internal/domain/account" "galaxy/user/internal/domain/common" "galaxy/user/internal/ports" pg "github.com/go-jet/jet/v2/postgres" ) // SQL constraint names declared in 00001_init.sql; referenced from error // translation so we can disambiguate UNIQUE violations on (email) versus // (user_name). const ( accountsEmailUniqueConstraint = "accounts_email_unique" accountsUserNameUniqueConstraint = "accounts_user_name_unique" ) // accountSelectColumns is the canonical SELECT list for accounts, matching // scanAccountRow's column order. var accountSelectColumns = pg.ColumnList{ pgtable.Accounts.UserID, pgtable.Accounts.Email, pgtable.Accounts.UserName, pgtable.Accounts.DisplayName, pgtable.Accounts.PreferredLanguage, pgtable.Accounts.TimeZone, pgtable.Accounts.DeclaredCountry, pgtable.Accounts.CreatedAt, pgtable.Accounts.UpdatedAt, pgtable.Accounts.DeletedAt, } // Create stores one new account record. Email and user-name uniqueness are // enforced by the schema; conflicts on those columns surface as // ports.ErrConflict (with ports.ErrUserNameConflict for the dedicated // user-name index). func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput) error { if err := input.Validate(); err != nil { return fmt.Errorf("create account in postgres: %w", err) } operationCtx, cancel, err := store.operationContext(ctx, "create account in postgres") if err != nil { return err } defer cancel() if err := insertAccount(operationCtx, store.db, input.Account); err != nil { return err } return nil } // insertAccount runs one INSERT against accounts using the supplied Queryer // (a *sql.DB or a *sql.Tx). It centralises the column list and error // translation used by Create and EnsureByEmail. func insertAccount(ctx context.Context, q queryer, record account.UserAccount) error { stmt := pgtable.Accounts.INSERT( pgtable.Accounts.UserID, pgtable.Accounts.Email, pgtable.Accounts.UserName, pgtable.Accounts.DisplayName, pgtable.Accounts.PreferredLanguage, pgtable.Accounts.TimeZone, pgtable.Accounts.DeclaredCountry, pgtable.Accounts.CreatedAt, pgtable.Accounts.UpdatedAt, pgtable.Accounts.DeletedAt, ).VALUES( record.UserID.String(), record.Email.String(), record.UserName.String(), record.DisplayName.String(), record.PreferredLanguage.String(), record.TimeZone.String(), nullableCountry(record.DeclaredCountry), record.CreatedAt.UTC(), record.UpdatedAt.UTC(), nullableTime(record.DeletedAt), ) query, args := stmt.Sql() _, err := q.ExecContext(ctx, query, args...) if err == nil { return nil } if mapped := classifyUniqueViolation(err, accountsUserNameUniqueConstraint, ports.ErrUserNameConflict); mapped != nil { return fmt.Errorf("create account %q in postgres: %w", record.UserID, mapped) } if isUniqueViolation(err) { return fmt.Errorf("create account %q in postgres: %w", record.UserID, ports.ErrConflict) } return fmt.Errorf("create account %q in postgres: %w", record.UserID, err) } // queryer is the subset of *sql.DB / *sql.Tx used by helpers that need to // run inside an existing transaction or against the bare pool. type queryer interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) } // GetByUserID returns the stored account identified by userID. func (store *Store) GetByUserID(ctx context.Context, userID common.UserID) (account.UserAccount, error) { if err := userID.Validate(); err != nil { return account.UserAccount{}, fmt.Errorf("get account by user id from postgres: %w", err) } operationCtx, cancel, err := store.operationContext(ctx, "get account by user id from postgres") if err != nil { return account.UserAccount{}, err } defer cancel() record, err := scanAccountByUserID(operationCtx, store.db, userID) switch { case errors.Is(err, ports.ErrNotFound): return account.UserAccount{}, fmt.Errorf("get account by user id %q from postgres: %w", userID, ports.ErrNotFound) case err != nil: return account.UserAccount{}, fmt.Errorf("get account by user id %q from postgres: %w", userID, err) } return record, nil } // GetByEmail returns the stored account identified by the normalized e-mail // address. func (store *Store) GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error) { if err := email.Validate(); err != nil { return account.UserAccount{}, fmt.Errorf("get account by email from postgres: %w", err) } operationCtx, cancel, err := store.operationContext(ctx, "get account by email from postgres") if err != nil { return account.UserAccount{}, err } defer cancel() record, err := scanAccountByEmail(operationCtx, store.db, email) switch { case errors.Is(err, ports.ErrNotFound): return account.UserAccount{}, fmt.Errorf("get account by email %q from postgres: %w", email, ports.ErrNotFound) case err != nil: return account.UserAccount{}, fmt.Errorf("get account by email %q from postgres: %w", email, err) } return record, nil } // GetByUserName returns the stored account identified by the exact stored // user name. func (store *Store) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) { if err := userName.Validate(); err != nil { return account.UserAccount{}, fmt.Errorf("get account by user name from postgres: %w", err) } operationCtx, cancel, err := store.operationContext(ctx, "get account by user name from postgres") if err != nil { return account.UserAccount{}, err } defer cancel() record, err := scanAccountByUserName(operationCtx, store.db, userName) switch { case errors.Is(err, ports.ErrNotFound): return account.UserAccount{}, fmt.Errorf("get account by user name %q from postgres: %w", userName, ports.ErrNotFound) case err != nil: return account.UserAccount{}, fmt.Errorf("get account by user name %q from postgres: %w", userName, err) } return record, nil } // ExistsByUserID reports whether userID currently identifies a stored account // that is not soft-deleted. Soft-deleted accounts are treated as non-existing // for external callers per Stage 22. func (store *Store) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) { if err := userID.Validate(); err != nil { return false, fmt.Errorf("exists by user id from postgres: %w", err) } operationCtx, cancel, err := store.operationContext(ctx, "exists by user id from postgres") if err != nil { return false, err } defer cancel() stmt := pg.SELECT(pgtable.Accounts.DeletedAt). FROM(pgtable.Accounts). WHERE(pgtable.Accounts.UserID.EQ(pg.String(userID.String()))) query, args := stmt.Sql() var deletedAt *time.Time err = store.db.QueryRowContext(operationCtx, query, args...).Scan(&deletedAt) switch { case errors.Is(err, sql.ErrNoRows): return false, nil case err != nil: return false, fmt.Errorf("exists by user id %q from postgres: %w", userID, err) } return deletedAt == nil, nil } // Update replaces the stored account state for record.UserID. Email and // user_name are immutable; mutation attempts return ports.ErrConflict. // declared_country, display_name, preferred_language, time_zone, updated_at, // and deleted_at are the columns affected. func (store *Store) Update(ctx context.Context, record account.UserAccount) error { if err := record.Validate(); err != nil { return fmt.Errorf("update account in postgres: %w", err) } return store.withTx(ctx, "update account in postgres", func(ctx context.Context, tx *sql.Tx) error { current, err := scanAccountForUpdate(ctx, tx, record.UserID) if err != nil { if errors.Is(err, ports.ErrNotFound) { return fmt.Errorf("update account %q in postgres: %w", record.UserID, ports.ErrNotFound) } return fmt.Errorf("update account %q in postgres: %w", record.UserID, err) } if current.Email != record.Email || current.UserName != record.UserName { return fmt.Errorf("update account %q in postgres: %w", record.UserID, ports.ErrConflict) } stmt := pgtable.Accounts.UPDATE( pgtable.Accounts.DisplayName, pgtable.Accounts.PreferredLanguage, pgtable.Accounts.TimeZone, pgtable.Accounts.DeclaredCountry, pgtable.Accounts.UpdatedAt, pgtable.Accounts.DeletedAt, ).SET( record.DisplayName.String(), record.PreferredLanguage.String(), record.TimeZone.String(), nullableCountry(record.DeclaredCountry), record.UpdatedAt.UTC(), nullableTime(record.DeletedAt), ).WHERE(pgtable.Accounts.UserID.EQ(pg.String(record.UserID.String()))) query, args := stmt.Sql() if _, err := tx.ExecContext(ctx, query, args...); err != nil { return fmt.Errorf("update account %q in postgres: %w", record.UserID, err) } return nil }) } // scanAccountByUserID is a thin wrapper around scanAccountWhere for the // (user_id) column so atomic flows can reuse the same scanner with FOR // UPDATE locking semantics. func scanAccountByUserID(ctx context.Context, q queryer, userID common.UserID) (account.UserAccount, error) { return scanAccountWhere(ctx, q, pgtable.Accounts.UserID.EQ(pg.String(userID.String())), false) } func scanAccountByEmail(ctx context.Context, q queryer, email common.Email) (account.UserAccount, error) { return scanAccountWhere(ctx, q, pgtable.Accounts.Email.EQ(pg.String(email.String())), false) } func scanAccountByUserName(ctx context.Context, q queryer, userName common.UserName) (account.UserAccount, error) { return scanAccountWhere(ctx, q, pgtable.Accounts.UserName.EQ(pg.String(userName.String())), false) } func scanAccountForUpdate(ctx context.Context, q queryer, userID common.UserID) (account.UserAccount, error) { return scanAccountWhere(ctx, q, pgtable.Accounts.UserID.EQ(pg.String(userID.String())), true) } func scanAccountForUpdateByEmail(ctx context.Context, q queryer, email common.Email) (account.UserAccount, error) { return scanAccountWhere(ctx, q, pgtable.Accounts.Email.EQ(pg.String(email.String())), true) } func scanAccountWhere(ctx context.Context, q queryer, condition pg.BoolExpression, forUpdate bool) (account.UserAccount, error) { stmt := pg.SELECT(accountSelectColumns). FROM(pgtable.Accounts). WHERE(condition) if forUpdate { stmt = stmt.FOR(pg.UPDATE()) } query, args := stmt.Sql() row := q.QueryRowContext(ctx, query, args...) return scanAccountRow(row) } func scanAccountRow(row *sql.Row) (account.UserAccount, error) { var ( record account.UserAccount userID string email string userName string displayName string preferredLang string timeZone string declaredCountry *string createdAt time.Time updatedAt time.Time deletedAt *time.Time ) if err := row.Scan( &userID, &email, &userName, &displayName, &preferredLang, &timeZone, &declaredCountry, &createdAt, &updatedAt, &deletedAt, ); err != nil { return account.UserAccount{}, mapNotFound(err) } record.UserID = common.UserID(userID) record.Email = common.Email(email) record.UserName = common.UserName(userName) record.DisplayName = common.DisplayName(displayName) record.PreferredLanguage = common.LanguageTag(preferredLang) record.TimeZone = common.TimeZoneName(timeZone) if declaredCountry != nil { record.DeclaredCountry = common.CountryCode(*declaredCountry) } record.CreatedAt = createdAt.UTC() record.UpdatedAt = updatedAt.UTC() record.DeletedAt = timeFromNullable(deletedAt) return record, nil } // AccountStore adapts Store to the UserAccountStore port. The wrapper is // returned by Store.Accounts() so callers that need only the narrow port // interface remain unaware of the broader Store surface. type AccountStore struct { store *Store } // Accounts returns one adapter that exposes the user-account store port over // Store. func (store *Store) Accounts() *AccountStore { if store == nil { return nil } return &AccountStore{store: store} } // Create stores one new account record. func (adapter *AccountStore) Create(ctx context.Context, input ports.CreateAccountInput) error { return adapter.store.Create(ctx, input) } // GetByUserID returns the stored account identified by userID. func (adapter *AccountStore) GetByUserID(ctx context.Context, userID common.UserID) (account.UserAccount, error) { return adapter.store.GetByUserID(ctx, userID) } // GetByEmail returns the stored account identified by email. func (adapter *AccountStore) GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error) { return adapter.store.GetByEmail(ctx, email) } // GetByUserName returns the stored account identified by userName. func (adapter *AccountStore) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) { return adapter.store.GetByUserName(ctx, userName) } // ExistsByUserID reports whether userID currently identifies a stored // account. func (adapter *AccountStore) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) { return adapter.store.ExistsByUserID(ctx, userID) } // Update replaces the stored account state for record.UserID. func (adapter *AccountStore) Update(ctx context.Context, record account.UserAccount) error { return adapter.store.Update(ctx, record) } var _ ports.UserAccountStore = (*AccountStore)(nil)