Files
libnovel/backend/internal/domain/domain.go
Admin da37b1be88
Some checks failed
Release / Test backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 1m0s
Release / Docker (push) Successful in 11m19s
Release / Deploy to prod (push) Successful in 2m10s
Release / Deploy to homelab (push) Failing after 7s
Release / Gitea Release (push) Successful in 2m25s
feat: option A — visibility gating + author submission system
Content visibility:
- Add `visibility` field to books ("public" | "admin_only"); new migration
  backfills all existing scraped books to admin_only
- Meilisearch: add visibility as filterable attribute; catalogue/search
  endpoints filter to public-only for non-admin requests
- Admin users identified by bearer token bypass the filter and see all books
- All PocketBase discovery queries (trending, recommended, recently-updated,
  audio shelf, discover, subscription feed) now filter to visibility=public
- New scraped books default to admin_only; WriteMetadata preserves existing
  visibility on PATCH (never overwrites)

Author submission:
- POST /api/admin/books/submit — creates a public book with submitted_by
- PATCH /api/admin/books/{slug}/publish / unpublish — toggle visibility
- SvelteKit proxies: /api/admin/books/[slug]/publish|unpublish
- /api/books/[slug] endpoint for admin book lookup

Frontend:
- backendFetchAdmin() helper sends admin token on any path
- Catalogue server load uses admin fetch when user is admin
- /submit page: author submission form with genre picker and rights assertion
- "Publish" nav link shown to all logged-in users
- Admin catalogue-tools: visibility management panel (load book by slug, toggle)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:20:16 +05:00

247 lines
11 KiB
Go

