refactor(store): split into per-aggregate files, unexport pool, remove dead ListBookIDs
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+13
-331
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user