feat: initial commit web-cad-neu with docker-compose, frontend and backend
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* AI Routes – KI Copilot chat endpoint via OpenRouter
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
const MODEL = 'anthropic/claude-sonnet-4.5';
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
function extractToken(request: any): string | null {
|
||||
const auth = request.headers?.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) {
|
||||
return auth.slice(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerAIRoutes(fastify: FastifyInstance, authService: AuthService) {
|
||||
fastify.post('/api/ai/chat', async (request, reply) => {
|
||||
// Auth check
|
||||
const token = extractToken(request);
|
||||
if (!token) {
|
||||
return reply.code(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
}
|
||||
|
||||
const { messages, context } = request.body as {
|
||||
messages?: Array<{ role: string; content: string }>;
|
||||
context?: {
|
||||
projectName?: string;
|
||||
elementCount?: number;
|
||||
layerCount?: number;
|
||||
elementTypeSummary?: Record<string, number>;
|
||||
};
|
||||
};
|
||||
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return reply.code(400).send({ error: 'Messages array is required' });
|
||||
}
|
||||
|
||||
const apiKey = process.env.API_KEY_OPENROUTER;
|
||||
if (!apiKey) {
|
||||
return reply.code(503).send({ error: 'AI service not configured' });
|
||||
}
|
||||
|
||||
// Build system prompt with CAD context
|
||||
const ctxParts: string[] = [
|
||||
'Du bist der KI Copilot für Web CAD, eine webbasierte CAD-Anwendung für Event- und Raumplanung.',
|
||||
'Du hilfst beim Anlegen von Bestuhlung, Räumen, Bühnen und anderen CAD-Elementen.',
|
||||
'Antworte auf Deutsch, klar und präzise. Verwende Markdown für Formatierung wenn sinnvoll.',
|
||||
];
|
||||
|
||||
if (context) {
|
||||
if (context.projectName) ctxParts.push(`Aktuelles Projekt: ${context.projectName}`);
|
||||
if (context.elementCount !== undefined) ctxParts.push(`Elemente im Projekt: ${context.elementCount}`);
|
||||
if (context.layerCount !== undefined) ctxParts.push(`Ebenen: ${context.layerCount}`);
|
||||
if (context.elementTypeSummary) {
|
||||
const summary = Object.entries(context.elementTypeSummary)
|
||||
.map(([type, count]) => `${type}: ${count}`)
|
||||
.join(', ');
|
||||
ctxParts.push(`Element-Typen: ${summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = ctxParts.join('\n');
|
||||
|
||||
// Build OpenRouter request
|
||||
const payload = {
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
],
|
||||
max_tokens: 1024,
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(OPENROUTER_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'HTTP-Referer': 'http://localhost:5173',
|
||||
'X-Title': 'Web CAD KI Copilot',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
request.log.error({ errText, status: response.status }, 'OpenRouter error');
|
||||
return reply.code(502).send({ error: 'AI service returned an error' });
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
const content = data.choices?.[0]?.message?.content ?? '';
|
||||
|
||||
if (!content) {
|
||||
return reply.code(502).send({ error: 'AI service returned empty response' });
|
||||
}
|
||||
|
||||
// Extract suggestions from response if present (simple heuristic)
|
||||
const suggestions: string[] = [];
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^[-*]\s*(.+)/);
|
||||
if (match && suggestions.length < 3) {
|
||||
suggestions.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ content, suggestions: suggestions.length > 0 ? suggestions : undefined });
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
return reply.code(504).send({ error: 'AI service timeout' });
|
||||
}
|
||||
request.log.error({ err }, 'AI route error');
|
||||
return reply.code(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Auth Routes – Registration, Login, Logout, Profile
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
|
||||
export function registerAuthRoutes(fastify: FastifyInstance, authService: AuthService) {
|
||||
// Register new user
|
||||
fastify.post('/api/auth/register', async (request, reply) => {
|
||||
const { email, password, name, role } = request.body as {
|
||||
email?: string; password?: string; name?: string; role?: string;
|
||||
};
|
||||
if (!email || !password || !name) {
|
||||
return reply.code(400).send({ error: 'Email, password and name are required' });
|
||||
}
|
||||
try {
|
||||
const result = await authService.register(email, password, name, role);
|
||||
return reply.code(201).send(result);
|
||||
} catch (err: any) { return reply.code(409).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
fastify.post('/api/auth/login', async (request, reply) => {
|
||||
const { email, password } = request.body as { email?: string; password?: string };
|
||||
if (!email || !password) {
|
||||
return reply.code(400).send({ error: 'Email and password are required' });
|
||||
}
|
||||
try {
|
||||
const result = await authService.login(email, password);
|
||||
return reply.send(result);
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
fastify.post('/api/auth/logout', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (token) {
|
||||
authService.destroySession(token);
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// Get current user profile
|
||||
fastify.get('/api/auth/me', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Not authenticated' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
const { password_hash, ...safe } = user;
|
||||
return safe;
|
||||
});
|
||||
|
||||
// Change password
|
||||
fastify.patch('/api/auth/password', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Not authenticated' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
|
||||
const { oldPassword, newPassword } = request.body as { oldPassword?: string; newPassword?: string };
|
||||
if (!oldPassword || !newPassword) {
|
||||
return reply.code(400).send({ error: 'oldPassword and newPassword are required' });
|
||||
}
|
||||
try {
|
||||
await authService.changePassword(user.id, oldPassword, newPassword);
|
||||
return reply.code(204).send();
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function extractToken(request: any): string | null {
|
||||
const auth = request.headers?.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) {
|
||||
return auth.slice(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Blocks Routes – CRUD for reusable drawing blocks
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBBlock } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List all blocks for a drawing
|
||||
fastify.get('/api/drawings/:drawingId/blocks', async (request) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
return db.listBlocks(drawingId);
|
||||
});
|
||||
|
||||
// Create a new block
|
||||
fastify.post('/api/drawings/:drawingId/blocks', async (request, reply) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
const body = request.body as Partial<DBBlock>;
|
||||
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
|
||||
return reply.code(201).send(db.createBlock({ ...body, drawing_id: drawingId }));
|
||||
});
|
||||
|
||||
// Update a block
|
||||
fastify.patch('/api/blocks/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBBlock>;
|
||||
const updated = db.updateBlock(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Block not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete a block
|
||||
fastify.delete('/api/blocks/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteBlock(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Block not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Drawings Routes – CRUD for drawings
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBDrawing } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List drawings for a project
|
||||
fastify.get('/api/projects/:projectId/drawings', async (request) => {
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
return db.listDrawings(projectId);
|
||||
});
|
||||
|
||||
// Get single drawing
|
||||
fastify.get('/api/drawings/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const drawing = db.getDrawing(id);
|
||||
if (!drawing) return reply.code(404).send({ error: 'Drawing not found' });
|
||||
return drawing;
|
||||
});
|
||||
|
||||
// Create drawing
|
||||
fastify.post('/api/projects/:projectId/drawings', async (request, reply) => {
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
const body = request.body as Partial<DBDrawing>;
|
||||
return reply.code(201).send(db.createDrawing({ ...body, project_id: projectId }));
|
||||
});
|
||||
|
||||
// Update drawing
|
||||
fastify.patch('/api/drawings/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBDrawing>;
|
||||
const updated = db.updateDrawing(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Drawing not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete drawing
|
||||
fastify.delete('/api/drawings/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteDrawing(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Drawing not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Elements Routes – CRUD for elements
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBElement } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List elements for a drawing
|
||||
fastify.get('/api/drawings/:drawingId/elements', async (request) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
return db.listElements(drawingId);
|
||||
});
|
||||
|
||||
// Create element
|
||||
fastify.post('/api/drawings/:drawingId/elements', async (request, reply) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
const body = request.body as Partial<DBElement>;
|
||||
return reply.code(201).send(db.createElement({ ...body, drawing_id: drawingId }));
|
||||
});
|
||||
|
||||
// Update element
|
||||
fastify.patch('/api/elements/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBElement>;
|
||||
const updated = db.updateElement(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Element not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete element
|
||||
fastify.delete('/api/elements/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteElement(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Element not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Layers Routes – CRUD for layers
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBLayer } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List layers for a drawing
|
||||
fastify.get('/api/drawings/:drawingId/layers', async (request) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
return db.listLayers(drawingId);
|
||||
});
|
||||
|
||||
// Create layer
|
||||
fastify.post('/api/drawings/:drawingId/layers', async (request, reply) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
const body = request.body as Partial<DBLayer>;
|
||||
return reply.code(201).send(db.createLayer({ ...body, drawing_id: drawingId }));
|
||||
});
|
||||
|
||||
// Update layer
|
||||
fastify.patch('/api/layers/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBLayer>;
|
||||
const updated = db.updateLayer(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Layer not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete layer
|
||||
fastify.delete('/api/layers/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteLayer(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Layer not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Projects Routes – CRUD for projects
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBProject } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List all projects
|
||||
fastify.get('/api/projects', async () => {
|
||||
return db.listProjects();
|
||||
});
|
||||
|
||||
// Get single project
|
||||
fastify.get('/api/projects/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const project = db.getProject(id);
|
||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
||||
return project;
|
||||
});
|
||||
|
||||
// Create project
|
||||
fastify.post('/api/projects', async (request, reply) => {
|
||||
const body = request.body as Partial<DBProject>;
|
||||
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
|
||||
return reply.code(201).send(db.createProject(body));
|
||||
});
|
||||
|
||||
// Update project
|
||||
fastify.patch('/api/projects/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBProject>;
|
||||
const updated = db.updateProject(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Project not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete project
|
||||
fastify.delete('/api/projects/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteProject(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Project not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Settings Routes – key/value application settings
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// Get a single setting by key
|
||||
fastify.get('/api/settings/:key', async (request, reply) => {
|
||||
const { key } = request.params as { key: string };
|
||||
const setting = db.getSetting(key);
|
||||
if (!setting) return reply.code(404).send({ error: 'Setting not found' });
|
||||
return setting;
|
||||
});
|
||||
|
||||
// Create or update a setting (upsert)
|
||||
fastify.put('/api/settings/:key', async (request) => {
|
||||
const { key } = request.params as { key: string };
|
||||
const body = request.body as { value?: string };
|
||||
if (body.value === undefined) {
|
||||
return { error: 'Value is required' };
|
||||
}
|
||||
db.setSetting(key, body.value);
|
||||
return { key, value: body.value, updated_at: new Date().toISOString() };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Users Routes – Admin user management
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
|
||||
export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||
// List all users (admin only — TODO: add role check middleware)
|
||||
fastify.get('/api/users', async () => {
|
||||
const users = db.listUsers();
|
||||
return users.map(({ password_hash, ...safe }) => safe);
|
||||
});
|
||||
|
||||
// Get single user
|
||||
fastify.get('/api/users/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const user = db.getUser(id);
|
||||
if (!user) return reply.code(404).send({ error: 'User not found' });
|
||||
const { password_hash, ...safe } = user;
|
||||
return safe;
|
||||
});
|
||||
|
||||
// Update user (name, role, email)
|
||||
fastify.patch('/api/users/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBUser>;
|
||||
// Prevent password update via this endpoint
|
||||
delete body.password_hash;
|
||||
const updated = db.updateUser(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'User not found' });
|
||||
const { password_hash, ...safe } = updated;
|
||||
return safe;
|
||||
});
|
||||
|
||||
// Delete user
|
||||
fastify.delete('/api/users/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteUser(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'User not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user