// Package domain contains the core value types shared across all packages
// in this module. It has zero internal imports — only the standard library.
// Every other package imports domain; domain imports nothing from this module.
package domain
import "time"
// ── Book types ────────────────────────────────────────────────────────────────
// Visibility values for BookMeta.Visibility.
const (
VisibilityPublic = "public" // visible to all users
VisibilityAdminOnly = "admin_only" // visible only to admin users (e.g. scraped content)
)
// BookMeta carries all bibliographic information about a novel.
type BookMeta struct {
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"`
Cover string `json:"cover,omitempty"`
Status string `json:"status,omitempty"`
Genres []string `json:"genres,omitempty"`
Summary string `json:"summary,omitempty"`
TotalChapters int `json:"total_chapters,omitempty"`
SourceURL string `json:"source_url"`
Ranking int `json:"ranking,omitempty"`
Rating float64 `json:"rating,omitempty"`
// MetaUpdated is the Unix timestamp (seconds) when the book record was last
// 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"`
// Visibility controls who can see this book.
// "public" = all users; "admin_only" = admin only (default for scraped content).
Visibility string `json:"visibility,omitempty"`
// SubmittedBy is the app_users ID of the author who submitted this book,
// or empty for scraped books.
SubmittedBy string `json:"submitted_by,omitempty"`
}
// CatalogueEntry is a lightweight book reference returned by catalogue pages.
type CatalogueEntry struct {
Slug string `json:"slug"`
Title string `json:"title"`
URL string `json:"url"`
}
// ChapterRef is a reference to a single chapter returned by chapter-list pages.
type ChapterRef struct {
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
Volume int `json:"volume,omitempty"`
}
// Chapter contains the fully-extracted text of a single chapter.
type Chapter struct {
Ref ChapterRef `json:"ref"`
Text string `json:"text"`
}
// RankingItem represents a single entry in the novel ranking list.
type RankingItem struct {
Rank int `json:"rank"`
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author,omitempty"`
Cover string `json:"cover,omitempty"`
Status string `json:"status,omitempty"`
Genres []string `json:"genres,omitempty"`
SourceURL string `json:"source_url,omitempty"`
Updated time.Time `json:"updated,omitempty"`
}
// ── Voice types ───────────────────────────────────────────────────────────────
// Voice describes a single text-to-speech voice available in the system.
type Voice struct {
// ID is the voice identifier passed to TTS clients (e.g. "af_bella", "alba").
ID string `json:"id"`
// Engine is "kokoro" or "pocket-tts".
Engine string `json:"engine"`
// Lang is the primary language tag (e.g. "en-us", "en-gb", "en", "es", "fr").
Lang string `json:"lang"`
// Gender is "f" or "m".
Gender string `json:"gender"`
}
// ── Storage record types ──────────────────────────────────────────────────────
// ChapterInfo is a lightweight chapter descriptor stored in the index.
type ChapterInfo struct {
Number int `json:"number"`
Title string `json:"title"`
Date string `json:"date,omitempty"`
}
// ReadingProgress holds a single user's reading position for one book.
type ReadingProgress struct {
Slug string `json:"slug"`
Chapter int `json:"chapter"`
UpdatedAt time.Time `json:"updated_at"`
}
// ── Task record types ─────────────────────────────────────────────────────────
// TaskStatus enumerates the lifecycle states of any task.
type TaskStatus string
const (
TaskStatusPending TaskStatus = "pending"
TaskStatusRunning TaskStatus = "running"
TaskStatusDone TaskStatus = "done"
TaskStatusFailed TaskStatus = "failed"
TaskStatusCancelled TaskStatus = "cancelled"
)
// ScrapeTask represents a book-scraping job stored in PocketBase.
type ScrapeTask struct {
ID string `json:"id"`
Kind string `json:"kind"` // "catalogue" | "book" | "book_range"
TargetURL string `json:"target_url"` // non-empty for single-book tasks
FromChapter int `json:"from_chapter,omitempty"`
ToChapter int `json:"to_chapter,omitempty"`
WorkerID string `json:"worker_id,omitempty"`
Status TaskStatus `json:"status"`
BooksFound int `json:"books_found"`
ChaptersScraped int `json:"chapters_scraped"`
ChaptersSkipped int `json:"chapters_skipped"`
Errors int `json:"errors"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// ScrapeResult is the outcome reported by the runner after finishing a ScrapeTask.
type ScrapeResult struct {
// Slug is the book slug that was scraped. Empty for catalogue tasks.
Slug string `json:"slug,omitempty"`
BooksFound int `json:"books_found"`
ChaptersScraped int `json:"chapters_scraped"`
ChaptersSkipped int `json:"chapters_skipped"`
Errors int `json:"errors"`
ErrorMessage string `json:"error_message,omitempty"`
}
// AudioTask represents an audio-generation job stored in PocketBase.
type AudioTask struct {
ID string `json:"id"`
CacheKey string `json:"cache_key"` // "slug/chapter/voice"
Slug string `json:"slug"`
Chapter int `json:"chapter"`
Voice string `json:"voice"`
WorkerID string `json:"worker_id,omitempty"`
Status TaskStatus `json:"status"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
}
// AudioResult is the outcome reported by the runner after finishing an AudioTask.
type AudioResult struct {
ObjectKey string `json:"object_key,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// TranslationTask represents a machine-translation job stored in PocketBase.
type TranslationTask struct {
ID string `json:"id"`
CacheKey string `json:"cache_key"` // "{slug}/{chapter}/{lang}"
Slug string `json:"slug"`
Chapter int `json:"chapter"`
Lang string `json:"lang"`
WorkerID string `json:"worker_id,omitempty"`
Status TaskStatus `json:"status"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
}
// TranslationResult is the outcome reported by the runner after finishing a TranslationTask.
type TranslationResult struct {
ObjectKey string `json:"object_key,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// ImportTask represents a PDF/EPUB import job stored in PocketBase.
type ImportTask struct {
ID string `json:"id"`
Slug string `json:"slug"` // derived from filename
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // "pdf" or "epub"
ObjectKey string `json:"object_key,omitempty"` // MinIO path to uploaded file
ChaptersKey string `json:"chapters_key,omitempty"` // MinIO path to pre-parsed chapters JSON
Author string `json:"author,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Genres []string `json:"genres,omitempty"`
Summary string `json:"summary,omitempty"`
BookStatus string `json:"book_status,omitempty"` // "ongoing" | "completed" | "hiatus"
WorkerID string `json:"worker_id,omitempty"`
InitiatorUserID string `json:"initiator_user_id,omitempty"` // PocketBase user ID who submitted the import
Status TaskStatus `json:"status"`
ChaptersDone int `json:"chapters_done"`
ChaptersTotal int `json:"chapters_total"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
}
// ImportResult is the outcome reported by the runner after finishing an ImportTask.
type ImportResult struct {
Slug string `json:"slug,omitempty"`
ChaptersImported int `json:"chapters_imported"`
ErrorMessage string `json:"error_message,omitempty"`
}
// AIJob represents an AI generation task tracked in PocketBase (ai_jobs collection).
type AIJob struct {
ID string `json:"id"`
// Kind is one of: "chapter-names", "batch-covers", "chapter-covers", "refresh-metadata".
Kind string `json:"kind"`
// Slug is the book slug for per-book jobs; empty for catalogue-wide jobs.
Slug string `json:"slug"`
Status TaskStatus `json:"status"`
// FromItem is the first item to process (chapter number, or 0-based book index).
// 0 = start from the beginning.
FromItem int `json:"from_item"`
// ToItem is the last item to process (inclusive). 0 = process all.
ToItem int `json:"to_item"`
// ItemsDone is the cumulative count of successfully processed items.
ItemsDone int `json:"items_done"`
// ItemsTotal is the total number of items in this job.
ItemsTotal int `json:"items_total"`
Model string `json:"model"`
// Payload is a JSON-encoded string with job-specific parameters
// (e.g. naming pattern for chapter-names, num_steps for batch-covers).
Payload string `json:"payload"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started,omitempty"`
Finished time.Time `json:"finished,omitempty"`
HeartbeatAt time.Time `json:"heartbeat_at,omitempty"`
}