feat: initial commit web-cad-neu with docker-compose, frontend and backend
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+11
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user