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:
@@ -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"},
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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<ChapterIdx[]> {
|
||||
|
||||
// ─── Reading progress ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getProgress(sessionId: string, slug: string): Promise<Progress | null> {
|
||||
return listOne<Progress>('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<Progress[]> {
|
||||
return listAll<Progress>('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<void> {
|
||||
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',
|
||||
`session_id="${sessionId}"&&slug="${slug}"`
|
||||
progressFilter(sessionId, slug, userId)
|
||||
);
|
||||
|
||||
const payload = {
|
||||
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);
|
||||
@@ -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<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 });
|
||||
}
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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: '/',
|
||||
|
||||
Reference in New Issue
Block a user