package admin 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" ) // adminAccountsPrimaryKey is the constraint name surfaced on the // primary-key UNIQUE violation when a duplicate username is inserted. // Postgres synthesises the constraint name as `_pkey` for // primary-key constraints, which matches the migration in // `backend/internal/postgres/migrations/00001_init.sql:199`. const adminAccountsPrimaryKey = "admin_accounts_pkey" // Store is the Postgres-backed query surface for the admin package. // 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} } // adminColumnList is the canonical projection used by every read path. // The slice ordering matches the destination struct fields. func adminColumnList() postgres.ColumnList { return postgres.ColumnList{ table.AdminAccounts.Username, table.AdminAccounts.PasswordHash, table.AdminAccounts.CreatedAt, table.AdminAccounts.LastUsedAt, table.AdminAccounts.DisabledAt, } } // Lookup returns the admin row and its bcrypt hash for username. // Returns ErrNotFound when no row matches. func (s *Store) Lookup(ctx context.Context, username string) (Admin, []byte, error) { stmt := postgres.SELECT(adminColumnList()). FROM(table.AdminAccounts). WHERE(table.AdminAccounts.Username.EQ(postgres.String(username))). LIMIT(1) var row model.AdminAccounts if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Admin{}, nil, ErrNotFound } return Admin{}, nil, fmt.Errorf("admin store: lookup %q: %w", username, err) } admin, hash := modelToAdmin(row) return admin, hash, nil } // ListAll returns every admin row paired with its bcrypt hash, ordered // by username ASC. Used by Cache.Warm and by the List handler (the // hashes are dropped before the handler sends a response, but Warm // needs them so Verify can match without a follow-up query). func (s *Store) ListAll(ctx context.Context) ([]Admin, [][]byte, error) { stmt := postgres.SELECT(adminColumnList()). FROM(table.AdminAccounts). ORDER_BY(table.AdminAccounts.Username.ASC()) var rows []model.AdminAccounts if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, nil, fmt.Errorf("admin store: list: %w", err) } admins := make([]Admin, 0, len(rows)) hashes := make([][]byte, 0, len(rows)) for _, row := range rows { admin, hash := modelToAdmin(row) admins = append(admins, admin) hashes = append(hashes, hash) } return admins, hashes, nil } // Insert persists a fresh admin row. Returns ErrUsernameTaken when the // primary-key UNIQUE constraint is violated (concurrent or repeat // Create). func (s *Store) Insert(ctx context.Context, username string, passwordHash []byte) (Admin, error) { stmt := table.AdminAccounts. INSERT(table.AdminAccounts.Username, table.AdminAccounts.PasswordHash). VALUES(username, passwordHash). RETURNING(adminColumnList()) var row model.AdminAccounts if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if isUniqueViolation(err, adminAccountsPrimaryKey) { return Admin{}, ErrUsernameTaken } return Admin{}, fmt.Errorf("admin store: insert %q: %w", username, err) } admin, _ := modelToAdmin(row) return admin, nil } // UpdatePasswordHash replaces the stored bcrypt hash for username. // Returns ErrNotFound when no row matches. func (s *Store) UpdatePasswordHash(ctx context.Context, username string, passwordHash []byte) (Admin, error) { stmt := table.AdminAccounts. UPDATE(table.AdminAccounts.PasswordHash). SET(passwordHash). WHERE(table.AdminAccounts.Username.EQ(postgres.String(username))). RETURNING(adminColumnList()) var row model.AdminAccounts if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Admin{}, ErrNotFound } return Admin{}, fmt.Errorf("admin store: update password for %q: %w", username, err) } admin, _ := modelToAdmin(row) return admin, nil } // SetDisabledAt patches `disabled_at` for username. Pass `&time` to // disable, `nil` to re-enable. Returns the refreshed Admin together // with its bcrypt hash so the cache stays consistent. Returns // ErrNotFound when no row matches. func (s *Store) SetDisabledAt(ctx context.Context, username string, disabledAt *time.Time) (Admin, []byte, error) { var disabledExpr postgres.Expression if disabledAt != nil { disabledExpr = postgres.TimestampzT(*disabledAt) } else { disabledExpr = postgres.TimestampzExp(postgres.NULL) } stmt := table.AdminAccounts. UPDATE(table.AdminAccounts.DisabledAt). SET(disabledExpr). WHERE(table.AdminAccounts.Username.EQ(postgres.String(username))). RETURNING(adminColumnList()) var row model.AdminAccounts if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Admin{}, nil, ErrNotFound } return Admin{}, nil, fmt.Errorf("admin store: set disabled_at for %q: %w", username, err) } admin, hash := modelToAdmin(row) return admin, hash, nil } // TouchLastUsed bumps last_used_at on a successful Verify. The caller // runs the update fire-and-forget; errors are returned for logging // but never propagated to the request. func (s *Store) TouchLastUsed(ctx context.Context, username string, now time.Time) error { stmt := table.AdminAccounts. UPDATE(table.AdminAccounts.LastUsedAt). SET(postgres.TimestampzT(now)). WHERE(table.AdminAccounts.Username.EQ(postgres.String(username))) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("admin store: touch last_used_at for %q: %w", username, err) } return nil } // BootstrapInsert inserts the seed admin row when no row with the // supplied username exists. The boolean reports whether the insert // happened (true) or was skipped because of an existing row (false). // // Idempotent across restarts: subsequent calls with the same username // return false without modifying the password hash. Operators rotate // the seed admin's password through `ResetPassword`, not by editing // env vars and restarting. func (s *Store) BootstrapInsert(ctx context.Context, username string, passwordHash []byte) (bool, error) { stmt := table.AdminAccounts. INSERT(table.AdminAccounts.Username, table.AdminAccounts.PasswordHash). VALUES(username, passwordHash). ON_CONFLICT(table.AdminAccounts.Username). DO_NOTHING() res, err := stmt.ExecContext(ctx, s.db) if err != nil { return false, fmt.Errorf("admin store: bootstrap insert %q: %w", username, err) } affected, err := res.RowsAffected() if err != nil { return false, fmt.Errorf("admin store: bootstrap rows-affected: %w", err) } return affected > 0, nil } // modelToAdmin projects a generated model row into the public Admin // struct plus the raw password hash. The conversion centralises the // pointer-copy of nullable timestamps so each method stays a one-liner. func modelToAdmin(row model.AdminAccounts) (Admin, []byte) { admin := Admin{ Username: row.Username, CreatedAt: row.CreatedAt, } if row.LastUsedAt != nil { t := *row.LastUsedAt admin.LastUsedAt = &t } if row.DisabledAt != nil { t := *row.DisabledAt admin.DisabledAt = &t } return admin, row.PasswordHash }