openreader/src/lib/client/api/user-state.ts
Richard R 925c995274 refactor(config): centralize provider default resolution and simplify user preference inheritance
Move provider normalization logic into a dedicated module to ensure consistent
handling of provider defaults and user preference inheritance across client and
server. Update layout components to mount ConfigProvider only at the shared
layout level, preventing unnecessary remounts and hydration issues during
navigation. Adjust app config defaults so user provider settings are empty by
default, always inheriting the admin-configured provider unless explicitly set.
Add tests for normalization and sync logic to ensure robust provider resolution.
2026-06-06 15:25:10 -06:00

271 lines
8.3 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;
};
type ChangelogVersionCheckResponse = {
shouldOpen: boolean;
currentVersion: string;
lastSeenVersion: string | 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 };
}
export async function postChangelogVersionCheck(
currentVersion: string,
options?: { signal?: AbortSignal },
): Promise<ChangelogVersionCheckResponse> {
const res = await fetch('/api/user/state/changelog/version-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentVersion }),
signal: options?.signal,
});
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(data?.error || 'Failed to check changelog version');
}
return (await res.json()) as ChangelogVersionCheckResponse;
}
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);
}
/**
* Immediately send any pending debounced preference patch and await the result.
* Use this when the user explicitly saves: we want the server write to complete
* (and surface failures) instead of silently relying on the debounce timer, which
* can be lost if the page is reloaded inside the debounce window.
*/
export async function flushUserPreferencesSync(): Promise<void> {
if (pendingPreferenceSync.timer) {
clearTimeout(pendingPreferenceSync.timer);
pendingPreferenceSync.timer = null;
}
const payload = { ...pendingPreferenceSync.patch };
pendingPreferenceSync.patch = {};
if (Object.keys(payload).length === 0) return;
if (activeSyncController) activeSyncController.abort();
activeSyncController = new AbortController();
try {
await putUserPreferences(payload, { signal: activeSyncController.signal });
} finally {
activeSyncController = null;
}
}
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);
}