Files
worldmonitor/api/user-prefs.ts
Elie Habib 2a71b65de9 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>
2026-03-29 16:46:12 +04:00

105 lines
3.5 KiB
TypeScript

/**
* 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);
}
}