feat(progress): associate progress with logged-in user for cross-device sync

Add optional user_id field to the progress collection. When a user is
authenticated, progress is keyed by user_id instead of session_id, making
it portable across devices and browsers. Anonymous reading still works via
session_id as before.

On login or registration, any progress accumulated under the anonymous
session is merged into the user's account (most-recent timestamp wins),
so chapters read before logging in are not lost.

- scripts/pb-init.sh: add user_id field to progress collection schema
- scraper/internal/storage/pocketbase.go: add user_id to EnsureCollections
- ui/src/lib/server/pocketbase.ts: Progress interface gains user_id;
  getProgress/allProgress/setProgress accept optional userId and query/write
  by user_id when present; add mergeSessionProgress() helper
- ui/src/routes/login/+page.server.ts: call mergeSessionProgress after
  successful login and registration (fire-and-forget, non-fatal)
- ui/src/routes/api/progress/+server.ts: pass locals.user?.id to setProgress
- ui/src/routes/books/+page.server.ts: pass locals.user?.id to allProgress
- ui/src/routes/books/[slug]/+page.server.ts: pass locals.user?.id to getProgress
This commit is contained in:
Admin
2026-03-04 09:51:11 +05:00
parent 48d8fdb6b9
commit 8edad54b10
7 changed files with 113 additions and 14 deletions

View File

@@ -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');

View File

@@ -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) });

View File

@@ -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) });

View File

@@ -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: '/',