From 82f82a990e6174ea8329b33e0fcabf5586ec50ab Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 11 Feb 2026 13:58:16 -0700 Subject: [PATCH] feat: add user preferences and document progress syncing - Implemented user preferences management with a new API for GET and PUT requests. - Added user document progress tracking with a new API for retrieving and updating progress. - Introduced database schema changes for user preferences and document progress. - Enhanced EPUB and TTS contexts to support syncing user preferences and document progress. - Added functions to handle transferring user preferences and progress during account linking. - Updated client-side logic to schedule syncing of user preferences and document progress. --- docs-site/docs/configure/auth.md | 17 ++ docs-site/docs/configure/database.md | 10 + docs-site/docs/reference/stack.md | 1 + drizzle/postgres/0001_user_state_minimal.sql | 21 ++ drizzle/postgres/meta/_journal.json | 9 +- drizzle/sqlite/0001_user_state_minimal.sql | 21 ++ drizzle/sqlite/meta/_journal.json | 9 +- src/app/api/user/claim/route.ts | 16 +- src/app/api/user/state/preferences/route.ts | 195 +++++++++++++++++++ src/app/api/user/state/progress/route.ts | 188 ++++++++++++++++++ src/components/auth/ClaimDataModal.tsx | 60 +++++- src/contexts/ConfigContext.tsx | 125 +++++++++++- src/contexts/DocumentContext.tsx | 6 +- src/contexts/EPUBContext.tsx | 12 +- src/contexts/TTSContext.tsx | 120 +++++++++--- src/db/schema.ts | 2 + src/db/schema_postgres.ts | 24 ++- src/db/schema_sqlite.ts | 22 +++ src/lib/client-user-state.ts | 181 +++++++++++++++++ src/lib/server/auth.ts | 29 ++- src/lib/server/claim-data.ts | 122 +++++++++++- src/lib/server/user-state-scope.ts | 30 +++ src/types/user-state.ts | 39 ++++ 23 files changed, 1209 insertions(+), 50 deletions(-) create mode 100644 drizzle/postgres/0001_user_state_minimal.sql create mode 100644 drizzle/sqlite/0001_user_state_minimal.sql create mode 100644 src/app/api/user/state/preferences/route.ts create mode 100644 src/app/api/user/state/progress/route.ts create mode 100644 src/lib/client-user-state.ts create mode 100644 src/lib/server/user-state-scope.ts create mode 100644 src/types/user-state.ts diff --git a/docs-site/docs/configure/auth.md b/docs-site/docs/configure/auth.md index 0214f21..fff001b 100644 --- a/docs-site/docs/configure/auth.md +++ b/docs-site/docs/configure/auth.md @@ -18,3 +18,20 @@ This page covers application-level configuration for provider access and authent - For storage/S3/SeaweedFS behavior: [Object / Blob Storage](./object-blob-storage) - For database mode: [Database](./database) - For migration behavior and commands: [Migrations](./migrations) + +## Sync notes + +### Auth enabled + +- Settings and reading progress are saved to the server. +- Updates are not instant push-based sync; they use normal client polling/refresh behavior. +- If two devices change the same item around the same time, the newest update wins. + +### Auth disabled + +- Settings and reading progress stay local in the browser (Dexie/IndexedDB). +- This avoids no-auth cross-browser conflicts, but there is no cross-device sync. + +## Claim modal note + +- You may still see old anonymous settings/progress available to claim from older deployments. diff --git a/docs-site/docs/configure/database.md b/docs-site/docs/configure/database.md index 6663c8e..c3140be 100644 --- a/docs-site/docs/configure/database.md +++ b/docs-site/docs/configure/database.md @@ -13,6 +13,9 @@ This page covers database mode selection for OpenReader WebUI. - Document and audiobook metadata/state used by server routes. - Auth/session tables when auth is enabled. +- TTS character usage counters (`user_tts_chars`) for daily rate limiting (when enabled). +- User settings preferences (`user_preferences`) when auth is enabled. +- User reading progress (`user_document_progress`) when auth is enabled. ## Related variables @@ -24,3 +27,10 @@ For database variable behavior, see [Environment Variables](../reference/environ - [Migrations](./migrations) - [Object / Blob Storage](./object-blob-storage) +- [Auth](./auth) + +## State sync summary + +- With auth enabled, settings and reading progress are stored in SQL and synced from the app. +- With auth disabled, settings and reading progress remain local in the browser. +- Sync is currently request-based (not realtime push invalidation). diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md index 757591d..136b170 100644 --- a/docs-site/docs/reference/stack.md +++ b/docs-site/docs/reference/stack.md @@ -28,6 +28,7 @@ title: Stack ## Next.js server - APIs: Route Handlers for sync, blob/content access, migrations, audiobook export, TTS/Whisper proxying +- State sync: request-based today (not realtime push updates) - Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters - Metadata DB: [Drizzle ORM](https://orm.drizzle.team/) with SQLite (`better-sqlite3`) by default and optional Postgres (`pg`) - Blob storage: embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3 diff --git a/drizzle/postgres/0001_user_state_minimal.sql b/drizzle/postgres/0001_user_state_minimal.sql new file mode 100644 index 0000000..0f8ef23 --- /dev/null +++ b/drizzle/postgres/0001_user_state_minimal.sql @@ -0,0 +1,21 @@ +CREATE TABLE "user_document_progress" ( + "user_id" text NOT NULL, + "document_id" text NOT NULL, + "reader_type" text NOT NULL, + "location" text NOT NULL, + "progress" real, + "client_updated_at_ms" bigint DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now(), + CONSTRAINT "user_document_progress_user_id_document_id_pk" PRIMARY KEY("user_id","document_id") +); +--> statement-breakpoint +CREATE TABLE "user_preferences" ( + "user_id" text PRIMARY KEY NOT NULL, + "data_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "client_updated_at_ms" bigint DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE INDEX "idx_user_document_progress_user_id_updated_at" ON "user_document_progress" USING btree ("user_id","updated_at"); diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index c0cfceb..9fea0ea 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1770191341206, "tag": "0000_perfect_risque", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1770843600000, + "tag": "0001_user_state_minimal", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/drizzle/sqlite/0001_user_state_minimal.sql b/drizzle/sqlite/0001_user_state_minimal.sql new file mode 100644 index 0000000..24514c4 --- /dev/null +++ b/drizzle/sqlite/0001_user_state_minimal.sql @@ -0,0 +1,21 @@ +CREATE TABLE `user_document_progress` ( + `user_id` text NOT NULL, + `document_id` text NOT NULL, + `reader_type` text NOT NULL, + `location` text NOT NULL, + `progress` real, + `client_updated_at_ms` integer DEFAULT 0 NOT NULL, + `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + `updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + PRIMARY KEY(`user_id`, `document_id`) +); +--> statement-breakpoint +CREATE TABLE `user_preferences` ( + `user_id` text PRIMARY KEY NOT NULL, + `data_json` text DEFAULT '{}' NOT NULL, + `client_updated_at_ms` integer DEFAULT 0 NOT NULL, + `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + `updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000) +); +--> statement-breakpoint +CREATE INDEX `idx_user_document_progress_user_id_updated_at` ON `user_document_progress` (`user_id`,`updated_at`); diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 4cf4829..cc48afa 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1770191340954, "tag": "0000_lively_korvac", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1770843600000, + "tag": "0001_user_state_minimal", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/app/api/user/claim/route.ts b/src/app/api/user/claim/route.ts index c537cd6..9acb8db 100644 --- a/src/app/api/user/claim/route.ts +++ b/src/app/api/user/claim/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { claimAnonymousData } from '@/lib/server/claim-data'; import { auth } from '@/lib/server/auth'; import { db } from '@/db'; -import { audiobooks, documents } from '@/db/schema'; +import { audiobooks, documents, userDocumentProgress, userPreferences } from '@/db/schema'; import { count, eq, ne } from 'drizzle-orm'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; @@ -22,7 +22,9 @@ async function checkClaimMigrationReadiness(): Promise { return null; } -async function getClaimableCounts(unclaimedUserId: string): Promise<{ documents: number; audiobooks: number }> { +async function getClaimableCounts( + unclaimedUserId: string, +): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number }> { const [docCount] = await db .select({ count: count() }) .from(documents) @@ -31,10 +33,20 @@ async function getClaimableCounts(unclaimedUserId: string): Promise<{ documents: .select({ count: count() }) .from(audiobooks) .where(eq(audiobooks.userId, unclaimedUserId)); + const [preferencesCount] = await db + .select({ count: count() }) + .from(userPreferences) + .where(eq(userPreferences.userId, unclaimedUserId)); + const [progressCount] = await db + .select({ count: count() }) + .from(userDocumentProgress) + .where(eq(userDocumentProgress.userId, unclaimedUserId)); return { documents: Number(docCount?.count ?? 0), audiobooks: Number(bookCount?.count ?? 0), + preferences: Number(preferencesCount?.count ?? 0), + progress: Number(progressCount?.count ?? 0), }; } diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts new file mode 100644 index 0000000..8ed8f3c --- /dev/null +++ b/src/app/api/user/state/preferences/route.ts @@ -0,0 +1,195 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { userPreferences } from '@/db/schema'; +import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state'; +import { resolveUserStateScope } from '@/lib/server/user-state-scope'; + +export const dynamic = 'force-dynamic'; + +function nowForDb(): Date | number { + return process.env.POSTGRES_URL ? new Date() : Date.now(); +} + +function serializePreferencesForDb(patch: SyncedPreferencesPatch): SyncedPreferencesPatch | string { + if (process.env.POSTGRES_URL) return patch; + return JSON.stringify(patch); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function parseStoredPreferences(value: unknown): SyncedPreferencesPatch { + if (!value) return {}; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + return isRecord(parsed) ? sanitizePreferencesPatch(parsed) : {}; + } catch { + return {}; + } + } + return isRecord(value) ? sanitizePreferencesPatch(value) : {}; +} + +function sanitizeSavedVoices(value: unknown): Record { + if (!isRecord(value)) return {}; + const out: Record = {}; + for (const [key, val] of Object.entries(value)) { + if (typeof key !== 'string' || key.length === 0) continue; + if (typeof val !== 'string') continue; + out[key] = val; + } + return out; +} + +function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch { + if (!isRecord(input)) return {}; + + const out: SyncedPreferencesPatch = {}; + + for (const key of SYNCED_PREFERENCE_KEYS) { + if (!(key in input)) continue; + const value = input[key]; + + switch (key) { + case 'viewType': + if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value; + break; + case 'voice': + case 'ttsProvider': + case 'ttsModel': + case 'ttsInstructions': + if (typeof value === 'string') out[key] = value; + break; + case 'voiceSpeed': + case 'audioPlayerSpeed': + case 'headerMargin': + case 'footerMargin': + case 'leftMargin': + case 'rightMargin': + if (Number.isFinite(value)) out[key] = Number(value); + break; + case 'skipBlank': + case 'epubTheme': + case 'smartSentenceSplitting': + case 'pdfHighlightEnabled': + case 'pdfWordHighlightEnabled': + case 'epubHighlightEnabled': + case 'epubWordHighlightEnabled': + if (typeof value === 'boolean') out[key] = value; + break; + case 'savedVoices': + out[key] = sanitizeSavedVoices(value); + break; + default: + break; + } + } + + return out; +} + +function normalizeClientUpdatedAtMs(value: unknown): number { + if (!Number.isFinite(value)) return Date.now(); + const normalized = Number(value); + if (normalized <= 0) return Date.now(); + return Math.floor(normalized); +} + +export async function GET(req: NextRequest) { + try { + const scope = await resolveUserStateScope(req); + if (scope instanceof Response) return scope; + + const rows = await db + .select({ + dataJson: userPreferences.dataJson, + clientUpdatedAtMs: userPreferences.clientUpdatedAtMs, + }) + .from(userPreferences) + .where(eq(userPreferences.userId, scope.ownerUserId)) + .limit(1); + + const row = rows[0]; + const storedPatch = parseStoredPreferences(row?.dataJson); + const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0); + + return NextResponse.json({ + preferences: storedPatch, + clientUpdatedAtMs, + hasStoredPreferences: Boolean(row), + }); + } catch (error) { + console.error('Error loading user preferences:', error); + return NextResponse.json({ error: 'Failed to load user preferences' }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + try { + const scope = await resolveUserStateScope(req); + if (scope instanceof Response) return scope; + + const body = (await req.json().catch(() => null)) as + | { patch?: unknown; clientUpdatedAtMs?: unknown } + | null; + const patch = sanitizePreferencesPatch(body?.patch); + const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs); + + if (Object.keys(patch).length === 0) { + return NextResponse.json({ error: 'No valid preferences provided' }, { status: 400 }); + } + + const existingRows = await db + .select({ + dataJson: userPreferences.dataJson, + clientUpdatedAtMs: userPreferences.clientUpdatedAtMs, + }) + .from(userPreferences) + .where(eq(userPreferences.userId, scope.ownerUserId)) + .limit(1); + const existing = existingRows[0]; + const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0); + const existingPatch = parseStoredPreferences(existing?.dataJson); + + if (existing && clientUpdatedAtMs < existingUpdated) { + return NextResponse.json({ + preferences: existingPatch, + clientUpdatedAtMs: existingUpdated, + applied: false, + }); + } + + const mergedPatch = { ...existingPatch, ...patch }; + const dataJson = serializePreferencesForDb(mergedPatch); + const updatedAt = nowForDb(); + + await db + .insert(userPreferences) + .values({ + userId: scope.ownerUserId, + dataJson, + clientUpdatedAtMs, + updatedAt, + }) + .onConflictDoUpdate({ + target: [userPreferences.userId], + set: { + dataJson, + clientUpdatedAtMs, + updatedAt, + }, + }); + + return NextResponse.json({ + preferences: mergedPatch, + clientUpdatedAtMs, + applied: true, + }); + } catch (error) { + console.error('Error updating user preferences:', error); + return NextResponse.json({ error: 'Failed to update user preferences' }, { status: 500 }); + } +} diff --git a/src/app/api/user/state/progress/route.ts b/src/app/api/user/state/progress/route.ts new file mode 100644 index 0000000..99666d3 --- /dev/null +++ b/src/app/api/user/state/progress/route.ts @@ -0,0 +1,188 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { userDocumentProgress } from '@/db/schema'; +import type { ReaderType } from '@/types/user-state'; +import { isValidDocumentId } from '@/lib/server/documents-blobstore'; +import { resolveUserStateScope } from '@/lib/server/user-state-scope'; + +export const dynamic = 'force-dynamic'; + +function nowForDb(): Date | number { + return process.env.POSTGRES_URL ? new Date() : Date.now(); +} + +function normalizeReaderType(value: unknown): ReaderType | null { + if (value === 'pdf' || value === 'epub' || value === 'html') return value; + return null; +} + +function normalizeClientUpdatedAtMs(value: unknown): number { + if (!Number.isFinite(value)) return Date.now(); + const normalized = Number(value); + if (normalized <= 0) return Date.now(); + return Math.floor(normalized); +} + +function toUpdatedAtMs(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (value instanceof Date) return value.getTime(); + return Date.now(); +} + +export async function GET(req: NextRequest) { + try { + const scope = await resolveUserStateScope(req); + if (scope instanceof Response) return scope; + + const documentId = (new URL(req.url).searchParams.get('documentId') || '').trim().toLowerCase(); + if (!isValidDocumentId(documentId)) { + return NextResponse.json({ error: 'Invalid documentId' }, { status: 400 }); + } + + const rows = await db + .select({ + documentId: userDocumentProgress.documentId, + readerType: userDocumentProgress.readerType, + location: userDocumentProgress.location, + progress: userDocumentProgress.progress, + clientUpdatedAtMs: userDocumentProgress.clientUpdatedAtMs, + updatedAt: userDocumentProgress.updatedAt, + }) + .from(userDocumentProgress) + .where(and( + eq(userDocumentProgress.userId, scope.ownerUserId), + eq(userDocumentProgress.documentId, documentId), + )) + .limit(1); + + const row = rows[0]; + if (!row) { + return NextResponse.json({ progress: null }); + } + + return NextResponse.json({ + progress: { + documentId: row.documentId, + readerType: row.readerType, + location: row.location, + progress: row.progress == null ? null : Number(row.progress), + clientUpdatedAtMs: Number(row.clientUpdatedAtMs ?? 0), + updatedAtMs: toUpdatedAtMs(row.updatedAt), + }, + }); + } catch (error) { + console.error('Error loading user progress:', error); + return NextResponse.json({ error: 'Failed to load user progress' }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + try { + const scope = await resolveUserStateScope(req); + if (scope instanceof Response) return scope; + + const body = (await req.json().catch(() => null)) as + | { + documentId?: unknown; + readerType?: unknown; + location?: unknown; + progress?: unknown; + clientUpdatedAtMs?: unknown; + } + | null; + + const documentId = typeof body?.documentId === 'string' ? body.documentId.trim().toLowerCase() : ''; + if (!isValidDocumentId(documentId)) { + return NextResponse.json({ error: 'Invalid documentId' }, { status: 400 }); + } + + const readerType = normalizeReaderType(body?.readerType); + if (!readerType) { + return NextResponse.json({ error: "Invalid readerType. Expected 'pdf', 'epub', or 'html'." }, { status: 400 }); + } + + const location = typeof body?.location === 'string' ? body.location.trim() : ''; + if (!location) { + return NextResponse.json({ error: 'Invalid location' }, { status: 400 }); + } + + const progress = + body?.progress == null + ? null + : Number.isFinite(body.progress) + ? Math.max(0, Math.min(1, Number(body.progress))) + : null; + const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs); + + const existingRows = await db + .select({ + clientUpdatedAtMs: userDocumentProgress.clientUpdatedAtMs, + location: userDocumentProgress.location, + readerType: userDocumentProgress.readerType, + progress: userDocumentProgress.progress, + updatedAt: userDocumentProgress.updatedAt, + }) + .from(userDocumentProgress) + .where(and( + eq(userDocumentProgress.userId, scope.ownerUserId), + eq(userDocumentProgress.documentId, documentId), + )) + .limit(1); + const existing = existingRows[0]; + const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0); + + if (existing && clientUpdatedAtMs < existingUpdated) { + return NextResponse.json({ + progress: { + documentId, + readerType: existing.readerType, + location: existing.location, + progress: existing.progress == null ? null : Number(existing.progress), + clientUpdatedAtMs: existingUpdated, + updatedAtMs: toUpdatedAtMs(existing.updatedAt), + }, + applied: false, + }); + } + + const updatedAt = nowForDb(); + await db + .insert(userDocumentProgress) + .values({ + userId: scope.ownerUserId, + documentId, + readerType, + location, + progress, + clientUpdatedAtMs, + updatedAt, + }) + .onConflictDoUpdate({ + target: [userDocumentProgress.userId, userDocumentProgress.documentId], + set: { + readerType, + location, + progress, + clientUpdatedAtMs, + updatedAt, + }, + }); + + return NextResponse.json({ + progress: { + documentId, + readerType, + location, + progress, + clientUpdatedAtMs, + updatedAtMs: toUpdatedAtMs(updatedAt), + }, + applied: true, + }); + } catch (error) { + console.error('Error updating user progress:', error); + return NextResponse.json({ error: 'Failed to update user progress' }, { status: 500 }); + } +} + diff --git a/src/components/auth/ClaimDataModal.tsx b/src/components/auth/ClaimDataModal.tsx index 84fa66e..1ffbce7 100644 --- a/src/components/auth/ClaimDataModal.tsx +++ b/src/components/auth/ClaimDataModal.tsx @@ -11,6 +11,24 @@ import { } from '@headlessui/react'; import { useAuthSession } from '@/hooks/useAuthSession'; import { useRouter } from 'next/navigation'; +import toast from 'react-hot-toast'; + +type ClaimableCounts = { + documents: number; + audiobooks: number; + preferences: number; + progress: number; +}; + +function toClaimableCounts(value: unknown): ClaimableCounts { + const rec = (value && typeof value === 'object') ? (value as Record) : {}; + return { + documents: Number(rec.documents ?? 0), + audiobooks: Number(rec.audiobooks ?? 0), + preferences: Number(rec.preferences ?? 0), + progress: Number(rec.progress ?? 0), + }; +} export default function ClaimDataModal() { const { data: sessionData } = useAuthSession(); @@ -18,6 +36,12 @@ export default function ClaimDataModal() { const [isOpen, setIsOpen] = useState(false); const [hasChecked, setHasChecked] = useState(false); const [isClaiming, setIsClaiming] = useState(false); + const [claimableCounts, setClaimableCounts] = useState({ + documents: 0, + audiobooks: 0, + preferences: 0, + progress: 0, + }); const user = sessionData?.user; const checkClaimableData = useCallback(async () => { @@ -29,7 +53,10 @@ export default function ClaimDataModal() { }); if (res.ok) { const data = await res.json(); - if (data.documents > 0 || data.audiobooks > 0) { + const counts = toClaimableCounts(data); + setClaimableCounts(counts); + + if (counts.documents + counts.audiobooks + counts.preferences + counts.progress > 0) { setIsOpen(true); } } @@ -53,13 +80,22 @@ export default function ClaimDataModal() { }); if (res.ok) { const data = await res.json(); - alert(`Successfully claimed ${data.claimed.documents} documents and ${data.claimed.audiobooks} audiobooks!`); + const claimed = toClaimableCounts(data?.claimed); + toast.success( + `Successfully claimed ${claimed.documents} documents, ` + + `${claimed.audiobooks} audiobooks, ` + + `${claimed.preferences} preference set(s), and ` + + `${claimed.progress} reading progress record(s)!`, + ); setIsOpen(false); router.refresh(); + return; } + const data = await res.json().catch(() => null) as { error?: string } | null; + toast.error(data?.error || 'Failed to claim data.'); } catch { - alert('Failed to claim data.'); + toast.error('Failed to claim data.'); } finally { setIsClaiming(false); } @@ -106,12 +142,22 @@ export default function ClaimDataModal() {

