376 lines
13 KiB
Go
376 lines
13 KiB
Go
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)
|