package user import ( "context" "database/sql" "errors" "fmt" "time" "galaxy/backend/internal/postgres/jet/backend/model" "galaxy/backend/internal/postgres/jet/backend/table" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" ) // Store is the Postgres-backed query surface for the user package. // All queries are built through go-jet against the generated table // bindings under `backend/internal/postgres/jet/backend/table`. type Store struct { db *sql.DB } // NewStore constructs a Store wrapping db. func NewStore(db *sql.DB) *Store { return &Store{db: db} } // AccountRow mirrors a row in `backend.accounts` with the specific // projection the user-package read paths need. It is not a full // representation of the table; column subsets like the audit trail are // folded into Account by the Service layer. type AccountRow struct { UserID uuid.UUID Email string UserName string DisplayName string PreferredLanguage string TimeZone string DeclaredCountry string PermanentBlock bool CreatedAt time.Time UpdatedAt time.Time DeletedAt *time.Time } // accountInsert is the parameter struct for InsertAccountWithSnapshot. type accountInsert struct { UserID uuid.UUID Email string UserName string PreferredLanguage string TimeZone string DeclaredCountry string } // settingsPatch carries the optional settings columns supplied by an // `UpdateSettingsInput`. Nil pointers mean "leave the column alone". type settingsPatch struct { PreferredLanguage *string TimeZone *string } func (p settingsPatch) empty() bool { return p.PreferredLanguage == nil && p.TimeZone == nil } // sanctionInsert is the parameter struct for ApplySanctionTx. type sanctionInsert struct { UserID uuid.UUID SanctionCode string Scope string ReasonCode string ActorType string ActorID string AppliedAt time.Time ExpiresAt *time.Time FlipPermanent bool } // limitInsert is the parameter struct for ApplyLimitTx. type limitInsert struct { UserID uuid.UUID LimitCode string Value int32 ReasonCode string ActorType string ActorID string AppliedAt time.Time ExpiresAt *time.Time } // errEmailRace is a sentinel returned by InsertAccountWithSnapshot when // the ON CONFLICT (email) DO NOTHING branch fires. The caller looks up // the existing user_id and returns it instead. var errEmailRace = errors.New("user store: email already exists") // accountColumns is the canonical projection used by every read of // `backend.accounts`. Centralised so the model-row → AccountRow // converter stays in sync with the SELECT order. func accountColumns() postgres.ColumnList { a := table.Accounts return postgres.ColumnList{ a.UserID, a.Email, a.UserName, a.DisplayName, a.PreferredLanguage, a.TimeZone, a.DeclaredCountry, a.PermanentBlock, a.CreatedAt, a.UpdatedAt, a.DeletedAt, } } // snapshotColumns is the canonical projection used by every read of // `backend.entitlement_snapshots`. func snapshotColumns() postgres.ColumnList { s := table.EntitlementSnapshots return postgres.ColumnList{ s.UserID, s.Tier, s.IsPaid, s.Source, s.ActorType, s.ActorID, s.ReasonCode, s.StartsAt, s.EndsAt, s.MaxRegisteredRaceNames, s.UpdatedAt, } } // LookupAccountIDByEmail returns the user_id of the live account for // email. The boolean reports whether a row was found. Soft-deleted // rows are skipped. func (s *Store) LookupAccountIDByEmail(ctx context.Context, email string) (uuid.UUID, bool, error) { stmt := postgres.SELECT(table.Accounts.UserID). FROM(table.Accounts). WHERE( table.Accounts.Email.EQ(postgres.String(email)). AND(table.Accounts.DeletedAt.IS_NULL()), ). LIMIT(1) var row model.Accounts if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return uuid.Nil, false, nil } return uuid.Nil, false, err } return row.UserID, true, nil } // LookupAccount returns the AccountRow projection for userID. Soft-deleted // rows are excluded; returns ErrAccountNotFound when no live row exists. func (s *Store) LookupAccount(ctx context.Context, userID uuid.UUID) (AccountRow, error) { stmt := postgres.SELECT(accountColumns()). FROM(table.Accounts). WHERE( table.Accounts.UserID.EQ(postgres.UUID(userID)). AND(table.Accounts.DeletedAt.IS_NULL()), ). LIMIT(1) var row model.Accounts if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return AccountRow{}, ErrAccountNotFound } return AccountRow{}, fmt.Errorf("user store: scan account: %w", err) } return modelToAccountRow(row), nil } // ListAccountRows returns the requested page of live accounts together // with the total live-row count for pagination. func (s *Store) ListAccountRows(ctx context.Context, page, pageSize int) ([]AccountRow, int, error) { a := table.Accounts totalStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")). FROM(a). WHERE(a.DeletedAt.IS_NULL()) var totalDest struct { Count int64 `alias:"count"` } if err := totalStmt.QueryContext(ctx, s.db, &totalDest); err != nil { return nil, 0, fmt.Errorf("user store: count accounts: %w", err) } offset := (page - 1) * pageSize listStmt := postgres.SELECT(accountColumns()). FROM(a). WHERE(a.DeletedAt.IS_NULL()). ORDER_BY(a.CreatedAt.DESC(), a.UserID.DESC()). LIMIT(int64(pageSize)).OFFSET(int64(offset)) var rows []model.Accounts if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, 0, fmt.Errorf("user store: list accounts: %w", err) } out := make([]AccountRow, 0, len(rows)) for _, row := range rows { out = append(out, modelToAccountRow(row)) } return out, int(totalDest.Count), nil } // InsertAccountWithSnapshot persists a brand-new accounts row and the // matching default entitlement snapshot in one transaction. On // ON CONFLICT (email) DO NOTHING it returns errEmailRace so the caller // can recover the existing user_id; on user_name UNIQUE violation it // returns the underlying pgconn error so the caller can retry the // suffix. func (s *Store) InsertAccountWithSnapshot(ctx context.Context, account accountInsert, snapshot EntitlementSnapshot) (uuid.UUID, error) { var declaredCountryArg postgres.Expression = postgres.StringExp(postgres.NULL) if account.DeclaredCountry != "" { declaredCountryArg = postgres.String(account.DeclaredCountry) } var insertedID uuid.UUID err := withTx(ctx, s.db, func(tx *sql.Tx) error { insertStmt := table.Accounts.INSERT( table.Accounts.UserID, table.Accounts.Email, table.Accounts.UserName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.DeclaredCountry, ).VALUES( account.UserID, account.Email, account.UserName, account.PreferredLanguage, account.TimeZone, declaredCountryArg, ). ON_CONFLICT(table.Accounts.Email).DO_NOTHING(). RETURNING(table.Accounts.UserID) var inserted model.Accounts if err := insertStmt.QueryContext(ctx, tx, &inserted); err != nil { if errors.Is(err, qrm.ErrNoRows) { return errEmailRace } return err } insertedID = inserted.UserID return insertSnapshotTx(ctx, tx, snapshot) }) if err != nil { return uuid.Nil, err } return insertedID, nil } // LookupEntitlementSnapshot loads the snapshot row for userID. Returns // ErrAccountNotFound when no row exists (a fresh account without a // snapshot is treated as "account not found" — the bootstrap path // always inserts the default snapshot). func (s *Store) LookupEntitlementSnapshot(ctx context.Context, userID uuid.UUID) (EntitlementSnapshot, error) { stmt := postgres.SELECT(snapshotColumns()). FROM(table.EntitlementSnapshots). WHERE(table.EntitlementSnapshots.UserID.EQ(postgres.UUID(userID))). LIMIT(1) var row model.EntitlementSnapshots if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return EntitlementSnapshot{}, ErrAccountNotFound } return EntitlementSnapshot{}, fmt.Errorf("user store: lookup snapshot for %s: %w", userID, err) } return modelToSnapshot(row), nil } // ListEntitlementSnapshots loads every snapshot row. Cache.Warm calls // this at process boot. func (s *Store) ListEntitlementSnapshots(ctx context.Context) ([]EntitlementSnapshot, error) { stmt := postgres.SELECT(snapshotColumns()).FROM(table.EntitlementSnapshots) var rows []model.EntitlementSnapshots if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("user store: list snapshots: %w", err) } out := make([]EntitlementSnapshot, 0, len(rows)) for _, row := range rows { out = append(out, modelToSnapshot(row)) } return out, nil } // ListActiveSanctions returns the active sanctions for userID joined // with the audit columns from the underlying records row. Order is // applied_at DESC so the most recent sanction surfaces first. func (s *Store) ListActiveSanctions(ctx context.Context, userID uuid.UUID) ([]ActiveSanction, error) { a := table.SanctionActive r := table.SanctionRecords stmt := postgres.SELECT( r.SanctionCode, r.Scope, r.ReasonCode, r.ActorType, r.ActorID, r.AppliedAt, r.ExpiresAt, ). FROM(a.INNER_JOIN(r, r.RecordID.EQ(a.RecordID))). WHERE(a.UserID.EQ(postgres.UUID(userID))). ORDER_BY(r.AppliedAt.DESC()) var rows []model.SanctionRecords if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("user store: list active sanctions: %w", err) } out := make([]ActiveSanction, 0, len(rows)) for _, row := range rows { entry := ActiveSanction{ SanctionCode: row.SanctionCode, Scope: row.Scope, ReasonCode: row.ReasonCode, Actor: ActorRef{Type: row.ActorType, ID: derefString(row.ActorID)}, AppliedAt: row.AppliedAt, } if row.ExpiresAt != nil { t := *row.ExpiresAt entry.ExpiresAt = &t } out = append(out, entry) } return out, nil } // ListActiveLimits returns the active limits for userID joined with // the audit columns from the underlying records row. func (s *Store) ListActiveLimits(ctx context.Context, userID uuid.UUID) ([]ActiveLimit, error) { a := table.LimitActive r := table.LimitRecords stmt := postgres.SELECT( r.LimitCode, a.Value, r.ReasonCode, r.ActorType, r.ActorID, r.AppliedAt, r.ExpiresAt, ). FROM(a.INNER_JOIN(r, r.RecordID.EQ(a.RecordID))). WHERE(a.UserID.EQ(postgres.UUID(userID))). ORDER_BY(r.AppliedAt.DESC()) var rows []struct { LimitRecords model.LimitRecords LimitActive model.LimitActive } if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("user store: list active limits: %w", err) } out := make([]ActiveLimit, 0, len(rows)) for _, row := range rows { entry := ActiveLimit{ LimitCode: row.LimitRecords.LimitCode, Value: row.LimitActive.Value, ReasonCode: row.LimitRecords.ReasonCode, Actor: ActorRef{Type: row.LimitRecords.ActorType, ID: derefString(row.LimitRecords.ActorID)}, AppliedAt: row.LimitRecords.AppliedAt, } if row.LimitRecords.ExpiresAt != nil { t := *row.LimitRecords.ExpiresAt entry.ExpiresAt = &t } out = append(out, entry) } return out, nil } // UpdateAccountDisplayName patches accounts.display_name and bumps // updated_at. Returns ErrAccountNotFound when no live row matches. func (s *Store) UpdateAccountDisplayName(ctx context.Context, userID uuid.UUID, displayName string, now time.Time) error { a := table.Accounts stmt := a.UPDATE(a.DisplayName, a.UpdatedAt). SET(displayName, now). WHERE( a.UserID.EQ(postgres.UUID(userID)). AND(a.DeletedAt.IS_NULL()), ) res, err := stmt.ExecContext(ctx, s.db) if err != nil { return fmt.Errorf("user store: update display_name: %w", err) } return rowsAffectedOrNotFound(res) } // UpdateAccountSettings patches the supplied settings columns and bumps // updated_at. Empty patches are a precondition error from the caller. func (s *Store) UpdateAccountSettings(ctx context.Context, userID uuid.UUID, patch settingsPatch, now time.Time) error { if patch.empty() { return fmt.Errorf("user store: update settings: empty patch") } a := table.Accounts rest := make([]any, 0, 2) if patch.PreferredLanguage != nil { rest = append(rest, a.PreferredLanguage.SET(postgres.String(*patch.PreferredLanguage))) } if patch.TimeZone != nil { rest = append(rest, a.TimeZone.SET(postgres.String(*patch.TimeZone))) } stmt := a.UPDATE(). SET(a.UpdatedAt.SET(postgres.TimestampzT(now)), rest...). WHERE( a.UserID.EQ(postgres.UUID(userID)). AND(a.DeletedAt.IS_NULL()), ) res, err := stmt.ExecContext(ctx, s.db) if err != nil { return fmt.Errorf("user store: update settings: %w", err) } return rowsAffectedOrNotFound(res) } // ApplyEntitlementTx persists a fresh entitlement_records row and // upserts the matching entitlement_snapshots row in one transaction. // Returns the persisted snapshot exactly as stored (created_at is the // input UpdatedAt, etc.). func (s *Store) ApplyEntitlementTx(ctx context.Context, snap EntitlementSnapshot) (EntitlementSnapshot, error) { if err := s.assertAccountLive(ctx, snap.UserID); err != nil { return EntitlementSnapshot{}, err } err := withTx(ctx, s.db, func(tx *sql.Tx) error { recordID := uuid.New() actorID := nullableString(snap.Actor.ID) var endsAt any if snap.EndsAt != nil { endsAt = *snap.EndsAt } recordStmt := table.EntitlementRecords.INSERT( table.EntitlementRecords.RecordID, table.EntitlementRecords.UserID, table.EntitlementRecords.Tier, table.EntitlementRecords.IsPaid, table.EntitlementRecords.Source, table.EntitlementRecords.ActorType, table.EntitlementRecords.ActorID, table.EntitlementRecords.ReasonCode, table.EntitlementRecords.StartsAt, table.EntitlementRecords.EndsAt, table.EntitlementRecords.CreatedAt, ).VALUES( recordID, snap.UserID, snap.Tier, snap.IsPaid, snap.Source, snap.Actor.Type, actorID, snap.ReasonCode, snap.StartsAt, endsAt, snap.UpdatedAt, ) if _, err := recordStmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert entitlement record: %w", err) } return upsertSnapshotTx(ctx, tx, snap) }) if err != nil { return EntitlementSnapshot{}, err } return snap, nil } // ApplySanctionTx persists a fresh sanction_records row, upserts // sanction_active, and (when alsoFlipPermanent is set) flips // accounts.permanent_block to true — all in one transaction. func (s *Store) ApplySanctionTx(ctx context.Context, input sanctionInsert) error { if err := s.assertAccountLive(ctx, input.UserID); err != nil { return err } return withTx(ctx, s.db, func(tx *sql.Tx) error { recordID := uuid.New() actorID := nullableString(input.ActorID) var expiresAt any if input.ExpiresAt != nil { expiresAt = *input.ExpiresAt } recordStmt := table.SanctionRecords.INSERT( table.SanctionRecords.RecordID, table.SanctionRecords.UserID, table.SanctionRecords.SanctionCode, table.SanctionRecords.Scope, table.SanctionRecords.ReasonCode, table.SanctionRecords.ActorType, table.SanctionRecords.ActorID, table.SanctionRecords.AppliedAt, table.SanctionRecords.ExpiresAt, ).VALUES( recordID, input.UserID, input.SanctionCode, input.Scope, input.ReasonCode, input.ActorType, actorID, input.AppliedAt, expiresAt, ) if _, err := recordStmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert sanction record: %w", err) } sa := table.SanctionActive activeStmt := sa.INSERT(sa.UserID, sa.SanctionCode, sa.RecordID). VALUES(input.UserID, input.SanctionCode, recordID). ON_CONFLICT(sa.UserID, sa.SanctionCode). DO_UPDATE(postgres.SET( sa.RecordID.SET(sa.EXCLUDED.RecordID), )) if _, err := activeStmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("upsert sanction_active: %w", err) } if input.FlipPermanent { a := table.Accounts permStmt := a.UPDATE(). SET( a.PermanentBlock.SET(postgres.Bool(true)), a.UpdatedAt.SET(postgres.TimestampzT(input.AppliedAt)), ). WHERE( a.UserID.EQ(postgres.UUID(input.UserID)). AND(a.DeletedAt.IS_NULL()), ) if _, err := permStmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("flip permanent_block: %w", err) } } return nil }) } // ApplyLimitTx persists a fresh limit_records row and upserts // limit_active in one transaction. func (s *Store) ApplyLimitTx(ctx context.Context, input limitInsert) error { if err := s.assertAccountLive(ctx, input.UserID); err != nil { return err } return withTx(ctx, s.db, func(tx *sql.Tx) error { recordID := uuid.New() actorID := nullableString(input.ActorID) var expiresAt any if input.ExpiresAt != nil { expiresAt = *input.ExpiresAt } recordStmt := table.LimitRecords.INSERT( table.LimitRecords.RecordID, table.LimitRecords.UserID, table.LimitRecords.LimitCode, table.LimitRecords.Value, table.LimitRecords.ReasonCode, table.LimitRecords.ActorType, table.LimitRecords.ActorID, table.LimitRecords.AppliedAt, table.LimitRecords.ExpiresAt, ).VALUES( recordID, input.UserID, input.LimitCode, input.Value, input.ReasonCode, input.ActorType, actorID, input.AppliedAt, expiresAt, ) if _, err := recordStmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert limit record: %w", err) } la := table.LimitActive activeStmt := la.INSERT(la.UserID, la.LimitCode, la.RecordID, la.Value). VALUES(input.UserID, input.LimitCode, recordID, input.Value). ON_CONFLICT(la.UserID, la.LimitCode). DO_UPDATE(postgres.SET( la.RecordID.SET(la.EXCLUDED.RecordID), la.Value.SET(la.EXCLUDED.Value), )) if _, err := activeStmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("upsert limit_active: %w", err) } return nil }) } // SoftDeleteAccount marks the account soft-deleted with the supplied // actor trail. The boolean reports whether the row actually changed // (true for a fresh delete; false when the row was already // soft-deleted or does not exist). The caller distinguishes "already // gone" from "never existed" by reading the row separately when it // matters; for the cascade orchestration "no change" is treated as a // successful idempotent operation. func (s *Store) SoftDeleteAccount(ctx context.Context, userID uuid.UUID, actor ActorRef, now time.Time) (bool, error) { a := table.Accounts actorIDExpr := nullableStringExpr(actor.ID) stmt := a.UPDATE(). SET( a.DeletedAt.SET(postgres.TimestampzT(now)), a.DeletedActorType.SET(postgres.String(actor.Type)), a.DeletedActorID.SET(actorIDExpr), a.UpdatedAt.SET(postgres.TimestampzT(now)), ). WHERE( a.UserID.EQ(postgres.UUID(userID)). AND(a.DeletedAt.IS_NULL()), ) res, err := stmt.ExecContext(ctx, s.db) if err != nil { return false, fmt.Errorf("user store: soft delete %s: %w", userID, err) } affected, err := res.RowsAffected() if err != nil { return false, fmt.Errorf("user store: soft delete rows-affected: %w", err) } return affected > 0, nil } // assertAccountLive returns ErrAccountNotFound when userID does not // match a live accounts row. Used by the mutation paths to fail fast // before opening a transaction. func (s *Store) assertAccountLive(ctx context.Context, userID uuid.UUID) error { a := table.Accounts stmt := postgres.SELECT(a.UserID). FROM(a). WHERE( a.UserID.EQ(postgres.UUID(userID)). AND(a.DeletedAt.IS_NULL()), ). LIMIT(1) var row model.Accounts if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return ErrAccountNotFound } return fmt.Errorf("user store: account live-check: %w", err) } return nil } func insertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot) error { es := table.EntitlementSnapshots actorID := nullableString(snap.Actor.ID) var endsAt any if snap.EndsAt != nil { endsAt = *snap.EndsAt } stmt := es.INSERT( es.UserID, es.Tier, es.IsPaid, es.Source, es.ActorType, es.ActorID, es.ReasonCode, es.StartsAt, es.EndsAt, es.MaxRegisteredRaceNames, es.UpdatedAt, ).VALUES( snap.UserID, snap.Tier, snap.IsPaid, snap.Source, snap.Actor.Type, actorID, snap.ReasonCode, snap.StartsAt, endsAt, snap.MaxRegisteredRaceNames, snap.UpdatedAt, ) if _, err := stmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert entitlement_snapshots: %w", err) } return nil } func upsertSnapshotTx(ctx context.Context, tx *sql.Tx, snap EntitlementSnapshot) error { es := table.EntitlementSnapshots actorID := nullableString(snap.Actor.ID) var endsAt any if snap.EndsAt != nil { endsAt = *snap.EndsAt } stmt := es.INSERT( es.UserID, es.Tier, es.IsPaid, es.Source, es.ActorType, es.ActorID, es.ReasonCode, es.StartsAt, es.EndsAt, es.MaxRegisteredRaceNames, es.UpdatedAt, ).VALUES( snap.UserID, snap.Tier, snap.IsPaid, snap.Source, snap.Actor.Type, actorID, snap.ReasonCode, snap.StartsAt, endsAt, snap.MaxRegisteredRaceNames, snap.UpdatedAt, ). ON_CONFLICT(es.UserID). DO_UPDATE(postgres.SET( es.Tier.SET(es.EXCLUDED.Tier), es.IsPaid.SET(es.EXCLUDED.IsPaid), es.Source.SET(es.EXCLUDED.Source), es.ActorType.SET(es.EXCLUDED.ActorType), es.ActorID.SET(es.EXCLUDED.ActorID), es.ReasonCode.SET(es.EXCLUDED.ReasonCode), es.StartsAt.SET(es.EXCLUDED.StartsAt), es.EndsAt.SET(es.EXCLUDED.EndsAt), es.MaxRegisteredRaceNames.SET(es.EXCLUDED.MaxRegisteredRaceNames), es.UpdatedAt.SET(es.EXCLUDED.UpdatedAt), )) if _, err := stmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("upsert entitlement_snapshots: %w", err) } return nil } // modelToAccountRow projects a generated model row onto the public // AccountRow struct. The DeclaredCountry field is collapsed from // nullable to "" by the projection. func modelToAccountRow(row model.Accounts) AccountRow { out := AccountRow{ UserID: row.UserID, Email: row.Email, UserName: row.UserName, DisplayName: row.DisplayName, PreferredLanguage: row.PreferredLanguage, TimeZone: row.TimeZone, PermanentBlock: row.PermanentBlock, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } if row.DeclaredCountry != nil { out.DeclaredCountry = *row.DeclaredCountry } if row.DeletedAt != nil { t := *row.DeletedAt out.DeletedAt = &t } return out } // modelToSnapshot projects a generated model row onto the public // EntitlementSnapshot struct. func modelToSnapshot(row model.EntitlementSnapshots) EntitlementSnapshot { out := EntitlementSnapshot{ UserID: row.UserID, Tier: row.Tier, IsPaid: row.IsPaid, Source: row.Source, Actor: ActorRef{Type: row.ActorType, ID: derefString(row.ActorID)}, ReasonCode: row.ReasonCode, StartsAt: row.StartsAt, MaxRegisteredRaceNames: row.MaxRegisteredRaceNames, UpdatedAt: row.UpdatedAt, } if row.EndsAt != nil { t := *row.EndsAt out.EndsAt = &t } return out } // nullableString converts a Go string to the `any` form expected by jet // VALUES: an empty string becomes nil so the column receives NULL. func nullableString(v string) any { if v == "" { return nil } return v } // nullableStringExpr returns a typed jet expression: the empty string // produces NULL, otherwise a String literal. Used by UPDATE SET paths // where jet's SET wants a typed Expression rather than `any`. func nullableStringExpr(v string) postgres.StringExpression { if v == "" { return postgres.StringExp(postgres.NULL) } return postgres.String(v) } // derefString returns the empty string when p is nil, otherwise *p. func derefString(p *string) string { if p == nil { return "" } return *p } // rowsAffectedOrNotFound returns ErrAccountNotFound when the UPDATE // affected zero rows, nil otherwise. Used by the account-mutation paths // that need fail-fast on a missing/soft-deleted target. func rowsAffectedOrNotFound(res sql.Result) error { if res == nil { return nil } affected, err := res.RowsAffected() if err != nil { return fmt.Errorf("user store: rows affected: %w", err) } if affected == 0 { return ErrAccountNotFound } return nil } // withTx wraps fn in a Postgres transaction. fn's return value // determines commit (nil) vs rollback (non-nil). Rollback errors are // swallowed when fn already returned an error, since the latter is // more actionable. func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("user store: begin tx: %w", err) } if err := fn(tx); err != nil { _ = tx.Rollback() return err } if err := tx.Commit(); err != nil { return fmt.Errorf("user store: commit tx: %w", err) } return nil }