feat: Admin SynthQuest level management + user dropdown with admin access

SynthQuest admin:
- New "🎮 SynthQuest" section in admin sidebar
- List custom levels with world, ID, title, patch status
- Create new level: world selector, title, subtitle, description,
  concept (hint), available modules (tag input), boss flag, sort order
- Edit existing levels inline
- Import patch base from sandbox JSON export (📥 button per level)
- Delete levels with confirmation

Server:
- custom_levels table (PostgreSQL)
- CRUD API at /api/v1/admin/levels
- POST /:id/import-patch to import sandbox JSON as preplaced modules

Admin access:
- User badge is now a hover dropdown with "🛠 Admin" + "Cerrar sesion"
- Admin visible in Sandbox toolbar, Workshop nav, and user dropdown
- onSwitchToAdmin passed through navigation chain

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 21:05:36 +01:00
parent f43a315047
commit 12569dba76
8 changed files with 374 additions and 8 deletions

View File

@@ -54,6 +54,23 @@ export const likes = pgTable('likes', {
pk: primaryKey({ columns: [table.userId, table.patchId] }),
}));
export const customLevels = pgTable('custom_levels', {
id: uuid('id').defaultRandom().primaryKey(),
worldId: varchar('world_id', { length: 20 }).notNull(),
levelId: varchar('level_id', { length: 50 }).unique().notNull(),
title: varchar('title', { length: 200 }).notNull(),
subtitle: varchar('subtitle', { length: 200 }),
description: text('description'),
concept: text('concept'),
availableModules: text('available_modules').array(),
preplacedData: jsonb('preplaced_data'), // imported from sandbox export
targetData: jsonb('target_data'),
sortOrder: integer('sort_order').default(0),
isBoss: boolean('is_boss').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const refreshTokens = pgTable('refresh_tokens', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),

View File

@@ -13,6 +13,7 @@ import userRoutes from './routes/users.js';
import adminRoutes from './routes/admin.js';
import syncRoutes from './routes/sync.js';
import workshopRoutes from './routes/workshop.js';
import levelRoutes from './routes/levels.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3001;
@@ -43,6 +44,7 @@ await fastify.register(userRoutes, { prefix: '/api/v1/users' });
await fastify.register(adminRoutes, { prefix: '/api/v1/admin' });
await fastify.register(syncRoutes, { prefix: '/api/v1/sync' });
await fastify.register(workshopRoutes, { prefix: '/api/v1/workshop' });
await fastify.register(levelRoutes, { prefix: '/api/v1/admin/levels' });
// Rate limit auth endpoints more aggressively
fastify.addHook('onRoute', (routeOptions) => {

View File

@@ -0,0 +1,95 @@
import { eq, asc } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { requireAdmin } from '../middleware/auth.js';
export default async function levelRoutes(fastify) {
// List all custom levels
fastify.get('/', { preHandler: [requireAdmin] }, async () => {
const levels = await db.select().from(schema.customLevels)
.orderBy(asc(schema.customLevels.worldId), asc(schema.customLevels.sortOrder));
return { levels };
});
// Create a new level (import from sandbox JSON)
fastify.post('/', { preHandler: [requireAdmin] }, async (request, reply) => {
const { worldId, levelId, title, subtitle, description, concept,
availableModules, preplacedData, targetData, sortOrder, isBoss } = request.body;
if (!worldId || !levelId || !title) {
return reply.code(400).send({ error: 'worldId, levelId and title required' });
}
// Check for duplicate levelId
const [existing] = await db.select().from(schema.customLevels)
.where(eq(schema.customLevels.levelId, levelId)).limit(1);
if (existing) {
return reply.code(409).send({ error: `Level ${levelId} already exists` });
}
const [level] = await db.insert(schema.customLevels).values({
worldId,
levelId,
title,
subtitle: subtitle || '',
description: description || '',
concept: concept || '',
availableModules: availableModules || [],
preplacedData: preplacedData || null,
targetData: targetData || null,
sortOrder: sortOrder || 0,
isBoss: isBoss || false,
}).returning();
return level;
});
// Update a level
fastify.patch('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
const updates = { ...request.body, updatedAt: new Date() };
delete updates.id;
delete updates.createdAt;
const [level] = await db.update(schema.customLevels)
.set(updates)
.where(eq(schema.customLevels.id, request.params.id))
.returning();
if (!level) return reply.code(404).send({ error: 'Level not found' });
return level;
});
// Delete a level
fastify.delete('/:id', { preHandler: [requireAdmin] }, async (request) => {
await db.delete(schema.customLevels)
.where(eq(schema.customLevels.id, request.params.id));
return { ok: true };
});
// Import preplaced modules from sandbox JSON export
fastify.post('/:id/import-patch', { preHandler: [requireAdmin] }, async (request, reply) => {
const { modules, connections } = request.body;
if (!modules) return reply.code(400).send({ error: 'modules required' });
// Convert sandbox export to preplacedModules format
const preplacedData = {
modules: modules.map(m => ({
id: m.id,
type: m.type,
x: m.x,
y: m.y,
params: m.params || {},
locked: true,
})),
connections: connections || [],
};
const [level] = await db.update(schema.customLevels)
.set({ preplacedData, updatedAt: new Date() })
.where(eq(schema.customLevels.id, request.params.id))
.returning();
if (!level) return reply.code(404).send({ error: 'Level not found' });
return level;
});
}