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

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