feat: admin soft-delete and hard-delete for books
Some checks failed
Release / Test backend (push) Successful in 58s
Release / Docker (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Check ui (push) Has been cancelled

- Add `archived` bool to domain.BookMeta, pbBook, and Meilisearch bookDoc
- ArchiveBook / UnarchiveBook patch the PocketBase record; ListBooks filters
  archived=false so hidden books disappear from all public responses
- Meilisearch: add `archived` as a filterable attribute; Search and Catalogue
  always prepend `archived = false` to exclude archived books from results
- DeleteBook permanently removes the PocketBase record, all chapters_idx rows,
  MinIO chapter objects, cover image, and the Meilisearch document
- New BookAdminStore interface with ArchiveBook, UnarchiveBook, DeleteBook
- Admin HTTP endpoints: PATCH /api/admin/books/{slug}/archive|unarchive,
  DELETE /api/admin/books/{slug}
- PocketBase schema: archived field added to live pb.libnovel.cc and to
  pb-init-v3.sh (both create block and add_field migration)
This commit is contained in:
root
2026-04-10 19:31:33 +05:00
parent e0dec05885
commit a1def0f0f8
8 changed files with 264 additions and 10 deletions

View File

@@ -55,6 +55,7 @@ var _ bookstore.CoverStore = (*Store)(nil)
var _ bookstore.TranslationStore = (*Store)(nil)
var _ bookstore.AIJobStore = (*Store)(nil)
var _ bookstore.ChapterImageStore = (*Store)(nil)
var _ bookstore.BookAdminStore = (*Store)(nil)
var _ taskqueue.Producer = (*Store)(nil)
var _ taskqueue.Consumer = (*Store)(nil)
var _ taskqueue.Reader = (*Store)(nil)
@@ -226,6 +227,7 @@ type pbBook struct {
Ranking int `json:"ranking"`
Rating float64 `json:"rating"`
Updated string `json:"updated"`
Archived bool `json:"archived"`
}
func (b pbBook) toDomain() domain.BookMeta {
@@ -246,6 +248,7 @@ func (b pbBook) toDomain() domain.BookMeta {
Ranking: b.Ranking,
Rating: b.Rating,
MetaUpdated: metaUpdated,
Archived: b.Archived,
}
}
@@ -275,7 +278,7 @@ func (s *Store) ReadMetadata(ctx context.Context, slug string) (domain.BookMeta,
}
func (s *Store) ListBooks(ctx context.Context) ([]domain.BookMeta, error) {
items, err := s.pb.listAll(ctx, "books", "", "title")
items, err := s.pb.listAll(ctx, "books", "archived=false", "title")
if err != nil {
return nil, err
}
@@ -376,6 +379,84 @@ func (s *Store) ReindexChapters(ctx context.Context, slug string) (int, error) {
return count, nil
}
// ── BookAdminStore ────────────────────────────────────────────────────────────
// ArchiveBook sets archived=true on the book record for slug.
func (s *Store) ArchiveBook(ctx context.Context, slug string) error {
book, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("ArchiveBook: %w", err)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
map[string]any{"archived": true})
}
// UnarchiveBook clears archived on the book record for slug.
func (s *Store) UnarchiveBook(ctx context.Context, slug string) error {
book, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("UnarchiveBook: %w", err)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
map[string]any{"archived": false})
}
// DeleteBook permanently removes all data for a book:
// - PocketBase books record
// - All PocketBase chapters_idx records for the slug
// - All MinIO chapter markdown objects ({slug}/chapter-*.md)
// - MinIO cover image (covers/{slug}.jpg)
func (s *Store) DeleteBook(ctx context.Context, slug string) error {
// 1. Fetch the book record to get its PocketBase ID.
book, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("DeleteBook: fetch: %w", err)
}
// 2. Delete all chapters_idx records.
filter := fmt.Sprintf(`slug=%q`, slug)
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "")
if err != nil && err != ErrNotFound {
return fmt.Errorf("DeleteBook: list chapters_idx: %w", err)
}
for _, raw := range items {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
if delErr := s.pb.delete(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID)); delErr != nil {
s.log.Warn("DeleteBook: delete chapters_idx record failed", "slug", slug, "id", rec.ID, "err", delErr)
}
}
}
// 3. Delete MinIO chapter objects.
if err := s.mc.deleteObjects(ctx, s.mc.bucketChapters, slug+"/"); err != nil {
s.log.Warn("DeleteBook: delete chapter objects failed", "slug", slug, "err", err)
}
// 4. Delete MinIO cover image.
if err := s.mc.deleteObjects(ctx, s.mc.bucketBrowse, CoverObjectKey(slug)); err != nil {
s.log.Warn("DeleteBook: delete cover failed", "slug", slug, "err", err)
}
// 5. Delete the PocketBase books record.
if err := s.pb.delete(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID)); err != nil {
return fmt.Errorf("DeleteBook: delete books record: %w", err)
}
return nil
}
// ── RankingStore ──────────────────────────────────────────────────────────────
func (s *Store) WriteRankingItem(ctx context.Context, item domain.RankingItem) error {