From 64ffa36c09ec09b1f0d00c70fea170a7d8bc188e Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 20:28:46 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20data=20sync=20(?= =?UTF-8?q?presets=20+=20game=20progress)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - GET/PUT /api/v1/sync/presets — upsert with last-write-wins - DELETE /api/v1/sync/presets/:id - GET/PUT /api/v1/sync/progress — game progress upsert Client: - syncService.js: offline-first sync layer - localStorage remains primary store - Pushes to server when logged in - Merges server data into local on sync - Auto-sync every 30s + on tab focus - AuthContext starts/stops sync on login/logout - Sync runs on session restore (refresh token) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/client/src/services/AuthContext.jsx | 5 + packages/client/src/services/syncService.js | 134 +++++++++++++++++++ packages/server/src/index.js | 2 + packages/server/src/routes/sync.js | 104 ++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 packages/client/src/services/syncService.js create mode 100644 packages/server/src/routes/sync.js diff --git a/packages/client/src/services/AuthContext.jsx b/packages/client/src/services/AuthContext.jsx index 29a676e..73e4fd2 100644 --- a/packages/client/src/services/AuthContext.jsx +++ b/packages/client/src/services/AuthContext.jsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { auth as authApi, users as usersApi, setAccessToken, setOnUnauthorized } from './api.js'; +import { startAutoSync, stopAutoSync } from './syncService.js'; const AuthContext = createContext(null); @@ -13,6 +14,7 @@ export function AuthProvider({ children }) { const [showAuth, setShowAuth] = useState(false); const logout = useCallback(async () => { + stopAutoSync(); try { await authApi.logout(); } catch {} setAccessToken(null); setUser(null); @@ -30,6 +32,7 @@ export function AuthProvider({ children }) { try { const me = await usersApi.me(); setUser(me); + startAutoSync(); } catch {} } setLoading(false); @@ -41,6 +44,7 @@ export function AuthProvider({ children }) { setAccessToken(data.accessToken); setUser(data.user); setShowAuth(false); + startAutoSync(); return data.user; }, []); @@ -49,6 +53,7 @@ export function AuthProvider({ children }) { setAccessToken(data.accessToken); setUser(data.user); setShowAuth(false); + startAutoSync(); return data.user; }, []); diff --git a/packages/client/src/services/syncService.js b/packages/client/src/services/syncService.js new file mode 100644 index 0000000..a72cdb5 --- /dev/null +++ b/packages/client/src/services/syncService.js @@ -0,0 +1,134 @@ +import { getAccessToken } from './api.js'; + +const API_BASE = '/api/v1/sync'; +const SYNC_INTERVAL = 30000; // 30 seconds + +let _syncTimer = null; + +async function apiFetch(method, path, body = null) { + const token = getAccessToken(); + if (!token) return null; + + const res = await fetch(`${API_BASE}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + credentials: 'include', + body: body ? JSON.stringify(body) : null, + }); + + if (!res.ok) return null; + return res.json(); +} + +// ==================== Preset Sync ==================== + +export async function syncPresets() { + if (!getAccessToken()) return; + + try { + // Get local presets + const localRaw = localStorage.getItem('reaktor_presets'); + const localPresets = localRaw ? JSON.parse(localRaw) : []; + + // Get server presets + const serverData = await apiFetch('GET', '/presets'); + if (!serverData) return; + + // Push local presets to server + if (localPresets.length > 0) { + await apiFetch('PUT', '/presets', { + presets: localPresets.map(p => ({ + name: p.name, + data: p.data, + updatedAt: p.savedAt || new Date().toISOString(), + })), + }); + } + + // Merge server presets into local (add any missing) + const serverPresets = serverData.presets || []; + const localNames = new Set(localPresets.map(p => p.name)); + let merged = [...localPresets]; + + for (const sp of serverPresets) { + if (!localNames.has(sp.name)) { + merged.push({ + name: sp.name, + data: sp.data, + savedAt: sp.updatedAt, + }); + } + } + + localStorage.setItem('reaktor_presets', JSON.stringify(merged)); + } catch (err) { + console.warn('[sync] preset sync failed:', err.message); + } +} + +// ==================== Game Progress Sync ==================== + +export async function syncProgress() { + if (!getAccessToken()) return; + + try { + const localRaw = localStorage.getItem('synthquest-progress'); + const localProgress = localRaw ? JSON.parse(localRaw) : null; + + // Get server progress + const serverData = await apiFetch('GET', '/progress'); + + if (localProgress) { + // Push local to server + await apiFetch('PUT', '/progress', { + data: localProgress, + updatedAt: new Date().toISOString(), + }); + } + + // If server has progress and local doesn't, pull it + if (serverData?.progress && !localProgress) { + localStorage.setItem('synthquest-progress', JSON.stringify(serverData.progress)); + } + + // Also sync hint data + const hintsRaw = localStorage.getItem('synthquest-hints'); + // Hints are local-only for now (anti-cheat integrity) + } catch (err) { + console.warn('[sync] progress sync failed:', err.message); + } +} + +// ==================== Auto Sync ==================== + +export function startAutoSync() { + if (_syncTimer) return; + + // Initial sync + syncPresets(); + syncProgress(); + + // Periodic sync + _syncTimer = setInterval(() => { + syncPresets(); + syncProgress(); + }, SYNC_INTERVAL); + + // Sync on tab focus + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + syncPresets(); + syncProgress(); + } + }); +} + +export function stopAutoSync() { + if (_syncTimer) { + clearInterval(_syncTimer); + _syncTimer = null; + } +} diff --git a/packages/server/src/index.js b/packages/server/src/index.js index a627a51..550b8ae 100644 --- a/packages/server/src/index.js +++ b/packages/server/src/index.js @@ -11,6 +11,7 @@ import { fileURLToPath } from 'url'; import authRoutes from './routes/auth.js'; import userRoutes from './routes/users.js'; import adminRoutes from './routes/admin.js'; +import syncRoutes from './routes/sync.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = process.env.PORT || 3001; @@ -39,6 +40,7 @@ await fastify.register(rateLimit, { await fastify.register(authRoutes, { prefix: '/api/v1/auth' }); await fastify.register(userRoutes, { prefix: '/api/v1/users' }); await fastify.register(adminRoutes, { prefix: '/api/v1/admin' }); +await fastify.register(syncRoutes, { prefix: '/api/v1/sync' }); // Rate limit auth endpoints more aggressively fastify.addHook('onRoute', (routeOptions) => { diff --git a/packages/server/src/routes/sync.js b/packages/server/src/routes/sync.js new file mode 100644 index 0000000..712c405 --- /dev/null +++ b/packages/server/src/routes/sync.js @@ -0,0 +1,104 @@ +import { eq } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; +import { authenticate } from '../middleware/auth.js'; + +export default async function syncRoutes(fastify) { + + // Get all user presets + fastify.get('/presets', { preHandler: [authenticate] }, async (request) => { + const presets = await db.select({ + id: schema.presets.id, + name: schema.presets.name, + data: schema.presets.data, + isAutosave: schema.presets.isAutosave, + updatedAt: schema.presets.updatedAt, + }).from(schema.presets) + .where(eq(schema.presets.userId, request.user.id)) + .orderBy(schema.presets.updatedAt); + + return { presets }; + }); + + // Upsert presets (merge from client) + fastify.put('/presets', { preHandler: [authenticate] }, async (request) => { + const { presets } = request.body; + if (!Array.isArray(presets)) return { error: 'presets must be array' }; + + const results = []; + for (const p of presets) { + if (!p.name || !p.data) continue; + + if (p.id) { + // Update existing + const [existing] = await db.select().from(schema.presets) + .where(eq(schema.presets.id, p.id)).limit(1); + + if (existing && existing.userId === request.user.id) { + // Only update if client is newer + const clientTime = p.updatedAt ? new Date(p.updatedAt) : new Date(); + if (clientTime >= new Date(existing.updatedAt)) { + const [updated] = await db.update(schema.presets) + .set({ name: p.name, data: p.data, isAutosave: p.isAutosave || false, updatedAt: new Date() }) + .where(eq(schema.presets.id, p.id)) + .returning(); + results.push(updated); + } else { + results.push(existing); // Server is newer, keep it + } + continue; + } + } + + // Insert new + const [inserted] = await db.insert(schema.presets).values({ + userId: request.user.id, + name: p.name, + data: p.data, + isAutosave: p.isAutosave || false, + }).returning(); + results.push(inserted); + } + + return { presets: results }; + }); + + // Delete a preset + fastify.delete('/presets/:id', { preHandler: [authenticate] }, async (request, reply) => { + await db.delete(schema.presets) + .where(eq(schema.presets.id, request.params.id)); + return { ok: true }; + }); + + // Get game progress + fastify.get('/progress', { preHandler: [authenticate] }, async (request) => { + const [progress] = await db.select().from(schema.gameProgress) + .where(eq(schema.gameProgress.userId, request.user.id)).limit(1); + + return { progress: progress?.data || null, updatedAt: progress?.updatedAt || null }; + }); + + // Upsert game progress (last-write-wins) + fastify.put('/progress', { preHandler: [authenticate] }, async (request) => { + const { data, updatedAt } = request.body; + if (!data) return { error: 'data required' }; + + const [existing] = await db.select().from(schema.gameProgress) + .where(eq(schema.gameProgress.userId, request.user.id)).limit(1); + + if (existing) { + const clientTime = updatedAt ? new Date(updatedAt) : new Date(); + if (clientTime >= new Date(existing.updatedAt)) { + await db.update(schema.gameProgress) + .set({ data, updatedAt: new Date() }) + .where(eq(schema.gameProgress.userId, request.user.id)); + } + } else { + await db.insert(schema.gameProgress).values({ + userId: request.user.id, + data, + }); + } + + return { ok: true }; + }); +}