feat(prefs): Phase 2 — frontend preferences sync (#2507)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
This commit is contained in:
Elie Habib
2026-03-29 16:46:12 +04:00
committed by GitHub
parent 751820c1cc
commit 2a71b65de9
3 changed files with 487 additions and 2 deletions

104
api/user-prefs.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* User preferences sync endpoint.
*
* GET /api/user-prefs?variant=<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<Response> {
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);
}
}

View File

@@ -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<PreciseCoordinates | null> =

View File

@@ -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<number, (data: Record<string, unknown>) => Record<string, unknown>> = {
// Future: MIGRATIONS[2] = (data) => { ...transform... }
};
type SyncState = 'synced' | 'pending' | 'syncing' | 'conflict' | 'offline' | 'signed-out' | 'error';
let _debounceTimer: ReturnType<typeof setTimeout> | 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<string, string> {
const blob: Record<string, string> = {};
for (const key of CLOUD_SYNC_KEYS) {
const val = localStorage.getItem(key);
if (val !== null) blob[key] = val;
}
return blob;
}
function applyCloudBlob(data: Record<string, unknown>): 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<string, unknown>,
fromVersion: number,
): Record<string, unknown> {
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 = `
<div class="update-toast-body">
<div class="update-toast-title">Settings restored</div>
<div class="update-toast-detail">Your preferences were loaded from the cloud.</div>
</div>
<button class="update-toast-action" data-action="undo">Undo</button>
<button class="update-toast-dismiss" data-action="dismiss" aria-label="Dismiss">\u00d7</button>
`;
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<string, string>;
_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<string, unknown>;
schemaVersion: number;
syncVersion: number;
}
async function fetchCloudPrefs(token: string, variant: string): Promise<CloudPrefs | null> {
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<string, string>,
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<void> {
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<void> {
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);
}