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:
@@ -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(),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
95
packages/server/src/routes/levels.js
Normal file
95
packages/server/src/routes/levels.js
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user