feat: initial commit web-cad-neu with docker-compose, frontend and backend

This commit is contained in:
2026-06-26 10:50:24 +02:00
commit 4ec76fe406
102 changed files with 25722 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
*.md
*.log
+9
View File
@@ -0,0 +1,9 @@
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache wget
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3001
CMD ["node", "dist/index.js"]
+3888
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "web-cad-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"postbuild": "cp src/database/schema.sql dist/database/schema.sql",
"start": "node dist/index.js",
"test": "vitest run"
},
"dependencies": {
"@fastify/cors": "^9.0.0",
"@fastify/static": "^7.0.0",
"@fastify/websocket": "^10.0.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^11.0.0",
"fastify": "^4.28.0",
"y-leveldb": "^0.2.0",
"yjs": "^13.6.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^20.14.0",
"tsx": "^4.16.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* AuthService Registration, Login, Session Management
*/
import bcrypt from 'bcrypt';
import { randomUUID } from 'crypto';
import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js';
const SALT_ROUNDS = 10;
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
export interface Session {
token: string;
userId: string;
expiresAt: number;
}
export interface AuthResult {
user: Omit<DBUser, 'password_hash'>;
session: Session;
}
export class AuthService {
private sessions = new Map<string, Session>();
constructor(private db: DatabaseInterface) {}
async register(email: string, password: string, name: string, role: string = 'planer'): Promise<AuthResult> {
const existing = this.db.getUserByEmail(email);
if (existing) {
throw new Error('Email already registered');
}
const password_hash = await bcrypt.hash(password, SALT_ROUNDS);
const user = this.db.createUser({ email, password_hash, name, role });
const session = this.createSession(user.id);
return { user: this.sanitizeUser(user), session };
}
async login(email: string, password: string): Promise<AuthResult> {
const user = this.db.getUserByEmail(email);
if (!user) {
throw new Error('Invalid credentials');
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
throw new Error('Invalid credentials');
}
const session = this.createSession(user.id);
return { user: this.sanitizeUser(user), session };
}
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
const user = this.db.getUser(userId);
if (!user) throw new Error('User not found');
const valid = await bcrypt.compare(oldPassword, user.password_hash);
if (!valid) throw new Error('Invalid current password');
const password_hash = await bcrypt.hash(newPassword, SALT_ROUNDS);
this.db.updateUser(userId, { password_hash });
}
createSession(userId: string): Session {
const token = randomUUID();
const session: Session = {
token,
userId,
expiresAt: Date.now() + SESSION_TTL_MS,
};
this.sessions.set(token, session);
return session;
}
validateSession(token: string): Session | null {
const session = this.sessions.get(token);
if (!session) return null;
if (Date.now() > session.expiresAt) {
this.sessions.delete(token);
return null;
}
return session;
}
destroySession(token: string): void {
this.sessions.delete(token);
}
getUserFromSession(token: string): DBUser | null {
const session = this.validateSession(token);
if (!session) return null;
return this.db.getUser(session.userId);
}
private sanitizeUser(user: DBUser): Omit<DBUser, 'password_hash'> {
const { password_hash, ...safe } = user;
return safe;
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Database Interface abstract DB layer for Web CAD
*/
export interface DBProject {
id: string;
name: string;
description: string | null;
owner_id: string;
created_at: string;
updated_at: string;
}
export interface DBDrawing {
id: string;
project_id: string;
name: string;
data_json: string;
created_at: string;
updated_at: string;
}
export interface DBLayer {
id: string;
drawing_id: string;
name: string;
visible: number;
locked: number;
color: string;
line_type: string;
transparency: number;
sort_order: number;
parent_id: string | null;
}
export interface DBElement {
id: string;
drawing_id: string;
layer_id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
properties_json: string;
created_at: string;
}
export interface DBBlock {
id: string;
drawing_id: string;
name: string;
description: string | null;
category: string;
elements_json: string;
thumbnail: string | null;
}
export interface DBSetting {
key: string;
value: string;
updated_at: string;
}
export interface DBUser {
id: string;
email: string;
password_hash: string;
name: string;
role: string;
created_at: string;
updated_at: string;
}
export interface DatabaseInterface {
init(): Promise<void>;
close(): void;
// Users
getUser(id: string): DBUser | null;
getUserByEmail(email: string): DBUser | null;
createUser(data: Partial<DBUser>): DBUser;
updateUser(id: string, data: Partial<DBUser>): DBUser | null;
deleteUser(id: string): boolean;
listUsers(): DBUser[];
// Projects
listProjects(): DBProject[];
getProject(id: string): DBProject | null;
createProject(data: Partial<DBProject>): DBProject;
updateProject(id: string, data: Partial<DBProject>): DBProject | null;
deleteProject(id: string): boolean;
// Drawings
listDrawings(projectId: string): DBDrawing[];
getDrawing(id: string): DBDrawing | null;
createDrawing(data: Partial<DBDrawing>): DBDrawing;
updateDrawing(id: string, data: Partial<DBDrawing>): DBDrawing | null;
deleteDrawing(id: string): boolean;
// Layers
listLayers(drawingId: string): DBLayer[];
createLayer(data: Partial<DBLayer>): DBLayer;
updateLayer(id: string, data: Partial<DBLayer>): DBLayer | null;
deleteLayer(id: string): boolean;
// Elements
listElements(drawingId: string): DBElement[];
createElement(data: Partial<DBElement>): DBElement;
updateElement(id: string, data: Partial<DBElement>): DBElement | null;
deleteElement(id: string): boolean;
// Blocks
listBlocks(drawingId: string): DBBlock[];
createBlock(data: Partial<DBBlock>): DBBlock;
updateBlock(id: string, data: Partial<DBBlock>): DBBlock | null;
deleteBlock(id: string): boolean;
// Settings
getSetting(key: string): DBSetting | null;
setSetting(key: string, value: string): void;
}
+246
View File
@@ -0,0 +1,246 @@
/**
* SQLite Adapter implements DatabaseInterface with better-sqlite3
*/
import Database from 'better-sqlite3';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import type {
DatabaseInterface, DBProject, DBDrawing, DBLayer,
DBElement, DBBlock, DBSetting, DBUser,
} from './DatabaseInterface.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
export class SqliteAdapter implements DatabaseInterface {
private db: Database.Database;
constructor(dbPath: string = ':memory:') {
this.db = new Database(dbPath);
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
}
async init(): Promise<void> {
const schemaPath = join(__dirname, 'schema.sql');
const schema = readFileSync(schemaPath, 'utf-8');
this.db.exec(schema);
}
close(): void {
this.db.close();
}
// ─── Users ──────────────────────────────────────────
getUser(id: string): DBUser | null {
return (this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DBUser) ?? null;
}
getUserByEmail(email: string): DBUser | null {
return (this.db.prepare('SELECT * FROM users WHERE email = ?').get(email) as DBUser) ?? null;
}
createUser(data: Partial<DBUser>): DBUser {
const id = data.id ?? `user-${Date.now()}`;
this.db.prepare(
'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)',
).run(id, data.email!, data.password_hash!, data.name!, data.role ?? 'planer');
return this.getUser(id)!;
}
updateUser(id: string, data: Partial<DBUser>): DBUser | null {
const sets: string[] = [];
const vals: any[] = [];
if (data.email !== undefined) { sets.push('email = ?'); vals.push(data.email); }
if (data.password_hash !== undefined) { sets.push('password_hash = ?'); vals.push(data.password_hash); }
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.role !== undefined) { sets.push('role = ?'); vals.push(data.role); }
sets.push("updated_at = datetime('now')");
if (sets.length > 1) {
this.db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.getUser(id);
}
deleteUser(id: string): boolean {
return this.db.prepare('DELETE FROM users WHERE id = ?').run(id).changes > 0;
}
listUsers(): DBUser[] {
return this.db.prepare('SELECT * FROM users ORDER BY created_at DESC').all() as DBUser[];
}
// ─── Projects ──────────────────────────────────────
listProjects(): DBProject[] {
return this.db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as DBProject[];
}
getProject(id: string): DBProject | null {
return (this.db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as DBProject) ?? null;
}
createProject(data: Partial<DBProject>): DBProject {
const id = data.id ?? `proj-${Date.now()}`;
this.db.prepare(
'INSERT INTO projects (id, name, description, owner_id) VALUES (?, ?, ?, ?)',
).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default');
return this.getProject(id)!;
}
updateProject(id: string, data: Partial<DBProject>): DBProject | null {
const sets: string[] = [];
const vals: any[] = [];
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.description !== undefined) { sets.push('description = ?'); vals.push(data.description); }
sets.push("updated_at = datetime('now')");
if (sets.length > 1) {
this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.getProject(id);
}
deleteProject(id: string): boolean {
return this.db.prepare('DELETE FROM projects WHERE id = ?').run(id).changes > 0;
}
// ─── Drawings ──────────────────────────────────────
listDrawings(projectId: string): DBDrawing[] {
return this.db.prepare('SELECT * FROM drawings WHERE project_id = ? ORDER BY updated_at DESC').all(projectId) as DBDrawing[];
}
getDrawing(id: string): DBDrawing | null {
return (this.db.prepare('SELECT * FROM drawings WHERE id = ?').get(id) as DBDrawing) ?? null;
}
createDrawing(data: Partial<DBDrawing>): DBDrawing {
const id = data.id ?? `draw-${Date.now()}`;
this.db.prepare(
'INSERT INTO drawings (id, project_id, name, data_json) VALUES (?, ?, ?, ?)',
).run(id, data.project_id!, data.name ?? 'Unbenannt', data.data_json ?? '{}');
return this.getDrawing(id)!;
}
updateDrawing(id: string, data: Partial<DBDrawing>): DBDrawing | null {
const sets: string[] = [];
const vals: any[] = [];
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.data_json !== undefined) { sets.push('data_json = ?'); vals.push(data.data_json); }
sets.push("updated_at = datetime('now')");
if (sets.length > 1) {
this.db.prepare(`UPDATE drawings SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.getDrawing(id);
}
deleteDrawing(id: string): boolean {
return this.db.prepare('DELETE FROM drawings WHERE id = ?').run(id).changes > 0;
}
// ─── Layers ────────────────────────────────────────
listLayers(drawingId: string): DBLayer[] {
return this.db.prepare('SELECT * FROM layers WHERE drawing_id = ? ORDER BY sort_order').all(drawingId) as DBLayer[];
}
createLayer(data: Partial<DBLayer>): DBLayer {
const id = data.id ?? `layer-${Date.now()}`;
this.db.prepare(
'INSERT INTO layers (id, drawing_id, name, visible, locked, color, line_type, transparency, sort_order, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(id, data.drawing_id!, data.name ?? 'Layer', data.visible ?? 1, data.locked ?? 0,
data.color ?? '#ffffff', data.line_type ?? 'solid', data.transparency ?? 0,
data.sort_order ?? 0, data.parent_id ?? null);
return this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer;
}
updateLayer(id: string, data: Partial<DBLayer>): DBLayer | null {
const sets: string[] = [];
const vals: any[] = [];
for (const [k, v] of Object.entries(data)) {
if (['name','visible','locked','color','line_type','transparency','sort_order','parent_id'].includes(k)) {
sets.push(`${k} = ?`); vals.push(v);
}
}
if (sets.length > 0) {
this.db.prepare(`UPDATE layers SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer ?? null;
}
deleteLayer(id: string): boolean {
return this.db.prepare('DELETE FROM layers WHERE id = ?').run(id).changes > 0;
}
// ─── Elements ──────────────────────────────────────
listElements(drawingId: string): DBElement[] {
return this.db.prepare('SELECT * FROM elements WHERE drawing_id = ?').all(drawingId) as DBElement[];
}
createElement(data: Partial<DBElement>): DBElement {
const id = data.id ?? `elem-${Date.now()}`;
this.db.prepare(
'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(id, data.drawing_id!, data.layer_id!, data.type!, data.x ?? 0, data.y ?? 0,
data.width ?? 0, data.height ?? 0, data.properties_json ?? '{}');
return this.db.prepare('SELECT * FROM elements WHERE id = ?').get(id) as DBElement;
}
updateElement(id: string, data: Partial<DBElement>): DBElement | null {
const sets: string[] = [];
const vals: any[] = [];
for (const [k, v] of Object.entries(data)) {
if (['layer_id','type','x','y','width','height','properties_json'].includes(k)) {
sets.push(`${k} = ?`); vals.push(v);
}
}
if (sets.length > 0) {
this.db.prepare(`UPDATE elements SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.db.prepare('SELECT * FROM elements WHERE id = ?').get(id) as DBElement ?? null;
}
deleteElement(id: string): boolean {
return this.db.prepare('DELETE FROM elements WHERE id = ?').run(id).changes > 0;
}
// ─── Blocks ────────────────────────────────────────
listBlocks(drawingId: string): DBBlock[] {
return this.db.prepare('SELECT * FROM blocks WHERE drawing_id = ?').all(drawingId) as DBBlock[];
}
createBlock(data: Partial<DBBlock>): DBBlock {
const id = data.id ?? `block-${Date.now()}`;
this.db.prepare(
'INSERT INTO blocks (id, drawing_id, name, description, category, elements_json, thumbnail) VALUES (?, ?, ?, ?, ?, ?, ?)',
).run(id, data.drawing_id!, data.name ?? 'Block', data.description ?? null,
data.category ?? 'Allgemein', data.elements_json ?? '[]', data.thumbnail ?? null);
return this.db.prepare('SELECT * FROM blocks WHERE id = ?').get(id) as DBBlock;
}
updateBlock(id: string, data: Partial<DBBlock>): DBBlock | null {
const sets: string[] = [];
const vals: any[] = [];
for (const [k, v] of Object.entries(data)) {
if (['name','description','category','elements_json','thumbnail'].includes(k)) {
sets.push(`${k} = ?`); vals.push(v);
}
}
if (sets.length > 0) {
this.db.prepare(`UPDATE blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.db.prepare('SELECT * FROM blocks WHERE id = ?').get(id) as DBBlock ?? null;
}
deleteBlock(id: string): boolean {
return this.db.prepare('DELETE FROM blocks WHERE id = ?').run(id).changes > 0;
}
// ─── Settings ──────────────────────────────────────
getSetting(key: string): DBSetting | null {
return (this.db.prepare('SELECT * FROM settings WHERE key = ?').get(key) as DBSetting) ?? null;
}
setSetting(key: string, value: string): void {
this.db.prepare(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')",
).run(key, value);
}
}
+98
View File
@@ -0,0 +1,98 @@
-- Web CAD Backend SQLite Schema
-- Version 1.0
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- ─── Users ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'planer' CHECK(role IN ('admin','planer','betrachter','gast')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Default User (seed) ────────────────────────────
INSERT OR IGNORE INTO users (id, email, password_hash, name, role) VALUES ('user-default', 'default@webcad.local', '', 'Default User', 'admin');
-- ─── Projects ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
owner_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
);
-- ─── Drawings ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS drawings (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
name TEXT NOT NULL,
data_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
-- ─── Layers ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS layers (
id TEXT PRIMARY KEY,
drawing_id TEXT NOT NULL,
name TEXT NOT NULL,
visible INTEGER NOT NULL DEFAULT 1,
locked INTEGER NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT '#ffffff',
line_type TEXT NOT NULL DEFAULT 'solid',
transparency REAL NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
parent_id TEXT,
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
);
-- ─── Elements ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS elements (
id TEXT PRIMARY KEY,
drawing_id TEXT NOT NULL,
layer_id TEXT NOT NULL,
type TEXT NOT NULL,
x REAL NOT NULL,
y REAL NOT NULL,
width REAL NOT NULL,
height REAL NOT NULL,
properties_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE,
FOREIGN KEY (layer_id) REFERENCES layers(id) ON DELETE CASCADE
);
-- ─── Block Definitions ──────────────────────────────
CREATE TABLE IF NOT EXISTS blocks (
id TEXT PRIMARY KEY,
drawing_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'Allgemein',
elements_json TEXT NOT NULL DEFAULT '[]',
thumbnail TEXT,
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
);
-- ─── Settings ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Indexes ────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_drawings_project ON drawings(project_id);
CREATE INDEX IF NOT EXISTS idx_layers_drawing ON layers(drawing_id);
CREATE INDEX IF NOT EXISTS idx_elements_drawing ON elements(drawing_id);
CREATE INDEX IF NOT EXISTS idx_elements_layer ON elements(layer_id);
CREATE INDEX IF NOT EXISTS idx_blocks_drawing ON blocks(drawing_id);
+40
View File
@@ -0,0 +1,40 @@
/**
* Web CAD Backend Entry Point
*/
import { createServer } from './server.js';
import { SqliteAdapter } from './database/SqliteAdapter.js';
import { initYjsPersistence, closeYjsPersistence } from './websocket/yjsServer.js';
const PORT = parseInt(process.env.PORT || '3001', 10);
const HOST = process.env.HOST || '0.0.0.0';
const DB_PATH = process.env.DB_PATH || '/data/web-cad.db';
async function main() {
const db = new SqliteAdapter(DB_PATH);
await db.init();
await initYjsPersistence();
const fastify = await createServer({ port: PORT, host: HOST, db });
try {
await fastify.listen({ port: PORT, host: HOST });
console.log(`Web CAD Backend running on http://${HOST}:${PORT}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
// Graceful shutdown
process.on('SIGTERM', async () => {
await fastify.close();
db.close();
process.exit(0);
});
process.on('SIGINT', async () => {
await fastify.close();
db.close();
process.exit(0);
});
}
main();
+132
View File
@@ -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' });
}
});
}
+82
View File
@@ -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;
}
+38
View File
@@ -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();
});
}
+45
View File
@@ -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();
});
}
+37
View File
@@ -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();
});
}
+37
View File
@@ -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();
});
}
+44
View File
@@ -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();
});
}
+26
View File
@@ -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() };
});
}
+43
View File
@@ -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();
});
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Server Fastify configuration with CORS, static, and WebSocket
*/
import Fastify from 'fastify';
import cors from '@fastify/cors';
import staticPlugin from '@fastify/static';
import websocket from '@fastify/websocket';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import type { DatabaseInterface } from './database/DatabaseInterface.js';
import { AuthService } from './auth/AuthService.js';
import { registerYjsWebSocket, initYjsPersistence, closeYjsPersistence } from './websocket/yjsServer.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
export interface ServerOptions {
port?: number;
host?: string;
db: DatabaseInterface;
}
export async function createServer(opts: ServerOptions) {
const fastify = Fastify({ logger: true });
// CORS
await fastify.register(cors, {
origin: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
});
// WebSocket
await fastify.register(websocket, {
options: { maxPayload: 1048576 },
});
// Static files (serve frontend build if exists)
const frontendDist = join(__dirname, '..', '..', 'frontend', 'dist');
try {
await fastify.register(staticPlugin, {
root: frontendDist,
prefix: '/',
});
} catch {
// Frontend dist may not exist in dev mode
}
// Health check
fastify.get('/api/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));
// Register route modules
const { registerProjectRoutes } = await import('./routes/projects.js');
const { registerDrawingRoutes } = await import('./routes/drawings.js');
const { registerLayerRoutes } = await import('./routes/layers.js');
const { registerElementRoutes } = await import('./routes/elements.js');
const { registerBlockRoutes } = await import('./routes/blocks.js');
const { registerSettingsRoutes } = await import('./routes/settings.js');
const { registerAuthRoutes } = await import('./routes/auth.js');
const { registerUserRoutes } = await import('./routes/users.js');
const { registerAIRoutes } = await import('./routes/ai.js');
const authService = new AuthService(opts.db);
registerAuthRoutes(fastify, authService);
registerProjectRoutes(fastify, opts.db);
registerDrawingRoutes(fastify, opts.db);
registerLayerRoutes(fastify, opts.db);
registerElementRoutes(fastify, opts.db);
registerBlockRoutes(fastify, opts.db);
registerSettingsRoutes(fastify, opts.db);
registerUserRoutes(fastify, opts.db, authService);
registerAIRoutes(fastify, authService);
// Yjs collaboration WebSocket
registerYjsWebSocket(fastify);
return fastify;
}
+11
View File
@@ -0,0 +1,11 @@
declare module 'y-leveldb' {
import type * as Y from 'yjs';
export class LeveldbPersistence {
constructor(dbPath: string, opts?: Record<string, unknown>);
getYDoc(name: string): Promise<Y.Doc>;
storeUpdate(name: string, update: Uint8Array): Promise<number>;
destroy(): Promise<void>;
clearDocument(name: string): Promise<void>;
}
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Yjs WebSocket Server Real-time collaboration with LevelDB persistence
*/
import * as Y from 'yjs';
import { LeveldbPersistence } from 'y-leveldb';
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from '@fastify/websocket';
const PERSISTENCE_DIR = process.env.YJS_PERSISTENCE_DIR || '/tmp/yjs-documents';
// Document store: docName → Y.Doc
const docs = new Map<string, Y.Doc>();
// Connection tracking: docName → Set<WebSocket>
const connections = new Map<string, Set<WebSocket>>();
let persistence: LeveldbPersistence | null = null;
let persistenceReady: Promise<void> | null = null;
export async function initYjsPersistence(): Promise<void> {
try {
persistence = new LeveldbPersistence(PERSISTENCE_DIR);
console.log(`Yjs persistence initialized at ${PERSISTENCE_DIR}`);
} catch (err) {
console.warn('Yjs persistence failed to init (non-fatal):', err);
}
}
export async function closeYjsPersistence(): Promise<void> {
if (persistence) {
await persistence.destroy();
persistence = null;
}
}
async function getOrCreateDoc(docName: string): Promise<Y.Doc> {
if (docs.has(docName)) {
return docs.get(docName)!;
}
const doc = new Y.Doc();
docs.set(docName, doc);
// Load persisted state if available
if (persistence && persistenceReady) {
try {
await persistenceReady;
const persistedYdoc = await persistence.getYDoc(docName);
const update = Y.encodeStateAsUpdate(persistedYdoc);
if (update.length > 0) {
Y.applyUpdate(doc, update);
}
} catch (err) {
console.warn(`Failed to load doc ${docName}:`, err);
}
}
// Persist updates to LevelDB
doc.on('update', (update: Uint8Array) => {
if (persistence) {
persistence.storeUpdate(docName, update).catch((err: any) => {
console.warn(`Failed to persist update for ${docName}:`, err);
});
}
});
return doc;
}
function broadcastUpdate(docName: string, update: Uint8Array, exclude?: WebSocket): void {
const conns = connections.get(docName);
if (!conns) return;
for (const ws of conns) {
if (ws !== exclude && ws.readyState === 1 /* OPEN */) {
try {
ws.send(update);
} catch {
// Connection error — will be cleaned up on close
}
}
}
}
export function registerYjsWebSocket(fastify: FastifyInstance): void {
fastify.get('/ws/collab/:docName', { websocket: true }, async (socket: WebSocket, request: any) => {
const docName = request.params.docName as string;
if (!docName) {
socket.close(4000, 'Missing docName');
return;
}
const doc = await getOrCreateDoc(docName);
// Track connection
if (!connections.has(docName)) {
connections.set(docName, new Set());
}
connections.get(docName)!.add(socket);
// Send current document state to new client
const stateUpdate = Y.encodeStateAsUpdate(doc);
if (stateUpdate.length > 0 && socket.readyState === 1) {
socket.send(stateUpdate);
}
// Listen for updates from this client
socket.on('message', (data: Buffer) => {
try {
const update = new Uint8Array(data);
Y.applyUpdate(doc, update);
broadcastUpdate(docName, update, socket);
} catch (err) {
console.warn(`Invalid update from client for ${docName}:`, err);
}
});
// Clean up on disconnect
socket.on('close', () => {
const conns = connections.get(docName);
if (conns) {
conns.delete(socket);
if (conns.size === 0) {
connections.delete(docName);
// Optionally keep doc in memory for a while
// For now, remove it to free memory
docs.delete(docName);
}
}
});
socket.on('error', () => {
const conns = connections.get(docName);
if (conns) {
conns.delete(socket);
}
});
});
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}