- 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)
118 lines
4.2 KiB
Go
118 lines
4.2 KiB
Go
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"})
|
|
}
|