- We found documents and audiobooks that were created before auth was enabled. - Would you like to claim them and add them to your account? + We found existing anonymous data from before auth was enabled. + Claim it now to attach it to your account.

+
+
Claimable data
+
    +
  • {claimableCounts.documents} document(s)
  • +
  • {claimableCounts.audiobooks} audiobook(s)
  • +
  • {claimableCounts.preferences} preference set(s)
  • +
  • {claimableCounts.progress} reading progress record(s)
  • +
+
+

- ⚠️ First user to claim these files will own them and revoke access for anyone else. + ⚠️ First user to claim this data will own it and revoke access for anyone else.

@@ -137,7 +183,7 @@ export default function ClaimDataModal() { transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background disabled:opacity-50" > - {isClaiming ? 'Claiming...' : 'Claim All'} + {isClaiming ? 'Claiming...' : 'Claim Data'}
diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 74737b9..bb68f2b 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -1,9 +1,13 @@ 'use client'; -import { createContext, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; +import { scheduleUserPreferencesSync, getUserPreferences, putUserPreferences } from '@/lib/client-user-state'; +import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state'; +import { useAuthSession } from '@/hooks/useAuthSession'; +import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import toast from 'react-hot-toast'; export type { ViewType } from '@/types/config'; @@ -50,10 +54,51 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(true); const [isDBReady, setIsDBReady] = useState(false); const didRunStartupMigrations = useRef(false); + const didAttemptInitialPreferenceSeedForSession = useRef(null); + const syncedPreferenceKeys = useMemo(() => new Set(SYNCED_PREFERENCE_KEYS), []); + const { authEnabled } = useAuthConfig(); + const { data: sessionData, isPending: isSessionPending } = useAuthSession(); + const sessionKey = sessionData?.user?.id ?? 'no-session'; // Helper function to generate provider-model key const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`; + const queueSyncedPreferencePatch = useCallback((patch: Partial) => { + if (!authEnabled) return; + + const syncedPatch: SyncedPreferencesPatch = {}; + for (const key of SYNCED_PREFERENCE_KEYS) { + if (!(key in patch)) continue; + const value = patch[key]; + if (value === undefined) continue; + (syncedPatch as Record)[key] = value; + } + if (Object.keys(syncedPatch).length === 0) return; + scheduleUserPreferencesSync(syncedPatch); + }, [authEnabled]); + + const buildSyncedPreferencePatch = useCallback(( + source: Partial, + options?: { nonDefaultOnly?: boolean }, + ): SyncedPreferencesPatch => { + const out: SyncedPreferencesPatch = {}; + for (const key of SYNCED_PREFERENCE_KEYS) { + if (!(key in source)) continue; + const value = source[key]; + if (value === undefined) continue; + if (options?.nonDefaultOnly) { + const defaultValue = APP_CONFIG_DEFAULTS[key]; + const same = + typeof value === 'object' + ? JSON.stringify(value) === JSON.stringify(defaultValue) + : value === defaultValue; + if (same) continue; + } + (out as Record)[key] = value; + } + return out; + }, []); + useEffect(() => { const handler = (event: Event) => { const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail; @@ -107,6 +152,38 @@ export function ConfigProvider({ children }: { children: ReactNode }) { void run(); }, [isDBReady]); + const refreshSyncedPreferencesFromServer = useCallback(async () => { + if (!isDBReady || !authEnabled) return; + try { + const remote = await getUserPreferences(); + if (!remote?.hasStoredPreferences) return; + if (!remote.preferences || Object.keys(remote.preferences).length === 0) return; + await updateAppConfig(remote.preferences as Partial); + } catch (error) { + console.warn('Failed to load synced preferences:', error); + } + }, [isDBReady, authEnabled]); + + useEffect(() => { + if (!isDBReady || !authEnabled || isSessionPending) return; + refreshSyncedPreferencesFromServer().catch((error) => { + console.warn('Synced preferences refresh failed:', error); + }); + }, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]); + + useEffect(() => { + if (!isDBReady || !authEnabled) return; + const onFocus = () => { + refreshSyncedPreferencesFromServer().catch((error) => { + console.warn('Focus synced preferences refresh failed:', error); + }); + }; + window.addEventListener('focus', onFocus); + return () => { + window.removeEventListener('focus', onFocus); + }; + }, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]); + const appConfig = useLiveQuery( async () => { if (!isDBReady) return null; @@ -124,6 +201,32 @@ export function ConfigProvider({ children }: { children: ReactNode }) { return { ...APP_CONFIG_DEFAULTS, ...rest }; }, [appConfig]); + useEffect(() => { + if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return; + if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return; + didAttemptInitialPreferenceSeedForSession.current = sessionKey; + + const run = async () => { + try { + const remote = await getUserPreferences(); + if (remote?.hasStoredPreferences) return; + + // Seed only user-customized (non-default) values. This prevents fresh/default + // profiles from overwriting existing server values during first-run races. + const patch = buildSyncedPreferencePatch(appConfig, { nonDefaultOnly: true }); + if (Object.keys(patch).length === 0) return; + + await putUserPreferences(patch, { clientUpdatedAtMs: Date.now() }); + } catch (error) { + console.warn('Failed to seed initial synced preferences from local Dexie:', error); + } + }; + + run().catch((error) => { + console.warn('Initial synced preferences seed failed:', error); + }); + }, [isDBReady, authEnabled, appConfig, buildSyncedPreferencePatch, isSessionPending, sessionKey]); + // Destructure for convenience and to match context shape const { apiKey, @@ -163,7 +266,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) { if (newConfig.baseUrl !== undefined) { updates.baseUrl = newConfig.baseUrl; } + if (newConfig.viewType !== undefined) { + updates.viewType = newConfig.viewType; + } await updateAppConfig(updates); + queueSyncedPreferencePatch(updates); } catch (error) { console.error('Error updating config:', error); throw error; @@ -189,6 +296,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) { savedVoices: updatedSavedVoices, voice: value as string, }); + queueSyncedPreferencePatch({ + savedVoices: updatedSavedVoices, + voice: value as string, + }); } // Special handling for provider/model changes - restore saved voice if available else if (key === 'ttsProvider' || key === 'ttsModel') { @@ -200,17 +311,29 @@ export function ConfigProvider({ children }: { children: ReactNode }) { [key]: value as AppConfigValues[keyof AppConfigValues], voice: restoredVoice, } as Partial); + queueSyncedPreferencePatch({ + [key]: value as AppConfigValues[keyof AppConfigValues], + voice: restoredVoice, + } as Partial); } else if (key === 'savedVoices') { const newSavedVoices = value as SavedVoices; await updateAppConfig({ savedVoices: newSavedVoices, }); + queueSyncedPreferencePatch({ + savedVoices: newSavedVoices, + }); } else { await updateAppConfig({ [key]: value as AppConfigValues[keyof AppConfigValues], } as Partial); + if (syncedPreferenceKeys.has(String(key))) { + queueSyncedPreferencePatch({ + [key]: value, + } as Partial); + } } } catch (error) { console.error(`Error updating config key ${String(key)}:`, error); diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx index d9f7390..e4e178e 100644 --- a/src/contexts/DocumentContext.tsx +++ b/src/contexts/DocumentContext.tsx @@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, R import type { BaseDocument, DocumentType } from '@/types/documents'; import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client-documents'; import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/document-cache'; +import { useAuthSession } from '@/hooks/useAuthSession'; interface DocumentContextType { // PDF Documents @@ -36,6 +37,8 @@ const DocumentContext = createContext(undefined export function DocumentProvider({ children }: { children: ReactNode }) { const [docs, setDocs] = useState(null); const isLoading = docs === null; + const { data: sessionData, isPending: isSessionPending } = useAuthSession(); + const sessionKey = sessionData?.user?.id ?? 'no-session'; const refreshDocuments = useCallback(async () => { const serverDocs = await listDocuments(); @@ -44,11 +47,12 @@ export function DocumentProvider({ children }: { children: ReactNode }) { }, []); useEffect(() => { + if (isSessionPending) return; refreshDocuments().catch((err) => { console.error('Failed to load documents from server:', err); setDocs([]); }); - }, [refreshDocuments]); + }, [refreshDocuments, sessionKey, isSessionPending]); useEffect(() => { const handler = () => { diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 9a0d6c0..6eb5dde 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -17,9 +17,11 @@ import type { SpineItem } from 'epubjs/types/section'; import type { Book, Rendition } from 'epubjs'; import { setLastDocumentLocation } from '@/lib/dexie'; +import { scheduleDocumentProgressSync } from '@/lib/client-user-state'; import { getDocumentMetadata } from '@/lib/client-documents'; import { ensureCachedDocument } from '@/lib/document-cache'; import { useTTS } from '@/contexts/TTSContext'; +import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { createRangeCfi } from '@/lib/epub'; import { useParams } from 'next/navigation'; import { useConfig } from './ConfigContext'; @@ -171,6 +173,7 @@ const collectContinuationFromRange = (range: Range | null | undefined, limit = E */ export function EPUBProvider({ children }: { children: ReactNode }) { const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS(); + const { authEnabled } = useAuthConfig(); const { id } = useParams(); // Configuration context to get TTS settings const { @@ -694,6 +697,13 @@ export function EPUBProvider({ children }: { children: ReactNode }) { if (id && locationRef.current !== 1) { console.log('Saving location:', location); setLastDocumentLocation(id as string, location.toString()); + if (authEnabled) { + scheduleDocumentProgressSync({ + documentId: id as string, + readerType: 'epub', + location: location.toString(), + }); + } } skipToLocation(location); @@ -703,7 +713,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { extractPageText(bookRef.current, renditionRef.current, shouldPauseRef.current); shouldPauseRef.current = true; } - }, [id, skipToLocation, extractPageText, setIsEPUB]); + }, [id, skipToLocation, extractPageText, setIsEPUB, authEnabled]); const clearWordHighlights = useCallback(() => { if (!renditionRef.current) return; diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index ba91692..81f50dd 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -27,7 +27,7 @@ import { } from 'react'; import { Howl } from 'howler'; import toast from 'react-hot-toast'; -import { useParams } from 'next/navigation'; +import { useParams, usePathname } from 'next/navigation'; import { useConfig } from '@/contexts/ConfigContext'; import { useAudioCache } from '@/hooks/audio/useAudioCache'; @@ -35,6 +35,7 @@ import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement'; import { useMediaSession } from '@/hooks/audio/useMediaSession'; import { useAudioContext } from '@/hooks/audio/useAudioContext'; import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie'; +import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client-user-state'; import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; import { withRetry, generateTTS, alignAudio } from '@/lib/client'; import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp'; @@ -53,6 +54,7 @@ import type { TTSRequestHeaders, TTSRetryOptions, } from '@/types/client'; +import type { ReaderType } from '@/types/user-state'; // Media globals declare global { @@ -304,7 +306,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const audioContext = useAudioContext(); const audioCache = useAudioCache(25); const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel); - const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit, isAtLimit } = useAuthRateLimit(); + const { + authEnabled, + onTTSStart, + onTTSComplete, + refresh: refreshRateLimit, + triggerRateLimit, + isAtLimit, + } = useAuthRateLimit(); // Add ref for location change handler const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null); @@ -332,6 +341,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Get document ID from URL params const { id } = useParams(); + const pathname = usePathname(); + + const currentReaderType: ReaderType = useMemo(() => { + if (pathname.startsWith('/epub/')) return 'epub'; + if (pathname.startsWith('/html/')) return 'html'; + return 'pdf'; + }, [pathname]); /** * State Management @@ -1716,38 +1732,75 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement skipBackward, }); - // Load last location on mount for both EPUB and PDF + // Load last location on mount for both EPUB and PDF. + // Prefer server-backed progress when available, then fall back to local Dexie. useEffect(() => { - if (id) { - getLastDocumentLocation(id as string).then(lastLocation => { - if (lastLocation) { - console.log('Setting last location:', lastLocation); + if (!id) return; - if (isEPUB && locationChangeHandlerRef.current) { - // For EPUB documents, use the location change handler - locationChangeHandlerRef.current(lastLocation); - } else if (!isEPUB) { - // For PDF documents, parse the location as "page:sentence" - try { - const [pageStr, sentenceIndexStr] = lastLocation.split(':'); - const page = parseInt(pageStr, 10); - const sentenceIndex = parseInt(sentenceIndexStr, 10); + let cancelled = false; + const docId = id as string; - if (!isNaN(page) && !isNaN(sentenceIndex)) { - console.log(`Restoring PDF position: page ${page}, sentence ${sentenceIndex}`); - // Skip to the page first, then the sentence index will be restored when setText is called - setCurrDocPage(page); - // Store the sentence index to be used when text is loaded - setPendingRestoreIndex(sentenceIndex); - } - } catch (error) { - console.warn('Error parsing PDF location:', error); - } + const applyLocation = (lastLocation: string) => { + console.log('Setting last location:', lastLocation); + + if (isEPUB && locationChangeHandlerRef.current) { + // For EPUB documents, use the location change handler + locationChangeHandlerRef.current(lastLocation); + return; + } + + if (!isEPUB) { + // For PDF documents, parse the location as "page:sentence" + try { + const [pageStr, sentenceIndexStr] = lastLocation.split(':'); + const page = parseInt(pageStr, 10); + const sentenceIndex = parseInt(sentenceIndexStr, 10); + + if (!isNaN(page) && !isNaN(sentenceIndex)) { + console.log(`Restoring PDF position: page ${page}, sentence ${sentenceIndex}`); + // Skip to the page first, then the sentence index will be restored when setText is called + setCurrDocPage(page); + // Store the sentence index to be used when text is loaded + setPendingRestoreIndex(sentenceIndex); } + } catch (error) { + console.warn('Error parsing PDF location:', error); } - }); - } - }, [id, isEPUB]); + } + }; + + const load = async () => { + if (authEnabled) { + try { + const remote = await getDocumentProgress(docId); + if (!cancelled && remote?.location) { + await setLastDocumentLocation(docId, remote.location).catch((error) => { + console.warn('Error caching remote location locally:', error); + }); + applyLocation(remote.location); + return; + } + } catch (error) { + console.warn('Error loading remote progress:', error); + } + } + + try { + const local = await getLastDocumentLocation(docId); + if (!cancelled && local) { + applyLocation(local); + } + } catch (error) { + console.warn('Error loading local last location:', error); + } + }; + + load(); + + return () => { + cancelled = true; + }; + }, [id, isEPUB, currentReaderType, authEnabled]); // Save current position periodically for PDFs useEffect(() => { @@ -1758,11 +1811,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setLastDocumentLocation(id as string, location).catch(error => { console.warn('Error saving PDF location:', error); }); + if (authEnabled) { + scheduleDocumentProgressSync({ + documentId: id as string, + readerType: currentReaderType, + location, + }); + } }, 1000); // Debounce saves by 1 second return () => clearTimeout(timeoutId); } - }, [id, isEPUB, currDocPageNumber, currentIndex, sentences.length]); + }, [id, isEPUB, currDocPageNumber, currentIndex, sentences.length, currentReaderType, authEnabled]); /** * Renders the TTS context provider with its children diff --git a/src/db/schema.ts b/src/db/schema.ts index a3b8549..a89f820 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -12,3 +12,5 @@ export const session = usePostgres ? postgresSchema.session : sqliteSchema.sessi export const account = usePostgres ? postgresSchema.account : sqliteSchema.account; export const verification = usePostgres ? postgresSchema.verification : sqliteSchema.verification; export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars; +export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences; +export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress; diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index ad979f5..d514176 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -1,4 +1,4 @@ -import { pgTable, text, integer, real, boolean, timestamp, date, bigint, primaryKey, index } from 'drizzle-orm/pg-core'; +import { pgTable, text, integer, real, boolean, timestamp, date, bigint, primaryKey, index, jsonb } from 'drizzle-orm/pg-core'; export const documents = pgTable('documents', { id: text('id').notNull(), @@ -96,3 +96,25 @@ export const userTtsChars = pgTable("user_tts_chars", { pk: primaryKey({ columns: [table.userId, table.date] }), dateIdx: index('idx_user_tts_chars_date').on(table.date), })); + +export const userPreferences = pgTable('user_preferences', { + userId: text('user_id').primaryKey(), + dataJson: jsonb('data_json').notNull().default({}), + clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + +export const userDocumentProgress = pgTable('user_document_progress', { + userId: text('user_id').notNull(), + documentId: text('document_id').notNull(), + readerType: text('reader_type').notNull(), // pdf, epub, html + location: text('location').notNull(), + progress: real('progress'), + clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}, (table) => ({ + pk: primaryKey({ columns: [table.userId, table.documentId] }), + userUpdatedIdx: index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt), +})); diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 06a2147..c806f86 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -97,3 +97,25 @@ export const userTtsChars = sqliteTable("user_tts_chars", { pk: primaryKey({ columns: [table.userId, table.date] }), dateIdx: index('idx_user_tts_chars_date').on(table.date), })); + +export const userPreferences = sqliteTable('user_preferences', { + userId: text('user_id').primaryKey(), + dataJson: text('data_json').notNull().default('{}'), + clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0), + createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), + updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), +}); + +export const userDocumentProgress = sqliteTable('user_document_progress', { + userId: text('user_id').notNull(), + documentId: text('document_id').notNull(), + readerType: text('reader_type').notNull(), // pdf, epub, html + location: text('location').notNull(), + progress: real('progress'), + clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0), + createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), + updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), +}, (table) => ({ + pk: primaryKey({ columns: [table.userId, table.documentId] }), + userUpdatedIdx: index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt), +})); diff --git a/src/lib/client-user-state.ts b/src/lib/client-user-state.ts new file mode 100644 index 0000000..5bc8823 --- /dev/null +++ b/src/lib/client-user-state.ts @@ -0,0 +1,181 @@ +import { SYNCED_PREFERENCE_KEYS, type DocumentProgressRecord, type ReaderType, type SyncedPreferencesPatch } from '@/types/user-state'; + +type PreferencesResponse = { + preferences: SyncedPreferencesPatch; + clientUpdatedAtMs: number; + hasStoredPreferences?: boolean; +}; + +type ProgressResponse = { + progress: DocumentProgressRecord | null; +}; + +function sanitizePreferencesPatch(input: SyncedPreferencesPatch): SyncedPreferencesPatch { + const patch: SyncedPreferencesPatch = {}; + for (const key of SYNCED_PREFERENCE_KEYS) { + if (!(key in input)) continue; + const value = input[key]; + if (value === undefined) continue; + (patch as Record)[key] = value; + } + return patch; +} + +export async function getUserPreferences(options?: { signal?: AbortSignal }): Promise { + const res = await fetch('/api/user/state/preferences', { signal: options?.signal }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to load user preferences'); + } + return (await res.json()) as PreferencesResponse; +} + +export async function putUserPreferences( + patch: SyncedPreferencesPatch, + options?: { signal?: AbortSignal; clientUpdatedAtMs?: number }, +): Promise { + const cleanPatch = sanitizePreferencesPatch(patch); + const res = await fetch('/api/user/state/preferences', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + patch: cleanPatch, + clientUpdatedAtMs: options?.clientUpdatedAtMs ?? Date.now(), + }), + signal: options?.signal, + }); + + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to update user preferences'); + } + + return (await res.json()) as PreferencesResponse & { applied: boolean }; +} + +type PendingPreferenceSync = { + patch: SyncedPreferencesPatch; + timer: ReturnType | null; +}; + +const pendingPreferenceSync: PendingPreferenceSync = { + patch: {}, + timer: null, +}; + +export function scheduleUserPreferencesSync(patch: SyncedPreferencesPatch, debounceMs: number = 600): void { + Object.assign(pendingPreferenceSync.patch, sanitizePreferencesPatch(patch)); + + if (pendingPreferenceSync.timer) { + clearTimeout(pendingPreferenceSync.timer); + } + + pendingPreferenceSync.timer = setTimeout(async () => { + const payload = { ...pendingPreferenceSync.patch }; + pendingPreferenceSync.patch = {}; + pendingPreferenceSync.timer = null; + if (Object.keys(payload).length === 0) return; + + try { + await putUserPreferences(payload); + } catch (error) { + console.warn('Failed to sync user preferences:', error); + } + }, debounceMs); +} + +export async function getDocumentProgress( + documentId: string, + options?: { signal?: AbortSignal }, +): Promise { + const res = await fetch(`/api/user/state/progress?documentId=${encodeURIComponent(documentId)}`, { + signal: options?.signal, + }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to load document progress'); + } + const data = (await res.json()) as ProgressResponse; + return data.progress ?? null; +} + +export async function putDocumentProgress(payload: { + documentId: string; + readerType: ReaderType; + location: string; + progress?: number | null; + clientUpdatedAtMs?: number; + signal?: AbortSignal; +}): Promise { + const res = await fetch('/api/user/state/progress', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + documentId: payload.documentId, + readerType: payload.readerType, + location: payload.location, + progress: payload.progress ?? null, + clientUpdatedAtMs: payload.clientUpdatedAtMs ?? Date.now(), + }), + signal: payload.signal, + }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to update document progress'); + } + const data = (await res.json()) as ProgressResponse; + return data.progress ?? null; +} + +type PendingProgressSync = { + payload: { + documentId: string; + readerType: ReaderType; + location: string; + progress: number | null; + }; + timer: ReturnType | null; +}; + +const pendingProgressByDoc = new Map(); + +export function scheduleDocumentProgressSync( + payload: { + documentId: string; + readerType: ReaderType; + location: string; + progress?: number | null; + }, + debounceMs: number = 1000, +): void { + const existing = pendingProgressByDoc.get(payload.documentId); + if (existing?.timer) { + clearTimeout(existing.timer); + } + + const next: PendingProgressSync = { + payload: { + documentId: payload.documentId, + readerType: payload.readerType, + location: payload.location, + progress: payload.progress ?? null, + }, + timer: null, + }; + + next.timer = setTimeout(async () => { + pendingProgressByDoc.delete(payload.documentId); + try { + await putDocumentProgress({ + documentId: next.payload.documentId, + readerType: next.payload.readerType, + location: next.payload.location, + progress: next.payload.progress, + }); + } catch (error) { + console.warn('Failed to sync document progress:', error); + } + }, debounceMs); + + pendingProgressByDoc.set(payload.documentId, next); +} diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index f7fe9da..9190ed6 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -7,7 +7,12 @@ import type { NextRequest } from 'next/server'; import { db } from "@/db"; import { rateLimiter } from "@/lib/server/rate-limiter"; import { isAuthEnabled } from "@/lib/server/auth-config"; -import { transferUserAudiobooks, transferUserDocuments } from "@/lib/server/claim-data"; +import { + transferUserAudiobooks, + transferUserDocuments, + transferUserPreferences, + transferUserProgress, +} from "@/lib/server/claim-data"; import * as schema from "@/db/schema"; // Import the dynamic schema @@ -135,6 +140,28 @@ const createAuth = () => betterAuth({ console.error("Error transferring documents during account linking:", error); // Don't throw here to prevent blocking the account linking process } + + // Transfer preferences from anonymous user to new authenticated user + try { + const transferred = await transferUserPreferences(anonymousUser.user.id, newUser.user.id); + if (transferred > 0) { + console.log(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + } + } catch (error) { + console.error("Error transferring preferences during account linking:", error); + // Don't throw here to prevent blocking the account linking process + } + + // Transfer reading progress from anonymous user to new authenticated user + try { + const transferred = await transferUserProgress(anonymousUser.user.id, newUser.user.id); + if (transferred > 0) { + console.log(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + } + } catch (error) { + console.error("Error transferring reading progress during account linking:", error); + // Don't throw here to prevent blocking the account linking process + } } catch (error) { console.error("Error in onLinkAccount callback:", error); // Don't throw here to prevent blocking the account linking process diff --git a/src/lib/server/claim-data.ts b/src/lib/server/claim-data.ts index 32624f4..3037720 100644 --- a/src/lib/server/claim-data.ts +++ b/src/lib/server/claim-data.ts @@ -1,6 +1,6 @@ import { db } from '@/db'; -import { documents, audiobooks, audiobookChapters } from '@/db/schema'; -import { eq } from 'drizzle-orm'; +import { documents, audiobooks, audiobookChapters, userPreferences, userDocumentProgress } from '@/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; import { UNCLAIMED_USER_ID } from './docstore'; import { deleteAudiobookObject, @@ -34,6 +34,25 @@ type AudiobookChapterRow = { format: string; }; +type UserPreferenceRow = { + userId: string; + dataJson: unknown; + clientUpdatedAtMs: number; + createdAt: unknown; + updatedAt: unknown; +}; + +type UserDocumentProgressRow = { + userId: string; + documentId: string; + readerType: string; + location: string; + progress: number | null; + clientUpdatedAtMs: number; + createdAt: unknown; + updatedAt: unknown; +}; + function contentTypeForAudiobookObject(fileName: string): string { if (fileName.endsWith('.mp3')) return 'audio/mpeg'; if (fileName.endsWith('.m4b')) return 'audio/mp4'; @@ -70,16 +89,22 @@ async function moveAudiobookBlobScope( } export async function claimAnonymousData(userId: string, unclaimedUserId: string = UNCLAIMED_USER_ID, namespace: string | null = null) { - if (!isAuthEnabled() || !userId) return { documents: 0, audiobooks: 0 }; + if (!isAuthEnabled() || !userId) { + return { documents: 0, audiobooks: 0, preferences: 0, progress: 0 }; + } - const [documentsClaimed, audiobooksClaimed] = await Promise.all([ + const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed] = await Promise.all([ transferUserDocuments(unclaimedUserId, userId), transferUserAudiobooks(unclaimedUserId, userId, namespace), + transferUserPreferences(unclaimedUserId, userId), + transferUserProgress(unclaimedUserId, userId), ]); return { documents: documentsClaimed, audiobooks: audiobooksClaimed, + preferences: preferencesClaimed, + progress: progressClaimed, }; } @@ -161,3 +186,92 @@ export async function transferUserAudiobooks( return books.length; } + +export async function transferUserPreferences(fromUserId: string, toUserId: string): Promise { + if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (fromUserId === toUserId) return 0; + + const fromRows = (await db + .select() + .from(userPreferences) + .where(eq(userPreferences.userId, fromUserId))) as UserPreferenceRow[]; + const fromRow = fromRows[0]; + if (!fromRow) return 0; + + const toRows = (await db + .select() + .from(userPreferences) + .where(eq(userPreferences.userId, toUserId))) as UserPreferenceRow[]; + const toRow = toRows[0]; + + if (!toRow || Number(fromRow.clientUpdatedAtMs ?? 0) > Number(toRow.clientUpdatedAtMs ?? 0)) { + await db + .insert(userPreferences) + .values({ + ...fromRow, + userId: toUserId, + }) + .onConflictDoUpdate({ + target: [userPreferences.userId], + set: { + dataJson: fromRow.dataJson, + clientUpdatedAtMs: fromRow.clientUpdatedAtMs, + updatedAt: fromRow.updatedAt, + }, + }); + } + + await db.delete(userPreferences).where(eq(userPreferences.userId, fromUserId)); + return 1; +} + +export async function transferUserProgress(fromUserId: string, toUserId: string): Promise { + if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (fromUserId === toUserId) return 0; + + const fromRows = (await db + .select() + .from(userDocumentProgress) + .where(eq(userDocumentProgress.userId, fromUserId))) as UserDocumentProgressRow[]; + if (fromRows.length === 0) return 0; + + const documentIds = fromRows.map((row) => row.documentId); + const toRows = (await db + .select() + .from(userDocumentProgress) + .where(and( + eq(userDocumentProgress.userId, toUserId), + inArray(userDocumentProgress.documentId, documentIds), + ))) as UserDocumentProgressRow[]; + const toByDocId = new Map(); + for (const row of toRows) { + toByDocId.set(row.documentId, row); + } + + for (const row of fromRows) { + const existing = toByDocId.get(row.documentId); + const fromUpdated = Number(row.clientUpdatedAtMs ?? 0); + const toUpdated = Number(existing?.clientUpdatedAtMs ?? 0); + if (existing && fromUpdated <= toUpdated) continue; + + await db + .insert(userDocumentProgress) + .values({ + ...row, + userId: toUserId, + }) + .onConflictDoUpdate({ + target: [userDocumentProgress.userId, userDocumentProgress.documentId], + set: { + readerType: row.readerType, + location: row.location, + progress: row.progress, + clientUpdatedAtMs: row.clientUpdatedAtMs, + updatedAt: row.updatedAt, + }, + }); + } + + await db.delete(userDocumentProgress).where(eq(userDocumentProgress.userId, fromUserId)); + return fromRows.length; +} diff --git a/src/lib/server/user-state-scope.ts b/src/lib/server/user-state-scope.ts new file mode 100644 index 0000000..dc4fe7a --- /dev/null +++ b/src/lib/server/user-state-scope.ts @@ -0,0 +1,30 @@ +import type { NextRequest } from 'next/server'; +import type { AuthContext } from '@/lib/server/auth'; +import { requireAuthContext } from '@/lib/server/auth'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; + +export type ResolvedUserStateScope = { + auth: AuthContext; + namespace: string | null; + ownerUserId: string; + unclaimedUserId: string; +}; + +export async function resolveUserStateScope( + req: NextRequest, +): Promise { + const auth = await requireAuthContext(req); + if (auth instanceof Response) return auth; + + const namespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(namespace); + const ownerUserId = auth.userId ?? unclaimedUserId; + + return { + auth, + namespace, + ownerUserId, + unclaimedUserId, + }; +} + diff --git a/src/types/user-state.ts b/src/types/user-state.ts new file mode 100644 index 0000000..0a3c83d --- /dev/null +++ b/src/types/user-state.ts @@ -0,0 +1,39 @@ +import type { AppConfigValues } from '@/types/config'; + +export const SYNCED_PREFERENCE_KEYS = [ + 'viewType', + 'voiceSpeed', + 'audioPlayerSpeed', + 'voice', + 'skipBlank', + 'epubTheme', + 'smartSentenceSplitting', + 'headerMargin', + 'footerMargin', + 'leftMargin', + 'rightMargin', + 'ttsProvider', + 'ttsModel', + 'ttsInstructions', + 'savedVoices', + 'pdfHighlightEnabled', + 'pdfWordHighlightEnabled', + 'epubHighlightEnabled', + 'epubWordHighlightEnabled', +] as const; + +export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number]; +export type SyncedPreferences = Pick; +export type SyncedPreferencesPatch = Partial; + +export type ReaderType = 'pdf' | 'epub' | 'html'; + +export interface DocumentProgressRecord { + documentId: string; + readerType: ReaderType; + location: string; + progress: number | null; + clientUpdatedAtMs: number; + updatedAtMs: number; +} +