Files
libnovel/ui/src/lib/server/pocketbase.ts
root 44f81bbf5c
All checks were successful
Release / Test backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 1m42s
Release / Docker (push) Successful in 6m13s
Release / Gitea Release (push) Successful in 21s
surface audio-ready chapters: headphones badge on chapter list, instant-play prompt on reader
- 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
2026-04-12 11:13:36 +05:00

2485 lines
84 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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; // 15
}
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); // 010
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;
}