/** * 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 { 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(path: string): Promise { 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; 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; } async function pbPost(path: string, body: unknown): Promise { 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 { 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 { const token = await getToken(); return fetch(`${PB_URL}${path}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); } interface PBList { items: T[]; totalItems: number; } async function listAll(collection: string, filter = '', sort = ''): Promise { 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>( `/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>( `/api/collections/${collection}/records?${params.toString()}` ); items.push(...(data.items ?? [])); } return items; } async function listN(collection: string, n: number, filter = '', sort = ''): Promise { const params = new URLSearchParams({ perPage: String(n) }); if (filter) params.set('filter', filter); if (sort) params.set('sort', sort); const data = await pbGet>( `/api/collections/${collection}/records?${params.toString()}` ); return data.items ?? []; } async function countCollection(collection: string, filter = ''): Promise { const params = new URLSearchParams({ perPage: '1' }); if (filter) params.set('filter', filter); const data = await pbGet>( `/api/collections/${collection}/records?${params.toString()}` ); return (data as { totalItems: number }).totalItems ?? 0; } async function listOne(collection: string, filter: string, sort = ''): Promise { const params = new URLSearchParams({ perPage: '1', filter }); if (sort) params.set('sort', sort); const data = await pbGet>( `/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 { const cached = await cache.get(RATINGS_CACHE_KEY); if (cached) return cached; const ratings = await listAll('book_ratings', '').catch(() => [] as BookRating[]); await cache.set(RATINGS_CACHE_KEY, ratings, RATINGS_CACHE_TTL); return ratings; } export async function listBooks(): Promise { const cached = await cache.get(BOOKS_CACHE_KEY); if (cached) { log.debug('pocketbase', 'listBooks cache hit', { total: cached.length }); return cached; } const books = await listAll('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 { const cached = await cache.get(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(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('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): Promise { 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(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('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(); 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 { 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 { const key = `books:trending:${limit}`; const cached = await cache.get(key); if (cached) return cached; const books = await listN('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, limit = 8 ): Promise { if (topGenres.length === 0) return []; const sortedGenres = [...topGenres].sort(); const key = `books:recs:${sortedGenres.join(':')}:${limit}`; let books = await cache.get(key); if (!books) { const genreFilter = sortedGenres .map((g) => `genres~"${g.replace(/"/g, '')}"`) .join('||'); books = await listN('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 { return listOne('books', `slug="${slug}"`); } export async function recentlyAddedBooks(limit = 6): Promise { const key = `books:recent:${limit}`; const cached = await cache.get(key); if (cached) return cached; const books = await listN('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 { const key = `books:recently-updated:${limit}`; const cached = await cache.get(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(); 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 { const cached = await cache.get(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 { await cache.invalidate(HOME_STATS_CACHE_KEY); } // ─── Chapter index ──────────────────────────────────────────────────────────── export async function listChapterIdx(slug: string): Promise { return listAll('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 { return listOne('progress', progressFilter(sessionId, slug, userId)); } export async function allProgress(sessionId: string, userId?: string): Promise { return listAll('progress', allProgressFilter(sessionId, userId), '-updated'); } export async function setProgress( sessionId: string, slug: string, chapter: number, userId?: string ): Promise { const existing = await listOne( 'progress', progressFilter(sessionId, slug, userId) ); const payload: Partial = { 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 { const existing = await listOne( '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 { 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', `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 = { 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> { const rows = await listAll( '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 { const filter = userId ? `user_id="${userId}"&&slug="${slug}"` : `session_id="${sessionId}"&&slug="${slug}"`; const row = await listOne('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 { const filter = userId ? `user_id="${userId}"&&slug="${slug}"` : `session_id="${sessionId}"&&slug="${slug}"`; const row = await listOne('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 { const alreadySaved = await isBookSaved(sessionId, slug, userId); if (alreadySaved) return; const payload: Partial = { 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 { const filter = userId ? `user_id="${userId}"&&slug="${slug}"` : `session_id="${sessionId}"&&slug="${slug}"`; const row = await listOne('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 { return listOne('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 { 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; } /** * Look up a user by email. Returns null if not found. */ export async function getUserByEmail(email: string): Promise { return listOne('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 { return listOne( '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 { return listOne( 'app_users', `polar_customer_id="${polarCustomerId.replace(/"/g, '\\"')}"` ); } /** * Patch arbitrary fields on an app_user record. */ export async function patchUser(userId: string, fields: Partial>): Promise { 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 { 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; } /** * Link an OAuth provider to an existing user account. */ export async function linkOAuthToUser( userId: string, provider: string, oauthId: string ): Promise { 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 { return listOne('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 { 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; } /** * Mark a user's email as verified and clear the verification token. */ export async function verifyUserEmail(userId: string): Promise { 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 { // 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 { 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 { return listOne('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 { const existing = await listOne( 'user_settings', settingsFilter(sessionId, userId) ); const payload: Partial = { 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 { const existing = await listOne( 'progress', progressFilter(sessionId, slug, userId) ); if (!existing) { // No progress record yet — create one with audio_time const payload: Partial = { 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 { const cached = await cache.get(SCRAPING_TASKS_CACHE_KEY); if (cached) return cached; const tasks = await listN('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 { return listOne('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 { const cached = await cache.get(AUDIO_JOBS_CACHE_KEY); if (cached) return cached; const jobs = await listN('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> { const jobs = await listAll('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 { const cached = await cache.get(AUDIO_BOOKS_CACHE_KEY); if (cached) return cached.slice(0, limit); // Count done jobs per slug const jobs = await listAll('audio_jobs', 'status="done"', 'slug'); const countBySlug = new Map(); 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 to deduplicate voice variants const chapsBySlug = new Map>(); 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> { 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('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(); 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(); 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 { const cached = await cache.get(TRANSLATION_JOBS_CACHE_KEY); if (cached) return cached; const jobs = await listN('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 { const row = await listOne('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( '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 { 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 { const row = await listOne( '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 { const row = await listOne('user_sessions', `session_id="${authSessionId}"`); return row === null; } /** * List all active sessions for a user. */ export async function listUserSessions(userId: string): Promise { return listAll('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 { const token = await getToken(); const all = await listAll('user_sessions', `user_id="${userId}"`, '-last_seen'); const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); const toDelete = new Set(); // 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 { // 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 { const row = await listOne('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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; } /** * 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 { 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 { 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 { 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; } /** * 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> { 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 = {}; 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 { 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 { 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 { 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 { const items = await listAll( '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 { 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 { 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 { 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> { const [allBooks, progressList, savedEntries] = await Promise.all([ listBooks(), listAll('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(allBooks.map((b) => [b.slug, b])); const result: Array<{ book: Book; chapter: number | null; saved: boolean }> = []; const seen = new Set(); // 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> { const [allBooks, progressList] = await Promise.all([ listBooks(), listAll('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[]) ]); const bookMap = new Map(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> { 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) : null)) .catch(() => null) ); const users = (await Promise.all(userFetches)).filter(Boolean) as User[]; const userMap = new Map(users.map((u) => [u.id, u])); // Fetch progress for each followed user const progressFetches = followingIds.map((id) => listAll('progress', `user_id="${id}"`, '-updated').catch(() => [] as Progress[]) ); const allProgressArrays = await Promise.all(progressFetches); const allBooks = await listBooks(); const bookMap = new Map(allBooks.map((b) => [b.slug, b])); // Merge: per slug take the most-recent progress entry const seen = new Set(); 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> { const rows = await listAll( '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 { const filter = userId ? `user_id="${userId}"&&slug="${slug}"` : `session_id="${sessionId}"&&slug="${slug}"`; const existing = await listOne('discovery_votes', filter); const payload: Partial = { 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 { const filter = discoveryFilter(sessionId, userId); const rows = await listAll('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 { const filter = userId ? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"` : `session_id="${sessionId}" && slug="${slug}"`; const row = await listOne('book_ratings', filter).catch(() => null); return row?.rating ?? 0; } export async function getBookAvgRating( slug: string ): Promise<{ avg: number; count: number }> { const rows = await listAll('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 { const filter = userId ? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"` : `session_id="${sessionId}" && slug="${slug}"`; const existing = await listOne('book_ratings', filter).catch(() => null); const payload: Partial = { 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 { 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 = { 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> { 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 = {}; 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 { 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(); 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 { const votes = await listAll( '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 { 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 { const filter = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`; const [progressRows, libraryRows, ratingRows, allBooks] = await Promise.all([ listAll('progress', filter, '-updated').catch(() => []), listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []), listAll('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(); 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 { const cached = await cache.get(AI_JOBS_CACHE_KEY); if (cached) return cached; const jobs = await listAll('ai_jobs', '', '-started'); await cache.set(AI_JOBS_CACHE_KEY, jobs, AI_JOBS_CACHE_TTL); return jobs; }