Add Hono backend with PostgreSQL, auth, cloud saves, and leaderboard

Server app (apps/server) with Hono framework and Drizzle ORM:
- PostgreSQL schema: users, saves, leaderboard, achievements tables
- Anonymous auth with UUID tokens, optional email/password linking
- Cloud save API: list, get, upsert, delete with auto-save hook
- Leaderboard API: per-category rankings with score submission
- CORS configured for dev server ports
- Typed middleware with Hono env variables

Frontend cloud save integration:
- API client with auth token management in localStorage
- useCloudSave hook auto-saves every 300 ticks when authenticated
- Vite env type declarations for VITE_API_URL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 17:35:18 -04:00
parent 8c9555bc08
commit 8ea6c771a1
16 changed files with 1289 additions and 9 deletions
+107
View File
@@ -0,0 +1,107 @@
import { Hono } from 'hono';
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { saves } from '../db/schema';
import { authMiddleware } from '../middleware/auth';
import type { AppEnv } from '../types';
const savesRouter = new Hono<AppEnv>();
savesRouter.use('*', authMiddleware);
savesRouter.get('/', async (c) => {
const userId = c.get('userId') as string;
const userSaves = await db
.select({
id: saves.id,
companyName: saves.companyName,
era: saves.era,
tickCount: saves.tickCount,
updatedAt: saves.updatedAt,
})
.from(saves)
.where(eq(saves.userId, userId))
.orderBy(desc(saves.updatedAt))
.limit(10);
return c.json({ saves: userSaves });
});
savesRouter.get('/:id', async (c) => {
const userId = c.get('userId') as string;
const saveId = c.req.param('id');
const [save] = await db
.select()
.from(saves)
.where(and(eq(saves.id, saveId), eq(saves.userId, userId)))
.limit(1);
if (!save) {
return c.json({ error: 'Save not found' }, 404);
}
return c.json({ save });
});
savesRouter.put('/', async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<{
companyName: string;
saveVersion: number;
gameData: unknown;
tickCount: number;
era: string;
}>();
const existing = await db
.select({ id: saves.id })
.from(saves)
.where(eq(saves.userId, userId))
.orderBy(desc(saves.updatedAt))
.limit(1);
if (existing.length > 0) {
await db
.update(saves)
.set({
companyName: body.companyName,
saveVersion: body.saveVersion,
gameData: body.gameData,
tickCount: body.tickCount,
era: body.era,
updatedAt: new Date(),
})
.where(eq(saves.id, existing[0].id));
return c.json({ id: existing[0].id, updated: true });
}
const [newSave] = await db
.insert(saves)
.values({
userId,
companyName: body.companyName,
saveVersion: body.saveVersion,
gameData: body.gameData,
tickCount: body.tickCount,
era: body.era,
})
.returning({ id: saves.id });
return c.json({ id: newSave.id, created: true });
});
savesRouter.delete('/:id', async (c) => {
const userId = c.get('userId') as string;
const saveId = c.req.param('id');
await db
.delete(saves)
.where(and(eq(saves.id, saveId), eq(saves.userId, userId)));
return c.json({ deleted: true });
});
export { savesRouter };