openreader/src/lib/client/api/user-state.ts

220 lines
6.7 KiB
TypeScript

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<string, unknown>)[key] = value;
}
return patch;
}
export async function getUserPreferences(options?: { signal?: AbortSignal }): Promise<PreferencesResponse> {
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<PreferencesResponse & { applied: boolean }> {
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<typeof setTimeout> | null;
sessionId: string | null;
};
const pendingPreferenceSync: PendingPreferenceSync = {
patch: {},
timer: null,
sessionId: null,
};
let activeSyncController: AbortController | null = null;
/**
* Cancel any pending debounced preference sync and abort in-flight requests.
* Call this on session change / sign-out to prevent cross-account writes.
*/
export function cancelPendingPreferenceSync(): void {
if (pendingPreferenceSync.timer) {
clearTimeout(pendingPreferenceSync.timer);
pendingPreferenceSync.timer = null;
}
pendingPreferenceSync.patch = {};
pendingPreferenceSync.sessionId = null;
if (activeSyncController) {
activeSyncController.abort();
activeSyncController = null;
}
}
export function scheduleUserPreferencesSync(
patch: SyncedPreferencesPatch,
sessionId: string,
debounceMs: number = 600,
): void {
Object.assign(pendingPreferenceSync.patch, sanitizePreferencesPatch(patch));
pendingPreferenceSync.sessionId = sessionId;
if (pendingPreferenceSync.timer) {
clearTimeout(pendingPreferenceSync.timer);
}
const capturedSessionId = sessionId;
pendingPreferenceSync.timer = setTimeout(async () => {
// If the session changed between scheduling and firing, discard.
if (pendingPreferenceSync.sessionId !== capturedSessionId) return;
const payload = { ...pendingPreferenceSync.patch };
pendingPreferenceSync.patch = {};
pendingPreferenceSync.timer = null;
if (Object.keys(payload).length === 0) return;
// Abort any previous in-flight sync and create a fresh controller.
if (activeSyncController) activeSyncController.abort();
activeSyncController = new AbortController();
try {
await putUserPreferences(payload, { signal: activeSyncController.signal });
} catch (error) {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to sync user preferences:', error);
} finally {
activeSyncController = null;
}
}, debounceMs);
}
export async function getDocumentProgress(
documentId: string,
options?: { signal?: AbortSignal },
): Promise<DocumentProgressRecord | null> {
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<DocumentProgressRecord | null> {
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<typeof setTimeout> | null;
};
const pendingProgressByDoc = new Map<string, PendingProgressSync>();
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);
}