From 2a71b65de942b1e73955a73fde2b9e8b7b37cd15 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Sun, 29 Mar 2026 16:46:12 +0400 Subject: [PATCH] =?UTF-8?q?feat(prefs):=20Phase=202=20=E2=80=94=20frontend?= =?UTF-8?q?=20preferences=20sync=20(#2507)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(prefs): Phase 2 — frontend preferences sync - api/user-prefs.ts: Vercel edge function (GET/POST/OPTIONS) that validates Clerk Bearer token via validateBearerToken(), then calls Convex mutations via ConvexHttpClient; returns 409 on CONFLICT, 400 on BLOB_TOO_LARGE - src/utils/cloud-prefs-sync.ts: full sync service with: - install(): patches Storage.prototype.setItem to auto-detect CLOUD_SYNC_KEYS changes; multi-tab coordination via storage event; tab-close flush via fetch({keepalive:true}) (NOT sendBeacon — cannot send Authorization headers) - onSignIn(): fetch cloud prefs, merge (cloud wins if newer syncVersion), applyCloudBlob with _suppressPatch flag to avoid re-triggering upload; shows 5s undo toast on first-ever sign-in with cloud data - uploadNow(): debounced 5s upload; CONFLICT retry with re-fetch + merge - onSignOut(): clears sync metadata only (preserves prefs in localStorage) - setSyncVersion/setState use Storage.prototype.setItem.call() directly to bypass the setItem patch for non-pref state keys - src/App.ts: move initAuthState() unconditional (out of isProUser() gate, per Q2 resolution); wire onSignIn/onSignOut via subscribeAuthState with _prevUserId tracking to detect transitions; call install() at startup 🤖 Generated with Claude Sonnet 4.6 via Claude Code + Compound Engineering Co-Authored-By: Claude Sonnet 4.6 * fix(prefs): address Greptile review findings on cloud-prefs-sync - P1: send schemaVersion in all POST bodies (postCloudPrefs, onSignOut flush, flushOnUnload) so Convex stores the correct schema version - P2a: flush pending debounce upload on sign-out (keepalive fetch) before clearing credentials, so last pref change isn't lost - P2b: only transition to 'synced' in conflict recovery if re-fetch returns non-null; otherwise set 'error' to surface the failure - P2c: applyCloudBlob now removes local keys absent from cloud blob, ensuring cloud state is the source of truth (not additive-only) --------- Co-authored-by: Claude Sonnet 4.6 --- api/user-prefs.ts | 104 ++++++++++ src/App.ts | 17 +- src/utils/cloud-prefs-sync.ts | 368 ++++++++++++++++++++++++++++++++++ 3 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 api/user-prefs.ts create mode 100644 src/utils/cloud-prefs-sync.ts diff --git a/api/user-prefs.ts b/api/user-prefs.ts new file mode 100644 index 000000000..580de23eb --- /dev/null +++ b/api/user-prefs.ts @@ -0,0 +1,104 @@ +/** + * User preferences sync endpoint. + * + * GET /api/user-prefs?variant= — returns current cloud prefs for signed-in user + * POST /api/user-prefs — saves prefs blob for signed-in user + * + * Authentication: Clerk Bearer token in Authorization header. + * Requires CONVEX_URL + CLERK_JWT_ISSUER_DOMAIN env vars. + */ + +export const config = { runtime: 'edge' }; + +// @ts-expect-error — JS module, no declaration file +import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; +// @ts-expect-error — JS module, no declaration file +import { jsonResponse } from './_json-response.js'; +import { ConvexHttpClient } from 'convex/browser'; +import { validateBearerToken } from '../server/auth-session'; + +export default async function handler(req: Request): Promise { + if (isDisallowedOrigin(req)) { + return jsonResponse({ error: 'Origin not allowed' }, 403); + } + + const cors = getCorsHeaders(req, 'GET, POST, OPTIONS'); + + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: cors }); + } + + if (req.method !== 'GET' && req.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, 405, cors); + } + + const authHeader = req.headers.get('Authorization') ?? ''; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + if (!token) { + return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors); + } + + const session = await validateBearerToken(token); + if (!session.valid || !session.userId) { + return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors); + } + + const convexUrl = process.env.CONVEX_URL; + if (!convexUrl) { + return jsonResponse({ error: 'Service unavailable' }, 503, cors); + } + + const client = new ConvexHttpClient(convexUrl); + client.setAuth(token); + + if (req.method === 'GET') { + const url = new URL(req.url); + const variant = url.searchParams.get('variant') ?? 'full'; + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const prefs = await client.query('userPreferences:getPreferences' as any, { variant }); + return jsonResponse(prefs ?? null, 200, cors); + } catch (err) { + console.error('[user-prefs] GET error:', err); + return jsonResponse({ error: 'Failed to fetch preferences' }, 500, cors); + } + } + + // POST — save prefs + let body: { variant?: unknown; data?: unknown; expectedSyncVersion?: unknown; schemaVersion?: unknown }; + try { + body = await req.json(); + } catch { + return jsonResponse({ error: 'Invalid JSON' }, 400, cors); + } + + if ( + typeof body.variant !== 'string' || + body.data === undefined || + typeof body.expectedSyncVersion !== 'number' + ) { + return jsonResponse({ error: 'MISSING_FIELDS' }, 400, cors); + } + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await client.mutation('userPreferences:setPreferences' as any, { + variant: body.variant, + data: body.data, + expectedSyncVersion: body.expectedSyncVersion, + schemaVersion: typeof body.schemaVersion === 'number' ? body.schemaVersion : undefined, + }); + return jsonResponse(result, 200, cors); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('CONFLICT')) { + return jsonResponse({ error: 'CONFLICT' }, 409, cors); + } + if (msg.includes('BLOB_TOO_LARGE')) { + return jsonResponse({ error: 'BLOB_TOO_LARGE' }, 400, cors); + } + console.error('[user-prefs] POST error:', err); + return jsonResponse({ error: 'Failed to save preferences' }, 500, cors); + } +} diff --git a/src/App.ts b/src/App.ts index 87296e759..3bcb04ce8 100644 --- a/src/App.ts +++ b/src/App.ts @@ -65,6 +65,7 @@ import { EventHandlerManager } from '@/app/event-handlers'; import { resolveUserRegion, resolvePreciseUserCoordinates, type PreciseCoordinates } from '@/utils/user-location'; import { showProBanner } from '@/components/ProBanner'; import { initAuthState, subscribeAuthState } from '@/services/auth-state'; +import { install as installCloudPrefsSync, onSignIn as cloudPrefsSignIn, onSignOut as cloudPrefsSignOut } from '@/utils/cloud-prefs-sync'; import { CorrelationEngine, militaryAdapter, @@ -779,12 +780,24 @@ export class App { this.bootstrapHydrationState = getBootstrapHydrationState(); // Verify OAuth OTT and hydrate auth session BEFORE any UI subscribes to auth state + await initAuthState(); if (isProUser()) { - await initAuthState(); initAuthAnalytics(); } + installCloudPrefsSync(SITE_VARIANT); this.enforceFreeTierLimits(); - this.unsubFreeTier = subscribeAuthState(() => { this.enforceFreeTierLimits(); }); + + let _prevUserId: string | null = null; + this.unsubFreeTier = subscribeAuthState((session) => { + this.enforceFreeTierLimits(); + const userId = session.user?.id ?? null; + if (userId !== null && userId !== _prevUserId) { + void cloudPrefsSignIn(userId, SITE_VARIANT); + } else if (userId === null && _prevUserId !== null) { + cloudPrefsSignOut(); + } + _prevUserId = userId; + }); const geoCoordsPromise: Promise = diff --git a/src/utils/cloud-prefs-sync.ts b/src/utils/cloud-prefs-sync.ts new file mode 100644 index 000000000..2bcfc51c5 --- /dev/null +++ b/src/utils/cloud-prefs-sync.ts @@ -0,0 +1,368 @@ +/** + * Cloud preferences sync service. + * + * Syncs CLOUD_SYNC_KEYS to Convex via /api/user-prefs (Vercel edge). + * + * Lifecycle hooks: + * install(variant) — call once at startup (patches localStorage.setItem, wires events) + * onSignIn(userId, variant) — fetch cloud prefs and merge on sign-in + * onSignOut() — clear sync metadata on sign-out + * + * Feature flag: VITE_CLOUD_PREFS_ENABLED=true must be set. + * Desktop guard: isDesktopRuntime() always skips sync. + */ + +import { CLOUD_SYNC_KEYS, type CloudSyncKey } from './sync-keys'; +import { isDesktopRuntime } from '@/services/runtime'; +import { getClerkToken } from '@/services/clerk'; + +const ENABLED = import.meta.env.VITE_CLOUD_PREFS_ENABLED === 'true'; + +// localStorage state keys — never uploaded to cloud +const KEY_SYNC_VERSION = 'wm-cloud-sync-version'; +const KEY_LAST_SYNC_AT = 'wm-last-sync-at'; +const KEY_SYNC_STATE = 'wm-cloud-sync-state'; +const KEY_LAST_SIGNED_IN_AS = 'wm-last-signed-in-as'; + +const CURRENT_PREFS_SCHEMA_VERSION = 1; +const MIGRATIONS: Record) => Record> = { + // Future: MIGRATIONS[2] = (data) => { ...transform... } +}; + +type SyncState = 'synced' | 'pending' | 'syncing' | 'conflict' | 'offline' | 'signed-out' | 'error'; + +let _debounceTimer: ReturnType | null = null; +let _currentVariant = 'full'; +let _installed = false; +let _suppressPatch = false; // prevents applyCloudBlob from re-triggering upload +let _cachedToken: string | null = null; // synchronous token cache for flush() + +// ── Guards ──────────────────────────────────────────────────────────────────── + +function isEnabled(): boolean { + return ENABLED && !isDesktopRuntime(); +} + +// ── State helpers ───────────────────────────────────────────────────────────── + +function getSyncVersion(): number { + return parseInt(localStorage.getItem(KEY_SYNC_VERSION) ?? '0', 10) || 0; +} + +function setSyncVersion(v: number): void { + // Use direct Storage.prototype.setItem to bypass our patch (state key, not a pref key) + Storage.prototype.setItem.call(localStorage, KEY_SYNC_VERSION, String(v)); +} + +function setState(s: SyncState): void { + Storage.prototype.setItem.call(localStorage, KEY_SYNC_STATE, s); +} + +// ── Blob helpers ────────────────────────────────────────────────────────────── + +function buildCloudBlob(): Record { + const blob: Record = {}; + for (const key of CLOUD_SYNC_KEYS) { + const val = localStorage.getItem(key); + if (val !== null) blob[key] = val; + } + return blob; +} + +function applyCloudBlob(data: Record): void { + _suppressPatch = true; + try { + for (const key of CLOUD_SYNC_KEYS) { + const val = data[key]; + if (typeof val === 'string') { + localStorage.setItem(key, val); + } else if (!(key in data)) { + localStorage.removeItem(key); + } + } + } finally { + _suppressPatch = false; + } +} + +function applyMigrations( + data: Record, + fromVersion: number, +): Record { + let result = data; + for (let v = fromVersion + 1; v <= CURRENT_PREFS_SCHEMA_VERSION; v++) { + result = MIGRATIONS[v]?.(result) ?? result; + } + return result; +} + +// ── Toast ───────────────────────────────────────────────────────────────────── + +function showUndoToast(prevBlobJson: string): void { + document.querySelector('.wm-sync-restore-toast')?.remove(); + + const toast = document.createElement('div'); + toast.className = 'wm-sync-restore-toast update-toast'; + toast.innerHTML = ` +
+
Settings restored
+
Your preferences were loaded from the cloud.
+
+ + + `; + + const autoTimer = setTimeout(() => toast.remove(), 5000); + + toast.addEventListener('click', (e) => { + const action = (e.target as HTMLElement).closest('[data-action]')?.getAttribute('data-action'); + if (action === 'undo') { + const prev = JSON.parse(prevBlobJson) as Record; + _suppressPatch = true; + try { + for (const [k, v] of Object.entries(prev)) { + if (CLOUD_SYNC_KEYS.includes(k as CloudSyncKey)) localStorage.setItem(k, v); + } + } finally { + _suppressPatch = false; + } + toast.remove(); + clearTimeout(autoTimer); + } else if (action === 'dismiss') { + toast.remove(); + clearTimeout(autoTimer); + } + }); + + document.body.appendChild(toast); +} + +// ── API helpers ─────────────────────────────────────────────────────────────── + +interface CloudPrefs { + data: Record; + schemaVersion: number; + syncVersion: number; +} + +async function fetchCloudPrefs(token: string, variant: string): Promise { + const res = await fetch(`/api/user-prefs?variant=${encodeURIComponent(variant)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.status === 401) return null; + if (!res.ok) throw new Error(`fetch prefs: ${res.status}`); + return (await res.json()) as CloudPrefs | null; +} + +async function postCloudPrefs( + token: string, + variant: string, + data: Record, + expectedSyncVersion: number, +): Promise<{ syncVersion: number } | { conflict: true }> { + const res = await fetch('/api/user-prefs', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ variant, data, expectedSyncVersion, schemaVersion: CURRENT_PREFS_SCHEMA_VERSION }), + }); + if (res.status === 409) return { conflict: true }; + if (!res.ok) throw new Error(`post prefs: ${res.status}`); + return (await res.json()) as { syncVersion: number }; +} + +// ── Core logic ──────────────────────────────────────────────────────────────── + +export async function onSignIn(userId: string, variant: string): Promise { + if (!isEnabled()) return; + + _currentVariant = variant; + setState('syncing'); + + const token = await getClerkToken(); + if (!token) { setState('error'); return; } + _cachedToken = token; + + try { + const cloud = await fetchCloudPrefs(token, variant); + + if (cloud && cloud.syncVersion > getSyncVersion()) { + const isFirstEverSync = getSyncVersion() === 0; + const prevBlobJson = isFirstEverSync ? JSON.stringify(buildCloudBlob()) : null; + + const migrated = applyMigrations(cloud.data, cloud.schemaVersion ?? 1); + applyCloudBlob(migrated); + setSyncVersion(cloud.syncVersion); + Storage.prototype.setItem.call(localStorage, KEY_LAST_SYNC_AT, String(Date.now())); + + if (isFirstEverSync && prevBlobJson && Object.keys(cloud.data).length > 0) { + showUndoToast(prevBlobJson); + } + + setState('synced'); + } else { + const blob = buildCloudBlob(); + const result = await postCloudPrefs(token, variant, blob, getSyncVersion()); + + if ('conflict' in result) { + setState('conflict'); + const fresh = await fetchCloudPrefs(token, variant); + if (fresh) { + const migrated = applyMigrations(fresh.data, fresh.schemaVersion ?? 1); + applyCloudBlob(migrated); + setSyncVersion(fresh.syncVersion); + setState('synced'); + } else { + setState('error'); + } + } else { + setSyncVersion(result.syncVersion); + Storage.prototype.setItem.call(localStorage, KEY_LAST_SYNC_AT, String(Date.now())); + setState('synced'); + } + } + + Storage.prototype.setItem.call(localStorage, KEY_LAST_SIGNED_IN_AS, userId); + } catch (err) { + console.warn('[cloud-prefs] onSignIn failed:', err); + setState(!navigator.onLine || (err instanceof TypeError && err.message.includes('fetch')) ? 'offline' : 'error'); + } +} + +export function onSignOut(): void { + if (!isEnabled()) return; + + if (_debounceTimer !== null && _cachedToken) { + // Flush pending upload synchronously before clearing credentials + clearTimeout(_debounceTimer); + _debounceTimer = null; + const blob = buildCloudBlob(); + fetch('/api/user-prefs', { + method: 'POST', + keepalive: true, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${_cachedToken}` }, + body: JSON.stringify({ variant: _currentVariant, data: blob, expectedSyncVersion: getSyncVersion(), schemaVersion: CURRENT_PREFS_SCHEMA_VERSION }), + }).catch(() => { /* best-effort on sign-out */ }); + } else if (_debounceTimer !== null) { + clearTimeout(_debounceTimer); + _debounceTimer = null; + } + _cachedToken = null; + + // Preserve prefs; only clear sync metadata + localStorage.removeItem(KEY_SYNC_VERSION); + localStorage.removeItem(KEY_LAST_SYNC_AT); + setState('signed-out'); +} + +async function uploadNow(variant: string): Promise { + const token = await getClerkToken(); + if (!token) return; + _cachedToken = token; + + setState('syncing'); + + try { + const result = await postCloudPrefs(token, variant, buildCloudBlob(), getSyncVersion()); + + if ('conflict' in result) { + setState('conflict'); + const fresh = await fetchCloudPrefs(token, variant); + if (fresh) { + const migrated = applyMigrations(fresh.data, fresh.schemaVersion ?? 1); + applyCloudBlob(migrated); + setSyncVersion(fresh.syncVersion); + const retryResult = await postCloudPrefs(token, variant, buildCloudBlob(), fresh.syncVersion); + if (!('conflict' in retryResult)) { + setSyncVersion(retryResult.syncVersion); + Storage.prototype.setItem.call(localStorage, KEY_LAST_SYNC_AT, String(Date.now())); + } + setState('synced'); + } else { + setState('error'); + } + } else { + setSyncVersion(result.syncVersion); + Storage.prototype.setItem.call(localStorage, KEY_LAST_SYNC_AT, String(Date.now())); + setState('synced'); + } + } catch (err) { + console.warn('[cloud-prefs] uploadNow failed:', err); + setState(!navigator.onLine || (err instanceof TypeError && err.message.includes('fetch')) ? 'offline' : 'error'); + } +} + +function schedulePrefUpload(variant: string): void { + setState('pending'); + if (_debounceTimer !== null) clearTimeout(_debounceTimer); + _debounceTimer = setTimeout(async () => { + _debounceTimer = null; + await uploadNow(variant); + }, 5000); +} + +export function onPrefChange(variant: string): void { + if (!isEnabled()) return; + _currentVariant = variant; + schedulePrefUpload(variant); +} + +// ── install ─────────────────────────────────────────────────────────────────── + +export function install(variant: string): void { + if (!isEnabled() || _installed) return; + _installed = true; + _currentVariant = variant; + + // Patch localStorage.setItem to detect pref changes in this tab. + // Use _suppressPatch to prevent applyCloudBlob from triggering spurious uploads. + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = function setItem(key: string, value: string) { + originalSetItem.call(this, key, value); + if (this === localStorage && !_suppressPatch && CLOUD_SYNC_KEYS.includes(key as CloudSyncKey)) { + schedulePrefUpload(_currentVariant); + } + }; + + // Multi-tab: another tab wrote a newer syncVersion — cancel our pending upload + window.addEventListener('storage', (e) => { + if (e.key === KEY_SYNC_VERSION && e.newValue !== null) { + const newV = parseInt(e.newValue, 10); + if (newV > getSyncVersion()) { + if (_debounceTimer !== null) { + clearTimeout(_debounceTimer); + _debounceTimer = null; + setState('synced'); + } + Storage.prototype.setItem.call(localStorage, KEY_SYNC_VERSION, e.newValue); + } + } + }); + + // Tab close: flush pending debounce via fetch with keepalive + // (sendBeacon cannot send Authorization headers) + const flushOnUnload = (): void => { + if (_debounceTimer === null || !_cachedToken) return; + clearTimeout(_debounceTimer); + _debounceTimer = null; + + const blob = buildCloudBlob(); + const payload = JSON.stringify({ variant: _currentVariant, data: blob, expectedSyncVersion: getSyncVersion(), schemaVersion: CURRENT_PREFS_SCHEMA_VERSION }); + fetch('/api/user-prefs', { + method: 'POST', + keepalive: true, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${_cachedToken}`, + }, + body: payload, + }).catch(() => { /* best-effort on unload */ }); + }; + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') flushOnUnload(); + }); + window.addEventListener('pagehide', flushOnUnload); +}