/** * 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'; 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 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 UserSettings { id?: string; session_id: string; user_id?: string; auto_next: boolean; voice: string; speed: number; updated?: string; } export interface User { id: string; username: string; password_hash: string; role: string; created: string; avatar_url?: string; } // ─── Auth token cache ───────────────────────────────────────────────────────── let _token = ''; let _tokenExp = 0; async function getToken(): Promise { if (_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; _tokenExp = Date.now() + 12 * 60 * 60 * 1000; // 12 hours 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) { 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): Promise { const params = new URLSearchParams({ perPage: '1', filter }); const data = await pbGet>( `/api/collections/${collection}/records?${params.toString()}` ); return data.items[0] ?? null; } // ─── Books ──────────────────────────────────────────────────────────────────── export async function listBooks(): Promise { 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', { total: books.length, nullTitles }); return books; } export async function getBook(slug: string): Promise { return listOne('books', `slug="${slug}"`); } export async function recentlyAddedBooks(limit = 6): Promise { return listN('books', limit, '', '-meta_updated'); } export async function recentlyUpdatedBooks(limit = 6): Promise { return listN('books', limit, '', '-meta_updated'); } export interface HomeStats { totalBooks: number; totalChapters: number; } export async function getHomeStats(): Promise { const [totalBooks, totalChapters] = await Promise.all([ countCollection('books'), countCollection('chapters_idx') ]); return { totalBooks, totalChapters }; } // ─── 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; } 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; } /** 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 } 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, '\\"')}"`); } /** * Create a new user with a hashed password. Throws if username already exists. */ export async function createUser(username: string, password: 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 password_hash = hashPassword(password); log.info('pocketbase', 'createUser: inserting new user', { username, role }); const res = await pbPost('/api/collections/app_users/records', { username, password_hash, role, 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; } /** * 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. */ 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; } 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 }, 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 (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 ────────────────────────────────────────────────────────────── export interface AudioCacheEntry { id: string; cache_key: string; filename: string; updated: string; } export async function listAudioCache(): Promise { return listAll('audio_cache', '', '-updated'); } // ─── Scraping tasks ─────────────────────────────────────────────────────────── export interface ScrapingTask { id: string; kind: string; target_url: string; status: string; books_found: number; chapters_scraped: number; chapters_skipped: number; errors: number; started: string; finished: string; error_message: string; } export async function listScrapingTasks(): Promise { return listAll('scraping_tasks', '', '-started'); } // ─── 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 { return listAll('audio_jobs', '', '-started'); } 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; created_at: string; last_seen: string; } /** * Create a new session record on login. Returns the record ID. */ export async function createUserSession( userId: string, authSessionId: string, userAgent: string, ip: string ): Promise { const now = new Date().toISOString(); const res = await pbPost('/api/collections/user_sessions/records', { user_id: userId, session_id: authSessionId, user_agent: userAgent, ip, created_at: now, last_seen: now }); if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'createUserSession POST failed', { userId, status: res.status, body }); throw new Error(`Failed to create session: ${res.status}`); } const rec = (await res.json()) as { id: string }; return rec.id; } /** * 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'); } /** * 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; } /** * 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(() => {}) ) ); } /** * 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}`); } } // ─── Comments ───────────────────────────────────────────────────────────────── export interface BookComment { id: string; slug: string; 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. * 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' ): Promise { const token = await getToken(); const slugEsc = slug.replace(/"/g, '\\"'); // Only top-level comments (parent_id is empty or missing) const filter = encodeURIComponent(`slug="${slugEsc}"&&(parent_id=""||parent_id=null)`); // PocketBase sorts: for 'top' we still fetch all and re-sort in JS because // PocketBase doesn't support computed sort fields. For 'new' we push the // sort down to the DB so large result sets are still paged correctly. const pbSort = sort === 'new' ? '&sort=-created' : '&sort=-created'; const res = await fetch( `${PB_URL}/api/collections/book_comments/records?filter=${filter}${pbSort}&perPage=200`, { headers: { Authorization: `Bearer ${token}` } } ); if (!res.ok) return []; const data = await res.json(); let items = (data.items ?? []) as BookComment[]; 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; // tie-break: newest first return new Date(b.created).getTime() - new Date(a.created).getTime(); }); } return items; } /** * 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 BookComment[]; } /** * 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 ): 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({ slug, 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 BookComment; 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 BookComment[]; 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 BookComment; 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 })); }