Compare commits
31 Commits
bf47739479
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d9c3f9495 | |||
| fe680de606 | |||
| e4a9446f9c | |||
| f5a81f0fe0 | |||
| 67c678121f | |||
| 6c04ca6793 | |||
| 3af9640ace | |||
| 6c4d62641a | |||
| be62470b53 | |||
| 0606dbb501 | |||
| a4371ca3ce | |||
| affa6be151 | |||
| 4303076ce4 | |||
| 39422a4348 | |||
| 7ad0e11873 | |||
| bd8bedcfed | |||
| f9f31db933 | |||
| 7e530dd09a | |||
| 3bec3938d0 | |||
| ef23463a74 | |||
| 6d15a692af | |||
| 1bcfaffdd4 | |||
| 89b91a1050 | |||
| 1d6c2cb30e | |||
| 04271d4cbd | |||
| 85c26b7826 | |||
| 3876a22dd6 | |||
| b195858b12 | |||
| f853372c90 | |||
| 19884b7b5d | |||
| 4ec76fe406 |
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.log
|
||||||
+2
-4
@@ -1,6 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
|
||||||
.git/
|
|
||||||
*.sqlite
|
|
||||||
*.db
|
*.db
|
||||||
.DS_Store
|
dist/
|
||||||
|
.env
|
||||||
|
|||||||
+704
@@ -0,0 +1,704 @@
|
|||||||
|
# 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
@@ -1,28 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
*.log
|
||||||
+7
-11
@@ -1,13 +1,9 @@
|
|||||||
FROM node:22-alpine
|
FROM node:20-alpine
|
||||||
RUN apk add --no-cache curl
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache wget
|
||||||
COPY package*.json ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci --only=production
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
EXPOSE 5000
|
EXPOSE 3001
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
CMD ["node", "server.js"]
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
const passport = require('passport');
|
|
||||||
|
|
||||||
// Protect routes with JWT
|
|
||||||
const authenticate = passport.authenticate('jwt', { session: false });
|
|
||||||
|
|
||||||
module.exports = authenticate;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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;
|
|
||||||
Generated
+2872
-1210
File diff suppressed because it is too large
Load Diff
+26
-19
@@ -1,25 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "web-cad-backend",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"private": true,
|
||||||
"main": "index.js",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"postbuild": "cp src/database/schema.sql dist/database/schema.sql",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"@fastify/cors": "^11.2.0",
|
||||||
"cors": "^2.8.6",
|
"@fastify/static": "^9.1.3",
|
||||||
"dxf-parser": "^1.1.2",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"express": "^5.2.1",
|
"bcrypt": "^6.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"better-sqlite3": "^11.0.0",
|
||||||
"multer": "^2.1.1",
|
"fastify": "^5.9.0",
|
||||||
"passport": "^0.7.0",
|
"y-leveldb": "^0.2.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"y-protocols": "^1.0.7",
|
||||||
"passport-local": "^1.0.0",
|
"yjs": "^13.6.0"
|
||||||
"sequelize": "^6.37.8",
|
},
|
||||||
"sqlite3": "^6.0.1"
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.0",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"tsx": "^4.16.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Dimension Tool",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Fügt ein Bemaßungswerkzeug für horizontale und vertikale Messungen hinzu.",
|
|
||||||
"author": "Agent Zero"
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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
@@ -1,132 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
-- 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);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Web CAD Backend – Entry Point
|
||||||
|
*/
|
||||||
|
import { createServer } from './server.js';
|
||||||
|
import { SqliteAdapter } from './database/SqliteAdapter.js';
|
||||||
|
import { initYjsPersistence, closeYjsPersistence } from './websocket/yjsServer.js';
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
const DB_PATH = process.env.DB_PATH || '/data/web-cad.db';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const db = new SqliteAdapter(DB_PATH);
|
||||||
|
await db.init();
|
||||||
|
await initYjsPersistence();
|
||||||
|
|
||||||
|
const fastify = await createServer({ port: PORT, host: HOST, db });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fastify.listen({ port: PORT, host: HOST });
|
||||||
|
console.log(`Web CAD Backend running on http://${HOST}:${PORT}`);
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
await fastify.close();
|
||||||
|
db.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
await fastify.close();
|
||||||
|
db.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* AI Routes – KI Copilot chat endpoint via OpenRouter
|
||||||
|
*/
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
|
|
||||||
|
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||||
|
const MODEL = 'anthropic/claude-sonnet-4.5';
|
||||||
|
const TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
function extractToken(request: any): string | null {
|
||||||
|
const auth = request.headers?.authorization;
|
||||||
|
if (auth && auth.startsWith('Bearer ')) {
|
||||||
|
return auth.slice(7);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAIRoutes(fastify: FastifyInstance, authService: AuthService) {
|
||||||
|
fastify.post('/api/ai/chat', async (request, reply) => {
|
||||||
|
// Auth check
|
||||||
|
const token = extractToken(request);
|
||||||
|
if (!token) {
|
||||||
|
return reply.code(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
const user = authService.getUserFromSession(token);
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messages, context } = request.body as {
|
||||||
|
messages?: Array<{ role: string; content: string }>;
|
||||||
|
context?: {
|
||||||
|
projectName?: string;
|
||||||
|
elementCount?: number;
|
||||||
|
layerCount?: number;
|
||||||
|
elementTypeSummary?: Record<string, number>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||||
|
return reply.code(400).send({ error: 'Messages array is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.API_KEY_OPENROUTER;
|
||||||
|
if (!apiKey) {
|
||||||
|
return reply.code(503).send({ error: 'AI service not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build system prompt with CAD context
|
||||||
|
const ctxParts: string[] = [
|
||||||
|
'Du bist der KI Copilot für Web CAD, eine webbasierte CAD-Anwendung für Event- und Raumplanung.',
|
||||||
|
'Du hilfst beim Anlegen von Bestuhlung, Räumen, Bühnen und anderen CAD-Elementen.',
|
||||||
|
'Antworte auf Deutsch, klar und präzise. Verwende Markdown für Formatierung wenn sinnvoll.',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
if (context.projectName) ctxParts.push(`Aktuelles Projekt: ${context.projectName}`);
|
||||||
|
if (context.elementCount !== undefined) ctxParts.push(`Elemente im Projekt: ${context.elementCount}`);
|
||||||
|
if (context.layerCount !== undefined) ctxParts.push(`Ebenen: ${context.layerCount}`);
|
||||||
|
if (context.elementTypeSummary) {
|
||||||
|
const summary = Object.entries(context.elementTypeSummary)
|
||||||
|
.map(([type, count]) => `${type}: ${count}`)
|
||||||
|
.join(', ');
|
||||||
|
ctxParts.push(`Element-Typen: ${summary}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = ctxParts.join('\n');
|
||||||
|
|
||||||
|
// Build OpenRouter request
|
||||||
|
const payload = {
|
||||||
|
model: MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
...messages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
|
],
|
||||||
|
max_tokens: 1024,
|
||||||
|
temperature: 0.7,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
|
||||||
|
const response = await fetch(OPENROUTER_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'HTTP-Referer': 'http://localhost:5173',
|
||||||
|
'X-Title': 'Web CAD KI Copilot',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
request.log.error({ errText, status: response.status }, 'OpenRouter error');
|
||||||
|
return reply.code(502).send({ error: 'AI service returned an error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as any;
|
||||||
|
const content = data.choices?.[0]?.message?.content ?? '';
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return reply.code(502).send({ error: 'AI service returned empty response' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract suggestions from response if present (simple heuristic)
|
||||||
|
const suggestions: string[] = [];
|
||||||
|
const lines = content.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/^[-*]\s*(.+)/);
|
||||||
|
if (match && suggestions.length < 3) {
|
||||||
|
suggestions.push(match[1].trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({ content, suggestions: suggestions.length > 0 ? suggestions : undefined });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
return reply.code(504).send({ error: 'AI service timeout' });
|
||||||
|
}
|
||||||
|
request.log.error({ err }, 'AI route error');
|
||||||
|
return reply.code(500).send({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Auth Routes – Registration, Login, Logout, Profile
|
||||||
|
*/
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
|
|
||||||
|
export function registerAuthRoutes(fastify: FastifyInstance, authService: AuthService) {
|
||||||
|
// Register new user
|
||||||
|
fastify.post('/api/auth/register', async (request, reply) => {
|
||||||
|
const { email, password, name, role } = request.body as {
|
||||||
|
email?: string; password?: string; name?: string; role?: string;
|
||||||
|
};
|
||||||
|
if (!email || !password || !name) {
|
||||||
|
return reply.code(400).send({ error: 'Email, password and name are required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await authService.register(email, password, name, role);
|
||||||
|
return reply.code(201).send(result);
|
||||||
|
} catch (err: any) { return reply.code(409).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login
|
||||||
|
fastify.post('/api/auth/login', async (request, reply) => {
|
||||||
|
const { email, password } = request.body as { email?: string; password?: string };
|
||||||
|
if (!email || !password) {
|
||||||
|
return reply.code(400).send({ error: 'Email and password are required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await authService.login(email, password);
|
||||||
|
return reply.send(result);
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
fastify.post('/api/auth/logout', async (request, reply) => {
|
||||||
|
const token = extractToken(request);
|
||||||
|
if (token) {
|
||||||
|
authService.destroySession(token);
|
||||||
|
}
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user profile
|
||||||
|
fastify.get('/api/auth/me', async (request, reply) => {
|
||||||
|
const token = extractToken(request);
|
||||||
|
if (!token) return reply.code(401).send({ error: 'Not authenticated' });
|
||||||
|
const user = authService.getUserFromSession(token);
|
||||||
|
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||||
|
const { password_hash, ...safe } = user;
|
||||||
|
return safe;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change password
|
||||||
|
fastify.patch('/api/auth/password', async (request, reply) => {
|
||||||
|
const token = extractToken(request);
|
||||||
|
if (!token) return reply.code(401).send({ error: 'Not authenticated' });
|
||||||
|
const user = authService.getUserFromSession(token);
|
||||||
|
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||||
|
|
||||||
|
const { oldPassword, newPassword } = request.body as { oldPassword?: string; newPassword?: string };
|
||||||
|
if (!oldPassword || !newPassword) {
|
||||||
|
return reply.code(400).send({ error: 'oldPassword and newPassword are required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await authService.changePassword(user.id, oldPassword, newPassword);
|
||||||
|
return reply.code(204).send();
|
||||||
|
} catch (err: any) {
|
||||||
|
return reply.code(400).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractToken(request: any): string | null {
|
||||||
|
const auth = request.headers?.authorization;
|
||||||
|
if (auth && auth.startsWith('Bearer ')) {
|
||||||
|
return auth.slice(7);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 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() };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
declare module 'y-leveldb' {
|
||||||
|
import type * as Y from 'yjs';
|
||||||
|
|
||||||
|
export class LeveldbPersistence {
|
||||||
|
constructor(dbPath: string, opts?: Record<string, unknown>);
|
||||||
|
getYDoc(name: string): Promise<Y.Doc>;
|
||||||
|
storeUpdate(name: string, update: Uint8Array): Promise<number>;
|
||||||
|
destroy(): Promise<void>;
|
||||||
|
clearDocument(name: string): Promise<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/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!"
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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:
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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:
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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
|
|
||||||
+25
-15
@@ -1,24 +1,34 @@
|
|||||||
version: '3.8'
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
|
||||||
build: ./backend
|
|
||||||
ports:
|
|
||||||
- "5000:5000"
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=production
|
|
||||||
- JWT_SECRET=your-secret-key-change-me
|
|
||||||
volumes:
|
|
||||||
- sqlite_data:/app/data
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "8082:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- backend-data:/app/data
|
||||||
|
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
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
sqlite_data:
|
backend-data:
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# 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)`
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# 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 +0,0 @@
|
|||||||
dmVyc2lvbjogJzMuOCcKCnNlcnZpY2VzOgogIGJhY2tlbmQ6CiAgICBidWlsZDogLi9iYWNrZW5kCiAgICBwb3J0czoKICAgICAgLSAiNTAwMDo1MDAwIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIEpXVF9TRUNSRVQ9ZjJiMjI3MWU1OWJmY2MxNWMxZjE3MzI4YjUxMGRiMDg2NjIwNTJjZGRhMDJkYjk4NWM5NDhkOWY1MzJhMjgyMgogICAgdm9sdW1lczoKICAgICAgLSBzcWxpdGVfZGF0YTovYXBwL2RhdGEKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogWyJDTUQtU0hFTEwiLCAid2dldCAtcU8tIGh0dHA6Ly9sb2NhbGhvc3Q6NTAwMC9hcGkvaGVhbHRoIHx8IGV4aXQgMSJdCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQoKICBmcm9udGVuZDoKICAgIGJ1aWxkOiAuL2Zyb250ZW5kCiAgICBleHBvc2U6CiAgICAgIC0gIjgwIgogICAgZGVwZW5kc19vbjoKICAgICAgLSBiYWNrZW5kCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6IFsiQ01ELVNIRUxMIiwgIndnZXQgLXFPLSBodHRwOi8vbG9jYWxob3N0OjgwIHx8IGV4aXQgMSJdCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQoKdm9sdW1lczoKICBzcWxpdGVfZGF0YToK
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
*.log
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# 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?
|
|
||||||
+3
-13
@@ -1,20 +1,10 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
COPY package*.json ./
|
RUN npm install --legacy-peer-deps
|
||||||
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;"]
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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 } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
+8
-9
@@ -1,13 +1,12 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
<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>frontend</title>
|
<title>Web CAD</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+13
-14
@@ -1,25 +1,24 @@
|
|||||||
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;
|
||||||
|
|
||||||
# Serve static frontend
|
resolver 127.0.0.11 valid=30s;
|
||||||
location / {
|
|
||||||
try_files $uri /index.html;
|
location / { try_files $uri $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;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy API requests to backend
|
location /ws {
|
||||||
location /api/ {
|
set $backend http://backend:3001;
|
||||||
proxy_pass http://backend:5000;
|
proxy_pass $backend;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1657
-2251
File diff suppressed because it is too large
Load Diff
+24
-20
@@ -1,30 +1,34 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "web-cad-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||||
"build": "vite build",
|
"build": "tsc && vite build",
|
||||||
"lint": "eslint .",
|
"preview": "vite preview",
|
||||||
"preview": "vite preview"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.16.1",
|
"dxf-parser": "^1.1.2",
|
||||||
"fabric": "^5.5.2",
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "^19.2.6",
|
"rbush": "^4.0.1",
|
||||||
"react-dom": "^19.2.6",
|
"react": "^18.3.1",
|
||||||
"react-router-dom": "^7.15.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"y-websocket": "^2.0.0",
|
||||||
|
"yjs": "^13.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@types/react": "^19.2.14",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@types/rbush": "^4.0.0",
|
||||||
"eslint": "^10.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"@types/react-dom": "^18.3.0",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"globals": "^17.6.0",
|
"jsdom": "^29.1.1",
|
||||||
"vite": "^8.0.12"
|
"typescript": "^5.5.0",
|
||||||
|
"vite": "^8.1.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,184 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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;
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,195 @@
|
|||||||
|
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
@@ -0,0 +1,306 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
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;
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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
Reference in New Issue
Block a user