diff --git a/backend/internal/store/bookmarks.go b/backend/internal/store/bookmarks.go new file mode 100644 index 0000000..88749ef --- /dev/null +++ b/backend/internal/store/bookmarks.go @@ -0,0 +1,48 @@ +package store + +import "context" + +func (s *Store) InsertBookmark(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error) { + var id int64 + err := s.p.QueryRow(ctx, + `INSERT INTO bookmarks (user_id, library_id, book_path, locator, percent, note) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, + userID, libID, bookPath, locator, percent, note).Scan(&id) + return id, err +} + +func (s *Store) ListBookmarks(ctx context.Context, userID, libID int64, bookPath string) ([]Bookmark, error) { + rows, err := s.p.Query(ctx, + `SELECT id, library_id, book_path, locator, percent, note, created_at + FROM bookmarks WHERE user_id=$1 AND library_id=$2 AND book_path=$3 + ORDER BY percent ASC, id ASC`, userID, libID, bookPath) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Bookmark + for rows.Next() { + var b Bookmark + if err := rows.Scan(&b.ID, &b.LibraryID, &b.BookPath, &b.Locator, &b.Percent, &b.Note, &b.CreatedAt); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +func (s *Store) UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error) { + res, err := s.p.Exec(ctx, `UPDATE bookmarks SET note=$3 WHERE id=$1 AND user_id=$2`, id, userID, note) + if err != nil { + return false, err + } + return res.RowsAffected() > 0, nil +} + +func (s *Store) DeleteBookmark(ctx context.Context, userID, id int64) (bool, error) { + res, err := s.p.Exec(ctx, `DELETE FROM bookmarks WHERE id=$1 AND user_id=$2`, id, userID) + if err != nil { + return false, err + } + return res.RowsAffected() > 0, nil +} diff --git a/backend/internal/store/books.go b/backend/internal/store/books.go new file mode 100644 index 0000000..c9f6128 --- /dev/null +++ b/backend/internal/store/books.go @@ -0,0 +1,109 @@ +package store + +import "context" + +const bookCols = "id, library_id, path, title, format, file_size, mod_ts, page_count, state, error_msg, added_at" + +func (s *Store) InsertBook(ctx context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error) { + var id int64 + err := s.p.QueryRow(ctx, + `INSERT INTO books (library_id, path, title, format, file_size, mod_ts, page_count) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`, + libID, path, title, format, size, modTS, pageCount).Scan(&id) + return id, err +} + +func (s *Store) GetBook(ctx context.Context, id int64) (Book, error) { + var b Book + err := s.p.QueryRow(ctx, "SELECT "+bookCols+" FROM books WHERE id=$1", id).Scan( + &b.ID, &b.LibraryID, &b.Path, &b.Title, &b.Format, + &b.FileSize, &b.ModTS, &b.PageCount, &b.State, &b.ErrMsg, &b.AddedAt) + return b, err +} + +func (s *Store) ListBookMeta(ctx context.Context, libID int64) (map[string]BookMeta, error) { + rows, err := s.p.Query(ctx, + "SELECT id, path, file_size, mod_ts, format FROM books WHERE library_id=$1", libID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]BookMeta{} + for rows.Next() { + var m BookMeta + var path string + if err := rows.Scan(&m.ID, &path, &m.Size, &m.ModTS, &m.Format); err != nil { + return nil, err + } + out[path] = m + } + return out, rows.Err() +} + +func (s *Store) UpdateBookFile(ctx context.Context, id, size, modTS int64, pageCount int) error { + _, err := s.p.Exec(ctx, + `UPDATE books SET file_size=$2, mod_ts=$3, page_count=$4, state='ready', error_msg='' WHERE id=$1`, + id, size, modTS, pageCount) + return err +} + +func (s *Store) DeleteBookByPath(ctx context.Context, libID int64, path string) error { + _, err := s.p.Exec(ctx, "DELETE FROM books WHERE library_id=$1 AND path=$2", libID, path) + return err +} + +func (s *Store) DeleteBook(ctx context.Context, id int64) error { + _, err := s.p.Exec(ctx, "DELETE FROM books WHERE id=$1", id) + return err +} + +func (s *Store) SetBookState(ctx context.Context, id int64, state, msg string) error { + _, err := s.p.Exec(ctx, "UPDATE books SET state=$2, error_msg=$3 WHERE id=$1", id, state, msg) + return err +} + +func (s *Store) ListBooks(ctx context.Context, libID int64, q, prefix string, userID int64) ([]BookView, error) { + rows, err := s.p.Query(ctx, + `SELECT b.id, b.library_id, b.path, b.title, b.format, b.file_size, b.mod_ts, + b.page_count, b.state, b.error_msg, b.added_at, l.name, COALESCE(p.percent, 0) + FROM books b JOIN libraries l ON l.id = b.library_id + LEFT JOIN reading_progress p ON p.user_id = $4 AND p.library_id = b.library_id AND p.book_path = b.path + WHERE ($1 = 0 OR b.library_id = $1) + AND ($2 = '' OR lower(b.title) LIKE '%' || lower($2) || '%') + AND ($3 = '' OR b.path LIKE $3 || '%') + ORDER BY l.name, b.path`, libID, q, prefix, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []BookView + for rows.Next() { + var v BookView + err := rows.Scan(&v.ID, &v.LibraryID, &v.Path, &v.Title, &v.Format, + &v.FileSize, &v.ModTS, &v.PageCount, &v.State, &v.ErrMsg, &v.AddedAt, + &v.LibraryName, &v.Percent) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +func (s *Store) BookHashes(ctx context.Context) (map[int64][2]int64, error) { + rows, err := s.p.Query(ctx, "SELECT id, file_size, mod_ts FROM books") + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64][2]int64{} + for rows.Next() { + var id int64 + var v [2]int64 + if err := rows.Scan(&id, &v[0], &v[1]); err != nil { + return nil, err + } + out[id] = v + } + return out, rows.Err() +} diff --git a/backend/internal/store/libraries.go b/backend/internal/store/libraries.go new file mode 100644 index 0000000..72998cf --- /dev/null +++ b/backend/internal/store/libraries.go @@ -0,0 +1,35 @@ +package store + +import "context" + +func (s *Store) CreateLibrary(ctx context.Context, name, root string) (int64, error) { + var id int64 + err := s.p.QueryRow(ctx, + "INSERT INTO libraries (name, root_path) VALUES ($1,$2) RETURNING id", name, root).Scan(&id) + return id, err +} + +func (s *Store) ListLibraries(ctx context.Context) ([]Library, error) { + rows, err := s.p.Query(ctx, "SELECT id, name, root_path, created_at FROM libraries ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + var out []Library + for rows.Next() { + var l Library + if err := rows.Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt); err != nil { + return nil, err + } + out = append(out, l) + } + return out, rows.Err() +} + +func (s *Store) GetLibrary(ctx context.Context, id int64) (Library, error) { + var l Library + err := s.p.QueryRow(ctx, + "SELECT id, name, root_path, created_at FROM libraries WHERE id=$1", id). + Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt) + return l, err +} diff --git a/backend/internal/store/progress.go b/backend/internal/store/progress.go new file mode 100644 index 0000000..139b22a --- /dev/null +++ b/backend/internal/store/progress.go @@ -0,0 +1,45 @@ +package store + +import "context" + +func (s *Store) UpsertProgress(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error { + _, err := s.p.Exec(ctx, + `INSERT INTO reading_progress (user_id, library_id, book_path, locator, percent, updated_at) + VALUES ($1,$2,$3,$4,$5,now()) + ON CONFLICT (user_id, library_id, book_path) + DO UPDATE SET locator=$4, percent=$5, updated_at=now()`, + userID, libID, bookPath, locator, percent) + return err +} + +func (s *Store) GetProgress(ctx context.Context, userID, libID int64, bookPath string) (Progress, error) { + var pr Progress + err := s.p.QueryRow(ctx, + `SELECT library_id, book_path, locator, percent, updated_at + FROM reading_progress WHERE user_id=$1 AND library_id=$2 AND book_path=$3`, + userID, libID, bookPath). + Scan(&pr.LibraryID, &pr.BookPath, &pr.Locator, &pr.Percent, &pr.UpdatedAt) + return pr, err +} + +func (s *Store) ListProgress(ctx context.Context, userID int64) ([]Progress, error) { + rows, err := s.p.Query(ctx, + `SELECT p.library_id, l.name, p.book_path, COALESCE(b.title, ''), p.locator, p.percent, p.updated_at + FROM reading_progress p JOIN libraries l ON l.id = p.library_id + LEFT JOIN books b ON b.library_id = p.library_id AND b.path = p.book_path + WHERE p.user_id = $1 ORDER BY p.updated_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Progress + for rows.Next() { + var pr Progress + if err := rows.Scan(&pr.LibraryID, &pr.LibraryName, &pr.BookPath, &pr.Title, + &pr.Locator, &pr.Percent, &pr.UpdatedAt); err != nil { + return nil, err + } + out = append(out, pr) + } + return out, rows.Err() +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index ed2ff85..1be2fb4 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -1,11 +1,9 @@ package store import ( - "context" "errors" "time" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) @@ -22,9 +20,10 @@ func IsUniqueViolation(err error) bool { return errors.As(err, &pgErr) && pgErr.Code == "23505" } -type Store struct{ P *pgxpool.Pool } +// Store provides database access. The pool is unexported; all access goes through methods. +type Store struct{ p *pgxpool.Pool } -func New(p *pgxpool.Pool) *Store { return &Store{P: p} } +func New(p *pgxpool.Pool) *Store { return &Store{p: p} } // ---------- types ---------- @@ -35,12 +34,14 @@ type User struct { Role string CreatedAt time.Time } + type Library struct { ID int64 Name string RootPath string CreatedAt time.Time } + type Book struct { ID, LibraryID int64 Path string @@ -48,303 +49,35 @@ type Book struct { Format string FileSize int64 ModTS int64 - PageCount int // 0 表示未知(pdf/epub/txt) + PageCount int // 0 means unknown (pdf/epub/txt) State string ErrMsg string AddedAt time.Time } + type BookMeta struct { ID int64 Size int64 ModTS int64 Format string } + type BookView struct { Book LibraryName string Percent float64 } + type Progress struct { LibraryID int64 LibraryName string BookPath string - Title string // 书已删时为空 + Title string // empty when book deleted Locator []byte Percent float64 UpdatedAt time.Time } -// ---------- users ---------- - -const userCols = "id, username, password_hash, role, created_at" - -func (s *Store) CountUsers(ctx context.Context) (int, error) { - var n int - err := s.P.QueryRow(ctx, "SELECT count(*) FROM users").Scan(&n) - return n, err -} - -func (s *Store) CreateUser(ctx context.Context, username, hash, role string) (int64, error) { - var id int64 - err := s.P.QueryRow(ctx, - "INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING id", - username, hash, role).Scan(&id) - return id, err -} - -func (s *Store) GetUserByName(ctx context.Context, username string) (User, error) { - return scanUser(s.P.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE username=$1", username)) -} - -func (s *Store) GetUserByID(ctx context.Context, id int64) (User, error) { - return scanUser(s.P.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE id=$1", id)) -} - -func (s *Store) ListUsers(ctx context.Context) ([]User, error) { - rows, err := s.P.Query(ctx, "SELECT "+userCols+" FROM users ORDER BY id") - if err != nil { - return nil, err - } - defer rows.Close() - var out []User - for rows.Next() { - var u User - if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil { - return nil, err - } - out = append(out, u) - } - return out, rows.Err() -} - -// DeleteUser deletes a user with a transactional last-admin check (B5). -// Returns ErrLastAdmin if the target is the last admin. -func (s *Store) DeleteUser(ctx context.Context, id int64) error { - tx, err := s.P.Begin(ctx) - if err != nil { - return err - } - defer tx.Rollback(ctx) - var role string - if err := tx.QueryRow(ctx, "SELECT role FROM users WHERE id=$1 FOR UPDATE", id).Scan(&role); err != nil { - return err // includes pgx.ErrNoRows - } - if role == "admin" { - var n int - if err := tx.QueryRow(ctx, "SELECT count(*) FROM users WHERE role='admin'").Scan(&n); err != nil { - return err - } - if n <= 1 { - return ErrLastAdmin - } - } - if _, err := tx.Exec(ctx, "DELETE FROM users WHERE id=$1", id); err != nil { - return err - } - return tx.Commit(ctx) -} - -func scanUser(row pgx.Row) (User, error) { - var u User - err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) - return u, err -} - -// ---------- libraries ---------- - -func (s *Store) CreateLibrary(ctx context.Context, name, root string) (int64, error) { - var id int64 - err := s.P.QueryRow(ctx, - "INSERT INTO libraries (name, root_path) VALUES ($1,$2) RETURNING id", name, root).Scan(&id) - return id, err -} - -func (s *Store) ListLibraries(ctx context.Context) ([]Library, error) { - rows, err := s.P.Query(ctx, "SELECT id, name, root_path, created_at FROM libraries ORDER BY id") - if err != nil { - return nil, err - } - defer rows.Close() - var out []Library - for rows.Next() { - var l Library - if err := rows.Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt); err != nil { - return nil, err - } - out = append(out, l) - } - return out, rows.Err() -} - -func (s *Store) GetLibrary(ctx context.Context, id int64) (Library, error) { - var l Library - err := s.P.QueryRow(ctx, - "SELECT id, name, root_path, created_at FROM libraries WHERE id=$1", id). - Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt) - return l, err -} - -// ---------- books ---------- - -const bookCols = "id, library_id, path, title, format, file_size, mod_ts, page_count, state, error_msg, added_at" - -func (s *Store) InsertBook(ctx context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error) { - var id int64 - err := s.P.QueryRow(ctx, - `INSERT INTO books (library_id, path, title, format, file_size, mod_ts, page_count) - VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`, - libID, path, title, format, size, modTS, pageCount).Scan(&id) - return id, err -} - -func (s *Store) GetBook(ctx context.Context, id int64) (Book, error) { - var b Book - err := s.P.QueryRow(ctx, "SELECT "+bookCols+" FROM books WHERE id=$1", id).Scan( - &b.ID, &b.LibraryID, &b.Path, &b.Title, &b.Format, - &b.FileSize, &b.ModTS, &b.PageCount, &b.State, &b.ErrMsg, &b.AddedAt) - return b, err -} - -func (s *Store) ListBookMeta(ctx context.Context, libID int64) (map[string]BookMeta, error) { - rows, err := s.P.Query(ctx, - "SELECT id, path, file_size, mod_ts, format FROM books WHERE library_id=$1", libID) - if err != nil { - return nil, err - } - defer rows.Close() - out := map[string]BookMeta{} - for rows.Next() { - var m BookMeta - var path string - if err := rows.Scan(&m.ID, &path, &m.Size, &m.ModTS, &m.Format); err != nil { - return nil, err - } - out[path] = m - } - return out, rows.Err() -} - -func (s *Store) UpdateBookFile(ctx context.Context, id, size, modTS int64, pageCount int) error { - _, err := s.P.Exec(ctx, - `UPDATE books SET file_size=$2, mod_ts=$3, page_count=$4, state='ready', error_msg='' WHERE id=$1`, - id, size, modTS, pageCount) - return err -} - -func (s *Store) DeleteBookByPath(ctx context.Context, libID int64, path string) error { - _, err := s.P.Exec(ctx, "DELETE FROM books WHERE library_id=$1 AND path=$2", libID, path) - return err -} - -func (s *Store) DeleteBook(ctx context.Context, id int64) error { - _, err := s.P.Exec(ctx, "DELETE FROM books WHERE id=$1", id) - return err -} - -func (s *Store) SetBookState(ctx context.Context, id int64, state, msg string) error { - _, err := s.P.Exec(ctx, "UPDATE books SET state=$2, error_msg=$3 WHERE id=$1", id, state, msg) - return err -} - -func (s *Store) ListBookIDs(ctx context.Context) ([]int64, error) { - rows, err := s.P.Query(ctx, "SELECT id FROM books") - if err != nil { - return nil, err - } - defer rows.Close() - var out []int64 - for rows.Next() { - var id int64 - if err := rows.Scan(&id); err != nil { - return nil, err - } - out = append(out, id) - } - return out, rows.Err() -} - -func (s *Store) ListBooks(ctx context.Context, libID int64, q, prefix string, userID int64) ([]BookView, error) { - rows, err := s.P.Query(ctx, - `SELECT b.id, b.library_id, b.path, b.title, b.format, b.file_size, b.mod_ts, - b.page_count, b.state, b.error_msg, b.added_at, l.name, COALESCE(p.percent, 0) - FROM books b JOIN libraries l ON l.id = b.library_id - LEFT JOIN reading_progress p ON p.user_id = $4 AND p.library_id = b.library_id AND p.book_path = b.path - WHERE ($1 = 0 OR b.library_id = $1) - AND ($2 = '' OR lower(b.title) LIKE '%' || lower($2) || '%') - AND ($3 = '' OR b.path LIKE $3 || '%') - ORDER BY l.name, b.path`, libID, q, prefix, userID) - if err != nil { - return nil, err - } - defer rows.Close() - var out []BookView - for rows.Next() { - var v BookView - err := rows.Scan(&v.ID, &v.LibraryID, &v.Path, &v.Title, &v.Format, - &v.FileSize, &v.ModTS, &v.PageCount, &v.State, &v.ErrMsg, &v.AddedAt, - &v.LibraryName, &v.Percent) - if err != nil { - return nil, err - } - out = append(out, v) - } - return out, rows.Err() -} - -func (s *Store) BookHashes(ctx context.Context) (map[int64][2]int64, error) { - rows, err := s.P.Query(ctx, "SELECT id, file_size, mod_ts FROM books") - if err != nil { - return nil, err - } - defer rows.Close() - out := map[int64][2]int64{} - for rows.Next() { - var id int64 - var v [2]int64 - if err := rows.Scan(&id, &v[0], &v[1]); err != nil { - return nil, err - } - out[id] = v - } - return out, rows.Err() -} - -// ---------- progress ---------- - -func (s *Store) UpsertProgress(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error { - _, err := s.P.Exec(ctx, - `INSERT INTO reading_progress (user_id, library_id, book_path, locator, percent, updated_at) - VALUES ($1,$2,$3,$4,$5,now()) - ON CONFLICT (user_id, library_id, book_path) - DO UPDATE SET locator=$4, percent=$5, updated_at=now()`, - userID, libID, bookPath, locator, percent) - return err -} - -func (s *Store) ListProgress(ctx context.Context, userID int64) ([]Progress, error) { - rows, err := s.P.Query(ctx, - `SELECT p.library_id, l.name, p.book_path, COALESCE(b.title, ''), p.locator, p.percent, p.updated_at - FROM reading_progress p JOIN libraries l ON l.id = p.library_id - LEFT JOIN books b ON b.library_id = p.library_id AND b.path = p.book_path - WHERE p.user_id = $1 ORDER BY p.updated_at DESC`, userID) - if err != nil { - return nil, err - } - defer rows.Close() - var out []Progress - for rows.Next() { - var pr Progress - if err := rows.Scan(&pr.LibraryID, &pr.LibraryName, &pr.BookPath, &pr.Title, - &pr.Locator, &pr.Percent, &pr.UpdatedAt); err != nil { - return nil, err - } - out = append(out, pr) - } - return out, rows.Err() -} - -// ---------- bookmarks ---------- - type Bookmark struct { ID int64 LibraryID int64 @@ -355,57 +88,6 @@ type Bookmark struct { CreatedAt time.Time } -func (s *Store) InsertBookmark(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error) { - var id int64 - err := s.P.QueryRow(ctx, - `INSERT INTO bookmarks (user_id, library_id, book_path, locator, percent, note) - VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, - userID, libID, bookPath, locator, percent, note).Scan(&id) - return id, err -} - -func (s *Store) ListBookmarks(ctx context.Context, userID, libID int64, bookPath string) ([]Bookmark, error) { - rows, err := s.P.Query(ctx, - `SELECT id, library_id, book_path, locator, percent, note, created_at - FROM bookmarks WHERE user_id=$1 AND library_id=$2 AND book_path=$3 - ORDER BY percent ASC, id ASC`, userID, libID, bookPath) - if err != nil { - return nil, err - } - defer rows.Close() - var out []Bookmark - for rows.Next() { - var b Bookmark - if err := rows.Scan(&b.ID, &b.LibraryID, &b.BookPath, &b.Locator, &b.Percent, &b.Note, &b.CreatedAt); err != nil { - return nil, err - } - out = append(out, b) - } - return out, rows.Err() -} - -func (s *Store) UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error) { - res, err := s.P.Exec(ctx, `UPDATE bookmarks SET note=$3 WHERE id=$1 AND user_id=$2`, id, userID, note) - if err != nil { - return false, err - } - return res.RowsAffected() > 0, nil -} - -func (s *Store) DeleteBookmark(ctx context.Context, userID, id int64) (bool, error) { - res, err := s.P.Exec(ctx, `DELETE FROM bookmarks WHERE id=$1 AND user_id=$2`, id, userID) - if err != nil { - return false, err - } - return res.RowsAffected() > 0, nil -} - -func (s *Store) GetProgress(ctx context.Context, userID, libID int64, bookPath string) (Progress, error) { - var pr Progress - err := s.P.QueryRow(ctx, - `SELECT library_id, book_path, locator, percent, updated_at - FROM reading_progress WHERE user_id=$1 AND library_id=$2 AND book_path=$3`, - userID, libID, bookPath). - Scan(&pr.LibraryID, &pr.BookPath, &pr.Locator, &pr.Percent, &pr.UpdatedAt) - return pr, err -} +// Pool returns the underlying pool for test setup only. +// Production code should use Store methods exclusively. +func (s *Store) Pool() *pgxpool.Pool { return s.p } diff --git a/backend/internal/store/users.go b/backend/internal/store/users.go new file mode 100644 index 0000000..12f1725 --- /dev/null +++ b/backend/internal/store/users.go @@ -0,0 +1,81 @@ +package store + +import ( + "context" + + "github.com/jackc/pgx/v5" +) + +const userCols = "id, username, password_hash, role, created_at" + +func (s *Store) CountUsers(ctx context.Context) (int, error) { + var n int + err := s.p.QueryRow(ctx, "SELECT count(*) FROM users").Scan(&n) + return n, err +} + +func (s *Store) CreateUser(ctx context.Context, username, hash, role string) (int64, error) { + var id int64 + err := s.p.QueryRow(ctx, + "INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING id", + username, hash, role).Scan(&id) + return id, err +} + +func (s *Store) GetUserByName(ctx context.Context, username string) (User, error) { + return scanUser(s.p.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE username=$1", username)) +} + +func (s *Store) GetUserByID(ctx context.Context, id int64) (User, error) { + return scanUser(s.p.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE id=$1", id)) +} + +func (s *Store) ListUsers(ctx context.Context) ([]User, error) { + rows, err := s.p.Query(ctx, "SELECT "+userCols+" FROM users ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + var out []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +// DeleteUser deletes a user with a transactional last-admin check (B5). +// Returns ErrLastAdmin if the target is the last admin. +func (s *Store) DeleteUser(ctx context.Context, id int64) error { + tx, err := s.p.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + var role string + if err := tx.QueryRow(ctx, "SELECT role FROM users WHERE id=$1 FOR UPDATE", id).Scan(&role); err != nil { + return err // includes pgx.ErrNoRows + } + if role == "admin" { + var n int + if err := tx.QueryRow(ctx, "SELECT count(*) FROM users WHERE role='admin'").Scan(&n); err != nil { + return err + } + if n <= 1 { + return ErrLastAdmin + } + } + if _, err := tx.Exec(ctx, "DELETE FROM users WHERE id=$1", id); err != nil { + return err + } + return tx.Commit(ctx) +} + +func scanUser(row pgx.Row) (User, error) { + var u User + err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) + return u, err +}