diff --git a/scraper/internal/storage/pocketbase.go b/scraper/internal/storage/pocketbase.go index 4f1d5d2..3445bde 100644 --- a/scraper/internal/storage/pocketbase.go +++ b/scraper/internal/storage/pocketbase.go @@ -325,6 +325,7 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { "type": "base", "fields": []map[string]interface{}{ {"name": "session_id", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, {"name": "slug", "type": "text", "required": true}, {"name": "chapter", "type": "number"}, {"name": "updated", "type": "date"}, diff --git a/scripts/pb-init.sh b/scripts/pb-init.sh index 07aa3e1..2c02076 100755 --- a/scripts/pb-init.sh +++ b/scripts/pb-init.sh @@ -106,6 +106,7 @@ create_collection "progress" '{ "type": "base", "fields": [ {"name": "session_id", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, {"name": "slug", "type": "text", "required": true}, {"name": "chapter", "type": "number"}, {"name": "updated", "type": "date"} diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 0e2de72..945cd16 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -37,7 +37,9 @@ export interface ChapterIdx { } export interface Progress { + id?: string; session_id: string; + user_id?: string; slug: string; chapter: number; updated: string; @@ -160,26 +162,51 @@ export async function listChapterIdx(slug: string): Promise { // ─── Reading progress ───────────────────────────────────────────────────────── -export async function getProgress(sessionId: string, slug: string): Promise { - return listOne('progress', `session_id="${sessionId}"&&slug="${slug}"`); +/** + * 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}"`; } -export async function allProgress(sessionId: string): Promise { - return listAll('progress', `session_id="${sessionId}"`, '-updated'); +function allProgressFilter(sessionId: string, userId?: string): string { + if (userId) return `user_id="${userId}"`; + return `session_id="${sessionId}"`; } -export async function setProgress(sessionId: string, slug: string, chapter: number): Promise { +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', - `session_id="${sessionId}"&&slug="${slug}"` + progressFilter(sessionId, slug, userId) ); - const payload = { + 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); @@ -196,6 +223,63 @@ export async function setProgress(sessionId: string, slug: string, chapter: numb } } +/** + * 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 }); +} + // ─── Users ──────────────────────────────────────────────────────────────────── import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'; diff --git a/ui/src/routes/api/progress/+server.ts b/ui/src/routes/api/progress/+server.ts index 3c1cd03..98e3c59 100644 --- a/ui/src/routes/api/progress/+server.ts +++ b/ui/src/routes/api/progress/+server.ts @@ -6,7 +6,9 @@ import { log } from '$lib/server/logger'; /** * POST /api/progress * Body: { slug: string, chapter: number } - * Records the user's reading position for the current session. + * Records the user's reading position. + * When the user is logged in, progress is keyed by user_id so it syncs across devices. + * When anonymous, progress is keyed by the session cookie. */ export const POST: RequestHandler = async ({ request, locals }) => { const body = await request.json().catch(() => null); @@ -16,7 +18,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { } try { - await setProgress(locals.sessionId, body.slug, body.chapter); + await setProgress(locals.sessionId, body.slug, body.chapter, locals.user?.id); } catch (e) { log.error('progress', 'setProgress failed', { slug: body.slug, chapter: body.chapter, err: String(e) }); error(500, 'Failed to save progress'); diff --git a/ui/src/routes/books/+page.server.ts b/ui/src/routes/books/+page.server.ts index 244a820..7d1c5b7 100644 --- a/ui/src/routes/books/+page.server.ts +++ b/ui/src/routes/books/+page.server.ts @@ -9,7 +9,7 @@ export const load: PageServerLoad = async ({ locals }) => { try { [books, progressList] = await Promise.all([ listBooks(), - allProgress(locals.sessionId) + allProgress(locals.sessionId, locals.user?.id) ]); } catch (e) { log.error('books', 'failed to load books or progress', { err: String(e) }); diff --git a/ui/src/routes/books/[slug]/+page.server.ts b/ui/src/routes/books/[slug]/+page.server.ts index c068d2d..484108e 100644 --- a/ui/src/routes/books/[slug]/+page.server.ts +++ b/ui/src/routes/books/[slug]/+page.server.ts @@ -14,7 +14,7 @@ export const load: PageServerLoad = async ({ params, locals }) => { [book, chapters, progress] = await Promise.all([ getBook(slug), listChapterIdx(slug), - getProgress(locals.sessionId, slug) + getProgress(locals.sessionId, slug, locals.user?.id) ]); } catch (e) { log.error('books', 'failed to load book page', { slug, err: String(e) }); diff --git a/ui/src/routes/login/+page.server.ts b/ui/src/routes/login/+page.server.ts index 799f3f3..be5f995 100644 --- a/ui/src/routes/login/+page.server.ts +++ b/ui/src/routes/login/+page.server.ts @@ -1,6 +1,6 @@ import { fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; -import { loginUser, createUser } from '$lib/server/pocketbase'; +import { loginUser, createUser, mergeSessionProgress } from '$lib/server/pocketbase'; import { createAuthToken } from '../../hooks.server'; import { log } from '$lib/server/logger'; @@ -16,7 +16,7 @@ export const load: PageServerLoad = async ({ locals }) => { }; export const actions: Actions = { - login: async ({ request, cookies }) => { + login: async ({ request, cookies, locals }) => { const data = await request.formData(); const username = (data.get('username') as string | null)?.trim() ?? ''; const password = (data.get('password') as string | null) ?? ''; @@ -37,6 +37,12 @@ export const actions: Actions = { return fail(401, { action: 'login', error: 'Invalid username or password.' }); } + // Merge any anonymous session progress into the user's account so that + // chapters read before logging in are preserved and portable across devices. + mergeSessionProgress(locals.sessionId, user.id).catch((err) => + log.warn('auth', 'login: mergeSessionProgress failed (non-fatal)', { err: String(err) }) + ); + const token = createAuthToken(user.id, user.username, user.role ?? 'user'); cookies.set(AUTH_COOKIE, token, { path: '/', @@ -48,7 +54,7 @@ export const actions: Actions = { redirect(302, '/books'); }, - register: async ({ request, cookies }) => { + register: async ({ request, cookies, locals }) => { const data = await request.formData(); const username = (data.get('username') as string | null)?.trim() ?? ''; const password = (data.get('password') as string | null) ?? ''; @@ -91,6 +97,11 @@ export const actions: Actions = { return fail(500, { action: 'register', error: 'An error occurred. Please try again.' }); } + // Merge any anonymous session progress into the newly created account. + mergeSessionProgress(locals.sessionId, user.id).catch((err) => + log.warn('auth', 'register: mergeSessionProgress failed (non-fatal)', { err: String(err) }) + ); + const token = createAuthToken(user.id, user.username, user.role ?? 'user'); cookies.set(AUTH_COOKIE, token, { path: '/',