- getReadyChaptersForSlug(slug, preferredVoice) in pocketbase.ts: per-slug done jobs map, cached 60s, prefers user's voice - GET /api/audio/chapters?slug=&voice= endpoint - Chapter list (/books/[slug]/chapters): amber headphones icon on ready rows, play button for instant listen, banner showing ready count - Chapter reader: audioReady + availableVoice from server load; 'Audio ready — Listen now' banner shown when audio exists and player is not yet active; sets voice preference before expanding player
2485 lines
84 KiB
TypeScript
2485 lines
84 KiB
TypeScript
/**
|
||
* Server-side PocketBase client.
|
||
* Uses admin credentials — never import this from client-side code.
|
||
* All methods talk directly to PocketBase REST API.
|
||
*/
|
||
|
||
import { env } from '$env/dynamic/private';
|
||
import { log } from '$lib/server/logger';
|
||
import * as cache from '$lib/server/cache';
|
||
|
||
const PB_URL = env.POCKETBASE_URL ?? 'http://localhost:8090';
|
||
const PB_EMAIL = env.POCKETBASE_ADMIN_EMAIL ?? 'admin@libnovel.local';
|
||
const PB_PASSWORD = env.POCKETBASE_ADMIN_PASSWORD ?? 'changeme123';
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
export interface AIJob {
|
||
id: string;
|
||
kind: string;
|
||
slug: string;
|
||
status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled';
|
||
from_item: number;
|
||
to_item: number;
|
||
items_done: number;
|
||
items_total: number;
|
||
model: string;
|
||
payload: string;
|
||
error_message?: string;
|
||
started?: string;
|
||
finished?: string;
|
||
heartbeat_at?: string;
|
||
}
|
||
|
||
export interface Book {
|
||
id: string;
|
||
slug: string;
|
||
title: string;
|
||
author: string;
|
||
cover: string;
|
||
status: string;
|
||
genres: string[] | string;
|
||
summary: string;
|
||
total_chapters: number;
|
||
source_url: string;
|
||
ranking: number;
|
||
meta_updated: string;
|
||
}
|
||
|
||
export interface ChapterIdx {
|
||
id: string;
|
||
slug: string;
|
||
number: number;
|
||
title: string;
|
||
date_label: string;
|
||
}
|
||
|
||
export interface Progress {
|
||
id?: string;
|
||
session_id: string;
|
||
user_id?: string;
|
||
slug: string;
|
||
chapter: number;
|
||
audio_time?: number;
|
||
updated: string;
|
||
}
|
||
|
||
export interface PBUserSettings {
|
||
id?: string;
|
||
session_id: string;
|
||
user_id?: string;
|
||
auto_next: boolean;
|
||
voice: string;
|
||
speed: number;
|
||
theme?: string;
|
||
locale?: string;
|
||
font_family?: string;
|
||
font_size?: number;
|
||
announce_chapter?: boolean;
|
||
audio_mode?: string;
|
||
updated?: string;
|
||
}
|
||
|
||
export interface User {
|
||
id: string;
|
||
username: string;
|
||
password_hash: string;
|
||
role: string;
|
||
created: string;
|
||
avatar_url?: string;
|
||
email?: string;
|
||
email_verified?: boolean;
|
||
verification_token?: string;
|
||
verification_token_exp?: string;
|
||
oauth_provider?: string;
|
||
oauth_id?: string;
|
||
polar_customer_id?: string;
|
||
polar_subscription_id?: string;
|
||
notify_new_chapters?: boolean;
|
||
}
|
||
|
||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||
|
||
let _token = '';
|
||
let _tokenExp = 0;
|
||
|
||
async function getToken(forceRefresh = false): Promise<string> {
|
||
if (!forceRefresh && _token && Date.now() < _tokenExp) return _token;
|
||
|
||
log.debug('pocketbase', 'authenticating with admin credentials', { url: PB_URL, email: PB_EMAIL });
|
||
|
||
const res = await fetch(`${PB_URL}/api/collections/_superusers/auth-with-password`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD })
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'admin auth failed', { status: res.status, url: PB_URL, body });
|
||
throw new Error(`PocketBase auth failed: ${res.status} — ${body}`);
|
||
}
|
||
|
||
const data = await res.json();
|
||
_token = data.token as string;
|
||
// PocketBase superuser tokens expire in ~1 hour by default.
|
||
// Cache for 50 minutes to stay safely within that window.
|
||
_tokenExp = Date.now() + 50 * 60 * 1000;
|
||
log.info('pocketbase', 'admin auth token refreshed', { url: PB_URL });
|
||
return _token;
|
||
}
|
||
|
||
// ─── Generic helpers ──────────────────────────────────────────────────────────
|
||
|
||
async function pbGet<T>(path: string): Promise<T> {
|
||
const token = await getToken();
|
||
const res = await fetch(`${PB_URL}${path}`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (!res.ok) {
|
||
// On 401/403, the token may have expired on the PocketBase side even if
|
||
// our local TTL hasn't fired yet. Force a refresh and retry once.
|
||
if (res.status === 401 || res.status === 403) {
|
||
const freshToken = await getToken(true);
|
||
const retry = await fetch(`${PB_URL}${path}`, {
|
||
headers: { Authorization: `Bearer ${freshToken}` }
|
||
});
|
||
if (retry.ok) return retry.json() as Promise<T>;
|
||
const retryBody = await retry.text().catch(() => '');
|
||
log.error('pocketbase', 'GET failed', { path, status: retry.status, body: retryBody });
|
||
throw new Error(`PocketBase GET ${path} failed: ${retry.status} — ${retryBody}`);
|
||
}
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'GET failed', { path, status: res.status, body });
|
||
throw new Error(`PocketBase GET ${path} failed: ${res.status} — ${body}`);
|
||
}
|
||
return res.json() as Promise<T>;
|
||
}
|
||
|
||
async function pbPost(path: string, body: unknown): Promise<Response> {
|
||
const token = await getToken();
|
||
return fetch(`${PB_URL}${path}`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
}
|
||
|
||
async function pbPatch(path: string, body: unknown): Promise<Response> {
|
||
const token = await getToken();
|
||
return fetch(`${PB_URL}${path}`, {
|
||
method: 'PATCH',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
}
|
||
|
||
async function pbDelete(path: string): Promise<Response> {
|
||
const token = await getToken();
|
||
return fetch(`${PB_URL}${path}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
}
|
||
|
||
interface PBList<T> {
|
||
items: T[];
|
||
totalItems: number;
|
||
}
|
||
|
||
async function listAll<T>(collection: string, filter = '', sort = ''): Promise<T[]> {
|
||
const perPage = 500;
|
||
const params = new URLSearchParams({ perPage: String(perPage), page: '1' });
|
||
if (filter) params.set('filter', filter);
|
||
if (sort) params.set('sort', sort);
|
||
|
||
const first = await pbGet<PBList<T>>(
|
||
`/api/collections/${collection}/records?${params.toString()}`
|
||
);
|
||
const items: T[] = first.items ?? [];
|
||
const total = first.totalItems ?? 0;
|
||
|
||
// Fetch remaining pages if there are more records than the first page holds.
|
||
const totalPages = Math.ceil(total / perPage);
|
||
for (let page = 2; page <= totalPages; page++) {
|
||
params.set('page', String(page));
|
||
const data = await pbGet<PBList<T>>(
|
||
`/api/collections/${collection}/records?${params.toString()}`
|
||
);
|
||
items.push(...(data.items ?? []));
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
async function listN<T>(collection: string, n: number, filter = '', sort = ''): Promise<T[]> {
|
||
const params = new URLSearchParams({ perPage: String(n) });
|
||
if (filter) params.set('filter', filter);
|
||
if (sort) params.set('sort', sort);
|
||
const data = await pbGet<PBList<T>>(
|
||
`/api/collections/${collection}/records?${params.toString()}`
|
||
);
|
||
return data.items ?? [];
|
||
}
|
||
|
||
async function countCollection(collection: string, filter = ''): Promise<number> {
|
||
const params = new URLSearchParams({ perPage: '1' });
|
||
if (filter) params.set('filter', filter);
|
||
const data = await pbGet<PBList<unknown>>(
|
||
`/api/collections/${collection}/records?${params.toString()}`
|
||
);
|
||
return (data as { totalItems: number }).totalItems ?? 0;
|
||
}
|
||
|
||
async function listOne<T>(collection: string, filter: string, sort = ''): Promise<T | null> {
|
||
const params = new URLSearchParams({ perPage: '1', filter });
|
||
if (sort) params.set('sort', sort);
|
||
const data = await pbGet<PBList<T>>(
|
||
`/api/collections/${collection}/records?${params.toString()}`
|
||
);
|
||
return data.items[0] ?? null;
|
||
}
|
||
|
||
// ─── Books ────────────────────────────────────────────────────────────────────
|
||
|
||
const BOOKS_CACHE_KEY = 'books:all';
|
||
const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes
|
||
|
||
const RATINGS_CACHE_KEY = 'book_ratings:all';
|
||
const RATINGS_CACHE_TTL = 5 * 60; // 5 minutes
|
||
|
||
const HOME_STATS_CACHE_KEY = 'home:stats';
|
||
const HOME_STATS_CACHE_TTL = 10 * 60; // 10 minutes — counts don't need to be exact
|
||
|
||
const SCRAPING_TASKS_CACHE_KEY = 'admin:scraping_tasks';
|
||
const AUDIO_JOBS_CACHE_KEY = 'admin:audio_jobs';
|
||
const TRANSLATION_JOBS_CACHE_KEY = 'admin:translation_jobs';
|
||
const ADMIN_JOBS_CACHE_TTL = 30; // 30 seconds — admin views poll frequently
|
||
|
||
const BOOK_SLUGS_CACHE_KEY = 'books:slugs';
|
||
const BOOK_SLUGS_CACHE_TTL = 10 * 60; // 10 minutes — slugs change rarely
|
||
|
||
async function getAllRatings(): Promise<BookRating[]> {
|
||
const cached = await cache.get<BookRating[]>(RATINGS_CACHE_KEY);
|
||
if (cached) return cached;
|
||
const ratings = await listAll<BookRating>('book_ratings', '').catch(() => [] as BookRating[]);
|
||
await cache.set(RATINGS_CACHE_KEY, ratings, RATINGS_CACHE_TTL);
|
||
return ratings;
|
||
}
|
||
|
||
export async function listBooks(): Promise<Book[]> {
|
||
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
|
||
if (cached) {
|
||
log.debug('pocketbase', 'listBooks cache hit', { total: cached.length });
|
||
return cached;
|
||
}
|
||
const books = await listAll<Book>('books', '', '+title');
|
||
const nullTitles = books.filter((b) => b.title == null).length;
|
||
if (nullTitles > 0) {
|
||
log.warn('pocketbase', 'listBooks: books with null title', { count: nullTitles, total: books.length });
|
||
}
|
||
log.debug('pocketbase', 'listBooks cache miss', { total: books.length, nullTitles });
|
||
await cache.set(BOOKS_CACHE_KEY, books, BOOKS_CACHE_TTL);
|
||
return books;
|
||
}
|
||
|
||
export interface BookSlug {
|
||
slug: string;
|
||
title: string;
|
||
}
|
||
|
||
/**
|
||
* Returns only the slug and title of every book. Cheaper than listBooks() —
|
||
* used for datalist autocomplete in admin forms. Cached for 10 minutes.
|
||
*/
|
||
export async function listBookSlugs(): Promise<BookSlug[]> {
|
||
const cached = await cache.get<BookSlug[]>(BOOK_SLUGS_CACHE_KEY);
|
||
if (cached) return cached;
|
||
// Re-use full books cache if already warm — avoids a second PocketBase call.
|
||
const fullCached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
|
||
if (fullCached) {
|
||
const slugs = fullCached.map((b) => ({ slug: b.slug, title: b.title }));
|
||
await cache.set(BOOK_SLUGS_CACHE_KEY, slugs, BOOK_SLUGS_CACHE_TTL);
|
||
return slugs;
|
||
}
|
||
const items = await listAll<BookSlug>('books', '', '+title').catch(() => [] as BookSlug[]);
|
||
const slugs = items.map((b) => ({ slug: b.slug, title: b.title }));
|
||
await cache.set(BOOK_SLUGS_CACHE_KEY, slugs, BOOK_SLUGS_CACHE_TTL);
|
||
return slugs;
|
||
}
|
||
|
||
/**
|
||
* Fetch only the books whose slugs are in the given set.
|
||
* Uses PocketBase filter `slug IN (...)` — a single request regardless of how
|
||
* many slugs are requested. Falls back to empty array on error.
|
||
*
|
||
* Use this instead of listBooks() whenever you only need a small subset of
|
||
* books (e.g. the user's reading list or saved shelf).
|
||
*
|
||
* PocketBase filter syntax for IN: slug='a' || slug='b' || ...
|
||
* Limited to 200 slugs to keep the filter URL sane; callers with larger sets
|
||
* should fall back to listBooks().
|
||
*/
|
||
export async function getBooksBySlugs(slugs: Iterable<string>): Promise<Book[]> {
|
||
const slugArr = [...new Set(slugs)].slice(0, 200);
|
||
if (slugArr.length === 0) return [];
|
||
|
||
// Check cache for each slug individually (populated by prior listBooks calls).
|
||
// If all slugs hit, skip the network round-trip entirely.
|
||
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
|
||
if (cached) {
|
||
const slugSet = new Set(slugArr);
|
||
const found = cached.filter((b) => slugSet.has(b.slug));
|
||
if (found.length === slugArr.length) {
|
||
log.debug('pocketbase', 'getBooksBySlugs cache hit', { count: found.length });
|
||
return found;
|
||
}
|
||
}
|
||
|
||
// Build filter: slug='a' || slug='b' || ...
|
||
const filter = slugArr.map((s) => `slug='${s.replace(/'/g, "\\'")}'`).join(' || ');
|
||
const books = await listAll<Book>('books', filter, '+title');
|
||
|
||
// Deduplicate by slug — PocketBase may have multiple records for the same
|
||
// slug if the scraper ran concurrently or the upsert raced. First record wins.
|
||
const seen = new Set<string>();
|
||
const deduped = books.filter((b) => {
|
||
if (seen.has(b.slug)) return false;
|
||
seen.add(b.slug);
|
||
return true;
|
||
});
|
||
|
||
if (deduped.length !== books.length) {
|
||
log.warn('pocketbase', 'getBooksBySlugs: duplicate slugs in DB', {
|
||
requested: slugArr.length,
|
||
raw: books.length,
|
||
deduped: deduped.length
|
||
});
|
||
} else {
|
||
log.debug('pocketbase', 'getBooksBySlugs', { requested: slugArr.length, found: books.length });
|
||
}
|
||
return deduped;
|
||
}
|
||
|
||
/** Invalidate the books cache (call after a book is created/updated/deleted). */
|
||
export async function invalidateBooksCache(): Promise<void> {
|
||
await Promise.all([
|
||
cache.invalidate(BOOKS_CACHE_KEY),
|
||
cache.invalidate(HOME_STATS_CACHE_KEY),
|
||
cache.invalidatePattern('books:recent:*'),
|
||
cache.invalidatePattern('books:recently-updated:*'),
|
||
cache.invalidatePattern('books:trending:*'),
|
||
cache.invalidatePattern('books:recs:*')
|
||
]);
|
||
}
|
||
|
||
/** Books sorted by ranking (lower = more popular). Excludes unranked (ranking=0). */
|
||
export async function getTrendingBooks(limit = 8): Promise<Book[]> {
|
||
const key = `books:trending:${limit}`;
|
||
const cached = await cache.get<Book[]>(key);
|
||
if (cached) return cached;
|
||
const books = await listN<Book>('books', limit, 'ranking>0', '+ranking');
|
||
await cache.set(key, books, 15 * 60);
|
||
return books;
|
||
}
|
||
|
||
/**
|
||
* Books matching the given genres that the user hasn't read yet.
|
||
* The raw genre-query result is cached (shared across users); per-user slug
|
||
* exclusion is applied in memory afterwards.
|
||
*/
|
||
export async function getRecommendedBooks(
|
||
topGenres: string[],
|
||
excludeSlugs: Set<string>,
|
||
limit = 8
|
||
): Promise<Book[]> {
|
||
if (topGenres.length === 0) return [];
|
||
const sortedGenres = [...topGenres].sort();
|
||
const key = `books:recs:${sortedGenres.join(':')}:${limit}`;
|
||
let books = await cache.get<Book[]>(key);
|
||
if (!books) {
|
||
const genreFilter = sortedGenres
|
||
.map((g) => `genres~"${g.replace(/"/g, '')}"`)
|
||
.join('||');
|
||
books = await listN<Book>('books', limit * 4, genreFilter, '+ranking');
|
||
await cache.set(key, books, 10 * 60);
|
||
}
|
||
return books.filter((b) => !excludeSlugs.has(b.slug)).slice(0, limit);
|
||
}
|
||
|
||
export async function getBook(slug: string): Promise<Book | null> {
|
||
return listOne<Book>('books', `slug="${slug}"`);
|
||
}
|
||
|
||
export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
|
||
const key = `books:recent:${limit}`;
|
||
const cached = await cache.get<Book[]>(key);
|
||
if (cached) return cached;
|
||
const books = await listN<Book>('books', limit, '', '-meta_updated');
|
||
await cache.set(key, books, 5 * 60);
|
||
return books;
|
||
}
|
||
|
||
/**
|
||
* Books with the most recently added chapters, ordered by chapter insertion time.
|
||
* Queries chapters_idx sorted by -created, deduplicates by slug, then loads books.
|
||
* This correctly reflects actual chapter activity, unlike meta_updated on books.
|
||
*/
|
||
export async function recentlyUpdatedBooks(limit = 8): Promise<Book[]> {
|
||
const key = `books:recently-updated:${limit}`;
|
||
const cached = await cache.get<Book[]>(key);
|
||
if (cached) return cached;
|
||
|
||
try {
|
||
// Fetch enough recent chapter rows to find `limit` distinct books
|
||
const rows = await listN<{ slug: string; created: string }>(
|
||
'chapters_idx', limit * 25, '', '-created'
|
||
);
|
||
|
||
const seen = new Set<string>();
|
||
const slugs: string[] = [];
|
||
for (const row of rows) {
|
||
if (!seen.has(row.slug)) {
|
||
seen.add(row.slug);
|
||
slugs.push(row.slug);
|
||
if (slugs.length >= limit) break;
|
||
}
|
||
}
|
||
|
||
if (!slugs.length) return recentlyAddedBooks(limit);
|
||
|
||
const books = await getBooksBySlugs(new Set(slugs));
|
||
// Restore recency order (getBooksBySlugs returns in title sort order)
|
||
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
||
const ordered = slugs.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []));
|
||
|
||
await cache.set(key, ordered, 5 * 60);
|
||
return ordered;
|
||
} catch {
|
||
// Fall back to meta_updated sort if chapters_idx query fails
|
||
return recentlyAddedBooks(limit);
|
||
}
|
||
}
|
||
|
||
export interface HomeStats {
|
||
totalBooks: number;
|
||
totalChapters: number;
|
||
}
|
||
|
||
export async function getHomeStats(): Promise<HomeStats> {
|
||
const cached = await cache.get<HomeStats>(HOME_STATS_CACHE_KEY);
|
||
if (cached) return cached;
|
||
const [totalBooks, totalChapters] = await Promise.all([
|
||
countCollection('books'),
|
||
countCollection('chapters_idx')
|
||
]);
|
||
const stats = { totalBooks, totalChapters };
|
||
await cache.set(HOME_STATS_CACHE_KEY, stats, HOME_STATS_CACHE_TTL);
|
||
return stats;
|
||
}
|
||
|
||
export async function invalidateHomeStatsCache(): Promise<void> {
|
||
await cache.invalidate(HOME_STATS_CACHE_KEY);
|
||
}
|
||
|
||
// ─── Chapter index ────────────────────────────────────────────────────────────
|
||
|
||
export async function listChapterIdx(slug: string): Promise<ChapterIdx[]> {
|
||
return listAll<ChapterIdx>('chapters_idx', `slug="${slug}"`, '+number');
|
||
}
|
||
|
||
// ─── Reading progress ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Build the PocketBase filter string for a progress lookup.
|
||
* When userId is set, keyed by user_id (portable across devices).
|
||
* When only sessionId is set, keyed by session_id (anonymous).
|
||
*/
|
||
function progressFilter(sessionId: string, slug: string, userId?: string): string {
|
||
if (userId) return `user_id="${userId}"&&slug="${slug}"`;
|
||
return `session_id="${sessionId}"&&slug="${slug}"`;
|
||
}
|
||
|
||
function allProgressFilter(sessionId: string, userId?: string): string {
|
||
if (userId) return `user_id="${userId}"`;
|
||
return `session_id="${sessionId}"`;
|
||
}
|
||
|
||
export async function getProgress(
|
||
sessionId: string,
|
||
slug: string,
|
||
userId?: string
|
||
): Promise<Progress | null> {
|
||
return listOne<Progress>('progress', progressFilter(sessionId, slug, userId));
|
||
}
|
||
|
||
export async function allProgress(sessionId: string, userId?: string): Promise<Progress[]> {
|
||
return listAll<Progress>('progress', allProgressFilter(sessionId, userId), '-updated');
|
||
}
|
||
|
||
export async function setProgress(
|
||
sessionId: string,
|
||
slug: string,
|
||
chapter: number,
|
||
userId?: string
|
||
): Promise<void> {
|
||
const existing = await listOne<Progress & { id: string }>(
|
||
'progress',
|
||
progressFilter(sessionId, slug, userId)
|
||
);
|
||
|
||
const payload: Partial<Progress> = {
|
||
session_id: sessionId,
|
||
slug,
|
||
chapter,
|
||
updated: new Date().toISOString()
|
||
};
|
||
if (userId) payload.user_id = userId;
|
||
|
||
if (existing) {
|
||
const res = await pbPatch(`/api/collections/progress/records/${existing.id}`, payload);
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'setProgress PATCH failed', { slug, chapter, status: res.status, body });
|
||
}
|
||
} else {
|
||
const res = await pbPost('/api/collections/progress/records', payload);
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'setProgress POST failed', { slug, chapter, status: res.status, body });
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Delete progress entry for a specific book (removes from library/continue reading).
|
||
*/
|
||
export async function deleteProgress(
|
||
sessionId: string,
|
||
slug: string,
|
||
userId?: string
|
||
): Promise<void> {
|
||
const existing = await listOne<Progress & { id: string }>(
|
||
'progress',
|
||
progressFilter(sessionId, slug, userId)
|
||
);
|
||
|
||
if (!existing) {
|
||
log.debug('pocketbase', 'deleteProgress: no record found', { sessionId, slug, userId });
|
||
return;
|
||
}
|
||
|
||
const res = await pbDelete(`/api/collections/progress/records/${existing.id}`);
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'deleteProgress failed', {
|
||
slug,
|
||
id: existing.id,
|
||
status: res.status,
|
||
body
|
||
});
|
||
throw new Error(`Failed to delete progress: ${res.status}`);
|
||
}
|
||
log.info('pocketbase', 'deleteProgress success', { slug, id: existing.id });
|
||
}
|
||
|
||
/**
|
||
* Merge anonymous session progress into a user account on login/register.
|
||
*
|
||
* For each book tracked under sessionId, upserts a user-keyed record keeping
|
||
* whichever chapter is more recent (or higher if timestamps are equal).
|
||
* This makes progress portable across devices for logged-in users.
|
||
*/
|
||
export async function mergeSessionProgress(sessionId: string, userId: string): Promise<void> {
|
||
let sessionRows: Progress[];
|
||
try {
|
||
sessionRows = await allProgress(sessionId);
|
||
} catch (e) {
|
||
log.warn('pocketbase', 'mergeSessionProgress: failed to read session progress', {
|
||
sessionId,
|
||
err: String(e)
|
||
});
|
||
return;
|
||
}
|
||
if (sessionRows.length === 0) return;
|
||
|
||
for (const row of sessionRows) {
|
||
try {
|
||
const userRow = await listOne<Progress & { id: string }>(
|
||
'progress',
|
||
`user_id="${userId}"&&slug="${row.slug}"`
|
||
);
|
||
|
||
// Keep the record with the more recent update (or higher chapter if timestamps match)
|
||
const sessionTs = row.updated ? new Date(row.updated).getTime() : 0;
|
||
const userTs = userRow?.updated ? new Date(userRow.updated).getTime() : 0;
|
||
const shouldOverwrite = !userRow || sessionTs > userTs ||
|
||
(sessionTs === userTs && row.chapter > (userRow?.chapter ?? 0));
|
||
|
||
if (shouldOverwrite) {
|
||
const payload: Partial<Progress> = {
|
||
session_id: sessionId,
|
||
user_id: userId,
|
||
slug: row.slug,
|
||
chapter: row.chapter,
|
||
updated: row.updated ?? new Date().toISOString()
|
||
};
|
||
if (userRow) {
|
||
await pbPatch(`/api/collections/progress/records/${userRow.id}`, payload);
|
||
} else {
|
||
await pbPost('/api/collections/progress/records', payload);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
log.warn('pocketbase', 'mergeSessionProgress: failed to merge row', {
|
||
slug: row.slug,
|
||
err: String(e)
|
||
});
|
||
}
|
||
}
|
||
log.info('pocketbase', 'mergeSessionProgress: done', { sessionId, userId, count: sessionRows.length });
|
||
}
|
||
|
||
// ─── User library (saved books) ───────────────────────────────────────────────
|
||
|
||
export interface UserLibraryEntry {
|
||
id?: string;
|
||
session_id: string;
|
||
user_id?: string;
|
||
slug: string;
|
||
saved_at: string;
|
||
shelf?: string;
|
||
}
|
||
|
||
function libraryFilter(sessionId: string, userId?: string): string {
|
||
if (userId) return `user_id="${userId}"`;
|
||
return `session_id="${sessionId}"`;
|
||
}
|
||
|
||
/** Returns all slugs the user has explicitly saved to their library. */
|
||
export async function getSavedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||
const rows = await listAll<UserLibraryEntry>(
|
||
'user_library',
|
||
libraryFilter(sessionId, userId)
|
||
);
|
||
return new Set(rows.map((r) => r.slug));
|
||
}
|
||
|
||
/** Returns whether a specific slug is saved. */
|
||
export async function isBookSaved(
|
||
sessionId: string,
|
||
slug: string,
|
||
userId?: string
|
||
): Promise<boolean> {
|
||
const filter = userId
|
||
? `user_id="${userId}"&&slug="${slug}"`
|
||
: `session_id="${sessionId}"&&slug="${slug}"`;
|
||
const row = await listOne<UserLibraryEntry>('user_library', filter);
|
||
return row !== null;
|
||
}
|
||
|
||
/** Returns the shelf the user has placed this book on, or '' if not saved / no shelf set. */
|
||
export async function getBookShelf(
|
||
sessionId: string,
|
||
slug: string,
|
||
userId?: string
|
||
): Promise<ShelfName> {
|
||
const filter = userId
|
||
? `user_id="${userId}"&&slug="${slug}"`
|
||
: `session_id="${sessionId}"&&slug="${slug}"`;
|
||
const row = await listOne<UserLibraryEntry>('user_library', filter).catch(() => null);
|
||
return (row?.shelf as ShelfName) || '';
|
||
}
|
||
|
||
/** Save a book to the user's library. No-op if already saved. */
|
||
export async function saveBook(
|
||
sessionId: string,
|
||
slug: string,
|
||
userId?: string
|
||
): Promise<void> {
|
||
const alreadySaved = await isBookSaved(sessionId, slug, userId);
|
||
if (alreadySaved) return;
|
||
const payload: Partial<UserLibraryEntry> = {
|
||
session_id: sessionId,
|
||
slug,
|
||
saved_at: new Date().toISOString()
|
||
};
|
||
if (userId) payload.user_id = userId;
|
||
const res = await pbPost('/api/collections/user_library/records', payload);
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'saveBook POST failed', { slug, status: res.status, body });
|
||
}
|
||
}
|
||
|
||
/** Remove a book from the user's library. */
|
||
export async function unsaveBook(
|
||
sessionId: string,
|
||
slug: string,
|
||
userId?: string
|
||
): Promise<void> {
|
||
const filter = userId
|
||
? `user_id="${userId}"&&slug="${slug}"`
|
||
: `session_id="${sessionId}"&&slug="${slug}"`;
|
||
const row = await listOne<UserLibraryEntry & { id: string }>('user_library', filter);
|
||
if (!row) return;
|
||
const token = await getToken();
|
||
await fetch(`${PB_URL}/api/collections/user_library/records/${row.id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
}
|
||
|
||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||
|
||
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
|
||
|
||
function hashPassword(password: string): string {
|
||
const salt = randomBytes(16).toString('hex');
|
||
const hash = scryptSync(password, salt, 64).toString('hex');
|
||
return `${salt}:${hash}`;
|
||
}
|
||
|
||
function verifyPassword(password: string, stored: string): boolean {
|
||
const [salt, hash] = stored.split(':');
|
||
if (!salt || !hash) return false;
|
||
const derived = scryptSync(password, salt, 64);
|
||
const hashBuf = Buffer.from(hash, 'hex');
|
||
if (derived.length !== hashBuf.length) return false;
|
||
return timingSafeEqual(derived, hashBuf);
|
||
}
|
||
|
||
/**
|
||
* Look up a user by username. Returns null if not found.
|
||
*/
|
||
export async function getUserByUsername(username: string): Promise<User | null> {
|
||
return listOne<User>('app_users', `username="${username.replace(/"/g, '\\"')}"`);
|
||
}
|
||
|
||
/**
|
||
* Look up a user by their PocketBase record ID. Returns null if not found.
|
||
*/
|
||
export async function getUserById(id: string): Promise<User | null> {
|
||
const token = await getToken();
|
||
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${encodeURIComponent(id)}`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (res.status === 404) return null;
|
||
if (!res.ok) return null;
|
||
return res.json() as Promise<User>;
|
||
}
|
||
|
||
/**
|
||
* Look up a user by email. Returns null if not found.
|
||
*/
|
||
export async function getUserByEmail(email: string): Promise<User | null> {
|
||
return listOne<User>('app_users', `email="${email.replace(/"/g, '\\"')}"`);
|
||
}
|
||
|
||
/**
|
||
* Look up a user by OAuth provider + provider user ID. Returns null if not found.
|
||
*/
|
||
export async function getUserByOAuth(provider: string, oauthId: string): Promise<User | null> {
|
||
return listOne<User>(
|
||
'app_users',
|
||
`oauth_provider="${provider.replace(/"/g, '\\"')}"&&oauth_id="${oauthId.replace(/"/g, '\\"')}"`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Look up a user by their Polar customer ID. Returns null if not found.
|
||
*/
|
||
export async function getUserByPolarCustomerId(polarCustomerId: string): Promise<User | null> {
|
||
return listOne<User>(
|
||
'app_users',
|
||
`polar_customer_id="${polarCustomerId.replace(/"/g, '\\"')}"`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Patch arbitrary fields on an app_user record.
|
||
*/
|
||
export async function patchUser(userId: string, fields: Partial<User & Record<string, unknown>>): Promise<void> {
|
||
const res = await pbPatch(`/api/collections/app_users/records/${encodeURIComponent(userId)}`, fields);
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'patchUser failed', { userId, status: res.status, body });
|
||
throw new Error(`patchUser failed: ${res.status} — ${body}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a new user via OAuth (no password). email_verified is true since the
|
||
* provider already verified it. Throws on DB errors.
|
||
*/
|
||
export async function createOAuthUser(
|
||
username: string,
|
||
email: string,
|
||
provider: string,
|
||
oauthId: string,
|
||
avatarUrl?: string,
|
||
role = 'user'
|
||
): Promise<User> {
|
||
log.info('pocketbase', 'createOAuthUser', { username, email, provider });
|
||
const res = await pbPost('/api/collections/app_users/records', {
|
||
username,
|
||
password_hash: '',
|
||
role,
|
||
email,
|
||
email_verified: true,
|
||
oauth_provider: provider,
|
||
oauth_id: oauthId,
|
||
avatar_url: avatarUrl ?? '',
|
||
created: new Date().toISOString()
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'createOAuthUser: PocketBase rejected record', {
|
||
username,
|
||
status: res.status,
|
||
body
|
||
});
|
||
throw new Error(`Failed to create OAuth user: ${res.status} ${body}`);
|
||
}
|
||
return res.json() as Promise<User>;
|
||
}
|
||
|
||
/**
|
||
* Link an OAuth provider to an existing user account.
|
||
*/
|
||
export async function linkOAuthToUser(
|
||
userId: string,
|
||
provider: string,
|
||
oauthId: string
|
||
): Promise<void> {
|
||
const res = await pbPatch(`/api/collections/app_users/records/${userId}`, {
|
||
oauth_provider: provider,
|
||
oauth_id: oauthId,
|
||
email_verified: true
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'linkOAuthToUser: PATCH failed', { userId, status: res.status, body });
|
||
throw new Error(`Failed to link OAuth: ${res.status}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Look up a user by verification token. Returns null if not found.
|
||
* @deprecated Email verification removed — kept only for migration safety.
|
||
*/
|
||
export async function getUserByVerificationToken(token: string): Promise<User | null> {
|
||
return listOne<User>('app_users', `verification_token="${token.replace(/"/g, '\\"')}"`);
|
||
}
|
||
|
||
/**
|
||
* Create a new user with a hashed password. Throws if username already exists.
|
||
* Stores email + verification token but does NOT log the user in.
|
||
*/
|
||
export async function createUser(
|
||
username: string,
|
||
password: string,
|
||
email: string,
|
||
role = 'user'
|
||
): Promise<User> {
|
||
log.info('pocketbase', 'createUser: checking for existing username', { username });
|
||
const existing = await getUserByUsername(username);
|
||
if (existing) {
|
||
log.warn('pocketbase', 'createUser: username already taken', { username });
|
||
throw new Error('Username already taken');
|
||
}
|
||
const existingEmail = await getUserByEmail(email);
|
||
if (existingEmail) {
|
||
log.warn('pocketbase', 'createUser: email already in use', { email });
|
||
throw new Error('Email already in use');
|
||
}
|
||
const password_hash = hashPassword(password);
|
||
const verification_token = randomBytes(32).toString('hex');
|
||
const verification_token_exp = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||
log.info('pocketbase', 'createUser: inserting new user', { username, email, role });
|
||
const res = await pbPost('/api/collections/app_users/records', {
|
||
username,
|
||
password_hash,
|
||
role,
|
||
email,
|
||
email_verified: false,
|
||
verification_token,
|
||
verification_token_exp,
|
||
created: new Date().toISOString()
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'createUser: PocketBase rejected record', {
|
||
username,
|
||
status: res.status,
|
||
body
|
||
});
|
||
throw new Error(`Failed to create user: ${res.status} ${body}`);
|
||
}
|
||
log.info('pocketbase', 'createUser: user created', { username, role });
|
||
return res.json() as Promise<User>;
|
||
}
|
||
|
||
/**
|
||
* Mark a user's email as verified and clear the verification token.
|
||
*/
|
||
export async function verifyUserEmail(userId: string): Promise<void> {
|
||
const res = await pbPatch(`/api/collections/app_users/records/${userId}`, {
|
||
email_verified: true,
|
||
verification_token: '',
|
||
verification_token_exp: ''
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'verifyUserEmail: PATCH failed', { userId, status: res.status, body });
|
||
throw new Error(`Failed to verify email: ${res.status}`);
|
||
}
|
||
log.info('pocketbase', 'verifyUserEmail: success', { userId });
|
||
}
|
||
|
||
/**
|
||
* Change a user's password. Verifies the current password first.
|
||
* Returns true on success, false if currentPassword is wrong.
|
||
* Throws on unexpected errors.
|
||
*/
|
||
export async function changePassword(
|
||
userId: string,
|
||
currentPassword: string,
|
||
newPassword: string
|
||
): Promise<boolean> {
|
||
// Fetch the user record directly by id to verify current password
|
||
const token = await getToken();
|
||
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'changePassword: fetch user failed', { userId, status: res.status, body });
|
||
throw new Error(`Failed to fetch user: ${res.status}`);
|
||
}
|
||
const user = (await res.json()) as User;
|
||
if (!verifyPassword(currentPassword, user.password_hash)) {
|
||
log.warn('pocketbase', 'changePassword: wrong current password', { userId });
|
||
return false;
|
||
}
|
||
const newHash = hashPassword(newPassword);
|
||
const patch = await pbPatch(`/api/collections/app_users/records/${userId}`, {
|
||
password_hash: newHash
|
||
});
|
||
if (!patch.ok) {
|
||
const body = await patch.text().catch(() => '');
|
||
log.error('pocketbase', 'changePassword: PATCH failed', { userId, status: patch.status, body });
|
||
throw new Error(`Failed to update password: ${patch.status}`);
|
||
}
|
||
log.info('pocketbase', 'changePassword: success', { userId });
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Verify username + password. Returns the user on success, null on failure.
|
||
* Only used for legacy accounts that still have a password_hash.
|
||
*/
|
||
export async function loginUser(username: string, password: string): Promise<User | null> {
|
||
log.debug('pocketbase', 'loginUser: lookup', { username });
|
||
const user = await getUserByUsername(username);
|
||
if (!user) {
|
||
log.warn('pocketbase', 'loginUser: username not found', { username });
|
||
return null;
|
||
}
|
||
if (!user.password_hash) {
|
||
log.warn('pocketbase', 'loginUser: account has no password (OAuth-only)', { username });
|
||
return null;
|
||
}
|
||
const ok = verifyPassword(password, user.password_hash);
|
||
if (!ok) {
|
||
log.warn('pocketbase', 'loginUser: wrong password', { username });
|
||
return null;
|
||
}
|
||
log.info('pocketbase', 'loginUser: success', { username, role: user.role });
|
||
return user;
|
||
}
|
||
|
||
// ─── User settings ────────────────────────────────────────────────────────────
|
||
|
||
function settingsFilter(sessionId: string, userId?: string): string {
|
||
if (userId) return `user_id="${userId}"`;
|
||
return `session_id="${sessionId}"`;
|
||
}
|
||
|
||
export async function getSettings(
|
||
sessionId: string,
|
||
userId?: string
|
||
): Promise<PBUserSettings | null> {
|
||
return listOne<PBUserSettings>('user_settings', settingsFilter(sessionId, userId));
|
||
}
|
||
|
||
export async function saveSettings(
|
||
sessionId: string,
|
||
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean; audioMode?: string },
|
||
userId?: string
|
||
): Promise<void> {
|
||
const existing = await listOne<PBUserSettings & { id: string }>(
|
||
'user_settings',
|
||
settingsFilter(sessionId, userId)
|
||
);
|
||
|
||
const payload: Partial<PBUserSettings> = {
|
||
session_id: sessionId,
|
||
auto_next: settings.autoNext,
|
||
voice: settings.voice,
|
||
speed: settings.speed,
|
||
updated: new Date().toISOString()
|
||
};
|
||
if (settings.theme !== undefined) payload.theme = settings.theme;
|
||
if (settings.locale !== undefined) payload.locale = settings.locale;
|
||
if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
|
||
if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
|
||
if (settings.announceChapter !== undefined) payload.announce_chapter = settings.announceChapter;
|
||
if (settings.audioMode !== undefined) payload.audio_mode = settings.audioMode;
|
||
if (userId) payload.user_id = userId;
|
||
|
||
if (existing) {
|
||
const res = await pbPatch(`/api/collections/user_settings/records/${existing.id}`, payload);
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'saveSettings PATCH failed', { status: res.status, body });
|
||
}
|
||
} else {
|
||
const res = await pbPost('/api/collections/user_settings/records', payload);
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'saveSettings POST failed', { status: res.status, body });
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Audio time ───────────────────────────────────────────────────────────────
|
||
|
||
export async function setAudioTime(
|
||
sessionId: string,
|
||
slug: string,
|
||
chapter: number,
|
||
audioTime: number,
|
||
userId?: string
|
||
): Promise<void> {
|
||
const existing = await listOne<Progress & { id: string }>(
|
||
'progress',
|
||
progressFilter(sessionId, slug, userId)
|
||
);
|
||
if (!existing) {
|
||
// No progress record yet — create one with audio_time
|
||
const payload: Partial<Progress> = {
|
||
session_id: sessionId,
|
||
slug,
|
||
chapter,
|
||
audio_time: audioTime,
|
||
updated: new Date().toISOString()
|
||
};
|
||
if (userId) payload.user_id = userId;
|
||
const res = await pbPost('/api/collections/progress/records', payload);
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'setAudioTime POST failed', { slug, chapter, status: res.status, body });
|
||
}
|
||
return;
|
||
}
|
||
const res = await pbPatch(`/api/collections/progress/records/${existing.id}`, {
|
||
audio_time: audioTime,
|
||
updated: new Date().toISOString()
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'setAudioTime PATCH failed', { slug, chapter, status: res.status, body });
|
||
}
|
||
}
|
||
|
||
// ─── Audio cache ──────────────────────────────────────────────────────────────
|
||
// There is no separate audio_cache collection — completed audio jobs in the
|
||
// audio_jobs collection serve as the cache record. We project them here.
|
||
|
||
export interface AudioCacheEntry {
|
||
id: string;
|
||
cache_key: string;
|
||
filename: string;
|
||
updated: string;
|
||
}
|
||
|
||
// ─── Scraping tasks ───────────────────────────────────────────────────────────
|
||
|
||
export interface ScrapingTask {
|
||
id: string;
|
||
kind: string;
|
||
target_url: string;
|
||
status: string;
|
||
books_found: number;
|
||
chapters_scraped: number;
|
||
chapters_skipped: number;
|
||
from_chapter: number;
|
||
to_chapter: number;
|
||
errors: number;
|
||
started: string;
|
||
finished: string;
|
||
error_message: string;
|
||
}
|
||
|
||
export async function listScrapingTasks(): Promise<ScrapingTask[]> {
|
||
const cached = await cache.get<ScrapingTask[]>(SCRAPING_TASKS_CACHE_KEY);
|
||
if (cached) return cached;
|
||
const tasks = await listN<ScrapingTask>('scraping_tasks', 500, '', '-started');
|
||
await cache.set(SCRAPING_TASKS_CACHE_KEY, tasks, ADMIN_JOBS_CACHE_TTL);
|
||
return tasks;
|
||
}
|
||
|
||
export async function getScrapingTask(id: string): Promise<ScrapingTask | null> {
|
||
return listOne<ScrapingTask>('scraping_tasks', `id="${id}"`);
|
||
}
|
||
|
||
// ─── Audio jobs ───────────────────────────────────────────────────────────────
|
||
|
||
export interface AudioJob {
|
||
id: string;
|
||
cache_key: string; // "slug/chapter/voice"
|
||
slug: string;
|
||
chapter: number;
|
||
voice: string;
|
||
status: string; // "pending" | "generating" | "done" | "failed"
|
||
error_message: string;
|
||
started: string;
|
||
finished: string;
|
||
}
|
||
|
||
export async function listAudioJobs(): Promise<AudioJob[]> {
|
||
const cached = await cache.get<AudioJob[]>(AUDIO_JOBS_CACHE_KEY);
|
||
if (cached) return cached;
|
||
const jobs = await listN<AudioJob>('audio_jobs', 500, '', '-started');
|
||
await cache.set(AUDIO_JOBS_CACHE_KEY, jobs, ADMIN_JOBS_CACHE_TTL);
|
||
return jobs;
|
||
}
|
||
|
||
/**
|
||
* Returns the set of book slugs that have at least one completed audio job.
|
||
* Used by the catalogue page to show audio-available badges.
|
||
*/
|
||
export async function getSlugsWithAudio(): Promise<Set<string>> {
|
||
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', 'slug');
|
||
return new Set(jobs.map((j) => j.slug));
|
||
}
|
||
|
||
/**
|
||
* Returns books that have at least one completed audio chapter, sorted by
|
||
* number of narrated chapters descending.
|
||
* Cached for 5 minutes (same TTL as the catalogue audio badge).
|
||
*/
|
||
const AUDIO_BOOKS_CACHE_KEY = 'audio:books_with_count';
|
||
const AUDIO_BOOKS_CACHE_TTL = 5 * 60;
|
||
|
||
export interface AudioBookEntry {
|
||
book: Book;
|
||
audioChapters: number;
|
||
}
|
||
|
||
export async function getBooksWithAudioCount(limit = 100): Promise<AudioBookEntry[]> {
|
||
const cached = await cache.get<AudioBookEntry[]>(AUDIO_BOOKS_CACHE_KEY);
|
||
if (cached) return cached.slice(0, limit);
|
||
|
||
// Count done jobs per slug
|
||
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', 'slug');
|
||
const countBySlug = new Map<string, number>();
|
||
for (const j of jobs) {
|
||
// audio_jobs can have multiple voice variants for the same chapter — deduplicate
|
||
// by chapter number so we count chapters, not voice variants.
|
||
// cache_key format: "slug/chapter/voice"
|
||
const slug = j.slug;
|
||
if (!countBySlug.has(slug)) countBySlug.set(slug, 0);
|
||
// We'll use a Set per slug after this loop instead
|
||
}
|
||
// Build slug → Set<chapter> to deduplicate voice variants
|
||
const chapsBySlug = new Map<string, Set<number>>();
|
||
for (const j of jobs) {
|
||
if (!chapsBySlug.has(j.slug)) chapsBySlug.set(j.slug, new Set());
|
||
chapsBySlug.get(j.slug)!.add(j.chapter);
|
||
}
|
||
|
||
const slugs = [...chapsBySlug.keys()];
|
||
if (slugs.length === 0) return [];
|
||
|
||
const books = await getBooksBySlugs(slugs);
|
||
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
||
|
||
const entries: AudioBookEntry[] = [];
|
||
for (const [slug, chapters] of chapsBySlug) {
|
||
const book = bookMap.get(slug);
|
||
if (!book) continue;
|
||
entries.push({ book, audioChapters: chapters.size });
|
||
}
|
||
// Sort by most chapters narrated first
|
||
entries.sort((a, b) => b.audioChapters - a.audioChapters);
|
||
|
||
await cache.set(AUDIO_BOOKS_CACHE_KEY, entries, AUDIO_BOOKS_CACHE_TTL);
|
||
return entries.slice(0, limit);
|
||
}
|
||
|
||
/**
|
||
* Returns a map of chapter number → best available voice for a given slug.
|
||
* "Best" means: prefer `preferredVoice` if a done job exists for it,
|
||
* otherwise fall back to any done voice for that chapter.
|
||
* Result is cached per slug for 60 seconds (audio jobs complete frequently).
|
||
*/
|
||
export async function getReadyChaptersForSlug(
|
||
slug: string,
|
||
preferredVoice = ''
|
||
): Promise<Map<number, string>> {
|
||
const cacheKey = `audio:ready_chapters:${slug}`;
|
||
const cached = await cache.get<{ chapter: number; voice: string }[]>(cacheKey);
|
||
const raw = cached ?? await (async () => {
|
||
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"&&status="done"`);
|
||
const jobs = await listAll<AudioJob>('audio_jobs', filter, 'chapter');
|
||
const result: { chapter: number; voice: string }[] = jobs.map((j) => ({
|
||
chapter: j.chapter,
|
||
voice: j.voice ?? ''
|
||
}));
|
||
await cache.set(cacheKey, result, 60);
|
||
return result;
|
||
})();
|
||
|
||
// Build chapter → voices map
|
||
const byChapter = new Map<number, string[]>();
|
||
for (const { chapter, voice } of raw) {
|
||
if (!byChapter.has(chapter)) byChapter.set(chapter, []);
|
||
byChapter.get(chapter)!.push(voice);
|
||
}
|
||
|
||
// Resolve best voice per chapter
|
||
const result = new Map<number, string>();
|
||
for (const [chapter, voices] of byChapter) {
|
||
const best = preferredVoice && voices.includes(preferredVoice)
|
||
? preferredVoice
|
||
: voices[0] ?? '';
|
||
result.set(chapter, best);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ─── Translation jobs ─────────────────────────────────────────────────────────
|
||
|
||
export interface TranslationJob {
|
||
id: string;
|
||
cache_key: string; // "slug/chapter/lang"
|
||
slug: string;
|
||
chapter: number;
|
||
lang: string;
|
||
status: string; // "pending" | "running" | "done" | "failed"
|
||
error_message: string;
|
||
started: string;
|
||
finished: string;
|
||
}
|
||
|
||
export async function listTranslationJobs(): Promise<TranslationJob[]> {
|
||
const cached = await cache.get<TranslationJob[]>(TRANSLATION_JOBS_CACHE_KEY);
|
||
if (cached) return cached;
|
||
const jobs = await listN<TranslationJob>('translation_jobs', 500, '', '-started');
|
||
await cache.set(TRANSLATION_JOBS_CACHE_KEY, jobs, ADMIN_JOBS_CACHE_TTL);
|
||
return jobs;
|
||
}
|
||
|
||
export async function getAudioTime(
|
||
sessionId: string,
|
||
slug: string,
|
||
chapter: number,
|
||
userId?: string
|
||
): Promise<number | null> {
|
||
const row = await listOne<Progress>('progress', progressFilter(sessionId, slug, userId));
|
||
if (!row || !row.audio_time) return null;
|
||
return row.audio_time;
|
||
}
|
||
|
||
// ─── User sessions ────────────────────────────────────────────────────────────
|
||
|
||
export interface UserSession {
|
||
id: string;
|
||
user_id: string;
|
||
session_id: string; // the auth session ID embedded in the token
|
||
user_agent: string;
|
||
ip: string;
|
||
device_fingerprint: string;
|
||
created_at: string;
|
||
last_seen: string;
|
||
}
|
||
|
||
/**
|
||
* Generate a short device fingerprint from the user-agent alone.
|
||
* IP is intentionally excluded so that network changes (VPN, mobile data,
|
||
* home vs. office wifi) don't create duplicate sessions for the same device.
|
||
* SHA-256 of the user-agent string, first 16 hex chars.
|
||
*/
|
||
function deviceFingerprint(userAgent: string, _ip?: string): string {
|
||
return createHash('sha256')
|
||
.update(userAgent)
|
||
.digest('hex')
|
||
.slice(0, 16);
|
||
}
|
||
|
||
/**
|
||
* Upsert a session record on login.
|
||
* - If a session already exists for this user + device fingerprint, touch it and
|
||
* return the existing authSessionId (so the caller can reuse the same token).
|
||
* - Otherwise create a new record.
|
||
* Returns `{ authSessionId, recordId }`.
|
||
*/
|
||
export async function upsertUserSession(
|
||
userId: string,
|
||
authSessionId: string,
|
||
userAgent: string,
|
||
ip: string
|
||
): Promise<{ authSessionId: string; recordId: string }> {
|
||
const fp = deviceFingerprint(userAgent, ip);
|
||
|
||
// Look for an existing session from the same device
|
||
const existing = await listOne<UserSession>(
|
||
'user_sessions',
|
||
`user_id="${userId}" && device_fingerprint="${fp}"`
|
||
);
|
||
|
||
if (existing) {
|
||
// Touch last_seen and update IP (may have changed due to network switch).
|
||
// Return the existing authSessionId so no new session row is created.
|
||
const token = await getToken();
|
||
await fetch(`${PB_URL}/api/collections/user_sessions/records/${existing.id}`, {
|
||
method: 'PATCH',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ last_seen: new Date().toISOString(), ip })
|
||
}).catch(() => {});
|
||
return { authSessionId: existing.session_id, recordId: existing.id };
|
||
}
|
||
|
||
// Create a new session record
|
||
const now = new Date().toISOString();
|
||
const res = await pbPost('/api/collections/user_sessions/records', {
|
||
user_id: userId,
|
||
session_id: authSessionId,
|
||
user_agent: userAgent,
|
||
ip,
|
||
device_fingerprint: fp,
|
||
created_at: now,
|
||
last_seen: now
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'upsertUserSession POST failed', { userId, status: res.status, body });
|
||
throw new Error(`Failed to create session: ${res.status}`);
|
||
}
|
||
const rec = (await res.json()) as { id: string };
|
||
// Best-effort: prune stale/excess sessions in the background
|
||
pruneStaleUserSessions(userId).catch(() => {});
|
||
return { authSessionId, recordId: rec.id };
|
||
}
|
||
|
||
/**
|
||
* @deprecated Use upsertUserSession instead.
|
||
* Kept temporarily so callers can be migrated incrementally.
|
||
*/
|
||
export async function createUserSession(
|
||
userId: string,
|
||
authSessionId: string,
|
||
userAgent: string,
|
||
ip: string
|
||
): Promise<string> {
|
||
const { recordId } = await upsertUserSession(userId, authSessionId, userAgent, ip);
|
||
return recordId;
|
||
}
|
||
|
||
/**
|
||
* Update last_seen on a session (best-effort, non-fatal if it fails).
|
||
*/
|
||
export async function touchUserSession(authSessionId: string): Promise<void> {
|
||
const row = await listOne<UserSession & { id: string }>(
|
||
'user_sessions',
|
||
`session_id="${authSessionId}"`
|
||
);
|
||
if (!row) return;
|
||
const token = await getToken();
|
||
await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, {
|
||
method: 'PATCH',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ last_seen: new Date().toISOString() })
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Check whether a session has been revoked (i.e., not present in DB).
|
||
* Returns true if revoked/missing, false if valid.
|
||
*/
|
||
export async function isSessionRevoked(authSessionId: string): Promise<boolean> {
|
||
const row = await listOne<UserSession>('user_sessions', `session_id="${authSessionId}"`);
|
||
return row === null;
|
||
}
|
||
|
||
/**
|
||
* List all active sessions for a user.
|
||
*/
|
||
export async function listUserSessions(userId: string): Promise<UserSession[]> {
|
||
return listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
|
||
}
|
||
|
||
/**
|
||
* Delete sessions for a user that haven't been seen in the last `days` days,
|
||
* and cap the total number of sessions at `maxSessions` (pruning oldest first).
|
||
* Called on login so the list self-cleans without a separate cron job.
|
||
*/
|
||
async function pruneStaleUserSessions(
|
||
userId: string,
|
||
days = 30,
|
||
maxSessions = 10
|
||
): Promise<void> {
|
||
const token = await getToken();
|
||
const all = await listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
|
||
|
||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||
const toDelete = new Set<string>();
|
||
|
||
// Mark stale sessions
|
||
for (const s of all) {
|
||
if (s.last_seen < cutoff) toDelete.add(s.id);
|
||
}
|
||
|
||
// Mark excess sessions beyond the cap (oldest first — list is sorted -last_seen)
|
||
const remaining = all.filter((s) => !toDelete.has(s.id));
|
||
if (remaining.length > maxSessions) {
|
||
remaining.slice(maxSessions).forEach((s) => toDelete.add(s.id));
|
||
}
|
||
|
||
if (toDelete.size === 0) return;
|
||
|
||
await Promise.all(
|
||
[...toDelete].map((id) =>
|
||
fetch(`${PB_URL}/api/collections/user_sessions/records/${id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
}).catch(() => {})
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Revoke (delete) a specific session by its PocketBase record ID.
|
||
* Only allows deletion if the session belongs to the given userId.
|
||
*/
|
||
export async function revokeUserSession(recordId: string, userId: string): Promise<boolean> {
|
||
// Verify ownership before deleting
|
||
const token = await getToken();
|
||
const res = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (!res.ok) return false;
|
||
const rec = (await res.json()) as UserSession;
|
||
if (rec.user_id !== userId) return false;
|
||
|
||
const del = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
return del.ok || del.status === 204;
|
||
}
|
||
|
||
/**
|
||
* Delete a session by its auth session ID (the value stored in the cookie).
|
||
* Used on logout so the row doesn't linger as a phantom active session.
|
||
*/
|
||
export async function deleteSessionByAuthId(authSessionId: string): Promise<void> {
|
||
const row = await listOne<UserSession>('user_sessions', `session_id="${authSessionId}"`);
|
||
if (!row) return;
|
||
const token = await getToken();
|
||
await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
}).catch(() => {});
|
||
}
|
||
|
||
/**
|
||
* Revoke all sessions for a user (used on password change etc).
|
||
*/
|
||
export async function revokeAllUserSessions(userId: string): Promise<void> {
|
||
const sessions = await listUserSessions(userId);
|
||
const token = await getToken();
|
||
await Promise.all(
|
||
sessions.map((s) =>
|
||
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
}).catch(() => {})
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Delete all data associated with a user account:
|
||
* - user_settings, user_library, progress, comment_votes, book_ratings,
|
||
* user_subscriptions, user_sessions, notifications rows owned by the user
|
||
* - the app_users record itself
|
||
*
|
||
* Does NOT delete audio files from MinIO (shared cache) or book comments
|
||
* (anonymised to preserve discussion threads).
|
||
*/
|
||
export async function deleteUserAccount(userId: string, sessionId: string): Promise<void> {
|
||
const collections = [
|
||
{ name: 'user_settings', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
|
||
{ name: 'user_library', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
|
||
{ name: 'progress', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
|
||
{ name: 'comment_votes', filter: `user_id="${userId}"` },
|
||
{ name: 'book_ratings', filter: `user_id="${userId}"` },
|
||
{ name: 'user_subscriptions', filter: `(follower_id="${userId}" || followee_id="${userId}")` },
|
||
{ name: 'notifications', filter: `user_id="${userId}"` },
|
||
{ name: 'user_sessions', filter: `user_id="${userId}"` },
|
||
];
|
||
|
||
const token = await getToken();
|
||
|
||
for (const { name, filter } of collections) {
|
||
try {
|
||
const rows = await listAll<{ id: string }>(name, filter);
|
||
await Promise.all(
|
||
rows.map((r) =>
|
||
fetch(`${PB_URL}/api/collections/${name}/records/${r.id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
}).catch(() => {})
|
||
)
|
||
);
|
||
} catch {
|
||
// Best-effort: log and continue so one failure doesn't abort the rest
|
||
log.warn('pocketbase', `deleteUserAccount: failed to purge ${name}`, { userId });
|
||
}
|
||
}
|
||
|
||
// Delete the user record last
|
||
const res = await pbDelete(`/api/collections/app_users/records/${userId}`);
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
log.error('pocketbase', 'deleteUserAccount: failed to delete app_users record', { userId, status: res.status, body });
|
||
throw new Error(`Failed to delete user record (${res.status})`);
|
||
}
|
||
log.info('pocketbase', 'deleteUserAccount: account deleted', { userId });
|
||
}
|
||
|
||
/**
|
||
* Update the avatar_url field for a user record.
|
||
*/
|
||
export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Promise<void> {
|
||
const token = await getToken();
|
||
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
|
||
method: 'PATCH',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ avatar_url: avatarUrl })
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
throw new Error(`updateUserAvatarUrl failed: ${res.status} ${body}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update a user's notification preferences (stored on app_users record).
|
||
*/
|
||
export async function updateUserNotificationPrefs(
|
||
userId: string,
|
||
prefs: { notify_new_chapters?: boolean }
|
||
): Promise<void> {
|
||
const token = await getToken();
|
||
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
|
||
method: 'PATCH',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(prefs)
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
throw new Error(`updateUserNotificationPrefs failed: ${res.status} ${body}`);
|
||
}
|
||
}
|
||
|
||
// ─── Comments ─────────────────────────────────────────────────────────────────
|
||
|
||
export interface PBBookComment {
|
||
id: string;
|
||
slug: string;
|
||
chapter?: number; // 0 or absent = book-level; N = chapter N
|
||
user_id: string;
|
||
username: string;
|
||
body: string;
|
||
upvotes: number;
|
||
downvotes: number;
|
||
created: string;
|
||
parent_id?: string; // empty / absent = top-level; set = reply
|
||
}
|
||
|
||
export interface CommentVote {
|
||
id: string;
|
||
comment_id: string;
|
||
user_id: string;
|
||
session_id: string;
|
||
vote: 'up' | 'down';
|
||
}
|
||
|
||
export type CommentSort = 'top' | 'new';
|
||
|
||
/**
|
||
* List top-level comments for a book or a specific chapter.
|
||
* chapter=0 (default) → book-level comments only
|
||
* chapter=N → comments for chapter N only
|
||
* sort='top' → by net score (upvotes − downvotes) desc, then newest
|
||
* sort='new' → newest first (default)
|
||
* Replies (parent_id != "") are NOT included — fetch them separately.
|
||
*/
|
||
export async function listComments(
|
||
slug: string,
|
||
sort: CommentSort = 'new',
|
||
chapter = 0
|
||
): Promise<PBBookComment[]> {
|
||
const token = await getToken();
|
||
const slugEsc = slug.replace(/"/g, '\\"');
|
||
const chapterFilter = chapter > 0
|
||
? `&&chapter=${chapter}`
|
||
: `&&(chapter=0||chapter=null)`;
|
||
const filter = encodeURIComponent(`slug="${slugEsc}"${chapterFilter}&&(parent_id=""||parent_id=null)`);
|
||
const res = await fetch(
|
||
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=-created&perPage=200`,
|
||
{ headers: { Authorization: `Bearer ${token}` } }
|
||
);
|
||
if (!res.ok) return [];
|
||
const data = await res.json();
|
||
let items = (data.items ?? []) as PBBookComment[];
|
||
if (sort === 'top') {
|
||
items = items.sort((a, b) => {
|
||
const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0);
|
||
const scoreA = (a.upvotes ?? 0) - (a.downvotes ?? 0);
|
||
if (scoreB !== scoreA) return scoreB - scoreA;
|
||
return new Date(b.created).getTime() - new Date(a.created).getTime();
|
||
});
|
||
}
|
||
return items;
|
||
}
|
||
|
||
/**
|
||
* Count unique readers for a book in the last 7 days.
|
||
* Uses progress.updated timestamp; counts both session-based and user-based.
|
||
*/
|
||
export async function countReadersThisWeek(slug: string): Promise<number> {
|
||
const token = await getToken();
|
||
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"&&updated>"${cutoff}"`);
|
||
const res = await fetch(
|
||
`${PB_URL}/api/collections/progress/records?filter=${filter}&perPage=500&fields=user_id,session_id`,
|
||
{ headers: { Authorization: `Bearer ${token}` } }
|
||
);
|
||
if (!res.ok) return 0;
|
||
const data = await res.json();
|
||
const items = (data.items ?? []) as { user_id?: string; session_id?: string }[];
|
||
// Deduplicate: prefer user_id when present, fall back to session_id
|
||
const unique = new Set(items.map((r) => r.user_id || r.session_id || '').filter(Boolean));
|
||
return unique.size;
|
||
}
|
||
|
||
/**
|
||
* List replies (1-level deep) for a single parent comment.
|
||
* Always sorted oldest-first so the conversation reads naturally.
|
||
*/
|
||
export async function listReplies(parentId: string): Promise<PBBookComment[]> {
|
||
const token = await getToken();
|
||
const filter = encodeURIComponent(`parent_id="${parentId.replace(/"/g, '\\"')}"`);
|
||
const res = await fetch(
|
||
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=created&perPage=100`,
|
||
{ headers: { Authorization: `Bearer ${token}` } }
|
||
);
|
||
if (!res.ok) return [];
|
||
const data = await res.json();
|
||
return (data.items ?? []) as PBBookComment[];
|
||
}
|
||
|
||
/**
|
||
* Create a new comment. Returns the created record.
|
||
* Pass parentId to create a reply; omit / pass undefined for a top-level comment.
|
||
*/
|
||
export async function createComment(
|
||
slug: string,
|
||
body: string,
|
||
userId: string | undefined,
|
||
username: string,
|
||
parentId?: string,
|
||
chapter = 0
|
||
): Promise<PBBookComment> {
|
||
const token = await getToken();
|
||
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: crypto.randomUUID().replace(/-/g, '').slice(0, 15),
|
||
slug,
|
||
chapter,
|
||
body,
|
||
user_id: userId ?? '',
|
||
username,
|
||
upvotes: 0,
|
||
downvotes: 0,
|
||
parent_id: parentId ?? '',
|
||
created: new Date().toISOString()
|
||
})
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '');
|
||
throw new Error(`createComment failed: ${res.status} ${text}`);
|
||
}
|
||
return res.json() as Promise<PBBookComment>;
|
||
}
|
||
|
||
/**
|
||
* Delete a comment (and optionally its replies) by ID.
|
||
* Only the comment owner (matched by userId) may delete.
|
||
* Throws if the comment doesn't exist or the user doesn't own it.
|
||
*/
|
||
export async function deleteComment(commentId: string, userId: string): Promise<void> {
|
||
const token = await getToken();
|
||
|
||
// Fetch the comment to verify ownership
|
||
const getRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (!getRes.ok) throw new Error(`Comment not found: ${commentId}`);
|
||
const comment = (await getRes.json()) as PBBookComment;
|
||
if (comment.user_id !== userId) throw new Error('Not authorized to delete this comment');
|
||
|
||
// Delete any replies first
|
||
const repliesFilter = encodeURIComponent(`parent_id="${commentId.replace(/"/g, '\\"')}"`);
|
||
const repliesRes = await fetch(
|
||
`${PB_URL}/api/collections/book_comments/records?filter=${repliesFilter}&perPage=100`,
|
||
{ headers: { Authorization: `Bearer ${token}` } }
|
||
);
|
||
if (repliesRes.ok) {
|
||
const repliesData = await repliesRes.json();
|
||
const replies = (repliesData.items ?? []) as PBBookComment[];
|
||
await Promise.all(
|
||
replies.map((r) =>
|
||
fetch(`${PB_URL}/api/collections/book_comments/records/${r.id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
})
|
||
)
|
||
);
|
||
}
|
||
|
||
// Delete the comment itself
|
||
const delRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (!delRes.ok) throw new Error(`deleteComment failed: ${delRes.status}`);
|
||
}
|
||
|
||
/**
|
||
* Get an existing vote by this voter (identified by user_id or session_id) on a comment.
|
||
*/
|
||
export async function getCommentVote(
|
||
commentId: string,
|
||
sessionId: string,
|
||
userId?: string
|
||
): Promise<CommentVote | null> {
|
||
const token = await getToken();
|
||
const voterFilter = userId
|
||
? `comment_id="${commentId}"&&user_id="${userId}"`
|
||
: `comment_id="${commentId}"&&session_id="${sessionId}"`;
|
||
const res = await fetch(
|
||
`${PB_URL}/api/collections/comment_votes/records?filter=${encodeURIComponent(voterFilter)}&perPage=1`,
|
||
{ headers: { Authorization: `Bearer ${token}` } }
|
||
);
|
||
if (!res.ok) return null;
|
||
const data = await res.json();
|
||
const items = (data.items ?? []) as CommentVote[];
|
||
return items[0] ?? null;
|
||
}
|
||
|
||
/**
|
||
* Cast or change a vote on a comment. Handles:
|
||
* - New vote: creates vote record, increments counter.
|
||
* - Same vote again: removes it (toggle off), decrements counter.
|
||
* - Changed vote: updates record, adjusts both counters.
|
||
* Returns the updated comment.
|
||
*/
|
||
export async function voteComment(
|
||
commentId: string,
|
||
vote: 'up' | 'down',
|
||
sessionId: string,
|
||
userId?: string
|
||
): Promise<PBBookComment> {
|
||
const token = await getToken();
|
||
|
||
// Fetch current comment
|
||
const commentRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (!commentRes.ok) throw new Error(`Comment not found: ${commentId}`);
|
||
const comment = (await commentRes.json()) as PBBookComment;
|
||
|
||
const existing = await getCommentVote(commentId, sessionId, userId);
|
||
|
||
let upDelta = 0;
|
||
let downDelta = 0;
|
||
|
||
if (!existing) {
|
||
// New vote
|
||
await fetch(`${PB_URL}/api/collections/comment_votes/records`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ comment_id: commentId, user_id: userId ?? '', session_id: sessionId, vote })
|
||
});
|
||
vote === 'up' ? upDelta++ : downDelta++;
|
||
} else if (existing.vote === vote) {
|
||
// Toggle off — remove vote
|
||
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
vote === 'up' ? upDelta-- : downDelta--;
|
||
} else {
|
||
// Changed vote
|
||
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
|
||
method: 'PATCH',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ vote })
|
||
});
|
||
if (vote === 'up') { upDelta++; downDelta--; }
|
||
else { upDelta--; downDelta++; }
|
||
}
|
||
|
||
// Patch comment counters
|
||
const patchRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||
method: 'PATCH',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
upvotes: Math.max(0, (comment.upvotes ?? 0) + upDelta),
|
||
downvotes: Math.max(0, (comment.downvotes ?? 0) + downDelta)
|
||
})
|
||
});
|
||
if (!patchRes.ok) throw new Error(`Failed to update vote counts on comment ${commentId}`);
|
||
return patchRes.json() as Promise<PBBookComment>;
|
||
}
|
||
|
||
/**
|
||
* Fetch votes cast by this session/user, keyed by comment_id.
|
||
* Returns a map of commentId → 'up' | 'down'.
|
||
*/
|
||
export async function getMyVotes(
|
||
commentIds: string[],
|
||
sessionId: string,
|
||
userId?: string
|
||
): Promise<Record<string, 'up' | 'down'>> {
|
||
if (commentIds.length === 0) return {};
|
||
const token = await getToken();
|
||
const idFilter = commentIds.map((id) => `comment_id="${id}"`).join('||');
|
||
const voterPart = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
|
||
const filter = encodeURIComponent(`(${idFilter})&&${voterPart}`);
|
||
const res = await fetch(
|
||
`${PB_URL}/api/collections/comment_votes/records?filter=${filter}&perPage=200`,
|
||
{ headers: { Authorization: `Bearer ${token}` } }
|
||
);
|
||
if (!res.ok) return {};
|
||
const data = await res.json();
|
||
const map: Record<string, 'up' | 'down'> = {};
|
||
for (const v of (data.items ?? []) as CommentVote[]) {
|
||
map[v.comment_id] = v.vote as 'up' | 'down';
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// ─── User subscriptions ───────────────────────────────────────────────────────
|
||
|
||
export interface UserSubscription {
|
||
id: string;
|
||
follower_id: string;
|
||
followee_id: string;
|
||
created: string;
|
||
}
|
||
|
||
/**
|
||
* Returns the subscription record if follower_id follows followee_id, else null.
|
||
*/
|
||
export async function getSubscription(
|
||
followerId: string,
|
||
followeeId: string
|
||
): Promise<UserSubscription | null> {
|
||
const filter = encodeURIComponent(`follower_id="${followerId}"&&followee_id="${followeeId}"`);
|
||
const res = await pbGet<{ items: UserSubscription[]; totalItems: number }>(
|
||
`/api/collections/user_subscriptions/records?filter=${filter}&perPage=1`
|
||
).catch(() => null);
|
||
return res?.items?.[0] ?? null;
|
||
}
|
||
|
||
/**
|
||
* Subscribe follower_id to followee_id. No-ops if already subscribed.
|
||
* Returns the subscription record.
|
||
*/
|
||
export async function subscribe(followerId: string, followeeId: string): Promise<void> {
|
||
const existing = await getSubscription(followerId, followeeId);
|
||
if (existing) return;
|
||
const res = await pbPost('/api/collections/user_subscriptions/records', {
|
||
follower_id: followerId,
|
||
followee_id: followeeId,
|
||
created: new Date().toISOString()
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
throw new Error(`Failed to subscribe: ${res.status} — ${body}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Unsubscribe follower_id from followee_id. No-ops if not subscribed.
|
||
*/
|
||
export async function unsubscribe(followerId: string, followeeId: string): Promise<void> {
|
||
const existing = await getSubscription(followerId, followeeId);
|
||
if (!existing) return;
|
||
const token = await getToken();
|
||
await fetch(`${PB_URL}/api/collections/user_subscriptions/records/${existing.id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Returns the list of user IDs that followerId is subscribed to.
|
||
*/
|
||
export async function getFollowingIds(followerId: string): Promise<string[]> {
|
||
const items = await listAll<UserSubscription>(
|
||
'user_subscriptions',
|
||
`follower_id="${followerId}"`,
|
||
'-created'
|
||
).catch(() => [] as UserSubscription[]);
|
||
return items.map((s) => s.followee_id);
|
||
}
|
||
|
||
/**
|
||
* Returns the count of subscribers (followers) for a given user.
|
||
*/
|
||
export async function getFollowerCount(followeeId: string): Promise<number> {
|
||
return countCollection('user_subscriptions', `followee_id="${followeeId}"`).catch(() => 0);
|
||
}
|
||
|
||
/**
|
||
* Returns the count of accounts a user is following.
|
||
*/
|
||
export async function getFollowingCount(followerId: string): Promise<number> {
|
||
return countCollection('user_subscriptions', `follower_id="${followerId}"`).catch(() => 0);
|
||
}
|
||
|
||
/**
|
||
* Public profile data for a user.
|
||
*/
|
||
export interface PublicProfile {
|
||
id: string;
|
||
username: string;
|
||
avatar_url?: string;
|
||
created: string;
|
||
followerCount: number;
|
||
followingCount: number;
|
||
}
|
||
|
||
/**
|
||
* Returns a user's public profile (no sensitive fields) by username.
|
||
*/
|
||
export async function getPublicProfile(username: string): Promise<PublicProfile | null> {
|
||
const user = await getUserByUsername(username);
|
||
if (!user) return null;
|
||
const [followerCount, followingCount] = await Promise.all([
|
||
getFollowerCount(user.id),
|
||
getFollowingCount(user.id)
|
||
]);
|
||
return {
|
||
id: user.id,
|
||
username: user.username,
|
||
avatar_url: user.avatar_url,
|
||
created: user.created,
|
||
followerCount,
|
||
followingCount
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Returns a user's public library: books they have saved or are reading.
|
||
* Only includes books with progress or explicit saves (user_library).
|
||
*/
|
||
export async function getUserPublicLibrary(
|
||
userId: string
|
||
): Promise<Array<{ book: Book; chapter: number | null; saved: boolean }>> {
|
||
const [allBooks, progressList, savedEntries] = await Promise.all([
|
||
listBooks(),
|
||
listAll<Progress>('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[]),
|
||
listAll<{ id: string; slug: string; saved_at: string }>(
|
||
'user_library',
|
||
`user_id="${userId}"`,
|
||
'-saved_at'
|
||
).catch(() => [] as { id: string; slug: string; saved_at: string }[])
|
||
]);
|
||
|
||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||
const result: Array<{ book: Book; chapter: number | null; saved: boolean }> = [];
|
||
const seen = new Set<string>();
|
||
|
||
// Books with progress first (most recently read)
|
||
for (const p of progressList) {
|
||
const book = bookMap.get(p.slug);
|
||
if (!book || seen.has(p.slug)) continue;
|
||
seen.add(p.slug);
|
||
result.push({ book, chapter: p.chapter, saved: false });
|
||
}
|
||
|
||
// Saved-only books next
|
||
for (const e of savedEntries) {
|
||
const book = bookMap.get(e.slug);
|
||
if (!book || seen.has(e.slug)) continue;
|
||
seen.add(e.slug);
|
||
result.push({ book, chapter: null, saved: true });
|
||
}
|
||
|
||
// Mark saved flag for books that are both in progress AND saved
|
||
const savedSlugs = new Set(savedEntries.map((e) => e.slug));
|
||
return result.map((r) => ({ ...r, saved: savedSlugs.has(r.book.slug) }));
|
||
}
|
||
|
||
/**
|
||
* Returns the currently-reading books (books with progress, not completed)
|
||
* for a given user ID.
|
||
*/
|
||
export async function getUserCurrentlyReading(
|
||
userId: string
|
||
): Promise<Array<{ book: Book; chapter: number }>> {
|
||
const [allBooks, progressList] = await Promise.all([
|
||
listBooks(),
|
||
listAll<Progress>('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[])
|
||
]);
|
||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||
return progressList
|
||
.filter((p) => {
|
||
const book = bookMap.get(p.slug);
|
||
return book && p.chapter > 0 && p.chapter < book.total_chapters;
|
||
})
|
||
.slice(0, 10)
|
||
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
|
||
}
|
||
|
||
/**
|
||
* Returns recently-updated books from ALL users that followerId is subscribed to.
|
||
* Deduplicates across followed users; sorts by most recently updated.
|
||
*/
|
||
export async function getSubscriptionFeed(
|
||
followerId: string,
|
||
limit = 12
|
||
): Promise<Array<{ book: Book; readerUsername: string }>> {
|
||
const followingIds = await getFollowingIds(followerId);
|
||
if (followingIds.length === 0) return [];
|
||
|
||
// Fetch all users we follow (for display names)
|
||
const token = await getToken();
|
||
const userFetches = followingIds.map((id) =>
|
||
fetch(`${PB_URL}/api/collections/app_users/records/${id}`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
})
|
||
.then((r) => (r.ok ? (r.json() as Promise<User>) : null))
|
||
.catch(() => null)
|
||
);
|
||
const users = (await Promise.all(userFetches)).filter(Boolean) as User[];
|
||
const userMap = new Map<string, User>(users.map((u) => [u.id, u]));
|
||
|
||
// Fetch progress for each followed user
|
||
const progressFetches = followingIds.map((id) =>
|
||
listAll<Progress>('progress', `user_id="${id}"`, '-updated').catch(() => [] as Progress[])
|
||
);
|
||
const allProgressArrays = await Promise.all(progressFetches);
|
||
|
||
const allBooks = await listBooks();
|
||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||
|
||
// Merge: per slug take the most-recent progress entry
|
||
const seen = new Set<string>();
|
||
const feed: Array<{ book: Book; readerUsername: string; updated: string }> = [];
|
||
|
||
for (let i = 0; i < followingIds.length; i++) {
|
||
const uid = followingIds[i];
|
||
const username = userMap.get(uid)?.username ?? 'unknown';
|
||
for (const p of allProgressArrays[i]) {
|
||
if (seen.has(p.slug)) continue;
|
||
const book = bookMap.get(p.slug);
|
||
if (!book) continue;
|
||
seen.add(p.slug);
|
||
feed.push({ book, readerUsername: username, updated: p.updated });
|
||
}
|
||
}
|
||
|
||
// Sort by most recently read across all followed users
|
||
feed.sort((a, b) => b.updated.localeCompare(a.updated));
|
||
return feed.slice(0, limit).map(({ book, readerUsername }) => ({ book, readerUsername }));
|
||
}
|
||
|
||
// ─── Discovery ────────────────────────────────────────────────────────────────
|
||
// NOTE: Requires a `discovery_votes` collection in PocketBase with fields:
|
||
// - session_id (text, required)
|
||
// - user_id (text, optional)
|
||
// - slug (text, required)
|
||
// - action (text, required) — one of: like | skip | nope | read_now
|
||
|
||
export interface DiscoveryVote {
|
||
id?: string;
|
||
session_id: string;
|
||
user_id?: string;
|
||
slug: string;
|
||
action: 'like' | 'skip' | 'nope' | 'read_now';
|
||
}
|
||
|
||
export interface DiscoveryPrefs {
|
||
genres: string[];
|
||
status: 'either' | 'ongoing' | 'completed';
|
||
}
|
||
|
||
function parseGenresLocal(genres: string[] | string): string[] {
|
||
if (Array.isArray(genres)) return genres;
|
||
if (!genres) return [];
|
||
try { return JSON.parse(genres) as string[]; } catch { return []; }
|
||
}
|
||
|
||
function discoveryFilter(sessionId: string, userId?: string): string {
|
||
if (userId) return `user_id="${userId}"`;
|
||
return `session_id="${sessionId}"`;
|
||
}
|
||
|
||
export async function getVotedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||
const rows = await listAll<DiscoveryVote>(
|
||
'discovery_votes',
|
||
discoveryFilter(sessionId, userId)
|
||
).catch(() => [] as DiscoveryVote[]);
|
||
return new Set(rows.map((r) => r.slug));
|
||
}
|
||
|
||
export async function upsertDiscoveryVote(
|
||
sessionId: string,
|
||
slug: string,
|
||
action: DiscoveryVote['action'],
|
||
userId?: string
|
||
): Promise<void> {
|
||
const filter = userId
|
||
? `user_id="${userId}"&&slug="${slug}"`
|
||
: `session_id="${sessionId}"&&slug="${slug}"`;
|
||
const existing = await listOne<DiscoveryVote & { id: string }>('discovery_votes', filter);
|
||
const payload: Partial<DiscoveryVote> = { session_id: sessionId, slug, action };
|
||
if (userId) payload.user_id = userId;
|
||
|
||
if (existing) {
|
||
const res = await pbPatch(`/api/collections/discovery_votes/records/${existing.id}`, payload);
|
||
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote PATCH failed', { slug, status: res.status });
|
||
} else {
|
||
const res = await pbPost('/api/collections/discovery_votes/records', payload);
|
||
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote POST failed', { slug, status: res.status });
|
||
}
|
||
}
|
||
|
||
export async function clearDiscoveryVotes(sessionId: string, userId?: string): Promise<void> {
|
||
const filter = discoveryFilter(sessionId, userId);
|
||
const rows = await listAll<DiscoveryVote & { id: string }>('discovery_votes', filter).catch(() => []);
|
||
await Promise.all(
|
||
rows.map((r) =>
|
||
pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {})
|
||
)
|
||
);
|
||
}
|
||
|
||
// ─── Ratings ──────────────────────────────────────────────────────────────────
|
||
|
||
export interface BookRating {
|
||
session_id: string;
|
||
user_id?: string;
|
||
slug: string;
|
||
rating: number; // 1–5
|
||
}
|
||
|
||
export async function getBookRating(
|
||
sessionId: string,
|
||
slug: string,
|
||
userId?: string
|
||
): Promise<number> {
|
||
const filter = userId
|
||
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
|
||
: `session_id="${sessionId}" && slug="${slug}"`;
|
||
const row = await listOne<BookRating>('book_ratings', filter).catch(() => null);
|
||
return row?.rating ?? 0;
|
||
}
|
||
|
||
export async function getBookAvgRating(
|
||
slug: string
|
||
): Promise<{ avg: number; count: number }> {
|
||
const rows = await listAll<BookRating>('book_ratings', `slug="${slug}"`).catch(() => []);
|
||
if (!rows.length) return { avg: 0, count: 0 };
|
||
const avg = rows.reduce((s, r) => s + r.rating, 0) / rows.length;
|
||
return { avg: Math.round(avg * 10) / 10, count: rows.length };
|
||
}
|
||
|
||
export async function setBookRating(
|
||
sessionId: string,
|
||
slug: string,
|
||
rating: number,
|
||
userId?: string
|
||
): Promise<void> {
|
||
const filter = userId
|
||
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
|
||
: `session_id="${sessionId}" && slug="${slug}"`;
|
||
const existing = await listOne<BookRating & { id: string }>('book_ratings', filter).catch(() => null);
|
||
const payload: Partial<BookRating> = { session_id: sessionId, slug, rating };
|
||
if (userId) payload.user_id = userId;
|
||
if (existing) {
|
||
await pbPatch(`/api/collections/book_ratings/records/${existing.id}`, payload);
|
||
} else {
|
||
await pbPost('/api/collections/book_ratings/records', payload);
|
||
}
|
||
await cache.invalidate(RATINGS_CACHE_KEY);
|
||
}
|
||
|
||
// ─── Shelves ───────────────────────────────────────────────────────────────────
|
||
|
||
export type ShelfName = '' | 'plan_to_read' | 'completed' | 'dropped';
|
||
|
||
export async function updateBookShelf(
|
||
sessionId: string,
|
||
slug: string,
|
||
shelf: ShelfName,
|
||
userId?: string
|
||
): Promise<void> {
|
||
const filter = userId
|
||
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
|
||
: `session_id="${sessionId}" && slug="${slug}"`;
|
||
const existing = await listOne<{ id: string }>('user_library', filter).catch(() => null);
|
||
if (!existing) {
|
||
// Save + set shelf in one shot
|
||
const payload: Record<string, unknown> = { session_id: sessionId, slug, shelf, saved_at: new Date().toISOString() };
|
||
if (userId) payload.user_id = userId;
|
||
await pbPost('/api/collections/user_library/records', payload);
|
||
} else {
|
||
await pbPatch(`/api/collections/user_library/records/${existing.id}`, { shelf });
|
||
}
|
||
}
|
||
|
||
export async function getShelfMap(
|
||
sessionId: string,
|
||
userId?: string
|
||
): Promise<Record<string, ShelfName>> {
|
||
const filter = userId
|
||
? `session_id="${sessionId}" || user_id="${userId}"`
|
||
: `session_id="${sessionId}"`;
|
||
const rows = await listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []);
|
||
const map: Record<string, ShelfName> = {};
|
||
for (const r of rows) map[r.slug] = (r.shelf as ShelfName) || '';
|
||
return map;
|
||
}
|
||
|
||
export async function getBooksForDiscovery(
|
||
sessionId: string,
|
||
userId?: string,
|
||
prefs?: DiscoveryPrefs
|
||
): Promise<Book[]> {
|
||
const [allBooks, votedSlugs, savedSlugs] = await Promise.all([
|
||
listBooks(),
|
||
getVotedSlugs(sessionId, userId),
|
||
getSavedSlugs(sessionId, userId)
|
||
]);
|
||
|
||
let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));
|
||
|
||
if (prefs?.genres?.length) {
|
||
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));
|
||
const genreFiltered = candidates.filter((b) => {
|
||
const genres = parseGenresLocal(b.genres);
|
||
return genres.some((g) => preferred.has(g.toLowerCase()));
|
||
});
|
||
if (genreFiltered.length >= 5) candidates = genreFiltered;
|
||
}
|
||
|
||
if (prefs?.status && prefs.status !== 'either') {
|
||
const sf = candidates.filter((b) => b.status?.toLowerCase().includes(prefs.status));
|
||
if (sf.length >= 3) candidates = sf;
|
||
}
|
||
|
||
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
|
||
// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
|
||
// appear — they're just pushed further back via a stable sort before shuffle.
|
||
const ratingRows = await getAllRatings();
|
||
const ratingMap = new Map<string, { sum: number; count: number }>();
|
||
for (const r of ratingRows) {
|
||
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
|
||
cur.sum += r.rating;
|
||
cur.count += 1;
|
||
ratingMap.set(r.slug, cur);
|
||
}
|
||
const avgRating = (slug: string) => {
|
||
const e = ratingMap.get(slug);
|
||
return e && e.count > 0 ? e.sum / e.count : 0;
|
||
};
|
||
|
||
// Sort by avg desc (unrated = 0, treated as unknown → middle of pack after rated)
|
||
// Then apply Fisher-Yates only within each rating tier so ordering feels natural.
|
||
candidates.sort((a, b) => avgRating(b.slug) - avgRating(a.slug));
|
||
|
||
// Shuffle within rating tiers (±0.5 star buckets) to avoid pure determinism
|
||
const tierOf = (slug: string) => Math.round(avgRating(slug) * 2); // 0–10
|
||
let start = 0;
|
||
while (start < candidates.length) {
|
||
let end = start + 1;
|
||
while (end < candidates.length && tierOf(candidates[end].slug) === tierOf(candidates[start].slug)) end++;
|
||
for (let i = end - 1; i > start; i--) {
|
||
const j = start + Math.floor(Math.random() * (i - start + 1));
|
||
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
|
||
}
|
||
start = end;
|
||
}
|
||
|
||
return candidates.slice(0, 50);
|
||
}
|
||
|
||
// ─── Discovery history ─────────────────────────────────────────────────────────
|
||
|
||
export interface VotedBook {
|
||
slug: string;
|
||
action: DiscoveryVote['action'];
|
||
votedAt: string;
|
||
book?: Book;
|
||
}
|
||
|
||
export async function getVotedBooks(
|
||
sessionId: string,
|
||
userId?: string
|
||
): Promise<VotedBook[]> {
|
||
const votes = await listAll<DiscoveryVote & { id: string; created: string }>(
|
||
'discovery_votes',
|
||
discoveryFilter(sessionId, userId),
|
||
'-created'
|
||
).catch(() => []);
|
||
|
||
if (!votes.length) return [];
|
||
|
||
const slugs = [...new Set(votes.map((v) => v.slug))];
|
||
const books = await getBooksBySlugs(new Set(slugs)).catch(() => [] as Book[]);
|
||
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
||
|
||
return votes.map((v) => ({
|
||
slug: v.slug,
|
||
action: v.action,
|
||
votedAt: v.created,
|
||
book: bookMap.get(v.slug)
|
||
}));
|
||
}
|
||
|
||
export async function undoDiscoveryVote(
|
||
sessionId: string,
|
||
slug: string,
|
||
userId?: string
|
||
): Promise<void> {
|
||
const filter = `${discoveryFilter(sessionId, userId)}&&slug="${slug}"`;
|
||
const row = await listOne<{ id: string }>('discovery_votes', filter).catch(() => null);
|
||
if (row) {
|
||
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
|
||
}
|
||
}
|
||
|
||
// ─── User stats ────────────────────────────────────────────────────────────────
|
||
|
||
export interface UserStats {
|
||
totalChaptersRead: number;
|
||
booksReading: number;
|
||
booksCompleted: number;
|
||
booksPlanToRead: number;
|
||
booksDropped: number;
|
||
topGenres: string[]; // top 3 by frequency
|
||
avgRatingGiven: number; // 0 if no ratings
|
||
streak: number; // consecutive days with progress
|
||
}
|
||
|
||
export async function getUserStats(
|
||
sessionId: string,
|
||
userId?: string
|
||
): Promise<UserStats> {
|
||
const filter = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
|
||
|
||
const [progressRows, libraryRows, ratingRows, allBooks] = await Promise.all([
|
||
listAll<Progress & { updated: string }>('progress', filter, '-updated').catch(() => []),
|
||
listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []),
|
||
listAll<BookRating>('book_ratings', filter).catch(() => []),
|
||
listBooks().catch(() => [] as Book[])
|
||
]);
|
||
|
||
// shelf counts
|
||
const shelfCounts = { reading: 0, completed: 0, plan_to_read: 0, dropped: 0 };
|
||
for (const r of libraryRows) {
|
||
const s = r.shelf || 'reading';
|
||
if (s in shelfCounts) shelfCounts[s as keyof typeof shelfCounts]++;
|
||
}
|
||
|
||
// top genres from books in progress/library
|
||
const libSlugs = new Set(libraryRows.map((r) => r.slug));
|
||
const progSlugs = new Set(progressRows.map((r) => r.slug));
|
||
const allSlugs = new Set([...libSlugs, ...progSlugs]);
|
||
const bookMap = new Map(allBooks.map((b) => [b.slug, b]));
|
||
const genreFreq = new Map<string, number>();
|
||
for (const slug of allSlugs) {
|
||
const book = bookMap.get(slug);
|
||
if (!book) continue;
|
||
for (const g of parseGenresLocal(book.genres)) {
|
||
genreFreq.set(g, (genreFreq.get(g) ?? 0) + 1);
|
||
}
|
||
}
|
||
const topGenres = [...genreFreq.entries()]
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 3)
|
||
.map(([g]) => g);
|
||
|
||
// avg rating given
|
||
const avgRatingGiven =
|
||
ratingRows.length > 0
|
||
? Math.round((ratingRows.reduce((s, r) => s + r.rating, 0) / ratingRows.length) * 10) / 10
|
||
: 0;
|
||
|
||
// reading streak: count consecutive calendar days (UTC) with a progress update
|
||
const days = new Set(
|
||
progressRows
|
||
.filter((r) => r.updated)
|
||
.map((r) => r.updated.slice(0, 10))
|
||
);
|
||
let streak = 0;
|
||
const today = new Date();
|
||
for (let i = 0; i < 365; i++) {
|
||
const d = new Date(today);
|
||
d.setUTCDate(d.getUTCDate() - i);
|
||
if (days.has(d.toISOString().slice(0, 10))) streak++;
|
||
else if (i > 0) break; // gap — stop
|
||
}
|
||
|
||
return {
|
||
totalChaptersRead: progressRows.length,
|
||
booksReading: shelfCounts.reading,
|
||
booksCompleted: shelfCounts.completed,
|
||
booksPlanToRead: shelfCounts.plan_to_read,
|
||
booksDropped: shelfCounts.dropped,
|
||
topGenres,
|
||
avgRatingGiven,
|
||
streak
|
||
};
|
||
}
|
||
|
||
// ─── AI Jobs ──────────────────────────────────────────────────────────────────
|
||
|
||
const AI_JOBS_CACHE_KEY = 'admin:ai_jobs';
|
||
const AI_JOBS_CACHE_TTL = 30; // 30 seconds — same as other admin job lists
|
||
|
||
/**
|
||
* List all AI jobs from PocketBase, sorted by started descending.
|
||
* Short-lived cache (30s) to avoid hammering PocketBase on every navigation.
|
||
*/
|
||
export async function listAIJobs(): Promise<AIJob[]> {
|
||
const cached = await cache.get<AIJob[]>(AI_JOBS_CACHE_KEY);
|
||
if (cached) return cached;
|
||
const jobs = await listAll<AIJob>('ai_jobs', '', '-started');
|
||
await cache.set(AI_JOBS_CACHE_KEY, jobs, AI_JOBS_CACHE_TTL);
|
||
return jobs;
|
||
}
|