Some checks failed
CI / Test backend (pull_request) Successful in 19s
Release / Test backend (push) Successful in 18s
CI / Check ui (pull_request) Successful in 41s
Release / Check ui (push) Successful in 21s
CI / Docker / backend (pull_request) Successful in 1m43s
CI / Docker / runner (pull_request) Successful in 1m28s
Release / Docker / backend (push) Successful in 1m40s
CI / Docker / caddy (pull_request) Successful in 6m45s
Release / Docker / runner (push) Successful in 1m48s
Release / Docker / caddy (push) Successful in 7m12s
CI / Docker / ui (pull_request) Successful in 1m20s
Release / Docker / ui (push) Successful in 1m19s
Release / Gitea Release (push) Failing after 2s
- New /auth/[provider] route: generates state cookie, redirects to provider - New /auth/[provider]/callback: exchanges code, fetches profile, auto-creates or links account, sets auth cookie - pocketbase.ts: add oauth_provider/oauth_id to User; new getUserByOAuth(), createOAuthUser(), linkOAuthToUser() helpers; loginUser() drops email_verified gate - pb-init-v3.sh: add oauth_provider + oauth_id fields (schema + migration) - docker-compose.yml: GOOGLE/GITHUB client ID/secret env vars (replaces SMTP vars) - Login page: two OAuth buttons (Google, GitHub) — register form removed - /verify-email route and email.ts removed (provider handles email verification) - /api/auth/register returns 410 (OAuth-only from now on)
1486 lines
48 KiB
TypeScript
1486 lines
48 KiB
TypeScript
/**
|
||
* Server-side PocketBase client.
|
||
* Uses admin credentials — never import this from client-side code.
|
||
* All methods talk directly to PocketBase REST API.
|
||
*/
|
||
|
||
import { env } from '$env/dynamic/private';
|
||
import { log } from '$lib/server/logger';
|
||
|
||
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 PBUserSettings {
|
||
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;
|
||
email?: string;
|
||
email_verified?: boolean;
|
||
verification_token?: string;
|
||
verification_token_exp?: string;
|
||
oauth_provider?: string;
|
||
oauth_id?: string;
|
||
}
|
||
|
||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||
|
||
let _token = '';
|
||
let _tokenExp = 0;
|
||
|
||
async function getToken(): Promise<string> {
|
||
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<T>(path: string): Promise<T> {
|
||
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<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): Promise<T | null> {
|
||
const params = new URLSearchParams({ perPage: '1', filter });
|
||
const data = await pbGet<PBList<T>>(
|
||
`/api/collections/${collection}/records?${params.toString()}`
|
||
);
|
||
return data.items[0] ?? null;
|
||
}
|
||
|
||
// ─── Books ────────────────────────────────────────────────────────────────────
|
||
|
||
export async function listBooks(): Promise<Book[]> {
|
||
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', { total: books.length, nullTitles });
|
||
return books;
|
||
}
|
||
|
||
export async function getBook(slug: string): Promise<Book | null> {
|
||
return listOne<Book>('books', `slug="${slug}"`);
|
||
}
|
||
|
||
export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
|
||
return listN<Book>('books', limit, '', '-meta_updated');
|
||
}
|
||
|
||
export interface HomeStats {
|
||
totalBooks: number;
|
||
totalChapters: number;
|
||
}
|
||
|
||
export async function getHomeStats(): Promise<HomeStats> {
|
||
const [totalBooks, totalChapters] = await Promise.all([
|
||
countCollection('books'),
|
||
countCollection('chapters_idx')
|
||
]);
|
||
return { totalBooks, totalChapters };
|
||
}
|
||
|
||
// ─── 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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/** 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 } 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 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, '\\"')}"`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 },
|
||
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 (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;
|
||
}
|
||
|
||
export async function listAudioCache(): Promise<AudioCacheEntry[]> {
|
||
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', '-finished');
|
||
return jobs.map((j) => ({
|
||
id: j.id,
|
||
cache_key: j.cache_key,
|
||
filename: `${j.cache_key}.mp3`,
|
||
updated: j.finished
|
||
}));
|
||
}
|
||
|
||
// ─── 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[]> {
|
||
return listAll<ScrapingTask>('scraping_tasks', '', '-started');
|
||
}
|
||
|
||
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[]> {
|
||
return listAll<AudioJob>('audio_jobs', '', '-started');
|
||
}
|
||
|
||
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;
|
||
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<string> {
|
||
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<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');
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* 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(() => {})
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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}`);
|
||
}
|
||
}
|
||
|
||
// ─── Comments ─────────────────────────────────────────────────────────────────
|
||
|
||
export interface PBBookComment {
|
||
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<PBBookComment[]> {
|
||
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 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;
|
||
// 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<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
|
||
): 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({
|
||
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<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 }));
|
||
}
|