mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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>
105 lines
3.5 KiB
TypeScript
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);
|
|
}
|
|
}
|