From a1def0f0f8df62733b95917d9532d2a2cccf9240 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 10 Apr 2026 19:31:33 +0500 Subject: [PATCH] feat: admin soft-delete and hard-delete for books - 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) --- backend/cmd/backend/main.go | 1 + .../internal/backend/handlers_books_admin.go | 117 ++++++++++++++++++ backend/internal/backend/server.go | 8 ++ backend/internal/bookstore/bookstore.go | 21 ++++ backend/internal/domain/domain.go | 3 + backend/internal/meili/client.go | 35 ++++-- backend/internal/storage/store.go | 83 ++++++++++++- scripts/pb-init-v3.sh | 6 +- 8 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 backend/internal/backend/handlers_books_admin.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 9c19483..5ceec22 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -198,6 +198,7 @@ func run() error { TextGen: textGenClient, BookWriter: store, AIJobStore: store, + BookAdminStore: store, Log: log, }, ) diff --git a/backend/internal/backend/handlers_books_admin.go b/backend/internal/backend/handlers_books_admin.go new file mode 100644 index 0000000..b0db8c8 --- /dev/null +++ b/backend/internal/backend/handlers_books_admin.go @@ -0,0 +1,117 @@ +package backend + +import ( + "errors" + "net/http" + + "github.com/libnovel/backend/internal/storage" +) + +// handleAdminArchiveBook handles PATCH /api/admin/books/{slug}/archive. +// Soft-deletes a book by setting archived=true in PocketBase and updating the +// Meilisearch document so it is excluded from all public search results. +// The book data is preserved and can be restored with the unarchive endpoint. +func (s *Server) handleAdminArchiveBook(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if slug == "" { + jsonError(w, http.StatusBadRequest, "missing slug") + return + } + if s.deps.BookAdminStore == nil { + jsonError(w, http.StatusServiceUnavailable, "book admin store not configured") + return + } + + if err := s.deps.BookAdminStore.ArchiveBook(r.Context(), slug); err != nil { + if errors.Is(err, storage.ErrNotFound) { + jsonError(w, http.StatusNotFound, "book not found") + return + } + s.deps.Log.Error("archive book failed", "slug", slug, "err", err) + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Update the Meilisearch document so the archived flag takes effect + // immediately in search/catalogue results. + if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok { + if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil { + s.deps.Log.Warn("archive book: meili upsert failed", "slug", slug, "err", upsertErr) + } + } + + s.deps.Log.Info("book archived", "slug", slug) + writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "archived"}) +} + +// handleAdminUnarchiveBook handles PATCH /api/admin/books/{slug}/unarchive. +// Restores a previously archived book by clearing the archived flag, making it +// publicly visible in search and catalogue results again. +func (s *Server) handleAdminUnarchiveBook(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if slug == "" { + jsonError(w, http.StatusBadRequest, "missing slug") + return + } + if s.deps.BookAdminStore == nil { + jsonError(w, http.StatusServiceUnavailable, "book admin store not configured") + return + } + + if err := s.deps.BookAdminStore.UnarchiveBook(r.Context(), slug); err != nil { + if errors.Is(err, storage.ErrNotFound) { + jsonError(w, http.StatusNotFound, "book not found") + return + } + s.deps.Log.Error("unarchive book failed", "slug", slug, "err", err) + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Sync the updated archived=false state back to Meilisearch. + if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok { + if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil { + s.deps.Log.Warn("unarchive book: meili upsert failed", "slug", slug, "err", upsertErr) + } + } + + s.deps.Log.Info("book unarchived", "slug", slug) + writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "active"}) +} + +// handleAdminDeleteBook handles DELETE /api/admin/books/{slug}. +// Permanently removes all data for a book: +// - PocketBase books record and all chapters_idx records +// - All MinIO chapter markdown objects and the cover image +// - Meilisearch document +// +// This operation is irreversible. Use the archive endpoint for soft-deletion. +func (s *Server) handleAdminDeleteBook(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if slug == "" { + jsonError(w, http.StatusBadRequest, "missing slug") + return + } + if s.deps.BookAdminStore == nil { + jsonError(w, http.StatusServiceUnavailable, "book admin store not configured") + return + } + + if err := s.deps.BookAdminStore.DeleteBook(r.Context(), slug); err != nil { + if errors.Is(err, storage.ErrNotFound) { + jsonError(w, http.StatusNotFound, "book not found") + return + } + s.deps.Log.Error("delete book failed", "slug", slug, "err", err) + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Remove from Meilisearch — best-effort (log on failure, don't fail request). + if err := s.deps.SearchIndex.DeleteBook(r.Context(), slug); err != nil { + s.deps.Log.Warn("delete book: meili delete failed", "slug", slug, "err", err) + } + + s.deps.Log.Info("book deleted", "slug", slug) + writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "deleted"}) +} diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index a0c2d9b..9132c7d 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -91,6 +91,9 @@ type Dependencies struct { // AIJobStore tracks long-running AI generation jobs in PocketBase. // If nil, job persistence is disabled (jobs still run but are not recorded). AIJobStore bookstore.AIJobStore + // BookAdminStore provides admin-only operations: archive, unarchive, hard-delete. + // If nil, the admin book management endpoints return 503. + BookAdminStore bookstore.BookAdminStore // Log is the structured logger. Log *slog.Logger } @@ -247,6 +250,11 @@ func (s *Server) ListenAndServe(ctx context.Context) error { // Admin data repair endpoints mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters) + // Admin book management (soft-delete / hard-delete) + mux.HandleFunc("PATCH /api/admin/books/{slug}/archive", s.handleAdminArchiveBook) + mux.HandleFunc("PATCH /api/admin/books/{slug}/unarchive", s.handleAdminUnarchiveBook) + mux.HandleFunc("DELETE /api/admin/books/{slug}", s.handleAdminDeleteBook) + // Admin chapter split (imported books) mux.HandleFunc("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters) diff --git a/backend/internal/bookstore/bookstore.go b/backend/internal/bookstore/bookstore.go index e549790..48d534a 100644 --- a/backend/internal/bookstore/bookstore.go +++ b/backend/internal/bookstore/bookstore.go @@ -216,6 +216,27 @@ type BookImporter interface { Import(ctx context.Context, objectKey, fileType string) ([]Chapter, error) } +// BookAdminStore covers admin-only operations for managing books in the catalogue. +// All methods require admin authorisation at the HTTP handler level. +type BookAdminStore interface { + // ArchiveBook sets archived=true on a book record, hiding it from all + // public search and catalogue responses. Returns ErrNotFound when the + // slug does not exist. + ArchiveBook(ctx context.Context, slug string) error + + // UnarchiveBook clears archived on a book record, making it publicly + // visible again. Returns ErrNotFound when the slug does not exist. + UnarchiveBook(ctx context.Context, slug string) error + + // DeleteBook permanently removes all data for a book: + // - PocketBase books record + // - All PocketBase chapters_idx records + // - All MinIO chapter markdown objects ({slug}/chapter-*.md) + // - MinIO cover image (covers/{slug}.jpg) + // The caller is responsible for also deleting the Meilisearch document. + DeleteBook(ctx context.Context, slug string) error +} + // ImportFileStore uploads raw import files to object storage. // Kept separate from BookImporter so the HTTP handler can upload the file // without a concrete type assertion, regardless of which Producer is wired. diff --git a/backend/internal/domain/domain.go b/backend/internal/domain/domain.go index 301cb2d..4d2689e 100644 --- a/backend/internal/domain/domain.go +++ b/backend/internal/domain/domain.go @@ -24,6 +24,9 @@ type BookMeta struct { // updated in PocketBase. Populated on read; not sent on write (PocketBase // manages its own updated field). MetaUpdated int64 `json:"meta_updated,omitempty"` + // Archived is true when the book has been soft-deleted by an admin. + // Archived books are excluded from all public search and catalogue responses. + Archived bool `json:"archived,omitempty"` } // CatalogueEntry is a lightweight book reference returned by catalogue pages. diff --git a/backend/internal/meili/client.go b/backend/internal/meili/client.go index 8eb24bf..c69dc84 100644 --- a/backend/internal/meili/client.go +++ b/backend/internal/meili/client.go @@ -32,11 +32,15 @@ type Client interface { // BookExists reports whether a book with the given slug is already in the // index. Used by the catalogue refresh to skip re-indexing known books. BookExists(ctx context.Context, slug string) bool + // DeleteBook removes a book document from the search index by slug. + DeleteBook(ctx context.Context, slug string) error // Search returns up to limit books matching query. + // Archived books are always excluded. Search(ctx context.Context, query string, limit int) ([]domain.BookMeta, error) // Catalogue queries books with optional filters, sort, and pagination. // Returns books, the total hit count for pagination, and a FacetResult // with available genre and status values from the index. + // Archived books are always excluded. Catalogue(ctx context.Context, q CatalogueQuery) ([]domain.BookMeta, int64, FacetResult, error) } @@ -99,7 +103,7 @@ func Configure(host, apiKey string) error { return fmt.Errorf("meili: update searchable attributes: %w", err) } - filterable := []interface{}{"status", "genres"} + filterable := []interface{}{"status", "genres", "archived"} if _, err := idx.UpdateFilterableAttributes(&filterable); err != nil { return fmt.Errorf("meili: update filterable attributes: %w", err) } @@ -128,6 +132,9 @@ type bookDoc struct { // MetaUpdated is the Unix timestamp (seconds) of the last PocketBase update. // Used for sort=update ("recently updated" ordering). MetaUpdated int64 `json:"meta_updated"` + // Archived is true when the book has been soft-deleted by an admin. + // Used as a filter to exclude archived books from all search results. + Archived bool `json:"archived"` } func toDoc(b domain.BookMeta) bookDoc { @@ -144,6 +151,7 @@ func toDoc(b domain.BookMeta) bookDoc { Rank: b.Ranking, Rating: b.Rating, MetaUpdated: b.MetaUpdated, + Archived: b.Archived, } } @@ -161,6 +169,7 @@ func fromDoc(d bookDoc) domain.BookMeta { Ranking: d.Rank, Rating: d.Rating, MetaUpdated: d.MetaUpdated, + Archived: d.Archived, } } @@ -184,13 +193,24 @@ func (c *MeiliClient) BookExists(_ context.Context, slug string) bool { return err == nil && doc.Slug != "" } +// DeleteBook removes a book document from the index by slug. +// The operation is fire-and-forget (Meilisearch processes tasks asynchronously). +func (c *MeiliClient) DeleteBook(_ context.Context, slug string) error { + if _, err := c.idx.DeleteDocument(slug, nil); err != nil { + return fmt.Errorf("meili: delete book %q: %w", slug, err) + } + return nil +} + // Search returns books matching query, up to limit results. +// Archived books are always excluded. func (c *MeiliClient) Search(_ context.Context, query string, limit int) ([]domain.BookMeta, error) { if limit <= 0 { limit = 20 } res, err := c.idx.Search(query, &meilisearch.SearchRequest{ - Limit: int64(limit), + Limit: int64(limit), + Filter: "archived = false", }) if err != nil { return nil, fmt.Errorf("meili: search %q: %w", query, err) @@ -231,17 +251,15 @@ func (c *MeiliClient) Catalogue(_ context.Context, q CatalogueQuery) ([]domain.B Facets: []string{"genres", "status"}, } - // Build filter - var filters []string + // Build filter — always exclude archived books + filters := []string{"archived = false"} if q.Genre != "" && q.Genre != "all" { filters = append(filters, fmt.Sprintf("genres = %q", q.Genre)) } if q.Status != "" && q.Status != "all" { filters = append(filters, fmt.Sprintf("status = %q", q.Status)) } - if len(filters) > 0 { - req.Filter = strings.Join(filters, " AND ") - } + req.Filter = strings.Join(filters, " AND ") // Map UI sort tokens to Meilisearch sort expressions. switch q.Sort { @@ -318,7 +336,8 @@ func sortStrings(s []string) { type NoopClient struct{} func (NoopClient) UpsertBook(_ context.Context, _ domain.BookMeta) error { return nil } -func (NoopClient) BookExists(_ context.Context, _ string) bool { return false } +func (NoopClient) BookExists(_ context.Context, _ string) bool { return false } +func (NoopClient) DeleteBook(_ context.Context, _ string) error { return nil } func (NoopClient) Search(_ context.Context, _ string, _ int) ([]domain.BookMeta, error) { return nil, nil } diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go index e813f9a..c42ce32 100644 --- a/backend/internal/storage/store.go +++ b/backend/internal/storage/store.go @@ -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 { diff --git a/scripts/pb-init-v3.sh b/scripts/pb-init-v3.sh index 2647565..08aad01 100755 --- a/scripts/pb-init-v3.sh +++ b/scripts/pb-init-v3.sh @@ -144,7 +144,8 @@ create "books" '{ {"name":"total_chapters","type":"number"}, {"name":"source_url", "type":"text"}, {"name":"ranking", "type":"number"}, - {"name":"meta_updated", "type":"text"} + {"name":"meta_updated", "type":"text"}, + {"name":"archived", "type":"bool"} ]}' create "chapters_idx" '{ @@ -255,6 +256,7 @@ create "user_settings" '{ {"name":"font_family", "type":"text"}, {"name":"font_size", "type":"number"}, {"name":"announce_chapter","type":"bool"}, + {"name":"audio_mode", "type":"text"}, {"name":"updated", "type":"text"} ]}' @@ -389,6 +391,8 @@ add_field "user_settings" "locale" "text" add_field "user_settings" "font_family" "text" add_field "user_settings" "font_size" "number" add_field "user_settings" "announce_chapter" "bool" +add_field "user_settings" "audio_mode" "text" +add_field "books" "archived" "bool" # ── 6. Indexes ──────────────────────────────────────────────────────────────── add_index "chapters_idx" "idx_chapters_idx_slug_number" \