feat: Phase 2 — data sync (presets + game progress)

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) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 20:28:46 +01:00
parent 3523111019
commit 64ffa36c09
4 changed files with 245 additions and 0 deletions

View File

@@ -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) => {

View File

@@ -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 };
});
}