Compare commits

..

3 Commits

Author SHA1 Message Date
Agent Zero bf47739479 Fix healthchecks: use node http module instead of wget/curl 2026-05-24 21:57:38 +00:00
Agent Zero d054d4c726 Fix frontend Dockerfile: apk instead of apt-get for nginx:alpine 2026-05-24 21:56:15 +00:00
Agent Zero cdf350c618 Fix healthchecks: install curl in backend and frontend images 2026-05-24 21:54:58 +00:00
199 changed files with 5777 additions and 41615 deletions
-8
View File
@@ -1,8 +0,0 @@
node_modules
dist
.git
*.md
.env*
.venv
__pycache__
*.log
+4 -2
View File
@@ -1,4 +1,6 @@
node_modules/ node_modules/
*.db
dist/ dist/
.env .git/
*.sqlite
*.db
.DS_Store
-704
View File
@@ -1,704 +0,0 @@
# Bauplan web-cad-neu
**Komplett funktionierende 2D-CAD Software für Event-Bestuhlungspläne**
Basierend auf: Pflichtenheft v4 (633 Zeilen) + UI-Mockup Iteration 9
---
## Technologie-Stack
| Schicht | Technologie | Lizenz |
|---------|-------------|--------|
| Frontend | React 18 + TypeScript + Vite | MIT |
| Rendering | Canvas 2D + rbush (R-Tree) | MIT |
| CRDT/Sync | Yjs + y-websocket | MIT |
| Backend | Node 20 + TypeScript + Fastify | MIT |
| Datenbank | SQLite (better-sqlite3) | Public Domain |
| DXF | dxf-parser + dxf-writer | MIT |
| PDF | pdf-lib | MIT |
| Auth | bcrypt | Apache |
| KI | OpenAI-kompatibler Endpoint (user-provided) | |
| Deployment | Docker + Coolify | Open Source |
---
## Phasen-Übersicht
| Phase | Titel | Anforderungen | Status |
|-------|-------|---------------|--------|
| 0 | Projekt-Setup & Config | | ✅ Angelegt |
| 1 | Core Types & Canvas Engine | F-CAD-01, F-CAD-04 | ⬜ |
| 2 | UI-Gerüst nach Mockup | F-UI-01 bis F-UI-08 | ⬜ |
| 3 | Zeichen-Tools | F-CAD-01, F-CAD-11, F-CAD-12 | ⬜ |
| 4 | Bearbeitungs-Tools | F-CAD-02, F-CAD-07, F-CAD-08 | ⬜ |
| 5 | Auswahl & Snap | F-CAD-04, F-CAD-07 | ⬜ |
| 6 | Layer-System | F-LAY-01 bis F-LAY-06 | ⬜ |
| 7 | Block-Bibliothek | F-LIB-01 bis F-LIB-07 | ⬜ |
| 8 | Bestuhlungs-Tools | F-EVT-01 bis F-EVT-05 | ⬜ |
| 9 | Bemaßung & Text | F-CAD-03, F-CAD-11 | ⬜ |
| 10 | Hintergrund & Grundriss | F-BG-01 bis F-BG-04 | ⬜ |
| 11 | Undo/Redo & History | F-CAD-09, F-CAD-10 | ⬜ |
| 12 | Command Line | F-CAD-05, F-UI-04 | ⬜ |
| 13 | Import/Export | F-IMP-01 bis F-IMP-04, F-EXP-01 bis F-EXP-04 | ⬜ |
| 14 | Backend & REST-API | F-INT-01, F-INT-02, F-INT-04 | ⬜ |
| 15 | Auth & Benutzerverwaltung | F-AUTH-01 bis F-AUTH-05 | ⬜ |
| 16 | Multi-User & Kollaboration | F-MU-01 bis F-MU-07 | ⬜ |
| 17 | KI Copilot | F-AI-01 bis F-AI-15 | ⬜ |
| 18 | Plugin-System | F-EXT-01 bis F-EXT-08 | ⬜ |
| 19 | Theme & Responsive | F-UI-05, F-UI-06, F-UI-10 | ⬜ |
| 20 | Docker & Deployment | NF-DEP-01 bis NF-DEP-05 | ⬜ |
| 21 | Tests & QA | NF-PERF-01 bis NF-PERF-05 | ⬜ |
---
## Phase 0: Projekt-Setup & Config ✅
**Ziel:** Saubere Projektstruktur, Config-Dateien, keine Duplikate
**Dateien:**
- `package.json` (root, frontend, backend)
- `tsconfig.json` (frontend, backend)
- `vite.config.ts`
- `docker-compose.yml` (eine Datei, nicht 5)
- `Dockerfile` (frontend, backend)
- `nginx.conf`
- `.gitignore`
- `README.md`
- `frontend/index.html`
- `frontend/src/types/cad.types.ts` Alle Core Types
**Erledigt:** ✅ Struktur, Config, Types, Canvas Core (SpatialIndex, ZoomPanController, LayerManager)
---
## Phase 1: Core Types & Canvas Engine
**Anforderungen:** F-CAD-01 (Grundelemente), F-CAD-04 (Raster & Fang)
**Dateien:**
- `frontend/src/canvas/RenderEngine.ts` Haupt-Renderer mit Canvas 2D, Viewport-Culling, Layer-basiertem Rendering, Grid
- `frontend/src/canvas/SpatialIndex.ts` ✅ Angelegt (rbush R-Tree)
- `frontend/src/canvas/ZoomPanController.ts` ✅ Angelegt (Zoom, Pan, screenToWorld, worldToScreen, zoomFit)
- `frontend/src/canvas/LayerManager.ts` ✅ Angelegt (CRUD, Visibility, Lock, Active Layer)
- `frontend/src/interaction/SnapEngine.ts` Snap-to-Grid, Endpoint, Midpoint, Intersection, Ortho, Polar
- `frontend/src/interaction/SelectionEngine.ts` Einzelauswahl, Fenster-Auswahl, Kreuz-Auswahl
**RenderEngine muss:**
- Canvas 2D Context verwalten
- requestAnimationFrame Render-Loop
- Zoom/Pan Transform anwenden
- Grid rendern (minor + major lines)
- Viewport-Culling via SpatialIndex
- Elemente nach Layer-Reihenfolge rendern
- Element-Typen rendern: line, circle, arc, rect, polygon, polyline, text, dimension, block_instance, chair
- Selection-Highlights rendern
- Snap-Points visuell hervorheben
- 50.000+ Elemente bei 60fps
---
## Phase 2: UI-Gerüst nach Mockup
**Anforderungen:** F-UI-01 bis F-UI-08
**Referenz:** `/a0/usr/workdir/web-cad-neu/mockup/editor.html` (Iteration 9)
**Komponenten:**
- `frontend/src/App.tsx` Root, ThemeProvider, Layout-Container
- `frontend/src/main.tsx` React Entry Point
- `frontend/src/styles/theme.css` Design Tokens (CSS Custom Properties), Dark/Light Theme
- `frontend/src/styles/layout.css` Layout-Regeln aus Mockup layout.css (1298 Zeilen)
- `frontend/src/styles/styles.css` Base Styles aus Mockup styles.css (195 Zeilen)
- `frontend/src/components/Topbar.tsx` Logo, Projektname, Saved-Badge, Undo/Redo, Version, Share, Theme, Hilfe, Sprache, Avatar
- `frontend/src/components/RibbonBar.tsx` 6 Tabs (Start, Einfügen, Format, Ansicht, Extras, KI) mit Ribbon-Groups
- `frontend/src/components/LeftSidebar.tsx` 4 Sektionen (Auswählen, Zeichnen, Bearbeiten, Bestuhlung), 20 Tools als Buttons mit SVG-Icons
- `frontend/src/components/CanvasArea.tsx` Canvas-Container, Koordinaten-Display, View-Tabs (2D/ISO/Front/Top)
- `frontend/src/components/RightSidebar.tsx` 4 Tabs: Properties, Layer (Tree), Bibliothek (Tree), KI Copilot
- `frontend/src/components/PropertiesPanel/PropertiesPanel.tsx` Geometrie, Darstellung, Block-Info
- `frontend/src/components/LayerPanel.tsx` Tree-Struktur mit Ordnern, Visibility-Toggle, Lock-Toggle, Add-Button
- `frontend/src/components/BlockLibrary.tsx` Tree-Struktur, Suchfeld, Kategorie-Filter, Draggable Leafs
- `frontend/src/components/KICopilot.tsx` Chat-UI, Schnell-Aktionen, Voice-Input, Send-Button
- `frontend/src/components/CommandLine.tsx` History + Input mit Autovervollständigung
- `frontend/src/components/StatusBar.tsx` SNAP/ORTHO/POLAR/GRID, Koordinaten, Layer, Werkzeug, Online
- `frontend/src/components/TreeView.tsx` Generische Tree-Komponente (collapsible, draggable)
- `frontend/src/components/MobileDrawers.tsx` Hamburger-Drawer + Right-Tab-Bar + Slide-in (≤768px)
**UI muss exakt dem Mockup entsprechen:**
- Topbar → Ribbon → (LeftSidebar | CanvasArea | RightSidebar) → CommandLine → StatusBar
- Inter/JetBrains Mono Fonts
- CSS Custom Properties für Theme
- SVG Icons aus Mockup übernehmen
- Responsive: Desktop ≥1280px, Mobile Drawers ≤768px
---
## Phase 3: Zeichen-Tools
**Anforderungen:** F-CAD-01, F-CAD-11, F-CAD-12
**Dateien:**
- `frontend/src/tools/Tool.ts` Base Tool Interface (onMouseDown, onMouseMove, onMouseUp, onKeyDown, render)
- `frontend/src/tools/ToolManager.ts` Active Tool, Tool-Switching, Tool-Events
- `frontend/src/tools/drawing/LineTool.ts` Linie (L): 2 Punkte → Linie
- `frontend/src/tools/drawing/PolylineTool.ts` Polylinie (PL): Multiple Punkte → Polyline
- `frontend/src/tools/drawing/RectTool.ts` Rechteck (REC): 2 Punkte → Rechteck
- `frontend/src/tools/drawing/CircleTool.ts` Kreis (C): Center + Radius
- `frontend/src/tools/drawing/ArcTool.ts` Bogen (A): Center + StartAngle + EndAngle
- `frontend/src/tools/drawing/PolygonTool.ts` Polygon: Multiple Punkte → Geschlossen
- `frontend/src/tools/drawing/TextTool.ts` Text (T): Position + Text + FontSize
- `frontend/src/tools/drawing/DimensionTool.ts` Bemaßung (DIM): 2 Punkte + Maßlinie
- `frontend/src/tools/drawing/HatchTool.ts` Schraffur (H): Fläche + Muster
- `frontend/src/tools/drawing/index.ts` Export aller Tools
**Jedes Tool:**
- Mouse-Events (down/move/up) verarbeiten
- Snap-Engine nutzen
- Preview während des Zeichnens
- Element auf aktiver Ebene erstellen
- Undo-Historie aktualisieren
---
## Phase 4: Bearbeitungs-Tools
**Anforderungen:** F-CAD-02, F-CAD-07, F-CAD-08
**Dateien:**
- `frontend/src/tools/modification/MoveTool.ts` Verschieben (M): Basispunkt + Ziel
- `frontend/src/tools/modification/CopyTool.ts` Kopieren (CO): Basispunkt + Ziel
- `frontend/src/tools/modification/RotateTool.ts` Rotieren (RO): Basispunkt + Winkel
- `frontend/src/tools/modification/ScaleTool.ts` Skalieren (SC): Basispunkt + Faktor
- `frontend/src/tools/modification/MirrorTool.ts` Spiegeln (MI): Spiegelachse
- `frontend/src/tools/modification/TrimTool.ts` Trimmen (TR): Schneiden an Grenzelementen
- `frontend/src/tools/modification/ExtendTool.ts` Verlängern (EX): Bis zu Grenzelement
- `frontend/src/tools/modification/FilletTool.ts` Abrunden (F): Radius + 2 Elemente
- `frontend/src/tools/modification/OffsetTool.ts` Versatz (O): Parallelverschiebung
- `frontend/src/tools/modification/DeleteTool.ts` Löschen (E)
- `frontend/src/tools/modification/index.ts` Export
**Gruppierung:**
- `frontend/src/tools/GroupTool.ts` Gruppe erstellen/auflösen
- Gruppen können verschachtelt werden
- Gruppen in Bibliothek speicherbar
---
## Phase 5: Auswahl & Snap
**Anforderungen:** F-CAD-04, F-CAD-07
**Dateien:**
- `frontend/src/interaction/SelectionEngine.ts` Einzelauswahl (Klick), Fenster-Auswahl (Links-Rechts), Kreuz-Auswahl (Rechts-Links), Quick-Select (Eigenschafts-basiert)
- `frontend/src/interaction/SnapEngine.ts` Snap-to-Grid, Endpoint, Midpoint, Intersection, Ortho-Modus, Polar-Tracking
- `frontend/src/interaction/index.ts` Export
**Snap-Engine:**
- Snap-Modi umschaltbar (F3)
- Visuelle Hervorhebung der Snap-Punkte
- Ortho-Modus (F8): Nur horizontal/vertikal
- Polar-Tracking: Winkel-Snaps (0°, 30°, 45°, 60°, 90°)
---
## Phase 6: Layer-System
**Anforderungen:** F-LAY-01 bis F-LAY-06
**Dateien:**
- `frontend/src/canvas/LayerManager.ts` ✅ Angelegt, erweitern um: Baumstruktur (parentId), Filter, Duplizieren
- `frontend/src/components/LayerPanel.tsx` Tree-Widget mit Parent-Child, Visibility/Lock/Color/LineType/Transparency pro Ebene
- `frontend/src/components/TreeView.tsx` Generische Tree-Komponente
**Funktionen:**
- Ebenen als Baumstruktur (Parent-Child)
- Eigenschaften pro Ebene: Name, Visibility, Lock, Color, LineType, Transparency
- Operationen: Erstellen, Löschen, Umbenennen, Verschieben im Baum, Duplizieren
- Element-Zuordnung: Drag-and-Drop zwischen Ebenen
- Aktive Ebene markieren
- Ebenen-Filter nach Eigenschaften
---
## Phase 7: Block-Bibliothek
**Anforderungen:** F-LIB-01 bis F-LIB-07
**Dateien:**
- `frontend/src/components/BlockLibrary.tsx` Tree mit Ordnern, Suchfeld, Kategorie-Filter, Draggable Leafs
- `frontend/src/services/blockService.ts` Block CRUD, SVG-Import, Block-Definition vs. Referenz
- `frontend/src/types/cad.types.ts` BlockDefinition Type ✅
**Funktionen:**
- Bibliothek als Baumstruktur (Ordner + Unterordner)
- SVG-Import in Bibliothek
- Gruppen als Blöcke speichern
- Drag-and-Drop in Zeichenbereich
- Block-Bearbeitung (Skalieren, Rotieren, Spiegeln)
- Block-Verwaltung (Umbenennen, Duplizieren, Löschen, Verschieben)
- Block-Definition vs. Referenz (Änderung an Definition → alle Instanzen aktualisieren)
---
## Phase 8: Bestuhlungs-Tools
**Anforderungen:** F-EVT-01 bis F-EVT-05
**Dateien:**
- `frontend/src/tools/drawing/ChairTool.ts` Einzelner Stuhl
- `frontend/src/tools/drawing/SeatingRowTool.ts` Reihen-Bestuhlung (R): Reihe mit Anzahl, Abstand, Ausrichtung
- `frontend/src/tools/drawing/SeatingBlockTool.ts` Block-Bestuhlung (B): Rechteck-Bereich mit Reihen, Spalten, Abständen
- `frontend/src/tools/drawing/TableTool.ts` Tisch (TAB)
- `frontend/src/tools/drawing/StageTool.ts` Bühne (S)
- `frontend/src/tools/drawing/SeatingTemplateTool.ts` Bestuhlungs-Vorlagen
**Funktionen:**
- Reihen-Bestuhlung: Anzahl, Abstand, Ausrichtung konfigurierbar
- Block-Bestuhlung: Reihen × Spalten, Abstände, Versatz
- Stuhl-Typ aus Bibliothek wählbar
- Parameter im Nachhinein änderbar
- Einzelne Stühle hinzufügen/entfernen/verschieben
- Automatische Zählung (pro Reihe, Block, Gesamt)
---
## Phase 9: Bemaßung & Text
**Anforderungen:** F-CAD-03, F-CAD-11
**Dateien:**
- `frontend/src/tools/drawing/DimensionTool.ts` Lineare, winkelige, radiale Bemaßung
- `frontend/src/tools/drawing/TextTool.ts` Einzel- und Mehrzeilentext
- `frontend/src/tools/drawing/LeaderTool.ts` Leaders (MLEADER)
- `frontend/src/tools/drawing/RevCloudTool.ts` Revision Clouds (REVCLOUD)
**Bemaßung:**
- Maßberechnung basierend auf Hintergrund-Maßstab
- Werte in realen Maßeinheiten (m/cm)
- Lineare, winkelige, radiale Bemaßung
---
## Phase 10: Hintergrund & Grundriss
**Anforderungen:** F-BG-01 bis F-BG-04
**Dateien:**
- `frontend/src/services/backgroundService.ts` Laden, Positionieren, Skalieren von Hintergrundbildern
- `frontend/src/components/BackgroundImport.tsx` Upload-Dialog
**Funktionen:**
- PNG, JPG, SVG, PDF-Seite als Hintergrund laden
- Maßstab definieren (1:100, Referenzstrecke)
- Hintergrund verschieben, rotieren, skalieren
- Sichtbarkeit ein/aus
---
## Phase 11: Undo/Redo & History
**Anforderungen:** F-CAD-09, F-CAD-10
**Dateien:**
- `frontend/src/history/HistoryManager.ts` Undo/Redo Stack mit beliebig vielen Schritten
- `frontend/src/history/index.ts` Export
**Funktionen:**
- Jede Operation erzeugt History-Eintrag
- Undo (Strg+Z) / Redo (Strg+Y)
- Historie einsehbar
- Kopieren zwischen Dateien via Zwischenablage
---
## Phase 12: Command Line
**Anforderungen:** F-CAD-05, F-UI-04
**Dateien:**
- `frontend/src/components/CommandLine.tsx` History + Input mit Autovervollständigung
- `frontend/src/services/commandRegistry.ts` Befehls-Registry (L, PL, C, REC, M, CO, RO, SC, MI, TR, O, DIM, H, BESTUHLUNG, etc.)
**Funktionen:**
- Befehlseingabe via Tastatur (AutoCAD-Style)
- Autovervollständigung
- Befehls-History
- Tastatur-Shortcuts (L, C, M, CO, RO, SC, MI, PL, H, etc.)
---
## Phase 13: Import/Export
**Anforderungen:** F-IMP-01 bis F-IMP-04, F-EXP-01 bis F-EXP-04
**Dateien:**
- `frontend/src/services/importService.ts` DXF, SVG, PDF, JSON Import
- `frontend/src/services/exportService.ts` DXF, SVG, PDF, PNG, JSON Export
- `frontend/src/services/dxfParser.ts` DXF Parsing (dxf-parser library)
- `frontend/src/services/dxfWriter.ts` DXF Writing
- `frontend/src/services/pdfExport.ts` PDF mit pdf-lib
**Formate:**
- Import: DXF, SVG, PDF (optional DWG via libredwg), JSON
- Export: DXF, SVG, PDF, PNG, JSON
- JSON enthält alle Daten (Layers, Elements, Blocks, Background)
---
## Phase 14: Backend & REST-API
**Anforderungen:** F-INT-01, F-INT-02, F-INT-04, F-INT-07
**Dateien:**
- `backend/src/index.ts` Fastify Server Start
- `backend/src/server.ts` Server-Konfiguration (CORS, Static, WebSocket)
- `backend/src/database/DatabaseInterface.ts` Abstrahierte DB-Schicht
- `backend/src/database/SqliteAdapter.ts` SQLite Implementation
- `backend/src/database/schema.sql` Schema: users, projects, drawings, blocks, layers, elements, settings
- `backend/src/database/migrations.ts` Migration-Runner
- `backend/src/routes/index.ts` Route-Registration
- `backend/src/routes/projects.ts` CRUD für Projekte
- `backend/src/routes/drawings.ts` CRUD für Zeichnungen
- `backend/src/routes/layers.ts` CRUD für Layer
- `backend/src/routes/elements.ts` CRUD für Elemente
- `backend/src/routes/blocks.ts` CRUD für Blöcke
- `backend/src/routes/export.ts` Export-Endpunkte
- `backend/src/routes/import.ts` Import-Endpunkte
- `backend/src/routes/settings.ts` App-Settings (KI-Config, etc.)
- `backend/src/routes/users.ts` User-Verwaltung
**API-First:**
- Alle Funktionen über REST-API erreichbar
- OpenAPI/Swagger Dokumentation
- Headless-Modus (nur API, keine UI)
- Webhook-Support
---
## Phase 15: Auth & Benutzerverwaltung
**Anforderungen:** F-AUTH-01 bis F-AUTH-05, NF-SEC-01, NF-SEC-02
**Dateien:**
- `backend/src/auth/AuthService.ts` Register, Login, Password-Reset, Session-Management
- `backend/src/routes/auth.ts` Auth-Endpunkte
- `frontend/src/contexts/AuthContext.tsx` Auth-State Management
- `frontend/src/pages/Login.tsx` Login-Seite
- `frontend/src/pages/Register.tsx` Registrierung
- `frontend/src/pages/Dashboard.tsx` Projekt-Übersicht
**Funktionen:**
- E-Mail/Passwort-Login (bcrypt)
- Passwort-Reset via E-Mail
- Session-Management (HTTP-only Cookies, CSRF-Schutz)
- Rollen: Planer, Betrachter, Admin, Gast
- Gast-Zugänge für spezifische Pläne
---
## Phase 16: Multi-User & Kollaboration
**Anforderungen:** F-MU-01 bis F-MU-07
**Dateien:**
- `backend/src/websocket/yjsServer.ts` Yjs WebSocket Server mit Persistenz
- `frontend/src/crdt/YjsDocument.ts` Yjs Document Wrapper
- `frontend/src/crdt/WebSocketProvider.ts` WebSocket-Verbindung
- `frontend/src/crdt/AwarenessManager.ts` Nutzer-Anwesenheit, Cursor-Positionen
- `frontend/src/crdt/useYjsBinding.ts` React Hook für Yjs-Binding
**Funktionen:**
- Echtzeit-Kollaboration (Yjs CRDT)
- Konfliktfreie Bearbeitung
- Nutzer-Anwesenheit (Avatar/Farbe)
- Cursor-Anzeige anderer Nutzer
- Berechtigungs-System (Rollen)
- Offline-Unterstützung (IndexedDB)
- Versionshistorie
---
## Phase 17: KI Copilot
**Anforderungen:** F-AI-01 bis F-AI-15
**Dateien:**
- `backend/src/routes/ai.ts` KI-Proxy-Endpoint (leitet an OpenAI-kompatiblen API weiter)
- `frontend/src/services/aiService.ts` KI-Client (sendet an Backend, Backend leitet weiter)
- `frontend/src/services/functionRegistry.ts` CAD-Function-Registry (alle CAD-Operationen als Functions)
- `frontend/src/components/KICopilot.tsx` Chat-UI, Schnell-Aktionen, Voice-Input
**Architektur:**
```
User Input → Backend → OpenAI-kompatibler API-Endpoint
→ LLM generiert Function Call (z.B. draw_line(x1,y1,x2,y2))
→ Function-Registry führt Function aus
→ Canvas aktualisiert sich
→ Ergebnis an LLM → Bestätigung an User
```
**Functions (registriert):**
- draw_line, draw_circle, draw_rect, draw_arc, draw_polyline, draw_text, draw_dimension
- move, copy, rotate, scale, mirror, trim, offset, delete
- create_layer, delete_layer, set_active_layer, toggle_layer_visibility
- insert_block, create_block, delete_block
- seating_row, seating_block, count_seats
- export_dxf, export_svg, export_pdf, import_dxf, import_svg
- undo, redo
- get_context (aktuelle Zeichnung, Selektion, Layer, Zoom)
**Guardrails:**
- Destruktive Operationen erfordern Bestätigung
- Function-Validation vor Ausführung
- Safety-Checks (nicht "Lösche alles" ohne Bestätigung)
**Context Injection:**
- Aktuelle Zeichnung (Summary, nicht alle Elemente)
- Selektion
- Aktive Ebene
- Zoom-Bereich
- Maßstab
---
## Phase 18: Plugin-System
**Anforderungen:** F-EXT-01 bis F-EXT-08
**Dateien:**
- `frontend/src/services/pluginRegistry.ts` Plugin-Registration, Lifecycle, Isolation
- `frontend/src/types/plugin.types.ts` Plugin-Manifest, Tool-API Interface
- `frontend/src/components/PluginSettings.tsx` Plugin-Verwaltung UI
**Funktionen:**
- Plugin-Manifest (Name, Version, Dependencies)
- Tool-API (Zeichnen, Bearbeiten, Bibliothek, Export, UI)
- Plugin-Isolation (try-catch, kein Core-Crash)
- Aktivieren/Deaktivieren ohne Neustart
- Plugin-UI-Integration (Ribbon-Tabs, Panels, Dialoge)
- Laufzeit-Laden
---
## Phase 19: Theme & Responsive
**Anforderungen:** F-UI-05, F-UI-06, F-UI-10
**Dateien:**
- `frontend/src/contexts/ThemeContext.tsx` Dark/Light Theme Toggle
- `frontend/src/styles/theme.css` CSS Custom Properties für beide Themes
- Mobile Drawers aus Mockup übernehmen
**Funktionen:**
- Dark- und Light-Mode
- Responsive: Desktop ≥1280px (alle Panels)
- Mobile ≤768px: Hamburger-Drawer + Right-Tab-Bar + Slide-in Drawers
- Voice Input (Web Speech API, optional)
---
## Phase 20: Docker & Deployment
**Anforderungen:** NF-DEP-01 bis NF-DEP-05
**Dateien:**
- `docker-compose.yml` ✅ Angelegt (frontend + backend)
- `frontend/Dockerfile` ✅ Angelegt (Node build + nginx)
- `backend/Dockerfile` ✅ Angelegt (Node + tsx)
- `docker-compose.coolify.yml` Coolify-spezifische Config
**Deployment:**
- 2 Container: Frontend (nginx) + Backend (Node)
- SQLite als Volume
- WebSocket-Proxy via Traefik
- SSL via Let's Encrypt
- Env-Variablen: API_URL, API_KEY, DB_PATH, JWT_SECRET
- Coolify-kompatibel
---
## Phase 21: Tests & QA
**Anforderungen:** NF-PERF-01 bis NF-PERF-05, NF-SEC-01 bis NF-SEC-05
**Tests:**
- `frontend/src/tools/__tests__/` Tool-Tests (Zeichnen, Bearbeiten)
- `backend/src/routes/__tests__/` API-Tests
- `backend/src/auth/__tests__/` Auth-Tests
- Performance-Benchmark: 50.000 Elemente bei 60fps
- Lighthouse-Score ≥ 80
- Security-Tests: Input-Validierung, CSRF, Auth
---
## Build-Reihenfolge
```
Phase 0 ✅ Setup
Phase 1 ⬜ Canvas Engine (RenderEngine, SnapEngine, SelectionEngine)
Phase 2 ⬜ UI-Gerüst (alle Komponenten nach Mockup)
Phase 3 ⬜ Zeichen-Tools (Line, Circle, Rect, Arc, Polyline, Polygon, Text, Dimension, Hatch)
Phase 4 ⬜ Bearbeitungs-Tools (Move, Copy, Rotate, Scale, Mirror, Trim, Extend, Fillet, Offset, Delete)
Phase 5 ⬜ Auswahl & Snap (Selection, Snap-Modes, Ortho, Polar)
Phase 6 ⬜ Layer-System (Tree, Eigenschaften, Operationen)
Phase 7 ⬜ Block-Bibliothek (Tree, SVG-Import, Drag-and-Drop, Definition/Referenz)
Phase 8 ⬜ Bestuhlungs-Tools (Reihe, Block, Tisch, Bühne, Zählung)
Phase 9 ⬜ Bemaßung & Text (Linear, Winkel, Radial, MTEXT, Leader, RevCloud)
Phase 10 ⬜ Hintergrund (Laden, Maßstab, Positionieren)
Phase 11 ⬜ Undo/Redo (History-Stack, Zwischenablage)
Phase 12 ⬜ Command Line (Befehlseingabe, Autovervollständigung, Shortcuts)
Phase 13 ⬜ Import/Export (DXF, SVG, PDF, PNG, JSON)
Phase 14 ⬜ Backend & REST-API (Fastify, SQLite, CRUD, OpenAPI)
Phase 15 ⬜ Auth (Login, Register, Rollen, Sessions)
Phase 16 ⬜ Multi-User (Yjs, WebSocket, Cursor, Awareness, Offline)
Phase 17 ⬜ KI Copilot (Function Calling, Context, Guardrails)
Phase 18 ⬜ Plugin-System (Manifest, API, Isolation, UI)
Phase 19 ⬜ Theme & Responsive (Dark/Light, Mobile Drawers)
Phase 20 ⬜ Docker & Deployment (Coolify, SSL, Env)
Phase 21 ⬜ Tests & QA (Unit, Integration, Performance, Security)
```
---
## Verzeichnis-Struktur (Final)
```
web-cad-neu/
├── package.json
├── docker-compose.yml
├── docker-compose.coolify.yml
├── .gitignore
├── README.md
├── BAUPLAN.md
├── mockup/ # UI-Referenz (Iteration 9)
│ ├── editor.html
│ └── assets/
├── frontend/
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ ├── Dockerfile
│ ├── nginx.conf
│ ├── index.html
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── types/
│ │ ├── cad.types.ts
│ │ └── plugin.types.ts
│ ├── canvas/
│ │ ├── RenderEngine.ts
│ │ ├── SpatialIndex.ts
│ │ ├── ZoomPanController.ts
│ │ └── LayerManager.ts
│ ├── interaction/
│ │ ├── SnapEngine.ts
│ │ ├── SelectionEngine.ts
│ │ └── index.ts
│ ├── tools/
│ │ ├── Tool.ts
│ │ ├── ToolManager.ts
│ │ ├── drawing/
│ │ │ ├── LineTool.ts
│ │ │ ├── PolylineTool.ts
│ │ │ ├── RectTool.ts
│ │ │ ├── CircleTool.ts
│ │ │ ├── ArcTool.ts
│ │ │ ├── PolygonTool.ts
│ │ │ ├── TextTool.ts
│ │ │ ├── DimensionTool.ts
│ │ │ ├── HatchTool.ts
│ │ │ ├── ChairTool.ts
│ │ │ ├── SeatingRowTool.ts
│ │ │ ├── SeatingBlockTool.ts
│ │ │ ├── TableTool.ts
│ │ │ ├── StageTool.ts
│ │ │ └── index.ts
│ │ └── modification/
│ │ ├── MoveTool.ts
│ │ ├── CopyTool.ts
│ │ ├── RotateTool.ts
│ │ ├── ScaleTool.ts
│ │ ├── MirrorTool.ts
│ │ ├── TrimTool.ts
│ │ ├── ExtendTool.ts
│ │ ├── FilletTool.ts
│ │ ├── OffsetTool.ts
│ │ ├── DeleteTool.ts
│ │ └── index.ts
│ ├── components/
│ │ ├── Topbar.tsx
│ │ ├── RibbonBar.tsx
│ │ ├── LeftSidebar.tsx
│ │ ├── CanvasArea.tsx
│ │ ├── RightSidebar.tsx
│ │ ├── CommandLine.tsx
│ │ ├── StatusBar.tsx
│ │ ├── LayerPanel.tsx
│ │ ├── BlockLibrary.tsx
│ │ ├── KICopilot.tsx
│ │ ├── TreeView.tsx
│ │ ├── MobileDrawers.tsx
│ │ ├── PropertiesPanel/
│ │ │ └── PropertiesPanel.tsx
│ │ └── pages/
│ │ ├── Login.tsx
│ │ ├── Register.tsx
│ │ └── Dashboard.tsx
│ ├── services/
│ │ ├── api.ts
│ │ ├── blockService.ts
│ │ ├── importService.ts
│ │ ├── exportService.ts
│ │ ├── backgroundService.ts
│ │ ├── aiService.ts
│ │ ├── functionRegistry.ts
│ │ ├── commandRegistry.ts
│ │ └── pluginRegistry.ts
│ ├── history/
│ │ ├── HistoryManager.ts
│ │ └── index.ts
│ ├── crdt/
│ │ ├── YjsDocument.ts
│ │ ├── WebSocketProvider.ts
│ │ ├── AwarenessManager.ts
│ │ └── useYjsBinding.ts
│ ├── contexts/
│ │ ├── AuthContext.tsx
│ │ └── ThemeContext.tsx
│ └── styles/
│ ├── theme.css
│ ├── layout.css
│ └── styles.css
├── backend/
│ ├── package.json
│ ├── tsconfig.json
│ ├── Dockerfile
│ └── src/
│ ├── index.ts
│ ├── server.ts
│ ├── auth/
│ │ ├── AuthService.ts
│ │ └── authPlugin.ts
│ ├── database/
│ │ ├── DatabaseInterface.ts
│ │ ├── SqliteAdapter.ts
│ │ ├── schema.sql
│ │ └── migrations.ts
│ ├── routes/
│ │ ├── index.ts
│ │ ├── auth.ts
│ │ ├── projects.ts
│ │ ├── drawings.ts
│ │ ├── layers.ts
│ │ ├── elements.ts
│ │ ├── blocks.ts
│ │ ├── export.ts
│ │ ├── import.ts
│ │ ├── settings.ts
│ │ ├── users.ts
│ │ └── ai.ts
│ └── websocket/
│ └── yjsServer.ts
└── specs/
└── requirements.md # Kopie des Pflichtenhefts
```
+28
View File
@@ -0,0 +1,28 @@
# Multi-Stage: Backend + Frontend
FROM node:22-alpine AS backend-build
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm ci --only=production
COPY backend/ ./
FROM node:22-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM node:22-alpine
WORKDIR /app
RUN apk add --no-cache curl
# Backend
COPY --from=backend-build /app/backend /app/backend
WORKDIR /app/backend
# Frontend
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist
# Serve Backend auf 5000
EXPOSE 5000
CMD ["node", "server.js"]
-21
View File
@@ -1,21 +0,0 @@
# Web CAD Neu
Web-basiertes 2D-CAD für Event-Bestuhlungspläne.
## Stack
- Frontend: React + TypeScript + Vite + HTML Canvas
- Backend: Node.js + TypeScript + Fastify + SQLite
- Collaboration: Yjs CRDT
## Entwicklung
```bash
npm install
npm run dev
```
- Frontend: http://localhost:5173
- Backend: http://localhost:3001
## Docker
```bash
docker-compose up --build
```
-5
View File
@@ -1,5 +0,0 @@
node_modules
dist
.git
*.md
*.log
+11 -7
View File
@@ -1,9 +1,13 @@
FROM node:20-alpine FROM node:22-alpine
RUN apk add --no-cache curl
WORKDIR /app WORKDIR /app
RUN apk add --no-cache wget
COPY package.json package-lock.json* ./ COPY package*.json ./
RUN npm install RUN npm ci --only=production
COPY . . COPY . .
RUN npm run build
EXPOSE 3001 EXPOSE 5000
CMD ["node", "dist/index.js"]
CMD ["node", "server.js"]
+22
View File
@@ -0,0 +1,22 @@
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: process.env.DB_PATH || './database.sqlite',
logging: false,
});
const connectDB = async () => {
try {
await sequelize.authenticate();
console.log('Database connected.');
await sequelize.sync();
console.log('Database synced.');
} catch (err) {
console.error('Database connection error:', err);
// Retry after 5s
setTimeout(connectDB, 5000);
}
};
module.exports = { sequelize, connectDB };
+23
View File
@@ -0,0 +1,23 @@
const passport = require('passport');
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
const User = require('../models/User');
const opts = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET || 'dev_secret_change_in_production',
};
passport.use(new JwtStrategy(opts, async (jwt_payload, done) => {
try {
const user = await User.findByPk(jwt_payload.id);
if (user) {
return done(null, user);
}
return done(null, false);
} catch (err) {
return done(err, false);
}
}));
module.exports = passport;
+6
View File
@@ -0,0 +1,6 @@
const passport = require('passport');
// Protect routes with JWT
const authenticate = passport.authenticate('jwt', { session: false });
module.exports = authenticate;
+33
View File
@@ -0,0 +1,33 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/database');
const User = require('./User');
const Block = sequelize.define('Block', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
svg_data: {
type: DataTypes.TEXT,
allowNull: false,
},
category: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: 'Allgemein',
},
is_public: {
type: DataTypes.BOOLEAN,
defaultValue: true,
},
});
Block.belongsTo(User, { foreignKey: 'userId', allowNull: true, onDelete: 'SET NULL' });
User.hasMany(Block, { foreignKey: 'userId' });
module.exports = Block;
+29
View File
@@ -0,0 +1,29 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/database');
const User = require('./User');
const Drawing = sequelize.define('Drawing', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'Unbenannt',
},
canvas_json: {
type: DataTypes.TEXT,
allowNull: true,
},
thumbnail: {
type: DataTypes.TEXT,
allowNull: true,
},
});
Drawing.belongsTo(User, { foreignKey: 'userId', onDelete: 'CASCADE' });
User.hasMany(Drawing, { foreignKey: 'userId' });
module.exports = Drawing;
+31
View File
@@ -0,0 +1,31 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/database');
const bcrypt = require('bcryptjs');
const User = sequelize.define('User', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
email: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
validate: { isEmail: true },
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
});
User.beforeCreate(async (user) => {
user.password = await bcrypt.hash(user.password, 10);
});
User.prototype.validatePassword = async function (password) {
return bcrypt.compare(password, this.password);
};
module.exports = User;
+1212 -2874
View File
File diff suppressed because it is too large Load Diff
+19 -26
View File
@@ -1,32 +1,25 @@
{ {
"name": "web-cad-backend", "name": "backend",
"version": "0.1.0", "version": "1.0.0",
"private": true, "description": "",
"type": "module", "main": "index.js",
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "test": "echo \"Error: no test specified\" && exit 1"
"build": "tsc",
"postbuild": "cp src/database/schema.sql dist/database/schema.sql",
"start": "node dist/index.js",
"test": "vitest run"
}, },
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "bcryptjs": "^3.0.3",
"@fastify/static": "^9.1.3", "cors": "^2.8.6",
"@fastify/websocket": "^11.2.0", "dxf-parser": "^1.1.2",
"bcrypt": "^6.0.0", "express": "^5.2.1",
"better-sqlite3": "^11.0.0", "jsonwebtoken": "^9.0.3",
"fastify": "^5.9.0", "multer": "^2.1.1",
"y-leveldb": "^0.2.0", "passport": "^0.7.0",
"y-protocols": "^1.0.7", "passport-jwt": "^4.0.1",
"yjs": "^13.6.0" "passport-local": "^1.0.0",
}, "sequelize": "^6.37.8",
"devDependencies": { "sqlite3": "^6.0.1"
"@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": "^4.1.9"
} }
} }
@@ -0,0 +1,6 @@
{
"name": "Dimension Tool",
"version": "1.0.0",
"description": "Fügt ein Bemaßungswerkzeug für horizontale und vertikale Messungen hinzu.",
"author": "Agent Zero"
}
+53
View File
@@ -0,0 +1,53 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const authenticate = require('../middleware/auth');
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_change_in_production';
// Register
router.post('/register', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
const existing = await User.findOne({ where: { email } });
if (existing) {
return res.status(409).json({ error: 'User already exists' });
}
const user = await User.create({ email, password });
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' });
res.status(201).json({ token, user: { id: user.id, email: user.email } });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Registration failed' });
}
});
// Login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
const user = await User.findOne({ where: { email } });
if (!user || !(await user.validatePassword(password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' });
res.json({ token, user: { id: user.id, email: user.email } });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Login failed' });
}
});
// Get current user
router.get('/me', authenticate, async (req, res) => {
res.json({ id: req.user.id, email: req.user.email });
});
module.exports = router;
+59
View File
@@ -0,0 +1,59 @@
const express = require('express');
const authenticate = require('../middleware/auth');
const Block = require('../models/Block');
const router = express.Router();
// Public blocks (no auth for listing)
router.get('/', async (req, res) => {
try {
const blocks = await Block.findAll({
where: { is_public: true },
attributes: ['id', 'name', 'svg_data', 'category'],
});
res.json(blocks);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to list blocks' });
}
});
// Protected: create a new block (requires auth)
router.post('/', authenticate, async (req, res) => {
try {
const { name, svg_data, category, is_public } = req.body;
if (!name || !svg_data) {
return res.status(400).json({ error: 'Name and svg_data required' });
}
const block = await Block.create({
name,
svg_data,
category: category || 'Allgemein',
is_public: is_public !== undefined ? is_public : true,
userId: req.user.id,
});
res.status(201).json(block);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to create block' });
}
});
// Protected: delete block (only owner)
router.delete('/:id', authenticate, async (req, res) => {
try {
const block = await Block.findOne({
where: { id: req.params.id, userId: req.user.id },
});
if (!block) {
return res.status(404).json({ error: 'Block not found or not authorized' });
}
await block.destroy();
res.json({ message: 'Block deleted' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to delete block' });
}
});
module.exports = router;
+94
View File
@@ -0,0 +1,94 @@
const express = require('express');
const authenticate = require('../middleware/auth');
const Drawing = require('../models/Drawing');
const router = express.Router();
// All routes require authentication
router.use(authenticate);
// List drawings for current user
router.get('/', async (req, res) => {
try {
const drawings = await Drawing.findAll({
where: { userId: req.user.id },
attributes: ['id', 'name', 'updatedAt'],
order: [['updatedAt', 'DESC']],
});
res.json(drawings);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to list drawings' });
}
});
// Create new drawing
router.post('/', async (req, res) => {
try {
const { name, canvas_json } = req.body;
const drawing = await Drawing.create({
name: name || 'Unbenannt',
canvas_json: canvas_json || '',
userId: req.user.id,
});
res.status(201).json(drawing);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to create drawing' });
}
});
// Get single drawing
router.get('/:id', async (req, res) => {
try {
const drawing = await Drawing.findOne({
where: { id: req.params.id, userId: req.user.id },
});
if (!drawing) {
return res.status(404).json({ error: 'Drawing not found' });
}
res.json(drawing);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to get drawing' });
}
});
// Update drawing
router.put('/:id', async (req, res) => {
try {
const drawing = await Drawing.findOne({
where: { id: req.params.id, userId: req.user.id },
});
if (!drawing) {
return res.status(404).json({ error: 'Drawing not found' });
}
const { name, canvas_json } = req.body;
if (name !== undefined) drawing.name = name;
if (canvas_json !== undefined) drawing.canvas_json = canvas_json;
await drawing.save();
res.json(drawing);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update drawing' });
}
});
// Delete drawing
router.delete('/:id', async (req, res) => {
try {
const drawing = await Drawing.findOne({
where: { id: req.params.id, userId: req.user.id },
});
if (!drawing) {
return res.status(404).json({ error: 'Drawing not found' });
}
await drawing.destroy();
res.json({ message: 'Drawing deleted' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to delete drawing' });
}
});
module.exports = router;
+101
View File
@@ -0,0 +1,101 @@
const express = require('express');
const authenticate = require('../middleware/auth');
const multer = require('multer');
const { parseString } = require('dxf-parser');
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
// Upload DXF file -> parse and return JSON representation (Fabric.js compatible)
router.post('/import', authenticate, upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const dxfContent = req.file.buffer.toString('utf-8');
console.log('DXF file size:', dxfContent.length);
const parsed = parseString(dxfContent);
// Convert parsed entities to Fabric.js objects (simplified)
const fabricObjects = [];
if (parsed && parsed.entities) {
parsed.entities.forEach(entity => {
const type = entity.type;
if (type === 'LINE') {
fabricObjects.push({
type: 'line',
x1: entity.start.x, y1: entity.start.y,
x2: entity.end.x, y2: entity.end.y,
stroke: '#000', strokeWidth: 1,
});
} else if (type === 'CIRCLE') {
fabricObjects.push({
type: 'circle',
left: entity.center.x - entity.radius,
top: entity.center.y - entity.radius,
radius: entity.radius,
fill: 'transparent', stroke: '#000',
});
} else if (type === 'ARC') {
// simplified arc handling
fabricObjects.push({
type: 'path',
path: `M ${entity.center.x} ${entity.center.y}`, // placeholder
stroke: '#000', strokeWidth: 1,
});
} else if (type === 'POLYLINE' || type === 'LWPOLYLINE') {
// convert vertices to polygon
if (entity.vertices && entity.vertices.length >= 2) {
const points = entity.vertices.map(v => ({ x: v.x, y: v.y }));
fabricObjects.push({
type: 'polygon',
points,
stroke: '#000', strokeWidth: 1, fill: 'transparent',
});
}
}
});
}
res.json({ objects: fabricObjects });
} catch (err) {
console.error('DXF import error:', err);
res.status(500).json({ error: 'Failed to parse DXF file: ' + err.message });
}
});
// Export canvas JSON to DXF string
router.post('/export', authenticate, (req, res) => {
try {
const { objects } = req.body;
if (!objects || !Array.isArray(objects)) {
return res.status(400).json({ error: 'Invalid objects array' });
}
// Build DXF content
let dxf = '0\nSECTION\n2\nHEADER\n0\nENDSEC\n';
dxf += '0\nSECTION\n2\nTABLES\n0\nENDSEC\n';
dxf += '0\nSECTION\n2\nBLOCKS\n0\nENDSEC\n';
dxf += '0\nSECTION\n2\nENTITIES\n';
objects.forEach(obj => {
if (obj.type === 'line') {
dxf += `0\nLINE\n8\n0\n10\n${obj.x1}\n20\n${obj.y1}\n11\n${obj.x2}\n21\n${obj.y2}\n`;
} else if (obj.type === 'circle') {
const cx = obj.left + obj.radius;
const cy = obj.top + obj.radius;
dxf += `0\nCIRCLE\n8\n0\n10\n${cx}\n20\n${cy}\n40\n${obj.radius}\n`;
} else if (obj.type === 'polygon') {
// LWPOLYLINE
dxf += `0\nLWPOLYLINE\n8\n0\n90\n${obj.points.length}\n`;
obj.points.forEach((pt, i) => {
dxf += `10\n${pt.x}\n20\n${pt.y}\n`;
});
}
});
dxf += '0\nENDSEC\n0\nEOF\n';
res.setHeader('Content-Type', 'application/dxf');
res.setHeader('Content-Disposition', 'attachment; filename=drawing.dxf');
res.send(dxf);
} catch (err) {
console.error('DXF export error:', err);
res.status(500).json({ error: 'Export failed' });
}
});
module.exports = router;
+56
View File
@@ -0,0 +1,56 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const router = express.Router();
const PLUGINS_DIR = path.join(__dirname, '..', 'plugins');
// Ensure plugins directory exists
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
}
// List all available plugins
router.get('/', (req, res) => {
try {
if (!fs.existsSync(PLUGINS_DIR)) {
return res.json([]);
}
const plugins = [];
const dirs = fs.readdirSync(PLUGINS_DIR);
for (const dir of dirs) {
const pluginPath = path.join(PLUGINS_DIR, dir);
const manifestPath = path.join(pluginPath, 'plugin.json');
if (fs.existsSync(manifestPath)) {
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
plugins.push({ id: dir, ...manifest });
} catch (e) {
console.warn(`Invalid plugin manifest: ${dir}`);
}
}
}
res.json(plugins);
} catch (err) {
console.error('Plugin list error:', err);
res.status(500).json({ error: 'Failed to list plugins' });
}
});
// Serve plugin code as JavaScript module
router.get('/:id/code', (req, res) => {
try {
const pluginDir = path.join(PLUGINS_DIR, req.params.id);
const codePath = path.join(pluginDir, 'plugin.js');
if (!fs.existsSync(codePath)) {
return res.status(404).json({ error: 'Plugin code not found' });
}
res.setHeader('Content-Type', 'application/javascript');
res.sendFile(codePath);
} catch (err) {
console.error('Plugin code error:', err);
res.status(500).json({ error: 'Failed to load plugin code' });
}
});
module.exports = router;
+132
View File
@@ -0,0 +1,132 @@
const { sequelize, connectDB } = require('./config/database');
const Block = require('./models/Block');
const eventBlocks = [
{
name: 'Stuhl (Standard)',
category: 'Bestuhlung',
is_public: true,
svg_data: `<svg viewBox="0 0 40 40">
<rect x="5" y="5" width="30" height="30" rx="2" fill="#4a90d9" stroke="#2c5f8a" stroke-width="2"/>
<rect x="10" y="28" width="20" height="4" rx="1" fill="#d4e6f1"/>
<circle cx="20" cy="18" r="8" fill="#d4e6f1" stroke="#2c5f8a" stroke-width="1.5"/>
</svg>`
},
{
name: 'Tisch (Rechteck 180x80)',
category: 'Möbel',
is_public: true,
svg_data: `<svg viewBox="0 0 180 80">
<rect x="0" y="0" width="180" height="80" rx="4" fill="#e8d5b7" stroke="#8b7355" stroke-width="2"/>
<rect x="5" y="5" width="170" height="70" rx="2" fill="#f5ead0" stroke="#c4a882" stroke-width="1"/>
</svg>`
},
{
name: 'Tisch (Rund Ø120)',
category: 'Möbel',
is_public: true,
svg_data: `<svg viewBox="0 0 120 120">
<circle cx="60" cy="60" r="58" fill="#e8d5b7" stroke="#8b7355" stroke-width="2"/>
<circle cx="60" cy="60" r="50" fill="#f5ead0" stroke="#c4a882" stroke-width="1"/>
</svg>`
},
{
name: 'Bühne (Modular 200x100)',
category: 'Bühne',
is_public: true,
svg_data: `<svg viewBox="0 0 200 100">
<rect x="0" y="0" width="200" height="100" fill="#8b7355" stroke="#6b5335" stroke-width="2"/>
<line x1="0" y1="25" x2="200" y2="25" stroke="#a08060" stroke-width="1"/>
<line x1="0" y1="50" x2="200" y2="50" stroke="#a08060" stroke-width="1"/>
<line x1="0" y1="75" x2="200" y2="75" stroke="#a08060" stroke-width="1"/>
<line x1="50" y1="0" x2="50" y2="100" stroke="#a08060" stroke-width="1"/>
<line x1="100" y1="0" x2="100" y2="100" stroke="#a08060" stroke-width="1"/>
<line x1="150" y1="0" x2="150" y2="100" stroke="#a08060" stroke-width="1"/>
</svg>`
},
{
name: 'Stehtisch',
category: 'Möbel',
is_public: true,
svg_data: `<svg viewBox="0 0 60 60">
<circle cx="30" cy="30" r="28" fill="#e8d5b7" stroke="#8b7355" stroke-width="2"/>
<circle cx="30" cy="30" r="24" fill="#f5ead0" stroke="#c4a882" stroke-width="1"/>
<circle cx="30" cy="30" r="5" fill="#8b7355"/>
</svg>`
},
{
name: 'Barhocker',
category: 'Bestuhlung',
is_public: true,
svg_data: `<svg viewBox="0 0 30 30">
<circle cx="15" cy="12" r="10" fill="#4a90d9" stroke="#2c5f8a" stroke-width="2"/>
<rect x="12" y="20" width="6" height="8" fill="#7f8c8d"/>
</svg>`
},
{
name: 'Mischpult',
category: 'Technik',
is_public: true,
svg_data: `<svg viewBox="0 0 120 60">
<rect x="0" y="0" width="120" height="60" rx="3" fill="#2c3e50" stroke="#1a252f" stroke-width="2"/>
<rect x="5" y="5" width="110" height="15" fill="#34495e" stroke="#2c3e50" stroke-width="1"/>
<circle cx="20" cy="40" r="5" fill="#e74c3c"/>
<circle cx="40" cy="40" r="5" fill="#e67e22"/>
<circle cx="60" cy="40" r="5" fill="#f1c40f"/>
<circle cx="80" cy="40" r="5" fill="#3498db"/>
<circle cx="100" cy="40" r="5" fill="#2ecc71"/>
</svg>`
},
{
name: 'Lautsprecher',
category: 'Technik',
is_public: true,
svg_data: `<svg viewBox="0 0 40 60">
<rect x="0" y="0" width="40" height="60" rx="3" fill="#34495e" stroke="#1a252f" stroke-width="2"/>
<rect x="8" y="8" width="24" height="25" rx="12" fill="#1a252f"/>
<circle cx="20" cy="20" r="6" fill="#7f8c8d"/>
<rect x="8" y="40" width="24" height="5" rx="2" fill="#1a252f"/>
</svg>`
},
{
name: 'Trennwand',
category: 'Raum',
is_public: true,
svg_data: `<svg viewBox="0 0 10 100">
<rect x="0" y="0" width="10" height="100" fill="#bdc3c7" stroke="#7f8c8d" stroke-width="1"/>
<line x1="2" y1="0" x2="2" y2="100" stroke="#95a5a6" stroke-width="0.5"/>
<line x1="5" y1="0" x2="5" y2="100" stroke="#95a5a6" stroke-width="0.5"/>
<line x1="8" y1="0" x2="8" y2="100" stroke="#95a5a6" stroke-width="0.5"/>
</svg>`
},
{
name: 'Notausgang-Schild',
category: 'Sicherheit',
is_public: true,
svg_data: `<svg viewBox="0 0 40 20">
<rect x="0" y="0" width="40" height="20" rx="2" fill="#27ae60" stroke="#1e8449" stroke-width="1"/>
<polygon points="8,5 3,10 8,15 7,15 2,10 7,5" fill="white"/>
<text x="20" y="15" font-size="8" fill="white" text-anchor="middle" font-family="Arial">EXIT</text>
</svg>`
},
];
async function seed() {
try {
await connectDB();
// Clear existing blocks
await Block.destroy({ where: {} });
console.log('Existing blocks cleared.');
// Insert new blocks
for (const block of eventBlocks) {
await Block.create(block);
}
console.log(`${eventBlocks.length} blocks created successfully.`);
process.exit(0);
} catch (err) {
console.error('Seed failed:', err);
process.exit(1);
}
}
seed();
+30
View File
@@ -0,0 +1,30 @@
const express = require('express');
const cors = require('cors');
const passport = require('passport');
require('./config/passport'); // Load JWT strategy
const { sequelize, connectDB } = require('./config/database');
const app = express();
const PORT = process.env.PORT || 5000;
// Middleware
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(passport.initialize());
// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/drawings', require('./routes/drawings'));
app.use('/api/blocks', require('./routes/blocks'));
app.use('/api/dxf', require('./routes/dxf'));
app.use('/api/plugins', require('./routes/plugins'));
// Health check
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
// Connect DB and start
connectDB().then(() => {
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
}).catch(err => {
console.error('Unable to start server:', err);
});
-98
View File
@@ -1,98 +0,0 @@
/**
* AuthService Registration, Login, Session Management
*/
import bcrypt from 'bcrypt';
import { randomUUID } from 'crypto';
import type { DatabaseInterface, DBUser, DBSession } 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 {
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 expiresAt = Date.now() + SESSION_TTL_MS;
this.db.createSession({ token, user_id: userId, expires_at: expiresAt });
return { token, userId, expiresAt };
}
validateSession(token: string): Session | null {
const dbSession = this.db.getSession(token);
if (!dbSession) return null;
if (Date.now() > dbSession.expires_at) {
this.db.deleteSession(token);
return null;
}
return {
token: dbSession.token,
userId: dbSession.user_id,
expiresAt: dbSession.expires_at,
};
}
destroySession(token: string): void {
this.db.deleteSession(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;
}
}
-46
View File
@@ -1,46 +0,0 @@
/**
* Shared Auth Middleware extractToken, requireAuth, requireAdmin
*/
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { AuthService } from './AuthService.js';
import type { DBUser } from '../database/DatabaseInterface.js';
export function extractToken(request: FastifyRequest): string | null {
const auth = request.headers?.authorization;
if (auth && auth.startsWith('Bearer ')) {
return auth.slice(7);
}
return null;
}
/**
* Require any authenticated user (valid session).
* Returns the user object or null (and sends error reply).
*/
export function requireAuth(request: FastifyRequest, reply: FastifyReply, authService: AuthService): DBUser | null {
const token = extractToken(request);
if (!token) {
reply.code(401).send({ error: 'Authentication required' });
return null;
}
const user = authService.getUserFromSession(token);
if (!user) {
reply.code(401).send({ error: 'Invalid or expired session' });
return null;
}
return user;
}
/**
* Require admin role (valid session + admin role).
* Returns the user object or null (and sends error reply).
*/
export function requireAdmin(request: FastifyRequest, reply: FastifyReply, authService: AuthService): DBUser | null {
const user = requireAuth(request, reply, authService);
if (!user) return null;
if (user.role !== 'admin') {
reply.code(403).send({ error: 'Admin access required' });
return null;
}
return user;
}
-216
View File
@@ -1,216 +0,0 @@
/**
* Database Interface abstract DB layer for Web CAD
*/
export interface DBProject {
id: string;
name: string;
description: string | null;
owner_id: string;
folder_id: string | null;
created_at: string;
updated_at: string;
}
export interface DBProjectFolder {
id: string;
name: string;
parent_id: string | null;
owner_id: string;
created_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 DBSession {
token: string;
user_id: string;
expires_at: number;
created_at: string;
}
export interface DBNotification {
id: string;
user_id: string;
type: string;
title: string;
message: string;
read: number;
created_at: string;
}
export interface DBProjectShare {
id: string;
project_id: string;
shared_with_email: string;
shared_by: string;
permission: string;
share_token: string | null;
created_at: string;
}
export interface DBGlobalBlockFolder {
id: string;
name: string;
parent_id: string | null;
created_at: string;
}
export interface DBGlobalBlock {
id: string;
folder_id: string | null;
name: string;
block_data: string;
svg_data: string | null;
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(ownerId?: string, folderId?: string | null): DBProject[];
getProject(id: string): DBProject | null;
createProject(data: Partial<DBProject>): DBProject;
updateProject(id: string, data: Partial<DBProject>): DBProject | null;
deleteProject(id: string): boolean;
moveProjectToFolder(projectId: string, folderId: string | null): DBProject | null;
// Project Folders
listProjectFolders(ownerId: string): DBProjectFolder[];
getProjectFolder(id: string): DBProjectFolder | null;
createProjectFolder(data: Partial<DBProjectFolder>): DBProjectFolder;
updateProjectFolder(id: string, data: Partial<DBProjectFolder>): DBProjectFolder | null;
deleteProjectFolder(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;
// Sessions
createSession(data: Partial<DBSession>): DBSession;
getSession(token: string): DBSession | null;
deleteSession(token: string): boolean;
deleteExpiredSessions(): void;
// Notifications
listNotifications(userId: string): DBNotification[];
getNotification(id: string): DBNotification | null;
createNotification(data: Partial<DBNotification>): DBNotification;
markNotificationRead(id: string): boolean;
deleteNotification(id: string): boolean;
// Project Shares
listProjectShares(projectId: string): DBProjectShare[];
getProjectShare(id: string): DBProjectShare | null;
createProjectShare(data: Partial<DBProjectShare>): DBProjectShare;
deleteProjectShare(id: string): boolean;
// Global Block Folders
listGlobalFolders(parentId?: string | null): DBGlobalBlockFolder[];
getGlobalFolder(id: string): DBGlobalBlockFolder | null;
createGlobalFolder(data: Partial<DBGlobalBlockFolder>): DBGlobalBlockFolder;
updateGlobalFolder(id: string, data: Partial<DBGlobalBlockFolder>): DBGlobalBlockFolder | null;
deleteGlobalFolder(id: string): boolean;
// Global Blocks
listGlobalBlocks(folderId?: string | null): DBGlobalBlock[];
getGlobalBlock(id: string): DBGlobalBlock | null;
createGlobalBlock(data: Partial<DBGlobalBlock>): DBGlobalBlock;
updateGlobalBlock(id: string, data: Partial<DBGlobalBlock>): DBGlobalBlock | null;
deleteGlobalBlock(id: string): boolean;
}
-458
View File
@@ -1,458 +0,0 @@
/**
* 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 { randomUUID } from 'crypto';
import type {
DatabaseInterface, DBProject, DBDrawing, DBLayer,
DBElement, DBBlock, DBSetting, DBUser, DBSession,
DBNotification, DBProjectShare,
DBGlobalBlockFolder, DBGlobalBlock,
DBProjectFolder,
} 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);
// Add folder_id column to projects table for existing databases
const columns = this.db.prepare("PRAGMA table_info('projects')").all() as { name: string }[];
if (!columns.some(col => col.name === 'folder_id')) {
this.db.exec('ALTER TABLE projects ADD COLUMN folder_id TEXT REFERENCES project_folders(id) ON DELETE SET NULL');
}
}
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(ownerId?: string, folderId?: string | null): DBProject[] {
if (ownerId && folderId !== undefined) {
if (folderId === null) {
return this.db.prepare('SELECT * FROM projects WHERE owner_id = ? AND folder_id IS NULL ORDER BY updated_at DESC').all(ownerId) as DBProject[];
}
return this.db.prepare('SELECT * FROM projects WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC').all(ownerId, folderId) as DBProject[];
}
if (ownerId) {
return this.db.prepare('SELECT * FROM projects WHERE owner_id = ? ORDER BY updated_at DESC').all(ownerId) as DBProject[];
}
if (folderId !== undefined) {
if (folderId === null) {
return this.db.prepare('SELECT * FROM projects WHERE folder_id IS NULL ORDER BY updated_at DESC').all() as DBProject[];
}
return this.db.prepare('SELECT * FROM projects WHERE folder_id = ? ORDER BY updated_at DESC').all(folderId) as 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, folder_id) VALUES (?, ?, ?, ?, ?)',
).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default', data.folder_id ?? null);
return this.getProject(id)!;
}
updateProject(id: string, data: Partial<DBProject>): DBProject | null {
const sets: string[] = [];
const vals: unknown[] = [];
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.description !== undefined) { sets.push('description = ?'); vals.push(data.description); }
if (data.folder_id !== undefined) { sets.push('folder_id = ?'); vals.push(data.folder_id); }
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;
}
moveProjectToFolder(projectId: string, folderId: string | null): DBProject | null {
this.db.prepare('UPDATE projects SET folder_id = ?, updated_at = datetime(\'now\') WHERE id = ?').run(folderId, projectId);
return this.getProject(projectId);
}
// ─── Project Folders ─────────────────────────────────
listProjectFolders(ownerId: string): DBProjectFolder[] {
return this.db.prepare('SELECT * FROM project_folders WHERE owner_id = ? ORDER BY name').all(ownerId) as DBProjectFolder[];
}
getProjectFolder(id: string): DBProjectFolder | null {
return (this.db.prepare('SELECT * FROM project_folders WHERE id = ?').get(id) as DBProjectFolder) ?? null;
}
createProjectFolder(data: Partial<DBProjectFolder>): DBProjectFolder {
const id = data.id ?? `pfolder-${Date.now()}`;
this.db.prepare(
'INSERT INTO project_folders (id, name, parent_id, owner_id) VALUES (?, ?, ?, ?)',
).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null, data.owner_id ?? 'user-default');
return this.getProjectFolder(id)!;
}
updateProjectFolder(id: string, data: Partial<DBProjectFolder>): DBProjectFolder | null {
const sets: string[] = [];
const vals: unknown[] = [];
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.parent_id !== undefined) { sets.push('parent_id = ?'); vals.push(data.parent_id); }
if (sets.length > 0) {
this.db.prepare(`UPDATE project_folders SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.getProjectFolder(id);
}
deleteProjectFolder(id: string): boolean {
// Move projects inside this folder to root (folder_id = NULL) before deleting
this.db.prepare('UPDATE projects SET folder_id = NULL WHERE folder_id = ?').run(id);
return this.db.prepare('DELETE FROM project_folders 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 !== undefined ? (data.visible ? 1 : 0) : 1, data.locked !== undefined ? (data.locked ? 1 : 0) : 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(k === 'visible' || k === 'locked' ? (v ? 1 : 0) : 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);
}
// ─── Sessions ───────────────────────────────────────
createSession(data: Partial<DBSession>): DBSession {
const token = data.token ?? randomUUID();
this.db.prepare(
'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)',
).run(token, data.user_id!, data.expires_at!);
return this.getSession(token)!;
}
getSession(token: string): DBSession | null {
return (this.db.prepare('SELECT * FROM sessions WHERE token = ?').get(token) as DBSession) ?? null;
}
deleteSession(token: string): boolean {
return this.db.prepare('DELETE FROM sessions WHERE token = ?').run(token).changes > 0;
}
deleteExpiredSessions(): void {
// Use consistent epoch-millisecond integer comparison.
// <= ensures sessions expiring at exactly the current time are also deleted.
const now = Math.floor(Date.now());
this.db.prepare('DELETE FROM sessions WHERE expires_at <= ?').run(now);
}
// ─── Notifications ──────────────────────────────────
listNotifications(userId: string): DBNotification[] {
return this.db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC').all(userId) as DBNotification[];
}
getNotification(id: string): DBNotification | null {
return this.db.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as DBNotification | null;
}
createNotification(data: Partial<DBNotification>): DBNotification {
const id = data.id ?? `notif-${Date.now()}`;
this.db.prepare(
'INSERT INTO notifications (id, user_id, type, title, message, read) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, data.user_id!, data.type ?? 'info', data.title!, data.message!, data.read ?? 0);
return this.db.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as DBNotification;
}
markNotificationRead(id: string): boolean {
return this.db.prepare('UPDATE notifications SET read = 1 WHERE id = ?').run(id).changes > 0;
}
deleteNotification(id: string): boolean {
return this.db.prepare('DELETE FROM notifications WHERE id = ?').run(id).changes > 0;
}
// ─── Project Shares ─────────────────────────────────
listProjectShares(projectId: string): DBProjectShare[] {
return this.db.prepare('SELECT * FROM project_shares WHERE project_id = ? ORDER BY created_at DESC').all(projectId) as DBProjectShare[];
}
getProjectShare(id: string): DBProjectShare | null {
return this.db.prepare('SELECT * FROM project_shares WHERE id = ?').get(id) as DBProjectShare | null;
}
createProjectShare(data: Partial<DBProjectShare>): DBProjectShare {
const id = data.id ?? `share-${Date.now()}`;
const shareToken = data.share_token ?? randomUUID();
this.db.prepare(
'INSERT INTO project_shares (id, project_id, shared_with_email, shared_by, permission, share_token) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, data.project_id!, data.shared_with_email!, data.shared_by!, data.permission ?? 'view', shareToken);
return this.db.prepare('SELECT * FROM project_shares WHERE id = ?').get(id) as DBProjectShare;
}
deleteProjectShare(id: string): boolean {
return this.db.prepare('DELETE FROM project_shares WHERE id = ?').run(id).changes > 0;
}
// ─── Global Block Folders ──────────────────────────────
listGlobalFolders(parentId?: string | null): DBGlobalBlockFolder[] {
if (parentId === undefined) {
return this.db.prepare('SELECT * FROM global_block_folders ORDER BY name').all() as DBGlobalBlockFolder[];
}
if (parentId === null) {
return this.db.prepare('SELECT * FROM global_block_folders WHERE parent_id IS NULL ORDER BY name').all() as DBGlobalBlockFolder[];
}
return this.db.prepare('SELECT * FROM global_block_folders WHERE parent_id = ? ORDER BY name').all(parentId) as DBGlobalBlockFolder[];
}
getGlobalFolder(id: string): DBGlobalBlockFolder | null {
return (this.db.prepare('SELECT * FROM global_block_folders WHERE id = ?').get(id) as DBGlobalBlockFolder) ?? null;
}
createGlobalFolder(data: Partial<DBGlobalBlockFolder>): DBGlobalBlockFolder {
const id = data.id ?? `gfolder-${Date.now()}`;
this.db.prepare(
'INSERT INTO global_block_folders (id, name, parent_id) VALUES (?, ?, ?)',
).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null);
return this.getGlobalFolder(id)!;
}
updateGlobalFolder(id: string, data: Partial<DBGlobalBlockFolder>): DBGlobalBlockFolder | null {
const sets: string[] = [];
const vals: unknown[] = [];
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.parent_id !== undefined) { sets.push('parent_id = ?'); vals.push(data.parent_id); }
if (sets.length > 0) {
this.db.prepare(`UPDATE global_block_folders SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.getGlobalFolder(id);
}
deleteGlobalFolder(id: string): boolean {
return this.db.prepare('DELETE FROM global_block_folders WHERE id = ?').run(id).changes > 0;
}
// ─── Global Blocks ─────────────────────────────────────
listGlobalBlocks(folderId?: string | null): DBGlobalBlock[] {
if (folderId === undefined) {
return this.db.prepare('SELECT * FROM global_blocks ORDER BY updated_at DESC').all() as DBGlobalBlock[];
}
if (folderId === null) {
return this.db.prepare('SELECT * FROM global_blocks WHERE folder_id IS NULL ORDER BY updated_at DESC').all() as DBGlobalBlock[];
}
return this.db.prepare('SELECT * FROM global_blocks WHERE folder_id = ? ORDER BY updated_at DESC').all(folderId) as DBGlobalBlock[];
}
getGlobalBlock(id: string): DBGlobalBlock | null {
return (this.db.prepare('SELECT * FROM global_blocks WHERE id = ?').get(id) as DBGlobalBlock) ?? null;
}
createGlobalBlock(data: Partial<DBGlobalBlock>): DBGlobalBlock {
const id = data.id ?? `gblock-${Date.now()}`;
this.db.prepare(
'INSERT INTO global_blocks (id, folder_id, name, block_data, svg_data) VALUES (?, ?, ?, ?, ?)',
).run(id, data.folder_id ?? null, data.name ?? 'Global Block', data.block_data ?? '{}', data.svg_data ?? null);
return this.getGlobalBlock(id)!;
}
updateGlobalBlock(id: string, data: Partial<DBGlobalBlock>): DBGlobalBlock | null {
const sets: string[] = [];
const vals: unknown[] = [];
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.folder_id !== undefined) { sets.push('folder_id = ?'); vals.push(data.folder_id); }
if (data.block_data !== undefined) { sets.push('block_data = ?'); vals.push(data.block_data); }
if (data.svg_data !== undefined) { sets.push('svg_data = ?'); vals.push(data.svg_data); }
sets.push("updated_at = datetime('now')");
this.db.prepare(`UPDATE global_blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
return this.getGlobalBlock(id);
}
deleteGlobalBlock(id: string): boolean {
return this.db.prepare('DELETE FROM global_blocks WHERE id = ?').run(id).changes > 0;
}
}
-178
View File
@@ -1,178 +0,0 @@
-- 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
);
-- ─── Sessions ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(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'))
);
-- ─── Project Folders ───────────────────────────────────
CREATE TABLE IF NOT EXISTS project_folders (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_id TEXT,
owner_id TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (parent_id) REFERENCES project_folders(id) ON DELETE CASCADE,
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_project_folders_parent ON project_folders(parent_id);
CREATE INDEX IF NOT EXISTS idx_project_folders_owner ON project_folders(owner_id);
-- ─── Projects: add folder_id column ─────────────────────
-- NOTE: ALTER TABLE is idempotent-safe via try/catch in adapter; schema.sql
-- is only exec'd on fresh databases. For existing DBs, the adapter runs
-- the ALTER in init().
-- ─── 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);
-- ─── Notifications ───────────────────────────────────
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'info',
title TEXT NOT NULL,
message TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- ─── Project Shares ──────────────────────────────────
CREATE TABLE IF NOT EXISTS project_shares (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
shared_with_email TEXT NOT NULL,
shared_by TEXT NOT NULL,
permission TEXT NOT NULL DEFAULT 'view' CHECK(permission IN ('view','edit','admin')),
share_token TEXT UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (shared_by) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_shares_project ON project_shares(project_id);
-- ─── Global Block Folders ─────────────────────────────
CREATE TABLE IF NOT EXISTS global_block_folders (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (parent_id) REFERENCES global_block_folders(id) ON DELETE CASCADE
);
-- ─── Global Blocks ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS global_blocks (
id TEXT PRIMARY KEY,
folder_id TEXT,
name TEXT NOT NULL,
block_data TEXT NOT NULL DEFAULT '{}',
svg_data TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (folder_id) REFERENCES global_block_folders(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_global_blocks_folder ON global_blocks(folder_id);
CREATE INDEX IF NOT EXISTS idx_global_block_folders_parent ON global_block_folders(parent_id);
-40
View File
@@ -1,40 +0,0 @@
/**
* 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
@@ -1,132 +0,0 @@
/**
* 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
@@ -1,82 +0,0 @@
/**
* 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;
}
-58
View File
@@ -1,58 +0,0 @@
/**
* Blocks Routes CRUD for reusable drawing blocks
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBBlock } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateName, validateIdParam } from '../utils/validation.js';
export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List all blocks for a drawing
fastify.get('/api/drawings/:drawingId/blocks', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { drawingId } = request.params as { drawingId: string };
const idErr = validateIdParam(drawingId, 'drawingId');
if (idErr) return reply.code(400).send({ error: idErr });
return db.listBlocks(drawingId);
});
// Create a new block
fastify.post('/api/drawings/:drawingId/blocks', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { drawingId } = request.params as { drawingId: string };
const idErr = validateIdParam(drawingId, 'drawingId');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBBlock>;
const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr });
return reply.code(201).send(db.createBlock({ ...body, drawing_id: drawingId }));
});
// Update a block
fastify.patch('/api/blocks/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBBlock>;
if (body.name !== undefined) {
const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr });
}
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) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteBlock(id);
if (!ok) return reply.code(404).send({ error: 'Block not found' });
return reply.code(204).send();
});
}
-71
View File
@@ -1,71 +0,0 @@
/**
* Drawings Routes CRUD for drawings
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBDrawing } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateName, validateIdParam } from '../utils/validation.js';
export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List drawings for a project
fastify.get('/api/projects/:projectId/drawings', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { projectId } = request.params as { projectId: string };
const idErr = validateIdParam(projectId, 'projectId');
if (idErr) return reply.code(400).send({ error: idErr });
return db.listDrawings(projectId);
});
// Get single drawing
fastify.get('/api/drawings/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
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) => {
if (!requireAuth(request, reply, authService)) return;
const { projectId } = request.params as { projectId: string };
const idErr = validateIdParam(projectId, 'projectId');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBDrawing>;
if (body.name !== undefined) {
const nameErr = validateName(body.name);
if (nameErr) return reply.code(400).send({ error: nameErr });
}
return reply.code(201).send(db.createDrawing({ ...body, project_id: projectId }));
});
// Update drawing
fastify.patch('/api/drawings/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBDrawing>;
if (body.name !== undefined) {
const nameErr = validateName(body.name);
if (nameErr) return reply.code(400).send({ error: nameErr });
}
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) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteDrawing(id);
if (!ok) return reply.code(404).send({ error: 'Drawing not found' });
return reply.code(204).send();
});
}
-52
View File
@@ -1,52 +0,0 @@
/**
* Elements Routes CRUD for elements
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBElement } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateIdParam } from '../utils/validation.js';
export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List elements for a drawing
fastify.get('/api/drawings/:drawingId/elements', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { drawingId } = request.params as { drawingId: string };
const idErr = validateIdParam(drawingId, 'drawingId');
if (idErr) return reply.code(400).send({ error: idErr });
return db.listElements(drawingId);
});
// Create element
fastify.post('/api/drawings/:drawingId/elements', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { drawingId } = request.params as { drawingId: string };
const idErr = validateIdParam(drawingId, 'drawingId');
if (idErr) return reply.code(400).send({ error: idErr });
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) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
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) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteElement(id);
if (!ok) return reply.code(404).send({ error: 'Element not found' });
return reply.code(204).send();
});
}
-167
View File
@@ -1,167 +0,0 @@
/**
* Global Blocks Routes CRUD for global block folders and blocks
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateName, validateIdParam } from '../utils/validation.js';
export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// ─── Global Block Folders ──────────────────────────────
// List all folders, optionally filtered by parent
fastify.get('/api/global-folders', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const parentId = (request.query as { parentId?: string | null }).parentId;
if (parentId === undefined || parentId === '' || parentId === 'null') {
return db.listGlobalFolders(parentId === 'null' ? null : undefined);
}
const idErr = validateIdParam(parentId, 'parentId');
if (idErr) return reply.code(400).send({ error: idErr });
return db.listGlobalFolders(parentId);
});
// Get a single folder
fastify.get('/api/global-folders/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const folder = db.getGlobalFolder(id);
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
return folder;
});
// Create a new folder
fastify.post('/api/global-folders', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const body = request.body as { name?: string; parent_id?: string | null };
const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr });
if (body.parent_id !== undefined && body.parent_id !== null) {
const pidErr = validateIdParam(body.parent_id, 'parent_id');
if (pidErr) return reply.code(400).send({ error: pidErr });
}
return reply.code(201).send(db.createGlobalFolder({ name: body.name, parent_id: body.parent_id ?? null }));
});
// Rename / move a folder
fastify.patch('/api/global-folders/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as { name?: string; parent_id?: string | null };
if (body.name !== undefined) {
const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr });
}
if (body.parent_id !== undefined && body.parent_id !== null) {
const pidErr = validateIdParam(body.parent_id, 'parent_id');
if (pidErr) return reply.code(400).send({ error: pidErr });
// Prevent setting parent to self
if (body.parent_id === id) return reply.code(400).send({ error: 'Cannot set parent to self' });
}
const updated = db.updateGlobalFolder(id, { name: body.name, parent_id: body.parent_id });
if (!updated) return reply.code(404).send({ error: 'Folder not found' });
return updated;
});
// Delete a folder (cascades to children, blocks get folder_id = NULL)
fastify.delete('/api/global-folders/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteGlobalFolder(id);
if (!ok) return reply.code(404).send({ error: 'Folder not found' });
return reply.code(204).send();
});
// ─── Global Blocks ────────────────────────────────────
// List all global blocks, optionally filtered by folder
fastify.get('/api/global-blocks', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const folderId = (request.query as { folderId?: string | null }).folderId;
if (folderId === undefined || folderId === '' || folderId === 'null') {
return db.listGlobalBlocks(folderId === 'null' ? null : undefined);
}
const idErr = validateIdParam(folderId, 'folderId');
if (idErr) return reply.code(400).send({ error: idErr });
return db.listGlobalBlocks(folderId);
});
// Get a single global block
fastify.get('/api/global-blocks/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const block = db.getGlobalBlock(id);
if (!block) return reply.code(404).send({ error: 'Global block not found' });
return block;
});
// Create a new global block
fastify.post('/api/global-blocks', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string };
const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr });
if (body.folder_id !== undefined && body.folder_id !== null) {
const fidErr = validateIdParam(body.folder_id, 'folder_id');
if (fidErr) return reply.code(400).send({ error: fidErr });
}
if (body.block_data !== undefined && typeof body.block_data !== 'string') {
return reply.code(400).send({ error: 'block_data must be a JSON string' });
}
if (body.svg_data !== undefined && typeof body.svg_data !== 'string') {
return reply.code(400).send({ error: 'svg_data must be a string' });
}
return reply.code(201).send(db.createGlobalBlock({
name: body.name,
folder_id: body.folder_id ?? null,
block_data: body.block_data ?? '{}',
svg_data: body.svg_data ?? null,
}));
});
// Update a global block (rename, move, update data)
fastify.patch('/api/global-blocks/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string };
if (body.name !== undefined) {
const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr });
}
if (body.folder_id !== undefined && body.folder_id !== null) {
const fidErr = validateIdParam(body.folder_id, 'folder_id');
if (fidErr) return reply.code(400).send({ error: fidErr });
}
if (body.block_data !== undefined && typeof body.block_data !== 'string') {
return reply.code(400).send({ error: 'block_data must be a JSON string' });
}
if (body.svg_data !== undefined && typeof body.svg_data !== 'string') {
return reply.code(400).send({ error: 'svg_data must be a string' });
}
const updated = db.updateGlobalBlock(id, body);
if (!updated) return reply.code(404).send({ error: 'Global block not found' });
return updated;
});
// Delete a global block
fastify.delete('/api/global-blocks/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteGlobalBlock(id);
if (!ok) return reply.code(404).send({ error: 'Global block not found' });
return reply.code(204).send();
});
}
-60
View File
@@ -1,60 +0,0 @@
/**
* Layers Routes CRUD for layers
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBLayer } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateName, validateIdParam } from '../utils/validation.js';
export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List layers for a drawing
fastify.get('/api/drawings/:drawingId/layers', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { drawingId } = request.params as { drawingId: string };
const idErr = validateIdParam(drawingId, 'drawingId');
if (idErr) return reply.code(400).send({ error: idErr });
return db.listLayers(drawingId);
});
// Create layer
fastify.post('/api/drawings/:drawingId/layers', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { drawingId } = request.params as { drawingId: string };
const idErr = validateIdParam(drawingId, 'drawingId');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBLayer>;
if (body.name !== undefined) {
const nameErr = validateName(body.name);
if (nameErr) return reply.code(400).send({ error: nameErr });
}
return reply.code(201).send(db.createLayer({ ...body, drawing_id: drawingId }));
});
// Update layer
fastify.patch('/api/layers/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBLayer>;
if (body.name !== undefined) {
const nameErr = validateName(body.name);
if (nameErr) return reply.code(400).send({ error: nameErr });
}
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) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteLayer(id);
if (!ok) return reply.code(404).send({ error: 'Layer not found' });
return reply.code(204).send();
});
}
-76
View File
@@ -1,76 +0,0 @@
/**
* Notifications Routes List, create, mark read, delete notifications
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateIdParam } from '../utils/validation.js';
function extractToken(request: any): string | null {
const auth = request.headers?.authorization;
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
return null;
}
export function registerNotificationRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List notifications for current user
fastify.get('/api/notifications', async (request, reply) => {
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' });
return db.listNotifications(user.id);
});
// Create notification (only for self)
fastify.post('/api/notifications', async (request, reply) => {
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 body = request.body as { type?: string; title?: string; message?: string };
if (!body.title || !body.message) return reply.code(400).send({ error: 'title and message are required' });
const validTypes = ['info', 'share', 'warning', 'error'];
const type = validTypes.includes(body.type ?? '') ? body.type! : 'info';
return reply.code(201).send(db.createNotification({
user_id: user.id,
type,
title: body.title,
message: body.message,
}));
});
// Mark notification as read (ownership check)
fastify.patch('/api/notifications/:id/read', async (request, reply) => {
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 { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const notif = db.getNotification(id);
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
const ok = db.markNotificationRead(id);
if (!ok) return reply.code(404).send({ error: 'Notification not found' });
return reply.code(204).send();
});
// Delete notification (ownership check)
fastify.delete('/api/notifications/:id', async (request, reply) => {
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 { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const notif = db.getNotification(id);
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
const ok = db.deleteNotification(id);
if (!ok) return reply.code(404).send({ error: 'Notification not found' });
return reply.code(204).send();
});
}
-116
View File
@@ -1,116 +0,0 @@
/**
* Project Folders Routes CRUD for project folders + move project to folder
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateName, validateIdParam } from '../utils/validation.js';
export function registerProjectFolderRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// ─── Project Folders ──────────────────────────────────
// List all folders for the authenticated user
fastify.get('/api/project-folders', async (request, reply) => {
const user = requireAuth(request, reply, authService);
if (!user) return;
return db.listProjectFolders(user.id);
});
// Get a single folder
fastify.get('/api/project-folders/:id', async (request, reply) => {
const user = requireAuth(request, reply, authService);
if (!user) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const folder = db.getProjectFolder(id);
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
return folder;
});
// Create a new folder
fastify.post('/api/project-folders', async (request, reply) => {
const user = requireAuth(request, reply, authService);
if (!user) return;
const body = request.body as { name?: string; parent_id?: string | null };
const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr });
if (body.parent_id !== undefined && body.parent_id !== null) {
const pidErr = validateIdParam(body.parent_id, 'parent_id');
if (pidErr) return reply.code(400).send({ error: pidErr });
// Verify parent exists and belongs to user
const parent = db.getProjectFolder(body.parent_id);
if (!parent) return reply.code(404).send({ error: 'Parent folder not found' });
if (parent.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
}
return reply.code(201).send(db.createProjectFolder({
name: body.name,
parent_id: body.parent_id ?? null,
owner_id: user.id,
}));
});
// Rename a folder
fastify.put('/api/project-folders/:id', async (request, reply) => {
const user = requireAuth(request, reply, authService);
if (!user) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const folder = db.getProjectFolder(id);
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
const body = request.body as { name?: string; parent_id?: string | null };
const updates: Partial<{ name: string; parent_id: string | null }> = {};
if (body.name !== undefined) {
const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr });
updates.name = body.name;
}
if (body.parent_id !== undefined) updates.parent_id = body.parent_id;
const updated = db.updateProjectFolder(id, updates);
if (!updated) return reply.code(404).send({ error: 'Folder not found' });
return updated;
});
// Delete a folder (projects inside move to root)
fastify.delete('/api/project-folders/:id', async (request, reply) => {
const user = requireAuth(request, reply, authService);
if (!user) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const folder = db.getProjectFolder(id);
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
const ok = db.deleteProjectFolder(id);
if (!ok) return reply.code(404).send({ error: 'Folder not found' });
return reply.code(204).send();
});
// Move project to folder
fastify.put('/api/projects/:id/folder', async (request, reply) => {
const user = requireAuth(request, reply, authService);
if (!user) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const project = db.getProject(id);
if (!project) return reply.code(404).send({ error: 'Project not found' });
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
const body = request.body as { folder_id?: string | null };
if (body.folder_id !== undefined && body.folder_id !== null) {
const fidErr = validateIdParam(body.folder_id, 'folder_id');
if (fidErr) return reply.code(400).send({ error: fidErr });
// Verify folder exists and belongs to user
const folder = db.getProjectFolder(body.folder_id);
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
}
const updated = db.moveProjectToFolder(id, body.folder_id ?? null);
if (!updated) return reply.code(404).send({ error: 'Project not found' });
return updated;
});
}
-71
View File
@@ -1,71 +0,0 @@
/**
* Projects Routes CRUD for projects
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBProject } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateName, validateIdParam } from '../utils/validation.js';
export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List projects owned by the authenticated user, optionally filtered by folder
fastify.get('/api/projects', async (request, reply) => {
const user = requireAuth(request, reply, authService);
if (!user) return;
const folderId = (request.query as { folderId?: string | null }).folderId;
if (folderId === undefined || folderId === '' || folderId === 'null') {
return db.listProjects(user.id, folderId === 'null' ? null : undefined);
}
const fidErr = validateIdParam(folderId, 'folderId');
if (fidErr) return reply.code(400).send({ error: fidErr });
return db.listProjects(user.id, folderId);
});
// Get single project
fastify.get('/api/projects/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
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 user = requireAuth(request, reply, authService);
if (!user) return;
const body = request.body as Partial<DBProject>;
const nameErr = validateName(body.name);
if (nameErr) return reply.code(400).send({ error: nameErr });
return reply.code(201).send(db.createProject({ ...body, owner_id: user.id }));
});
// Update project
fastify.patch('/api/projects/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const body = request.body as Partial<DBProject>;
if (body.name !== undefined) {
const nameErr = validateName(body.name);
if (nameErr) return reply.code(400).send({ error: nameErr });
}
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) => {
if (!requireAuth(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteProject(id);
if (!ok) return reply.code(404).send({ error: 'Project not found' });
return reply.code(204).send();
});
}
-34
View File
@@ -1,34 +0,0 @@
/**
* Settings Routes key/value application settings
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateSettingsKey, validateSettingsValue } from '../utils/validation.js';
export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// Get a single setting by key
fastify.get('/api/settings/:key', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { key } = request.params as { key: string };
const keyErr = validateSettingsKey(key);
if (keyErr) return reply.code(400).send({ error: keyErr });
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, reply) => {
if (!requireAuth(request, reply, authService)) return;
const { key } = request.params as { key: string };
const keyErr = validateSettingsKey(key);
if (keyErr) return reply.code(400).send({ error: keyErr });
const body = request.body as { value?: string };
const valueErr = validateSettingsValue(body.value);
if (valueErr) return reply.code(400).send({ error: valueErr });
db.setSetting(key, body.value as string);
return { key, value: body.value as string, updated_at: new Date().toISOString() };
});
}
-88
View File
@@ -1,88 +0,0 @@
/**
* Project Shares Routes List, create, delete project shares
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateIdParam } from '../utils/validation.js';
function extractToken(request: any): string | null {
const auth = request.headers?.authorization;
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
return null;
}
const VALID_PERMISSIONS = ['view', 'edit', 'admin'];
export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List shares for a project (owner only)
fastify.get('/api/projects/:projectId/shares', async (request, reply) => {
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 { projectId } = request.params as { projectId: string };
const projIdErr = validateIdParam(projectId, 'projectId');
if (projIdErr) return reply.code(400).send({ error: projIdErr });
const project = db.getProject(projectId);
if (!project) return reply.code(404).send({ error: 'Project not found' });
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
return db.listProjectShares(projectId);
});
// Create a project share (owner only)
fastify.post('/api/projects/:projectId/shares', async (request, reply) => {
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 { projectId } = request.params as { projectId: string };
const projIdErr = validateIdParam(projectId, 'projectId');
if (projIdErr) return reply.code(400).send({ error: projIdErr });
const project = db.getProject(projectId);
if (!project) return reply.code(404).send({ error: 'Project not found' });
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
const body = request.body as { shared_with_email?: string; permission?: string };
if (!body.shared_with_email) return reply.code(400).send({ error: 'shared_with_email is required' });
const permission = VALID_PERMISSIONS.includes(body.permission ?? '') ? body.permission! : 'view';
const share = db.createProjectShare({
project_id: projectId,
shared_with_email: body.shared_with_email,
shared_by: user.id,
permission,
});
// Create a notification for the shared user if they exist
const sharedUser = db.getUserByEmail(body.shared_with_email);
if (sharedUser) {
db.createNotification({
user_id: sharedUser.id,
type: 'share',
title: 'Projekt geteilt',
message: `${user.name} hat ein Projekt mit Ihnen geteilt: ${project.name}`,
});
}
return reply.code(201).send(share);
});
// Delete a project share (owner only)
fastify.delete('/api/shares/:id', async (request, reply) => {
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 { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const share = db.getProjectShare(id);
if (!share) return reply.code(404).send({ error: 'Share not found' });
const project = db.getProject(share.project_id);
if (!project) return reply.code(404).send({ error: 'Project not found' });
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
const ok = db.deleteProjectShare(id);
if (!ok) return reply.code(404).send({ error: 'Share not found' });
return reply.code(204).send();
});
}
-80
View File
@@ -1,80 +0,0 @@
/**
* Users Routes Admin user management
*/
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js';
import type { AuthService } from '../auth/AuthService.js';
import { validateIdParam } from '../utils/validation.js';
function extractToken(request: FastifyRequest): string | null {
const auth = request.headers?.authorization;
if (auth && auth.startsWith('Bearer ')) {
return auth.slice(7);
}
return null;
}
function requireAdmin(request: FastifyRequest, reply: FastifyReply, authService: AuthService): DBUser | null {
const token = extractToken(request);
if (!token) {
reply.code(401).send({ error: 'Authentication required' });
return null;
}
const user = authService.getUserFromSession(token);
if (!user) {
reply.code(401).send({ error: 'Invalid or expired session' });
return null;
}
if (user.role !== 'admin') {
reply.code(403).send({ error: 'Admin access required' });
return null;
}
return user;
}
export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List all users (admin only)
fastify.get('/api/users', async (request, reply) => {
if (!requireAdmin(request, reply, authService)) return;
const users = db.listUsers();
return users.map(({ password_hash, ...safe }) => safe);
});
// Get single user (admin only)
fastify.get('/api/users/:id', async (request, reply) => {
if (!requireAdmin(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
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 (admin only)
fastify.patch('/api/users/:id', async (request, reply) => {
if (!requireAdmin(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
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 (admin only)
fastify.delete('/api/users/:id', async (request, reply) => {
if (!requireAdmin(request, reply, authService)) return;
const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr });
const ok = db.deleteUser(id);
if (!ok) return reply.code(404).send({ error: 'User not found' });
return reply.code(204).send();
});
}
-112
View File
@@ -1,112 +0,0 @@
/**
* 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 — restrictive origin list
await fastify.register(cors, {
origin: (origin: string | undefined, cb: (err: Error | null, allow: boolean) => void) => {
// Allow requests with no origin (same-origin, curl, etc.)
if (!origin) return cb(null, true);
// Allow known origins
const allowed = ['https://web-cad-neu.server.media-on.de', 'http://localhost:5173', 'http://localhost:8082'];
if (allowed.includes(origin)) return cb(null, true);
cb(new Error('Not allowed by CORS'), false);
},
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 { registerNotificationRoutes } = await import('./routes/notifications.js');
const { registerShareRoutes } = await import('./routes/shares.js');
const { registerGlobalBlockRoutes } = await import('./routes/globalBlocks.js');
const { registerProjectFolderRoutes } = await import('./routes/projectFolders.js');
const authService = new AuthService(opts.db);
// Auth middleware — protect all /api/ routes except auth login/register
fastify.addHook('onRequest', async (request: any, reply: any) => {
const url = request.url;
// Skip auth endpoints and health check
if (url === '/api/auth/login' || url === '/api/auth/register' || url === '/api/auth/logout' || url === '/api/health') return;
// Skip non-API routes (static files, WebSocket)
if (!url.startsWith('/api/')) return;
const auth = request.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
return reply.code(401).send({ error: 'Authentication required' });
}
const token = auth.slice(7);
const user = authService.getUserFromSession(token);
if (!user) {
return reply.code(401).send({ error: 'Invalid or expired session' });
}
(request as any).user = user;
});
registerAuthRoutes(fastify, authService);
registerProjectRoutes(fastify, opts.db, authService);
registerDrawingRoutes(fastify, opts.db, authService);
registerLayerRoutes(fastify, opts.db, authService);
registerElementRoutes(fastify, opts.db, authService);
registerBlockRoutes(fastify, opts.db, authService);
registerSettingsRoutes(fastify, opts.db, authService);
registerUserRoutes(fastify, opts.db, authService);
registerAIRoutes(fastify, authService);
registerNotificationRoutes(fastify, opts.db, authService);
registerShareRoutes(fastify, opts.db, authService);
registerGlobalBlockRoutes(fastify, opts.db, authService);
registerProjectFolderRoutes(fastify, opts.db, authService);
// Yjs collaboration WebSocket
registerYjsWebSocket(fastify, authService);
return fastify;
}
-11
View File
@@ -1,11 +0,0 @@
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>;
}
}
-85
View File
@@ -1,85 +0,0 @@
/**
* Validation utilities for backend route input validation.
* Provides lightweight, manual validation functions without external dependencies.
*/
/** Maximum allowed length for name fields */
export const MAX_NAME_LENGTH = 255;
/** Maximum allowed length for ID fields */
export const MAX_ID_LENGTH = 128;
/** Allowed ID pattern: alphanumeric, hyphens, underscores */
const ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
/**
* Validate a name field (non-empty string within max length).
* Returns null if valid, or an error message string if invalid.
*/
export function validateName(value: unknown, fieldName = 'name'): string | null {
const capitalized = fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
if (typeof value !== 'string') {
return `${capitalized} is required and must be a string`;
}
if (value.trim().length === 0) {
return `${capitalized} must not be empty`;
}
if (value.length > MAX_NAME_LENGTH) {
return `${capitalized} must be at most ${MAX_NAME_LENGTH} characters`;
}
return null;
}
/**
* Validate an ID parameter (non-empty, alphanumeric/hyphen/underscore, within max length).
* Returns null if valid, or an error message string if invalid.
*/
export function validateIdParam(id: unknown, fieldName = 'id'): string | null {
if (typeof id !== 'string') {
return `${fieldName} is required and must be a string`;
}
if (id.length === 0) {
return `${fieldName} must not be empty`;
}
if (id.length > MAX_ID_LENGTH) {
return `${fieldName} is too long`;
}
if (!ID_PATTERN.test(id)) {
return `${fieldName} contains invalid characters`;
}
return null;
}
/**
* Validate a settings key (non-empty, reasonable length, alphanumeric + hyphens/underscores/dots).
* Returns null if valid, or an error message string if invalid.
*/
export function validateSettingsKey(key: unknown): string | null {
if (typeof key !== 'string' || key.trim().length === 0) {
return 'Settings key is required';
}
if (key.length > MAX_NAME_LENGTH) {
return 'Settings key is too long';
}
if (!/^[a-zA-Z0-9_.-]+$/.test(key)) {
return 'Settings key contains invalid characters';
}
return null;
}
/**
* Validate a settings value (non-empty string within reasonable length).
* Returns null if valid, or an error message string if invalid.
*/
export function validateSettingsValue(value: unknown): string | null {
if (value === undefined || value === null) {
return 'Value is required';
}
if (typeof value !== 'string') {
return 'Value must be a string';
}
if (value.length > 65535) {
return 'Value is too long';
}
return null;
}
-231
View File
@@ -1,231 +0,0 @@
/**
* Yjs WebSocket Server Real-time collaboration with LevelDB persistence.
*
* Message protocol (byte-prefixed):
* 0 = Y.Doc update (Y.encodeStateAsUpdate or Y.encodeStateAsUpdateYUpdate)
* 1 = Awareness update (encodeAwarenessUpdate from y-protocols/awareness)
*/
import * as Y from 'yjs';
import { LeveldbPersistence } from 'y-leveldb';
import { Awareness, encodeAwarenessUpdate, applyAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness';
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from '@fastify/websocket';
import type { AuthService } from '../auth/AuthService.js';
const PERSISTENCE_DIR = process.env.YJS_PERSISTENCE_DIR || '/tmp/yjs-documents';
// Message type prefixes (must match frontend WebSocketProvider.ts)
const MSG_DOC_UPDATE = 0;
const MSG_AWARENESS = 1;
// Document store: docName → Y.Doc
const docs = new Map<string, Y.Doc>();
// Awareness store: docName → Awareness
const awarenessMap = new Map<string, Awareness>();
// 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);
// Set persistenceReady so getOrCreateDoc can await it
persistenceReady = (async () => {
// Wait for LevelDB to be ready
await (persistence as any).getAllDocNames();
})();
await persistenceReady;
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);
// Create awareness instance for this document
const awareness = new Awareness(doc);
awarenessMap.set(docName, awareness);
// 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 getOrCreateAwareness(docName: string): Awareness | null {
return awarenessMap.get(docName) ?? null;
}
function broadcastUpdate(docName: string, update: Uint8Array, msgType: number, exclude?: WebSocket): void {
const conns = connections.get(docName);
if (!conns) return;
// Prepend message type byte
const msg = new Uint8Array(update.length + 1);
msg[0] = msgType;
msg.set(update, 1);
for (const ws of conns) {
if (ws !== exclude && ws.readyState === 1 /* OPEN */) {
try {
ws.send(msg);
} catch {
// Connection error — will be cleaned up on close
}
}
}
}
export function registerYjsWebSocket(fastify: FastifyInstance, authService: AuthService): 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;
}
// Issue #2: Authenticate WebSocket connection via query param token
const token = request.query.token as string;
if (!token) {
socket.close(4001, 'Authentication required');
return;
}
const user = authService.getUserFromSession(token);
if (!user) {
socket.close(4001, 'Invalid or expired session');
return;
}
const doc = await getOrCreateDoc(docName);
const awareness = getOrCreateAwareness(docName);
// Track connection
if (!connections.has(docName)) {
connections.set(docName, new Set());
}
connections.get(docName)!.add(socket);
// Send current document state to new client (with MSG_DOC_UPDATE prefix)
const stateUpdate = Y.encodeStateAsUpdate(doc);
if (stateUpdate.length > 0 && socket.readyState === 1) {
const msg = new Uint8Array(stateUpdate.length + 1);
msg[0] = MSG_DOC_UPDATE;
msg.set(stateUpdate, 1);
socket.send(msg);
}
// Send current awareness states to new client
if (awareness && awareness.getStates().size > 0 && socket.readyState === 1) {
const awarenessUpdate = encodeAwarenessUpdate(awareness, Array.from(awareness.getStates().keys()));
if (awarenessUpdate.length > 0) {
const msg = new Uint8Array(awarenessUpdate.length + 1);
msg[0] = MSG_AWARENESS;
msg.set(awarenessUpdate, 1);
socket.send(msg);
}
}
// Listen for messages from this client
socket.on('message', (data: Buffer) => {
try {
const raw = new Uint8Array(data);
if (raw.length === 0) return;
const msgType = raw[0];
const payload = raw.slice(1);
if (msgType === MSG_AWARENESS) {
// Awareness update — apply to server awareness and broadcast
if (awareness) {
applyAwarenessUpdate(awareness, payload, socket);
broadcastUpdate(docName, payload, MSG_AWARENESS, socket);
}
} else if (msgType === MSG_DOC_UPDATE) {
// Y.Doc update — apply to server doc and broadcast
Y.applyUpdate(doc, payload);
broadcastUpdate(docName, payload, MSG_DOC_UPDATE, socket);
} else {
// Legacy: treat entire message as a Y.Doc update (no prefix)
Y.applyUpdate(doc, raw);
broadcastUpdate(docName, raw, MSG_DOC_UPDATE, socket);
}
} catch (err) {
console.warn(`Invalid update from client for ${docName}:`, err);
}
});
// Clean up on disconnect (Issue #5: keep doc in memory for reconnection)
socket.on('close', () => {
const conns = connections.get(docName);
if (conns) {
conns.delete(socket);
// Remove this client's awareness state
if (awareness) {
// Find and remove the client's awareness state based on socket origin
const statesToRemove: number[] = [];
for (const [clientID, meta] of awareness.meta) {
if (meta === socket) {
statesToRemove.push(clientID);
}
}
if (statesToRemove.length > 0) {
removeAwarenessStates(awareness, statesToRemove, socket);
// Broadcast removal to remaining clients
const removalUpdate = encodeAwarenessUpdate(awareness, statesToRemove);
if (removalUpdate.length > 0) {
broadcastUpdate(docName, removalUpdate, MSG_AWARENESS, socket);
}
}
}
if (conns.size === 0) {
connections.delete(docName);
// Keep doc in memory for reconnection (persistence handles durability)
}
}
});
socket.on('error', () => {
const conns = connections.get(docName);
if (conns) {
conns.delete(socket);
}
});
});
}
-85
View File
@@ -1,85 +0,0 @@
# Test Report — Auth Middleware & CORS Fix (Issue #1 + #3)
**Date:** 2026-06-29
**Task:** Fix Issue #1 (No Authentication on CRUD Routes) + Issue #3 (CORS) from CODE_ANALYSIS.md
**File modified:** `backend/src/server.ts`
## Build Verification
```
cd /a0/usr/workdir/web-cad-neu/backend && npx tsc --noEmit
```
**Result:** ✅ PASS — No type errors, exit code 0
## Test Results
### Test 1: Unauthenticated /api/projects → 401
```
curl -s -o /dev/null -w '%{http_code}' http://localhost:3001/api/projects
```
**Expected:** 401
**Actual:** 401 ✅
### Test 2: Health check /api/health → 200
```
curl -s -o /dev/null -w '%{http_code}' http://localhost:3001/api/health
```
**Expected:** 200
**Actual:** 200 ✅
### Test 3: Register endpoint accessible (no auth required)
```
curl -s -X POST http://localhost:3001/api/auth/register -H 'Content-Type: application/json' \
-d '{"email":"test@test.de","password":"test1234","name":"Test User"}'
```
**Expected:** 201
**Actual:** 201 ✅ (user created, session token returned)
### Test 4: Login endpoint accessible (no auth required)
```
curl -s -w '\nHTTP_CODE:%{http_code}' -X POST http://localhost:3001/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"test@test.de","password":"test1234"}'
```
**Expected:** 200
**Actual:** 200 ✅ (user + session token returned)
### Test 5: Authenticated /api/projects → 200
```
TOKEN=<valid_session_token>
curl -s -w '\nHTTP_CODE:%{http_code}' http://localhost:3001/api/projects \
-H "Authorization: Bearer $TOKEN"
```
**Expected:** 200
**Actual:** 200 ✅ (project list returned)
## Smoke Test
- Backend starts without errors: ✅
- Server listens on port 3001: ✅
- Yjs persistence initialized: ✅
- Auth middleware correctly blocks unauthenticated /api/ requests: ✅
- Auth middleware correctly skips /api/auth/login, /api/auth/register, /api/health: ✅
- Auth middleware correctly skips non-/api/ routes (static files): ✅
- Valid Bearer token grants access to protected routes: ✅
- CORS restricted to allowed origins (web-cad-neu.server.media-on.de, localhost:5173, localhost:8082): ✅
## Changes Summary
### server.ts — Auth Middleware (Issue #1)
Added `onRequest` hook after `authService` creation, before route registrations:
- Skips `/api/auth/login`, `/api/auth/register`, `/api/health`
- Skips all non-`/api/` routes (static files, WebSocket)
- Extracts Bearer token from Authorization header
- Validates via `authService.getUserFromSession(token)`
- Returns 401 if no token or invalid session
- Attaches user to `(request as any).user` for route handlers
### server.ts — CORS Fix (Issue #3)
Changed `origin: true` to restrictive config:
- Allows requests with no origin (same-origin, curl)
- Allows known origins: `https://web-cad-neu.server.media-on.de`, `http://localhost:5173`, `http://localhost:8082`
- Rejects all other origins
## Known Issues
- None
-188
View File
@@ -1,188 +0,0 @@
/**
* AuthService Unit Tests Register / Login / ChangePassword / Session lifecycle
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { AuthService } from '../src/auth/AuthService.js';
describe('AuthService', () => {
let db: SqliteAdapter;
let auth: AuthService;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
auth = new AuthService(db);
});
afterAll(() => {
db.close();
});
// ─── Register ───────────────────────────────────────
describe('register()', () => {
it('should register a new user successfully', async () => {
const result = await auth.register('register@example.com', 'Password123!', 'Register User', 'planer');
expect(result.user).toBeDefined();
expect(result.user.email).toBe('register@example.com');
expect(result.user.name).toBe('Register User');
expect(result.user.password_hash).toBeUndefined();
expect(result.session).toBeDefined();
expect(result.session.token).toBeDefined();
expect(result.session.userId).toBe(result.user.id);
});
it('should throw on duplicate email', async () => {
await expect(
auth.register('register@example.com', 'AnotherPass!', 'Another User', 'planer'),
).rejects.toThrow('Email already registered');
});
it('should default role to planer when not specified', async () => {
const result = await auth.register('default-role@example.com', 'Pass123!', 'Default Role User');
expect(result.user.role).toBe('planer');
});
});
// ─── Login ─────────────────────────────────────────
describe('login()', () => {
it('should login with correct credentials', async () => {
const result = await auth.login('register@example.com', 'Password123!');
expect(result.user).toBeDefined();
expect(result.user.email).toBe('register@example.com');
expect(result.session.token).toBeDefined();
expect(result.session.userId).toBe(result.user.id);
});
it('should throw on wrong password', async () => {
await expect(
auth.login('register@example.com', 'WrongPassword!'),
).rejects.toThrow('Invalid credentials');
});
it('should throw on non-existent email', async () => {
await expect(
auth.login('nobody@example.com', 'SomePassword!'),
).rejects.toThrow('Invalid credentials');
});
});
// ─── Change Password ───────────────────────────────
describe('changePassword()', () => {
it('should change password with correct old password', async () => {
const result = await auth.register('changepw@example.com', 'OldPass123!', 'ChangePW User', 'admin');
const userId = result.user.id;
await auth.changePassword(userId, 'OldPass123!', 'NewPass456!');
// Should now be able to login with new password
const loginResult = await auth.login('changepw@example.com', 'NewPass456!');
expect(loginResult.user.id).toBe(userId);
});
it('should throw on wrong old password', async () => {
const result = await auth.register('changepw2@example.com', 'OriginalPass!', 'User2', 'planer');
await expect(
auth.changePassword(result.user.id, 'WrongOldPass!', 'NewPass!'),
).rejects.toThrow('Invalid current password');
});
it('should throw on non-existent user', async () => {
await expect(
auth.changePassword('non-existent-user-id', 'OldPass!', 'NewPass!'),
).rejects.toThrow('User not found');
});
it('should reject old password after change', async () => {
const result = await auth.register('changepw3@example.com', 'Original123!', 'User3', 'planer');
await auth.changePassword(result.user.id, 'Original123!', 'Changed456!');
await expect(
auth.login('changepw3@example.com', 'Original123!'),
).rejects.toThrow('Invalid credentials');
});
});
// ─── Session Management ────────────────────────────
describe('Session lifecycle', () => {
it('should create and validate a session', async () => {
const regResult = await auth.register('session@example.com', 'Pass123!', 'Session User', 'planer');
const token = regResult.session.token;
const session = auth.validateSession(token);
expect(session).not.toBeNull();
expect(session!.token).toBe(token);
expect(session!.userId).toBe(regResult.user.id);
});
it('should return null for invalid session token', () => {
const session = auth.validateSession('invalid-token-xyz');
expect(session).toBeNull();
});
it('should destroy a session and invalidate it', async () => {
const regResult = await auth.register('destroy@example.com', 'Pass123!', 'Destroy User', 'planer');
const token = regResult.session.token;
// Session should be valid
expect(auth.validateSession(token)).not.toBeNull();
// Destroy it
auth.destroySession(token);
// Session should now be invalid
expect(auth.validateSession(token)).toBeNull();
});
it('should handle destroying a non-existent session gracefully', () => {
// Should not throw
auth.destroySession('non-existent-token');
});
});
// ─── getUserFromSession ─────────────────────────────
describe('getUserFromSession()', () => {
it('should return user from valid session', async () => {
const regResult = await auth.register('getuser@example.com', 'Pass123!', 'GetUser User', 'planer');
const token = regResult.session.token;
const user = auth.getUserFromSession(token);
expect(user).not.toBeNull();
expect(user!.email).toBe('getuser@example.com');
expect(user!.password_hash).toBeDefined(); // returns full DBUser including password_hash
});
it('should return null for invalid session token', () => {
const user = auth.getUserFromSession('invalid-token-xyz');
expect(user).toBeNull();
});
it('should return null after session is destroyed', async () => {
const regResult = await auth.register('getuser2@example.com', 'Pass123!', 'GetUser2 User', 'planer');
const token = regResult.session.token;
auth.destroySession(token);
const user = auth.getUserFromSession(token);
expect(user).toBeNull();
});
});
// ─── createSession (standalone) ─────────────────────
describe('createSession()', () => {
it('should create a session with a unique token', async () => {
const regResult = await auth.register('createsession@example.com', 'Pass123!', 'CS User', 'planer');
const session1 = auth.createSession(regResult.user.id);
const session2 = auth.createSession(regResult.user.id);
expect(session1.token).not.toBe(session2.token);
expect(session1.userId).toBe(regResult.user.id);
expect(session2.userId).toBe(regResult.user.id);
expect(session1.expiresAt).toBeGreaterThan(Date.now());
expect(session2.expiresAt).toBeGreaterThan(Date.now());
});
});
});
-383
View File
@@ -1,383 +0,0 @@
/**
* SqliteAdapter Unit Tests init, CRUD for all entities, close
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
let idCounter = 0;
function uniqueId(prefix: string): string {
idCounter++;
return `${prefix}-${Date.now()}-${idCounter}`;
}
describe('SqliteAdapter', () => {
let db: SqliteAdapter;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
});
afterAll(() => {
db.close();
});
// ─── Init & Close ───────────────────────────────────
describe('init() and close()', () => {
it('should initialize the database without errors', async () => {
const testDb = new SqliteAdapter(':memory:');
await testDb.init();
const users = testDb.listUsers();
expect(Array.isArray(users)).toBe(true);
testDb.close();
});
it('should seed a default user on init', async () => {
const defaultUser = db.getUser('user-default');
expect(defaultUser).not.toBeNull();
expect(defaultUser!.email).toBe('default@webcad.local');
expect(defaultUser!.role).toBe('admin');
});
});
// ─── Users CRUD ─────────────────────────────────────
describe('Users CRUD', () => {
it('should create and get a user', () => {
const uid = uniqueId('user');
const user = db.createUser({
id: uid,
email: `${uid}@example.com`,
password_hash: 'hash123',
name: 'CRUD User',
role: 'planer',
});
expect(user.id).toBe(uid);
expect(user.email).toBe(`${uid}@example.com`);
const fetched = db.getUser(user.id);
expect(fetched).not.toBeNull();
expect(fetched!.email).toBe(`${uid}@example.com`);
});
it('should get user by email', () => {
const uid = uniqueId('user');
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Email User', role: 'planer' });
const user = db.getUserByEmail(`${uid}@example.com`);
expect(user).not.toBeNull();
expect(user!.name).toBe('Email User');
});
it('should return null for non-existent user', () => {
expect(db.getUser('non-existent-id')).toBeNull();
expect(db.getUserByEmail('nobody@nowhere.com')).toBeNull();
});
it('should update a user', () => {
const uid = uniqueId('user');
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Original Name', role: 'planer' });
const updated = db.updateUser(uid, { name: 'Updated Name', role: 'admin' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Name');
expect(updated!.role).toBe('admin');
});
it('should list users', () => {
const users = db.listUsers();
expect(users.length).toBeGreaterThanOrEqual(2);
});
it('should delete a user', () => {
const uid = uniqueId('user');
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Delete Me', role: 'planer' });
const ok = db.deleteUser(uid);
expect(ok).toBe(true);
expect(db.getUser(uid)).toBeNull();
});
it('should return false when deleting non-existent user', () => {
expect(db.deleteUser('non-existent-id')).toBe(false);
});
});
// ─── Projects CRUD ──────────────────────────────────
describe('Projects CRUD', () => {
it('should create and get a project', () => {
const pid = uniqueId('proj');
const project = db.createProject({ id: pid, name: 'Test Project', description: 'Test desc' });
expect(project.id).toBe(pid);
expect(project.name).toBe('Test Project');
const fetched = db.getProject(pid);
expect(fetched).not.toBeNull();
expect(fetched!.name).toBe('Test Project');
});
it('should return null for non-existent project', () => {
expect(db.getProject('non-existent-id')).toBeNull();
});
it('should update a project', () => {
const pid = uniqueId('proj');
db.createProject({ id: pid, name: 'Update Project' });
const updated = db.updateProject(pid, { name: 'Updated Project', description: 'New desc' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Project');
expect(updated!.description).toBe('New desc');
});
it('should list projects', () => {
const projects = db.listProjects();
expect(projects.length).toBeGreaterThanOrEqual(1);
});
it('should delete a project', () => {
const pid = uniqueId('proj');
db.createProject({ id: pid, name: 'Delete Project' });
const ok = db.deleteProject(pid);
expect(ok).toBe(true);
expect(db.getProject(pid)).toBeNull();
});
it('should cascade delete drawings when project is deleted', () => {
const pid = uniqueId('proj');
const did = uniqueId('draw');
db.createProject({ id: pid, name: 'Cascade Project' });
db.createDrawing({ id: did, project_id: pid, name: 'Cascade Drawing' });
db.deleteProject(pid);
expect(db.getDrawing(did)).toBeNull();
});
});
// ─── Drawings CRUD ──────────────────────────────────
describe('Drawings CRUD', () => {
let projectId: string;
it('should create and get a drawing', () => {
projectId = uniqueId('proj');
const did = uniqueId('draw');
db.createProject({ id: projectId, name: 'Drawing Project' });
const drawing = db.createDrawing({ id: did, project_id: projectId, name: 'Floor 1' });
expect(drawing.id).toBe(did);
expect(drawing.name).toBe('Floor 1');
expect(drawing.project_id).toBe(projectId);
const fetched = db.getDrawing(did);
expect(fetched).not.toBeNull();
expect(fetched!.name).toBe('Floor 1');
});
it('should list drawings by project', () => {
const did1 = uniqueId('draw');
const did2 = uniqueId('draw');
db.createDrawing({ id: did1, project_id: projectId, name: 'Drawing A' });
db.createDrawing({ id: did2, project_id: projectId, name: 'Drawing B' });
const drawings = db.listDrawings(projectId);
expect(drawings.length).toBeGreaterThanOrEqual(3);
});
it('should update a drawing', () => {
const did = uniqueId('draw');
db.createDrawing({ id: did, project_id: projectId, name: 'Update Me' });
const updated = db.updateDrawing(did, { name: 'Updated Drawing', data_json: '{"x":1}' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Drawing');
expect(updated!.data_json).toBe('{"x":1}');
});
it('should delete a drawing', () => {
const did = uniqueId('draw');
db.createDrawing({ id: did, project_id: projectId, name: 'Delete Me' });
const ok = db.deleteDrawing(did);
expect(ok).toBe(true);
expect(db.getDrawing(did)).toBeNull();
});
it('should cascade delete layers when drawing is deleted', () => {
const did = uniqueId('draw');
const lid = uniqueId('layer');
db.createDrawing({ id: did, project_id: projectId, name: 'Cascade Drawing' });
db.createLayer({ id: lid, drawing_id: did, name: 'Cascade Layer' });
db.deleteDrawing(did);
const layers = db.listLayers(did);
expect(layers.find((l) => l.id === lid)).toBeUndefined();
});
});
// ─── Layers CRUD ────────────────────────────────────
describe('Layers CRUD', () => {
let drawingId: string;
it('should create and get a layer', () => {
const pid = uniqueId('proj');
drawingId = uniqueId('draw');
const lid = uniqueId('layer');
db.createProject({ id: pid, name: 'Layer Project' });
db.createDrawing({ id: drawingId, project_id: pid, name: 'Layer Drawing' });
const layer = db.createLayer({ id: lid, drawing_id: drawingId, name: 'Layer 1', color: '#ff0000' });
expect(layer.id).toBe(lid);
expect(layer.name).toBe('Layer 1');
expect(layer.color).toBe('#ff0000');
expect(layer.visible).toBe(1);
expect(layer.locked).toBe(0);
});
it('should list layers by drawing', () => {
const lid2 = uniqueId('layer');
db.createLayer({ id: lid2, drawing_id: drawingId, name: 'Layer 2' });
const layers = db.listLayers(drawingId);
expect(layers.length).toBeGreaterThanOrEqual(2);
});
it('should update a layer', () => {
const lid = uniqueId('layer');
db.createLayer({ id: lid, drawing_id: drawingId, name: 'Update Layer' });
const updated = db.updateLayer(lid, { name: 'Updated Layer', visible: 0, locked: 1 });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Layer');
expect(updated!.visible).toBe(0);
expect(updated!.locked).toBe(1);
});
it('should delete a layer', () => {
const lid = uniqueId('layer');
db.createLayer({ id: lid, drawing_id: drawingId, name: 'Delete Layer' });
const ok = db.deleteLayer(lid);
expect(ok).toBe(true);
const layers = db.listLayers(drawingId);
expect(layers.find((l) => l.id === lid)).toBeUndefined();
});
});
// ─── Elements CRUD ──────────────────────────────────
describe('Elements CRUD', () => {
let drawingId: string;
let layerId: string;
it('should create and get an element', () => {
const pid = uniqueId('proj');
drawingId = uniqueId('draw');
layerId = uniqueId('layer');
const eid = uniqueId('elem');
db.createProject({ id: pid, name: 'Element Project' });
db.createDrawing({ id: drawingId, project_id: pid, name: 'Element Drawing' });
db.createLayer({ id: layerId, drawing_id: drawingId, name: 'Element Layer' });
const element = db.createElement({
id: eid,
drawing_id: drawingId,
layer_id: layerId,
type: 'rect',
x: 10,
y: 20,
width: 100,
height: 50,
});
expect(element.id).toBe(eid);
expect(element.type).toBe('rect');
expect(element.x).toBe(10);
expect(element.y).toBe(20);
});
it('should list elements by drawing', () => {
const eid2 = uniqueId('elem');
db.createElement({ id: eid2, drawing_id: drawingId, layer_id: layerId, type: 'circle' });
const elements = db.listElements(drawingId);
expect(elements.length).toBeGreaterThanOrEqual(2);
});
it('should update an element', () => {
const eid = uniqueId('elem');
db.createElement({ id: eid, drawing_id: drawingId, layer_id: layerId, type: 'line' });
const updated = db.updateElement(eid, { x: 500, y: 600, width: 200 });
expect(updated).not.toBeNull();
expect(updated!.x).toBe(500);
expect(updated!.y).toBe(600);
expect(updated!.width).toBe(200);
});
it('should delete an element', () => {
const eid = uniqueId('elem');
db.createElement({ id: eid, drawing_id: drawingId, layer_id: layerId, type: 'text' });
const ok = db.deleteElement(eid);
expect(ok).toBe(true);
const elements = db.listElements(drawingId);
expect(elements.find((e) => e.id === eid)).toBeUndefined();
});
});
// ─── Blocks CRUD ────────────────────────────────────
describe('Blocks CRUD', () => {
let drawingId: string;
it('should create and get a block', () => {
const pid = uniqueId('proj');
drawingId = uniqueId('draw');
const bid = uniqueId('block');
db.createProject({ id: pid, name: 'Block Project' });
db.createDrawing({ id: drawingId, project_id: pid, name: 'Block Drawing' });
const block = db.createBlock({ id: bid, drawing_id: drawingId, name: 'Table Block', category: 'Mobiliar' });
expect(block.id).toBe(bid);
expect(block.name).toBe('Table Block');
expect(block.category).toBe('Mobiliar');
expect(block.elements_json).toBe('[]');
});
it('should list blocks by drawing', () => {
const bid2 = uniqueId('block');
db.createBlock({ id: bid2, drawing_id: drawingId, name: 'Chair Block' });
const blocks = db.listBlocks(drawingId);
expect(blocks.length).toBeGreaterThanOrEqual(2);
});
it('should update a block', () => {
const bid = uniqueId('block');
db.createBlock({ id: bid, drawing_id: drawingId, name: 'Update Block' });
const updated = db.updateBlock(bid, { name: 'Updated Block', category: 'Bühne' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Block');
expect(updated!.category).toBe('Bühne');
});
it('should delete a block', () => {
const bid = uniqueId('block');
db.createBlock({ id: bid, drawing_id: drawingId, name: 'Delete Block' });
const ok = db.deleteBlock(bid);
expect(ok).toBe(true);
const blocks = db.listBlocks(drawingId);
expect(blocks.find((b) => b.id === bid)).toBeUndefined();
});
});
// ─── Settings CRUD ──────────────────────────────────
describe('Settings CRUD', () => {
it('should set and get a setting', () => {
db.setSetting('test-key', 'test-value');
const setting = db.getSetting('test-key');
expect(setting).not.toBeNull();
expect(setting!.key).toBe('test-key');
expect(setting!.value).toBe('test-value');
expect(setting!.updated_at).toBeDefined();
});
it('should return null for non-existent setting', () => {
expect(db.getSetting('non-existent-key')).toBeNull();
});
it('should upsert (update existing setting)', () => {
db.setSetting('upsert-key', 'initial');
db.setSetting('upsert-key', 'updated');
const setting = db.getSetting('upsert-key');
expect(setting).not.toBeNull();
expect(setting!.value).toBe('updated');
});
});
});
-126
View File
@@ -1,126 +0,0 @@
/**
* Backend Stresstest 50.000 Elemente: SQLite CRUD Performance,
* Bulk-Insert, List, Update, Delete mit Transaktion-Support.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import type { DBElement } from '../src/database/DatabaseInterface.js';
const N = 50_000;
describe('Backend Stresstest: 50.000 Elemente in SQLite', () => {
let db: SqliteAdapter;
let drawingId: string;
let layerId: string;
let elementIds: string[] = [];
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
// Create prerequisite project → drawing → layer
const project = db.createProject({ name: 'Stresstest Project' });
const drawing = db.createDrawing({ project_id: project.id, name: 'Stresstest Drawing' });
drawingId = drawing.id;
const layer = db.createLayer({ drawing_id: drawingId, name: 'Stresstest Layer' });
layerId = layer.id;
});
afterAll(() => {
db.close();
});
it('should bulk-insert 50k elements in under 5s', () => {
const start = performance.now();
// Use transaction for bulk insert
const insert = db['db'].prepare(
'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
);
const insertMany = db['db'].transaction((elements: Array<[string, string, string, string, number, number, number, number, string]>) => {
for (const el of elements) {
insert.run(...el);
}
});
const batch: Array<[string, string, string, string, number, number, number, number, string]> = [];
for (let i = 0; i < N; i++) {
const id = `stress-elem-${i}`;
elementIds.push(id);
batch.push([
id,
drawingId,
layerId,
'rect',
(i % 1000) * 1.5,
Math.floor(i / 1000) * 1.5,
1,
1,
'{}',
]);
}
insertMany(batch);
const elapsed = performance.now() - start;
console.log(`Bulk insert ${N} elements: ${elapsed.toFixed(1)}ms`);
expect(elapsed).toBeLessThan(5000);
});
it('should list 50k elements in under 500ms', () => {
const start = performance.now();
const elements = db.listElements(drawingId);
const elapsed = performance.now() - start;
console.log(`List ${N} elements: ${elapsed.toFixed(1)}ms, count=${elements.length}`);
expect(elements.length).toBe(N);
expect(elapsed).toBeLessThan(1000);
});
it('should update 100 elements in under 200ms', () => {
const start = performance.now();
for (let i = 0; i < 100; i++) {
db.updateElement(elementIds[i], { x: 9999 + i });
}
const elapsed = performance.now() - start;
console.log(`Update 100 elements: ${elapsed.toFixed(1)}ms`);
expect(elapsed).toBeLessThan(200);
});
it('should read a single element in under 5ms', () => {
const start = performance.now();
const element = db.listElements(drawingId);
const mid = element[Math.floor(element.length / 2)];
const elapsed = performance.now() - start;
console.log(`Read mid element from ${N}: ${elapsed.toFixed(1)}ms`);
expect(mid).toBeDefined();
expect(elapsed).toBeLessThan(2000);
});
it('should delete 1000 elements in under 500ms', () => {
const start = performance.now();
const deleteStmt = db['db'].prepare('DELETE FROM elements WHERE id = ?');
const deleteMany = db['db'].transaction((ids: string[]) => {
for (const id of ids) {
deleteStmt.run(id);
}
});
const toDelete = elementIds.slice(0, 1000);
deleteMany(toDelete);
const elapsed = performance.now() - start;
console.log(`Delete 1000 elements: ${elapsed.toFixed(1)}ms`);
expect(elapsed).toBeLessThan(500);
// Verify count
const remaining = db.listElements(drawingId);
expect(remaining.length).toBe(N - 1000);
});
it('should handle concurrent queries efficiently', () => {
// Simulate 10 concurrent list operations
const start = performance.now();
for (let i = 0; i < 10; i++) {
const elements = db.listElements(drawingId);
expect(elements.length).toBe(N - 1000);
}
const elapsed = performance.now() - start;
console.log(`10x list operations on ${N - 1000} elements: ${elapsed.toFixed(1)}ms`);
expect(elapsed).toBeLessThan(10000);
});
});
-140
View File
@@ -1,140 +0,0 @@
/**
* AI API Tests Auth guard, validation, and service availability checks
* Does NOT test actual OpenRouter API calls.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('AI API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let authToken: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Register a user to get an auth token
const registerRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
email: 'ai-test@example.com',
password: 'TestPass123!',
name: 'AI Test User',
role: 'planer',
},
});
const body = JSON.parse(registerRes.body);
authToken = body.session.token;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Auth Guard ─────────────────────────────────────
describe('POST /api/ai/chat Authentication', () => {
it('should return 401 without authorization header', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(401);
const body = JSON.parse(response.body);
expect(body.error).toContain('Authentication required');
});
it('should return 401 with invalid token', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: 'Bearer invalid-token-xxx' },
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(401);
const body = JSON.parse(response.body);
expect(body.error).toContain('Invalid or expired session');
});
it('should return 401 with malformed authorization header', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: 'NotBearer sometoken' },
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(401);
});
});
// ─── Validation ─────────────────────────────────────
describe('POST /api/ai/chat Validation', () => {
it('should return 400 when messages array is missing', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: {},
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Messages array is required');
});
it('should return 400 when messages array is empty', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: { messages: [] },
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Messages array is required');
});
it('should return 400 when messages is not an array', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: { messages: 'not an array' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── Service Availability ───────────────────────────
describe('POST /api/ai/chat Service availability', () => {
it('should return 503 when API_KEY_OPENROUTER is not set', async () => {
// Ensure env var is not set for this test
const originalValue = process.env.API_KEY_OPENROUTER;
delete process.env.API_KEY_OPENROUTER;
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(503);
const body = JSON.parse(response.body);
expect(body.error).toContain('AI service not configured');
// Restore original value if it existed
if (originalValue !== undefined) {
process.env.API_KEY_OPENROUTER = originalValue;
}
});
});
});
-269
View File
@@ -1,269 +0,0 @@
/**
* Auth API Tests Register / Login / Logout / Me / Password Change
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Auth API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let authToken: string | null = null;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Register ────────────────────────────────────────
describe('POST /api/auth/register', () => {
it('should register a new user with 201', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
email: 'testuser@example.com',
password: 'TestPass123!',
name: 'Test User',
role: 'planer',
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.user).toBeDefined();
expect(body.user.email).toBe('testuser@example.com');
expect(body.user.name).toBe('Test User');
expect(body.user.password_hash).toBeUndefined();
expect(body.session).toBeDefined();
expect(body.session.token).toBeDefined();
authToken = body.session.token;
});
it('should reject duplicate registration with 409', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
email: 'testuser@example.com',
password: 'TestPass123!',
name: 'Test User 2',
},
});
expect(response.statusCode).toBe(409);
const body = JSON.parse(response.body);
expect(body.error).toContain('already registered');
});
it('should reject missing fields with 400', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'incomplete@example.com' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── Login ──────────────────────────────────────────
describe('POST /api/auth/login', () => {
it('should login with correct credentials', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'testuser@example.com',
password: 'TestPass123!',
},
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.user).toBeDefined();
expect(body.session.token).toBeDefined();
authToken = body.session.token;
});
it('should reject wrong password with 401', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'testuser@example.com',
password: 'WrongPassword!',
},
});
expect(response.statusCode).toBe(401);
});
it('should reject non-existent email with 401', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'nobody@example.com',
password: 'SomePassword!',
},
});
expect(response.statusCode).toBe(401);
});
it('should reject missing fields with 400', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { email: 'testuser@example.com' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── Me ─────────────────────────────────────────────
describe('GET /api/auth/me', () => {
it('should return current user profile with valid token', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/auth/me',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.email).toBe('testuser@example.com');
expect(body.password_hash).toBeUndefined();
});
it('should reject without token with 401', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/auth/me',
});
expect(response.statusCode).toBe(401);
});
it('should reject invalid token with 401', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/auth/me',
headers: { authorization: 'Bearer invalid-token-xxx' },
});
expect(response.statusCode).toBe(401);
});
});
// ─── Password Change ───────────────────────────────
describe('PATCH /api/auth/password', () => {
it('should change password with correct old password', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/auth/password',
headers: { authorization: `Bearer ${authToken}` },
payload: {
oldPassword: 'TestPass123!',
newPassword: 'NewPassword456!',
},
});
expect(response.statusCode).toBe(204);
});
it('should allow login with new password', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'testuser@example.com',
password: 'NewPassword456!',
},
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.session.token).toBeDefined();
authToken = body.session.token;
});
it('should reject old password after change', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'testuser@example.com',
password: 'TestPass123!',
},
});
expect(response.statusCode).toBe(401);
});
it('should reject password change with wrong old password', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/auth/password',
headers: { authorization: `Bearer ${authToken}` },
payload: {
oldPassword: 'WrongOldPassword!',
newPassword: 'AnotherNew789!',
},
});
expect(response.statusCode).toBe(400);
});
it('should reject password change without auth', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/auth/password',
payload: {
oldPassword: 'NewPassword456!',
newPassword: 'AnotherNew789!',
},
});
expect(response.statusCode).toBe(401);
});
it('should reject missing password fields', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/auth/password',
headers: { authorization: `Bearer ${authToken}` },
payload: { oldPassword: 'NewPassword456!' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── Logout ─────────────────────────────────────────
describe('POST /api/auth/logout', () => {
it('should logout and invalidate token', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/logout',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(204);
// Token should now be invalid
const meResponse = await app.inject({
method: 'GET',
url: '/api/auth/me',
headers: { authorization: `Bearer ${authToken}` },
});
expect(meResponse.statusCode).toBe(401);
});
it('should return 204 even without token', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/logout',
});
expect(response.statusCode).toBe(204);
});
});
});
-254
View File
@@ -1,254 +0,0 @@
/**
* Blocks API Tests CRUD (List / Create / Update / Delete)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Blocks API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let drawingId: string;
let createdBlockId: string;
let authToken: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
const regRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'admin-test@example.com', password: 'Password123!', name: 'Admin Test', role: 'admin' },
});
authToken = JSON.parse(regRes.body).session.token;
// Create project → drawing hierarchy
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Blocks Test Project' },
});
const projectId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Blocks Test Drawing' },
});
drawingId = JSON.parse(drawRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Auth Check ──────────────────────────────────────
describe('Authentication', () => {
it('should return 401 without authorization header', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/blocks`,
});
expect(response.statusCode).toBe(401);
});
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/drawings/:drawingId/blocks', () => {
it('should create a block with 201', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Standard Table', category: 'Mobiliar', description: 'A standard round table' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Standard Table');
expect(body.category).toBe('Mobiliar');
expect(body.description).toBe('A standard round table');
expect(body.drawing_id).toBe(drawingId);
createdBlockId = body.id;
});
it('should create a block with default values', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Minimal Block' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Minimal Block');
expect(body.category).toBe('Allgemein');
expect(body.description).toBeNull();
expect(body.elements_json).toBe('[]');
});
it('should reject block without name with 400', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
headers: { authorization: `Bearer ${authToken}` },
payload: { category: 'Test' },
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Name is required');
});
it('should reject block with empty name with 400', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: '' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/drawings/:drawingId/blocks', () => {
it('should list blocks for a drawing', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/blocks`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should return empty array for drawing with no blocks', async () => {
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Empty Blocks Project' },
});
const projId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projId}/drawings`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Empty Drawing' },
});
const emptyDrawId = JSON.parse(drawRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${emptyDrawId}/blocks`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(0);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/blocks/:id', () => {
it('should update block name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/blocks/${createdBlockId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Updated Block Name' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Updated Block Name');
});
it('should update block category and description', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/blocks/${createdBlockId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { category: 'Bühne', description: 'Stage equipment' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.category).toBe('Bühne');
expect(body.description).toBe('Stage equipment');
});
it('should update block elements_json', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/blocks/${createdBlockId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { elements_json: '[{"type":"rect"}]' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.elements_json).toBe('[{"type":"rect"}]');
});
it('should return 404 for non-existent block update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/blocks/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/blocks/:id', () => {
it('should delete a block', async () => {
const createRes = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'To Be Deleted' },
});
const blockId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/blocks/${blockId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(204);
// Verify it's gone via list
const listRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/blocks`,
headers: { authorization: `Bearer ${authToken}` },
});
const blocks = JSON.parse(listRes.body);
expect(blocks.find((b: any) => b.id === blockId)).toBeUndefined();
});
it('should return 404 for non-existent block delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/blocks/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(404);
});
});
});
-226
View File
@@ -1,226 +0,0 @@
/**
* Drawings API Tests CRUD (List / Get / Create / Update / Delete)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Drawings API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let projectId: string;
let createdDrawingId: string;
let authToken: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
const regRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'admin-test@example.com', password: 'Password123!', name: 'Admin Test', role: 'admin' },
});
authToken = JSON.parse(regRes.body).session.token;
// Create a project first (drawings belong to projects)
const projectRes = await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Drawings Test Project' },
});
projectId = JSON.parse(projectRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Auth Check ──────────────────────────────────────
describe('Authentication', () => {
it('should return 401 without authorization header', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/projects/${projectId}/drawings`,
});
expect(response.statusCode).toBe(401);
});
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/projects/:projectId/drawings', () => {
it('should create a drawing with 201', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Ground Floor' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Ground Floor');
expect(body.project_id).toBe(projectId);
createdDrawingId = body.id;
});
it('should create a drawing with default name', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
headers: { authorization: `Bearer ${authToken}` },
payload: {},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Unbenannt');
expect(body.project_id).toBe(projectId);
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/projects/:projectId/drawings', () => {
it('should list drawings for a project', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/projects/${projectId}/drawings`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should return empty array for project with no drawings', async () => {
// Create a new empty project
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Empty Project' },
});
const emptyProjId = JSON.parse(projRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/projects/${emptyProjId}/drawings`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(0);
});
});
// ─── Get ────────────────────────────────────────────
describe('GET /api/drawings/:id', () => {
it('should get a single drawing', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${createdDrawingId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.id).toBe(createdDrawingId);
expect(body.name).toBe('Ground Floor');
});
it('should return 404 for non-existent drawing', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/drawings/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/drawings/:id', () => {
it('should update drawing name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/drawings/${createdDrawingId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'First Floor' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('First Floor');
});
it('should update drawing data_json', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/drawings/${createdDrawingId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { data_json: '{"layers":[]}' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.data_json).toBe('{"layers":[]}');
});
it('should return 404 for non-existent drawing update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/drawings/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/drawings/:id', () => {
it('should delete a drawing', async () => {
// Create a drawing to delete
const createRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'To Be Deleted' },
});
const drawingId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/drawings/${drawingId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(204);
// Verify it's gone
const getRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(getRes.statusCode).toBe(404);
});
it('should return 404 for non-existent drawing delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/drawings/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(404);
});
});
});
-249
View File
@@ -1,249 +0,0 @@
/**
* Elements API Tests CRUD (List / Create / Update / Delete)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Elements API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let drawingId: string;
let layerId: string;
let createdElementId: string;
let authToken: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
const regRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'admin-test@example.com', password: 'Password123!', name: 'Admin Test', role: 'admin' },
});
authToken = JSON.parse(regRes.body).session.token;
// Create project → drawing → layer hierarchy
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Elements Test Project' },
});
const projectId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Elements Test Drawing' },
});
drawingId = JSON.parse(drawRes.body).id;
const layerRes = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/layers`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Elements Layer' },
});
layerId = JSON.parse(layerRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Auth Check ──────────────────────────────────────
describe('Authentication', () => {
it('should return 401 without authorization header', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/elements`,
});
expect(response.statusCode).toBe(401);
});
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/drawings/:drawingId/elements', () => {
it('should create an element with 201', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/elements`,
headers: { authorization: `Bearer ${authToken}` },
payload: { layer_id: layerId, type: 'rect', x: 10, y: 20, width: 100, height: 50 },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.type).toBe('rect');
expect(body.x).toBe(10);
expect(body.y).toBe(20);
expect(body.width).toBe(100);
expect(body.height).toBe(50);
expect(body.drawing_id).toBe(drawingId);
expect(body.layer_id).toBe(layerId);
createdElementId = body.id;
});
it('should create an element with default values', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/elements`,
headers: { authorization: `Bearer ${authToken}` },
payload: { layer_id: layerId, type: 'circle' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.type).toBe('circle');
expect(body.x).toBe(0);
expect(body.y).toBe(0);
expect(body.width).toBe(0);
expect(body.height).toBe(0);
expect(body.properties_json).toBe('{}');
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/drawings/:drawingId/elements', () => {
it('should list elements for a drawing', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/elements`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should return empty array for drawing with no elements', async () => {
// Create a new drawing with no elements
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Empty Elements Project' },
});
const projId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projId}/drawings`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Empty Drawing' },
});
const emptyDrawId = JSON.parse(drawRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${emptyDrawId}/elements`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(0);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/elements/:id', () => {
it('should update element position', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/elements/${createdElementId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { x: 100, y: 200 },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.x).toBe(100);
expect(body.y).toBe(200);
});
it('should update element dimensions', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/elements/${createdElementId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { width: 300, height: 150 },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.width).toBe(300);
expect(body.height).toBe(150);
});
it('should update element properties_json', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/elements/${createdElementId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { properties_json: '{"color":"red"}' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.properties_json).toBe('{"color":"red"}');
});
it('should return 404 for non-existent element update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/elements/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
payload: { x: 0 },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/elements/:id', () => {
it('should delete an element', async () => {
// Create an element to delete
const createRes = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/elements`,
headers: { authorization: `Bearer ${authToken}` },
payload: { layer_id: layerId, type: 'line' },
});
const elemId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/elements/${elemId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(204);
// Verify it's gone via list
const listRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/elements`,
headers: { authorization: `Bearer ${authToken}` },
});
const elements = JSON.parse(listRes.body);
expect(elements.find((e: any) => e.id === elemId)).toBeUndefined();
});
it('should return 404 for non-existent element delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/elements/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(404);
});
});
});
-426
View File
@@ -1,426 +0,0 @@
/**
* Global Blocks API Tests CRUD for folders and blocks
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Global Blocks API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let authToken: string;
let createdFolderId: string;
let subFolderId: string;
let createdBlockId: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
const regRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'global-test@example.com', password: 'Password123!', name: 'Global Test', role: 'admin' },
});
authToken = JSON.parse(regRes.body).session.token;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Auth Check ──────────────────────────────────────
describe('Authentication', () => {
it('should return 401 without authorization header for folders', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/global-folders',
});
expect(response.statusCode).toBe(401);
});
it('should return 401 without authorization header for blocks', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/global-blocks',
});
expect(response.statusCode).toBe(401);
});
});
// ─── Folder CRUD ─────────────────────────────────────
describe('POST /api/global-folders', () => {
it('should create a root folder with 201', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/global-folders',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Möbel' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Möbel');
expect(body.parent_id).toBeNull();
createdFolderId = body.id;
});
it('should create a sub-folder with parent_id', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/global-folders',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Stühle', parent_id: createdFolderId },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Stühle');
expect(body.parent_id).toBe(createdFolderId);
subFolderId = body.id;
});
it('should reject folder without name with 400', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/global-folders',
headers: { authorization: `Bearer ${authToken}` },
payload: {},
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Name is required');
});
it('should reject folder with empty name with 400', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/global-folders',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: '' },
});
expect(response.statusCode).toBe(400);
});
});
describe('GET /api/global-folders', () => {
it('should list all folders', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/global-folders',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should list root folders (parentId=null)', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/global-folders?parentId=null',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.every((f: { parent_id: string | null }) => f.parent_id === null)).toBe(true);
});
it('should list sub-folders for a parent', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/global-folders?parentId=${createdFolderId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.length).toBeGreaterThanOrEqual(1);
expect(body[0].parent_id).toBe(createdFolderId);
});
});
describe('PATCH /api/global-folders/:id', () => {
it('should rename a folder', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/global-folders/${createdFolderId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Möbel & Ausstattung' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Möbel & Ausstattung');
});
it('should reject setting parent to self with 400', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/global-folders/${createdFolderId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { parent_id: createdFolderId },
});
expect(response.statusCode).toBe(400);
});
it('should return 404 for non-existent folder', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/global-folders/non-existent',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Block CRUD ──────────────────────────────────────
describe('POST /api/global-blocks', () => {
it('should create a global block in a folder with 201', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/global-blocks',
headers: { authorization: `Bearer ${authToken}` },
payload: {
name: 'Konferenzstuhl',
folder_id: subFolderId,
block_data: JSON.stringify({ type: 'chair', width: 50, height: 50 }),
svg_data: '<svg><rect width="50" height="50"/></svg>',
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Konferenzstuhl');
expect(body.folder_id).toBe(subFolderId);
expect(body.block_data).toContain('chair');
expect(body.svg_data).toContain('<svg>');
createdBlockId = body.id;
});
it('should create a global block without folder (root level)', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/global-blocks',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Root Block' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.folder_id).toBeNull();
expect(body.block_data).toBe('{}');
});
it('should reject block without name with 400', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/global-blocks',
headers: { authorization: `Bearer ${authToken}` },
payload: { folder_id: createdFolderId },
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Name is required');
});
it('should reject non-string block_data with 400', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/global-blocks',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Bad Block', block_data: { not: 'a string' } },
});
expect(response.statusCode).toBe(400);
expect(JSON.parse(response.body).error).toContain('block_data must be a JSON string');
});
});
describe('GET /api/global-blocks', () => {
it('should list all global blocks', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/global-blocks',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should list blocks for a specific folder', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/global-blocks?folderId=${subFolderId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.length).toBeGreaterThanOrEqual(1);
expect(body.every((b: { folder_id: string | null }) => b.folder_id === subFolderId)).toBe(true);
});
it('should list root-level blocks (folderId=null)', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/global-blocks?folderId=null',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.every((b: { folder_id: string | null }) => b.folder_id === null)).toBe(true);
});
});
describe('GET /api/global-blocks/:id', () => {
it('should get a single global block', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/global-blocks/${createdBlockId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.id).toBe(createdBlockId);
expect(body.name).toBe('Konferenzstuhl');
});
it('should return 404 for non-existent block', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/global-blocks/non-existent',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(404);
});
});
describe('PATCH /api/global-blocks/:id', () => {
it('should rename a block', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/global-blocks/${createdBlockId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Updated Chair' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Updated Chair');
});
it('should move a block to a different folder', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/global-blocks/${createdBlockId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { folder_id: createdFolderId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.folder_id).toBe(createdFolderId);
});
it('should update block_data', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/global-blocks/${createdBlockId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { block_data: JSON.stringify({ type: 'updated', width: 60 }) },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.block_data).toContain('updated');
});
it('should return 404 for non-existent block update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/global-blocks/non-existent',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ───────────────────────────────────────────
describe('DELETE /api/global-blocks/:id', () => {
it('should delete a global block', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/api/global-blocks',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'To Delete' },
});
const blockId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/global-blocks/${blockId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(204);
});
it('should return 404 for non-existent block delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/global-blocks/non-existent',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(404);
});
});
describe('DELETE /api/global-folders/:id', () => {
it('should delete a folder and cascade to sub-folders', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/api/global-folders',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Temp Folder' },
});
const folderId = JSON.parse(createRes.body).id;
const subRes = await app.inject({
method: 'POST',
url: '/api/global-folders',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Temp Sub', parent_id: folderId },
});
const subId = JSON.parse(subRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/global-folders/${folderId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(204);
// Sub-folder should be gone (cascade)
const checkSub = await app.inject({
method: 'GET',
url: `/api/global-folders/${subId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(checkSub.statusCode).toBe(404);
});
it('should return 404 for non-existent folder delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/global-folders/non-existent',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(404);
});
});
});
-45
View File
@@ -1,45 +0,0 @@
/**
* Health Endpoint Tests
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Health Endpoint', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
});
afterAll(async () => {
await app.close();
db.close();
});
it('GET /api/health should return ok status', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/health',
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.status).toBe('ok');
expect(body.timestamp).toBeDefined();
});
it('GET /api/health should return valid ISO timestamp', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/health',
});
const body = JSON.parse(response.body);
const date = new Date(body.timestamp);
expect(date.toString()).not.toBe('Invalid Date');
});
});
-235
View File
@@ -1,235 +0,0 @@
/**
* Layers API Tests CRUD (List / Create / Update / Delete)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Layers API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let drawingId: string;
let createdLayerId: string;
let authToken: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
const regRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'admin-test@example.com', password: 'Password123!', name: 'Admin Test', role: 'admin' },
});
authToken = JSON.parse(regRes.body).session.token;
// Create project → drawing hierarchy
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Layers Test Project' },
});
const projectId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Layers Test Drawing' },
});
drawingId = JSON.parse(drawRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Auth Check ──────────────────────────────────────
describe('Authentication', () => {
it('should return 401 without authorization header', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/layers`,
});
expect(response.statusCode).toBe(401);
});
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/drawings/:drawingId/layers', () => {
it('should create a layer with 201', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/layers`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Background', color: '#ff0000' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Background');
expect(body.color).toBe('#ff0000');
expect(body.drawing_id).toBe(drawingId);
createdLayerId = body.id;
});
it('should create a layer with default values', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/layers`,
headers: { authorization: `Bearer ${authToken}` },
payload: {},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Layer');
expect(body.visible).toBe(1);
expect(body.locked).toBe(0);
expect(body.color).toBe('#ffffff');
expect(body.line_type).toBe('solid');
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/drawings/:drawingId/layers', () => {
it('should list layers for a drawing', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/layers`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should return empty array for drawing with no layers', async () => {
// Create a new drawing with no layers
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Empty Layers Project' },
});
const projId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projId}/drawings`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Empty Drawing' },
});
const emptyDrawId = JSON.parse(drawRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${emptyDrawId}/layers`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(0);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/layers/:id', () => {
it('should update layer name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/layers/${createdLayerId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Updated Layer Name' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Updated Layer Name');
});
it('should update layer visibility', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/layers/${createdLayerId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { visible: 0, locked: 1 },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.visible).toBe(0);
expect(body.locked).toBe(1);
});
it('should update layer color and line_type', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/layers/${createdLayerId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { color: '#00ff00', line_type: 'dashed' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.color).toBe('#00ff00');
expect(body.line_type).toBe('dashed');
});
it('should return 404 for non-existent layer update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/layers/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/layers/:id', () => {
it('should delete a layer', async () => {
// Create a layer to delete
const createRes = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/layers`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'To Be Deleted' },
});
const layerId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/layers/${layerId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(204);
// Verify it's gone via list
const listRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/layers`,
headers: { authorization: `Bearer ${authToken}` },
});
const layers = JSON.parse(listRes.body);
expect(layers.find((l: any) => l.id === layerId)).toBeUndefined();
});
it('should return 404 for non-existent layer delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/layers/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(404);
});
});
});
-285
View File
@@ -1,285 +0,0 @@
/**
* Project Folders API Tests - CRUD for project folders + move project to folder
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Project Folders API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let authToken: string;
let createdFolderId: string;
let childFolderId: string;
let createdProjectId: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
const regRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'folder-test@example.com', password: 'Password123!', name: 'Folder Test' },
});
const regBody = JSON.parse(regRes.body);
authToken = regBody.session.token;
});
afterAll(async () => {
await app.close();
db.close();
});
it('should list empty folders initially', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/project-folders',
headers: { authorization: `Bearer ${authToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(0);
});
it('should create a folder', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/project-folders',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Test Folder' },
});
expect(res.statusCode).toBe(201);
const body = JSON.parse(res.body);
expect(body.name).toBe('Test Folder');
expect(body.parent_id).toBeNull();
expect(body.id).toBeDefined();
createdFolderId = body.id;
});
it('should create a child folder', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/project-folders',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Child Folder', parent_id: createdFolderId },
});
expect(res.statusCode).toBe(201);
const body = JSON.parse(res.body);
expect(body.name).toBe('Child Folder');
expect(body.parent_id).toBe(createdFolderId);
childFolderId = body.id;
});
it('should list folders including the created ones', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/project-folders',
headers: { authorization: `Bearer ${authToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.length).toBe(2);
expect(body.some((f: { id: string }) => f.id === createdFolderId)).toBe(true);
expect(body.some((f: { id: string }) => f.id === childFolderId)).toBe(true);
});
it('should get a single folder', async () => {
const res = await app.inject({
method: 'GET',
url: `/api/project-folders/${createdFolderId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.id).toBe(createdFolderId);
expect(body.name).toBe('Test Folder');
});
it('should rename a folder', async () => {
const res = await app.inject({
method: 'PUT',
url: `/api/project-folders/${createdFolderId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Renamed Folder' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.name).toBe('Renamed Folder');
});
it('should reject folder creation without name', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/project-folders',
headers: { authorization: `Bearer ${authToken}` },
payload: {},
});
expect(res.statusCode).toBe(400);
});
it('should reject rename without name', async () => {
const res = await app.inject({
method: 'PUT',
url: `/api/project-folders/${createdFolderId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: {},
});
expect(res.statusCode).toBe(400);
});
it('should reject operations on non-existent folder', async () => {
const getRes = await app.inject({
method: 'GET',
url: '/api/project-folders/nonexistent-folder',
headers: { authorization: `Bearer ${authToken}` },
});
expect(getRes.statusCode).toBe(404);
const delRes = await app.inject({
method: 'DELETE',
url: '/api/project-folders/nonexistent-folder',
headers: { authorization: `Bearer ${authToken}` },
});
expect(delRes.statusCode).toBe(404);
});
it('should reject unauthenticated requests', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/project-folders',
});
expect(res.statusCode).toBe(401);
});
// ─── Project + Folder integration ────────────────────
it('should create a project with folder_id', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Folder Project', folder_id: createdFolderId },
});
expect(res.statusCode).toBe(201);
const body = JSON.parse(res.body);
expect(body.name).toBe('Folder Project');
expect(body.folder_id).toBe(createdFolderId);
createdProjectId = body.id;
});
it('should list projects filtered by folder', async () => {
const res = await app.inject({
method: 'GET',
url: `/api/projects?folderId=${createdFolderId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.length).toBe(1);
expect(body[0].id).toBe(createdProjectId);
});
it('should list unfoldered projects with folderId=null', async () => {
// First create a project without folder
await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'No Folder Project' },
});
const res = await app.inject({
method: 'GET',
url: '/api/projects?folderId=null',
headers: { authorization: `Bearer ${authToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.every((p: { folder_id: string | null }) => p.folder_id === null)).toBe(true);
});
it('should move project to a different folder', async () => {
const res = await app.inject({
method: 'PUT',
url: `/api/projects/${createdProjectId}/folder`,
headers: { authorization: `Bearer ${authToken}` },
payload: { folder_id: childFolderId },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.folder_id).toBe(childFolderId);
});
it('should move project to root (folder_id = null)', async () => {
const res = await app.inject({
method: 'PUT',
url: `/api/projects/${createdProjectId}/folder`,
headers: { authorization: `Bearer ${authToken}` },
payload: { folder_id: null },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.folder_id).toBeNull();
});
it('should reject moving project to non-existent folder', async () => {
const res = await app.inject({
method: 'PUT',
url: `/api/projects/${createdProjectId}/folder`,
headers: { authorization: `Bearer ${authToken}` },
payload: { folder_id: 'nonexistent-folder' },
});
expect(res.statusCode).toBe(404);
});
it('should delete folder and move projects to root', async () => {
// Move project back to folder first
await app.inject({
method: 'PUT',
url: `/api/projects/${createdProjectId}/folder`,
headers: { authorization: `Bearer ${authToken}` },
payload: { folder_id: childFolderId },
});
// Delete the child folder
const delRes = await app.inject({
method: 'DELETE',
url: `/api/project-folders/${childFolderId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(delRes.statusCode).toBe(204);
// Verify project is now in root
const projRes = await app.inject({
method: 'GET',
url: `/api/projects/${createdProjectId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(projRes.statusCode).toBe(200);
const projBody = JSON.parse(projRes.body);
expect(projBody.folder_id).toBeNull();
});
it('should delete remaining folders', async () => {
const res = await app.inject({
method: 'DELETE',
url: `/api/project-folders/${createdFolderId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(res.statusCode).toBe(204);
// Verify no folders left
const listRes = await app.inject({
method: 'GET',
url: '/api/project-folders',
headers: { authorization: `Bearer ${authToken}` },
});
const listBody = JSON.parse(listRes.body);
expect(listBody.length).toBe(0);
});
});
-209
View File
@@ -1,209 +0,0 @@
/**
* Projects API Tests CRUD (List / Get / Create / Update / Delete)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Projects API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let createdProjectId: string | null = null;
let authToken: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
const regRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'admin-test@example.com', password: 'Password123!', name: 'Admin Test', role: 'admin' },
});
authToken = JSON.parse(regRes.body).session.token;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Auth Check ──────────────────────────────────────
describe('Authentication', () => {
it('should return 401 without authorization header', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/projects',
});
expect(response.statusCode).toBe(401);
});
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/projects', () => {
it('should create a project with 201', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: {
name: 'Test Project',
description: 'A test project',
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Test Project');
expect(body.description).toBe('A test project');
createdProjectId = body.id;
});
it('should reject project without name with 400', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: { description: 'No name' },
});
expect(response.statusCode).toBe(400);
});
it('should create project with default values', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Minimal Project' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Minimal Project');
expect(body.description).toBeNull();
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/projects', () => {
it('should list all projects', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
});
// ─── Get ────────────────────────────────────────────
describe('GET /api/projects/:id', () => {
it('should get a single project', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/projects/${createdProjectId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.id).toBe(createdProjectId);
expect(body.name).toBe('Test Project');
});
it('should return 404 for non-existent project', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/projects/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/projects/:id', () => {
it('should update project name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/projects/${createdProjectId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Updated Project Name' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Updated Project Name');
expect(body.id).toBe(createdProjectId);
});
it('should update project description', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/projects/${createdProjectId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { description: 'Updated description' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.description).toBe('Updated description');
});
it('should return 404 for non-existent project update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/projects/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/projects/:id', () => {
it('should delete a project', async () => {
// First create a project to delete
const createRes = await app.inject({
method: 'POST',
url: '/api/projects',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'To Be Deleted' },
});
const projectId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/projects/${projectId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(204);
// Verify it's gone
const getRes = await app.inject({
method: 'GET',
url: `/api/projects/${projectId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(getRes.statusCode).toBe(404);
});
it('should return 404 for non-existent project delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/projects/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(404);
});
});
});
-159
View File
@@ -1,159 +0,0 @@
/**
* Settings API Tests Key/Value settings (Get / Put / Upsert)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Settings API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let authToken: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
const regRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'admin-test@example.com', password: 'Password123!', name: 'Admin Test', role: 'admin' },
});
authToken = JSON.parse(regRes.body).session.token;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Auth Check ──────────────────────────────────────
describe('Authentication', () => {
it('should return 401 without authorization header', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/settings/non-existent-key',
});
expect(response.statusCode).toBe(401);
});
});
// ─── Get (missing key → 404) ─────────────────────────
describe('GET /api/settings/:key', () => {
it('should return 404 for non-existent setting', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/settings/non-existent-key',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(404);
const body = JSON.parse(response.body);
expect(body.error).toContain('Setting not found');
});
it('should return a setting that was previously set', async () => {
// First set a value
await app.inject({
method: 'PUT',
url: '/api/settings/test-key',
headers: { authorization: `Bearer ${authToken}` },
payload: { value: 'test-value' },
});
const response = await app.inject({
method: 'GET',
url: '/api/settings/test-key',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.key).toBe('test-key');
expect(body.value).toBe('test-value');
expect(body.updated_at).toBeDefined();
});
});
// ─── Put (upsert) ───────────────────────────────────
describe('PUT /api/settings/:key', () => {
it('should create a new setting', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/new-setting',
headers: { authorization: `Bearer ${authToken}` },
payload: { value: 'new-value' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.key).toBe('new-setting');
expect(body.value).toBe('new-value');
expect(body.updated_at).toBeDefined();
});
it('should update an existing setting (upsert)', async () => {
// Create initial
await app.inject({
method: 'PUT',
url: '/api/settings/upsert-key',
headers: { authorization: `Bearer ${authToken}` },
payload: { value: 'initial' },
});
// Update it
const response = await app.inject({
method: 'PUT',
url: '/api/settings/upsert-key',
headers: { authorization: `Bearer ${authToken}` },
payload: { value: 'updated' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.key).toBe('upsert-key');
expect(body.value).toBe('updated');
// Verify via GET
const getRes = await app.inject({
method: 'GET',
url: '/api/settings/upsert-key',
headers: { authorization: `Bearer ${authToken}` },
});
const getBody = JSON.parse(getRes.body);
expect(getBody.value).toBe('updated');
});
it('should reject setting without value', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/no-value-key',
headers: { authorization: `Bearer ${authToken}` },
payload: {},
});
const body = JSON.parse(response.body);
expect(body.error).toContain('Value is required');
});
it('should handle complex JSON values', async () => {
const complexValue = JSON.stringify({ nested: { object: true }, array: [1, 2, 3] });
const response = await app.inject({
method: 'PUT',
url: '/api/settings/complex-config',
headers: { authorization: `Bearer ${authToken}` },
payload: { value: complexValue },
});
expect(response.statusCode).toBe(200);
const getRes = await app.inject({
method: 'GET',
url: '/api/settings/complex-config',
headers: { authorization: `Bearer ${authToken}` },
});
const body = JSON.parse(getRes.body);
expect(body.value).toBe(complexValue);
});
});
});
-218
View File
@@ -1,218 +0,0 @@
/**
* Users API Tests List / Get / Update / Delete (password_hash stripping)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Users API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let testUserId: string;
let authToken: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create a test user directly via DB (bypassing AuthService)
const user = db.createUser({
email: 'test-admin@example.com',
password_hash: 'fake-hash-for-testing',
name: 'Test Admin',
role: 'admin',
});
testUserId = user.id;
// Register an admin user via API to get a valid auth token
const regRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'admin-auth@example.com', password: 'Password123!', name: 'Admin Auth', role: 'admin' },
});
const regBody = JSON.parse(regRes.body);
authToken = regBody.session.token;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── List ───────────────────────────────────────────
describe('GET /api/users', () => {
it('should return 401 without authorization header', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users',
});
expect(response.statusCode).toBe(401);
});
it('should list all users', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2); // default user + test user
});
it('should strip password_hash from list responses', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users',
headers: { authorization: `Bearer ${authToken}` },
});
const body = JSON.parse(response.body);
for (const user of body) {
expect(user.password_hash).toBeUndefined();
}
});
});
// ─── Get ────────────────────────────────────────────
describe('GET /api/users/:id', () => {
it('should get a single user', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/users/${testUserId}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.id).toBe(testUserId);
expect(body.email).toBe('test-admin@example.com');
expect(body.name).toBe('Test Admin');
expect(body.role).toBe('admin');
});
it('should strip password_hash from single user response', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/users/${testUserId}`,
headers: { authorization: `Bearer ${authToken}` },
});
const body = JSON.parse(response.body);
expect(body.password_hash).toBeUndefined();
});
it('should return 404 for non-existent user', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/users/:id', () => {
it('should update user name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/users/${testUserId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Updated Name' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Updated Name');
expect(body.id).toBe(testUserId);
});
it('should update user role', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/users/${testUserId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { role: 'planer' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.role).toBe('planer');
});
it('should strip password_hash from update response', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/users/${testUserId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'Another Name' },
});
const body = JSON.parse(response.body);
expect(body.password_hash).toBeUndefined();
});
it('should not update password_hash via PATCH endpoint', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/users/${testUserId}`,
headers: { authorization: `Bearer ${authToken}` },
payload: { password_hash: 'hacked-hash' },
});
expect(response.statusCode).toBe(200);
// Verify the password_hash was NOT changed in DB
const dbUser = db.getUser(testUserId);
expect(dbUser!.password_hash).toBe('fake-hash-for-testing');
});
it('should return 404 for non-existent user update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/users/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/users/:id', () => {
it('should delete a user', async () => {
// Create a user to delete
const user = db.createUser({
email: 'delete-me@example.com',
password_hash: 'temp-hash',
name: 'Delete Me',
role: 'planer',
});
const response = await app.inject({
method: 'DELETE',
url: `/api/users/${user.id}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(204);
// Verify it's gone
const getRes = await app.inject({
method: 'GET',
url: `/api/users/${user.id}`,
headers: { authorization: `Bearer ${authToken}` },
});
expect(getRes.statusCode).toBe(404);
});
it('should return 404 for non-existent user delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/users/non-existent-id',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(404);
});
});
});
-135
View File
@@ -1,135 +0,0 @@
/**
* Validation Utils Tests
*/
import { describe, it, expect } from 'vitest';
import { validateName, validateIdParam, validateSettingsKey, validateSettingsValue, MAX_NAME_LENGTH, MAX_ID_LENGTH } from '../src/utils/validation.js';
describe('Validation Utils', () => {
describe('validateName', () => {
it('should accept a valid name', () => {
expect(validateName('Test Project')).toBeNull();
});
it('should reject undefined', () => {
expect(validateName(undefined)).toContain('required');
});
it('should reject null', () => {
expect(validateName(null)).toContain('required');
});
it('should reject non-string types', () => {
expect(validateName(123)).toContain('required');
expect(validateName(true)).toContain('required');
expect(validateName({})).toContain('required');
});
it('should reject empty string', () => {
expect(validateName('')).toContain('empty');
});
it('should reject whitespace-only string', () => {
expect(validateName(' ')).toContain('empty');
});
it('should reject string exceeding max length', () => {
const longName = 'a'.repeat(MAX_NAME_LENGTH + 1);
expect(validateName(longName)).toContain('at most');
});
it('should accept string at exactly max length', () => {
const maxName = 'a'.repeat(MAX_NAME_LENGTH);
expect(validateName(maxName)).toBeNull();
});
it('should use custom field name in error', () => {
const err = validateName(undefined, 'project');
expect(err).toContain('Project');
});
});
describe('validateIdParam', () => {
it('should accept a valid alphanumeric ID', () => {
expect(validateIdParam('abc123')).toBeNull();
});
it('should accept ID with hyphens and underscores', () => {
expect(validateIdParam('my-id_123')).toBeNull();
});
it('should reject undefined', () => {
expect(validateIdParam(undefined)).toContain('required');
});
it('should reject null', () => {
expect(validateIdParam(null)).toContain('required');
});
it('should reject empty string', () => {
expect(validateIdParam('')).toContain('empty');
});
it('should reject ID with special characters', () => {
expect(validateIdParam('abc!def')).toContain('invalid');
expect(validateIdParam('abc def')).toContain('invalid');
expect(validateIdParam('abc/def')).toContain('invalid');
expect(validateIdParam('abc.def')).toContain('invalid');
});
it('should reject ID exceeding max length', () => {
const longId = 'a'.repeat(MAX_ID_LENGTH + 1);
expect(validateIdParam(longId)).toContain('too long');
});
it('should accept ID at exactly max length', () => {
const maxId = 'a'.repeat(MAX_ID_LENGTH);
expect(validateIdParam(maxId)).toBeNull();
});
});
describe('validateSettingsKey', () => {
it('should accept a valid key', () => {
expect(validateSettingsKey('theme.color')).toBeNull();
});
it('should accept key with dots, hyphens, underscores', () => {
expect(validateSettingsKey('my-setting_key.value')).toBeNull();
});
it('should reject undefined', () => {
expect(validateSettingsKey(undefined)).toContain('required');
});
it('should reject empty string', () => {
expect(validateSettingsKey('')).toContain('required');
});
it('should reject key with special characters', () => {
expect(validateSettingsKey('abc!def')).toContain('invalid');
expect(validateSettingsKey('abc def')).toContain('invalid');
});
});
describe('validateSettingsValue', () => {
it('should accept a valid string value', () => {
expect(validateSettingsValue('some value')).toBeNull();
});
it('should accept empty string value', () => {
expect(validateSettingsValue('')).toBeNull();
});
it('should reject undefined', () => {
expect(validateSettingsValue(undefined)).toContain('required');
});
it('should reject null', () => {
expect(validateSettingsValue(null)).toContain('required');
});
it('should reject non-string types', () => {
expect(validateSettingsValue(123)).toContain('string');
expect(validateSettingsValue(true)).toContain('string');
});
});
});
-14
View File
@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
-14
View File
@@ -1,14 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/types/**', 'src/websocket/**'],
},
},
});
-20
View File
@@ -1,20 +0,0 @@
#!/bin/bash
set -e
cd /data/web-cad-neu
echo "[$(date)] Pulling latest code..."
git pull origin main
echo "[$(date)] Building backend image..."
docker build -t web-cad-neu-backend:latest ./backend
echo "[$(date)] Building frontend image..."
docker build -t web-cad-neu-frontend:latest ./frontend
echo "[$(date)] Redeploying containers..."
cd /data/coolify/services/cnuavrh33goa3zs0upi6hgif
docker compose up -d --force-recreate
echo "[$(date)] Health check..."
sleep 5
docker exec backend-cnuavrh33goa3zs0upi6hgif wget -qO- http://localhost:3001/api/health
echo
echo "[$(date)] Frontend check..."
curl -sk -o /dev/null -w "%{http_code}" https://web-cad-neu.server.media-on.de
echo
echo "[$(date)] Deploy complete!"
+34
View File
@@ -0,0 +1,34 @@
version: '3.8'
services:
backend:
build: ./backend
ports:
- "5000:5000"
environment:
- NODE_ENV=production
- JWT_SECRET=${JWT_SECRET:?set JWT_SECRET}
volumes:
- sqlite_data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:5000/api/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
frontend:
build: ./frontend
expose:
- "80"
depends_on:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:80 || exit 1"]
interval: 10s
timeout: 5s
retries: 5
volumes:
sqlite_data:
+34
View File
@@ -0,0 +1,34 @@
version: '3.8'
services:
backend:
build: ./backend
ports:
- "5000:5000"
environment:
- NODE_ENV=production
- JWT_SECRET=6151504445a6e5defbe6888f91d002bbd5368f0af5dc38ce6bf25606818f0b3b
volumes:
- sqlite_data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:5000/api/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
frontend:
build: ./frontend
expose:
- "80"
depends_on:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:80 || exit 1"]
interval: 10s
timeout: 5s
retries: 5
volumes:
sqlite_data:
+55
View File
@@ -0,0 +1,55 @@
version: '3.8'
services:
backend:
image: node:22-alpine
working_dir: /app
environment:
- NODE_ENV=production
- JWT_SECRET=cad-jwt-secret-prod-2026
- PORT=5000
expose:
- "5000"
command:
- sh
- '-c'
- |
apk add --no-cache git
git clone --depth 1 https://forgejo.media-on.de/Leopoldadmin/web-cad.git /tmp/repo
cp -r /tmp/repo/backend/* /app/
rm -rf /tmp/repo
npm ci --only=production
node server.js
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:5000/api/health',r=>{process.exit(r.statusCode===200?0:1)})"]
interval: 15s
timeout: 5s
retries: 15
start_period: 40s
restart: unless-stopped
frontend:
image: node:22-alpine
working_dir: /app
expose:
- "80"
command:
- sh
- '-c'
- |
apk add --no-cache git
git clone --depth 1 https://forgejo.media-on.de/Leopoldadmin/web-cad.git /tmp/repo
cp -r /tmp/repo/frontend/* /app/
rm -rf /tmp/repo
npm ci
npx vite build --mode production
npx serve -s dist -l 80
depends_on:
- backend
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:80/',r=>{process.exit(r.statusCode===200?0:1)})"]
interval: 15s
timeout: 5s
retries: 15
start_period: 90s
restart: unless-stopped
+14 -24
View File
@@ -1,34 +1,24 @@
version: "3.8" version: '3.8'
services: services:
frontend:
build: ./frontend
ports:
- "8082:80"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
backend: backend:
build: ./backend build: ./backend
ports: ports:
- "3001:3001" - "5000:5000"
volumes:
- backend-data:/app/data
environment: environment:
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- API_KEY_OPENROUTER=${API_KEY_OPENROUTER:-}
- DB_PATH=/app/data/cad.db
- PORT=3001
- NODE_ENV=production - NODE_ENV=production
healthcheck: - JWT_SECRET=your-secret-key-change-me
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/api/health"] volumes:
interval: 30s - sqlite_data:/app/data
timeout: 5s restart: unless-stopped
retries: 3
start_period: 10s frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped restart: unless-stopped
volumes: volumes:
backend-data: sqlite_data:
-131
View File
@@ -1,131 +0,0 @@
# HMS Licht & Ton Component Inventory
## Overview
All components are built as Vue 3 single-file components (template + setup). In production (Nuxt 3), these map to `.vue` files in `components/` and `pages/` directories.
## Shared Components
### HmsLogo
- **Purpose:** SVG logo with white HMS text + orange (#EC6925) rectangular border
- **Props:** `size` (Number, default: 40)
- **States:** Default only (static SVG)
- **Accessibility:** `aria-label`, `role="img"`
### SpeakerIcon
- **Purpose:** SVG loudspeaker/light icon for equipment grid
- **Props:** `type` (String: linearray, subwoofer, monitor, pointsource, column, movinghead)
- **States:** Default (gray), Hover (orange via parent CSS)
- **Accessibility:** `aria-hidden="true"`
### AppHeader
- **Purpose:** Sticky navigation header with logo, desktop nav, mobile hamburger menu
- **States:** Default, Active nav item (`aria-current="page"`), Mobile menu open/closed
- **Accessibility:** `role="banner"`, `aria-label`, `aria-expanded`, `aria-controls`
### AppFooter
- **Purpose:** 4-column footer with company info, navigation, contact, legal links
- **States:** Default only
- **Accessibility:** `role="contentinfo"`
### ServiceCard
- **Purpose:** Service offering card with icon, title, description
- **Props:** `icon`, `title`, `description`
- **States:** Default, Hover (border highlight)
### EquipmentCard
- **Purpose:** Equipment listing card with image, badge, name, description, add-to-cart button
- **Props:** `item` (Object: id, code, name, category, description, brand, image)
- **Emits:** `click`, `add-to-cart`
- **States:** Default, Hover (border + shadow + lift), Focus (keyboard), No image (placeholder)
- **Accessibility:** `role="article"`, `tabindex="0"`, `aria-label` on add button
### LoadingSkeleton
- **Purpose:** Shimmer animation skeleton cards for loading states
- **Props:** `count` (Number, default: 6)
- **States:** Shimmer animation (infinite)
- **Accessibility:** `aria-live="polite"`, `aria-busy="true"`
### EmptyState
- **Purpose:** Empty state with icon, title, message, optional CTA button
- **Props:** `icon`, `title`, `message`, `actionLabel`
- **Emits:** `action`
- **States:** Default
- **Accessibility:** `role="status"`
### ErrorState
- **Purpose:** Error state with alert icon, title, message, retry button
- **Props:** `title`, `message`
- **Emits:** `retry`
- **States:** Default
- **Accessibility:** `role="alert"`
## Page Components
### HomePage (`#/`)
- **Sections:** Hero (bg image + overlay), Speaker Grid (6 types), Über uns (stats + image), 8 Service Cards, Mietkatalog CTA
- **States:** Default (all content static)
### ReferenzenPage (`#/referenzen`)
- **States:** Loading (skeleton), Loaded (gallery grid), Error (retry), Empty (no category results)
- **Features:** Category filter chips (Alle, Open-Air, Indoor, Rigging, Event), 9 real HMS photos
### KontaktPage (`#/kontakt`)
- **Sections:** Büro/Lager/Öffnungszeiten cards, 2 Ansprechpartner cards, Contact form
- **Form States:** Default, Validation errors (inline), Submitting (spinner), Submitted (success message)
- **Validation:** Name*, Email* (regex), Message*, Privacy checkbox*
### MietkatalogPage (`#/mietkatalog`)
- **States:** Loading (skeleton), Loaded (equipment grid), Error (retry), Empty (no search results)
- **Features:** Search input, Category filter chips (7 categories), Sort dropdown (name/brand/code), Result count
### EquipmentDetailPage (`#/mietkatalog/:id`)
- **States:** Loading (skeleton), Loaded (detail view), Error (not found)
- **Sections:** Image/placeholder, Specs table, Rental form (date range + quantity), Related items grid
- **Form:** Mietbeginn, Mietende, Anzahl (stepper), Add to cart button
### WarenkorbPage (`#/warenkorb`)
- **States:** Empty (no items), Cart with items, Submitting (spinner), Submitted (success)
- **Features:** Item list with quantity steppers, remove buttons, rental dates display, Request form (name*, email*, phone, event_date*, event_location*, message)
### AdminPage (`#/admin`)
- **States:** Login form, Loading (spinner), Logged in (success)
- **Features:** Username + password fields, minimal design
### NotFoundPage (`#/404`)
- **States:** Default (404 message + home link)
## UX State Coverage Summary
| Page | Loading | Empty | Error | Partial | Success |
|------|---------|-------|-------|---------|---------|
| Home | | | | | |
| Referenzen | Skeleton | No category | Retry | Filtered | |
| Kontakt | | | | | Form sent |
| Mietkatalog | Skeleton | No results | Retry | Filtered | |
| Detail | Skeleton | | Not found | | |
| Warenkorb | | Empty cart | | | Request sent |
| Admin | Spinner | | | | Logged in |
## Component States Documentation
### Button States
- **Default:** `background: var(--color-accent); color: white`
- **Hover:** `background: var(--color-accent-hover)`
- **Active:** `transform: translateY(0)`
- **Disabled:** `background: var(--secondary); cursor: not-allowed`
- **Focus-visible:** `outline: 2px solid var(--color-accent); outline-offset: 2px`
### Input States
- **Default:** `border: 1px solid var(--border-strong); background: var(--surface)`
- **Focus:** `border-color: var(--color-accent); box-shadow: 0 0 0 2px rgba(236,105,37,0.1)`
- **Error:** `border-color: var(--color-error)`
- **Disabled:** `background: var(--row); color: var(--secondary); cursor: not-allowed`
### Card States
- **Default:** `border: 1px solid var(--border); box-shadow: var(--shadow-sm)`
- **Hover:** `border-color: var(--secondary); box-shadow: var(--shadow-lg)`
### Chip States
- **Default:** `border: 1px solid var(--border-strong); background: var(--surface)`
- **Hover:** `border-color: var(--secondary)`
- **Active:** `background: var(--color-accent); color: white; border-color: var(--color-accent)`
-206
View File
@@ -1,206 +0,0 @@
# HMS Licht & Ton UI Design Dokumentation
## 1. Projekt-Überblick
**Projekt:** Neue Homepage für HMS Licht & Ton GbR, Veranstaltungstechnik aus Leipheim/Ellzee
**Domain:** hms.media-on.de (nicht öffentlich indexiert)
**Technologie:** Nuxt 3 (Vue 3 + Tailwind CSS, SSR/SSG) + FastAPI Backend + Coolify Deployment
## 2. Design-Philosophie
Das Design folgt einer **dunklen, professionellen Ästhetik** orientiert an den Agent Zero WebUI Grautönen. Orange (#EC6925) aus dem HMS-Logo wird **dezent als Akzentfarbe** eingesetzt ausschließlich für Buttons, Badges, kleine Text-Highlights und den Logo-Rahmen. Keine großen Orange-Flächen.
**Tonalität:** Professionell, erfahren, zuverlässig, technisch kompetent nicht werblich oder übertrieben.
## 3. Design Tokens
### 3.1 Farbpalette
#### Agent Zero Dark Theme (Hauptfarben)
| Token | Hex | Verwendung |
|-------|-----|-----------|
| `--bg` | `#131313` | Seiten-Hintergrund |
| `--panel` | `#1a1a1a` | Panels, Cards, Header |
| `--surface` | `#212121` | Input-Felder, Surface-Level |
| `--row` | `#272727` | List-Items, Zwischenebenen |
| `--card` | `#2d2d2d` | Card-Hintergrund |
| `--border` | `#444444a8` | Borders (mit Alpha) |
| `--secondary` | `#656565` | Sekundärfarbe, Icons |
| `--primary` | `#737a81` | Primärfarbe (leicht bläuliches Grau) |
| `--text` | `#ffffff` | Haupttext |
| `--text-muted` | `#d4d4d4` | Gedimmter Text |
#### Orange Akzent (Dezent)
| Token | Wert | Verwendung |
|-------|------|-----------|
| `--color-accent` | `#EC6925` | Buttons, Badges, Text-Akzente, Logo-Rahmen |
| `--color-accent-hover` | `#d4581a` | Button Hover |
| `--color-accent-light` | `rgba(236, 105, 37, 0.08)` | Badge-Hintergrund, Nav-Active |
| `--color-accent-border` | `rgba(236, 105, 37, 0.25)` | Card Hover Border |
| `--color-accent-dark` | `#b8461a` | Dunkle Variante |
#### Status-Farben (Dark Theme adaptiert)
| Token | Hex | Verwendung |
|-------|-----|-----------|
| `--color-success` | `#4ade80` | Erfolg-Meldungen |
| `--color-success-bg` | `rgba(74, 222, 128, 0.08)` | Erfolg-Hintergrund |
| `--color-error` | `#f87171` | Fehler-Meldungen, Pflichtfelder |
| `--color-error-bg` | `rgba(248, 113, 113, 0.08)` | Fehler-Hintergrund |
| `--color-warning` | `#fbbf24` | Warnungen |
| `--color-info` | `#60a5fa` | Info-Meldungen |
### 3.2 Border Radius (Minimal)
| Token | Wert |
|-------|------|
| `--radius-sm` | `2px` |
| `--radius-md` | `3px` |
| `--radius-lg` | `4px` |
| `--radius-xl` | `4px` |
| `--radius-full` | `9999px` (Badges, Pills) |
### 3.3 Typography
- **Font Family:** Inter (Google Fonts)
- **Weights:** 400 (Regular), 500 (Medium), 600 (Semibold), 700 (Bold), 800 (Extrabold)
- **Größen:** xs: 0.75rem bis 6xl: 3.75rem
- **Line Height:** 1.6 (Body), 1.1-1.3 (Headings)
### 3.4 Spacing
| Token | Wert |
|-------|------|
| `--space-xs` | 0.25rem (4px) |
| `--space-sm` | 0.5rem (8px) |
| `--space-md` | 1rem (16px) |
| `--space-lg` | 1.5rem (24px) |
| `--space-xl` | 2rem (32px) |
| `--space-2xl` | 3rem (48px) |
| `--space-3xl` | 4rem (64px) |
| `--space-4xl` | 6rem (96px) |
### 3.5 Shadows (Dark Theme)
| Token | Wert |
|-------|------|
| `--shadow-sm` | `0 1px 2px 0 rgb(0 0 0 / 0.3)` |
| `--shadow-md` | `0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3)` |
| `--shadow-lg` | `0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)` |
| `--shadow-xl` | `0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.3)` |
### 3.6 Transitions
| Token | Dauer |
|-------|-------|
| `--transition-fast` | 150ms ease |
| `--transition-base` | 250ms ease |
| `--transition-slow` | 400ms ease |
## 4. Layout-System
### 4.1 Header
- Sticky, Höhe: 4rem (Desktop), 3.5rem (Mobile)
- Logo links, Desktop-Nav mittig/rechts, Mobile Hamburger rechts
- Background: `var(--panel)`, Border-Bottom: `var(--border)`
### 4.2 Main Content
- Max-Width: 1280px (`max-w-7xl`)
- Padding: 1rem (Mobile), 1.5rem (sm), 2rem (lg)
### 4.3 Footer
- 4-Spalten Grid (1-Spalte auf Mobile)
- Background: `var(--bg)`, Border-Top: `var(--border)`
### 4.4 Responsive Breakpoints
- **sm:** 640px (Tablet Portrait)
- **md:** 768px (Tablet Landscape, Desktop Nav visible)
- **lg:** 1024px (Desktop, 3-4 Column Grids)
- **Mobile-First:** Alle Layouts starten mit 1-Spalte, expandieren bei Breakpoints
## 5. Seiten-Struktur (Sitemap)
```
Home (#/)
Referenzen (#/referenzen)
Mietkatalog (#/mietkatalog)
Equipment Detail (#/mietkatalog/:id)
Warenkorb (#/warenkorb)
Kontakt (#/kontakt)
Admin (#/admin)
```
## 6. Komponenten-Design
### 6.1 Cards
- Background: `var(--panel)`
- Border: `1px solid var(--border)`
- Border-Radius: `4px` (--radius-lg)
- Hover: Border wird heller, Shadow wird stärker
### 6.2 Buttons
- **Primary:** Orange bg, white text, 3px radius
- **Secondary:** Surface bg, muted text, border, 3px radius
- **Ghost:** Transparent, muted text, hover surface bg
### 6.3 Inputs
- Background: `var(--surface)`
- Border: `1px solid var(--border-strong)` (#555555)
- Focus: Orange border + subtle orange box-shadow
- Error: Red border + red box-shadow
### 6.4 Badges
- Border-Radius: `9999px` (pill shape)
- Primary: Orange bg (8% alpha), orange text, orange border (20% alpha)
### 6.5 Speaker Grid
- CSS Grid: `repeat(auto-fill, minmax(160px, 1fr))`
- Mobile: 2 columns
- Icons: 48x48px SVG, gray default, orange on hover
## 7. UX States
Alle Views implementieren Loading, Empty, Error und Success States:
- **Loading:** Skeleton-Shimmer Animationen
- **Empty:** Icon + Titel + Message + CTA Button
- **Error:** Alert-Icon + Titel + Message + Retry Button
- **Success:** Checkmark + Bestätigungstext + Weiter-Button
## 8. Accessibility (WCAG 2.1 AA)
- **Skip Link:** Zum Hauptinhalt springen am Seitenanfang
- **Semantic HTML:** header, main, footer, nav, section, article
- **ARIA:** aria-label, aria-current, aria-expanded, aria-controls, aria-invalid, aria-describedby, role=alert, role=status
- **Focus Visible:** 2px Orange Outline mit 2px Offset
- **Form Labels:** Jedes Input hat label for=..., Pflichtfelder markiert mit *
- **Heading Hierarchy:** h1 > h2 > h3, keine Levels übersprungen
- **Keyboard Navigation:** Alle interaktiven Elemente per Tab erreichbar, Enter auf Cards
- **Color Contrast:** Text #ffffff auf #131313 = 17.9:1 (AAA), Muted #d4d4d4 auf #131313 = 12.6:1 (AAA)
## 9. KI-/SEO-Features
- **robots:** noindex, nofollow, noarchive, nosnippet
- **JSON-LD:** LocalBusiness Schema mit Adresse, Öffnungszeiten, Services (Schema.org)
- **OpenGraph:** type, title, description, locale, site_name, url
- **Twitter Card:** summary_large_image
- **Semantic HTML5:** Korrekte Struktur für KI-Crawler
## 10. Bilder
- **Hero:** Unsplash Konzert-Bild (background-image, 35% opacity, dark gradient overlay)
- **Über uns:** Unsplash Veranstaltungstechnik-Setup Bild
- **Referenzen:** 9 echte HMS-Fotos (von hms-licht-ton.de, lokal auf Webspace gespeichert)
- **Equipment:** Platzhalter-Icons, Felder für echte Bilder vorbereitet (item.image)
- **Speaker Grid:** SVG-Icons (6 Lautsprecher-Typen)
## 11. Prototyp-URLs
- **Aktuell:** https://webspace.media-on.de/hms-prototype/index.html?v=8
- **Dateien:** index.html, style.css, app.js, logo.svg, img/ref1-9.jpg
## 12. Design-Entscheidungen
| Entscheidung | Begründung |
|-------------|------------|
| Dark Theme (Agent Zero Grautöne) | User-Präferenz, professionelle Wirkung |
| Orange nur als Akzent | User-Feedback: Übergänge zu Orange sollten subtil sein |
| Minimale Border-Radius (2-4px) | User-Feedback: abgerundete Ecken nur ganz minimal |
| Vue 3 SPA für Prototyp | Gleiche Framework wie Produktion (Nuxt 3) |
| Echte HMS-Fotos für Referenzen | Authentizität, keine Stock-Fotos |
| SVG-Icons für Speaker Grid | Kompakt, skalierbar, theme-bar |
| Inter Font | Modern, clean, gute Lesbarkeit |
| noindex meta | Domain hms.media-on.de nicht öffentlich |
+1
View File
@@ -0,0 +1 @@
dmVyc2lvbjogJzMuOCcKCnNlcnZpY2VzOgogIGJhY2tlbmQ6CiAgICBidWlsZDogLi9iYWNrZW5kCiAgICBwb3J0czoKICAgICAgLSAiNTAwMDo1MDAwIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIEpXVF9TRUNSRVQ9ZjJiMjI3MWU1OWJmY2MxNWMxZjE3MzI4YjUxMGRiMDg2NjIwNTJjZGRhMDJkYjk4NWM5NDhkOWY1MzJhMjgyMgogICAgdm9sdW1lczoKICAgICAgLSBzcWxpdGVfZGF0YTovYXBwL2RhdGEKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogWyJDTUQtU0hFTEwiLCAid2dldCAtcU8tIGh0dHA6Ly9sb2NhbGhvc3Q6NTAwMC9hcGkvaGVhbHRoIHx8IGV4aXQgMSJdCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQoKICBmcm9udGVuZDoKICAgIGJ1aWxkOiAuL2Zyb250ZW5kCiAgICBleHBvc2U6CiAgICAgIC0gIjgwIgogICAgZGVwZW5kc19vbjoKICAgICAgLSBiYWNrZW5kCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6IFsiQ01ELVNIRUxMIiwgIndnZXQgLXFPLSBodHRwOi8vbG9jYWxob3N0OjgwIHx8IGV4aXQgMSJdCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQoKdm9sdW1lczoKICBzcWxpdGVfZGF0YToK
-5
View File
@@ -1,5 +0,0 @@
node_modules
dist
.git
*.md
*.log
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+13 -3
View File
@@ -1,10 +1,20 @@
FROM node:20-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --legacy-peer-deps COPY package*.json ./
RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM nginx:alpine FROM nginx:alpine
RUN apk add --no-cache curl
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])
+9 -8
View File
@@ -1,12 +1,13 @@
<!DOCTYPE html> <!doctype html>
<html lang="de"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web CAD</title> <title>frontend</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>
+14 -13
View File
@@ -1,24 +1,25 @@
server { server {
listen 80; listen 80;
server_name localhost;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
resolver 127.0.0.11 valid=30s; # Serve static frontend
location / {
location / { try_files $uri $uri/ /index.html; } try_files $uri /index.html;
location /api/ {
set $backend http://backend:3001;
proxy_pass $backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
} }
location /ws { # Proxy API requests to backend
set $backend http://backend:3001; location /api/ {
proxy_pass $backend; proxy_pass http://backend:5000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
} }
} }
+2259 -1665
View File
File diff suppressed because it is too large Load Diff
+20 -24
View File
@@ -1,34 +1,30 @@
{ {
"name": "web-cad-frontend", "name": "frontend",
"version": "0.1.0",
"private": true, "private": true,
"version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0 --port 5173", "dev": "vite",
"build": "tsc && vite build", "build": "vite build",
"preview": "vite preview", "lint": "eslint .",
"test": "vitest run" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"dxf-parser": "^1.1.2", "axios": "^1.16.1",
"pdf-lib": "^1.17.1", "fabric": "^5.5.2",
"rbush": "^4.0.1", "react": "^19.2.6",
"react": "^18.3.1", "react-dom": "^19.2.6",
"react-dom": "^18.3.1", "react-router-dom": "^7.15.1"
"y-websocket": "^2.0.0",
"yjs": "^13.6.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@eslint/js": "^10.0.1",
"@testing-library/react": "^16.3.2", "@types/react": "^19.2.14",
"@testing-library/user-event": "^14.6.1", "@types/react-dom": "^19.2.3",
"@types/rbush": "^4.0.0", "@vitejs/plugin-react": "^6.0.1",
"@types/react": "^18.3.0", "eslint": "^10.3.0",
"@types/react-dom": "^18.3.0", "eslint-plugin-react-hooks": "^7.1.1",
"@vitejs/plugin-react": "^4.3.0", "eslint-plugin-react-refresh": "^0.5.2",
"jsdom": "^29.1.1", "globals": "^17.6.0",
"typescript": "^5.5.0", "vite": "^8.0.12"
"vite": "^8.1.3",
"vitest": "^4.1.9"
} }
} }
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+35
View File
@@ -0,0 +1,35 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
import Editor from './pages/Editor';
const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuth();
if (loading) return <div>Loading...</div>;
if (!user) return <Navigate to="/login" />;
return children;
};
const AppContent = () => (
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/editor/:id" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
<Route path="/editor/new" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/dashboard" />} />
</Routes>
</Router>
);
const App = () => (
<AuthProvider>
<AppContent />
</AuthProvider>
);
export default App;
-1800
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

-195
View File
@@ -1,195 +0,0 @@
import type { CADLayer } from '../types/cad.types';
export class LayerManager {
private layers: Map<string, CADLayer> = new Map();
private activeLayerId: string = '';
addLayer(layer: CADLayer): void {
this.layers.set(layer.id, layer);
if (!this.activeLayerId) this.activeLayerId = layer.id;
}
removeLayer(id: string): void {
this.layers.delete(id);
if (this.activeLayerId === id) {
const first = this.layers.keys().next();
this.activeLayerId = first.done ? '' : first.value;
}
}
getLayer(id: string): CADLayer | undefined {
return this.layers.get(id);
}
getLayers(): CADLayer[] {
return Array.from(this.layers.values()).sort((a, b) => a.sortOrder - b.sortOrder);
}
getVisibleLayers(): CADLayer[] {
return this.getLayers().filter(l => l.visible && !l.locked);
}
setActiveLayer(id: string): void {
if (this.layers.has(id)) this.activeLayerId = id;
}
getActiveLayer(): CADLayer | undefined {
return this.layers.get(this.activeLayerId);
}
getActiveLayerId(): string {
return this.activeLayerId;
}
toggleVisibility(id: string): void {
const layer = this.layers.get(id);
if (layer) layer.visible = !layer.visible;
}
toggleLock(id: string): void {
const layer = this.layers.get(id);
if (layer) layer.locked = !layer.locked;
}
clear(): void {
this.layers.clear();
this.activeLayerId = '';
}
/**
* Rename a layer.
*/
renameLayer(id: string, name: string): void {
const layer = this.layers.get(id);
if (layer) layer.name = name;
}
/**
* Update layer properties.
*/
updateLayer(id: string, props: Partial<CADLayer>): void {
const layer = this.layers.get(id);
if (layer) {
Object.assign(layer, props);
}
}
/**
* Set parent for a layer (tree structure).
*/
setParent(id: string, parentId: string | null): void {
const layer = this.layers.get(id);
if (!layer) return;
// Prevent circular references
if (parentId) {
let current: string | null = parentId;
while (current) {
if (current === id) return; // Would create a cycle
const parent = this.layers.get(current);
if (!parent) break;
current = parent.parentId;
}
}
layer.parentId = parentId;
}
/**
* Get child layers of a parent.
*/
getChildLayers(parentId: string | null): CADLayer[] {
return this.getLayers().filter(l => l.parentId === parentId);
}
/**
* Get layer tree structure.
*/
getLayerTree(): Array<CADLayer & { children: CADLayer[] }> {
const buildTree = (parentId: string | null): Array<CADLayer & { children: CADLayer[] }> => {
return this.getChildLayers(parentId).map(layer => ({
...layer,
children: buildTree(layer.id),
}));
};
return buildTree(null);
}
/**
* Move layer to a new position in the sort order.
*/
moveLayer(id: string, newSortOrder: number): void {
const layer = this.layers.get(id);
if (!layer) return;
layer.sortOrder = newSortOrder;
}
/**
* Duplicate a layer with all its properties.
*/
duplicateLayer(id: string): CADLayer | null {
const layer = this.layers.get(id);
if (!layer) return null;
const newId = `layer_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const copy: CADLayer = {
...layer,
id: newId,
name: `${layer.name} (Kopie)`,
sortOrder: layer.sortOrder + 1,
parentId: layer.parentId,
};
this.layers.set(newId, copy);
return copy;
}
/**
* Filter layers by criteria.
*/
filterLayers(criteria: {
visible?: boolean;
locked?: boolean;
color?: string;
lineType?: string;
nameContains?: string;
}): CADLayer[] {
return this.getLayers().filter(l => {
if (criteria.visible !== undefined && l.visible !== criteria.visible) return false;
if (criteria.locked !== undefined && l.locked !== criteria.locked) return false;
if (criteria.color && l.color !== criteria.color) return false;
if (criteria.lineType && l.lineType !== criteria.lineType) return false;
if (criteria.nameContains && !l.name.toLowerCase().includes(criteria.nameContains.toLowerCase())) return false;
return true;
});
}
/**
* Get all descendant layer IDs (children, grandchildren, etc.).
*/
getDescendantIds(id: string): string[] {
const result: string[] = [];
const collect = (parentId: string) => {
for (const layer of this.layers.values()) {
if (layer.parentId === parentId) {
result.push(layer.id);
collect(layer.id);
}
}
};
collect(id);
return result;
}
/**
* Check if a layer is locked.
*/
isLocked(id: string): boolean {
const layer = this.layers.get(id);
return layer ? layer.locked : false;
}
/**
* Get layer color.
*/
getLayerColor(id: string): string | undefined {
const layer = this.layers.get(id);
return layer?.color;
}
}
File diff suppressed because it is too large Load Diff
-306
View File
@@ -1,306 +0,0 @@
import type { CADElement, CADLayer } from '../types/cad.types';
import { RenderEngine } from './RenderEngine';
import { SpatialIndex } from './SpatialIndex';
import { LayerManager } from './LayerManager';
export type SelectionMode = 'single' | 'multiple' | 'box' | 'lasso';
export type SelectionFilter = 'all' | 'lines' | 'circles' | 'rects' | 'text' | 'chairs' | 'blocks';
export interface SelectionOptions {
mode: SelectionMode;
filter: SelectionFilter;
additive: boolean; // shift-click to add to selection
subtractive: boolean; // ctrl-click to remove from selection
tolerance: number; // world units for hit testing
}
export class SelectionEngine {
private renderEngine: RenderEngine;
private spatialIndex: SpatialIndex;
private layerManager: LayerManager;
private selectedIds: Set<string> = new Set();
private hoverId: string | null = null;
private options: SelectionOptions;
private boxStart: { x: number; y: number } | null = null;
private boxEnd: { x: number; y: number } | null = null;
private listeners: Array<(selected: CADElement[]) => void> = [];
constructor(
renderEngine: RenderEngine,
spatialIndex: SpatialIndex,
layerManager: LayerManager,
) {
this.renderEngine = renderEngine;
this.spatialIndex = spatialIndex;
this.layerManager = layerManager;
this.options = {
mode: 'single',
filter: 'all',
additive: false,
subtractive: false,
tolerance: 5,
};
}
setOptions(opts: Partial<SelectionOptions>): void {
this.options = { ...this.options, ...opts };
}
getOptions(): SelectionOptions {
return { ...this.options };
}
getSelectedIds(): Set<string> {
return new Set(this.selectedIds);
}
getSelectedElements(allElements: CADElement[]): CADElement[] {
return allElements.filter(e => this.selectedIds.has(e.id));
}
getHoverId(): string | null {
return this.hoverId;
}
setHover(id: string | null): void {
this.hoverId = id;
this.updateRenderSelection();
}
/**
* Click selection at world coordinates.
* Returns the selected element or null.
*/
clickSelect(worldX: number, worldY: number, allElements: CADElement[]): CADElement | null {
const hit = this.renderEngine.hitTest(worldX, worldY, this.options.tolerance);
if (!hit) {
if (!this.options.additive && !this.options.subtractive) {
this.clearSelection();
}
return null;
}
if (!this.matchesFilter(hit)) {
if (!this.options.additive && !this.options.subtractive) {
this.clearSelection();
}
return null;
}
if (this.options.subtractive) {
this.selectedIds.delete(hit.id);
} else if (this.options.additive) {
this.selectedIds.add(hit.id);
} else {
this.clearSelection();
this.selectedIds.add(hit.id);
}
this.updateRenderSelection();
this.notifyListeners(allElements);
return hit;
}
/**
* Start box selection at world coordinates.
*/
startBoxSelect(worldX: number, worldY: number): void {
this.boxStart = { x: worldX, y: worldY };
this.boxEnd = { x: worldX, y: worldY };
this.updateRenderSelection();
}
/**
* Update box selection end point during drag.
*/
updateBoxSelect(worldX: number, worldY: number, allElements: CADElement[]): void {
if (!this.boxStart) return;
this.boxEnd = { x: worldX, y: worldY };
this.updateRenderSelection();
}
/**
* Finish box selection and select all elements within the box.
* Left-to-right drag = window selection (fully enclosed elements).
* Right-to-left drag = crossing selection (intersecting elements).
*/
finishBoxSelect(allElements: CADElement[]): CADElement[] {
if (!this.boxStart || !this.boxEnd) {
this.boxStart = null;
this.boxEnd = null;
return [];
}
const minX = Math.min(this.boxStart.x, this.boxEnd.x);
const minY = Math.min(this.boxStart.y, this.boxEnd.y);
const maxX = Math.max(this.boxStart.x, this.boxEnd.x);
const maxY = Math.max(this.boxStart.y, this.boxEnd.y);
// Determine window vs crossing: if start X < end X, it's window (left-to-right)
const isWindow = this.boxStart.x <= this.boxEnd.x;
let elements: CADElement[];
if (isWindow) {
// Window selection: only fully enclosed elements
elements = this.renderEngine.getElementsInRect(minX, minY, maxX, maxY);
} else {
// Crossing selection: all intersecting elements
elements = this.renderEngine.getElementsIntersectingRect(minX, minY, maxX, maxY);
}
const filtered = elements.filter(e => this.matchesFilter(e));
if (this.options.subtractive) {
for (const el of filtered) this.selectedIds.delete(el.id);
} else if (this.options.additive) {
for (const el of filtered) this.selectedIds.add(el.id);
} else {
this.clearSelection();
for (const el of filtered) this.selectedIds.add(el.id);
}
this.boxStart = null;
this.boxEnd = null;
this.updateRenderSelection();
this.notifyListeners(allElements);
return filtered;
}
/**
* Quick select elements by property value.
* Supports filtering by type, layerId, color (stroke/fill), or any property.
*/
quickSelect(allElements: CADElement[], criteria: {
type?: string;
layerId?: string;
color?: string;
property?: string;
value?: unknown;
}, additive: boolean = false): CADElement[] {
if (!additive) this.clearSelection();
const matched = allElements.filter(el => {
if (criteria.type && el.type !== criteria.type) return false;
if (criteria.layerId && el.layerId !== criteria.layerId) return false;
if (criteria.color) {
const elColor = el.properties.stroke ?? el.properties.fill;
if (elColor !== criteria.color) return false;
}
if (criteria.property && criteria.value !== undefined) {
if ((el.properties as Record<string, unknown>)[criteria.property] !== criteria.value) return false;
}
return this.matchesFilter(el);
});
for (const el of matched) this.selectedIds.add(el.id);
this.updateRenderSelection();
this.notifyListeners(allElements);
return matched;
}
/**
* Cancel any in-progress box selection.
*/
cancelBoxSelect(): void {
this.boxStart = null;
this.boxEnd = null;
this.updateRenderSelection();
}
/**
* Select all elements matching the current filter.
*/
selectAll(allElements: CADElement[]): void {
const visibleLayerIds = new Set(this.layerManager.getVisibleLayers().map(l => l.id));
this.selectedIds.clear();
for (const el of allElements) {
if (!visibleLayerIds.has(el.layerId)) continue;
if (this.matchesFilter(el)) this.selectedIds.add(el.id);
}
this.updateRenderSelection();
this.notifyListeners(allElements);
}
/**
* Invert current selection.
*/
invertSelection(allElements: CADElement[]): void {
const visibleLayerIds = new Set(this.layerManager.getVisibleLayers().map(l => l.id));
const newSelection = new Set<string>();
for (const el of allElements) {
if (!visibleLayerIds.has(el.layerId)) continue;
if (!this.matchesFilter(el)) continue;
if (!this.selectedIds.has(el.id)) newSelection.add(el.id);
}
this.selectedIds = newSelection;
this.updateRenderSelection();
this.notifyListeners(allElements);
}
/**
* Clear all selection.
*/
clearSelection(): void {
this.selectedIds.clear();
this.updateRenderSelection();
}
/**
* Select specific elements by ID.
*/
selectByIds(ids: string[], additive: boolean = false): void {
if (!additive) this.selectedIds.clear();
for (const id of ids) this.selectedIds.add(id);
this.updateRenderSelection();
}
/**
* Add a listener that gets called when selection changes.
*/
addListener(fn: (selected: CADElement[]) => void): void {
this.listeners.push(fn);
}
removeListener(fn: (selected: CADElement[]) => void): void {
this.listeners = this.listeners.filter(f => f !== fn);
}
private matchesFilter(el: CADElement): boolean {
switch (this.options.filter) {
case 'all': return true;
case 'lines': return el.type === 'line';
case 'circles': return el.type === 'circle' || el.type === 'arc';
case 'rects': return el.type === 'rect';
case 'text': return el.type === 'text';
case 'chairs': return el.type === 'chair';
case 'blocks': return el.type === 'block_instance';
default: return true;
}
}
private updateRenderSelection(): void {
this.renderEngine.setSelection({
selectedIds: this.selectedIds,
hoverId: this.hoverId,
boxStart: this.boxStart,
boxEnd: this.boxEnd,
});
}
private notifyListeners(allElements: CADElement[]): void {
const selected = allElements.filter(e => this.selectedIds.has(e.id));
for (const fn of this.listeners) fn(selected);
}
isBoxSelecting(): boolean {
return this.boxStart !== null;
}
/**
* Returns true when the box selection drag actually moved away from the start point.
* Used to distinguish a click (no drag) from a drag-based box selection.
*/
hasBoxMoved(): boolean {
if (!this.boxStart || !this.boxEnd) return false;
return this.boxStart.x !== this.boxEnd.x || this.boxStart.y !== this.boxEnd.y;
}
}
-402
View File
@@ -1,402 +0,0 @@
import type { CADElement } from '../types/cad.types';
import type { SnapPoint } from './RenderEngine';
export type SnapMode =
| 'endpoint' | 'midpoint' | 'center' | 'intersection'
| 'nearest' | 'perpendicular' | 'tangent' | 'quadrant'
| 'grid' | 'none';
export interface SnapConfig {
enabled: boolean;
modes: Set<SnapMode>;
tolerance: number; // world units
gridSpacing: number;
polarEnabled: boolean;
polarAngles: number[]; // angles in degrees for polar tracking
polarTolerance: number; // angular tolerance in degrees
}
export interface SnapResult {
point: SnapPoint | null;
preview: SnapPoint[]; // nearby candidates for visual feedback
}
export class SnapEngine {
private config: SnapConfig;
private elements: CADElement[] = [];
constructor(config?: Partial<SnapConfig>) {
this.config = {
enabled: true,
modes: new Set<SnapMode>(['endpoint', 'midpoint', 'center', 'intersection', 'nearest']),
tolerance: 10,
gridSpacing: 20,
polarEnabled: false,
polarAngles: [0, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330],
polarTolerance: 5,
...config,
};
}
setElements(elements: CADElement[]): void {
this.elements = elements;
}
setConfig(config: Partial<SnapConfig>): void {
this.config = { ...this.config, ...config };
}
getConfig(): SnapConfig {
return { ...this.config, modes: new Set(this.config.modes) };
}
toggleMode(mode: SnapMode): void {
if (this.config.modes.has(mode)) {
this.config.modes.delete(mode);
} else {
this.config.modes.add(mode);
}
}
/**
* Find the best snap point near the given world coordinates.
* Returns null if no snap point is within tolerance.
* If refPoint is provided and polar tracking is enabled, snaps to polar angles.
*/
snap(worldX: number, worldY: number, refPoint?: { x: number; y: number }): SnapResult {
if (!this.config.enabled || this.config.modes.size === 0) {
return { point: null, preview: [] };
}
// Polar tracking: if we have a reference point, check polar angles first
if (this.config.polarEnabled && refPoint) {
const polarResult = this.polarSnap(worldX, worldY, refPoint);
if (polarResult) {
return { point: polarResult, preview: [polarResult] };
}
}
const candidates: SnapPoint[] = [];
const tol = this.config.tolerance;
// Grid snap (lowest priority)
if (this.config.modes.has('grid')) {
const gs = this.config.gridSpacing;
const gx = Math.round(worldX / gs) * gs;
const gy = Math.round(worldY / gs) * gs;
const dist = Math.sqrt((worldX - gx) ** 2 + (worldY - gy) ** 2);
if (dist < tol) {
candidates.push({ x: gx, y: gy, type: 'grid' });
}
}
// Element-based snaps
for (const el of this.elements) {
if (this.config.modes.has('endpoint')) {
this.collectEndpoints(el, worldX, worldY, tol, candidates);
}
if (this.config.modes.has('midpoint')) {
this.collectMidpoints(el, worldX, worldY, tol, candidates);
}
if (this.config.modes.has('center')) {
this.collectCenters(el, worldX, worldY, tol, candidates);
}
if (this.config.modes.has('nearest')) {
this.collectNearest(el, worldX, worldY, tol, candidates);
}
}
// Intersection snap (between pairs)
if (this.config.modes.has('intersection')) {
this.collectIntersections(worldX, worldY, tol, candidates);
}
if (candidates.length === 0) {
return { point: null, preview: [] };
}
// Sort by distance, pick closest
candidates.sort((a, b) => {
const da = (a.x - worldX) ** 2 + (a.y - worldY) ** 2;
const db = (b.x - worldX) ** 2 + (b.y - worldY) ** 2;
return da - db;
});
// Priority: endpoint > intersection > center > midpoint > nearest > grid
const priority: Record<string, number> = {
endpoint: 0, intersection: 1, center: 2, midpoint: 3, nearest: 4, grid: 5,
};
// Find best within tolerance — prefer higher priority if distances are close
const best = candidates[0];
const closeOnes = candidates.filter(c => {
const d = Math.sqrt((c.x - worldX) ** 2 + (c.y - worldY) ** 2);
return d < tol * 1.5;
});
closeOnes.sort((a, b) => {
const pa = priority[a.type] ?? 99;
const pb = priority[b.type] ?? 99;
if (pa !== pb) return pa - pb;
const da = (a.x - worldX) ** 2 + (a.y - worldY) ** 2;
const db = (b.x - worldX) ** 2 + (b.y - worldY) ** 2;
return da - db;
});
return {
point: closeOnes[0] || best,
preview: candidates.slice(0, 10),
};
}
private collectEndpoints(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const p = el.properties;
const check = (x: number, y: number) => {
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
if (d < tol) out.push({ x, y, type: 'endpoint' });
};
switch (el.type) {
case 'line':
check(p.x1 ?? el.x, p.y1 ?? el.y);
check(p.x2 ?? el.x + el.width, p.y2 ?? el.y + el.height);
break;
case 'polyline':
case 'polygon': {
const pts = p.points || [];
for (const pt of pts) check(pt.x, pt.y);
break;
}
case 'rect':
check(el.x - el.width / 2, el.y - el.height / 2);
check(el.x + el.width / 2, el.y - el.height / 2);
check(el.x - el.width / 2, el.y + el.height / 2);
check(el.x + el.width / 2, el.y + el.height / 2);
break;
case 'arc': {
const r = p.radius || el.width / 2;
const sa = (p.startAngle || 0) * Math.PI / 180;
const ea = (p.endAngle || 360) * Math.PI / 180;
check(el.x + r * Math.cos(sa), el.y + r * Math.sin(sa));
check(el.x + r * Math.cos(ea), el.y + r * Math.sin(ea));
break;
}
}
}
private collectMidpoints(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const p = el.properties;
const check = (x: number, y: number) => {
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
if (d < tol) out.push({ x, y, type: 'midpoint' });
};
switch (el.type) {
case 'line': {
const x1 = p.x1 ?? el.x, y1 = p.y1 ?? el.y;
const x2 = p.x2 ?? el.x + el.width, y2 = p.y2 ?? el.y + el.height;
check((x1 + x2) / 2, (y1 + y2) / 2);
break;
}
case 'polyline':
case 'polygon': {
const pts = p.points || [];
for (let i = 0; i < pts.length - 1; i++) {
check((pts[i].x + pts[i + 1].x) / 2, (pts[i].y + pts[i + 1].y) / 2);
}
if (el.type === 'polygon' && pts.length > 2) {
check((pts[pts.length - 1].x + pts[0].x) / 2, (pts[pts.length - 1].y + pts[0].y) / 2);
}
break;
}
case 'rect':
check(el.x, el.y - el.height / 2);
check(el.x, el.y + el.height / 2);
check(el.x - el.width / 2, el.y);
check(el.x + el.width / 2, el.y);
break;
}
}
private collectCenters(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const check = (x: number, y: number) => {
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
if (d < tol) out.push({ x, y, type: 'center' });
};
switch (el.type) {
case 'circle':
case 'arc':
check(el.x, el.y);
break;
case 'rect':
check(el.x, el.y);
break;
}
}
private collectNearest(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const p = el.properties;
const check = (x: number, y: number) => {
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
if (d < tol) out.push({ x, y, type: 'nearest' });
};
switch (el.type) {
case 'line': {
const x1 = p.x1 ?? el.x, y1 = p.y1 ?? el.y;
const x2 = p.x2 ?? el.x + el.width, y2 = p.y2 ?? el.y + el.height;
const np = this.nearestOnSegment(wx, wy, x1, y1, x2, y2);
check(np.x, np.y);
break;
}
case 'circle': {
const r = p.radius || el.width / 2;
const d = Math.sqrt((wx - el.x) ** 2 + (wy - el.y) ** 2);
if (d > 0) {
check(el.x + r * (wx - el.x) / d, el.y + r * (wy - el.y) / d);
}
break;
}
case 'polyline':
case 'polygon': {
const pts = p.points || [];
for (let i = 0; i < pts.length - 1; i++) {
const np = this.nearestOnSegment(wx, wy, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
check(np.x, np.y);
}
if (el.type === 'polygon' && pts.length > 2) {
const np = this.nearestOnSegment(wx, wy, pts[pts.length - 1].x, pts[pts.length - 1].y, pts[0].x, pts[0].y);
check(np.x, np.y);
}
break;
}
}
}
private collectIntersections(wx: number, wy: number, tol: number, out: SnapPoint[]): void {
// Check pairs of elements near the cursor
const nearby = this.elements.filter(el => {
const halfW = el.width / 2 + tol;
const halfH = el.height / 2 + tol;
return Math.abs(wx - el.x) < halfW && Math.abs(wy - el.y) < halfH;
});
for (let i = 0; i < nearby.length; i++) {
for (let j = i + 1; j < nearby.length; j++) {
const pts = this.findIntersection(nearby[i], nearby[j]);
for (const pt of pts) {
const d = Math.sqrt((wx - pt.x) ** 2 + (wy - pt.y) ** 2);
if (d < tol) out.push({ x: pt.x, y: pt.y, type: 'intersection' });
}
}
}
}
private findIntersection(a: CADElement, b: CADElement): Array<{ x: number; y: number }> {
// Get line segments from both elements
const segsA = this.getElementSegments(a);
const segsB = this.getElementSegments(b);
const results: Array<{ x: number; y: number }> = [];
for (const sa of segsA) {
for (const sb of segsB) {
const pt = this.segmentIntersection(sa.x1, sa.y1, sa.x2, sa.y2, sb.x1, sb.y1, sb.x2, sb.y2);
if (pt) results.push(pt);
}
}
return results;
}
private getElementSegments(el: CADElement): Array<{ x1: number; y1: number; x2: number; y2: number }> {
const p = el.properties;
switch (el.type) {
case 'line':
return [{
x1: p.x1 ?? el.x, y1: p.y1 ?? el.y,
x2: p.x2 ?? el.x + el.width, y2: p.y2 ?? el.y + el.height,
}];
case 'rect': {
const hw = el.width / 2, hh = el.height / 2;
return [
{ x1: el.x - hw, y1: el.y - hh, x2: el.x + hw, y2: el.y - hh },
{ x1: el.x + hw, y1: el.y - hh, x2: el.x + hw, y2: el.y + hh },
{ x1: el.x + hw, y1: el.y + hh, x2: el.x - hw, y2: el.y + hh },
{ x1: el.x - hw, y1: el.y + hh, x2: el.x - hw, y2: el.y - hh },
];
}
case 'polyline':
case 'polygon': {
const pts = p.points || [];
const segs: Array<{ x1: number; y1: number; x2: number; y2: number }> = [];
for (let i = 0; i < pts.length - 1; i++) {
segs.push({ x1: pts[i].x, y1: pts[i].y, x2: pts[i + 1].x, y2: pts[i + 1].y });
}
if (el.type === 'polygon' && pts.length > 2) {
segs.push({ x1: pts[pts.length - 1].x, y1: pts[pts.length - 1].y, x2: pts[0].x, y2: pts[0].y });
}
return segs;
}
default:
return [];
}
}
private segmentIntersection(
x1: number, y1: number, x2: number, y2: number,
x3: number, y3: number, x4: number, y4: number,
): { x: number; y: number } | null {
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
if (Math.abs(denom) < 1e-10) return null;
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
return { x: x1 + t * (x2 - x1), y: y1 + t * (y2 - y1) };
}
return null;
}
private nearestOnSegment(px: number, py: number, x1: number, y1: number, x2: number, y2: number): { x: number; y: number } {
const dx = x2 - x1;
const dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return { x: x1, y: y1 };
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return { x: x1 + t * dx, y: y1 + t * dy };
}
/**
* Polar tracking: snap cursor to the nearest polar angle from a reference point.
* Returns a SnapPoint if the cursor is close to a polar angle, or null.
*/
private polarSnap(worldX: number, worldY: number, refPoint: { x: number; y: number }): SnapPoint | null {
const dx = worldX - refPoint.x;
const dy = worldY - refPoint.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) return null; // too close to reference point
const cursorAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
const normalizedCursor = ((cursorAngle % 360) + 360) % 360;
// Find closest polar angle
let bestAngle: number | null = null;
let bestDiff = Infinity;
for (const angle of this.config.polarAngles) {
let diff = Math.abs(normalizedCursor - angle);
if (diff > 180) diff = 360 - diff;
if (diff < bestDiff) {
bestDiff = diff;
bestAngle = angle;
}
}
if (bestAngle === null || bestDiff > this.config.polarTolerance) return null;
// Project cursor position onto the polar angle line at the same distance
const rad = (bestAngle * Math.PI) / 180;
const snapX = refPoint.x + dist * Math.cos(rad);
const snapY = refPoint.y + dist * Math.sin(rad);
return { x: snapX, y: snapY, type: 'nearest' };
}
}
-48
View File
@@ -1,48 +0,0 @@
import RBush from 'rbush';
import type { BoundingBox, CADElement } from '../types/cad.types';
interface IndexedItem extends BoundingBox {
element: CADElement;
}
export class SpatialIndex {
private tree: RBush<IndexedItem>;
constructor() {
this.tree = new RBush<IndexedItem>();
}
insert(element: CADElement): void {
const bbox = this.elementBBox(element);
this.tree.insert({ ...bbox, element });
}
bulkInsert(elements: CADElement[]): void {
const items = elements.map(el => ({ ...this.elementBBox(el), element: el }));
this.tree.load(items);
}
search(viewport: BoundingBox): CADElement[] {
return this.tree.search(viewport).map(item => item.element);
}
remove(element: CADElement): void {
const bbox = this.elementBBox(element);
this.tree.remove({ ...bbox, element }, (a, b) => a.element.id === b.element.id);
}
clear(): void {
this.tree.clear();
}
private elementBBox(el: CADElement): BoundingBox {
const halfW = el.width / 2;
const halfH = el.height / 2;
return {
minX: el.x - halfW,
minY: el.y - halfH,
maxX: el.x + halfW,
maxY: el.y + halfH,
};
}
}
-99
View File
@@ -1,99 +0,0 @@
import type { Transform, Viewport } from '../types/cad.types';
export class ZoomPanController {
private scale = 1;
private offsetX = 0;
private offsetY = 0;
private canvas: HTMLCanvasElement;
private isPanning = false;
private lastX = 0;
private lastY = 0;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
}
getTransform(): Transform {
return {
a: this.scale, b: 0, c: 0,
d: this.scale, e: this.offsetX, f: this.offsetY,
};
}
getViewport(): Viewport {
const w = this.canvas.width / this.scale;
const h = this.canvas.height / this.scale;
const minX = -this.offsetX / this.scale || 0;
const minY = -this.offsetY / this.scale || 0;
return {
minX,
minY,
maxX: minX + w,
maxY: minY + h,
};
}
getScale(): number { return this.scale; }
zoomAt(cx: number, cy: number, factor: number): void {
const newScale = Math.max(0.01, Math.min(100, this.scale * factor));
const ratio = newScale / this.scale;
this.offsetX = cx - (cx - this.offsetX) * ratio;
this.offsetY = cy - (cy - this.offsetY) * ratio;
this.scale = newScale;
}
zoomFit(elements: Array<{x:number;y:number;width:number;height:number}>): void {
if (elements.length === 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of elements) {
minX = Math.min(minX, el.x - el.width/2);
minY = Math.min(minY, el.y - el.height/2);
maxX = Math.max(maxX, el.x + el.width/2);
maxY = Math.max(maxY, el.y + el.height/2);
}
const padding = 40;
const scaleX = (this.canvas.width - padding*2) / (maxX - minX);
const scaleY = (this.canvas.height - padding*2) / (maxY - minY);
this.scale = Math.min(scaleX, scaleY);
this.offsetX = -minX * this.scale + padding;
this.offsetY = -minY * this.scale + padding;
}
zoomToRect(rect: { minX: number; minY: number; maxX: number; maxY: number }): void {
const padding = 20;
const w = rect.maxX - rect.minX;
const h = rect.maxY - rect.minY;
if (w <= 0 || h <= 0) return;
const scaleX = (this.canvas.width - padding * 2) / w;
const scaleY = (this.canvas.height - padding * 2) / h;
this.scale = Math.max(0.01, Math.min(100, Math.min(scaleX, scaleY)));
this.offsetX = -rect.minX * this.scale + padding;
this.offsetY = -rect.minY * this.scale + padding;
}
pan(dx: number, dy: number): void {
this.offsetX += dx;
this.offsetY += dy;
}
screenToWorld(sx: number, sy: number): { x: number; y: number } {
const rect = this.canvas.getBoundingClientRect();
const x = (sx - rect.left - this.offsetX) / this.scale;
const y = (sy - rect.top - this.offsetY) / this.scale;
return { x, y };
}
worldToScreen(wx: number, wy: number): { x: number; y: number } {
return {
x: wx * this.scale + this.offsetX,
y: wy * this.scale + this.offsetY,
};
}
reset(): void {
this.scale = 1;
this.offsetX = 0;
this.offsetY = 0;
}
}
@@ -1,267 +0,0 @@
import React, { useState, useRef, useCallback } from 'react';
import { BackgroundService, DEFAULT_BACKGROUND, type BackgroundConfig } from '../services/backgroundService';
interface BackgroundImportProps {
open: boolean;
onClose: () => void;
onApply: (config: BackgroundConfig, image: HTMLImageElement | null) => void;
backgroundService: BackgroundService;
}
const BackgroundImport: React.FC<BackgroundImportProps> = ({ open, onClose, onApply, backgroundService }) => {
const [config, setConfig] = useState<BackgroundConfig>({ ...DEFAULT_BACKGROUND });
const [previewUrl, setPreviewUrl] = useState<string>('');
const [calibrationMode, setCalibrationMode] = useState(false);
const [calibPoint1, setCalibPoint1] = useState<{ x: number; y: number } | null>(null);
const [calibPoint2, setCalibPoint2] = useState<{ x: number; y: number } | null>(null);
const [realDistance, setRealDistance] = useState('');
const [unit, setUnit] = useState('m');
const [error, setError] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setError('');
try {
const cfg = await backgroundService.loadFromFile(file);
setConfig(cfg);
setPreviewUrl(cfg.src);
} catch (err) {
setError('Fehler beim Laden des Bildes: ' + (err as Error).message);
}
}, [backgroundService]);
const handlePreviewClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!calibrationMode || !previewCanvasRef.current) return;
const rect = previewCanvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (!calibPoint1) {
setCalibPoint1({ x, y });
} else if (!calibPoint2) {
setCalibPoint2({ x, y });
}
}, [calibrationMode, calibPoint1, calibPoint2]);
const handleCalibrate = useCallback(() => {
if (!calibPoint1 || !calibPoint2 || !realDistance) return;
const dx = calibPoint2.x - calibPoint1.x;
const dy = calibPoint2.y - calibPoint1.y;
const pixelDist = Math.sqrt(dx * dx + dy * dy);
const realDist = parseFloat(realDistance);
if (realDist <= 0) {
setError('Bitte eine gültige Referenzstrecke eingeben.');
return;
}
const result = backgroundService.calibrateScale(pixelDist, realDist, unit);
setConfig(prev => ({ ...prev, scale: result.scale }));
setCalibrationMode(false);
setCalibPoint1(null);
setCalibPoint2(null);
setRealDistance('');
}, [calibPoint1, calibPoint2, realDistance, unit, backgroundService]);
const handleApply = useCallback(() => {
const cfg = backgroundService.getConfig();
onApply(cfg, backgroundService.getImage());
onClose();
}, [backgroundService, onApply, onClose]);
const updateConfig = useCallback((partial: Partial<BackgroundConfig>) => {
backgroundService.updateConfig(partial);
setConfig(backgroundService.getConfig());
}, [backgroundService]);
if (!open) return null;
return (
<div className="modal-overlay" style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 1000,
}}>
<div className="modal-dialog" style={{
background: 'var(--color-bg-primary, #1e1e2e)', borderRadius: '8px',
padding: '24px', maxWidth: '600px', width: '90%', maxHeight: '90vh',
overflow: 'auto', color: 'var(--color-text, #e0e0e0)',
border: '1px solid var(--color-border, #333)',
}}>
<h2 style={{ marginTop: 0, marginBottom: '16px' }}>Hintergrund importieren</h2>
{error && (
<div style={{ color: '#ff6b6b', marginBottom: '12px', fontSize: '14px' }}>{error}</div>
)}
{/* File Upload */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px' }}>Bilddatei (PNG, JPG, SVG)</label>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,application/pdf"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<button
onClick={() => fileInputRef.current?.click()}
style={{
padding: '8px 16px', borderRadius: '4px',
border: '1px solid var(--color-border, #444)',
background: 'var(--color-bg-secondary, #2a2a3a)',
color: 'var(--color-text, #e0e0e0)', cursor: 'pointer',
}}
>
Datei wählen
</button>
{config.name && (
<span style={{ marginLeft: '12px', fontSize: '13px' }}>{config.name} ({config.width}×{config.height}px)</span>
)}
</div>
{/* Preview */}
{previewUrl && (
<div style={{ marginBottom: '16px' }}>
<canvas
ref={previewCanvasRef}
onClick={handlePreviewClick}
style={{
maxWidth: '100%', maxHeight: '200px',
border: '1px solid var(--color-border, #333)',
cursor: calibrationMode ? 'crosshair' : 'default',
display: 'block',
}}
width={config.width || 400}
height={config.height || 300}
/>
</div>
)}
{/* Calibration */}
{previewUrl && (
<div style={{ marginBottom: '16px', padding: '12px', border: '1px solid var(--color-border, #333)', borderRadius: '4px' }}>
<div style={{ fontSize: '14px', marginBottom: '8px', fontWeight: 600 }}>Maßstabs-Kalibrierung</div>
{!calibrationMode ? (
<button
onClick={() => setCalibrationMode(true)}
style={{ padding: '6px 12px', fontSize: '13px', cursor: 'pointer',
border: '1px solid var(--color-border, #444)', borderRadius: '4px',
background: 'var(--color-bg-secondary, #2a2a3a)', color: 'var(--color-text, #e0e0e0)' }}
>
Kalibrierung starten
</button>
) : (
<div>
<p style={{ fontSize: '13px', margin: '0 0 8px' }}>
Klicken Sie auf zwei Punkte mit bekanntem Abstand im Bild.
</p>
{calibPoint1 && !calibPoint2 && <p style={{ fontSize: '13px', color: '#8be9fd' }}>Erster Punkt gesetzt. Bitte zweiten Punkt klicken.</p>}
{calibPoint1 && calibPoint2 && (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="number"
placeholder="Referenzstrecke"
value={realDistance}
onChange={(e) => setRealDistance(e.target.value)}
style={{ width: '120px', padding: '4px 8px', fontSize: '13px' }}
/>
<select value={unit} onChange={(e) => setUnit(e.target.value)} style={{ padding: '4px 8px', fontSize: '13px' }}>
<option value="m">m</option>
<option value="cm">cm</option>
<option value="mm">mm</option>
</select>
<button onClick={handleCalibrate} style={{ padding: '6px 12px', fontSize: '13px', cursor: 'pointer' }}>Übernehmen</button>
<button onClick={() => { setCalibPoint1(null); setCalibPoint2(null); }} style={{ padding: '6px 12px', fontSize: '13px', cursor: 'pointer' }}>Zurücksetzen</button>
</div>
)}
</div>
)}
{config.scale !== 1 && (
<p style={{ fontSize: '12px', marginTop: '8px', color: '#50fa7b' }}>
Aktueller Maßstab: {config.scale.toFixed(2)} px/mm
</p>
)}
</div>
)}
{/* Controls */}
{previewUrl && (
<div style={{ marginBottom: '16px' }}>
{/* Opacity */}
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Deckkraft: {Math.round(config.opacity * 100)}%</label>
<input
type="range" min="0" max="1" step="0.05"
value={config.opacity}
onChange={(e) => updateConfig({ opacity: parseFloat(e.target.value) })}
style={{ width: '100%', marginBottom: '12px' }}
/>
{/* Position */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '8px' }}>
<label style={{ fontSize: '13px' }}>X-Offset:
<input type="number" value={config.offsetX}
onChange={(e) => updateConfig({ offsetX: parseFloat(e.target.value) || 0 })}
style={{ width: '80px', marginLeft: '6px', padding: '4px' }} />
</label>
<label style={{ fontSize: '13px' }}>Y-Offset:
<input type="number" value={config.offsetY}
onChange={(e) => updateConfig({ offsetY: parseFloat(e.target.value) || 0 })}
style={{ width: '80px', marginLeft: '6px', padding: '4px' }} />
</label>
</div>
{/* Rotation */}
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Rotation: {config.rotation}°</label>
<input
type="range" min="-180" max="180" step="1"
value={config.rotation}
onChange={(e) => updateConfig({ rotation: parseFloat(e.target.value) })}
style={{ width: '100%', marginBottom: '12px' }}
/>
{/* Scale */}
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Skalierung: {config.scale.toFixed(3)}</label>
<input
type="number" step="0.01" value={config.scale}
onChange={(e) => updateConfig({ scale: parseFloat(e.target.value) || 1 })}
style={{ width: '120px', padding: '4px' }}
/>
{/* Visibility */}
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '12px', fontSize: '13px' }}>
<input
type="checkbox" checked={config.visible}
onChange={(e) => updateConfig({ visible: e.target.checked })}
/>
Sichtbar
</label>
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={onClose}
style={{ padding: '8px 16px', cursor: 'pointer', borderRadius: '4px',
border: '1px solid var(--color-border, #444)', background: 'transparent',
color: 'var(--color-text, #e0e0e0)' }}
>
Abbrechen
</button>
<button
onClick={handleApply}
disabled={!previewUrl}
style={{ padding: '8px 16px', cursor: previewUrl ? 'pointer' : 'not-allowed', borderRadius: '4px',
border: 'none', background: previewUrl ? '#50fa7b' : '#555',
color: previewUrl ? '#1e1e2e' : '#999', fontWeight: 600 }}
>
Anwenden
</button>
</div>
</div>
</div>
);
};
export default BackgroundImport;
+104
View File
@@ -0,0 +1,104 @@
import React, { useState, useEffect } from 'react';
import api from '../services/api';
const BlockLibrary = ({ canvasRef }) => {
const [blocks, setBlocks] = useState([]);
const [categories, setCategories] = useState([]);
const [activeCategory, setActiveCategory] = useState('Alle');
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/blocks')
.then(res => {
const data = res.data || [];
setBlocks(data);
const cats = ['Alle', ...new Set(data.map(b => b.category || 'Allgemein'))];
setCategories(cats);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const filteredBlocks = activeCategory === 'Alle'
? blocks
: blocks.filter(b => (b.category || 'Allgemein') === activeCategory);
const handleDragStart = (e, block) => {
e.dataTransfer.setData('text/plain', block.svg_data);
};
const handleDrop = (e) => {
e.preventDefault();
const svgData = e.dataTransfer.getData('text/plain');
if (svgData && canvasRef.current) {
canvasRef.current.addSvgObject(svgData);
}
};
const handleDragOver = (e) => {
e.preventDefault();
};
if (loading) return <div style={{ padding: 10 }}>Lade...</div>;
return (
<div
style={{ padding: '0 10px 10px' }}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* Category filter */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
{categories.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
style={{
padding: '2px 8px',
fontSize: 11,
background: activeCategory === cat ? '#1890ff' : '#f0f0f0',
color: activeCategory === cat ? 'white' : '#333',
border: 'none',
borderRadius: 3,
cursor: 'pointer',
}}
>
{cat}
</button>
))}
</div>
{/* Block grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
{filteredBlocks.map(block => (
<div
key={block.id}
draggable
onDragStart={(e) => handleDragStart(e, block)}
style={{
border: '1px solid #ddd',
borderRadius: 4,
padding: 4,
background: 'white',
cursor: 'grab',
textAlign: 'center',
}}
title={block.name}
>
<div
dangerouslySetInnerHTML={{ __html: block.svg_data }}
style={{ width: '100%', aspectRatio: '1', marginBottom: 4 }}
/>
<div style={{ fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{block.name}
</div>
</div>
))}
</div>
{filteredBlocks.length === 0 && (
<p style={{ fontSize: 12, color: '#999' }}>Keine Blöcke in dieser Kategorie.</p>
)}
</div>
);
};
export default BlockLibrary;
-141
View File
@@ -1,141 +0,0 @@
import React, { useState, useRef } from 'react';
import type { BlockLibraryProps } from '../types/ui.types';
import type { BlockDefinition } from '../types/cad.types';
const categories = ['Alle', 'Bestuhlung', 'Tische', 'Bühne', 'Architektur', 'Custom'];
const BlockLibrary: React.FC<BlockLibraryProps> = ({ blocks, category, onCategoryChange, onSearch, onDragBlock, onRenameBlock, onDuplicateBlock, onDeleteBlock, onSvgImport, onSaveGroupAsBlock }) => {
const [searchQuery, setSearchQuery] = useState('');
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set(['Bestuhlung']));
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const q = e.target.value;
setSearchQuery(q);
onSearch(q);
};
const filteredBlocks = blocks.filter(b => {
const catMatch = category === 'Alle' || b.category === category;
const q = searchQuery.toLowerCase().trim();
const searchMatch = !q || b.name.toLowerCase().includes(q) || b.description.toLowerCase().includes(q);
return catMatch && searchMatch;
});
// Group by category
const grouped: Record<string, BlockDefinition[]> = {};
for (const b of filteredBlocks) {
if (!grouped[b.category]) grouped[b.category] = [];
grouped[b.category].push(b);
}
const toggleCat = (cat: string) => {
setExpandedCats((prev) => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
return next;
});
};
const handleDragStart = (e: React.DragEvent, blockId: string) => {
e.dataTransfer.setData('text/block-id', blockId);
e.dataTransfer.effectAllowed = 'copy';
onDragBlock(blockId);
};
const handleSvgImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const svgContent = ev.target?.result as string;
if (onSvgImport) {
onSvgImport(svgContent, file.name.replace(/\.svg$/i, ''), 'Custom');
} else {
window.dispatchEvent(new CustomEvent('block-svg-import', { detail: { svg: svgContent, name: file.name.replace(/\.svg$/i, ''), category: 'Custom' } }));
}
};
reader.readAsText(file);
e.target.value = '';
};
return (
<>
<div className="lib-search">
<span className="lib-search-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</span>
<input type="text" placeholder="Block suchen..." aria-label="Bibliothek durchsuchen" value={searchQuery} onChange={handleSearch} />
</div>
<div className="lib-categories">
{categories.map((cat) => (
<button key={cat} className={`lib-cat${category === cat ? ' active' : ''}`} onClick={() => onCategoryChange(cat)}>{cat}</button>
))}
</div>
<div className="lib-import">
<button className="lib-import-btn" title="SVG importieren" onClick={() => fileInputRef.current?.click()}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<span>SVG importieren</span>
</button>
{onSaveGroupAsBlock && (
<button className="lib-import-btn" title="Auswahl als Block speichern" onClick={() => { const name = window.prompt('Block-Name:', ''); if (name) onSaveGroupAsBlock(name); }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
<span>Als Block speichern</span>
</button>
)}
<input ref={fileInputRef} type="file" accept=".svg,image/svg+xml" style={{ display: 'none' }} onChange={handleSvgImport} />
</div>
<div className="tree tree-library" role="tree" aria-label="Bibliothek-Struktur">
{Object.entries(grouped).map(([cat, catBlocks]) => (
<div key={cat} className="lib-tree-node">
<div className="tree-item tree-item-folder" onClick={() => toggleCat(cat)}>
<span className="tree-toggle">{expandedCats.has(cat) ? '▾' : '▸'}</span>
<span className="tree-icon">📁</span>
<span className="tree-label">{cat}</span>
<span className="tree-count">{catBlocks.length}</span>
</div>
{expandedCats.has(cat) && (
<div className="tree-children">
{catBlocks.map((block) => (
<div
key={block.id}
className="tree-item tree-item-leaf draggable"
draggable
onDragStart={(e) => handleDragStart(e, block.id)}
title={block.description}
>
<span className="tree-icon">📦</span>
<span className="tree-label">{block.name}</span>
<span className="tree-actions" onClick={(e) => e.stopPropagation()}>
{onRenameBlock && (
<button className="layer-action-btn" title="Umbenennen" onClick={() => { const name = window.prompt('Neuer Name:', block.name); if (name) onRenameBlock(block.id, name); }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
)}
{onDuplicateBlock && (
<button className="layer-action-btn" title="Duplizieren" onClick={() => onDuplicateBlock(block.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
)}
{onDeleteBlock && (
<button className="layer-action-btn" title="Löschen" onClick={() => onDeleteBlock(block.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
)}
</span>
</div>
))}
</div>
)}
</div>
))}
{filteredBlocks.length === 0 && (
<div className="lib-empty">Keine Blöcke gefunden</div>
)}
</div>
</>
);
};
export default BlockLibrary;

Some files were not shown because too many files have changed in this diff Show More