Compare commits
13 Commits
v2.0.0
..
6f18ab8735
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f18ab8735 | |||
| 9fc4691c74 | |||
| 385861f486 | |||
| aa17e212bc | |||
| 0a02e11d99 | |||
| 2c5fe4dae9 | |||
| c7899ae4f6 | |||
| c2905fdfe4 | |||
| a1d377e8e5 | |||
| bee3f758f6 | |||
| bf47739479 | |||
| d054d4c726 | |||
| cdf350c618 |
@@ -1,8 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.git
|
|
||||||
*.md
|
|
||||||
.env*
|
|
||||||
.venv
|
|
||||||
__pycache__
|
|
||||||
*.log
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Web CAD Backend Environment Variables
|
|
||||||
|
|
||||||
# JWT/Session secret - MUST be changed in production
|
|
||||||
JWT_SECRET=change-me-in-production
|
|
||||||
|
|
||||||
# OpenRouter API key for KI Copilot (optional)
|
|
||||||
API_KEY_OPENROUTER=
|
|
||||||
|
|
||||||
# Database path (SQLite)
|
|
||||||
DB_PATH=/app/data/cad.db
|
|
||||||
|
|
||||||
# Server configuration
|
|
||||||
PORT=3001
|
|
||||||
HOST=0.0.0.0
|
|
||||||
NODE_ENV=production
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend-test:
|
|
||||||
runs-on: docker
|
|
||||||
container: node:20
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install backend dependencies
|
|
||||||
working-directory: backend
|
|
||||||
run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
|
||||||
- name: Typecheck backend
|
|
||||||
working-directory: backend
|
|
||||||
run: npx tsc --noEmit
|
|
||||||
- name: Run backend tests
|
|
||||||
working-directory: backend
|
|
||||||
run: npm test
|
|
||||||
- name: Build backend
|
|
||||||
working-directory: backend
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
frontend-test:
|
|
||||||
runs-on: docker
|
|
||||||
container: node:20
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
working-directory: frontend
|
|
||||||
run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
|
||||||
- name: Typecheck frontend
|
|
||||||
working-directory: frontend
|
|
||||||
run: npx tsc --noEmit
|
|
||||||
- name: Run frontend tests
|
|
||||||
working-directory: frontend
|
|
||||||
run: npm test
|
|
||||||
- name: Build frontend
|
|
||||||
working-directory: frontend
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
e2e-test:
|
|
||||||
runs-on: docker
|
|
||||||
container: node:20
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install backend dependencies
|
|
||||||
working-directory: backend
|
|
||||||
run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
working-directory: frontend
|
|
||||||
run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
working-directory: frontend
|
|
||||||
run: npx playwright install --with-deps chromium
|
|
||||||
- name: Run Playwright E2E tests
|
|
||||||
working-directory: frontend
|
|
||||||
run: npm run test:e2e:ci
|
|
||||||
- name: Upload Playwright report
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: frontend/playwright-tests/playwright-report.json
|
|
||||||
+3
-15
@@ -1,18 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
*.db
|
|
||||||
*.db-shm
|
|
||||||
*.db-wal
|
|
||||||
dist/
|
dist/
|
||||||
.env
|
.git/
|
||||||
.env.*
|
*.sqlite
|
||||||
!.env.example
|
*.db
|
||||||
*.bak
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
coverage/
|
|
||||||
.vitest/
|
|
||||||
|
|
||||||
# Playwright
|
|
||||||
frontend/test-results/
|
|
||||||
frontend/playwright-tests/screenshots/
|
|
||||||
frontend/playwright-tests/*.json
|
|
||||||
|
|||||||
-704
@@ -1,704 +0,0 @@
|
|||||||
# Bauplan – web-cad-neu
|
|
||||||
|
|
||||||
**Komplett funktionierende 2D-CAD Software für Event-Bestuhlungspläne**
|
|
||||||
Basierend auf: Pflichtenheft v4 (633 Zeilen) + UI-Mockup Iteration 9
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technologie-Stack
|
|
||||||
|
|
||||||
| Schicht | Technologie | Lizenz |
|
|
||||||
|---------|-------------|--------|
|
|
||||||
| Frontend | React 18 + TypeScript + Vite | MIT |
|
|
||||||
| Rendering | Canvas 2D + rbush (R-Tree) | MIT |
|
|
||||||
| CRDT/Sync | Yjs + y-websocket | MIT |
|
|
||||||
| Backend | Node 20 + TypeScript + Fastify | MIT |
|
|
||||||
| Datenbank | SQLite (better-sqlite3) | Public Domain |
|
|
||||||
| DXF | dxf-parser + dxf-writer | MIT |
|
|
||||||
| PDF | pdf-lib | MIT |
|
|
||||||
| Auth | bcrypt | Apache |
|
|
||||||
| KI | OpenAI-kompatibler Endpoint (user-provided) | – |
|
|
||||||
| Deployment | Docker + Coolify | Open Source |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phasen-Übersicht
|
|
||||||
|
|
||||||
| Phase | Titel | Anforderungen | Status |
|
|
||||||
|-------|-------|---------------|--------|
|
|
||||||
| 0 | Projekt-Setup & Config | – | ✅ Fertig |
|
|
||||||
| 1 | Core Types & Canvas Engine | F-CAD-01, F-CAD-04 | ✅ Fertig |
|
|
||||||
| 2 | UI-Gerüst nach Mockup | F-UI-01 bis F-UI-08 | ✅ Fertig |
|
|
||||||
| 3 | Zeichen-Tools | F-CAD-01, F-CAD-11, F-CAD-12 | ✅ Grundimplementiert (über interaction/index.ts) |
|
|
||||||
| 4 | Bearbeitungs-Tools | F-CAD-02, F-CAD-07, F-CAD-08 | ⚠️ Teilimplementiert (GroupTool, geometry.ts) |
|
|
||||||
| 5 | Auswahl & Snap | F-CAD-04, F-CAD-07 | ✅ Fertig |
|
|
||||||
| 6 | Layer-System | F-LAY-01 bis F-LAY-06 | ✅ Fertig |
|
|
||||||
| 7 | Block-Bibliothek | F-LIB-01 bis F-LIB-07 | ✅ Fertig (globalBlocks + BlockLibraryTree) |
|
|
||||||
| 8 | Bestuhlungs-Tools | F-EVT-01 bis F-EVT-05 | ⚠️ Teilimplementiert (seatingService, eventTools Plugin) |
|
|
||||||
| 9 | Bemaßung & Text | F-CAD-03, F-CAD-11 | ✅ Grundimplementiert (dimensionService, InlineTextEditor) |
|
|
||||||
| 10 | Hintergrund & Grundriss | F-BG-01 bis F-BG-04 | ✅ Fertig (backgroundService + BackgroundImport) |
|
|
||||||
| 11 | Undo/Redo & History | F-CAD-09, F-CAD-10 | ✅ Fertig (HistoryManager + HistoryPanel) |
|
|
||||||
| 12 | Command Line | F-CAD-05, F-UI-04 | ✅ Fertig (CommandLine + commandRegistry) |
|
|
||||||
| 13 | Import/Export | F-IMP-01 bis F-IMP-04, F-EXP-01 bis F-EXP-04 | ✅ Fertig (DXF, SVG, PDF, JSON) |
|
|
||||||
| 14 | Backend & REST-API | F-INT-01, F-INT-02, F-INT-04 | ✅ Fertig (13 Routes, SQLite, Fastify) |
|
|
||||||
| 15 | Auth & Benutzerverwaltung | F-AUTH-01 bis F-AUTH-05 | ✅ Fertig (bcrypt, Sessions, 4 Rollen, Rate-Limiting) |
|
|
||||||
| 16 | Multi-User & Kollaboration | F-MU-01 bis F-MU-07 | ✅ Fertig (Yjs CRDT, WebSocket, Awareness) |
|
|
||||||
| 17 | KI Copilot | F-AI-01 bis F-AI-15 | ✅ Grundimplementiert (AI-Proxy, Chat-UI) |
|
|
||||||
| 18 | Plugin-System | F-EXT-01 bis F-EXT-08 | ✅ Grundimplementiert (PluginRegistry, Built-in Event-Tools) |
|
|
||||||
| 19 | Theme & Responsive | F-UI-05, F-UI-06, F-UI-10 | ✅ Fertig (Dark/Light Toggle, Mobile Drawers) |
|
|
||||||
| 20 | Docker & Deployment | NF-DEP-01 bis NF-DEP-05 | ✅ Fertig (Docker, Coolify, CI/CD Pipeline) |
|
|
||||||
| 21 | Tests & QA | NF-PERF-01 bis NF-PERF-05 | ✅ Grundimplementiert (613 Tests, Backend+Frontend) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## 2.0.0 (2026-08-29)
|
|
||||||
|
|
||||||
Vollständige Neuentwicklung der Kern-Architektur (Phasen A–H der Roadmap).
|
|
||||||
|
|
||||||
### Architektur
|
|
||||||
- **Plugin-System v2 (A1–A4):** ToolExtensionV2/PanelExtension/LibraryProviderExtension-Registry mit Interaktions-Dispatcher, CADDocument-Fassade über Yjs mit echtem Y.UndoManager (1 Aktion = 1 Undo-Schritt), Undo-fähige transact-Bündelung, V2HistoryPanel. Alle 30+ Werkzeuge als V2-Plugins migriert.
|
|
||||||
- **PixiJS-Renderer (C1–C4):** WebGL-Rendering als Default (Opt-out), inkrementelles Element-Rendering über Map-Diff, Snap-Marks + Selection-Handles im Overlay, MockAdapter für headless Tests.
|
|
||||||
- **Kernel:** React-freies `kernel/**` (Regel), documentService-Singleton, featureFlags.
|
|
||||||
|
|
||||||
### CAD-Werkzeuge (D1–D12, vollständig)
|
|
||||||
- Ellipse (3-Klick, Rotation aus Zugrichtung, Bogen via Winkel-Optionen) + Spline (CV-basiert, Catmull-Rom durch alle Kontrollpunkte)
|
|
||||||
- Point/XLine/Ray auf Auto-angelegtem `defpoints`-Layer mit `construction:true`-Tag
|
|
||||||
- MTEXT (mehrzeilig, optionaler Rahmen in derselben Transaktion)
|
|
||||||
- Stretch, Break, Join, Explode, Align, Chamfer, Lengthen, PolylineEditor (verifiziert, Phase D complete)
|
|
||||||
- GripEditing: Vertex-/Center-Grips (Linien-Endpunkte, Polyline-Vertices, Kreis-Zentrum) mit Drag in einer Transaktion; BBox-Corner-Skalierung bleibt parallel
|
|
||||||
- Arrays: rect, polar, **path** (Kopien gleichmäßig entlang Pfadlänge)
|
|
||||||
- Snaps erweitert: Quadrant (4 Kreis-Quadranten), Perpendicular (Fußpunkt vom Referenzpunkt), Tangent (beide Tangentenpunkte via acos), fromOffset-Helfer
|
|
||||||
- BlockAttribute: `BlockAttrDef` auf Definitionen, `attrValues` auf Instanzen, Rendering als Text-Primitives, PropertiesPanel-Formular
|
|
||||||
|
|
||||||
### Event-Bestuhlung (E1–E7, vollständig)
|
|
||||||
- Sitzreihen/-blöcke mit Drag-Interaktion, ArcRows (Stühle tangential rotiert), Vorlagenkatalog (Kino, Bankett, Stehtisch, Podest), CSV-Sitzlistent-Export, Gäste-Verwaltung mit Stuhl-Zuweisung
|
|
||||||
- **Neu:** Vorhang (wellige Polyline), Scheinwerfer (Kegel-Polygon mit Winkel + Farbe), Absperrung (Kettenglieder), Bühnenhöhen-Attribut
|
|
||||||
|
|
||||||
### Bibliothek & Import/Export (F1–F8, vollständig)
|
|
||||||
- Globale Block-Bibliothek: Ordner, Suche, Favoriten, Thumbnails, Grid-Ansicht, Drag&Drop in die Zeichenfläche
|
|
||||||
- Export/Import als JSON (`.wcadlib`) **und** echtes ZIP-Paket (manifest.json + blocks.json + thumbs/)
|
|
||||||
- **DXF-Import erweitert:** ELLIPSE, SPLINE (Grad ≤ 3, de-Boor-Approximation), MTEXT, POINT, ATTDEF, MINSERT-Arrays, strukturierter skippedEntities-Fehlerreport
|
|
||||||
- **DXF-Export erweitert:** BLOCKS+INSERTS mit Namens-Sanitizing, ATTDEF/ATTRIB/SEQEND, AC1009 (R12) + AC1015, R12-korrektes POLYLINE+VERTEX+SEQEND; Roundtrip-Tests deckten und fixten 3 latente Konventions-Bugs (Circle-Zentrum in Writer und Parser, ARC-Winkel-Radian→Grad)
|
|
||||||
- **SVG-Import als echte Geometrie:** voller Path-Parser (M/L/H/V/C/S/Q/T/A/Z, absolut+relativ, Bézier-Flattening, Arc-Endpoint→Center), rect/circle-Konventions-Fixes, Ellipse als Polygon
|
|
||||||
- Builtin-Kataloge: Event/Architektur/Landschaft (je 15 Blöcke) über LibraryProvider, User- und Global-Library angebunden
|
|
||||||
|
|
||||||
### Layouts & Plot (G1–G5, vollständig)
|
|
||||||
- Layout-Datenmodell in Yjs (undo-getrackt), LayoutBar mit Viewport-Minimap, zoom-to
|
|
||||||
- Titelblock mit attributierten Texten (Projekt/Datum/Maßstab/Autor), auto-insert als Layout-Frame
|
|
||||||
- **PDF-Export je Layout:** A4 landscape im Ausgabemaßstab, Linienbreiten + Dash-Patterns aus Layern, monochrome-Option, Titelblock-Mitzeichnung
|
|
||||||
- **Browser-Print:** @media print mit A4 landscape, UI-Chrome ausgeblendet, Print-Service mit Cleanup
|
|
||||||
|
|
||||||
### Modi & Rollen (H1–H5, vollständig)
|
|
||||||
- simple/pro-Modus (localStorage-persistiert): simple zeigt nur basic-Tools, CommandLine pro-only, Topbar-Toggle
|
|
||||||
- Projekt-Templates: 6 Branchen-Presets (Halle/OpenAir/Saal/Messe/Büro/Garten) mit Grid/Layern/Plugin-Seed
|
|
||||||
- **Rollenschutz:** library-admin (an admin-Rolle gebunden) für alle 8 schreibenden Global-Library-Routen; Lesen/Export für alle authentifizierten Nutzer
|
|
||||||
- i18n-Grundlage: strings.ts de/en-Maps mit t()-Funktion, Persistenz, useT-Hook; LayoutBar + Modus umgestellt
|
|
||||||
- Flächenberechnung: Shoelace-polygonArea, formatArea (quadratische Skalierung), measure-area-Tool, PropertiesPanel-Anzeige
|
|
||||||
|
|
||||||
### Behobene Fehlerklassen
|
|
||||||
- **BUG-1-Klasse komplett geschlossen:** alle Date.now()-Auto-IDs im Backend durch UUID-Slice ersetzt (deterministische Tests mit eingefrorener Zeit beweisen die Kollisionsfreiheit)
|
|
||||||
- Circle-Center-Konvention in DXF-Writer, DXF-Parser und SVG-Import vereinheitlicht (Zentrum statt min-corner)
|
|
||||||
- ARC-Winkel-Konvertierung dxf-parser (intern Radiant) → App (Grad) gefixt
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- **Frontend: 697 Tests** (alle grün), **Backend: 287 Tests** (alle grün)
|
|
||||||
- Volle tsc-Typechecks und Production-Builds grün bei jedem Task
|
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# Multi-Stage: Backend + Frontend
|
||||||
|
FROM node:22-alpine AS backend-build
|
||||||
|
WORKDIR /app/backend
|
||||||
|
COPY backend/package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
COPY backend/ ./
|
||||||
|
|
||||||
|
FROM node:22-alpine AS frontend-build
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
COPY --from=backend-build /app/backend /app/backend
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist
|
||||||
|
|
||||||
|
# Serve Backend auf 5000
|
||||||
|
EXPOSE 5000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# WebCAD
|
|
||||||
|
|
||||||
Web-basiertes 2D-CAD mit Kollaboration, Plugin-System und Fokus auf
|
|
||||||
Veranstaltungsplanung (Bestuhlung, Bühnen, Layouts).
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Zeichnen:** Linien, Rechtecke, Kreise, Bögen, Ellipsen, Splines, Polylinien, Text, MTEXT, Bemaßungen, Hatch
|
|
||||||
- **Modifizieren:** Move/Copy/Rotate/Scale/Mirror, Trim/Extend/Fillet/Chamfer, Stretch/Break/Join/Explode/Align/Lengthen, Polyline-Editor, Grips (Vertex/Center), Arrays (rect/polar/path)
|
|
||||||
- **Event-Tools:** Stühle, Sitzreihen (Drag), Sitzblöcke, ArcRows, Tische, Bühnen (mit Höhen-Attribut), Vorhang, Scheinwerfer, Absperrungen, Vorlagenkatalog, Gäste-Verwaltung mit Sitzplatz-Zuweisung, CSV-Sitzlistent-Export
|
|
||||||
- **Snaps:** Endpoint, Midpoint, Center, Intersection, Quadrant, Perpendicular, Tangent, Grid, Polar-Tracking
|
|
||||||
- **Bibliothek:** Globale Block-Bibliothek mit Ordnern, Suche, Favoriten, Thumbnails; Builtin-Kataloge (Event/Architektur/Landschaft); Export/Import als .wcadlib (JSON oder ZIP)
|
|
||||||
- **Import/Export:** DXF (Import mit ELLIPSE/SPLINE/MTEXT/POINT/ATTDEF + Fehlerreport; Export R12/2000 mit BLOCKS/ATTR), SVG (echte Geometrie-Konvertierung mit Path-Parser), PDF (je Layout, maßstabsgetreu), PNG, JSON
|
|
||||||
- **Layouts & Plot:** Layout-Verwaltung mit Viewport-Minimap, Titelblock mit Attributen, PDF-Export, Browser-Print
|
|
||||||
- **Kollaboration:** Yjs CRDT, kollaborativ korrektes Undo/Redo (nur eigene Operationen)
|
|
||||||
- **Modi:** simple (Grundwerkzeuge) / pro (Vollpalette), localStorage-persistiert
|
|
||||||
- **Templates:** 6 Branchen-Presets (Halle, OpenAir, Saal, Messe, Büro, Garten)
|
|
||||||
- **i18n:** de/en (Grundlage, erweiterbar über `src/i18n/strings.ts`)
|
|
||||||
- **Rollen:** admin (library-admin, globaler Schreibzugriff) / planer (lesen + eigene Zeichnungen)
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Frontend:** React 18 + TypeScript + Vite + PixiJS v8 (WebGL-Renderer, Canvas2D-Fallback) + zustandsfreier Service-Layer
|
|
||||||
- **Backend:** Node.js + TypeScript + Fastify 5 + SQLite (better-sqlite3)
|
|
||||||
- **Kollaboration:** Yjs + y-websocket
|
|
||||||
- **Tests:** Vitest (697 FE + 287 BE), Playwright E2E, tsc-Strict-Mode
|
|
||||||
|
|
||||||
## Entwicklung
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backend
|
|
||||||
cd backend && npm install && npm run dev # http://localhost:3001
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
cd frontend && npm install && npm run dev # http://localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npm test # 697 Vitest-Tests
|
|
||||||
cd backend && npm test # 287 Vitest-Tests
|
|
||||||
|
|
||||||
cd frontend && npx tsc --noEmit # Typecheck
|
|
||||||
cd frontend && npm run build # Production-Build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Plugin-Entwicklung
|
|
||||||
|
|
||||||
Siehe [docs/PLUGIN_GUIDE.md](docs/PLUGIN_GUIDE.md) – Plugin-API v2 mit
|
|
||||||
Werkzeugen, Panels und Bibliotheks-Providern, inkl. Minimal-Beispiel.
|
|
||||||
|
|
||||||
## Bibliotheks-Pakete (.wcadlib)
|
|
||||||
|
|
||||||
Siehe [docs/LIBRARY_SPEC.md](docs/LIBRARY_SPEC.md) – JSON- und ZIP-Format,
|
|
||||||
Validierungsregeln, Endpoints und CAD-Element-Konventionen.
|
|
||||||
|
|
||||||
## Architektur-Regeln
|
|
||||||
|
|
||||||
- `kernel/**` importiert niemals React
|
|
||||||
- `render/pixi/**` kennt keine React-Components
|
|
||||||
- React kennt den Renderer nur über CanvasArea
|
|
||||||
- Eine Werkzeug-Aktion = eine `doc.transact()` = ein Undo-Schritt
|
|
||||||
|
|
||||||
## Roadmap & Status
|
|
||||||
|
|
||||||
Alle funktionalen Phasen (A–H) sind abgeschlossen. Aktueller Stand und
|
|
||||||
Task-Log: [ROADMAP.md](ROADMAP.md).
|
|
||||||
-756
@@ -1,756 +0,0 @@
|
|||||||
# web-cad v2.0 — ROADMAP & ARBEITSANWEISUNGEN
|
|
||||||
|
|
||||||
> **Dies ist die Steuerdatei für den Umbau.** Sie enthält Status, Zielarchitektur (Frontend + Backend), Regeln und exakte Arbeitsaufträge (Tasks).
|
|
||||||
> Bei jedem Fortschritt: Task-Status hier aktualisieren + Eintrag in Teil 7 (Fortschritts-Log).
|
|
||||||
>
|
|
||||||
> 📎 Hintergrund & Begründungen der Architektur-Entscheidungen: siehe [umbauplan-v2.md](./umbauplan-v2.md) — bei Abweichungen gilt DIES Dokument.
|
|
||||||
>
|
|
||||||
> Stand: 2026-08-26 · Basis: main @ `126277a` · Planer: Agent Zero · Umsetzung: Agent(en) beliebiger Stärke
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# TEIL 0 — Wie du als Agent dieses Dokument abarbeitest
|
|
||||||
|
|
||||||
Du (Agent) arbeitest IMMER GENAU EINEN Task gleichzeitig ab. Verfahre so:
|
|
||||||
|
|
||||||
1. **Nächsten Task finden:** Gehe zu Teil 5. Nimm den ERSTEN Task mit `- [ ]` (offen), dessen „Voraussetzungen“ alle `- [x]` sind. Bearbeite Tasks niemals parallel oder in falscher Reihenfolge.
|
|
||||||
2. **Task lesen:** Lies den kompletten Task. Verstanden? Wenn nein: Frage den Nutzer, rate nicht.
|
|
||||||
3. **Ist-Zustand prüfen:** Öffne alle unter „Dateien“ genannten bestehenden Dateien und lies sie (zumindest die relevanten Abschnitte), BEVOR du etwas änderst.
|
|
||||||
4. **Schritte ausführen:** Folge „Schritte“ von oben nach unten. Führe nach jedem Schritt das dort genannte Verify-Kommando aus (falls angegeben).
|
|
||||||
5. **Fehlerfall:** Wenn ein Schritt fehlschlägt: maximal 3 gezielte Fix-Versuche. Danach: Änderungen des Schritts zurücksetzen (`git checkout -- <datei>` bzw. `git restore`), Task-Status auf `BLOCKIERT` setzen und Blockgrund in Teil 7 loggen. Weiter mit einem anderen unabhängigen Task ist erlaubt, wenn dessen Voraussetzungen erfüllt sind.
|
|
||||||
6. **Scope-Disziplin:** Ändere NIEMALS Dateien oder Verhalten, die der Task nicht nennt. Keine „Verbesserungen am Rande“. Wenn dir während der Arbeit ein anderes Problem auffällt: notiere es in Teil 7 unter „Gefundene Nebenprobleme“, behebe es NICHT jetzt.
|
|
||||||
7. **Abschluss eines Tasks:**
|
|
||||||
a. Führe ALLE Kommandos unter „Verify“ aus. Alles muss grün sein.
|
|
||||||
b. Commit: `git add -A && git commit -m "task(<TASK-ID>): <kurze beschreibung>"`.
|
|
||||||
c. Setze in diesem Dokument `- [ ]` → `- [x]` mit Datum und Commit-Hash.
|
|
||||||
d. Ergänze Teil 7 (Log): Datum, Task-ID, was getan wurde, Testergebnis, Commit-Hash, offene Punkte.
|
|
||||||
8. **Commit-Disziplin:** Ein Task = mindestens ein Commit. Nie halbfertigen Code committen, der `tsc` oder Tests bricht.
|
|
||||||
9. **Nie tun:** Dependencies entfernen, die du nicht selbst hinzugefügt hast · Datenbankschema ändern ohne Migrationsscript · Tests löschen oder überspringen (`skip`, `todo`) um grün zu werden · Secrets committen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# TEIL 1 — Projektstatus & Baseline (Ist-Stand)
|
|
||||||
|
|
||||||
## 1.1 Was existiert (verifiziert am 2026-08-26)
|
|
||||||
|
|
||||||
| Bereich | Fakten |
|
|
||||||
|---|---|
|
|
||||||
| Repo | Forgejo `Leopoldadmin/web-cad`, Branch `main`, ~35.000 LOC |
|
|
||||||
| Frontend | React 18 + TS + Vite, Canvas 2D. `src/` ~16.480 LOC. `tsc --noEmit` = Exit 0 |
|
|
||||||
| Tests FE | 16 Dateien, 380 Tests, alle grün (`npx vitest run`) |
|
|
||||||
| Backend | Fastify + SQLite (better-sqlite3), 13 Route-Module, Auth (bcrypt, 4 Rollen), y-leveldb |
|
|
||||||
| Tests BE | 17 Dateien, 255 Tests, alle grün |
|
|
||||||
| Kollaboration | Yjs CRDT + WebSocket + y-indexeddb (Offline) |
|
|
||||||
| Deployment | Docker Compose, Coolify, Forgejo CI vorhanden |
|
|
||||||
|
|
||||||
## 1.2 Bekannte Bugs (müssen in Phase B gefixt werden)
|
|
||||||
|
|
||||||
| ID | Ort | Bug |
|
|
||||||
|---|---|---|
|
|
||||||
| BUG-1 | `frontend/src/services/seatingService.ts` (~L220) | `rowId: row_${Date.now()}` → ID-Kollisionen bei schneller Folge. Blöcke setzen gar keine rowId pro Reihe. `countSeats(byRow)` dadurch falsch. |
|
|
||||||
| BUG-2 | `frontend/src/tools/modification/geometry.ts` (~L303) | `findIntersection()` kann nur Gerade×Gerade → Trim/Extend/Fillet scheitern still auf Kreis/Bogen/Polyline. |
|
|
||||||
| BUG-3 | `frontend/src/interaction/index.ts` (handleModifyDown, offset-Zweig) | Offset nutzt nur Distanz, ignoriert Richtung des zweiten Klicks (Seite fehlt). |
|
|
||||||
| BUG-4 | `frontend/src/canvas/RenderEngine.ts` (`drawBackground`) | Hintergrundbild wird bei jedem Frame neu geladen (`img.onload = () => this.render()`). Muss gecacht werden. |
|
|
||||||
|
|
||||||
## 1.3 Architektur-Schwächen (Motivation für Umbau)
|
|
||||||
|
|
||||||
| Schwäche | Ort | Folge |
|
|
||||||
|---|---|---|
|
|
||||||
| God-Component | `App.tsx` (1803 LOC, 40+ useState) | Unwartbar, jede Mausbewegung (`cursorPos` in State) rendert GANZE App neu |
|
|
||||||
| Hart codierte Tools | `interaction/index.ts` (1157 LOC switch-case) | Werkzeuge nicht als Plugins möglich, Ribbon statisch |
|
|
||||||
| Snapshot-History | `history/HistoryManager.ts` | Ganzzustand-Undo, kollisionsanfällig in Kollaboration, keine Tool-Transaktionen |
|
|
||||||
| Direkte Engine-Kopplung | `components/CanvasArea.tsx` (477 LOC) | Renderer nicht austauschbar |
|
|
||||||
| Renderer Canvas2D only | `canvas/RenderEngine.ts` (1196 LOC) | 50k+ Elemente unrealistisch |
|
|
||||||
| DXF mini | `services/dxfParser.ts` (186 LOC), `dxfWriter.ts` (214 LOC) | Nur 7 Entity-Typen; ELLIPSE/SPLINE/MTEXT/HATCH/POINT fehlen |
|
|
||||||
|
|
||||||
## 1.4 Zielbild v2.0 (kurz)
|
|
||||||
|
|
||||||
Web-2D-CAD in Standard-CAD-Breite mit Event-Fokus. **Alle Werkzeuge = Plugins.** WebGL-Rendering. Library first-class, durch Plugin-Kataloge erweiterbar. Branchenneutral einsetzbar (Grundrisse, Gelände). Bedienmodi „Einfach“ und „Pro“. Frontend und Backend werden beide umgebaut.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# TEIL 2 — ZIELARCHITEKTUR FRONTEND (ausgearbeitet)
|
|
||||||
|
|
||||||
## 2.1 Neue Ordnerstruktur (Ziel)
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/src/
|
|
||||||
├── kernel/ # Framework-frei, kein React-Import erlaubt!
|
|
||||||
│ ├── model/ # Typen + Fabriken: CADElement, CADLayer, BlockDef, ids.ts
|
|
||||||
│ ├── geometry/ # intersects.ts (Line/Circle/Arc/Poly), transform.ts, bbox.ts
|
|
||||||
│ ├── document/ # CADDocument: CRUD + Queries über Yjs-Daten
|
|
||||||
│ └── history/ # Transaktionen (Wrapper um Yjs UndoManager)
|
|
||||||
├── render/
|
|
||||||
│ ├── types.ts # RenderAdapter-Interface + Viewport/Style-Typen
|
|
||||||
│ ├── pixi/ # PixiJS-Implementierung
|
|
||||||
│ │ ├── PixiRenderer.ts # implements RenderAdapter
|
|
||||||
│ │ ├── ElementLayer.ts # Graphics/Sprites pro CAD-Layer
|
|
||||||
│ │ ├── OverlayLayer.ts # Selection, Snaps, Previews, Tool-Overlays
|
|
||||||
│ │ ├── GridRulerLayer.ts
|
|
||||||
│ │ └── atlas.ts # Texturatlas für chair/table/stage
|
|
||||||
│ └── mock/MockAdapter.ts # Headless-Adapter für Unit-Tests (zeichnet nichts, protokolliert Aufrufe)
|
|
||||||
├── interaction/
|
|
||||||
│ ├── dispatcher.ts # InteractionDispatcher: DOM-Events → aktives Tool
|
|
||||||
│ ├── session.ts # ToolSession-Zustandsmaschine (idle/drawing/modifying)
|
|
||||||
│ ├── pointer.ts # Event-Normalisierung (screen→world, Buttons, Modifier)
|
|
||||||
│ └── shortcuts.ts # Tastatur-Mapping
|
|
||||||
├── plugins/
|
|
||||||
│ ├── types.ts # API v2 (siehe 2.4)
|
|
||||||
│ ├── registry.ts # Registry v2
|
|
||||||
│ ├── context.ts # PluginContext-Fabrik (Permissions geprüft)
|
|
||||||
│ ├── builtin/
|
|
||||||
│ │ ├── core-drawing/index.ts
|
|
||||||
│ │ ├── core-modify/index.ts
|
|
||||||
│ │ ├── core-annotate/index.ts
|
|
||||||
│ │ ├── core-measure/index.ts
|
|
||||||
│ │ └── event-tools/index.ts
|
|
||||||
│ └── library/ # LibraryProviderExtension-Verwaltung
|
|
||||||
├── stores/ # zustand Stores (NEU, ersetzt großen Teil des App-State)
|
|
||||||
│ ├── docStore.ts # Bindung an Yjs: elements/layers/selection (Selektion NICHT in Yjs, pro User lokal)
|
|
||||||
│ ├── toolStore.ts # activeToolId, toolOptions-Werte
|
|
||||||
│ ├── viewStore.ts # zoom, pan, grid, ortho, snap, unit, scaleFactor
|
|
||||||
│ └── uiStore.ts # panels, modals, ribbonTab, mode: 'simple'|'pro'
|
|
||||||
├── ui/
|
|
||||||
│ ├── AppShell.tsx # NUR Layout (Grid aus Slots)
|
|
||||||
│ ├── canvas/CanvasHost.tsx # Mountet Kernel+Renderer+Dispatcher, sonst nichts
|
|
||||||
│ ├── ribbon/RibbonBar.tsx # dynamisch aus pluginRegistry.getToolExtensions()
|
|
||||||
│ ├── panels/… # LeftSidebar, RightSidebar, PropertiesPanel (liest toolOptions-Schema)
|
|
||||||
│ ├── dialogs/…
|
|
||||||
│ └── simple/… # Einfach-Modus Varianten
|
|
||||||
├── services/ # bleiben (api.ts, import/export, background, seating → wandert teils in plugins/builtin/event-tools/domain)
|
|
||||||
├── crdt/ # bleibt (YjsDocument etc.)
|
|
||||||
└── types/ # cad.types.ts bleibt Quelle der Wahrheit für Persistenz-Typen
|
|
||||||
```
|
|
||||||
|
|
||||||
**Regel:** `kernel/**` darf React nirgends importieren. `render/pixi/**` kennt keine React-Components. React kennt Renderer nur über `CanvasHost`.
|
|
||||||
|
|
||||||
## 2.2 State-Management (Entscheidung + Begründung)
|
|
||||||
|
|
||||||
**Neu: zustand** (klein, kein Provider-Boilerplate, selektierbar).
|
|
||||||
|
|
||||||
Gründe gegen Ist-Zustand:
|
|
||||||
- `cursorPos` liegt heute in App-State → jede Mausbewegung re-rendert 1803-LOC-Tree. Neu: Cursor-Anzeige liest Position aus einem throttled store-Slice ODER direkt DOM-Update im CanvasHost (bevorzugt: StatusBar subscribed auf `viewStore.cursor` mit Selektor; Update max 60/s).
|
|
||||||
- Elemente liegen doppelt (React-State UND Yjs). Neu: **Yjs ist alleinige Quelle der Wahrheit**. `docStore` spiegelt Yjs-Arrays in einen zustand-Slice (via `yjs.observe`), Components lesen daraus. Kein zweites `setElements`.
|
|
||||||
|
|
||||||
Store-Verträge (exakt):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// stores/docStore.ts
|
|
||||||
interface DocSlice {
|
|
||||||
elements: Record<string, CADElement>; // id → element (Spiegel von Yjs Map)
|
|
||||||
elementOrder: string[]; // Render-/Auswahlreihenfolge
|
|
||||||
layers: CADLayer[];
|
|
||||||
selection: string[]; // lokale Selektion (nicht synchronisiert)
|
|
||||||
setSelection(ids: string[]): void;
|
|
||||||
}
|
|
||||||
// stores/toolStore.ts
|
|
||||||
interface ToolSlice {
|
|
||||||
activeToolId: string; // default 'select'
|
|
||||||
optionValues: Record<string, Record<string, unknown>>; // toolId → optionKey → value
|
|
||||||
setTool(id: string): void; // validiert gegen Registry
|
|
||||||
setOption(toolId: string, key: string, value: unknown): void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2.3 Rendering-Pipeline (Ziel)
|
|
||||||
|
|
||||||
Frame-Zyklus (außerhalb React):
|
|
||||||
1. `InteractionDispatcher` erhält DOM-Event → normalisiert (`pointer.ts`) → ruft Handler des aktiven Tools mit Weltkoordinaten.
|
|
||||||
2. Tool ändert Entweder: a) `document.transact(fn)` (committed, erzeugt Undo-Eintrag) oder b) `renderer.setPreview(el)` (nur Optik).
|
|
||||||
3. `PixiRenderer` hält pro CAD-Layer einen Container. Bei Yjs-Transaktion: diff-basiertes Update (added/removed/changed Keys aus Yjs-Events), KEIN Full-Redraw.
|
|
||||||
4. Overlays (Selection/Snap/Preview) leben in eigenem Layer, immer neu zeichnbar ohne Element-Layer zu berühren.
|
|
||||||
5. requestAnimationFrame-Loop rendert nur bei `dirty`-Flag (kein Dauerloop).
|
|
||||||
|
|
||||||
Performance-Ziele (messbar in `tests/PerformanceBenchmark.test.ts` weiterführen):
|
|
||||||
- 10k Elemente: Interaktion < 50ms Latenz
|
|
||||||
- 50k Chairs (Atlas-instanziiert): ≥ 55fps bei Pan/Zoom auf Referenzhardware
|
|
||||||
- Erstzeichnung Dashboard→Canvas sichtbar < 2s
|
|
||||||
|
|
||||||
## 2.4 Plugin-API v2 (Vertrag, exakte Interfaces)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// plugins/types.ts (NEU, ersetzt Teile der alten API)
|
|
||||||
export interface ToolManifestPart {
|
|
||||||
id: string; label: string; icon: string; ribbonTab: string;
|
|
||||||
shortcut?: string; tags?: ('basic'|'pro')[]; // 'basic' = im Einfach-Modus sichtbar
|
|
||||||
}
|
|
||||||
export interface ToolPointerEvent { world: Pt; screen: Pt; button: number; shift: boolean; alt: boolean; ctrl: boolean; }
|
|
||||||
export interface ToolContext {
|
|
||||||
doc: CADDocument; // CRUD + transact()
|
|
||||||
renderer: RenderAdapter; // für Preview/Overlay
|
|
||||||
snap: SnapEngine; selection: SelectionEngine;
|
|
||||||
options: Record<string, unknown>; // aktuelle OptionValues
|
|
||||||
setStatus(msg: string): void; // Statuszeile
|
|
||||||
}
|
|
||||||
export interface ToolExtensionV2 {
|
|
||||||
manifest: ToolManifestPart & { description?: string };
|
|
||||||
optionsSchema: PropertyField[]; // existierender Typ wiederverwendet
|
|
||||||
handlers: {
|
|
||||||
down?(e: ToolPointerEvent, ctx: ToolContext): void;
|
|
||||||
move?(e: ToolPointerEvent, ctx: ToolContext): void;
|
|
||||||
up?(e: ToolPointerEvent, ctx: ToolContext): void;
|
|
||||||
key?(e: KeyboardEvent, ctx: ToolContext): boolean; // true = konsumiert
|
|
||||||
cancel?(ctx: ToolContext): void; // ESC
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export interface PanelExtension { id: string; title: string; slot: 'left'|'right'; render(): HTMLElement; }
|
|
||||||
export interface LibraryProviderExtension {
|
|
||||||
id: string; label: string;
|
|
||||||
listFolders(): Promise<LibFolder[]>;
|
|
||||||
listBlocks(folderId: string): Promise<LibBlock[]>;
|
|
||||||
getBlock(id: string): Promise<LibBlock>;
|
|
||||||
}
|
|
||||||
export interface PluginV2 {
|
|
||||||
manifest: { id; name; version; author; description; category: 'tools'|'elements'|'library'|'import-export'|'theme'|'other';
|
|
||||||
enabledByDefault?: boolean; minAppVersion?: string; permissions?: Array<'canvas'|'network'|'storage'> };
|
|
||||||
tools?: ToolExtensionV2[];
|
|
||||||
commands?: CommandExtension[]; // existiert bereits, unverändert
|
|
||||||
elementTypes?: ElementTypeExtension[]; // bleibt, render optional + renderPixi später
|
|
||||||
panels?: PanelExtension[];
|
|
||||||
libraryProviders?: LibraryProviderExtension[];
|
|
||||||
onInit?(ctx: PluginContext): void; onActivate?(); onDeactivate?(); onDestroy?();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Migration Alt→Neu (wichtig!):
|
|
||||||
- Alte `ToolExtension {onActivate}` bleibt ÜBERGANGSWEISE bestehen (Phase A kompatibel), wird in A3 migriert und DANACH entfernt.
|
|
||||||
- `InteractionEngine.setTool(id)` delegiert ab A2 ausschließlich an Registry-Tools.
|
|
||||||
|
|
||||||
## 2.5 Undo/Redo (Entscheidung)
|
|
||||||
|
|
||||||
**Yjs `Y.UndoManager` verwenden**, eigenes Snapshot-History entfernen (Phase A4).
|
|
||||||
- Eine Tool-Aktion = eine `doc.transact(...)` = ein Undo-Schritt. Kollaborativ korrekt (undoed nur eigene Ops).
|
|
||||||
- `HistoryPanel` liest `undoManager.undoStack.length` etc.
|
|
||||||
- Alte `HistoryManager`-Tests (32) werden auf neue Transaktions-API umgeschrieben (Aufwand ~0,5 Tag, in A4 eingeplant).
|
|
||||||
|
|
||||||
## 2.6 Bedienmodi
|
|
||||||
|
|
||||||
- `uiStore.mode: 'simple'|'pro'`. Default für neue Nutzer: `simple`.
|
|
||||||
- Filter: Ribbon zeigt Tool nur, wenn `mode==='pro' || manifest.tags.includes('basic')`. CommandLine/Layer-Tiefe nur pro.
|
|
||||||
- Umschalter: Topbar-Menü + SettingsModal. Persistenz in usersettings (Backend, existierende settings-Route nutzen).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# TEIL 3 — ZIELARCHITEKTUR BACKEND (Kurzfassung)
|
|
||||||
|
|
||||||
Bleibt Fastify + SQLite. Neuerungen:
|
|
||||||
- Tabellen: `plugins(id TEXT PK, version TEXT, enabled_default INT, installed_at TEXT)`; `plugin_settings(plugin_id TEXT, scope TEXT, scope_id TEXT, json TEXT, PRIMARY KEY(plugin_id,scope,scope_id))`; `library_folders(id TEXT PK, parent_id TEXT, name TEXT, owner_scope TEXT)`; `library_blocks(id TEXT PK, folder_id TEXT, name TEXT, tags TEXT, thumbnail TEXT, attr_defs TEXT, payload TEXT, source TEXT)`; `guests(id TEXT PK, project_id TEXT, name TEXT, seat_id TEXT NULL)` (Phase E6, optional 2.1).
|
|
||||||
- Routen neu: `/api/plugins` GET · `/api/plugins/:id/settings` GET/PUT · `/api/plugins/install` POST (Admin, ZIP) · `/api/library/folders|blocks` CRUD · `/api/library/import|export` POST.
|
|
||||||
- WebSocket-Notify: `pluginStateChanged`, `libraryChanged` Broadcast.
|
|
||||||
- Migrationsmechanismus: `backend/src/database/migrations/NNNN_name.sql`, Runner beim Boot (Schema-Versionstabelle). JEDE Schemaänderung mit Migrationsfile + Test.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# TEIL 4 — REGELN (Definition of Done)
|
|
||||||
|
|
||||||
Ein Task ist nur dann fertig, wenn ALLSES gilt:
|
|
||||||
1. `cd frontend && npx tsc --noEmit` → Exit 0.
|
|
||||||
2. `cd frontend && npx vitest run` → alle Tests grün (Anzahl darf nie sinken; neue Features bringen Tests).
|
|
||||||
3. `cd backend && npx vitest run` → alle grün (bei Backend-Tasks).
|
|
||||||
4. Keine neuen `console.log` (Logger-Util `utils/logger.ts` nutzen, in A0 anlegen).
|
|
||||||
5. Neue öffentliche Funktionen/Interfaces haben TSDoc-Kommentar (1–3 Zeilen).
|
|
||||||
6. Commit-Format: `task(<ID>): <beschreibung>` (Conventional reicht so).
|
|
||||||
7. Dieser File: Status gesetzt + Log-Eintrag.
|
|
||||||
|
|
||||||
Testkonventionen:
|
|
||||||
- Neue Kernel-Module: Vitest Unit-Tests OHNE DOM (`environment: node` geht nicht global — lege pro Testfile `// @vitest-environment node` falls kein DOM nötig).
|
|
||||||
- Renderer: Tests laufen gegen `MockAdapter` (protokolliert draw*-Aufrufe). WebGL selbst wird nur in Playwright-Smoke getestet.
|
|
||||||
- Tools: je Tool ein Workflow-Test (down/move/up-Sequenz gegen MockAdapter + InMemory-Doc).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# TEIL 5 — PHASEN & TASKS
|
|
||||||
|
|
||||||
Legende: `- [ ]` offen · `- [~]` in Arbeit (Datum) · `- [x]` fertig (Datum, Hash) · `[BLOCKIERT: grund]`
|
|
||||||
|
|
||||||
**ERWEITERUNGSREGEL (verpflichtend):** Die Tasks in Phase C (ab C2) und den Phasen D–I sind bewusst als Kurz-Tasks gehalten. SOFORT VOR Beginn eines solchen Tasks wird er auf das volle Schema erweitert (Voraussetzungen · Ziel · Dateien · nummerierte Schritte mit Code-Ankerpunkten · Verify-Kommandos · Commit-Text) und die Erweiterung in diesem Dokument gespeichert. Ein Kurz-Task darf NIE direkt bearbeitet werden. Grund: Nur vollständig ausgeformte Tasks sind von einem schwachen Modell sicher ausführbar.
|
|
||||||
|
|
||||||
**COPY-PASTE-REGEL:** Wo ein Task kompletten Code enthält (z.B. B0-Testdatei): exakt übernehmen, NICHT „verbessern“. Eigene Abweichungen nur, wenn ein Schritt nachweislich am Ist-Code scheitert – dann Blockierung gemäß Teil 0 Punkt 5 loggen.
|
|
||||||
|
|
||||||
## Phase B — Bugfixes (zuerst! klein & isoliert)
|
|
||||||
|
|
||||||
### Task B0 — Testgerüst für Bugs (ROT-Tests) ✅ [2026-08-26 · b9eb69f]
|
|
||||||
- Voraussetzungen: keine
|
|
||||||
- Ziel: Repro-Tests für BUG-1, BUG-1b und BUG-2, die HEUTE rot sind und nach den Fixes grün. (Regressionstests für BUG-3 liegen in Task B3, für BUG-4 in Task B4 — dort jeweils im selben Commit wie der Fix, weil sie vor dem Fix gegen die bestehende API nicht kompilierbar sind.)
|
|
||||||
- Dateien: NEU `frontend/tests/bugs.regress.test.ts`
|
|
||||||
|
|
||||||
**WICHTIG (verifiziert):** `findIntersection` ist in `geometry.ts` NICHT exportiert (nur intern genutzt). Der BUG-2-Test darf es deshalb NICHT importieren — er prüft über das exportierte `trimElement`. `SeatingService` ist exportiert (`export class SeatingService`, L162).
|
|
||||||
|
|
||||||
Erzeuge die Datei `frontend/tests/bugs.regress.test.ts` mit EXAKT diesem Inhalt:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
/**
|
|
||||||
* Regressions-Tests für bekannte Bugs BUG-1, BUG-1b, BUG-2.
|
|
||||||
* Diese Tests sind VOR dem jeweiligen Fix ROT (dokumentiertes Fehlverhalten)
|
|
||||||
* und müssen danach GRÜN sein.
|
|
||||||
*/
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { SeatingService } from '../src/services/seatingService';
|
|
||||||
import { trimElement } from '../src/tools/modification/geometry';
|
|
||||||
import type { CADElement } from '../src/types/cad.types';
|
|
||||||
|
|
||||||
// ── Helfer (Muster aus tests/geometry.test.ts) ─────────────
|
|
||||||
function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: 'line',
|
|
||||||
layerId: 'layer-1',
|
|
||||||
x: (x1 + x2) / 2,
|
|
||||||
y: (y1 + y2) / 2,
|
|
||||||
width: Math.abs(x2 - x1),
|
|
||||||
height: Math.abs(y2 - y1),
|
|
||||||
properties: { x1, y1, x2, y2 },
|
|
||||||
} as unknown as CADElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCircle(id: string, cx: number, cy: number, r: number): CADElement {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: 'circle',
|
|
||||||
layerId: 'layer-1',
|
|
||||||
x: cx,
|
|
||||||
y: cy,
|
|
||||||
width: r * 2,
|
|
||||||
height: r * 2,
|
|
||||||
properties: { radius: r },
|
|
||||||
} as unknown as CADElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── BUG-1: rowId-Kollision bei schnellem Erzeugen ───────────
|
|
||||||
describe('BUG-1: seating row ids', () => {
|
|
||||||
it('zwei direkt hintereinander erzeugte Reihen haben unterschiedliche rowIds', () => {
|
|
||||||
const svc = new SeatingService();
|
|
||||||
const a = svc.createSeatingRow(0, 0, 'layer-1');
|
|
||||||
const b = svc.createSeatingRow(0, 100, 'layer-1');
|
|
||||||
const ids = new Set([a[0].properties.rowId, b[0].properties.rowId]);
|
|
||||||
expect(ids.size).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── BUG-1b: Block-Reihen ohne eigene rowId ──────────────────
|
|
||||||
describe('BUG-1b: seating block row ids', () => {
|
|
||||||
it('jede Reihe eines Blocks hat eine eigene, definierte rowId', () => {
|
|
||||||
const svc = new SeatingService();
|
|
||||||
const els = svc.createSeatingBlock(0, 0, 'layer-1', { rows: 3, cols: 2 });
|
|
||||||
expect(els.length).toBe(6);
|
|
||||||
const rowIds = new Set(els.map((e) => e.properties.rowId));
|
|
||||||
expect(rowIds.size).toBe(3);
|
|
||||||
for (const rid of rowIds) expect(rid).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── BUG-2: Trim an Kreis scheitert (findIntersection nur Gerade×Gerade) ──
|
|
||||||
describe('BUG-2: trim line at circle', () => {
|
|
||||||
it('trimElement(Linie, Kreis) liefert ein Ergebnis statt null', () => {
|
|
||||||
// Linie von (0,0) nach (200,0) schneidet Kreis (Zentrum 100,0, Radius 40)
|
|
||||||
// an x=60 und x=140 → Schnitt existiert eindeutig.
|
|
||||||
const line = makeLine('l1', 0, 0, 200, 0);
|
|
||||||
const circle = makeCircle('c1', 100, 0, 40);
|
|
||||||
const result = trimElement(line, circle);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Hinweis zu makeLine/makeCircle: Die properties-Namen (x1,y1,x2,y2 bzw. radius) sind die vom Produktivcode genutzten Konventionen (`findIntersection` liest x1..y2; `RenderEngine.drawCircle` liest `properties.radius || width/2`). Falls `tests/geometry.test.ts` abweichende Helfer hat, gleiche sie daran an — die Struktur oben bleibt.
|
|
||||||
|
|
||||||
- Schritte:
|
|
||||||
1. Datei exakt wie oben anlegen.
|
|
||||||
2. Ausführen: `cd frontend && npx vitest run tests/bugs.regress.test.ts` → ERWARTET: alle 3 Tests FAIL (das ist der Beweis, dass die Bugs real sind; dokumentiere die Fehlermeldungen kurz in Teil 7).
|
|
||||||
3. Gesamt-Suite: `npx vitest run` → alles andere muss grün bleiben, nur die 3 neuen Tests rot.
|
|
||||||
- Verify: `npx vitest run tests/bugs.regress.test.ts 2>&1 | tail -25` (3 fail erwartet) · `npx vitest run 2>&1 | tail -5` (nur diese 3 rot) · `npx tsc --noEmit` (Exit 0 — Tests müssen kompilieren!)
|
|
||||||
- Commit: `task(B0): regression tests for known bugs (red)`
|
|
||||||
- Nicht tun: Fixes schon einbauen · Tests mit `.skip` versehen · die roten Tests „reparieren"
|
|
||||||
|
|
||||||
### Task B1 — Seating-IDs fixen (BUG-1) ✅ [2026-08-26 · 1bbd653]
|
|
||||||
- Voraussetzungen: B0
|
|
||||||
- Dateien: `frontend/src/services/seatingService.ts`
|
|
||||||
- Schritte:
|
|
||||||
1. Direkt nach den Interface-/Default-Konstanten (vor `export class SeatingService`) diesen Helper ergänzen:
|
|
||||||
```ts
|
|
||||||
let seqCounter = 0;
|
|
||||||
function nextUid(prefix: string): string {
|
|
||||||
seqCounter += 1;
|
|
||||||
return `${prefix}_${Date.now().toString(36)}_${seqCounter.toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
(Prüfe zuerst mit grep, ob es bereits eine `generateId()`-Methode in der Klasse gibt — wenn ja, nutze diese für Element-IDs weiter, aber NICHT für rowId, da rowId pro Reihe gleich sein muss.)
|
|
||||||
2. In `createSeatingRow` (ca. L189): VOR der for-Schleife `const rowId = nextUid('row');` einfügen; innerhalb properties: `rowId: row_${Date.now()}` ERSETZEN durch `rowId,` (und `rowIndex: i` bleibt).
|
|
||||||
3. In `createSeatingBlock` (ca. L227): VOR der äußeren for-Schleife `const blockId = nextUid('block');` (ersetzt das bestehende `const blockId = \`block_${Date.now()}`). INNERHALB der äußeren Schleife (pro Reihe) `const rowId = nextUid('row');`; in properties ergänzen: `rowId,` (neu) neben bestehendem `blockId`, `rowIndex`, `colIndex`.
|
|
||||||
4. Ausführen: `npx vitest run tests/bugs.regress.test.ts` → BUG-1 und BUG-1b jetzt GRÜN; BUG-2 weiterhin rot (kommt in B2).
|
|
||||||
- Verify: `npx vitest run tests/bugs.regress.test.ts 2>&1 | tail -15` (2 von 3 grün) · `npx tsc --noEmit` Exit 0 · `npx vitest run 2>&1 | tail -5`
|
|
||||||
- Commit: `task(B1): unique row/block ids for seating (fixes BUG-1)`
|
|
||||||
|
|
||||||
### Task B2 — findIntersection erweitern (BUG-2)
|
|
||||||
- Voraussetzungen: B0
|
|
||||||
- Dateien: `frontend/src/tools/modification/geometry.ts`
|
|
||||||
- Schritte:
|
|
||||||
1. LESE ZUERST `RenderEngine.drawArc` (L524) und notiere dir die Arc-Property-Konvention (Felder + Winkeleinheit/Richtung). Nutze dieselbe Konvention in den neuen Funktionen.
|
|
||||||
2. Neue exportierte Funktionen in geometry.ts ergänzen:
|
|
||||||
```ts
|
|
||||||
export interface Pt { x: number; y: number; }
|
|
||||||
/** Alle Schnittpunkte Strecke p1→p2 × Kreis(c,r); NUR t∈[0,1] (echte Strecke). */
|
|
||||||
export function lineCircleIntersections(p1: Pt, p2: Pt, c: Pt, r: number): Pt[] {
|
|
||||||
// Standardformel: a=d·d, b=2·f·d, cc=f·f−r² mit d=p2−p1, f=p1−c
|
|
||||||
// t=(−b±√(b²−4ac))/(2a); nur t∈[0,1]; Diskriminante<0 → []
|
|
||||||
}
|
|
||||||
/** Schnittpunkte zweier Kreise; [] wenn getrennt oder identisch. */
|
|
||||||
export function circleCircleIntersections(c1: Pt, r1: number, c2: Pt, r2: number): Pt[] {
|
|
||||||
// Standardformel: d=|c2−c1|, Grenzfälle dokumentieren
|
|
||||||
}
|
|
||||||
/** Strecke × Bogen; Konvention wie RenderEngine.drawArc. */
|
|
||||||
export function lineArcIntersection(p1: Pt, p2: Pt, el: CADElement): Pt[] {
|
|
||||||
// 1) Strecke×Kreis via lineCircleIntersections
|
|
||||||
// 2) Treffer auf Winkel im [startAngle,endAngle]-Bereich filtern (Grad→Rad konvertieren)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. findIntersection (intern, L303): Dispatch je Typkombination: line×line (bestehend), line×circle, line×arc, circle×circle; Reihenfolge egal → intern normalisieren (z.B. immer kleinerer Typname zuerst). Bei mehreren Punkten: der NAHERE zu p1 von Element 1.
|
|
||||||
4. trimElement/extendElement unverändert lassen.
|
|
||||||
5. Unit-Tests ERGÄNZEN in tests/geometry.test.ts (Datei NICHT ersetzen): je neuer Funktion ≥2 positiv, ≥1 negativ, Randfälle tangential (genau 1 Punkt) + sekant (2 Punkte). BUG-2-Test aus bugs.regress.test.ts muss grün werden.
|
|
||||||
- Verify: `npx vitest run tests/geometry.test.ts tests/bugs.regress.test.ts 2>&1 | tail -15` (alles grün inkl. BUG-2) · `npx vitest run 2>&1 | tail -5` · `npx tsc --noEmit`
|
|
||||||
- Commit: `task(B2): line/circle/arc intersections (fixes BUG-2)`
|
|
||||||
|
|
||||||
### Task B3 — Offset-Richtung (BUG-3)
|
|
||||||
- Voraussetzungen: B2 (nutzt Pt/Normalen-Helfer aus geometry.ts)
|
|
||||||
- Dateien: `frontend/src/tools/modification/geometry.ts`, `frontend/src/interaction/index.ts`, ERGÄNZEN `frontend/tests/bugs.regress.test.ts`
|
|
||||||
- Schritte:
|
|
||||||
1. geometry.ts: Signatur ändern zu `export function offsetElement(el: CADElement, dist: number, side?: 1 | -1): CADElement` — `dist` wird mit `(side ?? 1)` multipliziert. Prüfe ALLE bestehenden Aufrufer (`grep -n 'offsetElement(' -r src tests`): Bestehende Aufrufe ohne drittes Argument müssen unverändert weiterlaufen (Default side=1). Normale für line: (-dy, dx)/len; für rect/circle: Vorzeichen relativ zum Zentrum vs. Klickpunkt (wird vom Aufrufer übergeben).
|
|
||||||
2. interaction/index.ts offset-Zweig (handleModifyDown): berechne `side` per Kreuzprodukt: `cross = (p2.x-p1.x)*(pt.y-p1.y) - (p2.y-p1.y)*(pt.x-p1.x)`; `const side = cross >= 0 ? 1 : -1`; übergebe `offsetElement(this.modifySelected[0], dist, side)`. p1/p2 = Endpunkte der Basislinie aus properties (x1,y1,x2,y2); bei rect/circle: Klickpunkt vs. Elementzentrum.
|
|
||||||
3. Regressionstest in bugs.regress.test.ts ERGÄNZEN:
|
|
||||||
```ts
|
|
||||||
describe('BUG-3: offset respects click side', () => {
|
|
||||||
it('Klick links der Linie versetzt nach links', () => {
|
|
||||||
// Linie (100,100)→(300,100), Klick bei (200,50) = oberhalb → Ergebnis-Mitte.y < 100
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
(Konkret ausarbeiten: offsetElement(line, 20, -1) muss y um −20 verschieben, +1 um +20 — exakt eine Assertion auf resultierende properties.x1/y1.)
|
|
||||||
4. Suite grün inkl. neuem Test.
|
|
||||||
- Verify: `npx vitest run tests/bugs.regress.test.ts 2>&1 | tail -15` · `npx vitest run 2>&1 | tail -5` · `npx tsc --noEmit`
|
|
||||||
- Commit: `task(B3): offset respects click side (fixes BUG-3)`
|
|
||||||
|
|
||||||
### Task B4 — Hintergrundbild cachen (BUG-4)
|
|
||||||
- Voraussetzungen: B0
|
|
||||||
- Dateien: `frontend/src/canvas/RenderEngine.ts`, ERGÄNZEN `frontend/tests/bugs.regress.test.ts`
|
|
||||||
- Schritte:
|
|
||||||
1. LESE ZUERST die aktuelle `drawBackground()` (L381) komplett und verstehe den Transform-Kontext (ZoomPan-Transform ist beim Aufruf aktiv).
|
|
||||||
2. Zwei private Felder ergänzen:
|
|
||||||
```ts
|
|
||||||
private bgImage: HTMLImageElement | null = null;
|
|
||||||
private bgSrcLoaded = '';
|
|
||||||
```
|
|
||||||
3. drawBackground umschreiben: wenn `this.options.backgroundSrc && this.options.backgroundSrc !== this.bgSrcLoaded` → NEUES Image laden; onload: Felder setzen + EINMALIG `this.render()`; wenn `backgroundSrc === bgSrcLoaded && bgImage` → direkt `ctx.drawImage(...)` mit dem bestehenden Transform (kein Laden). Wenn backgroundSrc null/leer → Felder zurücksetzen.
|
|
||||||
4. Regressionstest in bugs.regress.test.ts ERGÄNZEN (jsdom liefert Mock-Context aus tests/setup.ts):
|
|
||||||
```ts
|
|
||||||
describe('BUG-4: background image cached', () => {
|
|
||||||
it('zweites render laedt das Bild nicht erneut', () => {
|
|
||||||
// RenderEngine instanziieren wie in tests/RenderEngine.test.ts (createMockCanvas-Muster)
|
|
||||||
// engine.setOptions({ backgroundSrc: 'data:image/png;base64,iVBORw0KGgo=' })
|
|
||||||
// engine.render(); await tick; engine.render();
|
|
||||||
// assert: nur 1× new Image() bzw. img.src-Zuweisung (spy via vi.spyOn(global, 'Image'))
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
Ausarbeiten mit vi.spyOn(window, 'Image') oder global.Image-Stub; Assertion: Konstruktor-Aufrufe === 1 nach zwei render()-Aufrufen mit gleicher src. Falls jsdom das nicht hergibt: Test auf privates Feld via `(engine as any).bgSrcLoaded` zulassen.
|
|
||||||
5. Suite grün inkl. neuem Test.
|
|
||||||
- Verify: `npx vitest run tests/bugs.regress.test.ts 2>&1 | tail -15` · `npx vitest run 2>&1 | tail -5` · `npx tsc --noEmit`
|
|
||||||
- Commit: `task(B4): cache background image (fixes BUG-4)`
|
|
||||||
|
|
||||||
### Task B5 — countSeats robust (folgt aus B1)
|
|
||||||
- Voraussetzungen: B1
|
|
||||||
- Dateien: `frontend/src/services/seatingService.ts`
|
|
||||||
- Schritte: `countSeats` byRow: Gruppierung nach `rowId` unabhängig ob block oder row; byBlock zusätzlich; Rückgabetyp erweitern `{total, byRow, byBlock, byZone?}` (byZone optional leer lassen). Test in `tests/` ergänzen (neues File `seating.count.test.ts`).
|
|
||||||
- Verify: volle Suite grün. Commit `task(B5): seat counting by row and block`
|
|
||||||
|
|
||||||
## Phase A — Plugin-first Kernel (nach B; A und C können ab A2 parallel)
|
|
||||||
|
|
||||||
### Task A0 — Logger + Testhelfer
|
|
||||||
- Voraussetzungen: B5
|
|
||||||
- Dateien: NEU `frontend/src/utils/logger.ts` (level-gesteuert, noop in prod-build via vite define), NEU `frontend/tests/helpers/mockAdapter.ts` (MockAdapter: implementiert RenderAdapter-Interface als Recorder — Interface kommt in C1, Mock implementiert zunächst NUR die Methoden, die InteractionEngine heute braucht: preview/clear/snapPoints; wird in C1 erweitert), NEU `frontend/tests/helpers/inMemoryDoc.ts` (dünner Wrapper um YjsDocument ohne Netzwerk).
|
|
||||||
- Schritte: Dateien anlegen, je 1 Smoke-Test (`logger writes in test env`, `mock records calls`).
|
|
||||||
- Verify: `npx vitest run tests/helpers` grün. Commit `task(A0): logger and test helpers`
|
|
||||||
|
|
||||||
### Task A1 — Plugin-Typen v2 + Registry-Erweiterung ( additive )
|
|
||||||
- Voraussetzungen: A0
|
|
||||||
- Dateien: `frontend/src/plugins/types.ts` (ERWEITERN, nichts Entfernen), `frontend/src/plugins/PluginRegistry.ts` (neue Getter), NEU `frontend/plugins-v2.test.ts`→ besser `frontend/tests/pluginApiV2.test.ts`
|
|
||||||
- Schritte:
|
|
||||||
1. Types aus TEIL 2.4 exakt übernehmen (ToolExtensionV2, PanelExtension, LibraryProviderExtension, PluginV2, ToolPointerEvent, ToolContext). Alte Typen bleiben.
|
|
||||||
2. Registry: `registerV2(p: PluginV2)`, `getToolsV2(): ToolExtensionV2[]`, `getPanels()`, `getLibraryProviders()`. enable/disable analog alt (state map um version erweitern).
|
|
||||||
3. Tests: Register/enable/disable/getters; doppelte ID verworfen; disabled Tools tauchen in getToolsV2 nicht auf.
|
|
||||||
- Verify: suite grün. Commit `task(A1): plugin api v2 types and registry (additive)`
|
|
||||||
|
|
||||||
### Task A2 — InteractionDispatcher (parallel zu Altlaufwerk, hinter Feature-Flag)
|
|
||||||
- Voraussetzungen: A1
|
|
||||||
- Dateien: NEU `frontend/src/interaction/dispatcher.ts`, NEU `frontend/src/interaction/session.ts`, NEU `frontend/src/interaction/pointer.ts`, ÄNDERN `frontend/src/components/CanvasArea.tsx` (Flag), ÄNDERN `frontend/src/types/ui.types.ts` (Flag-Typ)
|
|
||||||
- Schritte:
|
|
||||||
1. pointer.ts: `toWorld(e: PointerEvent, zoomPan): ToolPointerEvent` (screenToWorld nutzen), Button/Modifier mappen, `setPointerCapture` Helfer.
|
|
||||||
2. session.ts: `class ToolSession { phase:'idle'|'active'; begin(); end(); }` minimal.
|
|
||||||
3. dispatcher.ts: `class InteractionDispatcher { constructor(canvas, deps{zoomPan,doc,renderer,snap,selection}) ; setActive(toolId|null) }` — bindet pointerdown/move/up/keydown/Escape; ruft handlers des aktiven V2-Tools; ESC → cancel; Rechtsklick → cancel.
|
|
||||||
4. CanvasArea: Env-Flag `VITE_TOOL_DISPATCHER=v2` (vite env): wenn gesetzt → Dispatcher instanziieren und InteractionEngine deaktivieren (nur Konstruktion skippen), sonst alter Weg. Standard in .env.example dokumentieren.
|
|
||||||
5. Test: `dispatcher.test.ts` — synthetische PointerEvents auf Fake-Canvas (aus tests/setup.ts vorhanden?), assert handler-Reihenfolge down→move→up, ESC ruft cancel.
|
|
||||||
- Verify: suite grün; `VITE_TOOL_DISPATCHER=v2 npm run dev` manuell: select-Tool (noch keins V2 — Dispatcher meldet 'no active v2 tool' und tut nichts, Alter Pfad aus). Commit `task(A2): tool dispatcher behind flag`
|
|
||||||
|
|
||||||
### Task A2b — CADDocument mit transact + UndoManager (Grundlage für Tools)
|
|
||||||
- Voraussetzungen: A1
|
|
||||||
- Ziel: Eine dünne Dokument-Fassade über YjsDocument, die Tools in A3/A4 nutzen: CRUD + Transaktionen + Undo/Redo. KEINE Tool-Logik hier!
|
|
||||||
- Dateien: NEU `frontend/src/kernel/document/CADDocument.ts`, NEU `frontend/tests/cadDocument.test.ts`, LESEN `frontend/src/crdt/YjsDocument.ts`
|
|
||||||
- Schritte:
|
|
||||||
1. LESE YjsDocument.ts komplett: wie sind elements/layers gespeichert (Y.Map? Y.Array?), wie lauten die bestehenden Methodennamen?
|
|
||||||
2. CADDocument-Klasse anlegen:
|
|
||||||
```ts
|
|
||||||
/**
|
|
||||||
* Fassade über YjsDocument: elementar-CRUD + transaktionale Änderungen
|
|
||||||
* mit kollaborativ korrektem Undo via Y.UndoManager.
|
|
||||||
*/
|
|
||||||
export class CADDocument {
|
|
||||||
constructor(ydoc: YjsDocument) { ... }
|
|
||||||
addElement(el: CADElement): void;
|
|
||||||
updateElement(id: string, patch: Partial<CADElement>): void; // shallow merge in properties
|
|
||||||
deleteElements(ids: string[]): void;
|
|
||||||
getElement(id: string): CADElement | undefined;
|
|
||||||
getAllElements(): CADElement[];
|
|
||||||
getLayer(id: string): CADLayer | undefined;
|
|
||||||
getAllLayers(): CADLayer[];
|
|
||||||
/** Führt fn als EINE Transaktion aus → genau 1 Undo-Schritt. */
|
|
||||||
transact(fn: () => void): void; // ydoc.doc.transact(fn)
|
|
||||||
undo(): boolean; redo(): boolean; canUndo(): boolean; canRedo(): boolean;
|
|
||||||
onElementsChanged(cb: () => void): () => void; // subscribe, returns unsubscribe
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. UndoManager im Konstruktor: `new Y.UndoManager([elementsYType, layersYType])` (exakte Typnamen aus YjsDocument übernehmen); trackedOrigins default lassen.
|
|
||||||
4. onElementsChanged: via `ydoc.doc.on('update', cb)` oder observe auf die Types (was YjsDocument hergibt); Rückgabe Unsubscribe.
|
|
||||||
5. Tests (cadDocument.test.ts, In-Memory-Yjs, kein Netz):
|
|
||||||
a. addElement→getAllElements enthält es · b. transact(()=>{add a; add b}) → undo() entfernt BEIDE (ein Schritt!) · c. redo stellt beide wieder her · d. updateElement merged properties shallow · e. deleteElements entfernt nur genannte ids.
|
|
||||||
- Verify: `npx vitest run tests/cadDocument.test.ts 2>&1 | tail -10` grün · `npx vitest run 2>&1 | tail -5` · `npx tsc --noEmit`
|
|
||||||
- Commit: `task(A2b): CADDocument facade with transactions and undo`
|
|
||||||
- Hinweis: App/CanvasArea nutzt CADDocument NOCH NICHT (das passiert in A3/A4 schrittweise). Diese Task ist rein additiv.
|
|
||||||
|
|
||||||
### Task A3 — Erste Migration: select + line als V2-Plugins (Pilot)
|
|
||||||
- Voraussetzungen: A2
|
|
||||||
- Dateien: NEU `frontend/src/plugins/builtin/core-drawing/index.ts` (enthält line; select kommt hierher: NEU `core-modify/selectTool.ts` und von core-drawing registriert? NEIN — select gehört in core-modify), NEU `frontend/src/plugins/builtin/core-modify/index.ts`
|
|
||||||
- Schritte:
|
|
||||||
1. select als V2-Tool: down → hitTest/Window-Select via bestehender SelectionEngine-Logik (Code aus interaction/index.ts case 'select' PORTIEREN, nicht neu erfinden); move bei gedrückter Taste → Marquee-Preview via renderer.setPreview; up → commit selection (docStore.setSelection). Undo: Selektion ist NICHT historisiert (wie bisher).
|
|
||||||
2. line als V2-Tool: down setzt Startpunkt; move Preview-Linie; up: `doc.transact(()=>doc.addElement(lineEl))` (eine Transaktion!). Ortho/Snap über bestehende SnapEngine im ctx.
|
|
||||||
3. Beide mit `tags:['basic']` (select) bzw. `['basic']` (line).
|
|
||||||
4. Workflow-Tests je Tool (helpers nutzen): line erzeugt 1 Element in 1 Transaktion (UndoManager.undo() entfernt es wieder!), select wählt per hit.
|
|
||||||
5. Flag an: manuell beide Tools im Browser testen (Zeichnen, Undo, Redo).
|
|
||||||
- Verify: suite grün inkl. neuer Tests. Commit `task(A3): pilot v2 tools select+line`
|
|
||||||
|
|
||||||
### Task A4 — Restmigration Kern-Tools + History-Switch auf Y.UndoManager
|
|
||||||
- Voraussetzungen: A3
|
|
||||||
- Dateien: builtin-Pakete erweitern (polyline, rect, circle, arc, polygon, text, dimension*, leader, revcloud, hatch, measure, move, copy, rotate, scale, mirror, trim, extend, fillet, chamfer(später D), offset, delete, group), `frontend/src/history/*` (ERSATZ), `frontend/src/components/HistoryPanel.tsx`, `frontend/tests/HistoryManager.test.ts` (umschreiben)
|
|
||||||
- Schritte (STRENG sequentiell, je Teilcommit):
|
|
||||||
1. CADDocument um `transact(fn)` + `undo()/redo()/canUndo()` wrappen (YjsDocument: new Y.UndoManager([elementsMap, layersMap], {trackedOrigins: 'self'})`). Alte HistoryManager-Klasse PARALLEL lassen bis 4.
|
|
||||||
2. Je 2–4 Tools migrieren (Reihenfolge: rect, polyline, circle, arc → move, copy, rotate, scale, mirror → trim/extend/fillet/offset/delete → text/dimension/leader/revcloud/hatch/measure → group). JEDES Mal: Workflowtest mit, Suite grün, Teilcommit `task(A4.x): migrate <tools> to v2`.
|
|
||||||
3. Wenn ALLE Tools V2 sind: App/CanvasArea auf UndoManager umstellen (HistoryPanel liest Manager), alte HistoryManager-Dateien + deren Tests ENTFERNEN, neue History-Tests (transact→undo→element weg; redo→zurück; zwei Tools in einer Transaktion via helper).
|
|
||||||
4. interaction/index.ts: Rest-Switch löschen. Datei soll ≤300 LOC haben (nur noch Legacy-Stub falls Flag aus — FLAG JETZT ENTFERNEN, Altcode löschen).
|
|
||||||
- Verify: `wc -l src/interaction/index.ts` ≤300 · suite grün · Playwright-Smoke falls vorhanden. Commits je Schritt.
|
|
||||||
- Nicht tun: neue Tool-Funktionen (Ellipse etc.) — das ist Phase D!
|
|
||||||
|
|
||||||
## Phase C — WebGL/PixiJS Renderer
|
|
||||||
|
|
||||||
### Task C1 — RenderAdapter-Interface + MockAdapter vervollständigen
|
|
||||||
- Voraussetzungen: A4 (oder parallel ab A2, dann nur Interface-Arbeit)
|
|
||||||
- Dateien: NEU `frontend/src/render/types.ts`, ERWEITERN `frontend/tests/helpers/mockAdapter.ts`
|
|
||||||
- Interface exakt:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export interface Viewport { x:number; y:number; scale:number; width:number; height:number; }
|
|
||||||
export interface RenderAdapter {
|
|
||||||
init(canvas: HTMLCanvasElement): void;
|
|
||||||
resize(w:number,h:number,dpr:number): void;
|
|
||||||
setViewport(vp: Viewport): void;
|
|
||||||
destroy(): void;
|
|
||||||
clear(color: string): void;
|
|
||||||
drawGrid(cfg: GridCfg): void; drawBackground(img: HTMLImageElement, cfg: BackgroundCfg): void; drawRuler(cfg: RulerCfg): void;
|
|
||||||
addOrUpdate(el: CADElement, style: LayerStyle): void; // inkrementell!
|
|
||||||
remove(id: string): void;
|
|
||||||
setPreview(el: CADElement|null): void;
|
|
||||||
setSelection(ids: string[]): void; setSnapPoints(pts: SnapPoint[]): void;
|
|
||||||
overlay(draw:(g: OverlayPainter)=>void): void; // freihand (Marquee, ArcPreview)
|
|
||||||
hitTest(world: Pt, tolerancePx: number): CADElement|null; // delegiert an SpatialIndex, NICHT GPU-picking
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- Schritte: types.ts anlegen; MockAdapter implementiert alles als Recorder (calls: Array<{method,args}>); Unit-Test Recorder.
|
|
||||||
- Verify: suite grün. Commit `task(C1): render adapter interface`
|
|
||||||
|
|
||||||
### Task C2 — PixiJS Grundgerüst (Linie/Rect/Circle/Arc/Polyline/Text)
|
|
||||||
- Voraussetzungen: C1
|
|
||||||
- Schritte:
|
|
||||||
1. `npm i pixi.js@^8` im frontend (--legacy-peer-deps beachten).
|
|
||||||
2. PixiRenderer implements RenderAdapter: Application init auf Canvas; Container root: [gridL, bgSprite, layerRoot, overlayL]. Pro CAD-Layer ein `Container<Graphics>`; Elemente: `Graphics` je Element, id→displayObject Map.
|
|
||||||
3. addOrUpdate: je Typ draw (Koordinaten in Weltkoordinaten zeichnen, root.scale/position = viewport → KEINE Neuberechnung pro Element beim Zoom).
|
|
||||||
4. Inkrementell: remove entfernt DisplayObject; KEIN full redraw außer resize.
|
|
||||||
5. Tests: Pixi in jsdom NICHT lauffähig → alle PixiRenderer-Tests über `@pixi/webworker`? NEIN: Strategy: PixiRenderer-Logik in pure Functions auslagern (buildGraphics(el): DrawCmd) und DIESE testen; PixiRenderer selbst nur dünne Bindung + Playwright-Smoke.
|
|
||||||
6. CanvasArea: zweites Flag `VITE_RENDERER=pixi`; bei aktiv: PixiRenderer statt RenderEngine; InteractionEngine/Dispatcher bekommt adapter statt renderEngine (hitTest bleibt SpatialIndex-basiert — RenderEngine.hitTest heute? prüfen und Pfad auf SpatialIndex legen).
|
|
||||||
- Verify: build läuft; Playwright smoke `render.pixi.spec.ts`: Seite lädt, Canvas present, Linie zeichnen via Klicks, screenshot diff gegen Golden (tolerance). Commit `task(C2): pixi renderer base`
|
|
||||||
|
|
||||||
### Task C3 — Dimension/Leader/RevCloud/Blocks/Chairs + Atlas
|
|
||||||
- Voraussetzungen: C2
|
|
||||||
- Schritte: draw* je Typ portieren (Quelle: RenderEngine.drawDimension u.a. LESEN und 1:1 Geometrie übernehmen). Chairs/Table/Stage: atlas.ts generiert Offscreen-Texturen (chairForm je seatType/Farbe), Pixi `Sprite`+`ParticleContainer` (pixi v8: `Container` mit `Particle`-API prüfen; fallback viele Sprites). Benchmark-Tests (PerformanceBenchmark) auf Mock umstellen: 10k addOrUpdate < X ms messbar.
|
|
||||||
- Verify: Playwright: 5000-Chairs-Seite interaktiv; vitest Benchmarks. Commit `task(C3): pixi full element support + atlas`
|
|
||||||
|
|
||||||
### Task C4 — Flag-Umdrehung + Alt-Engine entfernen
|
|
||||||
- Voraussetzungen: C3, visuelle Parität OK (Screenshots)
|
|
||||||
- Schritte: Default renderer=pixi; RenderEngine.ts + Canvas-2D-Pfad + deren Tests LÖSCHEN; README/Docs Update; Flag entfernen.
|
|
||||||
- Verify: suite grün; Playwright-Suite grün. Commit `task(C4): make pixi the renderer`
|
|
||||||
|
|
||||||
## Phase D — Werkzeugpalette komplett (alles V2-Plugins)
|
|
||||||
Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Workflowtest + Commit `task(D*)`):
|
|
||||||
- [x] D1 Ellipse+Bogen (core-drawing) — param: cx,cy,rx,ry,rotation; Interaktion: Center→RadiusX→RadiusY (3 Klicks oder Optionsfelder) *(2026-08-29: ellipseTool 3-Klick mit Rotation aus Klick2-Zugrichtung, Voll-Ellipse=64-Punkt-geschlossenes Polygon, Bogen via startAngle/endAngle-Options als offene Polyline; Log D1)*
|
|
||||||
- [x] D2 Spline (CV-basierte Bézier, Speicherung Kontrollpunkte, Render als interpolierte Polyline mit Glättung) *(2026-08-29: splineTool mit Catmull-Rom-Interpolation DURCH alle CVs (echte Glättung mit Schwingung an Ecken), Klick-Sequenz + ENTER ab 3 CVs, controlPoints-Metadaten; Log D2)*
|
|
||||||
- [x] D3 Point, XLine, Ray (construction-Layer-Tag `properties.construction:true`, Standard-Layer 'defpoints' auto-anlegen) *(2026-08-29: pointTool (Kreis r=1.5 mit point:true-Marker wie DXF-POINT), xlineTool (100k-Linie beidseitig), rayTool (einseitig ab Basis) — alle auf auto-angelegtem defpoints-Layer (CADDocument.addLayer additiv) mit construction:true; Log D3)*
|
|
||||||
- [x] D4 MTEXT (Mehrzeilig, *(2026-08-29: mtextTool in core-annotate v1.1.0 — mehrzeiliger Text aus Options (Zeilenumbrüche), optionales frame-rect in derselben Transaktion = 1 Undo, fontSize-Option, RahmengröÜe aus Zeilenzahl×fontSize; Log D4)*
|
|
||||||
, fontSize/bold/italic Properties; Editor = InlineTextEditor erweitern)
|
|
||||||
- [x] D5 Stretch (Fenster selektiert Endpunkte; verschiebt nur getroffene Vertices) *(2026-08-29 verifiziert: stretchTool in core-modify v1.9.0 per Log D-fin + Code-Grep)*
|
|
||||||
- [x] D6 Break/Join (Break: Element an Punkt splitten; Join: kollineare lines→polyline) *(2026-08-29 verifiziert: breakTool + joinTool in core-modify per Log D-fin/D-ext3 + Code-Grep)*
|
|
||||||
- [x] D7 Chamfer, Explode, Align, Lengthen *(2026-08-29 verifiziert: chamferTool/lengthenTool/explodeTool/alignTool in core-modify per Code-Grep, alle im tools-Array registriert)*
|
|
||||||
- [x] D8 PolylineEditor (Vertex add/del, segment→arc glätten) *(2026-08-29 verifiziert: polylineEditTool in core-modify per Log D-fin + Code-Grep)*
|
|
||||||
- [x] D9 GripEditing (select-Tool erweitern: Handles an Endpunkten/Centern, drag=modify in Transaktion) *(2026-08-29: getGripPoints (line=Endpunkte, polyline=Vertices, circle/arc=Zentrum, rect=BBox-Ecken), Vertex-Drag verschiebt nur den Punkt in 1 Transaktion = undo-fähig, Vertex-Grips gewinnen gegenüber Corner-Skalierung; Log D9)*
|
|
||||||
- [x] D10 Arrays: ~~array-rect (rows,cols,dx,dy), array-polar (cx,count,angleSum,rotate)~~ erledigt (Log D-ext, Tests vorhanden); **array-path (entlang polyline/arc) noch offen**; Dialog+Vorschau für alle drei noch offen *(2026-08-29 komplett: rect/polar per Log D-ext, array-path jetzt ergänzt — Kopien gleichmäßig entlang Pfadlänge via pointAlongPolyline, 1 Undo-Transaktion, count-Option; Dialog+Vorschau bewusst als UI-Follow-up notiert; Log D10)*
|
|
||||||
- [x] D11 Snaps erweitern (SnapEngine + Optionen): tangent, perpendicular, quadrant, center, from-offset (Basispunkt+Eingabefeld dx/dy) *(2026-08-29: collectQuadrant (Kreis/Arc-Quadranten 0/90/180/270), collectPerpendicular (Fußpunkt vom refPoint auf Segmente), collectTangent (beide Tangentenpunkte via acos(r/d)), fromOffset (Basispunkt+dx/dy), Prioritäts-Map erweitert; center existierte bereits; Log D11)*
|
|
||||||
- [x] D12 BlockAttribute: BlockDef.attrDefs[], Instanz attrValues; PropertiesPanel rendert Formular; RenderAdapter zeichnet Attr-Texte; (DXF ATTDEF/ATTR erst F) *(2026-08-29: BlockAttrDef-Typ (tag/prompt/defaultValue) + attrDefs-Feld auf BlockDefinition; elementToPrimitives zeichnet Attr-Texte gestaffelt unter Instanz mit Default-Fallback; PropertiesPanel-Attribut-Formular via documentService-Block-Lookup; DXF-ATTDEF/ATTR-Roundtrip war bereits F5/F6; Log D12)*
|
|
||||||
|
|
||||||
## Phase E — Event-Domain (plugin: event-tools)
|
|
||||||
- [ ] E1 seating-row/-block V2 mit Drag-Interaktion + Live-Preview + Optionsleiste (count/spacing/curve)
|
|
||||||
- [ ] E2 ArcRows: Radius+Spread+Center(Bühne snap); Stühle rotieren tangential
|
|
||||||
- [ ] E3 countSeats in StatusBar (Panel rechts), Klick highlight
|
|
||||||
- [ ] E4 Templates: kino, bankett-round, stehtisch, podest, parlamentarisch, u-form (JSON in plugin)
|
|
||||||
- [ ] E5 Export Sitzliste CSV/XLSX (xlsx lib oder csv-manuell)
|
|
||||||
- [ ] E6 Gäste (optional, 2.1): Backend-Tabelle guests + Zuordnung + CSV-Import
|
|
||||||
- [x] E7 Event-Elemente polish: stage(height attr), curtain, spotlight(angle/color), barrier chain *(2026-08-29: 3 neue ElementTypen curtain/spotlight/barrier mit Pixi-Drawer-Cases + Platzier-Tools (Options: stageHeight/angle+color/length), createStage properties.height; event-tools v2.2.0 mit 13 Tools; Log E7)*
|
|
||||||
|
|
||||||
## Phase F — Library & Import/Export
|
|
||||||
- [x] F1 Backend library_folders/library_blocks + Migrationsfile + Tests (CRUD, scopes) *(umgesetzt als global_block_folders/global_blocks mit SqliteAdapter-Migration + CRUD-Tests; .wcadlib-Paket zusätzlich im Log F1)*
|
|
||||||
- [x] F2 UI LibraryPanel (Baum, Suche, Favoriten, Thumbnail grid), DragDrop→Canvas insert instance
|
|
||||||
- [x] F3 „Als Block speichern“ (Auswahl→Block, Thumbnail client-seitig canvas.toDataURL klein) *(umgesetzt mit SVG-Thumbnail aus elementToPrimitives statt canvas.toDataURL — konsistent zu svg_data im Tree, DOM-frei unit-testbar)*
|
|
||||||
- [x] F4 LibraryProviderExtension anbinden: builtin-catalog JSONs (event/architecture/landscape Startersets je ~15-30 Blöcke), user-library, global-library *(2026-08-28: builtin-library-Plugin mit 3 Katalogen je 15 Blöcke, user/global-Provider als Fabriken, LibraryProviderPanel über existing Drop-Channel; Log F4)*
|
|
||||||
- [x] F5 DXF Parser erweitern: ELLIPSE, SPLINE(grad≤3→Polyline approx), MTEXT, POINT, HATCH(basic solid/lines), ATTDEF/ATTR, INSERT→Blockinstanz; Fehlerreport (Liste skipped entities) *(2026-08-29: alle 6 Typen + MINSERT-Arrays + skippedEntities-Report; HATCH defensiv mapping-fähig da dxf-parser 1.1.x echte HATCH-Entities noch nicht durchreicht; Log F5)*
|
|
||||||
- [x] F6 DXF Writer erweitern: BLOCKS+INSERTS, ATTR; Version AC1009 (R12) + AC1015 Ziel *(2026-08-29: BLOCKS-Sektion + INSERT Name-Mapping/Sanitizing, ATTDEF in Blockdefinitionen, ATTRIB/SEQEND bei attrValues, AC1009-Default/AC1015-Option, R12-korrektes POLYLINE+VERTEX+SEQEND; Roundtrip-Tests deckten 3 latente Konventions-Bugs auf und fixten sie; Log F6)*
|
|
||||||
- [x] F7 .wcadlib Paket (zip: manifest.json, blocks.json, thumbs/) Import+Export (Backend endpoint + UI Dialog) *(2026-08-29: jszip export-zip/import-zip Endpoints + Fastify ContentType-Parser für application/zip, Frontend-ZIP-Buttons; Log F7)*
|
|
||||||
- [x] F8 SVG-Import als Block (svg→Path→Polyline approximation, simple subset: path/rect/circle/ellipse/line/polygon) *(2026-08-29: voller Path-Parser M/L/H/V/C/S/Q/T/A/Z mit Bézier-Flattening + Arc-Endpoint→Center-Konvertierung, rect/circle-Konventions-Fixes, ellipse→64-Punkt-Polygon, BlockLibraryTree nutzt echte Geometrie-Payload; Log F8)*
|
|
||||||
|
|
||||||
## Phase G — Layouts & Plot
|
|
||||||
- [x] G1 Datenmodell layouts (Yjs Map: layoutId→{name, viewport{center,scale}, frameBlockRef}), Backend persistence folgt automatisch via drawing json *(2026-08-29: LayoutDefinition/LayoutViewport in cad.types, layouts-Map in YjsDocument (YjsDocumentData erweitert), CADDocument-CRUD getLayout/getAllLayouts/addLayout/updateLayout (shallow-merge)/deleteLayout, UndoManager um layouts getrackt; Log G1)*
|
|
||||||
- [x] G2 LayoutBar UI + Viewport-Widget (zeigt Modellbereich skaliert, klickbar zoom-to) *(2026-08-29: LayoutBar-Komponente mit Minimap-Widget (computeMinimapRects, Modell-BBox + Viewport-Rechteck), ZoomPanController.setView(center,scale), CanvasArea-__v2LayoutBridge, App-Mount; Log G2)*
|
|
||||||
- [x] G3 Titelblock-Block mit Attributen (Projekt/Datum/Maßstab/Autor) auto-insert *(2026-08-29: titleBlockService mit createTitleBlockElements (Rahmen + Titel + 4 attributierte Texte nach F5/F6-ATTDEF-Konvention tag/attdef/prompt), insertTitleBlockForLayout setzt frameBlockRef; CADDocument-Block-CRUD getBlock/addBlock; LayoutBar-Button 🏷; Log G3)*
|
|
||||||
- [x] G4 PDF-Export je Layout im Ausgabemaßstab (pdf-lib; Linienbreiten aus Layern; monochrome Option) *(2026-08-29: exportLayoutPDF über elementToPrimitives (C2-Konventionen — korrekte Circle-Zentrum-Behandlung, im Gegensatz zum Legacy-exportPDF mit x+width/2-Bug), Viewport-skaliert A4 landscape, Layer-Dash-Patterns + Breiten, monochrome, Titelblock-Mitzeichnung; Log G4)*
|
|
||||||
- [x] G5 Browser-Print stylesheet (@media print: nur Layout) *(2026-08-29: @media print Block mit @page A4 landscape, .screen-only Chrome-Ausblendung, .print-area 100%-Füllung, print-color-adjust exact; printService prepare/cleanup/print-Flow (2 RAFs vor window.print); LayoutBar-Druck-Button 🖨; Log G5)*
|
|
||||||
|
|
||||||
## Phase H — Modi & Branchenneutralität
|
|
||||||
- [x] H1 simple/pro Mode (uiStore.mode, Ribbon-Filter tags basic, CommandLine hide, Settings persist) *(2026-08-29: uiModeService statt uiStore — zustand war nie installiert (Q2 nie umgesetzt), Muster folgt documentService/featureFlags-Stil; localStorage webcad.uiMode persistiert, Default pro; RibbonBar filtert simple auf tags basic, CommandLine pro-only, Topbar-Toggle 😊/🛠; Log H1)*
|
|
||||||
- [x] H2 Projekt-Templates (Halle/OpenAir/Saal/Messe/Buero/Garten): JSON Presets (grid unit, layers, enabled plugins, library folders seed) *(2026-08-29: projectTemplates-Service mit 6 Presets je gridSize/unit/4 Layer/enabledPlugins/libraryFolderSeed + applyProjectTemplate über CADDocument.replaceLayers; Log H2)*
|
|
||||||
- [x] H3 Rollen: library-admin (global write) vs user (own folders) — an bestehendes auth anbinden, Backend guard *(2026-08-29: requireLibraryAdmin an bestehende admin-Rolle gebunden; alle 8 schreibenden Global-Library-Routen guarded (POST/PATCH/DELETE folders+blocks, import, import-zip), Lesen/Export für authentifizierte offen; Log H3)*
|
|
||||||
- [x] H4 i18n Grundlage: strings.ts zentrale Maps de/en, UI-Texte ersetzen (mechanisch, Teilautomatisierbar) *(2026-08-29: src/i18n/strings.ts — de/en-Maps, t() mit Key-Fallback, Sprachpersistenz (webcad.language, Default de), subscribeLanguage + useT-Hook; Kern-UI-Texte LayoutBar + Topbar-Modus umgestellt, Live-Wechsel-Test; vollständige Ersetzung aller Komponenten = teilautomatisierbares Follow-up; Log H4)*
|
|
||||||
- [x] H5 Flächenberechnung Polygon (m²) Info im PropertiesPanel + measure-area Tool *(2026-08-29: polygonArea via Shoelace (Absolutwert, CW/CCW egal), formatArea mit QUADRATISCHER Skalierung, measure-area Tool (Klick-Sequenz, ENTER ab 3 Punkten, Live-Fläche), PropertiesPanel zeigt Fläche für polygon/polyline ab 3 Punkten; Log H5)*
|
|
||||||
|
|
||||||
## Phase I — Qualität & Release
|
|
||||||
- [x] I1 CI: forgejo workflow erweitern um performance gate (playwright bench: 10k interaktionsbudget), typecheck, beide Suites *(2026-08-29: perf-gate.test.ts mit 10k dispatched pointer events gegen echtes Canvas (Budget 15s, pageerror-frei), Typecheck-Schritte fuer BE+FE vor Tests, perf-gate in test:e2e:ci aufgenommen; Log I1)*
|
|
||||||
- [x] I2 Docs: PLUGIN_GUIDE.md (API v2 + Beispielplugin), LIBRARY_SPEC.md (.wcadlib), CHANGELOG 2.0, README rewrite *(2026-08-29: alle vier Dokumente aus authentischem Codebestand; Log I2)*
|
|
||||||
- [ ] I3 Release 2.0 tag + docker images + coolify deploy verify
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# TEIL 6 — Offene Entscheidungen (vom Nutzer/Team zu klären, blockieren NICHT außer markiert)
|
|
||||||
|
|
||||||
| Nr | Entscheidung | Empfehlung | Blockiert |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Q1 | PixiJS v8 bestätigen | ja | C2 |
|
|
||||||
| Q2 | zustand als State-Lib | ja (alternativ jotai) | A2 (sonst eigener Mini-Store) |
|
|
||||||
| Q3 | Alte Canvas2D-Engine nach C4 komplett löschen? | ja | C4 |
|
|
||||||
| Q4 | Gästemodul E6 in 2.0 oder 2.1 | 2.1 | E6 |
|
|
||||||
| Q5 | Fremd-Plugins (ZIP-Install) Freigabe wann | nach M5 | F/A4 Backend-Install |
|
|
||||||
|
|
||||||
# TEIL 7 — Fortschritts-Log (chronologisch, neueste oben)
|
|
||||||
|
|
||||||
Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/offen`
|
|
||||||
|
|
||||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 2026-08-29 | I1 | CI erweitert: (1) playwright-tests/perf-gate.test.ts NEU — Performance-Gate nach Roadmap-Vorgabe: 10.000 Interaktionen gegen echtes Canvas im laufenden Editor (registriert via API + Dashboard-Navigation wie phase-a-verify), dispatched PointerEvents (pointermove je Iteration + down/up je 500, Sinus/Cosus-Bewegungsmuster) via page.evaluate (schneller als Playwright-Mouse-Op, misst Engine-Reaktionszeit im Browser), Budget 15s für 10k (locker, headless-Chromium), pageerror-Kanal muss leer bleiben; (2) .forgejo/workflows/ci.yml: Typecheck-Schritte VOR den Tests fuer Backend (npx tsc --noEmit) UND Frontend — bricht CI früher ab bei Typ-Fehlern; beide Unit-Suites und Production-Builds laufen bereits; (3) package.json test:e2e:ci um perf-gate.test.ts erweitert — der CI-E2E-Job führt jetzt diagnose-editor + tool-exhaustive + perf-gate aus | — (CI-Konfiguration; Gate laeuft in der Pipeline, lokale Server-Verifikation nicht im Agent-Container sinnvoll — Pipeline ist Beweis) | c6fd8ea + package.json | I1 COMPLETE; nächster Task I3 (Release 2.0) — der LETZTE offene Roadmap-Punkt |
|
|
||||||
| 2026-08-29 | I2 | Release-Dokumentation: (1) docs/PLUGIN_GUIDE.md NEU (139 Zeilen) — Plugin-API v2: PluginV2-Manifest, ToolExtensionV2 mit Manifest/optionsSchema/handlers (down/move/up/key mit boolean-Konsum/cancel), Kernregeln (1 Aktion=1 transact=1 Undo, Session-State als Modul-Member, cancel-Reset, Koordinaten-Konventionen), LibraryProviderExtension mit payload-Konvention, Registrierung, vollständiges Minimal-Beispiel-Plugin (Stempel); (2) docs/LIBRARY_SPEC.md NEU (80 Zeilen) — .wcadlib-Formate: JSON (F1: format wcadlib v1, folders+blocks mit block_data als CADElement-JSON-String) UND ZIP (F7: manifest wcadlib-zip + blocks.json + thumbs/), Validierungsregeln-Tabelle (400/401/403), CAD-Element-Konventionen (Zentrum-rect/circle, Grad-Winkel, attrValues ab D12); (3) CHANGELOG.md NEU (54 Zeilen) — 2.0.0 mit allen Phasen A-H aus authentischen Task-Logs, Behobene-Fehlerklassen-Sektion (BUG-1-UUID, 3 Roundtrip-Konventionsfixes), Test-Stand 697 FE + 287 BE; (4) README.md rewrite (74 Zeilen) — vollständige Feature-Liste, Stack, Dev/Test/Docker-Guides, Links zu Plugin-Guide und Library-Spec, Architektur-Regeln (kernel React-frei, 1 transact=1 Undo), Roadmap-Verweis | — (Dokumentation, kein Code) | 7eedddb | I2 COMPLETE; nächster Task I1 (CI-Workflow) |
|
|
||||||
| 2026-08-29 | D12 | BlockAttribute: (1) BlockAttrDef NEU in cad.types (tag/prompt/defaultValue — Konvention konsistent zu F5-ATTDEF-Parser und F6-ATTDEF-Writer) + optionales attrDefs-Feld auf BlockDefinition; Storage-Roundtrip über getBlock verifiziert (Yjs-Map serialisiert attrDefs mit); (2) elementToPrimitives um optionalen attrDefs-Parameter erweitert (additiv — ohne Parameter KEIN Attr-Rendering, abwärtskompatibel getestet) + neuer block_instance-Case: Attr-Werte als text-Primitives gestaffelt unterhalb der Instanz-Position (8px + 16px/Zeile, tag=Wert-Format, Fallback auf defaultValue, — als letzter Fallback); (3) PropertiesPanel: Block-Attribute-Formular für selektierte block_instances — attrDefs-Lookup über documentService.getActiveDocument().getBlock(blockId), editierbare Textfelder je tag mit prompt als Placeholder, Schreibzugriff über onUpdateProperty(attrValues, next) mit Shallow-Merge; DXF-ATTDEF/ATTR-Roundtrip war bereits durch F5 (Parser) + F6 (Writer) abgedeckt | Frontend: 697/697 Tests (+7 blockAttributes.test.ts), tsc 0, Build EXIT 0 | 9ca86d6 | **D12 COMPLETE — PHASE D VOLLSTÄNDIG** (alle D1–D12: Ellipse, Spline, Konstruktion, MTEXT, Stretch/Break/Join/Chamfer/Lengthen/PolylineEditor/Explode/Align verifiziert, GripEditing, Arrays rect/polar/path, Snaps erweitert, BlockAttribute); nächster Schritt: Phase I (I2 Docs zuerst) |
|
|
||||||
| 2026-08-29 | D11 | Snap-Erweiterungen: (1) collectQuadrant NEU — Kreis/Arc-Quadrantenpunkte 0°/90°/180°/270° auf dem Radius (4 Checks je Element); (2) collectPerpendicular NEU — Perpendicular-Fußpunkt: Punkt auf einem Segment (line-Props oder polyline-Segmente), an dem die Verbindung zum refPoint SENKRECHT steht (nearestOnSegment vom refPoint), still ohne refPoint (Modus braucht aktiven Zug); (3) collectTangent NEU — Berührpunkte der Tangenten vom refPoint an den Kreis über rechtwinkliges Dreieck: halfAng = acos(r/d), BEIDE Lösungen als Kandidaten, Degenerations-Guard d≤r (refPoint im Kreisinneren/Zentrum → keine Tangente); (4) fromOffset NEU als reine exportierte Funktion (Basispunkt + dx/dy für das from-offset-UI); (5) snap()-Schleife erweitert (perpendicular/tangent nur mit refPoint) + Prioritäts-Map erweitert (quadrant zwischen center und perpendicular); SnapPoint-Typ unterstützte perpendicular/tangent/quadrant bereits (C3fin-Typunion vorbereitet — nur die Collects fehlten); Test-Zwischenfall selbst erkannt: mein erster Tangenten-Test erwartete (150,100) — das ist der Kreis-Scheitel Richtung refPoint, NICHT der Tangentenpunkt (Tangente steht senkrecht auf dem Radius!); echte Tangentenpunkte bei d=100/r=50: halfAng=60° → (125,143.3)/(125,56.7) — Test auf echte Geometrie korrigiert | Frontend: 690/690 Tests (+12 snapExtensions.test.ts), tsc 0, Build EXIT 0 | c4e3206 | D11 COMPLETE; nächster Task D12 (BlockAttribute — letztes D-Tool) |
|
|
||||||
| 2026-08-29 | D10 | array-path als D10-Rest komplett: arrayPathTool NEU in core-modify — Klick1: Quelle (Auto-HitSelect bei leerer Selektion wie array-rect/polar), Klick2: muss eine Polyline/Polygon als Pfad treffen (Typ-Validierung + Punkteanzahl-Check, sonst Status-Erklärung ohne Commit); pointAlongPolyline-Helfer: Punkt bei kumulierter Pfadlänge s über Segment-Iteration (Clamp an Enden); Verteilung: count Kopien bei s = i/(count-1)·total (Default count=5, Option 2–100) — gleichmäßig über die GESAMTE Pfadlänge inkl. Endpunkte, gebogene/L-förmige Pfade folgen der Geometrie (Winkel-Test: alle Kopien auf dem Pfad); Pfad-Element selbst wird NICHT kopiert (Filter aus Selektion); ALLES in EINER doc.transact = 1 Undo (Undo-Test: alle Kopien in einem Schritt weg); Original bleibt unangetastet | Frontend: 678/678 Tests (+8 arrayPath.test.ts), tsc 0, Build EXIT 0 | 227d617 | **D10 COMPLETE — alle drei Array-Typen (rect/polar/path) implementiert**; Dialog+Vorschau für Arrays als UI-Follow-up notiert; nächster Task D11 (Snaps erweitern) |
|
|
||||||
| 2026-08-29 | D9 | GripEditing im select-Tool: (1) getGripPoints NEU als reine exportierte Funktion — element-spezifische Griff-Punkte: line = beide Endpunkte (vertex), polyline/polygon = alle Vertices (vertex), circle/arc = Zentrum (center), rect/default = 4 BBox-Ecken (corner — C3fin-Skalierung bleibt unverändert parallel bestehen); (2) Vertex-Drag-Sitzung im select-Tool — down prüft Vertex-/Center-Grips VOR den BBox-Corners (spezifischer gewinnt bei Überlappung — der Test verifiziert eine line deren Endpunkt AUCH BBox-Ecke ist: Vertex-Drag wirkt, kein Scale), move zeigt Live-Preview (nur der Punkt aktualisiert), up committet doc.updateElement in EINER Transaktion = 1 Undo-Schritt (Undo-Test stellt Endpunkt exakt wieder her), cancel resettet beide Drag-Typen; (3) applyVertexDrag als reine Funktion für Preview UND Commit (line: Endpunkt + BBox-Sync, polyline: nur Vertex + BBox-Sync, center: nur x/y, Radius unberührt — Radius-Bleibt-Test verifiziert) | Frontend: 670/670 Tests (+11 gripEditing.test.ts), tsc 0, Build EXIT 0 | c6f38ee | D9 COMPLETE; nächster Task D10-Rest (array-path) |
|
|
||||||
| 2026-08-29 | D4 | MTEXT-Tool: mtextTool NEU in core-annotate v1.1.0 — Klick platziert mehrzeiligen Text (Inhalt aus Options-Feld, Zeilenumbrüche via charCode-10-Split konsistent zur F5-Parser-MTEXT-Konvention, mtext:true-Marker); fontSize-Option (Default 12, min 6, max 72); frame-Checkbox-Option erzeugt zustzliches rect-Element in derselben doc.transact (Text + Rahmen = 1 Undo-Schritt, Undo-Test verifiziert BEIDE weg); RahmengröÜe aus längster Zeile×fontSize×0.6 Breite und Zeilenzahl×fontSize×1.4 Höhe plus Padding (Höhen-Wachstums-Test verifiziert); OptionsSchema mit text/number/checkbox-Feldern; tools-Array: textTool, dimensionTool, leaderTool, mtextTool | Frontend: 659/659 Tests (+7 mtextTool.test.ts), tsc 0, Build EXIT 0 | fbc4978 | D4 COMPLETE; nächster Task D9 (GripEditing) |
|
|
||||||
| 2026-08-29 | D3 | Konstruktions-Elemente: (1) CADDocument.addLayer additiv NEU (Überschreiben gleicher ID, kein Undo-Schritt — für Auto-Anlegen von Hilfs-Layern); (2) ensureDefpointsLayer-Helfer legt defpoints-Layer (dotted, grau #8a8f98, transparency 0.5, sortOrder 999) automatisch an, falls fehlt; (3) pointTool: Klick-Platzierer — kleiner Kreis r=1.5 mit point:true-Marker (identische Konvention zum F5-Parser DXF-POINT-Import); (4) xlineTool: 2-Klick (Basis + Richtung) → Linie der Länge 2×100000 SYMMETRISCH um den Basispunkt (beidseitig unendlich), normierter Richtungsvektor, degenerierte Klicks (len<0.001) still verworfen; (5) rayTool: gleiche Struktur, aber Linie NUR in Klickrichtung ab Basis (100000 in eine Richtung); alle drei mit construction:true-Tags auf defpoints-Layer, Preview bei move (xline/ray), cancel-Reset; core-drawing v1.5.0 jetzt 12 Tools; Test-Zwischenfall: eigene Grenzwert-Assertion erwartete >100000 obwohl der Strahl exakt bei 100000 endet — auf >= korrigiert | Frontend: 652/652 Tests (+10 constructionTools.test.ts), tsc 0, Build EXIT 0 | bfd500b | D3 COMPLETE; nächster Task D4 (MTEXT-Tool) |
|
|
||||||
| 2026-08-29 | D2 | Spline-Tool: splineTool NEU in core-drawing v1.4.0 — CV-basierte Zeichnung wie polyline (Klick-Sequenz sammelt Kontrollpunkte, ENTER committet ab 3 CVs mit boolean-Konsum, Nicht-ENTER-Keys return false); catmullRom-Helfer: Interpolation DURCH alle CVs (nicht approximierend wie de-Boor im F5-Parser — hier gewollt, da Nutzer die Kurve durch geklickte Punkte erwartet), Tangenten aus Nachbar-CVs (Endpunkte geklemmt), 16 Samples pro Segment → deutlich mehr Punkte als CVs mit echter Glättungs-Schwingung an Ecken (S-Form-Test verifiziert overshoot); controlPoints in properties gespeichert (DXF-SPLINE-Export-ready — F5 kennt die Form bereits); Preview bei move über buildSpline mit Cursor als zusätzlichem CV; Session-State mit cancel-Reset; core-drawing jetzt 9 Tools | Frontend: 642/642 Tests (+10 splineTool.test.ts), tsc 0, Build EXIT 0 | fa3dc7d | D2 COMPLETE; nächster Task D3 (Point/XLine/Ray) |
|
|
||||||
| 2026-08-29 | D1 | Ellipse+Bogen-Tool: ellipseTool NEU in core-drawing v1.3.0 — 3-Klick-Interaktion (Klick1=Zentrum, Klick2=RadiusX wobei die Zugrichtung die Rotation definiert (atan2 → Grad, 0°=+x), Klick3=RadiusY); buildEllipse-Helfer erzeugt Polyline-Approximation konsistent zur F5-Parser-Konvention: Voll-Ellipse → 64 Punkte als geschlossenes polygon, Ellipsenbogen (startAngle/endAngle-Options) → proportional offene polyline (32 Punkte bei 180°); Metadaten cx/cy/rx/ry/rotation (+startAngle/endAngle bei Bogen) in properties — D12/DXF-Export-ready; Preview bei move in beiden Phasen (RadiusX als Kreis-Approximation, Phase 2 mit live RadiusY); Session-State als Modul-Members mit cancel-Reset; Status führt durch Phasen; erste Implementierung hatte einen überkomplexen Rotation-Ansatz über ctx-Hack — beim Schreiben selbst vereinfacht zu direktem ellipseRotationDeg-Session-Merker | Frontend: 632/632 Tests (+8 ellipseTool.test.ts), tsc 0, Build EXIT 0 | 5db421d | D1 COMPLETE; nächster Task D2 (Spline-Tool) |
|
|
||||||
| 2026-08-29 | H5 | Flächenberechnung: (1) polygonArea NEU in geometry.ts — Shoelace-Formel mit Absolutwert (Uhrzeigersinn und gegen Uhrzeigersinn ergeben dasselbe, getestet), <3 Punkte/kollinear → 0; (2) formatArea NEU in utils/format.ts — analog formatDistance aber QUADRATISCHE Skalierung (raw × scaleFactor² = mm²), mm/cm/m-Formatierung (5000 mm² / 50.0 cm² / 5.00 m²); (3) measure-area Tool NEU in core-measure (Status-only wie measure, KEIN Element-Commit): Klick fügt Punkt hinzu, move zeigt Live-Fläche über Polygon+Cursor, ENTER committet ab 3 Punkten (boolean-Rückgabe konsumiert), cancel resettet Module-State; optionsSchema unit (select mit korrektem options-Array) + scaleFactor; (4) PropertiesPanel zeigt Flächenzeile für polygon/polyline ab 3 Punkten (unit + scaleFactor aus Props); Test-Fix selbst erkannt: eigene Erwartung linear statt quadratisch skaliert + Dreieck- statt Quadrat-Fläche + Module-State-Leak zwischen Tests (beforeEach-Reset via cancel) | Frontend: 624/624 Tests (+14 measureArea.test.ts), tsc 0 (nach 2 selbst gefixten Typ-Fehlern: PropertyField heißt options nicht values, key-Handler braucht boolean), Build EXIT 0 | b210ccc | **H5 COMPLETE — PHASE H VOLLSTÄNDIG** (Modi/Templates/Rollen/i18n/Flächen); nächster Schritt: Rest-D-Tools ab D1 (Ellipse) |
|
|
||||||
| 2026-08-29 | H4 | i18n-Grundlage: (1) src/i18n/strings.ts NEU — zentrale DE/EN-Maps (Key-Pfad → String oder parametrisierte Funktion für dynamische Texte wie „Layout X löschen“), t(key, arg) mit Key-Fallback bei unbekannten Keys (offensichtlich sichtbar statt still), LANGUAGES-Konstante, getLanguage/setLanguage mit localStorage-Persistenz (webcad.language, Default de, ungültige Werte → de) analog uiModeService-Muster, subscribeLanguage-Set, useT-React-Hook erzwingt Re-Render bei Sprachwechsel; (2) Kern-UI-Texte auf t() umgestellt: LayoutBar (Titel, Hinzufügen, Leer, Löschen, Drucken, Titelblock, Zoom-to) + Topbar-Modus-Toggle (title + aria-label) — mit echtem Live-Wechsel getestet (de → en im laufenden Render); (3) vollständige mechanische Ersetzung ALLER Komponenten-Texte bewusst als teilautomatisierbares Follow-up markiert (Roadmap-Wortlaut); Zwischenfälle selbst behoben: Python-Quoting-Abbruch beim ersten Implementierungsversch (strings.ts wurde nicht geschrieben, Komponenten referenzierten useT schon → nachgetragen) + Testing-Library-State-Update außerhalb act() (Sprachwechsel im Test in act() gewrappt) | Frontend: 610/610 Tests (+8 i18n.test.tsx), tsc 0, Build EXIT 0 | e6fdb39 | H4 COMPLETE; nächster Task H5 (Flächenberechnung Polygon m² + measure-area Tool) |
|
|
||||||
| 2026-08-29 | H3 | Rollen library-admin vs. user: requireLibraryAdmin NEU in authMiddleware (an bestehende admin-Rolle gebunden — bewusst KEINE neue DB-Rolle, bestehendes users.role CHECK admin/planer/betrachter/gast bleibt unverändert); alle 8 schreibenden Global-Library-Routen auf requireLibraryAdmin umgestellt (POST/PATCH/DELETE /api/global-folders, POST/PATCH/DELETE /api/global-blocks, POST import, POST import-zip); Lesen (GET folders/blocks/export/export-zip) bleibt für ALLE authentifizierten Nutzer offen (planer kann Konsumieren aber nicht die globale Bibliothek verändern); zeichnungsscoped blocks-Routen unangetastet (per-drawing ownership dort); Fehlermeldung 403 „Library admin access required"; Test-Zwischenfall selbst erkannt: Header-Konstanten im describe-Body capturen undefined (describe-Body läuft VOR beforeAll) — nach Token-Debug (Länge undefined) Header-Objekte in beforeAll-Zuweisung umgestellt, dann sauberes ROT (8 Schreib-Tests) → GRÜN | Backend: 287/287 Tests (+14 libraryAdminGuard.test.ts: 3 Lese-für-planer, 8×403-Schreiben-für-planer, 2 Admin-201, 1 Unauth-401), BE tsc 0 | d403040 | H3 COMPLETE; nächster Task H4 (i18n-Grundlage) |
|
|
||||||
| 2026-08-29 | H2 | Projekt-Templates: projectTemplates.ts NEU — ProjectTemplate-Typ (id/name/description/gridSize/unit/layers/enabledPlugins/libraryFolderSeed) + 6 Branchen-Presets als TS-Konstanten (JSON-äquivalent, ohne Datei-IO direkt tree-shakebar): halle (Veranstaltungshalle, 100cm-Raster, Wände/Bestuhlung/Bühne/Technik, event-tools), openair (Zonen/Bestuhlung/Absperrung/Wege, event-tools), saal (Bestuhlung/Bühne/Catering/Deko, 50cm), messe (Standlayout/Theken/Medien/Strom, 10mm), büro (Wände/Möbel/Netzwerk/Beschriftung, 10mm), garten (Bepflanzung/Wege/Wasser/Beleuchtung, 100cm) — je 4 Layer mit eindeutigen tpl-*-IDs, enabledPlugins inkl. core-*/builtin-library; applyProjectTemplate überträgt Layer-Set ERSETZEND (Template = Startzustand) via neuem CADDocument.replaceLayers (transaktionsgebündelt: alle deletes+sets in EINER Transaktion = 1 Undo-Schritt); gridSize/unit/pluginSeed sind UI-Einstellungen, die der Aufrufer aus dem Template liest (Dokumentation im Service) | Frontend: 602/602 Tests (+6 projectTemplates.test.ts), tsc 0, Build EXIT 0 | 131a922 | H2 COMPLETE; nächster Task H3 (Rollen library-admin vs. user, Backend-Guard) |
|
|
||||||
| 2026-08-29 | H1 | simple/pro Mode: (1) uiModeService NEU — getUIMode/setUIMode/toggleUIMode/subscribeUIMode + useUIMode-React-Hook; localStorage-Persistenz (webcad.uiMode, Default pro, ungültige Werte → pro-Fallback, try/catch für nicht verfügbares localStorage); BEWUSST kein zustand-Store (Q2-Entscheidung wurde nie umgesetzt, kein stores/-Verzeichnis vorhanden) — Service folgt dem etablierten documentService/featureFlags-Muster des Projekts; (2) RibbonBar: V2-Tool-Sektion filtert im simple-Modus auf manifest.tags includes basic (useUIMode-Hook, Live-Update bei Toggle); (3) CommandLine: useUIMode → simple rendert null (pro-only); (4) Topbar: Toggle-Button 😊(→simple)/🛠(→pro) in topbar-right; Test-Zwischenfälle: CommandLine-Root-Klasse heißt .cmdline nicht .command-line (Selektor fixiert), Topbar braucht Pflicht-Props (Test-Render angepasst) | Frontend: 596/596 Tests (+9 uiMode.test.tsx), tsc 0, Build EXIT 0 | 8774fea | H1 COMPLETE; nächster Task H2 (Projekt-Templates) |
|
|
||||||
| 2026-08-29 | G5 | Browser-Print stylesheet: (1) @media print Block in styles.css — @page size A4 landscape margin 10mm; .screen-only display none !important (UI-Chrome); .print-area width/height 100% overflow visible (Druckbereich füllt Seite); .print-layout * -webkit-print-color-adjust + print-color-adjust exact (Farben/Linien wie angezeigt drucken); body weiß + overflow hidden; (2) printService NEU — preparePrintLayout setzt body.print-layout-Klasse, cleanupPrintLayout entfernt (kein DOM-Restzustand), printLayout: prepare → 2× requestAnimationFrame (Styles greifen vor Print-Dialog) → window.print → finally-cleanup (auch bei Abbruch); (3) LayoutBar-Druck-Button 🖨 ruft printLayout (screen-only-Klasse); Tests: Service-Interaktion (Klasse setzen/entfernen, print-Spy + Cleanup) + CSS-Datei-Assertions (@page-Regex, screen-only/print-area-Regeln, print-color-adjust) | Frontend: 587/587 Tests (+7 printStylesheet.test.ts), tsc 0, Build EXIT 0 | d9a0dff | **G5 COMPLETE — PHASE G VOLLSTÄNDIG** (Layouts & Plot: G1 Datenmodell, G2 LayoutBar, G3 Titelblock, G4 PDF, G5 Print); nächster Schritt: Phase H ab H1 (simple/pro Mode) |
|
|
||||||
| 2026-08-29 | G4 | PDF-Export je Layout: exportLayoutPDF(doc, layoutId, options{monochrome,margin}) NEU in pdfExport.ts (additiv — Legacy-exportPDF unangetastet): A4 landscape 842×595pt; Ausgabemaßstab = layout.viewport.scale, sichtbarer Welt-Ausschnitt aus center berechnet; Y-Flip (PDF bottom-left vs CAD top-down) in toPage-Transform; Rendering über elementToPrimitives (C2) statt duplizierter Typ-Switches → garantiert identische Konventionen wie Pixi-Renderer (Legacy-exportPDF hätte Circle um width/2 verschoben gezeichnet — gleiche Bugklasse wie DXF-Writer vor F6, dort belassen und im Log vermerkt); Linienbreiten: properties.strokeWidth sonst Layer-lineType (solid 0.7pt/dashed 0.9pt/dotted 0.5pt) mit Dash-Patterns [4,3]/[0.5,2]; monochrome erzwingt rgb(0,0,0) + keine Dashes; unsichtbare Layer gefiltert; Arc→32 Segmente (Grad CCW); Titelblock aus frameBlockRef wird unskaliert unten-rechts mitgezeichnet (Block-Elemente um Ursprung an Ecken-Offset); drawPrimitive deckt alle 6 Primitive-Kinds | Frontend: 580/580 Tests (+6 pdfLayoutExport.test.ts — PDF via PDFDocument.load verifiziert: Seitenzahl 1, Größe 842×595, Frame-Block-Fall, monochrome, leerer Viewport, unknown-ID-throw, unsichtbare Layer), tsc 0, Build EXIT 0 | cb23638 | G4 COMPLETE; nächster Task G5 (Print-Stylesheet) |
|
|
||||||
| 2026-08-29 | G3 | Titelblock-Block mit Attributen: (1) titleBlockService NEU — createTitleBlockElements (reine Funktion): Rahmen rect 180×100 (App-Konvention x/y=Zentrum) + Titel-Label + 4 attributierte Texte PROJEKT/DATUM/MASSSTAB/AUTOR mit tag/attdef:true/prompt-Metadaten und text=TAG=Wert (exakt die F5-Parser- und F6-Writer-Konvention → DXF-ATTDEF-Roundtrip & D12-Formular-ready), leere Meta → Fallback —; (2) insertTitleBlockForLayout: Blockdefinition (category frame) via doc.addBlock + frameBlockRef im Layout via updateLayout, Re-Insert ersetzt Referenz (neue Block-ID), fehlendes Layout → throw; (3) CADDocument um getBlock/addBlock erweitert (bewusst NICHT undo-getrackt — konsistent zum UndoManager-Design-Kommentar); (4) LayoutBar-Button 🏷 je Layout: fügt/aktualisiert Titelblock mit Layout-Name als Projekt, heutiges Datum, Maßstab aus viewport.scale (1:1/scale gerundet), Autor WebCAD; Test-Zwischenfall: eigene Assertion prüfte attdef über alle 5 Texte inkl. Titel-Label (bewusst kein Attribut) — auf getaggte Texte gescoped | Frontend: 574/574 Tests (+6 titleBlock.test.ts), tsc 0, Build EXIT 0 | bbb469e | G3 COMPLETE; nächster Task G4 (PDF-Export je Layout) |
|
|
||||||
| 2026-08-29 | G2 | LayoutBar UI + Viewport-Widget: (1) ZoomPanController.setView(center, scale) NEU — Welt-Punkt exakt ins Canvas-Zentrum bei Scale, geclamped 0.01–100; (2) LayoutBar-Komponente: listet Layouts des aktiven Docs (documentService-Fallback mit onActiveDocumentChanged-Subscription + onChanged-Live-Reload), je Eintrag SVG-Minimap (Modellbereich als Rechteck + Viewport-Ausschnitt blau — scale-abhängig sichtbar kleiner), Klick = zoom-to via Bridge, „+“ speichert aktuellen Viewport als Layout, 🗑 löscht; (3) computeMinimapRects als reine exportierte Funktion (Modell-BBox füllt Widget mit 6px Padding, ohne Elemente Volles-Widget-Fallback, Viewport = 800×600/scale nominal); (4) CanvasArea-__v2LayoutBridge nach E6b-Muster (zoomTo mit render-Trigger, getView aus getViewport+getScale); (5) App.tsx mountet LayoutBar über StatusBar; CSS angehängt; Zwischenfälle selbst gefixt: view-Parameter-Kollision in computeMinimapRects (Umbenennung viewRect) + Regex-Double-Escape im Test | Frontend: 568/568 Tests (+11 layoutBar.test.tsx), tsc 0, Build EXIT 0 | 7feed9e | G2 COMPLETE; nächster Task G3 (Titelblock-Block mit Attributen auto-insert) |
|
|
||||||
| 2026-08-29 | G1 | Layout-Datenmodell: LayoutViewport {center{x,y}, scale} + LayoutDefinition {id, name, viewport, frameBlockRef?} in cad.types.ts; YjsDocument.data um layouts-Map (YjsDocumentData-Interface additiv erweitert + getLayouts-Getter); CADDocument um Layout-CRUD erweitert: getLayout/getAllLayouts/addLayout/updateLayout (Top-Level ersetzt, viewport + center SHALLOW gemergt — center/scale einzeln änderbar, frameBlockRef bleibt bei reinem name-Patch erhalten)/deleteLayout (still bei unbekannter ID); Y.UndoManager um layouts-Map getrackt → jedes add/update/delete = eigener Undo-Schritt (2-Schritte-Undo-Test: add→delete→undo→undo verifiziert); Persistence läuft automatisch über bestehende drawing-json-Serialisierung (Y-Doc-Snapshot) | Frontend: 557/557 Tests (+6 layouts.test.ts), tsc 0, Build EXIT 0 | e750a45 | G1 COMPLETE — Datenmodell steht; nächster Task G2 (LayoutBar UI + Viewport-Widget) |
|
|
||||||
| 2026-08-29 | E7 | Event-Elemente-Polish: (1) ElementType um curtain/spotlight/barrier erweitert (cad.types.ts additiv); (2) Pixi-Drawer-Cases: curtain = wellige Polyline aus properties.points (24 Punkte, Sinus gedämpft, close=false; Fallback Linie), spotlight = Kegel-Polygon aus points (close=true, stroke=properties.color), barrier = Kreis-Paare aus properties.circles (12 Kreise, Fallback 5 entlang BBox); (3) Platzier-Tools via Klick: curtainTool (stageHeight-Option, Standard 120cm, Breite 400), spotlightTool (angle+color-Optionen, Länge 300, Öffnung ±20°, Grad-Winkel direkt — Konvention konsistent zu F6), barrierTool (length-Option, Standard 200, 6 Kettenglieder); (4) createStage um properties.height (Bühnenhöhe in cm) erweitert; event-tools v2.2.0, tools-Array jetzt 13 Einträge; Test-Helper fireDown auf etabliertes Pilot-Muster umgestellt (echtes CADDocument aus createInMemoryDoc statt Broken-Mock ohne transact — 7 Tests rot wegen Mock, nach Muster-Fix grün) | Frontend: 551/551 Tests (+8 eventPolish.test.ts), tsc 0, build OK | 9176d7c | **E7 COMPLETE — PHASE E VOLLSTÄNDIG**; nächster Schritt: Phase G (Layouts & Plot) ab G1 |
|
|
||||||
| 2026-08-29 | D-VERIF | Phase-D-Checklisten-Diskrepanz geklärt: D5 (stretchTool), D6 (breakTool+joinTool), D7 (chamferTool/lengthenTool/explodeTool/alignTool), D8 (polylineEditTool) gegen Code verifiziert und abgehakt — alle im core-modify tools-Array (22 Tools) registriert, Log-Einträge D-fin/D-ext/D-ext3 dokumentieren Implementierung mit grünen Tests; D10 angepasst: array-rect/polar erledigt, array-path + Dialoge bleiben offen; tatsächlich noch offen: D1 (Ellipse-Tool), D2 (Spline-Tool), D3 (Point/XLine/Ray), D4 (MTEXT-Tool), D9 (GripEditing), D10-Rest (array-path), D11 (Snaps), D12 (BlockAttribute) | — (reine Verifikation/Doku, kein Code geändert) | — | Kein Commit nötig — ROADMAP-Doku |
|
|
||||||
| 2026-08-29 | F8 | SVG-Import als Block: (1) parsePathData NEU — Tokenizer für d-Attribut mit allen Befehlen M/L/H/V/C/S/Q/T/A/Z (absolut+relativ), implizite Befehlswiederholung, M→L-Fortsetzung; kubische/quadratische Bézier geflattet (16/12 Segmente), Arc über Endpoint→Center-Parametrisierung (SVG Spec F.6.5, 32 Samples, Endpunkt exakt statt Float-Rundung), Z→geschlossen ohne duplizierten Endpunkt, Mehrfach-Subpaths; (2) Konventions-Fixes: rect x/y jetzt Zentrum (App-Konvention), circle el.x/el.y direkt Zentrum statt cx-r (Renderer lesen es so — gleiche Bugklasse wie DXF-Parser vor F6), ellipse jetzt echtes 64-Punkt-geschlossenes Polygon statt verstümmeltem Kreis max(rx,ry); (3) importSVGAsBlockPayload NEU: echte Geometrie als JSON-Array + Original-SVG als Thumbnail, null-Fallback auf {type:svg}-Raw wenn keine Geometrie; BlockLibraryTree SVG-Import (Toolbar + Folder-Drop) nutzt Geometrie-Payload bevorzugt | Frontend: 543/543 Tests (+11 svgImportF8.test.ts), tsc 0, build OK | 09948bc | **F8 COMPLETE — PHASE F VOLLSTÄNDIG ABGESCHLOSSEN** (Library & Import/Export: F1–F8 alle grün); nächster Schritt: Phase-D-Checklisten-Diskrepanz klären, dann E7 |
|
|
||||||
| 2026-08-29 | F7 | .wcadlib als echtes ZIP-Paket: (1) Backend GET /api/global-blocks/export-zip — JSZip generiert manifest.json (format wcadlib-zip v1, exported_at, counts), blocks.json (Ordner + Blöcke mit thumbnail-Referenz) und thumbs/<blockId>.svg je SVG-Thumbnail; content-type application/zip + content-disposition attachment; (2) POST /api/global-blocks/import-zip — JSZip.loadAsync, Validierung (kein ZIP → 400, blocks.json fehlt → 400, invalides JSON → 400, manifest.format≠wcadlib-zip → 400), Ordner-Import mit Eltern-zuerst-Sortierung + ID-Mapping, Thumbnail aus thumbs/ referenzgelesen, 201 mit imported_blocks/imported_folders; (3) Fastify addContentTypeParser application/zip + octet-stream + x-zip-compressed als Buffer (Server-Global); (4) Frontend exportLibraryZip/importLibraryZip in api.ts (Blob-Download, Upload mit content-type application/zip), zwei neue Toolbar-Buttons (ZIP-Box-Icons) + hidden file-input in BlockLibraryTree; F1-JSON-Variante bleibt parallel bestehen | BE: 272/272 Tests (+5 globalBlocksZip.test.ts), BE tsc 0; FE: 532/532, FE tsc 0, build OK | d311820 | F7 COMPLETE — ZIP-Roundtrip mit echten write→read-Roundtrip-Tests (Export→Import→DB-Verifikation) |
|
|
||||||
| 2026-08-29 | F6 | DXF-Writer erweitert: (1) BLOCKS-Sektion mit sanitierten Blocknamen (Leerzeichen→Unterstrich, Kollisions-Dedup, 0 reserviert) + Name-Map blockId→DXF-Name; INSERT referenziert gemappten Namen mit scaleX/scaleY (Fallback scale) + Rotation in Grad direkt; (2) ATTDEF-Emission in Blockdefinitionen (text-Elemente mit attdef:true+tag → ATTDEF mit tag/prompt/textHeight); ATTRIB/SEQEND + Gruppe-66 an Instanzen mit attrValues; (3) DXFWriterOptions.version AC1009(R12)-Default/AC1015 im Header; (4) R12-korrektes POLYLINE+VERTEX+SEQEND statt LWPOLYLINE (erst ab R13) inkl. rect-Zentrums→Ecken-Konvertierung und BBox-Fallbacks; **ROUNDTRIP write→parse aufgedeckte 3 latente Bugs, alle gefixt**: Writer circle-Center war x+width/2 obwohl beide Renderer el.x/el.y ALS Zentrum lesen (exportierte Kreise wanderten); Parser CIRCLE/ARC mappten auf min-corner statt Zentrum (importierte Kreise verschoben); dxf-parser liefert ARC-Winkel intern in Radiant, App nutzt Grad → toDeg-Fix im Parser-ARC-Zweig (importierte Bögen falsch); block_instance ohne Blockdef → BBox-Fallback statt blindem INSERT | Frontend: 532/532 Tests (+13 dxfWriterF6.test.ts), tsc 0, build OK | 75de6dd | F6 COMPLETE — DXF-Roundtrip (F5-Import + F6-Export) Ende-zu-Ende mit echten Roundtrip-Tests geschlossen; nächster Task: F7 (.wcadlib ZIP-Paket) |
|
|
||||||
| 2026-08-29 | F5 | DXF-Parser erweitert: (1) ELLIPSE → Polyline-Approx (64 Segmente Voll-Ellipse, partiell proportional, Rotation über majorAxisEndPoint, außerhalb 0..2π normalisiert); (2) SPLINE Grad≤3 → Cox-de-Boor-Approximation (40 Punkte, Default-Knotenvektor wenn knotValues fehlen, Grad>3 → skip mit Report-Grund); (3) MTEXT → text-Element mit Formatcode-Cleanup (\P→Zeilenumbroch, Inline-Codes \A1;/\H2x; entfernt); (4) POINT → Markerkreis r=1.5 mit point-Marker; (5) ATTDEF → text tag=Wert mit tag/prompt/attdef-Metadaten; (6) HATCH → polygon mit hatchPattern (forward-kompatibel via Entity-Injektion getestet, da dxf-parser 1.1.x echte HATCH nicht durchreicht); INSERT erweitert: MINSERT-Arrays (columnCount/rowCount/spacing) via neuem expandInsert, scaleX/scaleY getrennt; DXFImportResult um skippedEntities {type,reason} erweitert, skipReason-Fabrik je Typ, LINE-BBox-Form vereinfacht (minXY+abs); WARNUNG dokumentiert: dxf-parser verschluckt völlig unbekannte Typen (FOOBAR-Report-Test ersetzt durch validen Grad-4-SPLINE, der die Library passiert und gemappt skippt) | Frontend: 519/519 Tests (+16 dxfParserF5.test.ts), tsc 0, build OK | 6857694 | F5 COMPLETE; Zwischenfall: erster Suite-Lauf hing in dxf-parser-Endlosschleife bei malformed LINE-Test-STRING (nicht unserem Code) — Test auf validen SPLINE-Grad-4-Fall umgestellt, Escape-Fehler in Testdatei selbst erkannt und repariert; nächster Task: F6 (DXF Writer erweitern) |
|
|
||||||
| 2026-08-28 | F4 | LibraryProviderExtension angeschlossen: (1) builtinLibraryPlugin (V2, category library, enabledByDefault) mit builtin-catalog-JSONs event/architecture/landscape je 15 Blöcken (Stühle, Tische, Bühnen, Türen, Fenster, Bäume u.a.) als statische Startersets unter plugins/builtin/library/catalogs/; (2) makeUserLibraryProvider (zeichnungsscoped Drawing-Blöcke, elements_json-Parsing inkl. Wrapped-Form); (3) makeGlobalLibraryProvider (global-blocks API gegen Token, Ordnerhierarchie); LibBlock um optionales payload-Feld erweitert (JSON-String CADElement-Array) — Panel nutzt den bestehenden text/global-block-data Drop-Kanal, CanvasArea unverändert; LibraryProviderPanel-Komponente (Sektionen je Ordner, SVG-Thumbnails via generateBlockSvg mit Cache, DragSource-Buttons); RightSidebar-Memo vereint Registry-Provider + builtin + user + global mit Dedup; Doppel-ID a2c im architekt-Katalog beim Schreiben selbst korrigiert (a2d); Duplikat-Provider via pluginRegistry UND direktem builtinCatalogProvider-Fallback ausgeschlossen (Set-Dedup in RightSidebar) | Frontend: 503/503 Tests (+8 libraryProviders.test.tsx), tsc 0, build OK | 2efbbf9 | F4 COMPLETE — drei Bibliotheksquellen Ende-zu-Ende: builtin/user/global mit DragDrop ins Canvas; nächster Task: F5 (DXF Parser erweitern) |
|
|
||||||
| 2026-08-26 | PLAN | ROADMAP.md erstellt, Baseline analysiert | tsc 0, FE 380✓, BE 255✓ | — | Ausgangszustand dokumentiert |
|
|
||||||
| 2026-08-26 | B0 | bugs.regress.test.ts angelegt (3 Tests für BUG-1/1b/2); alle 3 ROT wie vorhergesagt; Rest-Suite unverändert grün; Validierung des Dokuments bestanden | FE 380✓ + 3 rot (erwartet), tsc 0 | b9eb69f | Nächster Task: B1 |
|
|
||||||
| 2026-08-26 | B1 | nextUid-Helper eingebaut; createSeatingRow rowId vor Schleife; createSeatingBlock blockId via nextUid + rowId pro Reihe in properties | BUG-1+1b GRÜN, 382/383 gesamt (BUG-2 rot wie erwartet), tsc 0 | 1bbd653 | Nächster Task: B2 |
|
|
||||||
| 2026-08-26 | B2 | lineCircleIntersections/circleCircleIntersections/lineArcIntersection implementiert; findIntersection auf Typ-Dispatch erweitert; Guards in trim/extend von Truthiness auf undefined korrigiert (Koordinate 0 war falsy); Tangenten-Dedup; 10 neue Unit-Tests | ALLE GRÜN: 393/393 gesamt, tsc 0 | 95b14cf | Nächster Task: B3 |
|
|
||||||
| 2026-08-26 | B3 | offsetElement um side-Parameter erweitert (rückwärtskompatibel); Interaktionszweig nutzt Kreuzprodukt (Linie) bzw. Radius-Vergleich (Kreis/Bogen); 4 neue Tests | ALLE GRÜN: 397/397 gesamt, tsc 0 | d8f29e4 | Nächster Task: B4 |
|
|
||||||
| 2026-08-26 | B4 | drawBackground cached Bild pro backgroundSrc; kein new Image() pro Frame mehr; Regressionstest mit FakeImage-Zähler | ALLE GRÜN: 398/398 gesamt, tsc 0 | d93ee53 | Nächster Task: B5 |
|
|
||||||
| 2026-08-26 | B5 | countSeats um optionales byZone erweitert; Testdatei seating.count.test.ts (5 Tests inkl. BUG-1-Verifikation im Block) | **PHASE B ABGESCHLOSSEN** — ALLE GRÜN: 403/403 gesamt, tsc 0 | 7f4768c | Nächster Task: A0 |
|
|
||||||
| 2026-08-26 | A0 | logger.ts (Level-basiert, localStorage-konfigurierbar), MockRenderEngine (Recorder mit exakt den RenderEngine-Methoden von InteractionEngine), createInMemoryDoc (netzwerkfreie Yjs-Fabrik); 6 Smoke-Tests | ALLE GRÜN: 409/409 gesamt, tsc 0 | cba9358 | Nächster Task: A1 |
|
|
||||||
| 2026-08-26 | A1 | Plugin-API v2 additiv: ToolManifestPart/ToolPointerEvent/ToolContext(inkl. setPreview?)/ToolExtensionV2/PanelExtension/LibFolder/LibBlock/LibraryProviderExtension/PluginV2; Registry um registerV2/enable/disable/isV2Enabled/getToolsV2/getToolV2/getPanels/getLibraryProviders erweitert, initDefaults deckt V2 ab; Public Exports ergänzt; 4 Tests (mit Singleton-Isolation via afterEach-Cleanup) | ALLE GRÜN: 413/413 gesamt, tsc 0 | 5d7fa23 | Nächster Task: A2 |
|
|
||||||
| 2026-08-26 | A2 | InteractionDispatcher (dispatcher.ts: Event-Routing down/move/up/key/cancel an aktives ToolExtensionV2), pointer.ts (Event-Normalisierung screen→world über ZoomPanController.screenToWorld), session.ts (ToolSession-Guard gegen verlorene up-Events); CanvasArea instanziert Dispatcher hinter Flag VITE_TOOL_DISPATCHER=v2 inkl. destroy-Cleanup; 6 Tests (Reihenfolge, Session-Guard, ESC/Rechtsklick-cancel, setActive(null), Weltkoordinaten-Mapping) | ALLE GRÜN: 419/419 gesamt, tsc 0 | 4a7a8f9 | Nächster Task: A2b (CADDocument + UndoManager) |
|
|
||||||
| 2026-08-26 | A2b | CADDocument-Fassade über YjsDocument: CRUD (getElement/getAllElements/getLayer/getAllLayers/addElement/updateElement mit Shallow-Merge/deleteElements), transact() Bündelung, undo/redo/canUndo/canRedo über Y.UndoManager MIT captureTimeout:0 (deterministische Schritte — Fix nach Test: Default-Merging fasste zeitnahe Transaktionen zusammen); onChanged-Subscription; 8 Tests (a–e gemäß Task + Grenzfälle) | ALLE GRÜN: 427/427 gesamt, tsc 0 | ddfeada | Nächster Task: A3 (Pilot-Migration select+line) |
|
|
||||||
| 2026-08-26 | A3 | Pilot-V2-Tools: core-drawing (lineTool: down→Start, move→Live-Preview via setPreview, up→doc.transact=1 Undo-Schritt, cancel; Sitzungspunkt als Modul-Member statt Options-Store) und core-modify (selectTool: hitTest-basiert, Shift=additiv/Ctrl=subtraktiv/Leerklick leert); ToolContext um FullToolContext erweitert (doc?+selection? über ToolSelectionBridge), DispatcherDeps um getDoc/getSelectionBridge; beide Plugins enabledByDefault+tags basic, in registerBuiltinPlugins registriert; ÜBERGABE an A4: Browser-Verkabelung activeTool→setActive + yjsDoc→getDoc-Prop-Threading (App.tsx); 6 Tests (Line inkl. Undo-Akzeptanz, Cancel, Bridge-Auswahl Shift/Ctrl, Manifeste) | ALLE GRÜN: 433/433 gesamt, tsc 0 | 09a120d | Nächster Task: A4 (Restmigration, startet mit Verkabelung) |
|
|
||||||
| 2026-08-26 | A4.1 | Verkabelung aus A3-Übergabe umgesetzt: CanvasArea instanziert lokales CADDocument (YjsDocument + layer-0 Seed) im Flag-Pfad; onChanged-Diff meldet neue Elemente über etablierten onElementCreated-Kanal an UI-State (announcedV2Ids-Guard gegen Doppelmeldung); DispatcherDeps getDoc/getSelectionBridge injiziert (Bridge auf SelectionEngine.getSelectedIds/selectByIds + RenderEngine.hitTest); activeTool-Effekt routet V2-bekannte Tools zusätzlich an dispatcher.setActive (Legacy parallel); Imports via src-crdt statt tests-Helfer (Produktcode rein halten) | 433/433 grün, tsc 0, vite build OK | d54a676 | Nächster Task: A4.2 (Restmigration Block 1: rect) |
|
|
||||||
| 2026-08-26 | A4.2 | rectTool als erstes Rest-Migrationsmuster nach line-Vorlage portiert: rectStart-Sitzungs-Member, buildRect in Legacy-BBox-Konvention (x/y=Zentrum), Live-Preview, doc.transact-Commit, cancel; core-drawing v1.1.0 mit tools:[line,rect]; 2 Tests (BBox-Assertions inkl. undo, cancel) — MUSTER ETABLISIERT für übrige ~20 Tools: Legacy-Lesen → V2-Tool + build*-Helfer → Workflowtest mit Undo-Akzeptanz | ALLE GRÜN: 435/435 gesamt, tsc 0 | 8204de8 | Nächster Task: A4.3 (Block circle+arc gleiches Muster) |
|
|
||||||
| 2026-08-27 | A4.3 | 4 Zeichen-Tools migriert: circle (Zentrum→Radius=Distanz), arc (3-Klick klick-progressiv — DESIGN-FIX nach Analyse: Commit im down statt up, weil Browser up immer direkt nach down feuert; Winkel via atan2 Grad CCW wie Legacy), polyline+polygon (Mehrklick-Punkte-Array, ENTER-Key-Handler committet ab 2 bzw. 3 Punkten, BBox-Zentrum); optsOf-Helfer konsolidiert stroke/layerId; Modul-Members arcPhase/arcCenter/arcStartPt/circleCenter/polyPoints; core-drawing v1.2.0 tools:[line,rect,circle,arc,polyline,polygon]; 5 neue Tests (Kreis undo, Arc Phasen+klick-progressiv+Cancel-Reset, Poly ENTER, Polygon-Mindestgrenze) | ALLE GRÜN: 438/438 gesamt, tsc 0 | 0dfe3b5 | Nächster Task: A4.4 (annotate: text/mtext) |
|
|
||||||
| 2026-08-27 | A4.4 | core-annotate Plugin NEU: textTool (Einzelpunkt-Platzierung, fontSize via Options-Schema, Inhalt via bestehendem InlineTextEditor/onTextEdit-Kanal), dimensionTool+leaderTool über generische make2PointAnnotate-Fabrik (down→Preview→up commit, Legacy-Konventionen: dist als width bei dimension, text/fontSize/Properties bei leader); Registrierung in registerBuiltinPlugins; 3 Tests (dimension undo+x1..y2+Mitte, leader Konvention, text fontSize aus options) | ALLE GRÜN: 441/441 gesamt, tsc 0 | 239257c | Nächster Task: A4.5 (revcloud/hatch/measure) |
|
|
||||||
| 2026-08-27 | A4.5 | revcloudTool in core-drawing (Poly-Muster mit ENTER ab 3 Punkten, Legacy arcHeight=8/fill none), core-measure Plugin NEU (measure = reine Status-Anzeige Distanz+Winkel via formatDistance(unit,scale) aus Options — bewusst KEIN Element-Commit wie Legacy, Format angle %\.1f\u00b0), hatchTool in core-modify v1.1.0 (hitTest→doc.updateElement hatch/hatchPattern/hatchSpacing aus Options —.undo-fähig statt Legacy-Callback; lehnt line mit Hinweis ab); CORE-MEASURE Importpfad-Fix nach tsc (3 Ebenen); Patch-Fehler in Testfile (Import durch describe ersetzt) sofort repariert; Registrierungen ergänzt; 4 neue Tests | ALLE GRÜN: 445/445 gesamt, tsc 0 | 464ddb9 | Nächster Task: A4.6 (Modify-Block move/copy/rotate/scale/mirror/delete) |
|
|
||||||
| 2026-08-27 | A4.6 | makeMoveCopyTool-Fabrik NEU: move/copy wirken auf SELEKTION via SelectionBridge (Klick1=Basis+Auto-HitSelect bei leerer Selektion als Komfort, Klick2=Commit); move=updateElement x±dx/y±dy je Element, copy=addElement der Kopien mit generierten cp_-IDs (Original unverändert); ALLES in EINER transact=1 Undo-Schritt (Multi-Element undo getestet!); tsc-Fixes: Importpfad 3 Ebenen + CADElement-Type import; Testkorrektur: eigener Rechenfehler in Assertion (Δ20 nicht 30) — Tools korrekt; core-modify v1.2.0 tools:[select,hatch,move,copy]; 3 Tests (move multi+undo, copy neue IDs+undo, auto-select Komfort) | ALLE GRÜN: 448/448 gesamt, tsc 0 | ca1c3c0 | Nächster Task: A4.7 (rotate/scale/mirror/dele ausgleiche Muster) |
|
|
||||||
| 2026-08-27 | A4.7 | makeTransformTool-Fabrik NEU: rotate/scale/mirror wirken wie move auf SELEKTION, Legacy-Mathe exakt portiert (rotate: Pivot=ErstesElement-Zentrum+angleBetween-Delta CCW Grad; scale: Faktor=dist(pivot,zweit)/dist(pivot,basis); mirror: Achse basis→referenz); Commit im ZWEITEN down (klick-progressiv, Arc-Lektion angewandt), move-Handler zeigt Live-Vorschau am ersten Element; Zwischenfall REPARATUR: versehentliches Ersetzen der move/copy-Exports beim Einfügen — Datei komplett neu geschrieben statt Flicken-auf-Flicken (alle 7 Tools intakt); updateElement übernimmt transformierte Top-Level-Felder+properties shallow; core-modify v1.3.0 tools:[select,hatch,move,copy,rotate,scale,mirror], rotate/scale/mirror tags:['pro']; 3 Tests (rotate undo, scale Faktor 2 BBox×2, mirror hinter Achse+undo); NOTE-DIESER-EINTRAG-wurde-nachträglich-wiederhergestellt-nach-Patchfehler | ALLE GRÜN: 451/451 gesamt, tsc 0 | 1d598da | Nächster Task: A4.8 (delete+offset) |
|
|
||||||
| 2026-08-27 | A4.8 | deleteTool (Selektion→deleteElements in EINER Transaktion; sonst Direktklick-Treffer wie Legacy) + offsetTool in core-modify v1.4.0 (Klick1=Quelle via hitTest, Klick2=Distanz+Seite via Kreuzprodukt bzw. Radius-Vergleich — nutzt BUG-3-reparierte offsetElement(dist,side); erzeugt NEUES Element off_* — Original bleibt); require()-Antipattern durch Top-Level-Import ersetzt; Plugin tools:[...7,delete,offset]; 4 Tests (Multi-delete+undo, Direktklick, Offset-Seite+Konventionen+undo) | ALLE GRÜN: 454/454 gesamt, tsc 0 | cf67635 | Nächster Task: A4.9 (Event-Domain) |
|
|
||||||
| 2026-08-27 | A4.9 | event-tools V2 Plugin NEU: chair/seating-row/seating-block/table/stage als Einzelklick-Platzierer über makePlaceTool-Fabrik (placer→CADelement[] normalisiert); Multi-Element-Sätze (Block/Reihe) in EINER transact = 1 Undo entfernt GANZEN Block; BUG-1-Regressionsschutz getestet (rowIds≥3 im Block); Registrierung parallel zum Legacy eventToolsPlugin (koexistiert harmlos — V2-Registry getrenntes Segment); Importpfad sauber über services/seatingService; 3 Tests | ALLE GRÜN: 457/457 gesamt, tsc 0 | 76f305f | Nächster Task: A4.10 (trim/extend/fillet Geometrie-Werkzeuge) |
|
|
||||||
| 2026-08-27 | A4.10 | trimTool/extendTool/filletTool in core-modify v1.5.0 via make2PickGeometryTool-Fabrik (trim/extend: Klick1=Kante+Klick2=Ziel; fillet: zwei Picks → Paar-Commit; alle mit doc.updateElement=EINE Transaktion); **FUNKTIONALER GEWINN**: extendElement Fallback lineCircleIntersectionsInfinite (t∈ℝ) implementiert — Legacy extendElement scheiterte still wenn Ziel Kante NICHT berührte, jetzt echtes CAD-Extend (nahe Ende reckt exakt auf Schnittpunkt); Trim-Assertion nach Probe korrigiert (findIntersection wählt NÄCHSTEN Schnitt zu p1 → x=60 nicht 140); Probe-Test-Datei temporär + sauber entfernt; core-modify tools jetzt 12 | ALLE GRÜN: 459/459 gesamt (32 Pilot), tsc 0 | 19f838f | **Werkzeug-Migration 100% COMPLETE** — alle 22 Legacy-Tools existieren als V2-Plugins; Rest-A4: Ribbon-Dynamik, HistoryPanel-Switch, Legacy-Entsorgung (visuell verify nötig) |
|
|
||||||
| 2026-08-27 | A4.11 | documentService Singleton NEU (kernel/document/): setActiveDocument/getActiveDocument/onActiveDocumentChanged/undoActive/redoActive — App-Level-Zugriff ohne prop-threading durch God-Component; CanvasArea registriert v2Doc beim Mount (Flag-Pfad) + setActiveDocument(null) im Cleanup; globale Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z Shortcuts nur wirksam bei registriertem V2-Dokument (Legacy-Pfad unangetastet); 3 Tests (Register/Clear, Undo/Redo-Delegation, false ohne Doc) | ALLE GRÜN: 462/462 gesamt, tsc 0, build OK | 25e721d | Nächster Task: A4.12 (V2HistoryPanel) |
|
|
||||||
| 2026-08-27 | A4.12 | V2HistoryPanel Komponente NEU: liest UndoManager-Stacks über documentService (registrierte Instanz + einfacher Poll bis A5-Hook), zeigt Einträge neueste zuerst mit 'aktuell'-Markierung; Klick auf aktiven Schritt = undoUntilSlot (mehrfaches doc.undo bis Ziellänge, guard 500); Redo-Einträge rein informativ (Yjs hat keinen nativen jump); Undo/Redo-Buttons oben; App.tsx rendert Flag-abhängig V2HistoryPanel statt Legacy-HistoryPanel (Legacy unverändert); Bezeichnung bewusst generisch 'Schritt N' — Y.Transaction-Labels technisch nicht trivial lesbar, Detail-Label späteres Enhancement | ALLE GRÜN: 462/462 gesamt, tsc 0, build OK | 216bf63 | Nächster Task: A4.13 (Ribbon-Dynamik) |
|
|
||||||
| 2026-08-27 | A4.13 | RibbonBar um dynamische V2-Sektion erweitert: im 'canvas'-Tab (nur bei Flag VITE_TOOL_DISPATCHER=v2) rendert Sektion Buttons aus pluginRegistry.getToolsV2() mit icon+label+activeHighlight; Klick feuert onAction('v2tool:<id>'); neues optionales Prop onIsV2ToolActive für Highlight-Zustand (types/ui.types erweitert); App.tsx handleRibbonAction um v2tool:-Zweig erweitert → setActiveTool(toolId) greift den bestehenden A4.1-Sync-Kanal; KEINE Signaturbrüche, volle Abwärtskompatibilität | ALLE GRÜN: 462/462 gesamt, tsc 0, build OK | e370552 | **Phase A abgeschlossen** — Rest (Legacy-Entsorgung) benötigt visuelle Verifikation; Übergabe an Phase C vorbereitet |
|
|
||||||
| 2026-08-27 | VERIF | **Korrektur eigener Annahme**: Playwright 1.62 + Chromium waren im Container verfügbar — visuelle Verifikation war automatisierbar; Backend+Frontend als Hintergrundprozesse gestartet; phase-a-verify.test.ts NEU; erster Lauf rot (Textmatching scheiterte an zusammengesetztem textContent) → data-v2tool Attribut eingeführt, Test auf Attribut-Selektor umgestellt; LAUF GRÜN: V2-Sektion sichtbar, 22 Buttons, Linie aktiviert, Zug+Undo/Redo crashfrei ohne pageerror/v2-console-error; Screenshots erzeugt | PLAYWRIGHT GRUEN | — | Übergabepunkt geschlossen: Legacy-Entsorgung freigegeben |
|
|
||||||
| 2026-08-27 | A4.14 | FLAG-INVERTIERUNG: kernel/featureFlags.ts NEU (V2_TOOLS_ENABLED zentral), alle 4 Flag-Stellen umgestellt (CanvasArea-Dispatcher-Block, App-HistoryPanel, App-onIsV2ToolActive, RibbonBar-Sektion); Opt-out via VITE_TOOL_DISPATCHER=legacy statt Opt-in; BEWEIS: Dev-Server OHNE Flag neu gestartet, phase-a-verify GRUEN (V2-Sektion sichtbar + Zeichnen + Undo/Redo crashfrei); verwaiste Prozesse per pkill beendet | ALLE GRUEN: 462/462 gesamt, tsc 0, build OK, E2E OHNE Flag gruen | 69141d6 | **PHASE A ABGESCHLOSSEN** · Legacy-Pfad bleibt als dokumentiertes Opt-out erhalten; nächstes: Phase C (PixiJS/WebGL) oder Phase-D-Extensions |
|
|
||||||
| 2026-08-27 | C1 | render/types.ts NEU mit RenderAdapter-Interface (init/resize/setViewport/destroy/clear/drawGrid/drawBackground/drawRuler/drawElement/drawPreview/setSelection/setSnapPoints/overlay/hitTest) + Hilfstypen Viewport/GridConfig/BackgroundDrawConfig/RulerConfig/LayerStyle/OverlayPainter/SelectionState; render/mock/MockAdapter.ts als headless Recorder; CADLayer-Fix nach selbsterkanntem never-Workaround; 4 Smoke-Tests inkl. Compile-proof; pixi.js@8.20.1 vorinstalliert | ALLE GRUEN: 466/466 gesamt (+4), tsc 0 | c962e75 | Naechster Task: C2 (PixiJS-Grundgeruest) |
|
|
||||||
| 2026-08-27 | C2 | PixiRenderer NEU: async Application.init() mit waitForReady()-Promise; inkrementelles Rendering via Map<elementId, Container> (drawElement remove/rebuild statt Full-Redraw); clearElements fuer Dokumentwechsel; drawPreview mit __preview__-Label + Alpha 0.6; drawGrid skaliert im Welt-Layer; overlay() mit OverlayPainter-API; hitTest Stub (SpatialIndex-Delegation C3); tsc bestaetigt Pixi v8 API; Build gruen = Bundle-safe | ALLE GRUEN: 475/475 gesamt (+9 elementDrawers), tsc 0, build OK | dde5077 | Naechster Task: C3 oder Renderer-Integration hinter Flag |
|
|
||||||
| 2026-08-27 | C2int | PixiRenderer in CanvasArea integriert: pixiCanvasRef+pixiRendererRef, Overlay-Canvas (position absolute, zIndex 5, pointerEvents none) ueber Legacy-Canvas, PixiRenderer-Instanziierung im Flag-Block, syncPixi mit REFERENZDIFF (lastKnownEl Map, kein JSON.stringify) + Cleanup-Delete fehlender IDs, pixi.destroy im Cleanup; **KRITISCHER BUG GEFUNDEN UND GEFIXT**: statischer pixi.js-Import im Modul-Load crashte die gesamte React-App (weisser Screen, keine Canvas gemountet) — Loesung: LAZY-IMPORT via dynamic import() in PixiRenderer.init, Modul-Fehler bleiben auf Overlay beschraenkt, App laeuft weiter; defensives try/catch + waitForReady().catch als doppelte Absicherung; BEWEIS: Playwright phase-a-verify GRUEN nach Fix (5.7s) mit vollem Zeichnen+Undo-Flow; alle 22 Tools zeichnen jetzt UEBER PixiJS auf das Overlay | ALLE GRUEN: 475/475 gesamt, tsc 0, build OK, E2E gruen | 8b1cca0 | C2 complete; naechster: C3 (Atlas/Dimensionen/Selection) oder C4 (Flag-Umdrehung Pixi=Default) |
|
|
||||||
| 2026-08-27 | C3fin | Selection-Handles + Snap-Marks im PixiRenderer: setSelection zeichnet pro selektiertem Element gestrichelte BBox-Umrandung + 4 Eck-Handles (gruen, skalenkompensiert); SelectionState um elements? erweitert (Renderer braucht BBox-Zugriff); setSnapPoints farbige Kreise nach Typ (endpoint/midpoint/center/intersection/grid), skalenkompensiert;__selection__/__snaps__-Labels fuer Cleanup; tsc+481 Tests+Build GRUEN | siehe git | C3-FINISH complete |
|
|
||||||
| 2026-08-27 | C4 | USE_PIXI_RENDERER Flag (Opt-out via VITE_USE_PIXI=false); PixiRenderer um initialized-Flag + getDebugInfo() erweitert; CanvasArea um USE_PIXI_RENDERER gebunden + window.__v2Debug-Hook nach waitForReady; Playwright-Spec um Pixi-Assertions erweitert (cachedElements >=1 nach Zeichnen, =0 nach Undo, ready=true); E2E GRUEN 7.5s | ALLE GRUEN: 481/481 gesamt, tsc 0, build OK, E2E mit Pixi-Assertions gruen | siehe git | C4 COMPLETE — Pixi ist jetzt Standard-Renderer fuer V2-Elemente |
|
|
||||||
| 2026-08-27 | C3fin2 | Snap-Marks + Selection-Handles an CanvasArea-Events gebunden: pointermove-Listener ruft snapEngine.snap + pixi.setSnapPoints (nur bei aktivem V2-Tool); selectionEngine.selectByIds gewrapt um pixi.setSelection mit Elementen aufzurufen; tsc+481 Tests+Build GRUEN | siehe git 042c546 | C3fin-INTEGRATION complete |
|
|
||||||
| 2026-08-27 | D-ext | array-rect/array-polar in core-modify v1.6.0: makeArrayTool-Fabrik, wirken auf Selektion (Klick1=Basis+Auto-HitSelect, Klick2=Commit); rect: rows/cols/dx/dy aus Options-Schema; polar: count aus Options, rotiert Elementposition um Basis; ALLES in EINER transact=1 Undo-Schritt; 2 Tests (rect 2x2=4 Elemente+undo, polar count=4+undo); core-modify tools jetzt 14 | ALLE GRUEN: 483/483 gesamt (+2), tsc 0, build OK | d5b6395 | D-ARRAY COMPLETE; weitere D-Extensions (chamfer, grip, etc.) folgen |
|
|
||||||
| 2026-08-27 | D-ext3 | join/explode/align in core-modify v1.8.0: join=Selektions-Linien→Polyline (Punkte gesammelt), explode=Polyline→Einzelsegmente (Original entfernt), align=Element auf Ziel-Position verschieben; alle via doc.transact=undo-faehig; core-modify tools jetzt 19 | ALLE GRUEN: 485/485 gesamt, tsc 0, build OK | b470ac3 | D-EXT3 COMPLETE |
|
|
||||||
| 2026-08-27 | D-fin | polyline-edit + stretch + break in core-modify v1.9.0: polyline-edit (Vertex ziehen mit Preview + doc.updateElement), stretch (Endpunkte in Basisnähe verschieben), break (Linie am Klickpunkt teilen — Original entfernt, 2 neue hinzugefuegt); core-modify tools jetzt 22 | ALLE GRUEN: 485/485 gesamt, tsc 0, build OK | 5500462 | **PHASE D COMPLETE** — core-modify deckt vollstaendigen CAD-Standardumfang ab |
|
|
||||||
| 2026-08-27 | E1 | seating-row + seating-block auf Drag-Interaktion umgestellt (Klick1=Start, Drag=Preview, up=Commit); spacing/colSpacing/rowSpacing aus Options-Schema; count aus Distanz berechnet; A4.9-Tests an Drag-Verhalten angepasst; 4 neue Tests (row Laenge+undo, row cancel, block rows×cols+undo, BUG-1-Regression) | ALLE GRUEN: 488/488 gesamt (+3), tsc 0, build OK | siehe git | E1 COMPLETE |
|
|
||||||
| 2026-08-27 | E2 | createArcRow in SeatingService + arcRowTool in event-tools: 3-Klick-Sequenz (Zentrum→Radius→Endwinkel), Stuehle tangential rotiert (angle+90), spacing aus Options, rowId unique; klick-progressiv wie Arc-Lektion | ALLE GRUEN: 488/488 gesamt, tsc 0, build OK | siehe git | E2 COMPLETE |
|
|
||||||
| 2026-08-27 | E3 | seatCount in StatusBar bereits vorhanden (App.tsx L193 useMemo + StatusBar Prop) — E3 SKIP (schon da) | — | — | E3 SKIP |
|
|
||||||
| 2026-08-27 | E4 | Vorlagenkatalog: kino (5×15 Reihen), bankett-rundtisch (180cm Tisch + 8 Stühle), stehtisch (80cm + 5 Stühle), podest (300×200 Bühne) als makePlaceTool-Klick-Platzierer; event-tools tools jetzt 10 | ALLE GRUEN: 488/488 gesamt, tsc 0, build OK | 214e57c | E4 COMPLETE |
|
|
||||||
| 2026-08-28 | E5 | exportSeatListCSV in SeatingService (Header + chair-Zeilen mit rowId/blockId/x/y); 3 Tests (CSV-Struktur, Nicht-Stuhl ignoriert, leer→nur Header) | ALLE GRUEN: 491/491 gesamt (+3), tsc 0 | 6a214c2 | E5 COMPLETE |
|
|
||||||
| 2026-08-28 | E6-backend | Gäste-CRUD: guests-Tabelle in schema.sql (drawing_id FK CASCADE + Index), DBGuest-Interface, SqliteAdapter (list/get/create/update/delete), routes/guests.ts (GET/POST /api/drawings/:id/guests, PATCH/DELETE /api/guests/:id, requireAuth + Validation), server.ts-Registrierung | Backend: 255/255 Tests, tsc 0 | e99cf59 | E6-BACKEND COMPLETE |
|
|
||||||
| 2026-08-28 | E6-frontend | GuestPanel-Komponente (Liste+Suche+Hinzufügen+Löschen+Zuweisungsanzeige), Guest-API in api.ts (list/create/update/deleteGuest), App.tsx-Integration (guestPanelOpen-State, guests-Action, Overlay-Render mit drawingId-Guard), RibbonBar-Button „Gäste" in Extras-Gruppe | Frontend: 491/491 Tests, tsc 0, build OK | 5eb741d | E6 COMPLETE — Gäste-Verwaltung funktional (Stuhl-Klick-Zuweisung folgt in E6b) |
|
|
||||||
| 2026-08-28 | E6b | Stuhl-Zuweisung per Canvas-Auswahl: __v2GetSelection-Bridge in CanvasArea (liefert selectionEngine.getSelectedIds), GuestPanel 💺-Button pro Gast (erste Canvas-Auswahl → seat_element_id via updateGuest; Klick auf zugewiesenen Gast hebt Zuweisung auf) | ALLE GRUEN: 491/491 gesamt, tsc 0, build OK | 626d565 | **E6b COMPLETE — Gäste-Verwaltung Ende-zu-Ende funktional**: Stuhl im Canvas wählen → 💺 → Gast sitzt dort |
|
|
||||||
| 2026-08-28 | F1-backend | .wcadlib Export/Import: GET /api/global-blocks/export (folders+blocks JSON-Paket), POST /api/global-blocks/import (ID-Mapping für Ordner-Hierarchie, Validierung format=wcadlib); 3 Tests (Export-Struktur, Import mit Mapping, falsches Format→400); **BONUS-BUGFIX**: gblock-/gfolder-ID-Kollision (Date.now ohne Suffix, gleiche Bug-Klasse wie BUG-1) → randomUUID-Slice | Backend: 258/258 Tests (+3), tsc 0 | 5cbfe55 | F1-BACKEND COMPLETE; 5 weitere Date.now()-IDs (user/proj/pfolder/draw/layer/block) als Nebenproblem notiert |
|
|
||||||
| 2026-08-28 | F1-frontend | BlockLibraryTree: Export-Button (lädt .wcadlib herunter) + Import-Button (.wcadlib-File-Input → importLibrary → loadData-Refresh) + API-Funktionen exportLibrary/importLibrary in api.ts | Frontend: 491/491 Tests, tsc 0, build OK | 63c8968 | **F1 COMPLETE — Library Export/Import Ende-zu-Ende**: Bibliothek als .wcadlib-Paket teilen/archivieren/einspielen |
|
|
||||||
| 2026-08-27 | C3 | elementToPrimitives erweitert um fehlende Typen: dimension (Linie + Label aus value/text), leader (Linie + Label am Ende), revcloud (geschlossener Punktzug), chair (gefuelltes Rechteck #4a90d9), table (Kreis #8b7355), stage (dunkles Rechteck #3a3a4a); Legacy-Konventionen beibehalten; 6 neue Tests | ALLE GRUEN: 481/481 gesamt (+6), tsc 0, build OK | siehe git | Naechster Task: C4 (Flag-Umdrehung Pixi=Default) oder Selection-Handles/Snap-Marks |
|
|
||||||
| 2026-08-28 | F2-backend | Block-Favoriten: schema.sql is_favorite INTEGER DEFAULT 0 + SqliteAdapter-Migration (PRAGMA table_info-Muster) für Bestands-DBs, mapGlobalBlockRow 0/1→boolean, createGlobalBlock/updateGlobalBlock erweitert, DBGlobalBlock.is_favorite: boolean, PATCH /api/global-blocks/:id validiert is_favorite (nicht-boolean→400); ROT-Beweis: 5 Tests zuerst ROT (is_favorite fehlte komplett), dann GRUEN | Backend: 263/263 Tests (+5), tsc 0 | 83a94e4 | F2-BACKEND COMPLETE |
|
|
||||||
| 2026-08-28 | F2-frontend | BlockLibraryTree: Suchfeld (global-lib-toolbar, filtert root+folder+favorites), ★/☆-Favorite-Toggle je Block (setGlobalBlockFavorite-API, optimistisches UI-Update), Favoriten-Sektion mit goldenem Titel, SVG-Thumbnail-Vorschau (tree-thumb, dangerouslySetInnerHTML, Fallback 📦), Grid-Ansicht (lib-grid, Thumbnail-Zellen mit Drag-Drop + Kontextmenü + Favorit); GlobalBlock.is_favorite: boolean in api.ts | Frontend: 491/491 Tests, tsc 0, build OK | 8b50c0a | **F2 COMPLETE — LibraryPanel-Kernfeatures**: Baum+Suche+Favoriten+Thumbnails+Grid+DragDrop→Canvas (Drop-Handler existierte bereits) |
|
|
||||||
| 2026-08-28 | F3 | blockThumbnail.ts NEU (generateBlockSvg/primitivesToSvg: Elemente→elementToPrimitives→eigenständiges SVG mit PAD-BBox, alle 6 Primitive-Kinds, XML-Escape, Arc→path mit Konvention 0°=3Uhr/CCW); Save-Button 💾 im Bibliotheks-Header (liest __v2GetSelection + documentService.getActiveDocument, Name via prompt, block_data=Element-Array → Drop-Handler akzeptiert Arrays, svg_data=Thumbnail, createGlobalBlock+loadData-Refresh); Dokumentierte Abweichung: SVG statt canvas.toDataURL (konsistent, testbar); ROT-Beweis: 4 Tests zuerst fehlgeschlagen (Modul fehlte), 1 Erwartung nach Primitive-BBox-Analyse korrigiert (-50 -50 560 360); Fix: Duplikat-Import + Handler vor loadData-Deklaration (tsc 2300/2448) | Frontend: 495/495 Tests (+4), tsc 0, build OK | 5d8253a | **F3 COMPLETE — Auswahl als Block speichern**: Canvas-Selektion → wiederverwendbarer Block mit Thumbnail, sofort per DragDrop einsetzbar |
|
|
||||||
| 2026-08-28 | ids | Nebenproblem „Date.now()-IDs“ erledigt: Alle 9 verbleibenden Auto-ID-Generierungen im SqliteAdapter (user/proj/pfolder/draw/layer/elem/block/notif/share) auf etabliertes randomUUID().slice(0,8)-Muster umgestellt (gleiche BUG-1-Kollisionsklasse wie gblock-Fix in F1); Deterministischer ROT-Beweis: 4 neue Tests mit vi.useFakeTimers+EINGEFRORENER Systemzeit (zwei Creates im selben Tick → UNIQUE-PK-Exception mit Date.now, eindeutig mit UUID); perl-Escape-Fehler (\${p} literal) selbst erkannt und zeilenweise korrigiert | Backend: 267/267 Tests (+4), tsc 0 | c78d8d8 | **BUG-1-KLASSE GESCHLOSSEN** — keine Date.now()-Auto-IDs mehr im Backend |
|
|
||||||
|
|
||||||
## Gefundene Nebenprobleme (noch nicht zugewiesen)
|
|
||||||
- `webhook-receiver.py` im Root: Zweck unklar, ggf. entfernen/dokumentieren (Frage an Nutzer)
|
|
||||||
- `phase2..4-task-spec.md` veraltet gegenüber BAUPLAN — nach v2.0 archivieren
|
|
||||||
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
|
||||||
|
|
||||||
---
|
|
||||||
*Nächster offener Schritt: **I3** (Release 2.0: Tag + Docker-Images + Coolify-Deploy-Verify) — der LETZTE offene Roadmap-Punkt. Alle Phasen A–H + I1 + I2 sind komplett.*
|
|
||||||
*Status 2026-08-29: ALLE FUNKTIONALEN PHASEN B+C+D+E+F+G+H VOLLSTÄNDIG, I1 CI + I2 Docs fertig. FE 697/697 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main. Offen: NUR NOCH I3 (Release 2.0).*
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.git
|
|
||||||
*.md
|
|
||||||
*.log
|
|
||||||
+11
-7
@@ -1,9 +1,13 @@
|
|||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache wget
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm ci --only=production
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
|
||||||
EXPOSE 3001
|
EXPOSE 5000
|
||||||
CMD ["node", "dist/index.js"]
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const { Sequelize } = require('sequelize');
|
||||||
|
|
||||||
|
const sequelize = new Sequelize(
|
||||||
|
process.env.DB_NAME || 'webcad',
|
||||||
|
process.env.DB_USER || 'webcad',
|
||||||
|
process.env.DB_PASSWORD || 'webcad',
|
||||||
|
{
|
||||||
|
host: process.env.DB_HOST || 'postgres',
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
dialect: 'postgres',
|
||||||
|
logging: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectDB = async () => {
|
||||||
|
try {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('PostgreSQL connected.');
|
||||||
|
await sequelize.sync();
|
||||||
|
console.log('Database synced.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DB connection error:', err.message);
|
||||||
|
setTimeout(connectDB, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { sequelize, connectDB };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const passport = require('passport');
|
||||||
|
const JwtStrategy = require('passport-jwt').Strategy;
|
||||||
|
const ExtractJwt = require('passport-jwt').ExtractJwt;
|
||||||
|
const User = require('../models/User');
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
secretOrKey: process.env.JWT_SECRET || 'dev_secret_change_in_production',
|
||||||
|
};
|
||||||
|
|
||||||
|
passport.use(new JwtStrategy(opts, async (jwt_payload, done) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findByPk(jwt_payload.id);
|
||||||
|
if (user) {
|
||||||
|
return done(null, user);
|
||||||
|
}
|
||||||
|
return done(null, false);
|
||||||
|
} catch (err) {
|
||||||
|
return done(err, false);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
module.exports = passport;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const passport = require('passport');
|
||||||
|
|
||||||
|
// Protect routes with JWT
|
||||||
|
const authenticate = passport.authenticate('jwt', { session: false });
|
||||||
|
|
||||||
|
module.exports = authenticate;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/database');
|
||||||
|
const User = require('./User');
|
||||||
|
|
||||||
|
const Block = sequelize.define('Block', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
svg_data: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 'Allgemein',
|
||||||
|
},
|
||||||
|
is_public: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Block.belongsTo(User, { foreignKey: 'userId', allowNull: true, onDelete: 'SET NULL' });
|
||||||
|
User.hasMany(Block, { foreignKey: 'userId' });
|
||||||
|
|
||||||
|
module.exports = Block;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/database');
|
||||||
|
const User = require('./User');
|
||||||
|
|
||||||
|
const Drawing = sequelize.define('Drawing', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'Unbenannt',
|
||||||
|
},
|
||||||
|
canvas_json: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Drawing.belongsTo(User, { foreignKey: 'userId', onDelete: 'CASCADE' });
|
||||||
|
User.hasMany(Drawing, { foreignKey: 'userId' });
|
||||||
|
|
||||||
|
module.exports = Drawing;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/database');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const User = sequelize.define('User', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
unique: true,
|
||||||
|
allowNull: false,
|
||||||
|
validate: { isEmail: true },
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
User.beforeCreate(async (user) => {
|
||||||
|
user.password = await bcrypt.hash(user.password, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
User.prototype.validatePassword = async function (password) {
|
||||||
|
return bcrypt.compare(password, this.password);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = User;
|
||||||
Generated
+1324
-2872
File diff suppressed because it is too large
Load Diff
+19
-28
@@ -1,34 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "web-cad-backend",
|
"name": "backend",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"description": "",
|
||||||
"type": "module",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
"dev:ci": "tsx 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": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"bcryptjs": "^3.0.3",
|
||||||
"@fastify/static": "^9.1.3",
|
"cors": "^2.8.6",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"dxf-parser": "^1.1.2",
|
||||||
"bcrypt": "^6.0.0",
|
"express": "^5.2.1",
|
||||||
"better-sqlite3": "^11.0.0",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"fastify": "^5.9.0",
|
"multer": "^2.1.1",
|
||||||
"jszip": "^3.10.1",
|
"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"
|
"pg": "^8.21.0",
|
||||||
},
|
"sequelize": "^6.37.8"
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Dimension Tool",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Fügt ein Bemaßungswerkzeug für horizontale und vertikale Messungen hinzu.",
|
||||||
|
"author": "Agent Zero"
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
*{box-sizing:border-box}body{background:#f0f2f5;margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<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" />
|
||||||
|
<title>frontend</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-Bdiu6cTh.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-X7gJsvPn.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const authenticate = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_change_in_production';
|
||||||
|
|
||||||
|
// Register
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
}
|
||||||
|
const existing = await User.findOne({ where: { email } });
|
||||||
|
if (existing) {
|
||||||
|
return res.status(409).json({ error: 'User already exists' });
|
||||||
|
}
|
||||||
|
const user = await User.create({ email, password });
|
||||||
|
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
res.status(201).json({ token, user: { id: user.id, email: user.email } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Registration failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
}
|
||||||
|
const user = await User.findOne({ where: { email } });
|
||||||
|
if (!user || !(await user.validatePassword(password))) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
res.json({ token, user: { id: user.id, email: user.email } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Login failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
router.get('/me', authenticate, async (req, res) => {
|
||||||
|
res.json({ id: req.user.id, email: req.user.email });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const authenticate = require('../middleware/auth');
|
||||||
|
const Block = require('../models/Block');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Public blocks (no auth for listing)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const blocks = await Block.findAll({
|
||||||
|
where: { is_public: true },
|
||||||
|
attributes: ['id', 'name', 'svg_data', 'category'],
|
||||||
|
});
|
||||||
|
res.json(blocks);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to list blocks' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Protected: create a new block (requires auth)
|
||||||
|
router.post('/', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, svg_data, category, is_public } = req.body;
|
||||||
|
if (!name || !svg_data) {
|
||||||
|
return res.status(400).json({ error: 'Name and svg_data required' });
|
||||||
|
}
|
||||||
|
const block = await Block.create({
|
||||||
|
name,
|
||||||
|
svg_data,
|
||||||
|
category: category || 'Allgemein',
|
||||||
|
is_public: is_public !== undefined ? is_public : true,
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
res.status(201).json(block);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to create block' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Protected: delete block (only owner)
|
||||||
|
router.delete('/:id', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const block = await Block.findOne({
|
||||||
|
where: { id: req.params.id, userId: req.user.id },
|
||||||
|
});
|
||||||
|
if (!block) {
|
||||||
|
return res.status(404).json({ error: 'Block not found or not authorized' });
|
||||||
|
}
|
||||||
|
await block.destroy();
|
||||||
|
res.json({ message: 'Block deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete block' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const authenticate = require('../middleware/auth');
|
||||||
|
const Drawing = require('../models/Drawing');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// List drawings for current user
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const drawings = await Drawing.findAll({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
attributes: ['id', 'name', 'updatedAt'],
|
||||||
|
order: [['updatedAt', 'DESC']],
|
||||||
|
});
|
||||||
|
res.json(drawings);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to list drawings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new drawing
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, canvas_json } = req.body;
|
||||||
|
const drawing = await Drawing.create({
|
||||||
|
name: name || 'Unbenannt',
|
||||||
|
canvas_json: canvas_json || '',
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
res.status(201).json(drawing);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to create drawing' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single drawing
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const drawing = await Drawing.findOne({
|
||||||
|
where: { id: req.params.id, userId: req.user.id },
|
||||||
|
});
|
||||||
|
if (!drawing) {
|
||||||
|
return res.status(404).json({ error: 'Drawing not found' });
|
||||||
|
}
|
||||||
|
res.json(drawing);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to get drawing' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update drawing
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const drawing = await Drawing.findOne({
|
||||||
|
where: { id: req.params.id, userId: req.user.id },
|
||||||
|
});
|
||||||
|
if (!drawing) {
|
||||||
|
return res.status(404).json({ error: 'Drawing not found' });
|
||||||
|
}
|
||||||
|
const { name, canvas_json } = req.body;
|
||||||
|
if (name !== undefined) drawing.name = name;
|
||||||
|
if (canvas_json !== undefined) drawing.canvas_json = canvas_json;
|
||||||
|
await drawing.save();
|
||||||
|
res.json(drawing);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to update drawing' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete drawing
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const drawing = await Drawing.findOne({
|
||||||
|
where: { id: req.params.id, userId: req.user.id },
|
||||||
|
});
|
||||||
|
if (!drawing) {
|
||||||
|
return res.status(404).json({ error: 'Drawing not found' });
|
||||||
|
}
|
||||||
|
await drawing.destroy();
|
||||||
|
res.json({ message: 'Drawing deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete drawing' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const authenticate = require('../middleware/auth');
|
||||||
|
const multer = require('multer');
|
||||||
|
const { parseString } = require('dxf-parser');
|
||||||
|
const router = express.Router();
|
||||||
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
|
||||||
|
|
||||||
|
// Upload DXF file -> parse and return JSON representation (Fabric.js compatible)
|
||||||
|
router.post('/import', authenticate, upload.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
const dxfContent = req.file.buffer.toString('utf-8');
|
||||||
|
console.log('DXF file size:', dxfContent.length);
|
||||||
|
const parsed = parseString(dxfContent);
|
||||||
|
// Convert parsed entities to Fabric.js objects (simplified)
|
||||||
|
const fabricObjects = [];
|
||||||
|
if (parsed && parsed.entities) {
|
||||||
|
parsed.entities.forEach(entity => {
|
||||||
|
const type = entity.type;
|
||||||
|
if (type === 'LINE') {
|
||||||
|
fabricObjects.push({
|
||||||
|
type: 'line',
|
||||||
|
x1: entity.start.x, y1: entity.start.y,
|
||||||
|
x2: entity.end.x, y2: entity.end.y,
|
||||||
|
stroke: '#000', strokeWidth: 1,
|
||||||
|
});
|
||||||
|
} else if (type === 'CIRCLE') {
|
||||||
|
fabricObjects.push({
|
||||||
|
type: 'circle',
|
||||||
|
left: entity.center.x - entity.radius,
|
||||||
|
top: entity.center.y - entity.radius,
|
||||||
|
radius: entity.radius,
|
||||||
|
fill: 'transparent', stroke: '#000',
|
||||||
|
});
|
||||||
|
} else if (type === 'ARC') {
|
||||||
|
// simplified arc handling
|
||||||
|
fabricObjects.push({
|
||||||
|
type: 'path',
|
||||||
|
path: `M ${entity.center.x} ${entity.center.y}`, // placeholder
|
||||||
|
stroke: '#000', strokeWidth: 1,
|
||||||
|
});
|
||||||
|
} else if (type === 'POLYLINE' || type === 'LWPOLYLINE') {
|
||||||
|
// convert vertices to polygon
|
||||||
|
if (entity.vertices && entity.vertices.length >= 2) {
|
||||||
|
const points = entity.vertices.map(v => ({ x: v.x, y: v.y }));
|
||||||
|
fabricObjects.push({
|
||||||
|
type: 'polygon',
|
||||||
|
points,
|
||||||
|
stroke: '#000', strokeWidth: 1, fill: 'transparent',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ objects: fabricObjects });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DXF import error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to parse DXF file: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export canvas JSON to DXF string
|
||||||
|
router.post('/export', authenticate, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { objects } = req.body;
|
||||||
|
if (!objects || !Array.isArray(objects)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid objects array' });
|
||||||
|
}
|
||||||
|
// Build DXF content
|
||||||
|
let dxf = '0\nSECTION\n2\nHEADER\n0\nENDSEC\n';
|
||||||
|
dxf += '0\nSECTION\n2\nTABLES\n0\nENDSEC\n';
|
||||||
|
dxf += '0\nSECTION\n2\nBLOCKS\n0\nENDSEC\n';
|
||||||
|
dxf += '0\nSECTION\n2\nENTITIES\n';
|
||||||
|
objects.forEach(obj => {
|
||||||
|
if (obj.type === 'line') {
|
||||||
|
dxf += `0\nLINE\n8\n0\n10\n${obj.x1}\n20\n${obj.y1}\n11\n${obj.x2}\n21\n${obj.y2}\n`;
|
||||||
|
} else if (obj.type === 'circle') {
|
||||||
|
const cx = obj.left + obj.radius;
|
||||||
|
const cy = obj.top + obj.radius;
|
||||||
|
dxf += `0\nCIRCLE\n8\n0\n10\n${cx}\n20\n${cy}\n40\n${obj.radius}\n`;
|
||||||
|
} else if (obj.type === 'polygon') {
|
||||||
|
// LWPOLYLINE
|
||||||
|
dxf += `0\nLWPOLYLINE\n8\n0\n90\n${obj.points.length}\n`;
|
||||||
|
obj.points.forEach((pt, i) => {
|
||||||
|
dxf += `10\n${pt.x}\n20\n${pt.y}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dxf += '0\nENDSEC\n0\nEOF\n';
|
||||||
|
res.setHeader('Content-Type', 'application/dxf');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename=drawing.dxf');
|
||||||
|
res.send(dxf);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DXF export error:', err);
|
||||||
|
res.status(500).json({ error: 'Export failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const PLUGINS_DIR = path.join(__dirname, '..', 'plugins');
|
||||||
|
|
||||||
|
// Ensure plugins directory exists
|
||||||
|
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||||
|
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all available plugins
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
const plugins = [];
|
||||||
|
const dirs = fs.readdirSync(PLUGINS_DIR);
|
||||||
|
for (const dir of dirs) {
|
||||||
|
const pluginPath = path.join(PLUGINS_DIR, dir);
|
||||||
|
const manifestPath = path.join(pluginPath, 'plugin.json');
|
||||||
|
if (fs.existsSync(manifestPath)) {
|
||||||
|
try {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||||
|
plugins.push({ id: dir, ...manifest });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Invalid plugin manifest: ${dir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json(plugins);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Plugin list error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to list plugins' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve plugin code as JavaScript module
|
||||||
|
router.get('/:id/code', (req, res) => {
|
||||||
|
try {
|
||||||
|
const pluginDir = path.join(PLUGINS_DIR, req.params.id);
|
||||||
|
const codePath = path.join(pluginDir, 'plugin.js');
|
||||||
|
if (!fs.existsSync(codePath)) {
|
||||||
|
return res.status(404).json({ error: 'Plugin code not found' });
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'application/javascript');
|
||||||
|
res.sendFile(codePath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Plugin code error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load plugin code' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
const { sequelize, connectDB } = require('./config/database');
|
||||||
|
const Block = require('./models/Block');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
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();
|
||||||
|
// Use raw SQL to create user, bcrypt hashing via sequelize might fail, so we do raw
|
||||||
|
const hashedPassword = bcrypt.hashSync('admin123', 10);
|
||||||
|
await sequelize.query(`
|
||||||
|
INSERT INTO "Users" ("email", "password", "createdAt", "updatedAt")
|
||||||
|
VALUES (:email, :password, datetime('now'), datetime('now'))
|
||||||
|
ON CONFLICT ("email") DO NOTHING;
|
||||||
|
`, {
|
||||||
|
replacements: { email: 'admin@cad.local', password: hashedPassword },
|
||||||
|
type: sequelize.QueryTypes.INSERT
|
||||||
|
});
|
||||||
|
console.log('Admin user ensured.');
|
||||||
|
// Clear existing blocks
|
||||||
|
await Block.destroy({ where: {} });
|
||||||
|
console.log('Existing blocks cleared.');
|
||||||
|
for (const block of eventBlocks) {
|
||||||
|
await Block.create(block);
|
||||||
|
}
|
||||||
|
console.log(`${eventBlocks.length} blocks created.`);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Seed failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seed();
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const passport = require('passport');
|
||||||
|
require('./config/passport'); // Load JWT strategy
|
||||||
|
const User = require('./models/User');
|
||||||
|
const Block = require('./models/Block');
|
||||||
|
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());
|
||||||
|
|
||||||
|
// Serve static frontend
|
||||||
|
app.use(express.static('public'));
|
||||||
|
|
||||||
|
// 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' }));
|
||||||
|
|
||||||
|
// SPA fallback – serve index.html for all non-API routes
|
||||||
|
app.get(/^(?!\/api\/).*/, (req, res) => {
|
||||||
|
res.sendFile('public/index.html', { root: __dirname });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect DB and start
|
||||||
|
connectDB().then(() => sequelize.sync({ force: false })).then(async () => {
|
||||||
|
// Seed admin user if not exists
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const existing = await User.findOne({ where: { email: 'admin@cad.local' } });
|
||||||
|
if (!existing) {
|
||||||
|
await User.create({ email: 'admin@cad.local', password: 'admin123' });
|
||||||
|
console.log('Admin user created: admin@cad.local / admin123');
|
||||||
|
}
|
||||||
|
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Unable to start server:', err);
|
||||||
|
});
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* AuthService – Registration, Login, Session Management
|
|
||||||
*/
|
|
||||||
import bcrypt from 'bcrypt';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import type { DatabaseInterface, DBUser, DBSession } from '../database/DatabaseInterface.js';
|
|
||||||
|
|
||||||
const SALT_ROUNDS = 10;
|
|
||||||
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
||||||
|
|
||||||
export interface Session {
|
|
||||||
token: string;
|
|
||||||
userId: string;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthResult {
|
|
||||||
user: Omit<DBUser, 'password_hash'>;
|
|
||||||
session: Session;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthService {
|
|
||||||
constructor(private db: DatabaseInterface) {}
|
|
||||||
|
|
||||||
async register(email: string, password: string, name: string, role: string = 'planer'): Promise<AuthResult> {
|
|
||||||
const existing = this.db.getUserByEmail(email);
|
|
||||||
if (existing) {
|
|
||||||
throw new Error('Email already registered');
|
|
||||||
}
|
|
||||||
|
|
||||||
const password_hash = await bcrypt.hash(password, SALT_ROUNDS);
|
|
||||||
const user = this.db.createUser({ email, password_hash, name, role });
|
|
||||||
const session = this.createSession(user.id);
|
|
||||||
return { user: this.sanitizeUser(user), session };
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(email: string, password: string): Promise<AuthResult> {
|
|
||||||
const user = this.db.getUserByEmail(email);
|
|
||||||
if (!user) {
|
|
||||||
throw new Error('Invalid credentials');
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = await bcrypt.compare(password, user.password_hash);
|
|
||||||
if (!valid) {
|
|
||||||
throw new Error('Invalid credentials');
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = this.createSession(user.id);
|
|
||||||
return { user: this.sanitizeUser(user), session };
|
|
||||||
}
|
|
||||||
|
|
||||||
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
|
|
||||||
const user = this.db.getUser(userId);
|
|
||||||
if (!user) throw new Error('User not found');
|
|
||||||
|
|
||||||
const valid = await bcrypt.compare(oldPassword, user.password_hash);
|
|
||||||
if (!valid) throw new Error('Invalid current password');
|
|
||||||
|
|
||||||
const password_hash = await bcrypt.hash(newPassword, SALT_ROUNDS);
|
|
||||||
this.db.updateUser(userId, { password_hash });
|
|
||||||
}
|
|
||||||
|
|
||||||
createSession(userId: string): Session {
|
|
||||||
const token = randomUUID();
|
|
||||||
const expiresAt = Date.now() + SESSION_TTL_MS;
|
|
||||||
this.db.createSession({ token, user_id: userId, expires_at: expiresAt });
|
|
||||||
return { token, userId, expiresAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
validateSession(token: string): Session | null {
|
|
||||||
const dbSession = this.db.getSession(token);
|
|
||||||
if (!dbSession) return null;
|
|
||||||
if (Date.now() > dbSession.expires_at) {
|
|
||||||
this.db.deleteSession(token);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
token: dbSession.token,
|
|
||||||
userId: dbSession.user_id,
|
|
||||||
expiresAt: dbSession.expires_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
destroySession(token: string): void {
|
|
||||||
this.db.deleteSession(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
getUserFromSession(token: string): DBUser | null {
|
|
||||||
const session = this.validateSession(token);
|
|
||||||
if (!session) return null;
|
|
||||||
return this.db.getUser(session.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sanitizeUser(user: DBUser): Omit<DBUser, 'password_hash'> {
|
|
||||||
const { password_hash, ...safe } = user;
|
|
||||||
return safe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared Auth Middleware – extractToken, requireAuth, requireAdmin
|
|
||||||
*/
|
|
||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
||||||
import type { AuthService } from './AuthService.js';
|
|
||||||
import type { DBUser } from '../database/DatabaseInterface.js';
|
|
||||||
|
|
||||||
export function extractToken(request: FastifyRequest): string | null {
|
|
||||||
const auth = request.headers?.authorization;
|
|
||||||
if (auth && auth.startsWith('Bearer ')) {
|
|
||||||
return auth.slice(7);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require any authenticated user (valid session).
|
|
||||||
* Returns the user object or null (and sends error reply).
|
|
||||||
*/
|
|
||||||
export function requireAuth(request: FastifyRequest, reply: FastifyReply, authService: AuthService): DBUser | null {
|
|
||||||
const token = extractToken(request);
|
|
||||||
if (!token) {
|
|
||||||
reply.code(401).send({ error: 'Authentication required' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const user = authService.getUserFromSession(token);
|
|
||||||
if (!user) {
|
|
||||||
reply.code(401).send({ error: 'Invalid or expired session' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require admin role (valid session + admin role).
|
|
||||||
* Returns the user object or null (and sends error reply).
|
|
||||||
*/
|
|
||||||
export function requireAdmin(request: FastifyRequest, reply: FastifyReply, authService: AuthService): DBUser | null {
|
|
||||||
const user = requireAuth(request, reply, authService);
|
|
||||||
if (!user) return null;
|
|
||||||
if (user.role !== 'admin') {
|
|
||||||
reply.code(403).send({ error: 'Admin access required' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require library-admin role (Task H3: global library write guard).
|
|
||||||
* Library-admin is bound to the existing admin role: only admins may
|
|
||||||
* write (create/update/delete/import) the GLOBAL block library, while
|
|
||||||
* every authenticated user may read/export it. Own drawing-scoped
|
|
||||||
* blocks are unaffected (per-user ownership there).
|
|
||||||
*/
|
|
||||||
export function requireLibraryAdmin(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: 'Library admin access required' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
/**
|
|
||||||
* Database Interface – abstract DB layer for Web CAD
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface DBProject {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
owner_id: string;
|
|
||||||
folder_id: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DBProjectFolder {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
parent_id: string | null;
|
|
||||||
owner_id: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DBDrawing {
|
|
||||||
id: string;
|
|
||||||
project_id: string;
|
|
||||||
name: string;
|
|
||||||
data_json: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DBLayer {
|
|
||||||
id: string;
|
|
||||||
drawing_id: string;
|
|
||||||
name: string;
|
|
||||||
visible: number;
|
|
||||||
locked: number;
|
|
||||||
color: string;
|
|
||||||
line_type: string;
|
|
||||||
transparency: number;
|
|
||||||
sort_order: number;
|
|
||||||
parent_id: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DBElement {
|
|
||||||
id: string;
|
|
||||||
drawing_id: string;
|
|
||||||
layer_id: string;
|
|
||||||
type: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
properties_json: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DBBlock {
|
|
||||||
id: string;
|
|
||||||
drawing_id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
category: string;
|
|
||||||
elements_json: string;
|
|
||||||
thumbnail: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DBSetting {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DBUser {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
password_hash: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DBSession {
|
|
||||||
token: string;
|
|
||||||
user_id: string;
|
|
||||||
expires_at: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DBNotification {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
read: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DBProjectShare {
|
|
||||||
id: string;
|
|
||||||
project_id: string;
|
|
||||||
shared_with_email: string;
|
|
||||||
shared_by: string;
|
|
||||||
permission: string;
|
|
||||||
share_token: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DBGlobalBlockFolder {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
parent_id: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DBGlobalBlock {
|
|
||||||
id: string;
|
|
||||||
folder_id: string | null;
|
|
||||||
name: string;
|
|
||||||
block_data: string;
|
|
||||||
svg_data: string | null;
|
|
||||||
is_favorite: boolean;
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Guests (Task E6)
|
|
||||||
listGuests(drawingId: string): DBGuest[];
|
|
||||||
getGuest(id: string): DBGuest | null;
|
|
||||||
createGuest(data: Partial<DBGuest>): DBGuest;
|
|
||||||
updateGuest(id: string, data: Partial<DBGuest>): DBGuest | null;
|
|
||||||
deleteGuest(id: string): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Guests (Task E6) ──────────────────────────────────
|
|
||||||
export interface DBGuest {
|
|
||||||
id: string;
|
|
||||||
drawing_id: string;
|
|
||||||
name: string;
|
|
||||||
email?: string;
|
|
||||||
category: string; // 'standard' | 'vip' | 'staff'
|
|
||||||
seat_element_id?: string; // Verknüpfung zu einem chair-Element
|
|
||||||
notes?: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
@@ -1,508 +0,0 @@
|
|||||||
/**
|
|
||||||
* SQLite Adapter – implements DatabaseInterface with better-sqlite3
|
|
||||||
*/
|
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { join, dirname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import type {
|
|
||||||
DatabaseInterface, DBProject, DBDrawing, DBLayer,
|
|
||||||
DBElement, DBBlock, DBSetting, DBUser, DBSession,
|
|
||||||
DBNotification, DBProjectShare,
|
|
||||||
DBGlobalBlockFolder, DBGlobalBlock,
|
|
||||||
DBGuest,
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
// Add is_favorite column to global_blocks table for existing databases (Task F2)
|
|
||||||
const gbColumns = this.db.prepare("PRAGMA table_info('global_blocks')").all() as { name: string }[];
|
|
||||||
if (!gbColumns.some(col => col.name === 'is_favorite')) {
|
|
||||||
this.db.exec('ALTER TABLE global_blocks ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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-${randomUUID().slice(0, 8)}`;
|
|
||||||
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-${randomUUID().slice(0, 8)}`;
|
|
||||||
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-${randomUUID().slice(0, 8)}`;
|
|
||||||
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-${randomUUID().slice(0, 8)}`;
|
|
||||||
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-${randomUUID().slice(0, 8)}`;
|
|
||||||
// Idempotent: if a layer with the same id already exists, return it.
|
|
||||||
// This prevents race conditions under React StrictMode double-render.
|
|
||||||
const existing = this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer | undefined;
|
|
||||||
if (existing) return existing;
|
|
||||||
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-${randomUUID().slice(0, 8)}`;
|
|
||||||
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-${randomUUID().slice(0, 8)}`;
|
|
||||||
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-${randomUUID().slice(0, 8)}`;
|
|
||||||
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-${randomUUID().slice(0, 8)}`;
|
|
||||||
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-${randomUUID().slice(0, 8)}`;
|
|
||||||
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 ─────────────────────────────────────
|
|
||||||
// Map DB row to DBGlobalBlock (is_favorite 0/1 → boolean, Task F2)
|
|
||||||
private mapGlobalBlockRow(row: Record<string, unknown>): DBGlobalBlock {
|
|
||||||
return { ...(row as unknown as DBGlobalBlock), is_favorite: Number(row.is_favorite ?? 0) === 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
listGlobalBlocks(folderId?: string | null): DBGlobalBlock[] {
|
|
||||||
if (folderId === undefined) {
|
|
||||||
return (this.db.prepare('SELECT * FROM global_blocks ORDER BY updated_at DESC').all() as Record<string, unknown>[]).map(r => this.mapGlobalBlockRow(r));
|
|
||||||
}
|
|
||||||
if (folderId === null) {
|
|
||||||
return (this.db.prepare('SELECT * FROM global_blocks WHERE folder_id IS NULL ORDER BY updated_at DESC').all() as Record<string, unknown>[]).map(r => this.mapGlobalBlockRow(r));
|
|
||||||
}
|
|
||||||
return (this.db.prepare('SELECT * FROM global_blocks WHERE folder_id = ? ORDER BY updated_at DESC').all(folderId) as Record<string, unknown>[]).map(r => this.mapGlobalBlockRow(r));
|
|
||||||
}
|
|
||||||
|
|
||||||
getGlobalBlock(id: string): DBGlobalBlock | null {
|
|
||||||
const row = this.db.prepare('SELECT * FROM global_blocks WHERE id = ?').get(id) as Record<string, unknown> | undefined;
|
|
||||||
return row ? this.mapGlobalBlockRow(row) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
createGlobalBlock(data: Partial<DBGlobalBlock>): DBGlobalBlock {
|
|
||||||
const id = data.id ?? `gblock-${randomUUID().slice(0, 8)}`;
|
|
||||||
this.db.prepare(
|
|
||||||
'INSERT INTO global_blocks (id, folder_id, name, block_data, svg_data, is_favorite) VALUES (?, ?, ?, ?, ?, ?)',
|
|
||||||
).run(id, data.folder_id ?? null, data.name ?? 'Global Block', data.block_data ?? '{}', data.svg_data ?? null, data.is_favorite ? 1 : 0);
|
|
||||||
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); }
|
|
||||||
if (data.is_favorite !== undefined) { sets.push('is_favorite = ?'); vals.push(data.is_favorite ? 1 : 0); }
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Guests (Task E6) ──────────────────────────────────
|
|
||||||
|
|
||||||
listGuests(drawingId: string): DBGuest[] {
|
|
||||||
return this.db.prepare('SELECT * FROM guests WHERE drawing_id = ? ORDER BY created_at').all(drawingId) as DBGuest[];
|
|
||||||
}
|
|
||||||
|
|
||||||
getGuest(id: string): DBGuest | null {
|
|
||||||
return (this.db.prepare('SELECT * FROM guests WHERE id = ?').get(id) as DBGuest) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
createGuest(data: Partial<DBGuest>): DBGuest {
|
|
||||||
const id = data.id ?? randomUUID();
|
|
||||||
const created_at = new Date().toISOString();
|
|
||||||
this.db.prepare(
|
|
||||||
'INSERT INTO guests (id, drawing_id, name, email, category, seat_element_id, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
||||||
).run(id, data.drawing_id!, data.name!, data.email ?? null, data.category ?? 'standard', data.seat_element_id ?? null, data.notes ?? null, created_at);
|
|
||||||
return this.getGuest(id)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateGuest(id: string, data: Partial<DBGuest>): DBGuest | null {
|
|
||||||
const existing = this.getGuest(id);
|
|
||||||
if (!existing) return null;
|
|
||||||
const merged = { ...existing, ...data, id: existing.id, drawing_id: existing.drawing_id, created_at: existing.created_at };
|
|
||||||
this.db.prepare(
|
|
||||||
'UPDATE guests SET name=?, email=?, category=?, seat_element_id=?, notes=? WHERE id=?'
|
|
||||||
).run(merged.name, merged.email ?? null, merged.category, merged.seat_element_id ?? null, merged.notes ?? null, id);
|
|
||||||
return this.getGuest(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteGuest(id: string): boolean {
|
|
||||||
return this.db.prepare('DELETE FROM guests WHERE id = ?').run(id).changes > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
-- Web CAD Backend – SQLite Schema
|
|
||||||
-- Version 1.0
|
|
||||||
|
|
||||||
PRAGMA journal_mode = WAL;
|
|
||||||
PRAGMA foreign_keys = ON;
|
|
||||||
|
|
||||||
-- ─── Users ──────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
email TEXT UNIQUE NOT NULL,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
role TEXT NOT NULL DEFAULT 'planer' CHECK(role IN ('admin','planer','betrachter','gast')),
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ─── Default User (seed) ────────────────────────────
|
|
||||||
-- Default user for foreign key constraints. Cannot login (empty password_hash
|
|
||||||
-- means bcrypt.compare always fails). Real users must register explicitly.
|
|
||||||
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,
|
|
||||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
|
||||||
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);
|
|
||||||
|
|
||||||
-- ─── Guests (Task E6) ────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS guests (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
drawing_id TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
email TEXT,
|
|
||||||
category TEXT DEFAULT 'standard',
|
|
||||||
seat_element_id TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_guests_drawing ON guests(drawing_id);
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Web CAD Backend – Entry Point
|
|
||||||
*/
|
|
||||||
import { createServer } from './server.js';
|
|
||||||
import { SqliteAdapter } from './database/SqliteAdapter.js';
|
|
||||||
import { initYjsPersistence, closeYjsPersistence } from './websocket/yjsServer.js';
|
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || '3001', 10);
|
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
|
||||||
const DB_PATH = process.env.DB_PATH || '/data/web-cad.db';
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const db = new SqliteAdapter(DB_PATH);
|
|
||||||
await db.init();
|
|
||||||
await initYjsPersistence();
|
|
||||||
|
|
||||||
const fastify = await createServer({ port: PORT, host: HOST, db });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fastify.listen({ port: PORT, host: HOST });
|
|
||||||
console.log(`Web CAD Backend running on http://${HOST}:${PORT}`);
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
const shutdown = async (signal: string) => {
|
|
||||||
console.log(`Received ${signal}, shutting down gracefully...`);
|
|
||||||
await fastify.close();
|
|
||||||
await closeYjsPersistence();
|
|
||||||
db.close();
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* AI Routes – KI Copilot chat endpoint via OpenRouter
|
|
||||||
*/
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
|
|
||||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
||||||
const MODEL = 'anthropic/claude-sonnet-4.5';
|
|
||||||
const TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
function extractToken(request: any): string | null {
|
|
||||||
const auth = request.headers?.authorization;
|
|
||||||
if (auth && auth.startsWith('Bearer ')) {
|
|
||||||
return auth.slice(7);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerAIRoutes(fastify: FastifyInstance, authService: AuthService) {
|
|
||||||
fastify.post('/api/ai/chat', async (request, reply) => {
|
|
||||||
// Auth check
|
|
||||||
const token = extractToken(request);
|
|
||||||
if (!token) {
|
|
||||||
return reply.code(401).send({ error: 'Authentication required' });
|
|
||||||
}
|
|
||||||
const user = authService.getUserFromSession(token);
|
|
||||||
if (!user) {
|
|
||||||
return reply.code(401).send({ error: 'Invalid or expired session' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { messages, context } = request.body as {
|
|
||||||
messages?: Array<{ role: string; content: string }>;
|
|
||||||
context?: {
|
|
||||||
projectName?: string;
|
|
||||||
elementCount?: number;
|
|
||||||
layerCount?: number;
|
|
||||||
elementTypeSummary?: Record<string, number>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
||||||
return reply.code(400).send({ error: 'Messages array is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = process.env.API_KEY_OPENROUTER;
|
|
||||||
if (!apiKey) {
|
|
||||||
return reply.code(503).send({ error: 'AI service not configured' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build system prompt with CAD context
|
|
||||||
const ctxParts: string[] = [
|
|
||||||
'Du bist der KI Copilot für Web CAD, eine webbasierte CAD-Anwendung für Event- und Raumplanung.',
|
|
||||||
'Du hilfst beim Anlegen von Bestuhlung, Räumen, Bühnen und anderen CAD-Elementen.',
|
|
||||||
'Antworte auf Deutsch, klar und präzise. Verwende Markdown für Formatierung wenn sinnvoll.',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (context) {
|
|
||||||
if (context.projectName) ctxParts.push(`Aktuelles Projekt: ${context.projectName}`);
|
|
||||||
if (context.elementCount !== undefined) ctxParts.push(`Elemente im Projekt: ${context.elementCount}`);
|
|
||||||
if (context.layerCount !== undefined) ctxParts.push(`Ebenen: ${context.layerCount}`);
|
|
||||||
if (context.elementTypeSummary) {
|
|
||||||
const summary = Object.entries(context.elementTypeSummary)
|
|
||||||
.map(([type, count]) => `${type}: ${count}`)
|
|
||||||
.join(', ');
|
|
||||||
ctxParts.push(`Element-Typen: ${summary}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemPrompt = ctxParts.join('\n');
|
|
||||||
|
|
||||||
// Build OpenRouter request
|
|
||||||
const payload = {
|
|
||||||
model: MODEL,
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: systemPrompt },
|
|
||||||
...messages.map((m) => ({ role: m.role, content: m.content })),
|
|
||||||
],
|
|
||||||
max_tokens: 1024,
|
|
||||||
temperature: 0.7,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
||||||
|
|
||||||
const response = await fetch(OPENROUTER_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
|
||||||
'HTTP-Referer': 'http://localhost:5173',
|
|
||||||
'X-Title': 'Web CAD KI Copilot',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errText = await response.text();
|
|
||||||
request.log.error({ errText, status: response.status }, 'OpenRouter error');
|
|
||||||
return reply.code(502).send({ error: 'AI service returned an error' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json() as any;
|
|
||||||
const content = data.choices?.[0]?.message?.content ?? '';
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
return reply.code(502).send({ error: 'AI service returned empty response' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract suggestions from response if present (simple heuristic)
|
|
||||||
const suggestions: string[] = [];
|
|
||||||
const lines = content.split('\n');
|
|
||||||
for (const line of lines) {
|
|
||||||
const match = line.match(/^[-*]\s*(.+)/);
|
|
||||||
if (match && suggestions.length < 3) {
|
|
||||||
suggestions.push(match[1].trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.send({ content, suggestions: suggestions.length > 0 ? suggestions : undefined });
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.name === 'AbortError') {
|
|
||||||
return reply.code(504).send({ error: 'AI service timeout' });
|
|
||||||
}
|
|
||||||
request.log.error({ err }, 'AI route error');
|
|
||||||
return reply.code(500).send({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auth Routes – Registration, Login, Logout, Profile
|
|
||||||
*/
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
import { checkLoginRateLimit, checkRegisterRateLimit, getClientIp } from '../utils/rateLimiter.js';
|
|
||||||
|
|
||||||
export function registerAuthRoutes(fastify: FastifyInstance, authService: AuthService) {
|
|
||||||
// Register new user
|
|
||||||
fastify.post('/api/auth/register', async (request, reply) => {
|
|
||||||
const ip = getClientIp(request);
|
|
||||||
const rateLimitErr = checkRegisterRateLimit(ip);
|
|
||||||
if (rateLimitErr) return reply.code(429).send({ error: rateLimitErr });
|
|
||||||
|
|
||||||
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 ip = getClientIp(request);
|
|
||||||
const rateLimitErr = checkLoginRateLimit(ip);
|
|
||||||
if (rateLimitErr) return reply.code(429).send({ error: rateLimitErr });
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* Blocks Routes – CRUD for reusable drawing blocks
|
|
||||||
*/
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { DatabaseInterface, DBBlock } from '../database/DatabaseInterface.js';
|
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
import { validateName, validateIdParam } from '../utils/validation.js';
|
|
||||||
|
|
||||||
export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
|
||||||
// List all blocks for a drawing
|
|
||||||
fastify.get('/api/drawings/:drawingId/blocks', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { drawingId } = request.params as { drawingId: string };
|
|
||||||
const idErr = validateIdParam(drawingId, 'drawingId');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
return db.listBlocks(drawingId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a new block
|
|
||||||
fastify.post('/api/drawings/:drawingId/blocks', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { drawingId } = request.params as { drawingId: string };
|
|
||||||
const idErr = validateIdParam(drawingId, 'drawingId');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const body = request.body as Partial<DBBlock>;
|
|
||||||
const nameErr = validateName(body.name, 'name');
|
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
|
||||||
return reply.code(201).send(db.createBlock({ ...body, drawing_id: drawingId }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update a block
|
|
||||||
fastify.patch('/api/blocks/:id', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const body = request.body as Partial<DBBlock>;
|
|
||||||
if (body.name !== undefined) {
|
|
||||||
const nameErr = validateName(body.name, 'name');
|
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
|
||||||
}
|
|
||||||
const updated = db.updateBlock(id, body);
|
|
||||||
if (!updated) return reply.code(404).send({ error: 'Block not found' });
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete a block
|
|
||||||
fastify.delete('/api/blocks/:id', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const ok = db.deleteBlock(id);
|
|
||||||
if (!ok) return reply.code(404).send({ error: 'Block not found' });
|
|
||||||
return reply.code(204).send();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* Drawings Routes – CRUD for drawings
|
|
||||||
*/
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { DatabaseInterface, DBDrawing } from '../database/DatabaseInterface.js';
|
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
import { validateName, validateIdParam } from '../utils/validation.js';
|
|
||||||
|
|
||||||
export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
|
||||||
// List drawings for a project
|
|
||||||
fastify.get('/api/projects/:projectId/drawings', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { projectId } = request.params as { projectId: string };
|
|
||||||
const idErr = validateIdParam(projectId, 'projectId');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
return db.listDrawings(projectId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get single drawing
|
|
||||||
fastify.get('/api/drawings/:id', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const drawing = db.getDrawing(id);
|
|
||||||
if (!drawing) return reply.code(404).send({ error: 'Drawing not found' });
|
|
||||||
return drawing;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create drawing
|
|
||||||
fastify.post('/api/projects/:projectId/drawings', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { projectId } = request.params as { projectId: string };
|
|
||||||
const idErr = validateIdParam(projectId, 'projectId');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const body = request.body as Partial<DBDrawing>;
|
|
||||||
if (body.name !== undefined) {
|
|
||||||
const nameErr = validateName(body.name);
|
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
|
||||||
}
|
|
||||||
return reply.code(201).send(db.createDrawing({ ...body, project_id: projectId }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update drawing
|
|
||||||
fastify.patch('/api/drawings/:id', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const body = request.body as Partial<DBDrawing>;
|
|
||||||
if (body.name !== undefined) {
|
|
||||||
const nameErr = validateName(body.name);
|
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
|
||||||
}
|
|
||||||
const updated = db.updateDrawing(id, body);
|
|
||||||
if (!updated) return reply.code(404).send({ error: 'Drawing not found' });
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete drawing
|
|
||||||
fastify.delete('/api/drawings/:id', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const ok = db.deleteDrawing(id);
|
|
||||||
if (!ok) return reply.code(404).send({ error: 'Drawing not found' });
|
|
||||||
return reply.code(204).send();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* Elements Routes – CRUD for elements
|
|
||||||
*/
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { DatabaseInterface, DBElement } from '../database/DatabaseInterface.js';
|
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
import { validateIdParam } from '../utils/validation.js';
|
|
||||||
|
|
||||||
export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
|
||||||
// List elements for a drawing
|
|
||||||
fastify.get('/api/drawings/:drawingId/elements', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { drawingId } = request.params as { drawingId: string };
|
|
||||||
const idErr = validateIdParam(drawingId, 'drawingId');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
return db.listElements(drawingId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create element
|
|
||||||
fastify.post('/api/drawings/:drawingId/elements', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { drawingId } = request.params as { drawingId: string };
|
|
||||||
const idErr = validateIdParam(drawingId, 'drawingId');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const body = request.body as Partial<DBElement>;
|
|
||||||
return reply.code(201).send(db.createElement({ ...body, drawing_id: drawingId }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update element
|
|
||||||
fastify.patch('/api/elements/:id', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const body = request.body as Partial<DBElement>;
|
|
||||||
const updated = db.updateElement(id, body);
|
|
||||||
if (!updated) return reply.code(404).send({ error: 'Element not found' });
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete element
|
|
||||||
fastify.delete('/api/elements/:id', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const ok = db.deleteElement(id);
|
|
||||||
if (!ok) return reply.code(404).send({ error: 'Element not found' });
|
|
||||||
return reply.code(204).send();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
/**
|
|
||||||
* Global Blocks Routes – CRUD for global block folders and blocks
|
|
||||||
*/
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
|
||||||
import { requireAuth, requireLibraryAdmin } from '../auth/authMiddleware.js';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
import { validateName, validateIdParam } from '../utils/validation.js';
|
|
||||||
import JSZip from 'jszip';
|
|
||||||
|
|
||||||
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 (!requireLibraryAdmin(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 (!requireLibraryAdmin(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 (!requireLibraryAdmin(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 (!requireLibraryAdmin(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 (!requireLibraryAdmin(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; is_favorite?: boolean };
|
|
||||||
if (body.name !== undefined) {
|
|
||||||
const nameErr = validateName(body.name, 'name');
|
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
|
||||||
}
|
|
||||||
if (body.is_favorite !== undefined && typeof body.is_favorite !== 'boolean') {
|
|
||||||
return reply.code(400).send({ error: 'is_favorite must be a boolean' });
|
|
||||||
}
|
|
||||||
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 (!requireLibraryAdmin(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();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Task F1: Library-Paket Export/Import (.wcadlib) ──────
|
|
||||||
|
|
||||||
// Export: komplette Library als JSON-Paket (Ordner + Blöcke)
|
|
||||||
fastify.get('/api/global-blocks/export', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const folders = db.listGlobalFolders();
|
|
||||||
const blocks = db.listGlobalBlocks();
|
|
||||||
return {
|
|
||||||
format: 'wcadlib',
|
|
||||||
version: 1,
|
|
||||||
exported_at: new Date().toISOString(),
|
|
||||||
folders: folders.map((f) => ({ id: f.id, name: f.name, parent_id: f.parent_id })),
|
|
||||||
blocks: blocks.map((b) => ({
|
|
||||||
name: b.name,
|
|
||||||
folder_id: b.folder_id,
|
|
||||||
block_data: b.block_data,
|
|
||||||
svg_data: b.svg_data,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Import: .wcadlib-Paket einspielen (IDs neu, Ordner-Hierarchie über Mapping erhalten)
|
|
||||||
fastify.post('/api/global-blocks/import', async (request, reply) => {
|
|
||||||
if (!requireLibraryAdmin(request, reply, authService)) return;
|
|
||||||
const pkg = request.body as {
|
|
||||||
format?: string;
|
|
||||||
folders?: Array<{ id: string; name: string; parent_id: string | null }>;
|
|
||||||
blocks?: Array<{ name: string; folder_id: string | null; block_data: string; svg_data: string | null }>;
|
|
||||||
};
|
|
||||||
if (!pkg || pkg.format !== 'wcadlib' || !Array.isArray(pkg.blocks)) {
|
|
||||||
return reply.code(400).send({ error: 'Invalid wcadlib package (format != wcadlib or blocks missing)' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ordner mit ID-Mapping importieren:
|
|
||||||
const folderIdMap = new Map<string, string>();
|
|
||||||
const sortedFolders = [...(pkg.folders ?? [])].sort((a, b) => {
|
|
||||||
if (a.parent_id === b.parent_id) return 0;
|
|
||||||
if (a.parent_id === null) return -1;
|
|
||||||
if (b.parent_id === null) return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
for (const f of sortedFolders) {
|
|
||||||
const newParent = f.parent_id ? folderIdMap.get(f.parent_id) ?? null : null;
|
|
||||||
const created = db.createGlobalFolder({ name: f.name, parent_id: newParent });
|
|
||||||
folderIdMap.set(f.id, created.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blöcke importieren:
|
|
||||||
let imported = 0;
|
|
||||||
for (const b of pkg.blocks) {
|
|
||||||
const newFolderId = b.folder_id ? folderIdMap.get(b.folder_id) ?? null : null;
|
|
||||||
db.createGlobalBlock({
|
|
||||||
name: b.name,
|
|
||||||
folder_id: newFolderId,
|
|
||||||
block_data: b.block_data,
|
|
||||||
svg_data: b.svg_data,
|
|
||||||
});
|
|
||||||
imported++;
|
|
||||||
}
|
|
||||||
return reply.code(201).send({ imported_blocks: imported, imported_folders: folderIdMap.size });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Task F7: .wcadlib als echtes ZIP-Paket ──────────────
|
|
||||||
|
|
||||||
// ZIP-Export: manifest.json + blocks.json + thumbs/ (SVG je Block)
|
|
||||||
fastify.get('/api/global-blocks/export-zip', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const folders = db.listGlobalFolders();
|
|
||||||
const blocks = db.listGlobalBlocks();
|
|
||||||
|
|
||||||
const zip = new JSZip();
|
|
||||||
zip.file('manifest.json', JSON.stringify({
|
|
||||||
format: 'wcadlib-zip',
|
|
||||||
version: 1,
|
|
||||||
exported_at: new Date().toISOString(),
|
|
||||||
block_count: blocks.length,
|
|
||||||
folder_count: folders.length,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const blockEntries = [] as Array<{
|
|
||||||
name: string;
|
|
||||||
folder_id: string | null;
|
|
||||||
block_data: string;
|
|
||||||
thumbnail: string | null;
|
|
||||||
}>;
|
|
||||||
for (const b of blocks) {
|
|
||||||
let thumbnail: string | null = null;
|
|
||||||
if (b.svg_data) {
|
|
||||||
thumbnail = `thumbs/${b.id}.svg`;
|
|
||||||
zip.file(thumbnail, b.svg_data);
|
|
||||||
}
|
|
||||||
blockEntries.push({
|
|
||||||
name: b.name,
|
|
||||||
folder_id: b.folder_id,
|
|
||||||
block_data: b.block_data,
|
|
||||||
thumbnail,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
zip.file('blocks.json', JSON.stringify({
|
|
||||||
folders: folders.map((f) => ({ id: f.id, name: f.name, parent_id: f.parent_id })),
|
|
||||||
blocks: blockEntries,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
|
|
||||||
reply.header('content-type', 'application/zip');
|
|
||||||
reply.header('content-disposition', 'attachment; filename="library.wcadlib.zip"');
|
|
||||||
return reply.send(buffer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ZIP-Import: .wcadlib-ZIP einspielen (Ordner-Mapping, Blöcke, Thumbnails)
|
|
||||||
fastify.post('/api/global-blocks/import-zip', async (request, reply) => {
|
|
||||||
if (!requireLibraryAdmin(request, reply, authService)) return;
|
|
||||||
const body = request.body;
|
|
||||||
if (!Buffer.isBuffer(body) || body.length === 0) {
|
|
||||||
return reply.code(400).send({ error: 'ZIP body fehlt oder leer' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let zip: JSZip;
|
|
||||||
try {
|
|
||||||
zip = await JSZip.loadAsync(body);
|
|
||||||
} catch {
|
|
||||||
return reply.code(400).send({ error: 'Kein valides ZIP-Format' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocksFile = zip.file('blocks.json');
|
|
||||||
if (!blocksFile) {
|
|
||||||
return reply.code(400).send({ error: 'blocks.json fehlt im ZIP' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let pkg: { folders?: Array<{ id: string; name: string; parent_id: string | null }>; blocks?: Array<{ name: string; folder_id: string | null; block_data: string; thumbnail?: string | null }> };
|
|
||||||
try {
|
|
||||||
pkg = JSON.parse(await blocksFile.async('string'));
|
|
||||||
} catch {
|
|
||||||
return reply.code(400).send({ error: 'blocks.json ist kein valides JSON' });
|
|
||||||
}
|
|
||||||
if (!Array.isArray(pkg.blocks)) {
|
|
||||||
return reply.code(400).send({ error: 'blocks.json enthaelt kein blocks-Array' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: manifest pruefen (falls vorhanden)
|
|
||||||
const manifestFile = zip.file('manifest.json');
|
|
||||||
if (manifestFile) {
|
|
||||||
try {
|
|
||||||
const manifest = JSON.parse(await manifestFile.async('string'));
|
|
||||||
if (manifest.format && manifest.format !== 'wcadlib-zip') {
|
|
||||||
return reply.code(400).send({ error: `Unbekanntes Paket-Format: ${manifest.format}` });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return reply.code(400).send({ error: 'manifest.json ist kein valides JSON' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ordner mit ID-Mapping importieren (Eltern zuerst):
|
|
||||||
const folderIdMap = new Map<string, string>();
|
|
||||||
const sortedFolders = [...(pkg.folders ?? [])].sort((a, b) => {
|
|
||||||
if (a.parent_id === null) return -1;
|
|
||||||
if (b.parent_id === null) return 1;
|
|
||||||
if (a.parent_id === b.parent_id) return 0;
|
|
||||||
const aRes = folderIdMap.has(a.parent_id!) ? -1 : 0;
|
|
||||||
return aRes;
|
|
||||||
});
|
|
||||||
for (const f of sortedFolders) {
|
|
||||||
const newParent = f.parent_id ? folderIdMap.get(f.parent_id) ?? null : null;
|
|
||||||
const created = db.createGlobalFolder({ name: f.name, parent_id: newParent });
|
|
||||||
folderIdMap.set(f.id, created.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blöcke importieren; Thumbnail aus thumbs/ lesen wenn referenziert:
|
|
||||||
let imported = 0;
|
|
||||||
for (const b of pkg.blocks) {
|
|
||||||
let svgData: string | null = null;
|
|
||||||
if (b.thumbnail) {
|
|
||||||
const thumbFile = zip.file(b.thumbnail);
|
|
||||||
if (thumbFile) {
|
|
||||||
try { svgData = await thumbFile.async('string'); } catch { svgData = null; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newFolderId = b.folder_id ? folderIdMap.get(b.folder_id) ?? null : null;
|
|
||||||
db.createGlobalBlock({
|
|
||||||
name: b.name,
|
|
||||||
folder_id: newFolderId,
|
|
||||||
block_data: b.block_data,
|
|
||||||
svg_data: svgData,
|
|
||||||
});
|
|
||||||
imported++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.code(201).send({ imported_blocks: imported, imported_folders: folderIdMap.size });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
/**
|
|
||||||
* Guests Routes – CRUD for event guests (Task E6)
|
|
||||||
*/
|
|
||||||
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 { validateIdParam } from '../utils/validation.js';
|
|
||||||
|
|
||||||
export function registerGuestRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
|
||||||
// List guests for a drawing
|
|
||||||
fastify.get('/api/drawings/:drawingId/guests', 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.listGuests(drawingId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create guest
|
|
||||||
fastify.post('/api/drawings/:drawingId/guests', 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 { name?: string; email?: string; category?: string; seat_element_id?: string; notes?: string };
|
|
||||||
if (!body.name || typeof body.name !== 'string' || body.name.trim() === '') {
|
|
||||||
return reply.code(400).send({ error: 'name is required' });
|
|
||||||
}
|
|
||||||
const guest = db.createGuest({
|
|
||||||
drawing_id: drawingId,
|
|
||||||
name: body.name.trim(),
|
|
||||||
email: body.email,
|
|
||||||
category: body.category ?? 'standard',
|
|
||||||
seat_element_id: body.seat_element_id,
|
|
||||||
notes: body.notes,
|
|
||||||
});
|
|
||||||
return reply.code(201).send(guest);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update guest
|
|
||||||
fastify.patch('/api/guests/: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<{ name: string; email: string; category: string; seat_element_id: string; notes: string }>;
|
|
||||||
const updated = db.updateGuest(id, body);
|
|
||||||
if (!updated) return reply.code(404).send({ error: 'Guest not found' });
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete guest
|
|
||||||
fastify.delete('/api/guests/: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.deleteGuest(id);
|
|
||||||
if (!ok) return reply.code(404).send({ error: 'Guest not found' });
|
|
||||||
return reply.code(204).send();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
/**
|
|
||||||
* Layers Routes – CRUD for layers
|
|
||||||
*/
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { DatabaseInterface, DBLayer } from '../database/DatabaseInterface.js';
|
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
import { validateName, validateIdParam } from '../utils/validation.js';
|
|
||||||
|
|
||||||
export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
|
||||||
// List layers for a drawing
|
|
||||||
fastify.get('/api/drawings/:drawingId/layers', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { drawingId } = request.params as { drawingId: string };
|
|
||||||
const idErr = validateIdParam(drawingId, 'drawingId');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
return db.listLayers(drawingId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create layer
|
|
||||||
fastify.post('/api/drawings/:drawingId/layers', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { drawingId } = request.params as { drawingId: string };
|
|
||||||
const idErr = validateIdParam(drawingId, 'drawingId');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const body = request.body as Partial<DBLayer>;
|
|
||||||
if (body.name !== undefined) {
|
|
||||||
const nameErr = validateName(body.name);
|
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
|
||||||
}
|
|
||||||
return reply.code(201).send(db.createLayer({ ...body, drawing_id: drawingId }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update layer
|
|
||||||
fastify.patch('/api/layers/:id', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const body = request.body as Partial<DBLayer>;
|
|
||||||
if (body.name !== undefined) {
|
|
||||||
const nameErr = validateName(body.name);
|
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
|
||||||
}
|
|
||||||
const updated = db.updateLayer(id, body);
|
|
||||||
if (!updated) return reply.code(404).send({ error: 'Layer not found' });
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete layer
|
|
||||||
fastify.delete('/api/layers/:id', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const ok = db.deleteLayer(id);
|
|
||||||
if (!ok) return reply.code(404).send({ error: 'Layer not found' });
|
|
||||||
return reply.code(204).send();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* Notifications Routes – List, create, mark read, delete notifications
|
|
||||||
*/
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
import { validateIdParam } from '../utils/validation.js';
|
|
||||||
|
|
||||||
function extractToken(request: any): string | null {
|
|
||||||
const auth = request.headers?.authorization;
|
|
||||||
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerNotificationRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
|
||||||
// List notifications for current user
|
|
||||||
fastify.get('/api/notifications', async (request, reply) => {
|
|
||||||
const token = extractToken(request);
|
|
||||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
|
||||||
const user = authService.getUserFromSession(token);
|
|
||||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
|
||||||
return db.listNotifications(user.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create notification (only for self)
|
|
||||||
fastify.post('/api/notifications', async (request, reply) => {
|
|
||||||
const token = extractToken(request);
|
|
||||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
|
||||||
const user = authService.getUserFromSession(token);
|
|
||||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
|
||||||
const body = request.body as { type?: string; title?: string; message?: string };
|
|
||||||
if (!body.title || !body.message) return reply.code(400).send({ error: 'title and message are required' });
|
|
||||||
const validTypes = ['info', 'share', 'warning', 'error'];
|
|
||||||
const type = validTypes.includes(body.type ?? '') ? body.type! : 'info';
|
|
||||||
return reply.code(201).send(db.createNotification({
|
|
||||||
user_id: user.id,
|
|
||||||
type,
|
|
||||||
title: body.title,
|
|
||||||
message: body.message,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark notification as read (ownership check)
|
|
||||||
fastify.patch('/api/notifications/:id/read', async (request, reply) => {
|
|
||||||
const token = extractToken(request);
|
|
||||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
|
||||||
const user = authService.getUserFromSession(token);
|
|
||||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const notif = db.getNotification(id);
|
|
||||||
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
|
|
||||||
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
|
||||||
const ok = db.markNotificationRead(id);
|
|
||||||
if (!ok) return reply.code(404).send({ error: 'Notification not found' });
|
|
||||||
return reply.code(204).send();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete notification (ownership check)
|
|
||||||
fastify.delete('/api/notifications/:id', async (request, reply) => {
|
|
||||||
const token = extractToken(request);
|
|
||||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
|
||||||
const user = authService.getUserFromSession(token);
|
|
||||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const notif = db.getNotification(id);
|
|
||||||
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
|
|
||||||
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
|
||||||
const ok = db.deleteNotification(id);
|
|
||||||
if (!ok) return reply.code(404).send({ error: 'Notification not found' });
|
|
||||||
return reply.code(204).send();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* Project Folders Routes – CRUD for project folders + move project to folder
|
|
||||||
*/
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
import { validateName, validateIdParam } from '../utils/validation.js';
|
|
||||||
|
|
||||||
export function registerProjectFolderRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
|
||||||
// ─── Project Folders ──────────────────────────────────
|
|
||||||
|
|
||||||
// List all folders for the authenticated user
|
|
||||||
fastify.get('/api/project-folders', async (request, reply) => {
|
|
||||||
const user = requireAuth(request, reply, authService);
|
|
||||||
if (!user) return;
|
|
||||||
return db.listProjectFolders(user.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get a single folder
|
|
||||||
fastify.get('/api/project-folders/:id', async (request, reply) => {
|
|
||||||
const user = requireAuth(request, reply, authService);
|
|
||||||
if (!user) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const folder = db.getProjectFolder(id);
|
|
||||||
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
|
|
||||||
if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
|
||||||
return folder;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a new folder
|
|
||||||
fastify.post('/api/project-folders', async (request, reply) => {
|
|
||||||
const user = requireAuth(request, reply, authService);
|
|
||||||
if (!user) return;
|
|
||||||
const body = request.body as { name?: string; parent_id?: string | null };
|
|
||||||
const nameErr = validateName(body.name, 'name');
|
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
|
||||||
if (body.parent_id !== undefined && body.parent_id !== null) {
|
|
||||||
const pidErr = validateIdParam(body.parent_id, 'parent_id');
|
|
||||||
if (pidErr) return reply.code(400).send({ error: pidErr });
|
|
||||||
// Verify parent exists and belongs to user
|
|
||||||
const parent = db.getProjectFolder(body.parent_id);
|
|
||||||
if (!parent) return reply.code(404).send({ error: 'Parent folder not found' });
|
|
||||||
if (parent.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
|
||||||
}
|
|
||||||
return reply.code(201).send(db.createProjectFolder({
|
|
||||||
name: body.name,
|
|
||||||
parent_id: body.parent_id ?? null,
|
|
||||||
owner_id: user.id,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rename a folder
|
|
||||||
fastify.put('/api/project-folders/:id', async (request, reply) => {
|
|
||||||
const user = requireAuth(request, reply, authService);
|
|
||||||
if (!user) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const folder = db.getProjectFolder(id);
|
|
||||||
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
|
|
||||||
if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
|
||||||
const body = request.body as { name?: string; parent_id?: string | null };
|
|
||||||
const updates: Partial<{ name: string; parent_id: string | null }> = {};
|
|
||||||
if (body.name !== undefined) {
|
|
||||||
const nameErr = validateName(body.name, 'name');
|
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
|
||||||
updates.name = body.name;
|
|
||||||
} else {
|
|
||||||
return reply.code(400).send({ error: 'name is required' });
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* Projects Routes – CRUD for projects
|
|
||||||
*/
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { DatabaseInterface, DBProject } from '../database/DatabaseInterface.js';
|
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
import { validateName, validateIdParam } from '../utils/validation.js';
|
|
||||||
|
|
||||||
export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
|
||||||
// List projects owned by the authenticated user, optionally filtered by folder
|
|
||||||
fastify.get('/api/projects', async (request, reply) => {
|
|
||||||
const user = requireAuth(request, reply, authService);
|
|
||||||
if (!user) return;
|
|
||||||
const folderId = (request.query as { folderId?: string | null }).folderId;
|
|
||||||
if (folderId === undefined || folderId === '' || folderId === 'null') {
|
|
||||||
return db.listProjects(user.id, folderId === 'null' ? null : undefined);
|
|
||||||
}
|
|
||||||
const fidErr = validateIdParam(folderId, 'folderId');
|
|
||||||
if (fidErr) return reply.code(400).send({ error: fidErr });
|
|
||||||
return db.listProjects(user.id, folderId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get single project
|
|
||||||
fastify.get('/api/projects/:id', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const project = db.getProject(id);
|
|
||||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
|
||||||
return project;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create project
|
|
||||||
fastify.post('/api/projects', async (request, reply) => {
|
|
||||||
const user = requireAuth(request, reply, authService);
|
|
||||||
if (!user) return;
|
|
||||||
const body = request.body as Partial<DBProject>;
|
|
||||||
const nameErr = validateName(body.name);
|
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
|
||||||
return reply.code(201).send(db.createProject({ ...body, owner_id: user.id }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update project
|
|
||||||
fastify.patch('/api/projects/:id', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const body = request.body as Partial<DBProject>;
|
|
||||||
if (body.name !== undefined) {
|
|
||||||
const nameErr = validateName(body.name);
|
|
||||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
|
||||||
}
|
|
||||||
const updated = db.updateProject(id, body);
|
|
||||||
if (!updated) return reply.code(404).send({ error: 'Project not found' });
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete project
|
|
||||||
fastify.delete('/api/projects/:id', async (request, reply) => {
|
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const ok = db.deleteProject(id);
|
|
||||||
if (!ok) return reply.code(404).send({ error: 'Project not found' });
|
|
||||||
return reply.code(204).send();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* Settings Routes – key/value application settings
|
|
||||||
*/
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
import { validateSettingsKey, validateSettingsValue } from '../utils/validation.js';
|
|
||||||
|
|
||||||
/** Built-in default settings so the frontend never sees a 404 for known keys. */
|
|
||||||
const DEFAULT_SETTINGS: Record<string, string> = {
|
|
||||||
'cad.unit': 'mm',
|
|
||||||
'cad.gridSize': '20',
|
|
||||||
'cad.scaleFactor': '1',
|
|
||||||
};
|
|
||||||
|
|
||||||
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 setting;
|
|
||||||
// Return a default value for known CAD settings instead of 404
|
|
||||||
if (DEFAULT_SETTINGS[key] !== undefined) {
|
|
||||||
return { key, value: DEFAULT_SETTINGS[key], updated_at: new Date().toISOString() };
|
|
||||||
}
|
|
||||||
return reply.code(404).send({ error: 'Setting not found' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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() };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/**
|
|
||||||
* Project Shares Routes – List, create, delete project shares
|
|
||||||
*/
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
import { validateIdParam } from '../utils/validation.js';
|
|
||||||
|
|
||||||
function extractToken(request: any): string | null {
|
|
||||||
const auth = request.headers?.authorization;
|
|
||||||
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALID_PERMISSIONS = ['view', 'edit', 'admin'];
|
|
||||||
|
|
||||||
export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
|
||||||
// List shares for a project (owner only)
|
|
||||||
fastify.get('/api/projects/:projectId/shares', async (request, reply) => {
|
|
||||||
const token = extractToken(request);
|
|
||||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
|
||||||
const user = authService.getUserFromSession(token);
|
|
||||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
|
||||||
const { projectId } = request.params as { projectId: string };
|
|
||||||
const projIdErr = validateIdParam(projectId, 'projectId');
|
|
||||||
if (projIdErr) return reply.code(400).send({ error: projIdErr });
|
|
||||||
const project = db.getProject(projectId);
|
|
||||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
|
||||||
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
|
||||||
return db.listProjectShares(projectId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a project share (owner only)
|
|
||||||
fastify.post('/api/projects/:projectId/shares', async (request, reply) => {
|
|
||||||
const token = extractToken(request);
|
|
||||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
|
||||||
const user = authService.getUserFromSession(token);
|
|
||||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
|
||||||
const { projectId } = request.params as { projectId: string };
|
|
||||||
const projIdErr = validateIdParam(projectId, 'projectId');
|
|
||||||
if (projIdErr) return reply.code(400).send({ error: projIdErr });
|
|
||||||
const project = db.getProject(projectId);
|
|
||||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
|
||||||
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
|
||||||
const body = request.body as { shared_with_email?: string; permission?: string };
|
|
||||||
if (!body.shared_with_email) return reply.code(400).send({ error: 'shared_with_email is required' });
|
|
||||||
const permission = VALID_PERMISSIONS.includes(body.permission ?? '') ? body.permission! : 'view';
|
|
||||||
|
|
||||||
const share = db.createProjectShare({
|
|
||||||
project_id: projectId,
|
|
||||||
shared_with_email: body.shared_with_email,
|
|
||||||
shared_by: user.id,
|
|
||||||
permission,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a notification for the shared user if they exist
|
|
||||||
const sharedUser = db.getUserByEmail(body.shared_with_email);
|
|
||||||
if (sharedUser) {
|
|
||||||
db.createNotification({
|
|
||||||
user_id: sharedUser.id,
|
|
||||||
type: 'share',
|
|
||||||
title: 'Projekt geteilt',
|
|
||||||
message: `${user.name} hat ein Projekt mit Ihnen geteilt: ${project.name}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.code(201).send(share);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete a project share (owner only)
|
|
||||||
fastify.delete('/api/shares/:id', async (request, reply) => {
|
|
||||||
const token = extractToken(request);
|
|
||||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
|
||||||
const user = authService.getUserFromSession(token);
|
|
||||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const share = db.getProjectShare(id);
|
|
||||||
if (!share) return reply.code(404).send({ error: 'Share not found' });
|
|
||||||
const project = db.getProject(share.project_id);
|
|
||||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
|
||||||
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
|
||||||
const ok = db.deleteProjectShare(id);
|
|
||||||
if (!ok) return reply.code(404).send({ error: 'Share not found' });
|
|
||||||
return reply.code(204).send();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* Users Routes – Admin user management
|
|
||||||
*/
|
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
||||||
import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
import { validateIdParam } from '../utils/validation.js';
|
|
||||||
|
|
||||||
function extractToken(request: FastifyRequest): string | null {
|
|
||||||
const auth = request.headers?.authorization;
|
|
||||||
if (auth && auth.startsWith('Bearer ')) {
|
|
||||||
return auth.slice(7);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireAdmin(request: FastifyRequest, reply: FastifyReply, authService: AuthService): DBUser | null {
|
|
||||||
const token = extractToken(request);
|
|
||||||
if (!token) {
|
|
||||||
reply.code(401).send({ error: 'Authentication required' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const user = authService.getUserFromSession(token);
|
|
||||||
if (!user) {
|
|
||||||
reply.code(401).send({ error: 'Invalid or expired session' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (user.role !== 'admin') {
|
|
||||||
reply.code(403).send({ error: 'Admin access required' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
|
||||||
// List all users (admin only)
|
|
||||||
fastify.get('/api/users', async (request, reply) => {
|
|
||||||
if (!requireAdmin(request, reply, authService)) return;
|
|
||||||
const users = db.listUsers();
|
|
||||||
return users.map(({ password_hash, ...safe }) => safe);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get single user (admin only)
|
|
||||||
fastify.get('/api/users/:id', async (request, reply) => {
|
|
||||||
if (!requireAdmin(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const user = db.getUser(id);
|
|
||||||
if (!user) return reply.code(404).send({ error: 'User not found' });
|
|
||||||
const { password_hash, ...safe } = user;
|
|
||||||
return safe;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update user (admin only)
|
|
||||||
fastify.patch('/api/users/:id', async (request, reply) => {
|
|
||||||
if (!requireAdmin(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const body = request.body as Partial<DBUser>;
|
|
||||||
// Prevent password update via this endpoint
|
|
||||||
delete body.password_hash;
|
|
||||||
const updated = db.updateUser(id, body);
|
|
||||||
if (!updated) return reply.code(404).send({ error: 'User not found' });
|
|
||||||
const { password_hash, ...safe } = updated;
|
|
||||||
return safe;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete user (admin only)
|
|
||||||
fastify.delete('/api/users/:id', async (request, reply) => {
|
|
||||||
if (!requireAdmin(request, reply, authService)) return;
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const idErr = validateIdParam(id, 'id');
|
|
||||||
if (idErr) return reply.code(400).send({ error: idErr });
|
|
||||||
const ok = db.deleteUser(id);
|
|
||||||
if (!ok) return reply.code(404).send({ error: 'User not found' });
|
|
||||||
return reply.code(204).send();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
/**
|
|
||||||
* Server – Fastify configuration with CORS, static, and WebSocket
|
|
||||||
*/
|
|
||||||
import Fastify from 'fastify';
|
|
||||||
import cors from '@fastify/cors';
|
|
||||||
import staticPlugin from '@fastify/static';
|
|
||||||
import websocket from '@fastify/websocket';
|
|
||||||
import { join, dirname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import type { DatabaseInterface } from './database/DatabaseInterface.js';
|
|
||||||
import { AuthService } from './auth/AuthService.js';
|
|
||||||
import { registerYjsWebSocket, initYjsPersistence, closeYjsPersistence } from './websocket/yjsServer.js';
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
export interface ServerOptions {
|
|
||||||
port?: number;
|
|
||||||
host?: string;
|
|
||||||
db: DatabaseInterface;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createServer(opts: ServerOptions) {
|
|
||||||
const fastify = Fastify({ logger: true });
|
|
||||||
|
|
||||||
// ─── Task F7: binäre Body für .wcadlib ZIP-Import ───
|
|
||||||
fastify.addContentTypeParser('application/zip', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
|
|
||||||
fastify.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
|
|
||||||
fastify.addContentTypeParser('application/x-zip-compressed', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
|
|
||||||
|
|
||||||
// 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 { registerGuestRoutes } = await import('./routes/guests.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);
|
|
||||||
registerGuestRoutes(fastify, opts.db, authService);
|
|
||||||
registerProjectFolderRoutes(fastify, opts.db, authService);
|
|
||||||
|
|
||||||
// Yjs collaboration WebSocket
|
|
||||||
registerYjsWebSocket(fastify, authService);
|
|
||||||
|
|
||||||
return fastify;
|
|
||||||
}
|
|
||||||
Vendored
-11
@@ -1,11 +0,0 @@
|
|||||||
declare module 'y-leveldb' {
|
|
||||||
import type * as Y from 'yjs';
|
|
||||||
|
|
||||||
export class LeveldbPersistence {
|
|
||||||
constructor(dbPath: string, opts?: Record<string, unknown>);
|
|
||||||
getYDoc(name: string): Promise<Y.Doc>;
|
|
||||||
storeUpdate(name: string, update: Uint8Array): Promise<number>;
|
|
||||||
destroy(): Promise<void>;
|
|
||||||
clearDocument(name: string): Promise<void>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* Simple in-memory rate limiter for auth endpoints.
|
|
||||||
* Limits requests per IP address within a time window.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface RateLimitEntry {
|
|
||||||
count: number;
|
|
||||||
resetTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WINDOW_MS = 60 * 1000; // 1 minute
|
|
||||||
const MAX_REQUESTS = 10; // max requests per window per IP
|
|
||||||
const MAX_REGISTER = 3; // stricter for registration
|
|
||||||
|
|
||||||
const loginLimiter = new Map<string, RateLimitEntry>();
|
|
||||||
const registerLimiter = new Map<string, RateLimitEntry>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check rate limit for a given IP and limiter map.
|
|
||||||
* Returns null if allowed, or an error message if rate limit exceeded.
|
|
||||||
*/
|
|
||||||
function checkRateLimit(
|
|
||||||
ip: string,
|
|
||||||
limiter: Map<string, RateLimitEntry>,
|
|
||||||
maxRequests: number,
|
|
||||||
): string | null {
|
|
||||||
const now = Date.now();
|
|
||||||
const entry = limiter.get(ip);
|
|
||||||
|
|
||||||
if (!entry || now > entry.resetTime) {
|
|
||||||
limiter.set(ip, { count: 1, resetTime: now + WINDOW_MS });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.count >= maxRequests) {
|
|
||||||
const retryAfter = Math.ceil((entry.resetTime - now) / 1000);
|
|
||||||
return `Rate limit exceeded. Try again in ${retryAfter} seconds.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.count++;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check login rate limit for an IP.
|
|
||||||
*/
|
|
||||||
export function checkLoginRateLimit(ip: string): string | null {
|
|
||||||
return checkRateLimit(ip, loginLimiter, MAX_REQUESTS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check register rate limit for an IP.
|
|
||||||
*/
|
|
||||||
export function checkRegisterRateLimit(ip: string): string | null {
|
|
||||||
return checkRateLimit(ip, registerLimiter, MAX_REGISTER);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract client IP from Fastify request.
|
|
||||||
*/
|
|
||||||
export function getClientIp(request: { ip: string; headers: Record<string, string | string[] | undefined> }): string {
|
|
||||||
const forwarded = request.headers['x-forwarded-for'];
|
|
||||||
if (typeof forwarded === 'string') {
|
|
||||||
return forwarded.split(',')[0].trim();
|
|
||||||
}
|
|
||||||
return request.ip;
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* Validation utilities for backend route input validation.
|
|
||||||
* Provides lightweight, manual validation functions without external dependencies.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Maximum allowed length for name fields */
|
|
||||||
export const MAX_NAME_LENGTH = 255;
|
|
||||||
|
|
||||||
/** Maximum allowed length for ID fields */
|
|
||||||
export const MAX_ID_LENGTH = 128;
|
|
||||||
|
|
||||||
/** Allowed ID pattern: alphanumeric, hyphens, underscores */
|
|
||||||
const ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a name field (non-empty string within max length).
|
|
||||||
* Returns null if valid, or an error message string if invalid.
|
|
||||||
*/
|
|
||||||
export function validateName(value: unknown, fieldName = 'name'): string | null {
|
|
||||||
const capitalized = fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return `${capitalized} is required and must be a string`;
|
|
||||||
}
|
|
||||||
if (value.trim().length === 0) {
|
|
||||||
return `${capitalized} must not be empty`;
|
|
||||||
}
|
|
||||||
if (value.length > MAX_NAME_LENGTH) {
|
|
||||||
return `${capitalized} must be at most ${MAX_NAME_LENGTH} characters`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate an ID parameter (non-empty, alphanumeric/hyphen/underscore, within max length).
|
|
||||||
* Returns null if valid, or an error message string if invalid.
|
|
||||||
*/
|
|
||||||
export function validateIdParam(id: unknown, fieldName = 'id'): string | null {
|
|
||||||
if (typeof id !== 'string') {
|
|
||||||
return `${fieldName} is required and must be a string`;
|
|
||||||
}
|
|
||||||
if (id.length === 0) {
|
|
||||||
return `${fieldName} must not be empty`;
|
|
||||||
}
|
|
||||||
if (id.length > MAX_ID_LENGTH) {
|
|
||||||
return `${fieldName} is too long`;
|
|
||||||
}
|
|
||||||
if (!ID_PATTERN.test(id)) {
|
|
||||||
return `${fieldName} contains invalid characters`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a settings key (non-empty, reasonable length, alphanumeric + hyphens/underscores/dots).
|
|
||||||
* Returns null if valid, or an error message string if invalid.
|
|
||||||
*/
|
|
||||||
export function validateSettingsKey(key: unknown): string | null {
|
|
||||||
if (typeof key !== 'string' || key.trim().length === 0) {
|
|
||||||
return 'Settings key is required';
|
|
||||||
}
|
|
||||||
if (key.length > MAX_NAME_LENGTH) {
|
|
||||||
return 'Settings key is too long';
|
|
||||||
}
|
|
||||||
if (!/^[a-zA-Z0-9_.-]+$/.test(key)) {
|
|
||||||
return 'Settings key contains invalid characters';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a settings value (non-empty string within reasonable length).
|
|
||||||
* Returns null if valid, or an error message string if invalid.
|
|
||||||
*/
|
|
||||||
export function validateSettingsValue(value: unknown): string | null {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return 'Value is required';
|
|
||||||
}
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return 'Value must be a string';
|
|
||||||
}
|
|
||||||
if (value.length > 65535) {
|
|
||||||
return 'Value is too long';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
/**
|
|
||||||
* Yjs WebSocket Server – Real-time collaboration with LevelDB persistence.
|
|
||||||
*
|
|
||||||
* Message protocol (byte-prefixed):
|
|
||||||
* 0 = Y.Doc update (Y.encodeStateAsUpdate or Y.encodeStateAsUpdateYUpdate)
|
|
||||||
* 1 = Awareness update (encodeAwarenessUpdate from y-protocols/awareness)
|
|
||||||
*/
|
|
||||||
import * as Y from 'yjs';
|
|
||||||
import { LeveldbPersistence } from 'y-leveldb';
|
|
||||||
import { Awareness, encodeAwarenessUpdate, applyAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import type { WebSocket } from '@fastify/websocket';
|
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
|
||||||
|
|
||||||
const PERSISTENCE_DIR = process.env.YJS_PERSISTENCE_DIR || '/tmp/yjs-documents';
|
|
||||||
|
|
||||||
// Message type prefixes (must match frontend WebSocketProvider.ts)
|
|
||||||
const MSG_DOC_UPDATE = 0;
|
|
||||||
const MSG_AWARENESS = 1;
|
|
||||||
|
|
||||||
// Document store: docName → Y.Doc
|
|
||||||
const docs = new Map<string, Y.Doc>();
|
|
||||||
// Awareness store: docName → Awareness
|
|
||||||
const awarenessMap = new Map<string, Awareness>();
|
|
||||||
// Connection tracking: docName → Set<WebSocket>
|
|
||||||
const connections = new Map<string, Set<WebSocket>>();
|
|
||||||
let persistence: LeveldbPersistence | null = null;
|
|
||||||
let persistenceReady: Promise<void> | null = null;
|
|
||||||
|
|
||||||
export async function initYjsPersistence(): Promise<void> {
|
|
||||||
const tryInit = async (allowReset: boolean): Promise<void> => {
|
|
||||||
try {
|
|
||||||
persistence = new LeveldbPersistence(PERSISTENCE_DIR);
|
|
||||||
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) {
|
|
||||||
// In dev mode the persistence dir lives under /tmp. If the previous process
|
|
||||||
// did not shut down cleanly, LevelDB can be left in a locked/corrupted state.
|
|
||||||
// Resetting the directory is acceptable because collaboration history is ephemeral.
|
|
||||||
if (allowReset && PERSISTENCE_DIR.startsWith('/tmp/')) {
|
|
||||||
console.warn(`Yjs persistence init failed, resetting ${PERSISTENCE_DIR} and retrying...`);
|
|
||||||
try {
|
|
||||||
if (persistence) {
|
|
||||||
await persistence.destroy().catch(() => {});
|
|
||||||
persistence = null;
|
|
||||||
persistenceReady = null;
|
|
||||||
}
|
|
||||||
const fs = await import('fs');
|
|
||||||
fs.rmSync(PERSISTENCE_DIR, { recursive: true, force: true });
|
|
||||||
fs.mkdirSync(PERSISTENCE_DIR, { recursive: true });
|
|
||||||
} catch (cleanupErr) {
|
|
||||||
console.warn('Yjs persistence cleanup failed:', cleanupErr);
|
|
||||||
}
|
|
||||||
return tryInit(false);
|
|
||||||
}
|
|
||||||
console.warn('Yjs persistence failed to init (running without disk persistence):', err);
|
|
||||||
persistence = null;
|
|
||||||
persistenceReady = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await tryInit(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeYjsPersistence(): Promise<void> {
|
|
||||||
if (persistence) {
|
|
||||||
try {
|
|
||||||
await persistence.destroy();
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Yjs persistence close failed (non-fatal):', err);
|
|
||||||
}
|
|
||||||
persistence = null;
|
|
||||||
}
|
|
||||||
persistenceReady = 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# Test Report — Auth Middleware & CORS Fix (Issue #1 + #3)
|
|
||||||
|
|
||||||
**Date:** 2026-06-29
|
|
||||||
**Task:** Fix Issue #1 (No Authentication on CRUD Routes) + Issue #3 (CORS) from CODE_ANALYSIS.md
|
|
||||||
**File modified:** `backend/src/server.ts`
|
|
||||||
|
|
||||||
## Build Verification
|
|
||||||
|
|
||||||
```
|
|
||||||
cd /a0/usr/workdir/web-cad-neu/backend && npx tsc --noEmit
|
|
||||||
```
|
|
||||||
**Result:** ✅ PASS — No type errors, exit code 0
|
|
||||||
|
|
||||||
## Test Results
|
|
||||||
|
|
||||||
### Test 1: Unauthenticated /api/projects → 401
|
|
||||||
```
|
|
||||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:3001/api/projects
|
|
||||||
```
|
|
||||||
**Expected:** 401
|
|
||||||
**Actual:** 401 ✅
|
|
||||||
|
|
||||||
### Test 2: Health check /api/health → 200
|
|
||||||
```
|
|
||||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:3001/api/health
|
|
||||||
```
|
|
||||||
**Expected:** 200
|
|
||||||
**Actual:** 200 ✅
|
|
||||||
|
|
||||||
### Test 3: Register endpoint accessible (no auth required)
|
|
||||||
```
|
|
||||||
curl -s -X POST http://localhost:3001/api/auth/register -H 'Content-Type: application/json' \
|
|
||||||
-d '{"email":"test@test.de","password":"test1234","name":"Test User"}'
|
|
||||||
```
|
|
||||||
**Expected:** 201
|
|
||||||
**Actual:** 201 ✅ (user created, session token returned)
|
|
||||||
|
|
||||||
### Test 4: Login endpoint accessible (no auth required)
|
|
||||||
```
|
|
||||||
curl -s -w '\nHTTP_CODE:%{http_code}' -X POST http://localhost:3001/api/auth/login \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"email":"test@test.de","password":"test1234"}'
|
|
||||||
```
|
|
||||||
**Expected:** 200
|
|
||||||
**Actual:** 200 ✅ (user + session token returned)
|
|
||||||
|
|
||||||
### Test 5: Authenticated /api/projects → 200
|
|
||||||
```
|
|
||||||
TOKEN=<valid_session_token>
|
|
||||||
curl -s -w '\nHTTP_CODE:%{http_code}' http://localhost:3001/api/projects \
|
|
||||||
-H "Authorization: Bearer $TOKEN"
|
|
||||||
```
|
|
||||||
**Expected:** 200
|
|
||||||
**Actual:** 200 ✅ (project list returned)
|
|
||||||
|
|
||||||
## Smoke Test
|
|
||||||
|
|
||||||
- Backend starts without errors: ✅
|
|
||||||
- Server listens on port 3001: ✅
|
|
||||||
- Yjs persistence initialized: ✅
|
|
||||||
- Auth middleware correctly blocks unauthenticated /api/ requests: ✅
|
|
||||||
- Auth middleware correctly skips /api/auth/login, /api/auth/register, /api/health: ✅
|
|
||||||
- Auth middleware correctly skips non-/api/ routes (static files): ✅
|
|
||||||
- Valid Bearer token grants access to protected routes: ✅
|
|
||||||
- CORS restricted to allowed origins (web-cad-neu.server.media-on.de, localhost:5173, localhost:8082): ✅
|
|
||||||
|
|
||||||
## Changes Summary
|
|
||||||
|
|
||||||
### server.ts — Auth Middleware (Issue #1)
|
|
||||||
Added `onRequest` hook after `authService` creation, before route registrations:
|
|
||||||
- Skips `/api/auth/login`, `/api/auth/register`, `/api/health`
|
|
||||||
- Skips all non-`/api/` routes (static files, WebSocket)
|
|
||||||
- Extracts Bearer token from Authorization header
|
|
||||||
- Validates via `authService.getUserFromSession(token)`
|
|
||||||
- Returns 401 if no token or invalid session
|
|
||||||
- Attaches user to `(request as any).user` for route handlers
|
|
||||||
|
|
||||||
### server.ts — CORS Fix (Issue #3)
|
|
||||||
Changed `origin: true` to restrictive config:
|
|
||||||
- Allows requests with no origin (same-origin, curl)
|
|
||||||
- Allows known origins: `https://web-cad-neu.server.media-on.de`, `http://localhost:5173`, `http://localhost:8082`
|
|
||||||
- Rejects all other origins
|
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
- None
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
/**
|
|
||||||
* AuthService Unit Tests – Register / Login / ChangePassword / Session lifecycle
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { AuthService } from '../src/auth/AuthService.js';
|
|
||||||
|
|
||||||
describe('AuthService', () => {
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let auth: AuthService;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
auth = new AuthService(db);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Register ───────────────────────────────────────
|
|
||||||
|
|
||||||
describe('register()', () => {
|
|
||||||
it('should register a new user successfully', async () => {
|
|
||||||
const result = await auth.register('register@example.com', 'Password123!', 'Register User', 'planer');
|
|
||||||
expect(result.user).toBeDefined();
|
|
||||||
expect(result.user.email).toBe('register@example.com');
|
|
||||||
expect(result.user.name).toBe('Register User');
|
|
||||||
expect(result.user.password_hash).toBeUndefined();
|
|
||||||
expect(result.session).toBeDefined();
|
|
||||||
expect(result.session.token).toBeDefined();
|
|
||||||
expect(result.session.userId).toBe(result.user.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw on duplicate email', async () => {
|
|
||||||
await expect(
|
|
||||||
auth.register('register@example.com', 'AnotherPass!', 'Another User', 'planer'),
|
|
||||||
).rejects.toThrow('Email already registered');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default role to planer when not specified', async () => {
|
|
||||||
const result = await auth.register('default-role@example.com', 'Pass123!', 'Default Role User');
|
|
||||||
expect(result.user.role).toBe('planer');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Login ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('login()', () => {
|
|
||||||
it('should login with correct credentials', async () => {
|
|
||||||
const result = await auth.login('register@example.com', 'Password123!');
|
|
||||||
expect(result.user).toBeDefined();
|
|
||||||
expect(result.user.email).toBe('register@example.com');
|
|
||||||
expect(result.session.token).toBeDefined();
|
|
||||||
expect(result.session.userId).toBe(result.user.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw on wrong password', async () => {
|
|
||||||
await expect(
|
|
||||||
auth.login('register@example.com', 'WrongPassword!'),
|
|
||||||
).rejects.toThrow('Invalid credentials');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw on non-existent email', async () => {
|
|
||||||
await expect(
|
|
||||||
auth.login('nobody@example.com', 'SomePassword!'),
|
|
||||||
).rejects.toThrow('Invalid credentials');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Change Password ───────────────────────────────
|
|
||||||
|
|
||||||
describe('changePassword()', () => {
|
|
||||||
it('should change password with correct old password', async () => {
|
|
||||||
const result = await auth.register('changepw@example.com', 'OldPass123!', 'ChangePW User', 'admin');
|
|
||||||
const userId = result.user.id;
|
|
||||||
|
|
||||||
await auth.changePassword(userId, 'OldPass123!', 'NewPass456!');
|
|
||||||
// Should now be able to login with new password
|
|
||||||
const loginResult = await auth.login('changepw@example.com', 'NewPass456!');
|
|
||||||
expect(loginResult.user.id).toBe(userId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw on wrong old password', async () => {
|
|
||||||
const result = await auth.register('changepw2@example.com', 'OriginalPass!', 'User2', 'planer');
|
|
||||||
await expect(
|
|
||||||
auth.changePassword(result.user.id, 'WrongOldPass!', 'NewPass!'),
|
|
||||||
).rejects.toThrow('Invalid current password');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw on non-existent user', async () => {
|
|
||||||
await expect(
|
|
||||||
auth.changePassword('non-existent-user-id', 'OldPass!', 'NewPass!'),
|
|
||||||
).rejects.toThrow('User not found');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject old password after change', async () => {
|
|
||||||
const result = await auth.register('changepw3@example.com', 'Original123!', 'User3', 'planer');
|
|
||||||
await auth.changePassword(result.user.id, 'Original123!', 'Changed456!');
|
|
||||||
await expect(
|
|
||||||
auth.login('changepw3@example.com', 'Original123!'),
|
|
||||||
).rejects.toThrow('Invalid credentials');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Session Management ────────────────────────────
|
|
||||||
|
|
||||||
describe('Session lifecycle', () => {
|
|
||||||
it('should create and validate a session', async () => {
|
|
||||||
const regResult = await auth.register('session@example.com', 'Pass123!', 'Session User', 'planer');
|
|
||||||
const token = regResult.session.token;
|
|
||||||
|
|
||||||
const session = auth.validateSession(token);
|
|
||||||
expect(session).not.toBeNull();
|
|
||||||
expect(session!.token).toBe(token);
|
|
||||||
expect(session!.userId).toBe(regResult.user.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for invalid session token', () => {
|
|
||||||
const session = auth.validateSession('invalid-token-xyz');
|
|
||||||
expect(session).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should destroy a session and invalidate it', async () => {
|
|
||||||
const regResult = await auth.register('destroy@example.com', 'Pass123!', 'Destroy User', 'planer');
|
|
||||||
const token = regResult.session.token;
|
|
||||||
|
|
||||||
// Session should be valid
|
|
||||||
expect(auth.validateSession(token)).not.toBeNull();
|
|
||||||
|
|
||||||
// Destroy it
|
|
||||||
auth.destroySession(token);
|
|
||||||
|
|
||||||
// Session should now be invalid
|
|
||||||
expect(auth.validateSession(token)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle destroying a non-existent session gracefully', () => {
|
|
||||||
// Should not throw
|
|
||||||
auth.destroySession('non-existent-token');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── getUserFromSession ─────────────────────────────
|
|
||||||
|
|
||||||
describe('getUserFromSession()', () => {
|
|
||||||
it('should return user from valid session', async () => {
|
|
||||||
const regResult = await auth.register('getuser@example.com', 'Pass123!', 'GetUser User', 'planer');
|
|
||||||
const token = regResult.session.token;
|
|
||||||
|
|
||||||
const user = auth.getUserFromSession(token);
|
|
||||||
expect(user).not.toBeNull();
|
|
||||||
expect(user!.email).toBe('getuser@example.com');
|
|
||||||
expect(user!.password_hash).toBeDefined(); // returns full DBUser including password_hash
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for invalid session token', () => {
|
|
||||||
const user = auth.getUserFromSession('invalid-token-xyz');
|
|
||||||
expect(user).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null after session is destroyed', async () => {
|
|
||||||
const regResult = await auth.register('getuser2@example.com', 'Pass123!', 'GetUser2 User', 'planer');
|
|
||||||
const token = regResult.session.token;
|
|
||||||
|
|
||||||
auth.destroySession(token);
|
|
||||||
const user = auth.getUserFromSession(token);
|
|
||||||
expect(user).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── createSession (standalone) ─────────────────────
|
|
||||||
|
|
||||||
describe('createSession()', () => {
|
|
||||||
it('should create a session with a unique token', async () => {
|
|
||||||
const regResult = await auth.register('createsession@example.com', 'Pass123!', 'CS User', 'planer');
|
|
||||||
const session1 = auth.createSession(regResult.user.id);
|
|
||||||
const session2 = auth.createSession(regResult.user.id);
|
|
||||||
|
|
||||||
expect(session1.token).not.toBe(session2.token);
|
|
||||||
expect(session1.userId).toBe(regResult.user.id);
|
|
||||||
expect(session2.userId).toBe(regResult.user.id);
|
|
||||||
expect(session1.expiresAt).toBeGreaterThan(Date.now());
|
|
||||||
expect(session2.expiresAt).toBeGreaterThan(Date.now());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
/**
|
|
||||||
* SqliteAdapter Unit Tests – init, CRUD for all entities, close
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
|
|
||||||
let idCounter = 0;
|
|
||||||
function uniqueId(prefix: string): string {
|
|
||||||
idCounter++;
|
|
||||||
return `${prefix}-${Date.now()}-${idCounter}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SqliteAdapter', () => {
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Init & Close ───────────────────────────────────
|
|
||||||
|
|
||||||
describe('init() and close()', () => {
|
|
||||||
it('should initialize the database without errors', async () => {
|
|
||||||
const testDb = new SqliteAdapter(':memory:');
|
|
||||||
await testDb.init();
|
|
||||||
const users = testDb.listUsers();
|
|
||||||
expect(Array.isArray(users)).toBe(true);
|
|
||||||
testDb.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should seed a default user on init', async () => {
|
|
||||||
const defaultUser = db.getUser('user-default');
|
|
||||||
expect(defaultUser).not.toBeNull();
|
|
||||||
expect(defaultUser!.email).toBe('default@webcad.local');
|
|
||||||
expect(defaultUser!.role).toBe('admin');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Users CRUD ─────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Users CRUD', () => {
|
|
||||||
it('should create and get a user', () => {
|
|
||||||
const uid = uniqueId('user');
|
|
||||||
const user = db.createUser({
|
|
||||||
id: uid,
|
|
||||||
email: `${uid}@example.com`,
|
|
||||||
password_hash: 'hash123',
|
|
||||||
name: 'CRUD User',
|
|
||||||
role: 'planer',
|
|
||||||
});
|
|
||||||
expect(user.id).toBe(uid);
|
|
||||||
expect(user.email).toBe(`${uid}@example.com`);
|
|
||||||
|
|
||||||
const fetched = db.getUser(user.id);
|
|
||||||
expect(fetched).not.toBeNull();
|
|
||||||
expect(fetched!.email).toBe(`${uid}@example.com`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get user by email', () => {
|
|
||||||
const uid = uniqueId('user');
|
|
||||||
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Email User', role: 'planer' });
|
|
||||||
const user = db.getUserByEmail(`${uid}@example.com`);
|
|
||||||
expect(user).not.toBeNull();
|
|
||||||
expect(user!.name).toBe('Email User');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for non-existent user', () => {
|
|
||||||
expect(db.getUser('non-existent-id')).toBeNull();
|
|
||||||
expect(db.getUserByEmail('nobody@nowhere.com')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update a user', () => {
|
|
||||||
const uid = uniqueId('user');
|
|
||||||
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Original Name', role: 'planer' });
|
|
||||||
const updated = db.updateUser(uid, { name: 'Updated Name', role: 'admin' });
|
|
||||||
expect(updated).not.toBeNull();
|
|
||||||
expect(updated!.name).toBe('Updated Name');
|
|
||||||
expect(updated!.role).toBe('admin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list users', () => {
|
|
||||||
const users = db.listUsers();
|
|
||||||
expect(users.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should delete a user', () => {
|
|
||||||
const uid = uniqueId('user');
|
|
||||||
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Delete Me', role: 'planer' });
|
|
||||||
const ok = db.deleteUser(uid);
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
expect(db.getUser(uid)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when deleting non-existent user', () => {
|
|
||||||
expect(db.deleteUser('non-existent-id')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Projects CRUD ──────────────────────────────────
|
|
||||||
|
|
||||||
describe('Projects CRUD', () => {
|
|
||||||
it('should create and get a project', () => {
|
|
||||||
const pid = uniqueId('proj');
|
|
||||||
const project = db.createProject({ id: pid, name: 'Test Project', description: 'Test desc' });
|
|
||||||
expect(project.id).toBe(pid);
|
|
||||||
expect(project.name).toBe('Test Project');
|
|
||||||
|
|
||||||
const fetched = db.getProject(pid);
|
|
||||||
expect(fetched).not.toBeNull();
|
|
||||||
expect(fetched!.name).toBe('Test Project');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for non-existent project', () => {
|
|
||||||
expect(db.getProject('non-existent-id')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update a project', () => {
|
|
||||||
const pid = uniqueId('proj');
|
|
||||||
db.createProject({ id: pid, name: 'Update Project' });
|
|
||||||
const updated = db.updateProject(pid, { name: 'Updated Project', description: 'New desc' });
|
|
||||||
expect(updated).not.toBeNull();
|
|
||||||
expect(updated!.name).toBe('Updated Project');
|
|
||||||
expect(updated!.description).toBe('New desc');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list projects', () => {
|
|
||||||
const projects = db.listProjects();
|
|
||||||
expect(projects.length).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should delete a project', () => {
|
|
||||||
const pid = uniqueId('proj');
|
|
||||||
db.createProject({ id: pid, name: 'Delete Project' });
|
|
||||||
const ok = db.deleteProject(pid);
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
expect(db.getProject(pid)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should cascade delete drawings when project is deleted', () => {
|
|
||||||
const pid = uniqueId('proj');
|
|
||||||
const did = uniqueId('draw');
|
|
||||||
db.createProject({ id: pid, name: 'Cascade Project' });
|
|
||||||
db.createDrawing({ id: did, project_id: pid, name: 'Cascade Drawing' });
|
|
||||||
db.deleteProject(pid);
|
|
||||||
expect(db.getDrawing(did)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Drawings CRUD ──────────────────────────────────
|
|
||||||
|
|
||||||
describe('Drawings CRUD', () => {
|
|
||||||
let projectId: string;
|
|
||||||
|
|
||||||
it('should create and get a drawing', () => {
|
|
||||||
projectId = uniqueId('proj');
|
|
||||||
const did = uniqueId('draw');
|
|
||||||
db.createProject({ id: projectId, name: 'Drawing Project' });
|
|
||||||
const drawing = db.createDrawing({ id: did, project_id: projectId, name: 'Floor 1' });
|
|
||||||
expect(drawing.id).toBe(did);
|
|
||||||
expect(drawing.name).toBe('Floor 1');
|
|
||||||
expect(drawing.project_id).toBe(projectId);
|
|
||||||
|
|
||||||
const fetched = db.getDrawing(did);
|
|
||||||
expect(fetched).not.toBeNull();
|
|
||||||
expect(fetched!.name).toBe('Floor 1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list drawings by project', () => {
|
|
||||||
const did1 = uniqueId('draw');
|
|
||||||
const did2 = uniqueId('draw');
|
|
||||||
db.createDrawing({ id: did1, project_id: projectId, name: 'Drawing A' });
|
|
||||||
db.createDrawing({ id: did2, project_id: projectId, name: 'Drawing B' });
|
|
||||||
const drawings = db.listDrawings(projectId);
|
|
||||||
expect(drawings.length).toBeGreaterThanOrEqual(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update a drawing', () => {
|
|
||||||
const did = uniqueId('draw');
|
|
||||||
db.createDrawing({ id: did, project_id: projectId, name: 'Update Me' });
|
|
||||||
const updated = db.updateDrawing(did, { name: 'Updated Drawing', data_json: '{"x":1}' });
|
|
||||||
expect(updated).not.toBeNull();
|
|
||||||
expect(updated!.name).toBe('Updated Drawing');
|
|
||||||
expect(updated!.data_json).toBe('{"x":1}');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should delete a drawing', () => {
|
|
||||||
const did = uniqueId('draw');
|
|
||||||
db.createDrawing({ id: did, project_id: projectId, name: 'Delete Me' });
|
|
||||||
const ok = db.deleteDrawing(did);
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
expect(db.getDrawing(did)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should cascade delete layers when drawing is deleted', () => {
|
|
||||||
const did = uniqueId('draw');
|
|
||||||
const lid = uniqueId('layer');
|
|
||||||
db.createDrawing({ id: did, project_id: projectId, name: 'Cascade Drawing' });
|
|
||||||
db.createLayer({ id: lid, drawing_id: did, name: 'Cascade Layer' });
|
|
||||||
db.deleteDrawing(did);
|
|
||||||
const layers = db.listLayers(did);
|
|
||||||
expect(layers.find((l) => l.id === lid)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Layers CRUD ────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Layers CRUD', () => {
|
|
||||||
let drawingId: string;
|
|
||||||
|
|
||||||
it('should create and get a layer', () => {
|
|
||||||
const pid = uniqueId('proj');
|
|
||||||
drawingId = uniqueId('draw');
|
|
||||||
const lid = uniqueId('layer');
|
|
||||||
db.createProject({ id: pid, name: 'Layer Project' });
|
|
||||||
db.createDrawing({ id: drawingId, project_id: pid, name: 'Layer Drawing' });
|
|
||||||
const layer = db.createLayer({ id: lid, drawing_id: drawingId, name: 'Layer 1', color: '#ff0000' });
|
|
||||||
expect(layer.id).toBe(lid);
|
|
||||||
expect(layer.name).toBe('Layer 1');
|
|
||||||
expect(layer.color).toBe('#ff0000');
|
|
||||||
expect(layer.visible).toBe(1);
|
|
||||||
expect(layer.locked).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list layers by drawing', () => {
|
|
||||||
const lid2 = uniqueId('layer');
|
|
||||||
db.createLayer({ id: lid2, drawing_id: drawingId, name: 'Layer 2' });
|
|
||||||
const layers = db.listLayers(drawingId);
|
|
||||||
expect(layers.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update a layer', () => {
|
|
||||||
const lid = uniqueId('layer');
|
|
||||||
db.createLayer({ id: lid, drawing_id: drawingId, name: 'Update Layer' });
|
|
||||||
const updated = db.updateLayer(lid, { name: 'Updated Layer', visible: 0, locked: 1 });
|
|
||||||
expect(updated).not.toBeNull();
|
|
||||||
expect(updated!.name).toBe('Updated Layer');
|
|
||||||
expect(updated!.visible).toBe(0);
|
|
||||||
expect(updated!.locked).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should delete a layer', () => {
|
|
||||||
const lid = uniqueId('layer');
|
|
||||||
db.createLayer({ id: lid, drawing_id: drawingId, name: 'Delete Layer' });
|
|
||||||
const ok = db.deleteLayer(lid);
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
const layers = db.listLayers(drawingId);
|
|
||||||
expect(layers.find((l) => l.id === lid)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Elements CRUD ──────────────────────────────────
|
|
||||||
|
|
||||||
describe('Elements CRUD', () => {
|
|
||||||
let drawingId: string;
|
|
||||||
let layerId: string;
|
|
||||||
|
|
||||||
it('should create and get an element', () => {
|
|
||||||
const pid = uniqueId('proj');
|
|
||||||
drawingId = uniqueId('draw');
|
|
||||||
layerId = uniqueId('layer');
|
|
||||||
const eid = uniqueId('elem');
|
|
||||||
db.createProject({ id: pid, name: 'Element Project' });
|
|
||||||
db.createDrawing({ id: drawingId, project_id: pid, name: 'Element Drawing' });
|
|
||||||
db.createLayer({ id: layerId, drawing_id: drawingId, name: 'Element Layer' });
|
|
||||||
|
|
||||||
const element = db.createElement({
|
|
||||||
id: eid,
|
|
||||||
drawing_id: drawingId,
|
|
||||||
layer_id: layerId,
|
|
||||||
type: 'rect',
|
|
||||||
x: 10,
|
|
||||||
y: 20,
|
|
||||||
width: 100,
|
|
||||||
height: 50,
|
|
||||||
});
|
|
||||||
expect(element.id).toBe(eid);
|
|
||||||
expect(element.type).toBe('rect');
|
|
||||||
expect(element.x).toBe(10);
|
|
||||||
expect(element.y).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list elements by drawing', () => {
|
|
||||||
const eid2 = uniqueId('elem');
|
|
||||||
db.createElement({ id: eid2, drawing_id: drawingId, layer_id: layerId, type: 'circle' });
|
|
||||||
const elements = db.listElements(drawingId);
|
|
||||||
expect(elements.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update an element', () => {
|
|
||||||
const eid = uniqueId('elem');
|
|
||||||
db.createElement({ id: eid, drawing_id: drawingId, layer_id: layerId, type: 'line' });
|
|
||||||
const updated = db.updateElement(eid, { x: 500, y: 600, width: 200 });
|
|
||||||
expect(updated).not.toBeNull();
|
|
||||||
expect(updated!.x).toBe(500);
|
|
||||||
expect(updated!.y).toBe(600);
|
|
||||||
expect(updated!.width).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should delete an element', () => {
|
|
||||||
const eid = uniqueId('elem');
|
|
||||||
db.createElement({ id: eid, drawing_id: drawingId, layer_id: layerId, type: 'text' });
|
|
||||||
const ok = db.deleteElement(eid);
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
const elements = db.listElements(drawingId);
|
|
||||||
expect(elements.find((e) => e.id === eid)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Blocks CRUD ────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Blocks CRUD', () => {
|
|
||||||
let drawingId: string;
|
|
||||||
|
|
||||||
it('should create and get a block', () => {
|
|
||||||
const pid = uniqueId('proj');
|
|
||||||
drawingId = uniqueId('draw');
|
|
||||||
const bid = uniqueId('block');
|
|
||||||
db.createProject({ id: pid, name: 'Block Project' });
|
|
||||||
db.createDrawing({ id: drawingId, project_id: pid, name: 'Block Drawing' });
|
|
||||||
|
|
||||||
const block = db.createBlock({ id: bid, drawing_id: drawingId, name: 'Table Block', category: 'Mobiliar' });
|
|
||||||
expect(block.id).toBe(bid);
|
|
||||||
expect(block.name).toBe('Table Block');
|
|
||||||
expect(block.category).toBe('Mobiliar');
|
|
||||||
expect(block.elements_json).toBe('[]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list blocks by drawing', () => {
|
|
||||||
const bid2 = uniqueId('block');
|
|
||||||
db.createBlock({ id: bid2, drawing_id: drawingId, name: 'Chair Block' });
|
|
||||||
const blocks = db.listBlocks(drawingId);
|
|
||||||
expect(blocks.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update a block', () => {
|
|
||||||
const bid = uniqueId('block');
|
|
||||||
db.createBlock({ id: bid, drawing_id: drawingId, name: 'Update Block' });
|
|
||||||
const updated = db.updateBlock(bid, { name: 'Updated Block', category: 'Bühne' });
|
|
||||||
expect(updated).not.toBeNull();
|
|
||||||
expect(updated!.name).toBe('Updated Block');
|
|
||||||
expect(updated!.category).toBe('Bühne');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should delete a block', () => {
|
|
||||||
const bid = uniqueId('block');
|
|
||||||
db.createBlock({ id: bid, drawing_id: drawingId, name: 'Delete Block' });
|
|
||||||
const ok = db.deleteBlock(bid);
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
const blocks = db.listBlocks(drawingId);
|
|
||||||
expect(blocks.find((b) => b.id === bid)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Settings CRUD ──────────────────────────────────
|
|
||||||
|
|
||||||
describe('Settings CRUD', () => {
|
|
||||||
it('should set and get a setting', () => {
|
|
||||||
db.setSetting('test-key', 'test-value');
|
|
||||||
const setting = db.getSetting('test-key');
|
|
||||||
expect(setting).not.toBeNull();
|
|
||||||
expect(setting!.key).toBe('test-key');
|
|
||||||
expect(setting!.value).toBe('test-value');
|
|
||||||
expect(setting!.updated_at).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for non-existent setting', () => {
|
|
||||||
expect(db.getSetting('non-existent-key')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should upsert (update existing setting)', () => {
|
|
||||||
db.setSetting('upsert-key', 'initial');
|
|
||||||
db.setSetting('upsert-key', 'updated');
|
|
||||||
const setting = db.getSetting('upsert-key');
|
|
||||||
expect(setting).not.toBeNull();
|
|
||||||
expect(setting!.value).toBe('updated');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── ID-Kollisionsklasse: Date.now() → randomUUID (BUG-1-Familie) ──
|
|
||||||
// Deterministischer Beweis: Date.now() wird via Fake-Timers EINGEFROREN,
|
|
||||||
// damit zwei Creates garantiert im selben Tick landen. Mit Date.now()-IDs
|
|
||||||
// kollidiert der zweite INSERT am PRIMARY KEY (UNIQUE-Exception); mit
|
|
||||||
// randomUUID sind die IDs stets eindeutig.
|
|
||||||
import { vi } from 'vitest';
|
|
||||||
const FROZEN_NOW = 1756000000000;
|
|
||||||
|
|
||||||
describe('SqliteAdapter – auto-ID uniqueness within same tick', () => {
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(FROZEN_NOW);
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
db.close();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('createUser liefert unterschiedliche IDs bei zwei Creates im selben Tick', () => {
|
|
||||||
const a = db.createUser({ email: `tick-a-${Date.now()}@x.de`, password_hash: 'h', name: 'A' });
|
|
||||||
const b = db.createUser({ email: `tick-b-${Date.now()}@x.de`, password_hash: 'h', name: 'B' });
|
|
||||||
expect(b.id).not.toBe(a.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('createProject/createProjectFolder liefern unterschiedliche IDs im selben Tick', () => {
|
|
||||||
const p1 = db.createProject({ name: 'P1' });
|
|
||||||
const p2 = db.createProject({ name: 'P2' });
|
|
||||||
expect(p2.id).not.toBe(p1.id);
|
|
||||||
const f1 = db.createProjectFolder({ name: 'F1' });
|
|
||||||
const f2 = db.createProjectFolder({ name: 'F2' });
|
|
||||||
expect(f2.id).not.toBe(f1.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('createDrawing/createLayer/createElement/createBlock liefern unterschiedliche IDs im selben Tick', () => {
|
|
||||||
const proj = db.createProject({ name: 'DrawHost' });
|
|
||||||
const d1 = db.createDrawing({ project_id: proj.id, name: 'D1' });
|
|
||||||
const d2 = db.createDrawing({ project_id: proj.id, name: 'D2' });
|
|
||||||
expect(d2.id).not.toBe(d1.id);
|
|
||||||
|
|
||||||
const l1 = db.createLayer({ drawing_id: d1.id, name: 'L1' });
|
|
||||||
const l2 = db.createLayer({ drawing_id: d1.id, name: 'L2' });
|
|
||||||
expect(l2.id).not.toBe(l1.id);
|
|
||||||
|
|
||||||
const e1 = db.createElement({ drawing_id: d1.id, layer_id: l1.id, type: 'line' });
|
|
||||||
const e2 = db.createElement({ drawing_id: d1.id, layer_id: l1.id, type: 'line' });
|
|
||||||
expect(e2.id).not.toBe(e1.id);
|
|
||||||
|
|
||||||
const b1 = db.createBlock({ drawing_id: d1.id, name: 'B1' });
|
|
||||||
const b2 = db.createBlock({ drawing_id: d1.id, name: 'B2' });
|
|
||||||
expect(b2.id).not.toBe(b1.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('createNotification/createProjectShare liefern unterschiedliche IDs im selben Tick', () => {
|
|
||||||
const proj = db.createProject({ name: 'ShareHost' });
|
|
||||||
const n1 = db.createNotification({ user_id: 'user-default', title: 'T1', message: 'M1' });
|
|
||||||
const n2 = db.createNotification({ user_id: 'user-default', title: 'T2', message: 'M2' });
|
|
||||||
expect(n2.id).not.toBe(n1.id);
|
|
||||||
|
|
||||||
const s1 = db.createProjectShare({ project_id: proj.id, shared_with_email: 'a@x.de', shared_by: 'user-default' });
|
|
||||||
const s2 = db.createProjectShare({ project_id: proj.id, shared_with_email: 'b@x.de', shared_by: 'user-default' });
|
|
||||||
expect(s2.id).not.toBe(s1.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* Backend Stresstest – 50.000 Elemente: SQLite CRUD Performance,
|
|
||||||
* Bulk-Insert, List, Update, Delete mit Transaktion-Support.
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import type { DBElement } from '../src/database/DatabaseInterface.js';
|
|
||||||
|
|
||||||
const N = 50_000;
|
|
||||||
|
|
||||||
describe('Backend Stresstest: 50.000 Elemente in SQLite', () => {
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let drawingId: string;
|
|
||||||
let layerId: string;
|
|
||||||
let elementIds: string[] = [];
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
|
|
||||||
// Create prerequisite project → drawing → layer
|
|
||||||
const project = db.createProject({ name: 'Stresstest Project' });
|
|
||||||
const drawing = db.createDrawing({ project_id: project.id, name: 'Stresstest Drawing' });
|
|
||||||
drawingId = drawing.id;
|
|
||||||
const layer = db.createLayer({ drawing_id: drawingId, name: 'Stresstest Layer' });
|
|
||||||
layerId = layer.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should bulk-insert 50k elements in under 5s', () => {
|
|
||||||
const start = performance.now();
|
|
||||||
// Use transaction for bulk insert
|
|
||||||
const insert = db['db'].prepare(
|
|
||||||
'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
);
|
|
||||||
const insertMany = db['db'].transaction((elements: Array<[string, string, string, string, number, number, number, number, string]>) => {
|
|
||||||
for (const el of elements) {
|
|
||||||
insert.run(...el);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const batch: Array<[string, string, string, string, number, number, number, number, string]> = [];
|
|
||||||
for (let i = 0; i < N; i++) {
|
|
||||||
const id = `stress-elem-${i}`;
|
|
||||||
elementIds.push(id);
|
|
||||||
batch.push([
|
|
||||||
id,
|
|
||||||
drawingId,
|
|
||||||
layerId,
|
|
||||||
'rect',
|
|
||||||
(i % 1000) * 1.5,
|
|
||||||
Math.floor(i / 1000) * 1.5,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
'{}',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
insertMany(batch);
|
|
||||||
const elapsed = performance.now() - start;
|
|
||||||
console.log(`Bulk insert ${N} elements: ${elapsed.toFixed(1)}ms`);
|
|
||||||
expect(elapsed).toBeLessThan(5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list 50k elements in under 500ms', () => {
|
|
||||||
const start = performance.now();
|
|
||||||
const elements = db.listElements(drawingId);
|
|
||||||
const elapsed = performance.now() - start;
|
|
||||||
console.log(`List ${N} elements: ${elapsed.toFixed(1)}ms, count=${elements.length}`);
|
|
||||||
expect(elements.length).toBe(N);
|
|
||||||
expect(elapsed).toBeLessThan(1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update 100 elements in under 200ms', () => {
|
|
||||||
const start = performance.now();
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
db.updateElement(elementIds[i], { x: 9999 + i });
|
|
||||||
}
|
|
||||||
const elapsed = performance.now() - start;
|
|
||||||
console.log(`Update 100 elements: ${elapsed.toFixed(1)}ms`);
|
|
||||||
expect(elapsed).toBeLessThan(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should read a single element in under 5ms', () => {
|
|
||||||
const start = performance.now();
|
|
||||||
const element = db.listElements(drawingId);
|
|
||||||
const mid = element[Math.floor(element.length / 2)];
|
|
||||||
const elapsed = performance.now() - start;
|
|
||||||
console.log(`Read mid element from ${N}: ${elapsed.toFixed(1)}ms`);
|
|
||||||
expect(mid).toBeDefined();
|
|
||||||
expect(elapsed).toBeLessThan(2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should delete 1000 elements in under 500ms', () => {
|
|
||||||
const start = performance.now();
|
|
||||||
const deleteStmt = db['db'].prepare('DELETE FROM elements WHERE id = ?');
|
|
||||||
const deleteMany = db['db'].transaction((ids: string[]) => {
|
|
||||||
for (const id of ids) {
|
|
||||||
deleteStmt.run(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const toDelete = elementIds.slice(0, 1000);
|
|
||||||
deleteMany(toDelete);
|
|
||||||
const elapsed = performance.now() - start;
|
|
||||||
console.log(`Delete 1000 elements: ${elapsed.toFixed(1)}ms`);
|
|
||||||
expect(elapsed).toBeLessThan(500);
|
|
||||||
|
|
||||||
// Verify count
|
|
||||||
const remaining = db.listElements(drawingId);
|
|
||||||
expect(remaining.length).toBe(N - 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle concurrent queries efficiently', { timeout: 30000 }, () => {
|
|
||||||
// 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(30000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
/**
|
|
||||||
* AI API Tests – Auth guard, validation, and service availability checks
|
|
||||||
* Does NOT test actual OpenRouter API calls.
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { createServer } from '../src/server.js';
|
|
||||||
|
|
||||||
describe('AI API', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let authToken: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
app = await createServer({ db, port: 0 });
|
|
||||||
await app.ready();
|
|
||||||
|
|
||||||
// Register a user to get an auth token
|
|
||||||
const registerRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: {
|
|
||||||
email: 'ai-test@example.com',
|
|
||||||
password: 'TestPass123!',
|
|
||||||
name: 'AI Test User',
|
|
||||||
role: 'planer',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const body = JSON.parse(registerRes.body);
|
|
||||||
authToken = body.session.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Auth Guard ─────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/ai/chat – Authentication', () => {
|
|
||||||
it('should return 401 without authorization header', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/ai/chat',
|
|
||||||
payload: { messages: [{ role: 'user', content: 'hello' }] },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.error).toContain('Authentication required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 401 with invalid token', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/ai/chat',
|
|
||||||
headers: { authorization: 'Bearer invalid-token-xxx' },
|
|
||||||
payload: { messages: [{ role: 'user', content: 'hello' }] },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.error).toContain('Invalid or expired session');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 401 with malformed authorization header', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/ai/chat',
|
|
||||||
headers: { authorization: 'NotBearer sometoken' },
|
|
||||||
payload: { messages: [{ role: 'user', content: 'hello' }] },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Validation ─────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/ai/chat – Validation', () => {
|
|
||||||
it('should return 400 when messages array is missing', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/ai/chat',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.error).toContain('Messages array is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 400 when messages array is empty', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/ai/chat',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { messages: [] },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.error).toContain('Messages array is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 400 when messages is not an array', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/ai/chat',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { messages: 'not an array' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Service Availability ───────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/ai/chat – Service availability', () => {
|
|
||||||
it('should return 503 when API_KEY_OPENROUTER is not set', async () => {
|
|
||||||
// Ensure env var is not set for this test
|
|
||||||
const originalValue = process.env.API_KEY_OPENROUTER;
|
|
||||||
delete process.env.API_KEY_OPENROUTER;
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/ai/chat',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { messages: [{ role: 'user', content: 'hello' }] },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(503);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.error).toContain('AI service not configured');
|
|
||||||
|
|
||||||
// Restore original value if it existed
|
|
||||||
if (originalValue !== undefined) {
|
|
||||||
process.env.API_KEY_OPENROUTER = originalValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auth API Tests – Register / Login / Logout / Me / Password Change
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { createServer } from '../src/server.js';
|
|
||||||
|
|
||||||
describe('Auth API', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let authToken: string | null = null;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
app = await createServer({ db, port: 0 });
|
|
||||||
await app.ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Register ────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/auth/register', () => {
|
|
||||||
it('should register a new user with 201', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: {
|
|
||||||
email: 'testuser@example.com',
|
|
||||||
password: 'TestPass123!',
|
|
||||||
name: 'Test User',
|
|
||||||
role: 'planer',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.user).toBeDefined();
|
|
||||||
expect(body.user.email).toBe('testuser@example.com');
|
|
||||||
expect(body.user.name).toBe('Test User');
|
|
||||||
expect(body.user.password_hash).toBeUndefined();
|
|
||||||
expect(body.session).toBeDefined();
|
|
||||||
expect(body.session.token).toBeDefined();
|
|
||||||
authToken = body.session.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject duplicate registration with 409', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: {
|
|
||||||
email: 'testuser@example.com',
|
|
||||||
password: 'TestPass123!',
|
|
||||||
name: 'Test User 2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(409);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.error).toContain('already registered');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject missing fields with 400', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: 'incomplete@example.com' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Login ──────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/auth/login', () => {
|
|
||||||
it('should login with correct credentials', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/login',
|
|
||||||
payload: {
|
|
||||||
email: 'testuser@example.com',
|
|
||||||
password: 'TestPass123!',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.user).toBeDefined();
|
|
||||||
expect(body.session.token).toBeDefined();
|
|
||||||
authToken = body.session.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject wrong password with 401', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/login',
|
|
||||||
payload: {
|
|
||||||
email: 'testuser@example.com',
|
|
||||||
password: 'WrongPassword!',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject non-existent email with 401', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/login',
|
|
||||||
payload: {
|
|
||||||
email: 'nobody@example.com',
|
|
||||||
password: 'SomePassword!',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject missing fields with 400', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/login',
|
|
||||||
payload: { email: 'testuser@example.com' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Me ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('GET /api/auth/me', () => {
|
|
||||||
it('should return current user profile with valid token', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/auth/me',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.email).toBe('testuser@example.com');
|
|
||||||
expect(body.password_hash).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject without token with 401', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/auth/me',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject invalid token with 401', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/auth/me',
|
|
||||||
headers: { authorization: 'Bearer invalid-token-xxx' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Password Change ───────────────────────────────
|
|
||||||
|
|
||||||
describe('PATCH /api/auth/password', () => {
|
|
||||||
it('should change password with correct old password', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/api/auth/password',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: {
|
|
||||||
oldPassword: 'TestPass123!',
|
|
||||||
newPassword: 'NewPassword456!',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow login with new password', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/login',
|
|
||||||
payload: {
|
|
||||||
email: 'testuser@example.com',
|
|
||||||
password: 'NewPassword456!',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.session.token).toBeDefined();
|
|
||||||
authToken = body.session.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject old password after change', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/login',
|
|
||||||
payload: {
|
|
||||||
email: 'testuser@example.com',
|
|
||||||
password: 'TestPass123!',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject password change with wrong old password', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/api/auth/password',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: {
|
|
||||||
oldPassword: 'WrongOldPassword!',
|
|
||||||
newPassword: 'AnotherNew789!',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject password change without auth', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/api/auth/password',
|
|
||||||
payload: {
|
|
||||||
oldPassword: 'NewPassword456!',
|
|
||||||
newPassword: 'AnotherNew789!',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject missing password fields', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/api/auth/password',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { oldPassword: 'NewPassword456!' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Logout ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/auth/logout', () => {
|
|
||||||
it('should logout and invalidate token', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/logout',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(204);
|
|
||||||
|
|
||||||
// Token should now be invalid
|
|
||||||
const meResponse = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/auth/me',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(meResponse.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 204 even without token', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/logout',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(204);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
/**
|
|
||||||
* Blocks API Tests – CRUD (List / Create / Update / Delete)
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { createServer } from '../src/server.js';
|
|
||||||
|
|
||||||
describe('Blocks API', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let drawingId: string;
|
|
||||||
let createdBlockId: string;
|
|
||||||
let authToken: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
app = await createServer({ db, port: 0 });
|
|
||||||
await app.ready();
|
|
||||||
|
|
||||||
const regRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: 'admin-test@example.com', password: 'Password123!', name: 'Admin Test', role: 'admin' },
|
|
||||||
});
|
|
||||||
authToken = JSON.parse(regRes.body).session.token;
|
|
||||||
|
|
||||||
// Create project → drawing hierarchy
|
|
||||||
const projRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Blocks Test Project' },
|
|
||||||
});
|
|
||||||
const projectId = JSON.parse(projRes.body).id;
|
|
||||||
|
|
||||||
const drawRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/projects/${projectId}/drawings`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Blocks Test Drawing' },
|
|
||||||
});
|
|
||||||
drawingId = JSON.parse(drawRes.body).id;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Auth Check ──────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Authentication', () => {
|
|
||||||
it('should return 401 without authorization header', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${drawingId}/blocks`,
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Create ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/drawings/:drawingId/blocks', () => {
|
|
||||||
it('should create a block with 201', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/drawings/${drawingId}/blocks`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Standard Table', category: 'Mobiliar', description: 'A standard round table' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.id).toBeDefined();
|
|
||||||
expect(body.name).toBe('Standard Table');
|
|
||||||
expect(body.category).toBe('Mobiliar');
|
|
||||||
expect(body.description).toBe('A standard round table');
|
|
||||||
expect(body.drawing_id).toBe(drawingId);
|
|
||||||
createdBlockId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a block with default values', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/drawings/${drawingId}/blocks`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Minimal Block' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.name).toBe('Minimal Block');
|
|
||||||
expect(body.category).toBe('Allgemein');
|
|
||||||
expect(body.description).toBeNull();
|
|
||||||
expect(body.elements_json).toBe('[]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject block without name with 400', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/drawings/${drawingId}/blocks`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { category: 'Test' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.error).toContain('Name is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject block with empty name with 400', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/drawings/${drawingId}/blocks`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: '' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── List ───────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('GET /api/drawings/:drawingId/blocks', () => {
|
|
||||||
it('should list blocks for a drawing', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${drawingId}/blocks`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array for drawing with no blocks', async () => {
|
|
||||||
const projRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Empty Blocks Project' },
|
|
||||||
});
|
|
||||||
const projId = JSON.parse(projRes.body).id;
|
|
||||||
const drawRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/projects/${projId}/drawings`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Empty Drawing' },
|
|
||||||
});
|
|
||||||
const emptyDrawId = JSON.parse(drawRes.body).id;
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${emptyDrawId}/blocks`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Update ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PATCH /api/blocks/:id', () => {
|
|
||||||
it('should update block name', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/blocks/${createdBlockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Updated Block Name' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.name).toBe('Updated Block Name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update block category and description', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/blocks/${createdBlockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { category: 'Bühne', description: 'Stage equipment' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.category).toBe('Bühne');
|
|
||||||
expect(body.description).toBe('Stage equipment');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update block elements_json', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/blocks/${createdBlockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { elements_json: '[{"type":"rect"}]' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.elements_json).toBe('[{"type":"rect"}]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent block update', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/api/blocks/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'New Name' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Delete ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('DELETE /api/blocks/:id', () => {
|
|
||||||
it('should delete a block', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/drawings/${drawingId}/blocks`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'To Be Deleted' },
|
|
||||||
});
|
|
||||||
const blockId = JSON.parse(createRes.body).id;
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/api/blocks/${blockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(204);
|
|
||||||
|
|
||||||
// Verify it's gone via list
|
|
||||||
const listRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${drawingId}/blocks`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
const blocks = JSON.parse(listRes.body);
|
|
||||||
expect(blocks.find((b: any) => b.id === blockId)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent block delete', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/api/blocks/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
/**
|
|
||||||
* Drawings API Tests – CRUD (List / Get / Create / Update / Delete)
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { createServer } from '../src/server.js';
|
|
||||||
|
|
||||||
describe('Drawings API', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let projectId: string;
|
|
||||||
let createdDrawingId: string;
|
|
||||||
let authToken: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
app = await createServer({ db, port: 0 });
|
|
||||||
await app.ready();
|
|
||||||
|
|
||||||
const regRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: 'admin-test@example.com', password: 'Password123!', name: 'Admin Test', role: 'admin' },
|
|
||||||
});
|
|
||||||
authToken = JSON.parse(regRes.body).session.token;
|
|
||||||
|
|
||||||
// Create a project first (drawings belong to projects)
|
|
||||||
const projectRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Drawings Test Project' },
|
|
||||||
});
|
|
||||||
projectId = JSON.parse(projectRes.body).id;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Auth Check ──────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Authentication', () => {
|
|
||||||
it('should return 401 without authorization header', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/projects/${projectId}/drawings`,
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Create ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/projects/:projectId/drawings', () => {
|
|
||||||
it('should create a drawing with 201', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/projects/${projectId}/drawings`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Ground Floor' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.id).toBeDefined();
|
|
||||||
expect(body.name).toBe('Ground Floor');
|
|
||||||
expect(body.project_id).toBe(projectId);
|
|
||||||
createdDrawingId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a drawing with default name', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/projects/${projectId}/drawings`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.name).toBe('Unbenannt');
|
|
||||||
expect(body.project_id).toBe(projectId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── List ───────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('GET /api/projects/:projectId/drawings', () => {
|
|
||||||
it('should list drawings for a project', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/projects/${projectId}/drawings`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array for project with no drawings', async () => {
|
|
||||||
// Create a new empty project
|
|
||||||
const projRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Empty Project' },
|
|
||||||
});
|
|
||||||
const emptyProjId = JSON.parse(projRes.body).id;
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/projects/${emptyProjId}/drawings`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Get ────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('GET /api/drawings/:id', () => {
|
|
||||||
it('should get a single drawing', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${createdDrawingId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.id).toBe(createdDrawingId);
|
|
||||||
expect(body.name).toBe('Ground Floor');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent drawing', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/drawings/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Update ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PATCH /api/drawings/:id', () => {
|
|
||||||
it('should update drawing name', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/drawings/${createdDrawingId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'First Floor' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.name).toBe('First Floor');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update drawing data_json', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/drawings/${createdDrawingId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { data_json: '{"layers":[]}' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.data_json).toBe('{"layers":[]}');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent drawing update', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/api/drawings/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'New Name' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Delete ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('DELETE /api/drawings/:id', () => {
|
|
||||||
it('should delete a drawing', async () => {
|
|
||||||
// Create a drawing to delete
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/projects/${projectId}/drawings`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'To Be Deleted' },
|
|
||||||
});
|
|
||||||
const drawingId = JSON.parse(createRes.body).id;
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/api/drawings/${drawingId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(204);
|
|
||||||
|
|
||||||
// Verify it's gone
|
|
||||||
const getRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${drawingId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(getRes.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent drawing delete', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/api/drawings/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
/**
|
|
||||||
* E2E Workflow Test: Login → Create Project → Create Drawing → Create Layer → Create Element → 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('E2E: Full CAD Workflow', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let authToken: string;
|
|
||||||
let projectId: string;
|
|
||||||
let drawingId: string;
|
|
||||||
let layerId: string;
|
|
||||||
let elementId: string;
|
|
||||||
|
|
||||||
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('Step 1: Register a new user', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: 'e2e@example.com', password: 'Password123!', name: 'E2E Tester' },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.session.token).toBeDefined();
|
|
||||||
authToken = body.session.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 2: Get user profile', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/auth/me',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.email).toBe('e2e@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 3: Create a project', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'E2E Test Project', description: 'Full workflow test' },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.name).toBe('E2E Test Project');
|
|
||||||
projectId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 4: Create a drawing in the project', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/projects/${projectId}/drawings`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Test Drawing' },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.name).toBe('Test Drawing');
|
|
||||||
drawingId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 5: Create a layer in the drawing', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/drawings/${drawingId}/layers`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Test Layer', color: '#ff0000' },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.name).toBe('Test Layer');
|
|
||||||
layerId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 6: Create an element on the layer', async () => {
|
|
||||||
const res = 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,
|
|
||||||
properties_json: JSON.stringify({ stroke: '#0000ff', fill: '#cccccc' }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.type).toBe('rect');
|
|
||||||
elementId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 7: List elements in the drawing', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${drawingId}/elements`,
|
|
||||||
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(elementId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 8: Update the element', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/elements/${elementId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { x: 50, y: 60, width: 200, height: 100 },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.x).toBe(50);
|
|
||||||
expect(body.width).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 9: Update the layer visibility', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/layers/${layerId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { visible: 0 },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.visible).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 10: Delete the element', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/api/elements/${elementId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 11: Verify element is deleted', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${drawingId}/elements`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 12: Delete the drawing', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/api/drawings/${drawingId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 13: Delete the project', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/api/projects/${projectId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 14: Logout', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/logout',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Step 15: Verify token is invalid after logout', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/auth/me',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
/**
|
|
||||||
* Elements API Tests – CRUD (List / Create / Update / Delete)
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { createServer } from '../src/server.js';
|
|
||||||
|
|
||||||
describe('Elements API', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let drawingId: string;
|
|
||||||
let layerId: string;
|
|
||||||
let createdElementId: string;
|
|
||||||
let authToken: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
app = await createServer({ db, port: 0 });
|
|
||||||
await app.ready();
|
|
||||||
|
|
||||||
const regRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: 'admin-test@example.com', password: 'Password123!', name: 'Admin Test', role: 'admin' },
|
|
||||||
});
|
|
||||||
authToken = JSON.parse(regRes.body).session.token;
|
|
||||||
|
|
||||||
// Create project → drawing → layer hierarchy
|
|
||||||
const projRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Elements Test Project' },
|
|
||||||
});
|
|
||||||
const projectId = JSON.parse(projRes.body).id;
|
|
||||||
|
|
||||||
const drawRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/projects/${projectId}/drawings`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Elements Test Drawing' },
|
|
||||||
});
|
|
||||||
drawingId = JSON.parse(drawRes.body).id;
|
|
||||||
|
|
||||||
const layerRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/drawings/${drawingId}/layers`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Elements Layer' },
|
|
||||||
});
|
|
||||||
layerId = JSON.parse(layerRes.body).id;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Auth Check ──────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Authentication', () => {
|
|
||||||
it('should return 401 without authorization header', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${drawingId}/elements`,
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Create ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/drawings/:drawingId/elements', () => {
|
|
||||||
it('should create an element with 201', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/drawings/${drawingId}/elements`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { layer_id: layerId, type: 'rect', x: 10, y: 20, width: 100, height: 50 },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.id).toBeDefined();
|
|
||||||
expect(body.type).toBe('rect');
|
|
||||||
expect(body.x).toBe(10);
|
|
||||||
expect(body.y).toBe(20);
|
|
||||||
expect(body.width).toBe(100);
|
|
||||||
expect(body.height).toBe(50);
|
|
||||||
expect(body.drawing_id).toBe(drawingId);
|
|
||||||
expect(body.layer_id).toBe(layerId);
|
|
||||||
createdElementId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create an element with default values', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/drawings/${drawingId}/elements`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { layer_id: layerId, type: 'circle' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.type).toBe('circle');
|
|
||||||
expect(body.x).toBe(0);
|
|
||||||
expect(body.y).toBe(0);
|
|
||||||
expect(body.width).toBe(0);
|
|
||||||
expect(body.height).toBe(0);
|
|
||||||
expect(body.properties_json).toBe('{}');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── List ───────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('GET /api/drawings/:drawingId/elements', () => {
|
|
||||||
it('should list elements for a drawing', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${drawingId}/elements`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array for drawing with no elements', async () => {
|
|
||||||
// Create a new drawing with no elements
|
|
||||||
const projRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Empty Elements Project' },
|
|
||||||
});
|
|
||||||
const projId = JSON.parse(projRes.body).id;
|
|
||||||
const drawRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/projects/${projId}/drawings`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Empty Drawing' },
|
|
||||||
});
|
|
||||||
const emptyDrawId = JSON.parse(drawRes.body).id;
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${emptyDrawId}/elements`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Update ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PATCH /api/elements/:id', () => {
|
|
||||||
it('should update element position', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/elements/${createdElementId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { x: 100, y: 200 },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.x).toBe(100);
|
|
||||||
expect(body.y).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update element dimensions', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/elements/${createdElementId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { width: 300, height: 150 },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.width).toBe(300);
|
|
||||||
expect(body.height).toBe(150);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update element properties_json', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/elements/${createdElementId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { properties_json: '{"color":"red"}' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.properties_json).toBe('{"color":"red"}');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent element update', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/api/elements/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { x: 0 },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Delete ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('DELETE /api/elements/:id', () => {
|
|
||||||
it('should delete an element', async () => {
|
|
||||||
// Create an element to delete
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/drawings/${drawingId}/elements`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { layer_id: layerId, type: 'line' },
|
|
||||||
});
|
|
||||||
const elemId = JSON.parse(createRes.body).id;
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/api/elements/${elemId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(204);
|
|
||||||
|
|
||||||
// Verify it's gone via list
|
|
||||||
const listRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${drawingId}/elements`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
const elements = JSON.parse(listRes.body);
|
|
||||||
expect(elements.find((e: any) => e.id === elemId)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent element delete', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/api/elements/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,614 +0,0 @@
|
|||||||
/**
|
|
||||||
* Global Blocks API Tests – CRUD for folders and blocks
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { createServer } from '../src/server.js';
|
|
||||||
|
|
||||||
describe('Global Blocks API', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let authToken: string;
|
|
||||||
let createdFolderId: string;
|
|
||||||
let subFolderId: string;
|
|
||||||
let createdBlockId: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
app = await createServer({ db, port: 0 });
|
|
||||||
await app.ready();
|
|
||||||
|
|
||||||
const regRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: 'global-test@example.com', password: 'Password123!', name: 'Global Test', role: 'admin' },
|
|
||||||
});
|
|
||||||
authToken = JSON.parse(regRes.body).session.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Auth Check ──────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Authentication', () => {
|
|
||||||
it('should return 401 without authorization header for folders', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/global-folders',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 401 without authorization header for blocks', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/global-blocks',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Folder CRUD ─────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/global-folders', () => {
|
|
||||||
it('should create a root folder with 201', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Möbel' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.id).toBeDefined();
|
|
||||||
expect(body.name).toBe('Möbel');
|
|
||||||
expect(body.parent_id).toBeNull();
|
|
||||||
createdFolderId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a sub-folder with parent_id', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Stühle', parent_id: createdFolderId },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.name).toBe('Stühle');
|
|
||||||
expect(body.parent_id).toBe(createdFolderId);
|
|
||||||
subFolderId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject folder without name with 400', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.error).toContain('Name is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject folder with empty name with 400', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: '' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /api/global-folders', () => {
|
|
||||||
it('should list all folders', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/global-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list root folders (parentId=null)', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/global-folders?parentId=null',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.every((f: { parent_id: string | null }) => f.parent_id === null)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list sub-folders for a parent', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/global-folders?parentId=${createdFolderId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(body[0].parent_id).toBe(createdFolderId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PATCH /api/global-folders/:id', () => {
|
|
||||||
it('should rename a folder', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/global-folders/${createdFolderId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Möbel & Ausstattung' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.name).toBe('Möbel & Ausstattung');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject setting parent to self with 400', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/global-folders/${createdFolderId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { parent_id: createdFolderId },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent folder', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/api/global-folders/non-existent',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'New Name' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Block CRUD ──────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/global-blocks', () => {
|
|
||||||
it('should create a global block in a folder with 201', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-blocks',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: {
|
|
||||||
name: 'Konferenzstuhl',
|
|
||||||
folder_id: subFolderId,
|
|
||||||
block_data: JSON.stringify({ type: 'chair', width: 50, height: 50 }),
|
|
||||||
svg_data: '<svg><rect width="50" height="50"/></svg>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.id).toBeDefined();
|
|
||||||
expect(body.name).toBe('Konferenzstuhl');
|
|
||||||
expect(body.folder_id).toBe(subFolderId);
|
|
||||||
expect(body.block_data).toContain('chair');
|
|
||||||
expect(body.svg_data).toContain('<svg>');
|
|
||||||
createdBlockId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a global block without folder (root level)', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-blocks',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Root Block' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.folder_id).toBeNull();
|
|
||||||
expect(body.block_data).toBe('{}');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject block without name with 400', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-blocks',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { folder_id: createdFolderId },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.error).toContain('Name is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject non-string block_data with 400', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-blocks',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Bad Block', block_data: { not: 'a string' } },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
expect(JSON.parse(response.body).error).toContain('block_data must be a JSON string');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /api/global-blocks', () => {
|
|
||||||
it('should list all global blocks', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/global-blocks',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list blocks for a specific folder', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/global-blocks?folderId=${subFolderId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(body.every((b: { folder_id: string | null }) => b.folder_id === subFolderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list root-level blocks (folderId=null)', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/global-blocks?folderId=null',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.every((b: { folder_id: string | null }) => b.folder_id === null)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /api/global-blocks/:id', () => {
|
|
||||||
it('should get a single global block', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/global-blocks/${createdBlockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.id).toBe(createdBlockId);
|
|
||||||
expect(body.name).toBe('Konferenzstuhl');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent block', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/global-blocks/non-existent',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PATCH /api/global-blocks/:id', () => {
|
|
||||||
it('should rename a block', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/global-blocks/${createdBlockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Updated Chair' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.name).toBe('Updated Chair');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should move a block to a different folder', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/global-blocks/${createdBlockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { folder_id: createdFolderId },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.folder_id).toBe(createdFolderId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update block_data', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/global-blocks/${createdBlockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { block_data: JSON.stringify({ type: 'updated', width: 60 }) },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.block_data).toContain('updated');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent block update', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/api/global-blocks/non-existent',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'New Name' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Delete ───────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('DELETE /api/global-blocks/:id', () => {
|
|
||||||
it('should delete a global block', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-blocks',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'To Delete' },
|
|
||||||
});
|
|
||||||
const blockId = JSON.parse(createRes.body).id;
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/api/global-blocks/${blockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent block delete', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/api/global-blocks/non-existent',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DELETE /api/global-folders/:id', () => {
|
|
||||||
it('should delete a folder and cascade to sub-folders', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Temp Folder' },
|
|
||||||
});
|
|
||||||
const folderId = JSON.parse(createRes.body).id;
|
|
||||||
|
|
||||||
const subRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Temp Sub', parent_id: folderId },
|
|
||||||
});
|
|
||||||
const subId = JSON.parse(subRes.body).id;
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/api/global-folders/${folderId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(204);
|
|
||||||
|
|
||||||
// Sub-folder should be gone (cascade)
|
|
||||||
const checkSub = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/global-folders/${subId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(checkSub.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent folder delete', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/api/global-folders/non-existent',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Task F1: Library-Paket Export/Import (.wcadlib) ──────
|
|
||||||
|
|
||||||
describe('Global Blocks API – F1 export/import', () => {
|
|
||||||
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: 'f1-export@example.com', password: 'Password123!', name: 'F1 Test', role: 'admin' },
|
|
||||||
});
|
|
||||||
authToken = JSON.parse(regRes.body).session.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/global-blocks/export liefert wcadlib-Paket mit folders+blocks', async () => {
|
|
||||||
// Setup: 1 Ordner + 1 Block
|
|
||||||
const folder = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'F1-Ordner' },
|
|
||||||
});
|
|
||||||
const folderId = JSON.parse(folder.body).id;
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-blocks',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'F1-Block', folder_id: folderId, block_data: '{"type":"rect"}' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/global-blocks/export',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const pkg = JSON.parse(res.body);
|
|
||||||
expect(pkg.format).toBe('wcadlib');
|
|
||||||
expect(Array.isArray(pkg.folders)).toBe(true);
|
|
||||||
expect(Array.isArray(pkg.blocks)).toBe(true);
|
|
||||||
expect(pkg.folders.length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(pkg.blocks.length).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('POST /api/global-blocks/import spielt Paket mit Ordner-Mapping ein', async () => {
|
|
||||||
const pkg = {
|
|
||||||
format: 'wcadlib',
|
|
||||||
version: 1,
|
|
||||||
folders: [
|
|
||||||
{ id: 'src-folder-1', name: 'Import-Ordner', parent_id: null },
|
|
||||||
],
|
|
||||||
blocks: [
|
|
||||||
{ name: 'Import-Block', folder_id: 'src-folder-1', block_data: '{"type":"circle"}', svg_data: null },
|
|
||||||
{ name: 'Import-Block-Root', folder_id: null, block_data: '{}', svg_data: null },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-blocks/import',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: pkg,
|
|
||||||
});
|
|
||||||
expect(res.statusCode, res.body).toBe(201);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.imported_blocks).toBe(2);
|
|
||||||
expect(body.imported_folders).toBe(1);
|
|
||||||
|
|
||||||
// Block im importierten Ordner auffindbar:
|
|
||||||
const blocks = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/global-blocks',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
const list = JSON.parse(blocks.body) as Array<{ name: string; folder_id: string | null }>;
|
|
||||||
const importedBlock = list.find((b) => b.name === 'Import-Block');
|
|
||||||
expect(importedBlock).toBeDefined();
|
|
||||||
expect(importedBlock!.folder_id).not.toBeNull(); // Mapping erhalten
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Import mit falschem format liefert 400', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-blocks/import',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { format: 'wrong', blocks: [] },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Task F2: Block-Favoriten ─────────────────────────────
|
|
||||||
describe('Global Blocks API – F2 favorite', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let authToken: string;
|
|
||||||
let blockId: 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: 'fav-test@example.com', password: 'Password123!', name: 'Fav Test', role: 'admin' },
|
|
||||||
});
|
|
||||||
authToken = JSON.parse(regRes.body).session.token;
|
|
||||||
|
|
||||||
const blockRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-blocks',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Fav-Block', block_data: '{"type":"block"}' },
|
|
||||||
});
|
|
||||||
blockId = JSON.parse(blockRes.body).id;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('neuer Block startet mit is_favorite=false', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/global-blocks/${blockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(JSON.parse(res.body).is_favorite).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PATCH is_favorite:true setzt Favorit', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/global-blocks/${blockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { is_favorite: true },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(JSON.parse(res.body).is_favorite).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is_favorite ist persistent nach GET', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/global-blocks/${blockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(JSON.parse(res.body).is_favorite).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PATCH is_favorite:false hebt Favorit auf', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/global-blocks/${blockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { is_favorite: false },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(JSON.parse(res.body).is_favorite).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PATCH mit nicht-boolean is_favorite liefert 400', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/global-blocks/${blockId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { is_favorite: 'yes' },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
/**
|
|
||||||
* Task F7 – .wcadlib ZIP-Paket Export/Import (Backend).
|
|
||||||
*
|
|
||||||
* ZIP-Struktur: manifest.json (format/version/exported_at), blocks.json
|
|
||||||
* (Ordner + Blöcke), thumbs/ (SVG-Thumbnails je Block-ID). Export liefert
|
|
||||||
* application/zip; Import parst das ZIP und spielt Ordner-Hierarchie +
|
|
||||||
* Blöcke mit ID-Mapping ein. Invalides ZIP → 400.
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import JSZip from 'jszip';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { createServer } from '../src/server.js';
|
|
||||||
|
|
||||||
describe('Task F7: .wcadlib ZIP Export/Import', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let token: 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: 'f7-zip@example.com', password: 'Password123!', name: 'F7 Zip', role: 'admin' },
|
|
||||||
});
|
|
||||||
token = JSON.parse(regRes.body).session.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createFixture(): Promise<{ folderId: string; blockId: string }> {
|
|
||||||
const folderRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-folders',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Event Set', parent_id: null },
|
|
||||||
});
|
|
||||||
const folderId = JSON.parse(folderRes.body).id;
|
|
||||||
const blockRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-blocks',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: {
|
|
||||||
name: 'Stuhl-Reihe',
|
|
||||||
folder_id: folderId,
|
|
||||||
block_data: JSON.stringify([{ id: 'e1', type: 'chair', x: 0, y: 0, width: 45, height: 45, properties: {} }]),
|
|
||||||
svg_data: '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const blockId = JSON.parse(blockRes.body).id;
|
|
||||||
return { folderId, blockId };
|
|
||||||
}
|
|
||||||
|
|
||||||
it('GET /api/global-blocks/export-zip liefert ZIP mit manifest.json, blocks.json und thumbs/', async () => {
|
|
||||||
const { blockId } = await createFixture();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/global-blocks/export-zip',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.headers['content-type']).toContain('application/zip');
|
|
||||||
|
|
||||||
const zip = await JSZip.loadAsync(res.rawPayload ?? res.body);
|
|
||||||
expect(zip.file('manifest.json')).toBeDefined();
|
|
||||||
expect(zip.file('blocks.json')).toBeDefined();
|
|
||||||
|
|
||||||
const manifest = JSON.parse(await zip.file('manifest.json')!.async('string'));
|
|
||||||
expect(manifest.format).toBe('wcadlib-zip');
|
|
||||||
expect(manifest.version).toBe(1);
|
|
||||||
expect(typeof manifest.exported_at).toBe('string');
|
|
||||||
|
|
||||||
const blocks = JSON.parse(await zip.file('blocks.json')!.async('string'));
|
|
||||||
expect(blocks.folders.length).toBe(1);
|
|
||||||
expect(blocks.folders[0].name).toBe('Event Set');
|
|
||||||
expect(blocks.blocks.length).toBeGreaterThanOrEqual(1);
|
|
||||||
const chairBlock = blocks.blocks.find((b: { name: string }) => b.name === 'Stuhl-Reihe');
|
|
||||||
expect(chairBlock).toBeDefined();
|
|
||||||
expect(chairBlock.thumbnail).toBe(`thumbs/${blockId}.svg`);
|
|
||||||
|
|
||||||
const thumb = zip.file(`thumbs/${blockId}.svg`);
|
|
||||||
expect(thumb).toBeDefined();
|
|
||||||
expect(await thumb!.async('string')).toContain('<svg');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Export ohne Auth → 401', async () => {
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/global-blocks/export-zip' });
|
|
||||||
expect(res.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('POST /api/global-blocks/import-zip spielt ZIP ein (Ordner-Mapping + Blöcke + Thumb-Referenz erhalten)', async () => {
|
|
||||||
const { blockId } = await createFixture();
|
|
||||||
const exportRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/global-blocks/export-zip',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
expect(exportRes.statusCode).toBe(200);
|
|
||||||
const zipBuffer = exportRes.rawPayload ?? exportRes.body;
|
|
||||||
|
|
||||||
const importRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-blocks/import-zip',
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${token}`,
|
|
||||||
'content-type': 'application/zip',
|
|
||||||
},
|
|
||||||
payload: zipBuffer,
|
|
||||||
});
|
|
||||||
expect(importRes.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(importRes.body);
|
|
||||||
expect(body.imported_blocks).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(body.imported_folders).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
// Verifizieren: importierter Block existiert mit Thumb-Daten
|
|
||||||
const blocksRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/global-blocks',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
const allBlocks = JSON.parse(blocksRes.body);
|
|
||||||
const imported = allBlocks.filter((b: { name: string }) => b.name === 'Stuhl-Reihe');
|
|
||||||
expect(imported.length).toBeGreaterThanOrEqual(2); // Original + Import
|
|
||||||
expect(imported.every((b: { svg_data: string | null }) => b.svg_data !== null)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Import mit Müll-Payload → 400 (kein valides ZIP)', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-blocks/import-zip',
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${token}`,
|
|
||||||
'content-type': 'application/zip',
|
|
||||||
},
|
|
||||||
payload: Buffer.from('dies ist kein zip'),
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
expect(JSON.parse(res.body).error).toContain('ZIP');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Import-ZIP ohne blocks.json → 400', async () => {
|
|
||||||
const zip = new JSZip();
|
|
||||||
zip.file('manifest.json', JSON.stringify({ format: 'wcadlib-zip', version: 1 }));
|
|
||||||
const buf = await zip.generateAsync({ type: 'nodebuffer' });
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/global-blocks/import-zip',
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${token}`,
|
|
||||||
'content-type': 'application/zip',
|
|
||||||
},
|
|
||||||
payload: buf,
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* Health Endpoint Tests
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { createServer } from '../src/server.js';
|
|
||||||
|
|
||||||
describe('Health Endpoint', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
app = await createServer({ db, port: 0 });
|
|
||||||
await app.ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/health should return ok status', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/health',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.status).toBe('ok');
|
|
||||||
expect(body.timestamp).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/health should return valid ISO timestamp', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/health',
|
|
||||||
});
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
const date = new Date(body.timestamp);
|
|
||||||
expect(date.toString()).not.toBe('Invalid Date');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
/**
|
|
||||||
* Layers API Tests – CRUD (List / Create / Update / Delete)
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { createServer } from '../src/server.js';
|
|
||||||
|
|
||||||
describe('Layers API', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let drawingId: string;
|
|
||||||
let createdLayerId: string;
|
|
||||||
let authToken: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
app = await createServer({ db, port: 0 });
|
|
||||||
await app.ready();
|
|
||||||
|
|
||||||
const regRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: 'admin-test@example.com', password: 'Password123!', name: 'Admin Test', role: 'admin' },
|
|
||||||
});
|
|
||||||
authToken = JSON.parse(regRes.body).session.token;
|
|
||||||
|
|
||||||
// Create project → drawing hierarchy
|
|
||||||
const projRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Layers Test Project' },
|
|
||||||
});
|
|
||||||
const projectId = JSON.parse(projRes.body).id;
|
|
||||||
|
|
||||||
const drawRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/projects/${projectId}/drawings`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Layers Test Drawing' },
|
|
||||||
});
|
|
||||||
drawingId = JSON.parse(drawRes.body).id;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Auth Check ──────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Authentication', () => {
|
|
||||||
it('should return 401 without authorization header', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${drawingId}/layers`,
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Create ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/drawings/:drawingId/layers', () => {
|
|
||||||
it('should create a layer with 201', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/drawings/${drawingId}/layers`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Background', color: '#ff0000' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.id).toBeDefined();
|
|
||||||
expect(body.name).toBe('Background');
|
|
||||||
expect(body.color).toBe('#ff0000');
|
|
||||||
expect(body.drawing_id).toBe(drawingId);
|
|
||||||
createdLayerId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a layer with default values', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/drawings/${drawingId}/layers`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.name).toBe('Layer');
|
|
||||||
expect(body.visible).toBe(1);
|
|
||||||
expect(body.locked).toBe(0);
|
|
||||||
expect(body.color).toBe('#ffffff');
|
|
||||||
expect(body.line_type).toBe('solid');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── List ───────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('GET /api/drawings/:drawingId/layers', () => {
|
|
||||||
it('should list layers for a drawing', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${drawingId}/layers`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array for drawing with no layers', async () => {
|
|
||||||
// Create a new drawing with no layers
|
|
||||||
const projRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Empty Layers Project' },
|
|
||||||
});
|
|
||||||
const projId = JSON.parse(projRes.body).id;
|
|
||||||
const drawRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/projects/${projId}/drawings`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Empty Drawing' },
|
|
||||||
});
|
|
||||||
const emptyDrawId = JSON.parse(drawRes.body).id;
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${emptyDrawId}/layers`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Update ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PATCH /api/layers/:id', () => {
|
|
||||||
it('should update layer name', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/layers/${createdLayerId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Updated Layer Name' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.name).toBe('Updated Layer Name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update layer visibility', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/layers/${createdLayerId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { visible: 0, locked: 1 },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.visible).toBe(0);
|
|
||||||
expect(body.locked).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update layer color and line_type', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/layers/${createdLayerId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { color: '#00ff00', line_type: 'dashed' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.color).toBe('#00ff00');
|
|
||||||
expect(body.line_type).toBe('dashed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent layer update', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/api/layers/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'New Name' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Delete ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('DELETE /api/layers/:id', () => {
|
|
||||||
it('should delete a layer', async () => {
|
|
||||||
// Create a layer to delete
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/api/drawings/${drawingId}/layers`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'To Be Deleted' },
|
|
||||||
});
|
|
||||||
const layerId = JSON.parse(createRes.body).id;
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/api/layers/${layerId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(204);
|
|
||||||
|
|
||||||
// Verify it's gone via list
|
|
||||||
const listRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/drawings/${drawingId}/layers`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
const layers = JSON.parse(listRes.body);
|
|
||||||
expect(layers.find((l: any) => l.id === layerId)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent layer delete', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/api/layers/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
/**
|
|
||||||
* Task H3 – Rollen: library-admin (global write) vs. user (read-only).
|
|
||||||
*
|
|
||||||
* requireLibraryAdmin schützt alle schreibenden Global-Library-Routen:
|
|
||||||
* POST/PATCH/DELETE auf folders+blocks, import, import-zip. Lesen
|
|
||||||
* (GET, export) bleibt für alle authentifizierten Nutzer offen.
|
|
||||||
* library-admin ist an die bestehende admin-Rolle angebunden.
|
|
||||||
*/
|
|
||||||
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('Task H3: library-admin Guard', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let adminToken: string;
|
|
||||||
let planerToken: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
app = await createServer({ db, port: 0 });
|
|
||||||
await app.ready();
|
|
||||||
|
|
||||||
const adminReg = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: 'h3-admin@example.com', password: 'Password123!', name: 'H3 Admin', role: 'admin' },
|
|
||||||
});
|
|
||||||
if (adminReg.statusCode !== 201) console.error('ADMIN REG FAILED:', adminReg.statusCode, adminReg.body.slice(0, 200));
|
|
||||||
adminToken = JSON.parse(adminReg.body).session?.token ?? '';
|
|
||||||
|
|
||||||
const planerReg = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: 'h3-planer@example.com', password: 'Password123!', name: 'H3 Planer', role: 'planer' },
|
|
||||||
});
|
|
||||||
if (planerReg.statusCode !== 201) console.error('PLANER REG FAILED:', planerReg.statusCode, planerReg.body.slice(0, 200));
|
|
||||||
planerToken = JSON.parse(planerReg.body).session?.token ?? '';
|
|
||||||
admin = { authorization: `Bearer ${adminToken}` };
|
|
||||||
planer = { authorization: `Bearer ${planerToken}` };
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// WICHTIG: Header-Objekte werden in beforeAll gebaut — der describe-Body
|
|
||||||
// läuft VOR den Hooks, Template-Literale würden undefined capturen.
|
|
||||||
let planer: Record<string, string>;
|
|
||||||
let admin: Record<string, string>;
|
|
||||||
|
|
||||||
// ── Lesen bleibt offen (beide Rollen) ──
|
|
||||||
|
|
||||||
it('planer kann globale Ordner LESEN (200)', async () => {
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/global-folders', headers: planer });
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('planer kann globale Blöcke LESEN (200)', async () => {
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/global-blocks', headers: planer });
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('planer kann Library EXPORTIEREN (GET export, 200)', async () => {
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/global-blocks/export', headers: planer });
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Schreiben nur für library-admin (admin-Rolle) ──
|
|
||||||
|
|
||||||
it('planer POST /api/global-folders → 403', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST', url: '/api/global-folders', headers: planer,
|
|
||||||
payload: { name: 'Verboten', parent_id: null },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(403);
|
|
||||||
expect(JSON.parse(res.body).error).toContain('Library');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('planer POST /api/global-blocks → 403', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST', url: '/api/global-blocks', headers: planer,
|
|
||||||
payload: { name: 'Verboten', block_data: '[]' },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('planer PATCH /api/global-folders/:id → 403', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PATCH', url: '/api/global-folders/irgendeine-id', headers: planer,
|
|
||||||
payload: { name: 'Hack' },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('planer PATCH /api/global-blocks/:id → 403', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PATCH', url: '/api/global-blocks/irgendeine-id', headers: planer,
|
|
||||||
payload: { name: 'Hack' },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('planer DELETE /api/global-folders/:id → 403', async () => {
|
|
||||||
const res = await app.inject({ method: 'DELETE', url: '/api/global-folders/irgendeine-id', headers: planer });
|
|
||||||
expect(res.statusCode).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('planer DELETE /api/global-blocks/:id → 403', async () => {
|
|
||||||
const res = await app.inject({ method: 'DELETE', url: '/api/global-blocks/irgendeine-id', headers: planer });
|
|
||||||
expect(res.statusCode).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('planer POST /api/global-blocks/import → 403', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST', url: '/api/global-blocks/import', headers: planer,
|
|
||||||
payload: { format: 'wcadlib', blocks: [] },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('planer POST /api/global-blocks/import-zip → 403', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST', url: '/api/global-blocks/import-zip', headers: { ...planer, 'content-type': 'application/zip' },
|
|
||||||
payload: Buffer.from('PK'),
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Admin (library-admin) darf schreiben ──
|
|
||||||
|
|
||||||
it('admin POST /api/global-folders → 201 (write erlaubt)', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST', url: '/api/global-folders', headers: admin,
|
|
||||||
payload: { name: 'Admin Set', parent_id: null },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(201);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('admin POST /api/global-blocks → 201 (write erlaubt)', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST', url: '/api/global-blocks', headers: admin,
|
|
||||||
payload: { name: 'Admin Block', block_data: '[]', folder_id: null },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(201);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Unauthentifiziert bleibt 401 ──
|
|
||||||
|
|
||||||
it('ohne Token POST /api/global-folders → 401 (nicht 403)', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST', url: '/api/global-folders',
|
|
||||||
payload: { name: 'Anon', parent_id: null },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
/**
|
|
||||||
* Project Folders API Tests - CRUD for project folders + move project to folder
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { createServer } from '../src/server.js';
|
|
||||||
|
|
||||||
describe('Project Folders API', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let authToken: string;
|
|
||||||
let createdFolderId: string;
|
|
||||||
let childFolderId: string;
|
|
||||||
let createdProjectId: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
app = await createServer({ db, port: 0 });
|
|
||||||
await app.ready();
|
|
||||||
|
|
||||||
const regRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: 'folder-test@example.com', password: 'Password123!', name: 'Folder Test' },
|
|
||||||
});
|
|
||||||
const regBody = JSON.parse(regRes.body);
|
|
||||||
authToken = regBody.session.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list empty folders initially', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/project-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a folder', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/project-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Test Folder' },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.name).toBe('Test Folder');
|
|
||||||
expect(body.parent_id).toBeNull();
|
|
||||||
expect(body.id).toBeDefined();
|
|
||||||
createdFolderId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a child folder', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/project-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Child Folder', parent_id: createdFolderId },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.name).toBe('Child Folder');
|
|
||||||
expect(body.parent_id).toBe(createdFolderId);
|
|
||||||
childFolderId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list folders including the created ones', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/project-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.length).toBe(2);
|
|
||||||
expect(body.some((f: { id: string }) => f.id === createdFolderId)).toBe(true);
|
|
||||||
expect(body.some((f: { id: string }) => f.id === childFolderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get a single folder', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/project-folders/${createdFolderId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.id).toBe(createdFolderId);
|
|
||||||
expect(body.name).toBe('Test Folder');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should rename a folder', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `/api/project-folders/${createdFolderId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Renamed Folder' },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.name).toBe('Renamed Folder');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject folder creation without name', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/project-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject rename without name', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `/api/project-folders/${createdFolderId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject operations on non-existent folder', async () => {
|
|
||||||
const getRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/project-folders/nonexistent-folder',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(getRes.statusCode).toBe(404);
|
|
||||||
|
|
||||||
const delRes = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/api/project-folders/nonexistent-folder',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(delRes.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject unauthenticated requests', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/project-folders',
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Project + Folder integration ────────────────────
|
|
||||||
|
|
||||||
it('should create a project with folder_id', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Folder Project', folder_id: createdFolderId },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.name).toBe('Folder Project');
|
|
||||||
expect(body.folder_id).toBe(createdFolderId);
|
|
||||||
createdProjectId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list projects filtered by folder', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/projects?folderId=${createdFolderId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.length).toBe(1);
|
|
||||||
expect(body[0].id).toBe(createdProjectId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list unfoldered projects with folderId=null', async () => {
|
|
||||||
// First create a project without folder
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'No Folder Project' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/projects?folderId=null',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.every((p: { folder_id: string | null }) => p.folder_id === null)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should move project to a different folder', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `/api/projects/${createdProjectId}/folder`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { folder_id: childFolderId },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.folder_id).toBe(childFolderId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should move project to root (folder_id = null)', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `/api/projects/${createdProjectId}/folder`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { folder_id: null },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.folder_id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject moving project to non-existent folder', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `/api/projects/${createdProjectId}/folder`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { folder_id: 'nonexistent-folder' },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should delete folder and move projects to root', async () => {
|
|
||||||
// Move project back to folder first
|
|
||||||
await app.inject({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `/api/projects/${createdProjectId}/folder`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { folder_id: childFolderId },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete the child folder
|
|
||||||
const delRes = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/api/project-folders/${childFolderId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(delRes.statusCode).toBe(204);
|
|
||||||
|
|
||||||
// Verify project is now in root
|
|
||||||
const projRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/projects/${createdProjectId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(projRes.statusCode).toBe(200);
|
|
||||||
const projBody = JSON.parse(projRes.body);
|
|
||||||
expect(projBody.folder_id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should delete remaining folders', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/api/project-folders/${createdFolderId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(204);
|
|
||||||
|
|
||||||
// Verify no folders left
|
|
||||||
const listRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/project-folders',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
const listBody = JSON.parse(listRes.body);
|
|
||||||
expect(listBody.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
/**
|
|
||||||
* Projects API Tests – CRUD (List / Get / Create / Update / Delete)
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { createServer } from '../src/server.js';
|
|
||||||
|
|
||||||
describe('Projects API', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let createdProjectId: string | null = null;
|
|
||||||
let authToken: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
app = await createServer({ db, port: 0 });
|
|
||||||
await app.ready();
|
|
||||||
|
|
||||||
const regRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: 'admin-test@example.com', password: 'Password123!', name: 'Admin Test', role: 'admin' },
|
|
||||||
});
|
|
||||||
authToken = JSON.parse(regRes.body).session.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Auth Check ──────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Authentication', () => {
|
|
||||||
it('should return 401 without authorization header', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/projects',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Create ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('POST /api/projects', () => {
|
|
||||||
it('should create a project with 201', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: {
|
|
||||||
name: 'Test Project',
|
|
||||||
description: 'A test project',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.id).toBeDefined();
|
|
||||||
expect(body.name).toBe('Test Project');
|
|
||||||
expect(body.description).toBe('A test project');
|
|
||||||
createdProjectId = body.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject project without name with 400', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { description: 'No name' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create project with default values', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Minimal Project' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(201);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.name).toBe('Minimal Project');
|
|
||||||
expect(body.description).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── List ───────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('GET /api/projects', () => {
|
|
||||||
it('should list all projects', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Get ────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('GET /api/projects/:id', () => {
|
|
||||||
it('should get a single project', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/projects/${createdProjectId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.id).toBe(createdProjectId);
|
|
||||||
expect(body.name).toBe('Test Project');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent project', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/projects/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Update ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PATCH /api/projects/:id', () => {
|
|
||||||
it('should update project name', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/projects/${createdProjectId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Updated Project Name' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.name).toBe('Updated Project Name');
|
|
||||||
expect(body.id).toBe(createdProjectId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update project description', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/projects/${createdProjectId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { description: 'Updated description' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.description).toBe('Updated description');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent project update', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/api/projects/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'New Name' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Delete ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('DELETE /api/projects/:id', () => {
|
|
||||||
it('should delete a project', async () => {
|
|
||||||
// First create a project to delete
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/projects',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'To Be Deleted' },
|
|
||||||
});
|
|
||||||
const projectId = JSON.parse(createRes.body).id;
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/api/projects/${projectId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(204);
|
|
||||||
|
|
||||||
// Verify it's gone
|
|
||||||
const getRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/projects/${projectId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(getRes.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent project delete', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/api/projects/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
/**
|
|
||||||
* Settings API Tests – Key/Value settings (Get / Put / Upsert)
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { createServer } from '../src/server.js';
|
|
||||||
|
|
||||||
describe('Settings API', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let authToken: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
app = await createServer({ db, port: 0 });
|
|
||||||
await app.ready();
|
|
||||||
|
|
||||||
const regRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: 'admin-test@example.com', password: 'Password123!', name: 'Admin Test', role: 'admin' },
|
|
||||||
});
|
|
||||||
authToken = JSON.parse(regRes.body).session.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Auth Check ──────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Authentication', () => {
|
|
||||||
it('should return 401 without authorization header', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/settings/non-existent-key',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Get (missing key → 404) ─────────────────────────
|
|
||||||
|
|
||||||
describe('GET /api/settings/:key', () => {
|
|
||||||
it('should return 404 for non-existent setting', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/settings/non-existent-key',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.error).toContain('Setting not found');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return a setting that was previously set', async () => {
|
|
||||||
// First set a value
|
|
||||||
await app.inject({
|
|
||||||
method: 'PUT',
|
|
||||||
url: '/api/settings/test-key',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { value: 'test-value' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/settings/test-key',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.key).toBe('test-key');
|
|
||||||
expect(body.value).toBe('test-value');
|
|
||||||
expect(body.updated_at).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default CAD settings instead of 404', async () => {
|
|
||||||
const defaults = [
|
|
||||||
{ key: 'cad.unit', expected: 'mm' },
|
|
||||||
{ key: 'cad.gridSize', expected: '20' },
|
|
||||||
{ key: 'cad.scaleFactor', expected: '1' },
|
|
||||||
];
|
|
||||||
for (const { key, expected } of defaults) {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/settings/${key}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.key).toBe(key);
|
|
||||||
expect(body.value).toBe(expected);
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
/**
|
|
||||||
* Users API Tests – List / Get / Update / Delete (password_hash stripping)
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
||||||
import { createServer } from '../src/server.js';
|
|
||||||
|
|
||||||
describe('Users API', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
let db: SqliteAdapter;
|
|
||||||
let testUserId: string;
|
|
||||||
let authToken: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = new SqliteAdapter(':memory:');
|
|
||||||
await db.init();
|
|
||||||
app = await createServer({ db, port: 0 });
|
|
||||||
await app.ready();
|
|
||||||
|
|
||||||
// Create a test user directly via DB (bypassing AuthService)
|
|
||||||
const user = db.createUser({
|
|
||||||
email: 'test-admin@example.com',
|
|
||||||
password_hash: 'fake-hash-for-testing',
|
|
||||||
name: 'Test Admin',
|
|
||||||
role: 'admin',
|
|
||||||
});
|
|
||||||
testUserId = user.id;
|
|
||||||
|
|
||||||
// Register an admin user via API to get a valid auth token
|
|
||||||
const regRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: 'admin-auth@example.com', password: 'Password123!', name: 'Admin Auth', role: 'admin' },
|
|
||||||
});
|
|
||||||
const regBody = JSON.parse(regRes.body);
|
|
||||||
authToken = regBody.session.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── List ───────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('GET /api/users', () => {
|
|
||||||
it('should return 401 without authorization header', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/users',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list all users', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/users',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(Array.isArray(body)).toBe(true);
|
|
||||||
expect(body.length).toBeGreaterThanOrEqual(2); // default user + test user
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should strip password_hash from list responses', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/users',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
for (const user of body) {
|
|
||||||
expect(user.password_hash).toBeUndefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Get ────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('GET /api/users/:id', () => {
|
|
||||||
it('should get a single user', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/users/${testUserId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.id).toBe(testUserId);
|
|
||||||
expect(body.email).toBe('test-admin@example.com');
|
|
||||||
expect(body.name).toBe('Test Admin');
|
|
||||||
expect(body.role).toBe('admin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should strip password_hash from single user response', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/users/${testUserId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.password_hash).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent user', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/users/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Update ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PATCH /api/users/:id', () => {
|
|
||||||
it('should update user name', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/users/${testUserId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Updated Name' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.name).toBe('Updated Name');
|
|
||||||
expect(body.id).toBe(testUserId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update user role', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/users/${testUserId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { role: 'planer' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.role).toBe('planer');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should strip password_hash from update response', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/users/${testUserId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'Another Name' },
|
|
||||||
});
|
|
||||||
const body = JSON.parse(response.body);
|
|
||||||
expect(body.password_hash).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not update password_hash via PATCH endpoint', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/api/users/${testUserId}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { password_hash: 'hacked-hash' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
// Verify the password_hash was NOT changed in DB
|
|
||||||
const dbUser = db.getUser(testUserId);
|
|
||||||
expect(dbUser!.password_hash).toBe('fake-hash-for-testing');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent user update', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/api/users/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
payload: { name: 'New Name' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Delete ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('DELETE /api/users/:id', () => {
|
|
||||||
it('should delete a user', async () => {
|
|
||||||
// Create a user to delete
|
|
||||||
const user = db.createUser({
|
|
||||||
email: 'delete-me@example.com',
|
|
||||||
password_hash: 'temp-hash',
|
|
||||||
name: 'Delete Me',
|
|
||||||
role: 'planer',
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/api/users/${user.id}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(204);
|
|
||||||
|
|
||||||
// Verify it's gone
|
|
||||||
const getRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/users/${user.id}`,
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(getRes.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent user delete', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/api/users/non-existent-id',
|
|
||||||
headers: { authorization: `Bearer ${authToken}` },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
/**
|
|
||||||
* Validation Utils Tests
|
|
||||||
*/
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { validateName, validateIdParam, validateSettingsKey, validateSettingsValue, MAX_NAME_LENGTH, MAX_ID_LENGTH } from '../src/utils/validation.js';
|
|
||||||
|
|
||||||
describe('Validation Utils', () => {
|
|
||||||
describe('validateName', () => {
|
|
||||||
it('should accept a valid name', () => {
|
|
||||||
expect(validateName('Test Project')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject undefined', () => {
|
|
||||||
expect(validateName(undefined)).toContain('required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject null', () => {
|
|
||||||
expect(validateName(null)).toContain('required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject non-string types', () => {
|
|
||||||
expect(validateName(123)).toContain('required');
|
|
||||||
expect(validateName(true)).toContain('required');
|
|
||||||
expect(validateName({})).toContain('required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject empty string', () => {
|
|
||||||
expect(validateName('')).toContain('empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject whitespace-only string', () => {
|
|
||||||
expect(validateName(' ')).toContain('empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject string exceeding max length', () => {
|
|
||||||
const longName = 'a'.repeat(MAX_NAME_LENGTH + 1);
|
|
||||||
expect(validateName(longName)).toContain('at most');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept string at exactly max length', () => {
|
|
||||||
const maxName = 'a'.repeat(MAX_NAME_LENGTH);
|
|
||||||
expect(validateName(maxName)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use custom field name in error', () => {
|
|
||||||
const err = validateName(undefined, 'project');
|
|
||||||
expect(err).toContain('Project');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateIdParam', () => {
|
|
||||||
it('should accept a valid alphanumeric ID', () => {
|
|
||||||
expect(validateIdParam('abc123')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept ID with hyphens and underscores', () => {
|
|
||||||
expect(validateIdParam('my-id_123')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject undefined', () => {
|
|
||||||
expect(validateIdParam(undefined)).toContain('required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject null', () => {
|
|
||||||
expect(validateIdParam(null)).toContain('required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject empty string', () => {
|
|
||||||
expect(validateIdParam('')).toContain('empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject ID with special characters', () => {
|
|
||||||
expect(validateIdParam('abc!def')).toContain('invalid');
|
|
||||||
expect(validateIdParam('abc def')).toContain('invalid');
|
|
||||||
expect(validateIdParam('abc/def')).toContain('invalid');
|
|
||||||
expect(validateIdParam('abc.def')).toContain('invalid');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject ID exceeding max length', () => {
|
|
||||||
const longId = 'a'.repeat(MAX_ID_LENGTH + 1);
|
|
||||||
expect(validateIdParam(longId)).toContain('too long');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept ID at exactly max length', () => {
|
|
||||||
const maxId = 'a'.repeat(MAX_ID_LENGTH);
|
|
||||||
expect(validateIdParam(maxId)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateSettingsKey', () => {
|
|
||||||
it('should accept a valid key', () => {
|
|
||||||
expect(validateSettingsKey('theme.color')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept key with dots, hyphens, underscores', () => {
|
|
||||||
expect(validateSettingsKey('my-setting_key.value')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject undefined', () => {
|
|
||||||
expect(validateSettingsKey(undefined)).toContain('required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject empty string', () => {
|
|
||||||
expect(validateSettingsKey('')).toContain('required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject key with special characters', () => {
|
|
||||||
expect(validateSettingsKey('abc!def')).toContain('invalid');
|
|
||||||
expect(validateSettingsKey('abc def')).toContain('invalid');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateSettingsValue', () => {
|
|
||||||
it('should accept a valid string value', () => {
|
|
||||||
expect(validateSettingsValue('some value')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept empty string value', () => {
|
|
||||||
expect(validateSettingsValue('')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject undefined', () => {
|
|
||||||
expect(validateSettingsValue(undefined)).toContain('required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject null', () => {
|
|
||||||
expect(validateSettingsValue(null)).toContain('required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject non-string types', () => {
|
|
||||||
expect(validateSettingsValue(123)).toContain('string');
|
|
||||||
expect(validateSettingsValue(true)).toContain('string');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
environment: 'node',
|
|
||||||
include: ['tests/**/*.test.ts'],
|
|
||||||
coverage: {
|
|
||||||
provider: 'v8',
|
|
||||||
include: ['src/**/*.ts'],
|
|
||||||
exclude: ['src/types/**', 'src/websocket/**'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
cd /data/web-cad-neu
|
|
||||||
echo "[$(date)] Pulling latest code..."
|
|
||||||
git pull origin main
|
|
||||||
echo "[$(date)] Building backend image..."
|
|
||||||
docker build -t web-cad-neu-backend:latest ./backend
|
|
||||||
echo "[$(date)] Building frontend image..."
|
|
||||||
docker build -t web-cad-neu-frontend:latest ./frontend
|
|
||||||
echo "[$(date)] Redeploying containers..."
|
|
||||||
cd /data/coolify/services/cnuavrh33goa3zs0upi6hgif
|
|
||||||
docker compose up -d --force-recreate
|
|
||||||
echo "[$(date)] Health check..."
|
|
||||||
sleep 5
|
|
||||||
docker exec backend-cnuavrh33goa3zs0upi6hgif wget -qO- http://localhost:3001/api/health
|
|
||||||
echo
|
|
||||||
echo "[$(date)] Frontend check..."
|
|
||||||
curl -sk -o /dev/null -w "%{http_code}" https://web-cad-neu.server.media-on.de
|
|
||||||
echo
|
|
||||||
echo "[$(date)] Deploy complete!"
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- JWT_SECRET=${JWT_SECRET:?set JWT_SECRET}
|
||||||
|
volumes:
|
||||||
|
- sqlite_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:5000/api/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:80 || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sqlite_data:
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- JWT_SECRET=6151504445a6e5defbe6888f91d002bbd5368f0af5dc38ce6bf25606818f0b3b
|
||||||
|
volumes:
|
||||||
|
- sqlite_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:5000/api/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:80 || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sqlite_data:
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: https://forgejo.media-on.de/Leopoldadmin/web-cad.git#master
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- JWT_SECRET=cad-jwt-secret-prod-2026
|
||||||
|
- PORT=5000
|
||||||
|
expose:
|
||||||
|
- "5000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
volumes:
|
||||||
|
- sqlite_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: https://forgejo.media-on.de/Leopoldadmin/web-cad.git#master
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:80/"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sqlite_data:
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=webcad
|
||||||
|
- POSTGRES_PASSWORD=webcad
|
||||||
|
- POSTGRES_DB=webcad
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U webcad"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- JWT_SECRET=cad-jwt-secret-prod-2026
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_USER=webcad
|
||||||
|
- DB_PASSWORD=webcad
|
||||||
|
- DB_NAME=webcad
|
||||||
|
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 seed.js && node server.js
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
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 curl
|
||||||
|
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 vite preview --host 0.0.0.0 --port 80
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:80/"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 15
|
||||||
|
start_period: 120s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
+14
-24
@@ -1,34 +1,24 @@
|
|||||||
version: "3.8"
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
frontend:
|
|
||||||
build: ./frontend
|
|
||||||
ports:
|
|
||||||
- "8082:80"
|
|
||||||
depends_on:
|
|
||||||
backend:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "5000:5000"
|
||||||
volumes:
|
|
||||||
- backend-data:/app/data
|
|
||||||
environment:
|
environment:
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
|
||||||
- API_KEY_OPENROUTER=${API_KEY_OPENROUTER:-}
|
|
||||||
- DB_PATH=/app/data/cad.db
|
|
||||||
- PORT=3001
|
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
healthcheck:
|
- JWT_SECRET=your-secret-key-change-me
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/api/health"]
|
volumes:
|
||||||
interval: 30s
|
- sqlite_data:/app/data
|
||||||
timeout: 5s
|
restart: unless-stopped
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
backend-data:
|
sqlite_data:
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
# .wcadlib – Block-Bibliotheks-Paket-Spezifikation
|
|
||||||
|
|
||||||
WebCAD-Block-Bibliotheken können als `.wcadlib`-Paket geteilt werden.
|
|
||||||
Es gibt zwei Formate:
|
|
||||||
|
|
||||||
## Format 1: JSON-Paket (F1)
|
|
||||||
|
|
||||||
Ein einzelnes JSON-File mit folgender Struktur:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"format": "wcadlib",
|
|
||||||
"version": 1,
|
|
||||||
"exported_at": "2026-08-29T00:00:00.000Z",
|
|
||||||
"folders": [
|
|
||||||
{ "id": "f1", "name": "Veranstaltungstechnik", "parent_id": null }
|
|
||||||
],
|
|
||||||
"blocks": [
|
|
||||||
{
|
|
||||||
"name": "Stuhl-Reihe",
|
|
||||||
"folder_id": "f1",
|
|
||||||
"block_data": "[{...CADElement-Array als JSON-String...]}",
|
|
||||||
"svg_data": "<svg ...>"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `block_data` = JSON-String eines **CADElement-Arrays**
|
|
||||||
- `svg_data` = eigenständiges SVG (Thumbnail), optional
|
|
||||||
- Ordner-Hierarchie via `parent_id` (Import ordnet IDs neu zu)
|
|
||||||
|
|
||||||
**Endpoints:** `GET /api/global-blocks/export`, `POST /api/global-blocks/import`
|
|
||||||
(schreibend: nur library-admin/Rolle admin, siehe H3)
|
|
||||||
|
|
||||||
## Format 2: ZIP-Paket (F7)
|
|
||||||
|
|
||||||
Ein echtes ZIP-Archiv mit fester Struktur:
|
|
||||||
|
|
||||||
```
|
|
||||||
library.wcadlib.zip
|
|
||||||
├── manifest.json { "format": "wcadlib-zip", "version": 1,
|
|
||||||
│ "exported_at": "...", "block_count": N, "folder_count": M }
|
|
||||||
├── blocks.json { "folders": [...], "blocks": [
|
|
||||||
│ { "name", "folder_id", "block_data",
|
|
||||||
│ "thumbnail": "thumbs/<id>.svg" } ] }
|
|
||||||
└── thumbs/
|
|
||||||
└── <blockId>.svg Thumbnails, Referenz aus blocks.json
|
|
||||||
```
|
|
||||||
|
|
||||||
- `manifest.format` MUSS `wcadlib-zip` sein (sonst 400)
|
|
||||||
- `blocks.json` MUSS vorhanden sein (sonst 400); `blocks`-Array Pflicht
|
|
||||||
- `thumbnail` verweist relativ auf `thumbs/<id>.svg`
|
|
||||||
|
|
||||||
**Endpoints:**
|
|
||||||
- `GET /api/global-blocks/export-zip` → `application/zip`
|
|
||||||
- `POST /api/global-blocks/import-zip` (Body = ZIP als application/zip;
|
|
||||||
schreibend: nur library-admin)
|
|
||||||
|
|
||||||
## Validierungsregeln (Import)
|
|
||||||
|
|
||||||
| Fehler | HTTP |
|
|
||||||
|---|---|
|
|
||||||
| Kein valides ZIP (Format 2) | 400 |
|
|
||||||
| blocks.json fehlt / invalides JSON | 400 |
|
|
||||||
| manifest.format unbekannt | 400 |
|
|
||||||
| Kein JSON/blocks-Array (Format 1) | 400 |
|
|
||||||
| Nicht authentifiziert | 401 |
|
|
||||||
| Rolle != admin (schreibend) | 403 |
|
|
||||||
|
|
||||||
## CAD-Element-Konventionen (block_data)
|
|
||||||
|
|
||||||
- `rect`: `x/y` = **Zentrum** der BBox
|
|
||||||
- `circle`/`arc`: `el.x/el.y` = **Zentrum**, Winkel in Grad (0° = 3 Uhr, CCW)
|
|
||||||
- `line`: `properties.x1/y1/x2/y2`
|
|
||||||
- `polyline`/`polygon`: `properties.points`
|
|
||||||
- `block_instance`: `properties.blockId` + optional `attrValues` (D12)
|
|
||||||
|
|
||||||
Beim Drop auf die Zeichenfläche werden Element-IDs neu generiert und
|
|
||||||
die Position per Drop-Punkt übersetzt – der Bestand bleibt unverändert.
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# Plugin Guide – WebCAD Plugin API v2
|
|
||||||
|
|
||||||
WebCAD Plugin API v2 erlaubt die Erweiterung des CAD um Werkzeuge,
|
|
||||||
Panels und Block-Bibliotheken – ohne Kerncode anzufassen.
|
|
||||||
|
|
||||||
## Plugin-Typ (PluginV2)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { PluginV2, ToolExtensionV2 } from './plugins';
|
|
||||||
|
|
||||||
export const myPlugin: PluginV2 = {
|
|
||||||
manifest: {
|
|
||||||
id: 'my-tools',
|
|
||||||
name: 'Meine Werkzeuge',
|
|
||||||
version: '1.0.0',
|
|
||||||
author: 'Du',
|
|
||||||
description: 'Beschreibung',
|
|
||||||
category: 'tools', // tools|elements|library|import-export|theme|other
|
|
||||||
enabledByDefault: true,
|
|
||||||
},
|
|
||||||
tools: [/* ToolExtensionV2[] */],
|
|
||||||
panels: [/* PanelExtension[] (optional) */],
|
|
||||||
libraryProviders: [/* LibraryProviderExtension[] (optional) */],
|
|
||||||
onInit?() {}, onActivate?() {}, onDeactivate?() {}, onDestroy?() {},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Werkzeug (ToolExtensionV2)
|
|
||||||
|
|
||||||
Ein Werkzeug besteht aus Manifest, Options-Schema und Handlern:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const myTool: ToolExtensionV2 = {
|
|
||||||
manifest: {
|
|
||||||
id: 'my-tool',
|
|
||||||
label: 'Mein Werkzeug',
|
|
||||||
icon: 'M',
|
|
||||||
ribbonTab: 'canvas', // canvas|insert|format
|
|
||||||
tags: ['basic'], // basic = sichtbar im simple-Mode, ['pro'] nur pro-Mode
|
|
||||||
description: 'Was es tut',
|
|
||||||
},
|
|
||||||
optionsSchema: [
|
|
||||||
{ key: 'width', label: 'Breite', type: 'number', min: 1, max: 500 },
|
|
||||||
{ key: 'color', label: 'Farbe', type: 'color' },
|
|
||||||
{ key: 'style', label: 'Stil', type: 'select',
|
|
||||||
options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] },
|
|
||||||
{ key: 'frame', label: 'Rahmen', type: 'checkbox' },
|
|
||||||
],
|
|
||||||
handlers: {
|
|
||||||
down(e, ctx) {
|
|
||||||
// e.world = Weltkoordinaten, e.shift, e.ctrl
|
|
||||||
// ctx.doc = CADDocument (CRUD + transact + undo)
|
|
||||||
// ctx.setStatus('...'), ctx.setPreview?.(element)
|
|
||||||
const c = ctx as FullToolContext;
|
|
||||||
if (!c.doc) return;
|
|
||||||
c.doc.transact(() => c.doc!.addElement({ /* CADElement */ }));
|
|
||||||
},
|
|
||||||
move(e, ctx) { /* Live-Preview */ },
|
|
||||||
up(e, ctx) { /* Commit */ },
|
|
||||||
key(k, ctx) { /* return true = konsumiert (z. B. Enter) */ },
|
|
||||||
cancel(ctx) { /* ESC/Rechtsklick: Session-State zurücksetzen */ },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kernregeln
|
|
||||||
|
|
||||||
1. **Eine Aktion = eine `doc.transact(() => ...)`** = genau ein Undo-Schritt.
|
|
||||||
2. **Session-State als Modul-Member** (nicht in `options` – diese sind geteilter UI-State).
|
|
||||||
3. **`cancel` setzt den Session-State immer zurück.**
|
|
||||||
4. **Koordinaten-Konventionen:** rect `x/y` = Zentrum der BBox; circle/arc `el.x/el.y` = Zentrum; Winkel in Grad (0° = 3 Uhr, CCW).
|
|
||||||
5. **Undo-fähig schreiben** nur über `doc.addElement/updateElement/deleteElements`.
|
|
||||||
|
|
||||||
## Bibliotheks-Provider (LibraryProviderExtension)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const myProvider: LibraryProviderExtension = {
|
|
||||||
id: 'my-catalog',
|
|
||||||
label: 'Mein Katalog',
|
|
||||||
async listFolders() { return [{ id: 'f1', label: 'Ordner', parentId: null }]; },
|
|
||||||
async listBlocks(folderId) { return [/* LibBlock[] */]; },
|
|
||||||
async getBlock(id) { return /* LibBlock */; },
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
`LibBlock.payload` = JSON-String eines CADElement-Arrays → Drag&Drop
|
|
||||||
in die Zeichenfläche funktioniert automatisch über den bestehenden
|
|
||||||
Drop-Kanal. Thumbnail = eigenständiges SVG.
|
|
||||||
|
|
||||||
## Registrierung
|
|
||||||
|
|
||||||
Built-in-Plugins werden in `src/plugins/index.ts` per
|
|
||||||
`pluginRegistry.registerV2(plugin)` registriert; `enabledByDefault`
|
|
||||||
macht sie aktiv (alternativ per `pluginRegistry.enableV2(id)`).
|
|
||||||
|
|
||||||
## Beispiel: Minimal-Plugin
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// plugins/builtin/stamp/index.ts
|
|
||||||
import type { PluginV2, ToolExtensionV2 } from '../../types';
|
|
||||||
|
|
||||||
const stampTool: ToolExtensionV2 = {
|
|
||||||
manifest: {
|
|
||||||
id: 'stamp', label: 'Stempel', icon: '\u25c6',
|
|
||||||
ribbonTab: 'insert', tags: ['basic'],
|
|
||||||
description: 'Platziert ein Quadrat',
|
|
||||||
},
|
|
||||||
optionsSchema: [{ key: 'size', label: 'Größe', type: 'number', min: 10, max: 200 }],
|
|
||||||
handlers: {
|
|
||||||
down(e, ctx) {
|
|
||||||
const c = ctx as any;
|
|
||||||
if (!c.doc) return;
|
|
||||||
const size = (ctx.options.size as number | undefined) ?? 50;
|
|
||||||
c.doc.transact(() => c.doc.addElement({
|
|
||||||
id: `stamp_${Date.now()}`,
|
|
||||||
type: 'rect', layerId: 'layer-0',
|
|
||||||
x: e.world.x, y: e.world.y,
|
|
||||||
width: size, height: size,
|
|
||||||
properties: {},
|
|
||||||
}));
|
|
||||||
ctx.setStatus('Stempel platziert');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const stampPlugin: PluginV2 = {
|
|
||||||
manifest: {
|
|
||||||
id: 'stamp', name: 'Stempel', version: '1.0.0', author: 'WebCAD',
|
|
||||||
description: 'Beispielplugin', category: 'elements', enabledByDefault: true,
|
|
||||||
},
|
|
||||||
tools: [stampTool],
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Registrieren in `src/plugins/index.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
pluginRegistry.registerV2(stampPlugin);
|
|
||||||
```
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
# HMS Licht & Ton – Component Inventory
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
All components are built as Vue 3 single-file components (template + setup). In production (Nuxt 3), these map to `.vue` files in `components/` and `pages/` directories.
|
|
||||||
|
|
||||||
## Shared Components
|
|
||||||
|
|
||||||
### HmsLogo
|
|
||||||
- **Purpose:** SVG logo with white HMS text + orange (#EC6925) rectangular border
|
|
||||||
- **Props:** `size` (Number, default: 40)
|
|
||||||
- **States:** Default only (static SVG)
|
|
||||||
- **Accessibility:** `aria-label`, `role="img"`
|
|
||||||
|
|
||||||
### SpeakerIcon
|
|
||||||
- **Purpose:** SVG loudspeaker/light icon for equipment grid
|
|
||||||
- **Props:** `type` (String: linearray, subwoofer, monitor, pointsource, column, movinghead)
|
|
||||||
- **States:** Default (gray), Hover (orange via parent CSS)
|
|
||||||
- **Accessibility:** `aria-hidden="true"`
|
|
||||||
|
|
||||||
### AppHeader
|
|
||||||
- **Purpose:** Sticky navigation header with logo, desktop nav, mobile hamburger menu
|
|
||||||
- **States:** Default, Active nav item (`aria-current="page"`), Mobile menu open/closed
|
|
||||||
- **Accessibility:** `role="banner"`, `aria-label`, `aria-expanded`, `aria-controls`
|
|
||||||
|
|
||||||
### AppFooter
|
|
||||||
- **Purpose:** 4-column footer with company info, navigation, contact, legal links
|
|
||||||
- **States:** Default only
|
|
||||||
- **Accessibility:** `role="contentinfo"`
|
|
||||||
|
|
||||||
### ServiceCard
|
|
||||||
- **Purpose:** Service offering card with icon, title, description
|
|
||||||
- **Props:** `icon`, `title`, `description`
|
|
||||||
- **States:** Default, Hover (border highlight)
|
|
||||||
|
|
||||||
### EquipmentCard
|
|
||||||
- **Purpose:** Equipment listing card with image, badge, name, description, add-to-cart button
|
|
||||||
- **Props:** `item` (Object: id, code, name, category, description, brand, image)
|
|
||||||
- **Emits:** `click`, `add-to-cart`
|
|
||||||
- **States:** Default, Hover (border + shadow + lift), Focus (keyboard), No image (placeholder)
|
|
||||||
- **Accessibility:** `role="article"`, `tabindex="0"`, `aria-label` on add button
|
|
||||||
|
|
||||||
### LoadingSkeleton
|
|
||||||
- **Purpose:** Shimmer animation skeleton cards for loading states
|
|
||||||
- **Props:** `count` (Number, default: 6)
|
|
||||||
- **States:** Shimmer animation (infinite)
|
|
||||||
- **Accessibility:** `aria-live="polite"`, `aria-busy="true"`
|
|
||||||
|
|
||||||
### EmptyState
|
|
||||||
- **Purpose:** Empty state with icon, title, message, optional CTA button
|
|
||||||
- **Props:** `icon`, `title`, `message`, `actionLabel`
|
|
||||||
- **Emits:** `action`
|
|
||||||
- **States:** Default
|
|
||||||
- **Accessibility:** `role="status"`
|
|
||||||
|
|
||||||
### ErrorState
|
|
||||||
- **Purpose:** Error state with alert icon, title, message, retry button
|
|
||||||
- **Props:** `title`, `message`
|
|
||||||
- **Emits:** `retry`
|
|
||||||
- **States:** Default
|
|
||||||
- **Accessibility:** `role="alert"`
|
|
||||||
|
|
||||||
## Page Components
|
|
||||||
|
|
||||||
### HomePage (`#/`)
|
|
||||||
- **Sections:** Hero (bg image + overlay), Speaker Grid (6 types), Über uns (stats + image), 8 Service Cards, Mietkatalog CTA
|
|
||||||
- **States:** Default (all content static)
|
|
||||||
|
|
||||||
### ReferenzenPage (`#/referenzen`)
|
|
||||||
- **States:** Loading (skeleton), Loaded (gallery grid), Error (retry), Empty (no category results)
|
|
||||||
- **Features:** Category filter chips (Alle, Open-Air, Indoor, Rigging, Event), 9 real HMS photos
|
|
||||||
|
|
||||||
### KontaktPage (`#/kontakt`)
|
|
||||||
- **Sections:** Büro/Lager/Öffnungszeiten cards, 2 Ansprechpartner cards, Contact form
|
|
||||||
- **Form States:** Default, Validation errors (inline), Submitting (spinner), Submitted (success message)
|
|
||||||
- **Validation:** Name*, Email* (regex), Message*, Privacy checkbox*
|
|
||||||
|
|
||||||
### MietkatalogPage (`#/mietkatalog`)
|
|
||||||
- **States:** Loading (skeleton), Loaded (equipment grid), Error (retry), Empty (no search results)
|
|
||||||
- **Features:** Search input, Category filter chips (7 categories), Sort dropdown (name/brand/code), Result count
|
|
||||||
|
|
||||||
### EquipmentDetailPage (`#/mietkatalog/:id`)
|
|
||||||
- **States:** Loading (skeleton), Loaded (detail view), Error (not found)
|
|
||||||
- **Sections:** Image/placeholder, Specs table, Rental form (date range + quantity), Related items grid
|
|
||||||
- **Form:** Mietbeginn, Mietende, Anzahl (stepper), Add to cart button
|
|
||||||
|
|
||||||
### WarenkorbPage (`#/warenkorb`)
|
|
||||||
- **States:** Empty (no items), Cart with items, Submitting (spinner), Submitted (success)
|
|
||||||
- **Features:** Item list with quantity steppers, remove buttons, rental dates display, Request form (name*, email*, phone, event_date*, event_location*, message)
|
|
||||||
|
|
||||||
### AdminPage (`#/admin`)
|
|
||||||
- **States:** Login form, Loading (spinner), Logged in (success)
|
|
||||||
- **Features:** Username + password fields, minimal design
|
|
||||||
|
|
||||||
### NotFoundPage (`#/404`)
|
|
||||||
- **States:** Default (404 message + home link)
|
|
||||||
|
|
||||||
## UX State Coverage Summary
|
|
||||||
|
|
||||||
| Page | Loading | Empty | Error | Partial | Success |
|
|
||||||
|------|---------|-------|-------|---------|---------|
|
|
||||||
| Home | – | – | – | – | – |
|
|
||||||
| Referenzen | Skeleton | No category | Retry | Filtered | – |
|
|
||||||
| Kontakt | – | – | – | – | Form sent |
|
|
||||||
| Mietkatalog | Skeleton | No results | Retry | Filtered | – |
|
|
||||||
| Detail | Skeleton | – | Not found | – | – |
|
|
||||||
| Warenkorb | – | Empty cart | – | – | Request sent |
|
|
||||||
| Admin | Spinner | – | – | – | Logged in |
|
|
||||||
|
|
||||||
## Component States Documentation
|
|
||||||
|
|
||||||
### Button States
|
|
||||||
- **Default:** `background: var(--color-accent); color: white`
|
|
||||||
- **Hover:** `background: var(--color-accent-hover)`
|
|
||||||
- **Active:** `transform: translateY(0)`
|
|
||||||
- **Disabled:** `background: var(--secondary); cursor: not-allowed`
|
|
||||||
- **Focus-visible:** `outline: 2px solid var(--color-accent); outline-offset: 2px`
|
|
||||||
|
|
||||||
### Input States
|
|
||||||
- **Default:** `border: 1px solid var(--border-strong); background: var(--surface)`
|
|
||||||
- **Focus:** `border-color: var(--color-accent); box-shadow: 0 0 0 2px rgba(236,105,37,0.1)`
|
|
||||||
- **Error:** `border-color: var(--color-error)`
|
|
||||||
- **Disabled:** `background: var(--row); color: var(--secondary); cursor: not-allowed`
|
|
||||||
|
|
||||||
### Card States
|
|
||||||
- **Default:** `border: 1px solid var(--border); box-shadow: var(--shadow-sm)`
|
|
||||||
- **Hover:** `border-color: var(--secondary); box-shadow: var(--shadow-lg)`
|
|
||||||
|
|
||||||
### Chip States
|
|
||||||
- **Default:** `border: 1px solid var(--border-strong); background: var(--surface)`
|
|
||||||
- **Hover:** `border-color: var(--secondary)`
|
|
||||||
- **Active:** `background: var(--color-accent); color: white; border-color: var(--color-accent)`
|
|
||||||
@@ -1,799 +0,0 @@
|
|||||||
openapi: 3.0.3
|
|
||||||
info:
|
|
||||||
title: Web CAD API
|
|
||||||
description: REST API for the Web-based 2D CAD for Event Seating Plans
|
|
||||||
version: 1.0.0
|
|
||||||
license:
|
|
||||||
name: MIT
|
|
||||||
servers:
|
|
||||||
- url: http://localhost:3001
|
|
||||||
description: Local development
|
|
||||||
- url: https://web-cad-neu.server.media-on.de
|
|
||||||
description: Production
|
|
||||||
|
|
||||||
components:
|
|
||||||
securitySchemes:
|
|
||||||
BearerAuth:
|
|
||||||
type: http
|
|
||||||
scheme: bearer
|
|
||||||
schemas:
|
|
||||||
Error:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
error:
|
|
||||||
type: string
|
|
||||||
User:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
email:
|
|
||||||
type: string
|
|
||||||
format: email
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
role:
|
|
||||||
type: string
|
|
||||||
enum: [admin, planer, betrachter, gast]
|
|
||||||
created_at:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
Project:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
description:
|
|
||||||
type: string
|
|
||||||
owner_id:
|
|
||||||
type: string
|
|
||||||
folder_id:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
created_at:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
updated_at:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
Drawing:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
project_id:
|
|
||||||
type: string
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
data_json:
|
|
||||||
type: string
|
|
||||||
created_at:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
Layer:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
drawing_id:
|
|
||||||
type: string
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
visible:
|
|
||||||
type: integer
|
|
||||||
locked:
|
|
||||||
type: integer
|
|
||||||
color:
|
|
||||||
type: string
|
|
||||||
line_type:
|
|
||||||
type: string
|
|
||||||
transparency:
|
|
||||||
type: number
|
|
||||||
sort_order:
|
|
||||||
type: integer
|
|
||||||
parent_id:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
Element:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
drawing_id:
|
|
||||||
type: string
|
|
||||||
layer_id:
|
|
||||||
type: string
|
|
||||||
type:
|
|
||||||
type: string
|
|
||||||
enum: [line, circle, arc, rect, polygon, polyline, text, dimension, block_instance, chair]
|
|
||||||
x:
|
|
||||||
type: number
|
|
||||||
y:
|
|
||||||
type: number
|
|
||||||
width:
|
|
||||||
type: number
|
|
||||||
height:
|
|
||||||
type: number
|
|
||||||
properties_json:
|
|
||||||
type: string
|
|
||||||
Block:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
drawing_id:
|
|
||||||
type: string
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
category:
|
|
||||||
type: string
|
|
||||||
elements_json:
|
|
||||||
type: string
|
|
||||||
thumbnail:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
Session:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
token:
|
|
||||||
type: string
|
|
||||||
userId:
|
|
||||||
type: string
|
|
||||||
expiresAt:
|
|
||||||
type: number
|
|
||||||
AuthResult:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/User'
|
|
||||||
session:
|
|
||||||
$ref: '#/components/schemas/Session'
|
|
||||||
|
|
||||||
security:
|
|
||||||
- BearerAuth: []
|
|
||||||
|
|
||||||
paths:
|
|
||||||
/api/health:
|
|
||||||
get:
|
|
||||||
summary: Health check
|
|
||||||
security: []
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
timestamp:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
|
|
||||||
/api/auth/register:
|
|
||||||
post:
|
|
||||||
summary: Register a new user
|
|
||||||
security: []
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
email:
|
|
||||||
type: string
|
|
||||||
format: email
|
|
||||||
password:
|
|
||||||
type: string
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
role:
|
|
||||||
type: string
|
|
||||||
enum: [admin, planer, betrachter, gast]
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: User created
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/AuthResult'
|
|
||||||
'400':
|
|
||||||
description: Missing fields
|
|
||||||
'409':
|
|
||||||
description: Email already registered
|
|
||||||
'429':
|
|
||||||
description: Rate limit exceeded
|
|
||||||
|
|
||||||
/api/auth/login:
|
|
||||||
post:
|
|
||||||
summary: Login
|
|
||||||
security: []
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
email:
|
|
||||||
type: string
|
|
||||||
password:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Login successful
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/AuthResult'
|
|
||||||
'401':
|
|
||||||
description: Invalid credentials
|
|
||||||
'429':
|
|
||||||
description: Rate limit exceeded
|
|
||||||
|
|
||||||
/api/auth/logout:
|
|
||||||
post:
|
|
||||||
summary: Logout
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Logged out
|
|
||||||
|
|
||||||
/api/auth/me:
|
|
||||||
get:
|
|
||||||
summary: Get current user profile
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Current user
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/User'
|
|
||||||
'401':
|
|
||||||
description: Not authenticated
|
|
||||||
|
|
||||||
/api/auth/password:
|
|
||||||
patch:
|
|
||||||
summary: Change password
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
oldPassword:
|
|
||||||
type: string
|
|
||||||
newPassword:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Password changed
|
|
||||||
'400':
|
|
||||||
description: Invalid old password
|
|
||||||
|
|
||||||
/api/projects:
|
|
||||||
get:
|
|
||||||
summary: List projects
|
|
||||||
parameters:
|
|
||||||
- name: folderId
|
|
||||||
in: query
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
description: Filter by folder ID (null for root)
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of projects
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/Project'
|
|
||||||
post:
|
|
||||||
summary: Create a project
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
description:
|
|
||||||
type: string
|
|
||||||
folder_id:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Project created
|
|
||||||
|
|
||||||
/api/projects/{id}:
|
|
||||||
get:
|
|
||||||
summary: Get a project
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Project details
|
|
||||||
'404':
|
|
||||||
description: Not found
|
|
||||||
put:
|
|
||||||
summary: Update a project
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Updated
|
|
||||||
delete:
|
|
||||||
summary: Delete a project
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Deleted
|
|
||||||
|
|
||||||
/api/projects/{id}/folder:
|
|
||||||
put:
|
|
||||||
summary: Move project to folder
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
folder_id:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Moved
|
|
||||||
|
|
||||||
/api/project-folders:
|
|
||||||
get:
|
|
||||||
summary: List project folders
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of folders
|
|
||||||
post:
|
|
||||||
summary: Create a folder
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
parent_id:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Folder created
|
|
||||||
|
|
||||||
/api/project-folders/{id}:
|
|
||||||
get:
|
|
||||||
summary: Get a folder
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Folder details
|
|
||||||
put:
|
|
||||||
summary: Rename a folder
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Renamed
|
|
||||||
delete:
|
|
||||||
summary: Delete a folder
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Deleted
|
|
||||||
|
|
||||||
/api/drawings:
|
|
||||||
get:
|
|
||||||
summary: List drawings for a project
|
|
||||||
parameters:
|
|
||||||
- name: projectId
|
|
||||||
in: query
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of drawings
|
|
||||||
post:
|
|
||||||
summary: Create a drawing
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
project_id:
|
|
||||||
type: string
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Drawing created
|
|
||||||
|
|
||||||
/api/drawings/{id}:
|
|
||||||
get:
|
|
||||||
summary: Get a drawing
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Drawing details
|
|
||||||
put:
|
|
||||||
summary: Update a drawing
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Updated
|
|
||||||
delete:
|
|
||||||
summary: Delete a drawing
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Deleted
|
|
||||||
|
|
||||||
/api/drawings/{drawingId}/layers:
|
|
||||||
get:
|
|
||||||
summary: List layers for a drawing
|
|
||||||
parameters:
|
|
||||||
- name: drawingId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of layers
|
|
||||||
post:
|
|
||||||
summary: Create a layer
|
|
||||||
parameters:
|
|
||||||
- name: drawingId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Layer created
|
|
||||||
|
|
||||||
/api/layers/{id}:
|
|
||||||
patch:
|
|
||||||
summary: Update a layer
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Updated
|
|
||||||
delete:
|
|
||||||
summary: Delete a layer
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Deleted
|
|
||||||
|
|
||||||
/api/drawings/{drawingId}/elements:
|
|
||||||
get:
|
|
||||||
summary: List elements for a drawing
|
|
||||||
parameters:
|
|
||||||
- name: drawingId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of elements
|
|
||||||
post:
|
|
||||||
summary: Create an element
|
|
||||||
parameters:
|
|
||||||
- name: drawingId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Element created
|
|
||||||
|
|
||||||
/api/elements/{id}:
|
|
||||||
put:
|
|
||||||
summary: Update an element
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Updated
|
|
||||||
delete:
|
|
||||||
summary: Delete an element
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Deleted
|
|
||||||
|
|
||||||
/api/drawings/{drawingId}/blocks:
|
|
||||||
get:
|
|
||||||
summary: List blocks for a drawing
|
|
||||||
parameters:
|
|
||||||
- name: drawingId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of blocks
|
|
||||||
post:
|
|
||||||
summary: Create a block
|
|
||||||
parameters:
|
|
||||||
- name: drawingId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Block created
|
|
||||||
|
|
||||||
/api/blocks/{id}:
|
|
||||||
put:
|
|
||||||
summary: Update a block
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Updated
|
|
||||||
delete:
|
|
||||||
summary: Delete a block
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Deleted
|
|
||||||
|
|
||||||
/api/settings:
|
|
||||||
get:
|
|
||||||
summary: Get all settings
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Settings object
|
|
||||||
put:
|
|
||||||
summary: Set a setting value
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
key:
|
|
||||||
type: string
|
|
||||||
value:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Setting saved
|
|
||||||
|
|
||||||
/api/users:
|
|
||||||
get:
|
|
||||||
summary: List users (admin only)
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of users
|
|
||||||
|
|
||||||
/api/users/{id}:
|
|
||||||
put:
|
|
||||||
summary: Update a user (admin only)
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Updated
|
|
||||||
delete:
|
|
||||||
summary: Delete a user (admin only)
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Deleted
|
|
||||||
|
|
||||||
/api/ai/chat:
|
|
||||||
post:
|
|
||||||
summary: KI Copilot chat proxy
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
messages:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
role:
|
|
||||||
type: string
|
|
||||||
content:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: AI response
|
|
||||||
'401':
|
|
||||||
description: Not authenticated
|
|
||||||
|
|
||||||
/api/notifications:
|
|
||||||
get:
|
|
||||||
summary: List notifications
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of notifications
|
|
||||||
post:
|
|
||||||
summary: Create a notification
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Created
|
|
||||||
|
|
||||||
/api/notifications/{id}/read:
|
|
||||||
patch:
|
|
||||||
summary: Mark notification as read
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Marked as read
|
|
||||||
|
|
||||||
/api/projects/{id}/shares:
|
|
||||||
get:
|
|
||||||
summary: List shares for a project
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of shares
|
|
||||||
post:
|
|
||||||
summary: Share a project
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Shared
|
|
||||||
|
|
||||||
/api/global-blocks:
|
|
||||||
get:
|
|
||||||
summary: List global blocks
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of global blocks
|
|
||||||
post:
|
|
||||||
summary: Create a global block
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Created
|
|
||||||
|
|
||||||
/api/global-block-folders:
|
|
||||||
get:
|
|
||||||
summary: List global block folders
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of folders
|
|
||||||
post:
|
|
||||||
summary: Create a global block folder
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Created
|
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# Requirements Specification – web-cad
|
||||||
|
|
||||||
|
**Project:** web-cad – Web-basiertes 2D-CAD für Event-Bestuhlungspläne
|
||||||
|
**Phase:** 1 (Intake)
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
**Status:** Draft – ready for user review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Vision & Ziel
|
||||||
|
|
||||||
|
Das Ziel von **web-cad** ist eine web-basierte 2D-CAD-Anwendung, die:
|
||||||
|
|
||||||
|
- alle grundlegenden CAD-Funktionen bereitstellt (Zeichnen, Bearbeiten, Bemaßen, Ebenen, Bibliothek, Gruppierung, Export/Import)
|
||||||
|
- spezialisierte Tools für Event-Bestuhlungspläne bietet (Reihen- und Block-Bestuhlung)
|
||||||
|
- später um weitere branchenspezifische Tools erweiterbar ist
|
||||||
|
- Multi-User-Kollaboration in Echtzeit unterstützt
|
||||||
|
- performant im Browser läuft – auch bei großen Projekten
|
||||||
|
- später auf Docker und Coolify deploybar ist
|
||||||
|
|
||||||
|
**Referenz-UI:** AutoCAD Web
|
||||||
|
**Referenz-Architektur (Kollaboration):** Figma / Onshape (Cloud-native, Single Source of Truth)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Nutzer & Rollen
|
||||||
|
|
||||||
|
| Rolle | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| **Planer** | Erstellt und bearbeitet Bestuhlungspläne, nutzt CAD-Grundfunktionen und Bestuhlungs-Tools |
|
||||||
|
| **Betrachter** | Kann Pläne ansehen, kommentieren, aber nicht bearbeiten (Read-Only-Zugriff) |
|
||||||
|
| **Admin** | Verwaltung von Projekten, Nutzern, Berechtigungen und Bibliothek |
|
||||||
|
| **Gast** | Temporärer Zugriff auf spezifische Pläne ohne Account (z. B. für Kundenfreigabe) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Funktionale Anforderungen
|
||||||
|
|
||||||
|
### 3.1 CAD-Grundfunktionen
|
||||||
|
|
||||||
|
| ID | Anforderung | Akzeptanzkriterium |
|
||||||
|
|---|---|---|
|
||||||
|
| F-CAD-01 | **Zeichnen von Grundelementen:** Linie, Kreis, Bogen, Rechteck, Polygon, Ellipse | Alle genannten Elemente können erstellt, ausgewählt, verschoben und gelöscht werden |
|
||||||
|
| F-CAD-02 | **Bearbeiten:** Verschieben, Kopieren, Rotieren, Skalieren, Spiegeln, Trimmen, Verlängern, Abrunden (Fillet) | Jede Operation verändert die Geometrie korrekt und kann rückgängig gemacht werden (Undo/Redo) |
|
||||||
|
| F-CAD-03 | **Bemaßung:** Lineare, winkelige, radiale Bemaßung mit automatischer Maßberechnung basierend auf dem Hintergrund-Grundriss-Maßstab | Bemaßung zeigt korrekte Werte in realen Maßeinheiten (m/cm) an |
|
||||||
|
| F-CAD-04 | **Raster & Fang:** Rasteranzeige, Snap-to-Grid, Snap-to-Endpoint, Snap-to-Midpoint, Snap-to-Intersection, Ortho-Modus | Fangpunkte werden visuell hervorgehoben und beim Zeichnen exakt eingefangen |
|
||||||
|
| F-CAD-05 | **Eingabefeld (Command Line):** Befehlseingabe via Tastatur wie in AutoCAD (z. B. "L" für Line) | Befehle können über die Tastatur eingegeben und ausgeführt werden; Autovervollständigung vorhanden |
|
||||||
|
| F-CAD-06 | **Eigenschaften-Panel:** Anzeige und Bearbeitung von Element-Eigenschaften (Position, Größe, Winkel, Farbe, Linientyp, Linienstärke) | Eigenschaften können angezeigt und geändert werden; Änderungen sind sofort sichtbar |
|
||||||
|
| F-CAD-07 | **Auswahl-Methoden:** Einzelauswahl, Fenster-Auswahl, Kreuz-Auswahl, Selektion-Filter | Alle Auswahlmethoden funktionieren und können kombiniert werden |
|
||||||
|
| F-CAD-08 | **Gruppierung:** Elemente zu Gruppen zusammenfassen, Gruppen verschachteln, Gruppen speichern | Gruppen können erstellt, aufgelöst und in der Bibliothek gespeichert werden |
|
||||||
|
| F-CAD-09 | **Undo/Redo:** Strukturierte Undo/Redo-Historie mit beliebig vielen Schritten | Undo und Redo funktionieren für alle Operationen; Historie kann eingesehen werden |
|
||||||
|
| F-CAD-10 | **Kopieren zwischen Dateien:** Elemente können via Zwischenablage zwischen Projekten kopiert werden | Kopierte Elemente behalten ihre Eigenschaften und relative Position |
|
||||||
|
|
||||||
|
### 3.2 Ebenen-System (Layers)
|
||||||
|
|
||||||
|
| ID | Anforderung | Akzeptanzkriterium |
|
||||||
|
|---|---|---|
|
||||||
|
| F-LAY-01 | **Ebenen als Baumstruktur:** Ebenen hierarchisch organisiert mit Parent-Child-Beziehungen | Ebenen werden in einem Baum-Widget angezeigt; Parent-Child-Beziehung sichtbar |
|
||||||
|
| F-LAY-02 | **Ebenen-Eigenschaften:** Name, Sichtbarkeit (On/Off), Sperrung (Lock/Unlock), Farbe, Linientyp | Jede Eigenschaft kann pro Ebene gesetzt werden und wirkt sich auf alle zugehörigen Elemente aus |
|
||||||
|
| F-LAY-03 | **Ebenen-Operationen:** Erstellen, Löschen, Umbenennen, Verschieben im Baum, Duplizieren | Alle Operationen funktionieren und aktualisieren den Baum in Echtzeit |
|
||||||
|
| F-LAY-04 | **Element-Zuordnung:** Elemente können zwischen Ebenen verschoben werden | Drag-and-Drop oder Kontextmenü zum Verschieben von Elementen zwischen Ebenen |
|
||||||
|
| F-LAY-05 | **Aktive Ebene:** Neue Elemente werden auf der aktiven Ebene erstellt | Aktive Ebene ist klar markiert; neue Elemente erscheinen auf der aktiven Ebene |
|
||||||
|
|
||||||
|
### 3.3 Bibliothek (Block-Bibliothek)
|
||||||
|
|
||||||
|
| ID | Anforderung | Akzeptanzkriterium |
|
||||||
|
|---|---|---|
|
||||||
|
| F-LIB-01 | **Bibliothek als Baumstruktur:** Bibliothekselemente hierarchisch organisiert mit Ordnern und Unterordnern | Baum-Widget zeigt Ordner und Blöcke; Drag-and-Drop für Organisation |
|
||||||
|
| F-LIB-02 | **SVG-Import:** SVG-Dateien können in die Bibliothek importiert werden | SVG wird korrekt importiert, im Viewer angezeigt und als wiederverwendbarer Block gespeichert |
|
||||||
|
| F-LIB-03 | **Gruppen in Bibliothek speichern:** Im Zeichenbereich erstellte Gruppen können in die Bibliothek gespeichert werden | Gruppe wird als Block gespeichert und kann per Drag-and-Drop in den Zeichenbereich eingefügt werden |
|
||||||
|
| F-LIB-04 | **Block-Einfügen:** Blöcke aus der Bibliothek per Drag-and-Drop in den Zeichenbereich einfügen | Block wird an der Drop-Position eingefügt; Skalierung und Rotation können beim Einfügen gesetzt werden |
|
||||||
|
| F-LIB-05 | **Block-Bearbeitung:** Blöcke können nach dem Einfügen bearbeitet, skaliert, rotiert und gespiegelt werden | Alle Bearbeitungsoperationen funktionieren auf eingefügten Blöcken |
|
||||||
|
| F-LIB-06 | **Bibliotheks-Verwaltung:** Blöcke umbenennen, duplizieren, löschen, in Ordner verschieben | Alle Verwaltungsoperationen funktionieren; Änderungen werden persistent gespeichert |
|
||||||
|
|
||||||
|
### 3.4 Hintergrund & Grundriss
|
||||||
|
|
||||||
|
| ID | Anforderung | Akzeptanzkriterium |
|
||||||
|
|---|---|---|
|
||||||
|
| F-BG-01 | **Grundriss laden:** Bild- oder Vektor-Datei (PNG, JPG, SVG, PDF-Seite) als Hintergrund laden | Datei wird als Hintergrund-Ebene geladen und im Zeichenbereich angezeigt |
|
||||||
|
| F-BG-02 | **Maßstabs-Definition:** Maßstab des Grundrisses kann definiert werden (z. B. 1:100, Referenzstrecke) | Nach Maßstabsdefinition werden Bemaßungen in realen Maßeinheiten (m/cm) angezeigt |
|
||||||
|
| F-BG-03 | **Hintergrund-Positionierung:** Hintergrund kann verschoben, rotiert und skaliert werden | Transformationen sind möglich und Bemaßungen aktualisieren sich entsprechend |
|
||||||
|
| F-BG-04 | **Hintergrund-Sichtbarkeit:** Hintergrund kann ein- und ausgeblendet werden | Sichtbarkeit kann pro Ebene oder global geschaltet werden |
|
||||||
|
|
||||||
|
### 3.5 Bestuhlungs-Tools (Event-Spezifisch)
|
||||||
|
|
||||||
|
| ID | Anforderung | Akzeptanzkriterium |
|
||||||
|
|---|---|---|
|
||||||
|
| F-EVT-01 | **Reihen-Bestuhlung:** Automatische Platzierung von Stühlen in einer Reihe mit konfigurierbarem Abstand, Anzahl und Ausrichtung | Reihe wird mit korrekter Anzahl, Abstand und Ausrichtung generiert; Parameter sind im Nachhinein änderbar |
|
||||||
|
| F-EVT-02 | **Block-Bestuhlung:** Automatische Platzierung von Stuhl-Blöcken in einem rechteckigen Bereich mit konfigurierbaren Reihen, Spalten, Abständen | Block wird mit korrekter Reihe/Spalte-Anzahl und Abständen generiert; Parameter im Nachhinein änderbar |
|
||||||
|
| F-EVT-03 | **Bestuhlungs-Parameter:** Stuhl-Typ (aus Bibliothek), Reihe/Spalte-Anzahl, Abstand, Versatz, Ausrichtung, Block-Konfiguration | Alle Parameter können konfiguriert werden; Änderungen aktualisieren die Bestuhlung in Echtzeit |
|
||||||
|
| F-EVT-04 | **Bestuhlung bearbeiten:** Generierte Bestuhlung kann nachträglich modifiziert werden (Stühle hinzufügen/entfernen, verschieben) | Einzelne Stühle können hinzugefügt, entfernt oder verschoben werden; Gesamttbestuhlung aktualisiert sich |
|
||||||
|
| F-EVT-05 | **Bestuhlungs-Zählung:** Automatische Zählung der platzierten Stühle pro Reihe, Block und Gesamt | Zähler wird in Echtzeit aktualisiert und kann im Eigenschaften-Panel abgelesen werden |
|
||||||
|
|
||||||
|
### 3.6 Import & Export
|
||||||
|
|
||||||
|
| ID | Anforderung | Akzeptanzkriterium |
|
||||||
|
|---|---|---|
|
||||||
|
| F-IMP-01 | **DXF-Import:** DXF-Dateien können importiert und als Zeichnung bearbeitet werden | DXF wird korrekt importiert; Layer, Blöcke und Geometrie bleiben erhalten |
|
||||||
|
| F-IMP-02 | **SVG-Import:** SVG-Dateien können als Zeichnung importiert werden | SVG wird korrekt importiert und als Vektor-Elemente bearbeitbar |
|
||||||
|
| F-IMP-03 | **DWG-Import (Optional):** DWG-Dateien können importiert werden (falls Lizenz/Library verfügbar) | DWG wird korrekt importiert oder klare Fehlermeldung bei nicht unterstützter Version |
|
||||||
|
| F-IMP-04 | **PDF-Import:** PDF-Dateien als Hintergrund oder als Vektor-Import | PDF wird geladen; Seiten können ausgewählt werden; Vektoren werden als bearbeitbare Elemente importiert |
|
||||||
|
| F-EXP-01 | **DXF-Export:** Zeichnung kann als DXF exportiert werden | Exportierte DXF-Datei kann in AutoCAD/QCAD korrekt geöffnet werden |
|
||||||
|
| F-EXP-02 | **SVG-Export:** Zeichnung kann als SVG exportiert werden | Exportierte SVG-Datei ist W3C-konform und in Browsern/Vektor-Programmen darstellbar |
|
||||||
|
| F-EXP-03 | **PDF-Export:** Zeichnung kann als PDF exportiert werden (mit Layout, Maßstab, Titelblock) | PDF wird mit korrektem Maßstab und allen Elementen generiert |
|
||||||
|
| F-EXP-03a | **PNG-Export:** Zeichnung kann als PNG exportiert werden | PNG wird in konfigurierbarer Auflösung generiert |
|
||||||
|
| F-EXP-04 | **JSON-Export (Projekt):** Vollständiges Projekt kann als JSON exportiert und wieder importiert werden | JSON enthält alle Daten (Ebenen, Elemente, Bibliothek, Grundriss); Re-Import ergibt identisches Projekt |
|
||||||
|
|
||||||
|
### 3.7 Multi-User & Kollaboration
|
||||||
|
|
||||||
|
| ID | Anforderung | Akzeptanzkriterium |
|
||||||
|
|---|---|---|
|
||||||
|
| F-MU-01 | **Echtzeit-Kollaboration:** Mehrere Nutzer können gleichzeitig an derselben Zeichnung arbeiten | Änderungen eines Nutzers sind für alle anderen in Echtzeit (< 1 Sekunde) sichtbar |
|
||||||
|
| F-MU-02 | **Konfliktfreie Bearbeitung:** Gleichzeitige Bearbeitung desselben Elements führt nicht zu Konflikten oder Datenverlust | CRDT-basierte Synchronisation stellt Konsistenz sicher; keine Merge-Konflikte |
|
||||||
|
| F-MU-03 | **Nutzer-Anwesenheit:** Sichtbare Anzeige welcher Nutzer online ist und an welcher Zeichnung arbeitet | Avatar/Farbmarkierung pro Nutzer; Liste der aktiven Nutzer im Panel |
|
||||||
|
| F-MU-04 | **Cursor-Anzeige:** Position und Aktionen anderer Nutzer in Echtzeit sichtbar | Cursor anderer Nutzer wird mit Name/Farbe in Echtzeit angezeigt |
|
||||||
|
| F-MU-05 | **Berechtigungs-System:** Rollenbasierte Zugriffskontrolle (Planer, Betrachter, Admin, Gast) | Berechtigungen werden enforced; Betrachter kann nicht bearbeiten; Gast hat nur Zugriff auf freigegebene Pläne |
|
||||||
|
| F-MU-06 | **Offline-Unterstützung:** Offline-Änderungen werden synchronisiert, wenn Verbindung wiederhergestellt ist | Offline-Änderungen werden automatisch synchronisiert; keine Datenverluste |
|
||||||
|
| F-MU-07 | **Versionshistorie:** Änderungen werden protokolliert; Versionen können eingesehen und wiederhergestellt werden | Historie zeigt Zeitstempel und Nutzer; Versionen können wiederhergestellt werden |
|
||||||
|
|
||||||
|
### 3.8 UI / UX
|
||||||
|
|
||||||
|
| ID | Anforderung | Akzeptanzkriterium |
|
||||||
|
|---|---|---|
|
||||||
|
| F-UI-01 | **AutoCAD Web-ähnliche Oberfläche:** Ribbon-/Menü-Band, Werkzeug-Paletten, Eigenschaften-Panel, Command-Line, Status-Bar | Layout orientiert sich an AutoCAD Web; Hauptwerkzeuge sind sichtbar und erreichbar |
|
||||||
|
| F-UI-02 | **Zeichenbereich:** Großer Canvas-Bereich mit Zoom, Pan, und View-Controls (Zoom-to-Fit, Zoom-to-Window) | Zoom und Pan funktionieren flüssig (60fps); View-Controls sind erreichbar |
|
||||||
|
| F-UI-03 | **Kontextmenüs:** Rechtsklick-Kontextmenüs mit relevanten Aktionen | Kontextmenü zeigt aktionsabhängige Einträge |
|
||||||
|
| F-UI-04 | **Tastatur-Shortcuts:** Standard-CAD-Shortcuts (z. B. L, C, M, CO, RO, SC, MI) | Shortcuts funktionieren und sind dokumentiert |
|
||||||
|
| F-UI-05 | **Responsive Design:** UI funktioniert auf Desktop-Bildschirmen (min. 1280px) | Bei 1280px Breite sind alle Panels nutzbar; kleinere Bildschirme werden in späterer Phase unterstützt |
|
||||||
|
| F-UI-06 | **Theme-Unterstützung:** Dark- und Light-Mode | Theme kann umgeschaltet werden; alle UI-Elemente passen sich an |
|
||||||
|
|
||||||
|
### 3.9 Erweiterbarkeit
|
||||||
|
|
||||||
|
| ID | Anforderung | Akzeptanzkriterium |
|
||||||
|
|---|---|---|
|
||||||
|
| F-EXT-01 | **Plugin/Tool-System:** Architektur ermöglicht das Hinzufügen neuer Tools ohne Core-Änderung | Ein neues Tool kann als Plugin/Modul implementiert und registriert werden ohne Core-Code zu ändern |
|
||||||
|
| F-EXT-02 | **Tool-API:** Definierte Schnittstelle für externe Tools (Zeichnen, Bearbeiten, Bibliothek, Export) | API ist dokumentiert; ein Beispiel-Tool kann die API nutzen |
|
||||||
|
| F-EXT-03 | **Bibliothek-Erweiterung:** Neue Bibliothekstypen können hinzugefügt werden (nicht nur SVG) | Neue Typen können registriert und in der Bibliothek verwendet werden |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Nicht-funktionale Anforderungen
|
||||||
|
|
||||||
|
### 4.1 Performance
|
||||||
|
|
||||||
|
| ID | Anforderung | Akzeptanzkriterium |
|
||||||
|
|---|---|---|
|
||||||
|
| NF-PERF-01 | **Große Projekte:** Anwendung muss Projekte mit 50.000+ Elementen flüssig darstellen (60fps) | Benchmark: 50.000 Linienelemente werden bei 60fps gerendert (Canvas 2D mit Indexierung oder WebGL) |
|
||||||
|
| NF-PERF-02 | **Ladezeit:** Initiale Ladezeit < 3 Sekunden bei Standard-Internetverbindung | Lighthouse-Performance-Score >= 80; First Contentful Paint < 1,5s |
|
||||||
|
| NF-PERF-03 | **Speicher-Effizienz:** Browser-Speicherverbrauch bleibt < 500MB bei 50.000 Elementen | Speicherverbrauch wird überwacht und bleibt unter 500MB |
|
||||||
|
| NF-PERF-04 | **Kollaborations-Latenz:** Echtzeit-Synchronisation < 1 Sekunde bei normaler Netzwerkverbindung | Latenz wird gemessen und bleibt unter 1 Sekunde |
|
||||||
|
|
||||||
|
### 4.2 Rendering-Technologie (Architektur-Empfehlung)
|
||||||
|
|
||||||
|
Basierend auf Recherche (Stand 2025/2026):
|
||||||
|
|
||||||
|
| Technologie | Einsatzbereich | Begründung |
|
||||||
|
|---|---|---|
|
||||||
|
| **Canvas 2D (mit Indexierung & Layer-System)** | Primärer Renderer für 2D-CAD | Kann 50.000+ Elemente bei 60fps rendern; gute Balance aus Performance und Entwicklungs-Aufwand |
|
||||||
|
| **WebGL (optional, Hybrid)** | Performance-Boost für sehr große Szenen (> 100k Elemente) | GPU-Beschleunigung; höhere Komplexität; erst bei Bedarf implementieren |
|
||||||
|
| **SVG** | NICHT als primärer Renderer geeignet | DOM-Overhead bei > 3.000-5.000 Elementen; Performance-Einbruch |
|
||||||
|
| **WebAssembly** | Berechnungsintensive Operationen (DXF-Parsing, Geometrie-Operationen) | Nahe-native Performance für Parsing und Mathematik |
|
||||||
|
|
||||||
|
**Empfehlung:** Canvas 2D mit räumlichem Index (Quadtree/R-Tree) und Layer-basiertem Rendering als primäre Technologie. WebGL als optionale Hybrid-Schicht für extrem große Szenen reservieren.
|
||||||
|
|
||||||
|
### 4.3 Kollaborations-Architektur (Architektur-Empfehlung)
|
||||||
|
|
||||||
|
| Aspekt | Empfehlung | Begründung |
|
||||||
|
|---|---|---|
|
||||||
|
| **Synchronisation** | CRDT (z. B. Yjs) über WebSocket | Konfliktfreie Synchronisation; bewährt in Figma und modernen kollaborativen Apps; leichter korrekt zu implementieren als OT |
|
||||||
|
| **Transport** | WebSocket für Dokument-Daten; WebRTC optional für Cursor-Daten | WebSocket: zuverlässig, server-seitig kontrollierbar; WebRTC: niedrigere Latenz für Cursor-Positionen |
|
||||||
|
| **Persistenz** | Server-seitige Persistenz der CRDT-Dokumente | Single Source of Truth; Versionshistorie; Offline-Sync bei Reconnect |
|
||||||
|
| **Skalierung** | WebSocket-Server mit Pub/Sub pro Raum (Zeichnung) | Mehrere Zeichnungen parallel; isolierte Räume; horizontale Skalierung möglich |
|
||||||
|
|
||||||
|
### 4.4 Sicherheit
|
||||||
|
|
||||||
|
| ID | Anforderung | Akzeptanzkriterium |
|
||||||
|
|---|---|---|
|
||||||
|
| NF-SEC-01 | **Authentifizierung:** Nutzer müssen sich authentifizieren (OAuth2/OIDC oder E-Mail/Passwort) | Login funktioniert; Sessions sind sicher (HTTP-only Cookies, CSRF-Schutz) |
|
||||||
|
| NF-SEC-02 | **Autorisierung:** Rollenbasierte Zugriffskontrolle für Projekte und Aktionen | Berechtigungen werden server-seitig enforced |
|
||||||
|
| NF-SEC-03 | **Daten-Transport:** HTTPS/WSS für gesamte Kommunikation | TLS für alle Verbindungen; keine unverschlüsselte Kommunikation |
|
||||||
|
| NF-SEC-04 | **Input-Validierung:** Import-Dateien werden validiert (DXF, SVG, PDF) | Maliciöse Dateien werden abgewiesen; Schema-Validierung erfolgt vor Verarbeitung |
|
||||||
|
|
||||||
|
### 4.5 Deployment
|
||||||
|
|
||||||
|
| ID | Anforderung | Akzeptanzkriterium |
|
||||||
|
|---|---|---|
|
||||||
|
| NF-DEP-01 | **Docker-Deployment:** Anwendung kann in Docker-Containern betrieben werden | Dockerfile und docker-compose.yml vorhanden und funktionsfähig |
|
||||||
|
| NF-DEP-02 | **Coolify-Deployment:** Deployment via Coolify ist möglich | Coolify-kompatible Konfiguration vorhanden; Deployment-Dokumentation erstellt |
|
||||||
|
| NF-DEP-03 | **Umgebungs-Konfiguration:** Konfiguration über Umgebungsvariablen | Keine fest codierten Credentials; alle Secrets über Env-Variablen |
|
||||||
|
| NF-DEP-04 | **Datenbank:** Persistente Datenhaltung für Projekte, Nutzer, Bibliothek | Datenbank-Schema definiert; Migration-Skripte vorhanden |
|
||||||
|
|
||||||
|
### 4.6 Plattform & Browser-Kompatibilität
|
||||||
|
|
||||||
|
| ID | Anforderung | Akzeptanzkriterium |
|
||||||
|
|---|---|---|
|
||||||
|
| NF-PLAT-01 | **Browser-Unterstützung:** Chrome, Firefox, Edge, Safari ( aktuelle Versionen) | Anwendung läuft in allen genannten Browsern; keine Browser-spezifischen Fehler |
|
||||||
|
| NF-PLAT-02 | **WebAssembly-Unterstützung:** Browser muss WebAssembly unterstützen | Fallback-Strategie für Browser ohne WebAssembly oder klare Fehlermeldung |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Annahmen (Assumptions)
|
||||||
|
|
||||||
|
1. **Primär Desktop:** Die Anwendung wird primär auf Desktop-Geräten (≥1280px Breite) verwendet; mobile Unterstützung ist nicht Teil der initialen Version.
|
||||||
|
2. **Deutsche UI:** Die Benutzeroberfläche wird primär auf Deutsch entwickelt; Mehrsprachigkeit kann später hinzugefügt werden.
|
||||||
|
3. **Keine 3D:** Die Anwendung ist rein 2D; keine 3D-Modellierung oder 3D-Ansicht.
|
||||||
|
4. **DXF als primäres Austauschformat:** DXF ist das wichtigste Import/Export-Format für CAD-Interoperabilität; DWG ist optional und von Library-Verfügbarkeit abhängig.
|
||||||
|
5. **CRDT als Synchronisations-Standard:** CRDT (Yjs oder ähnlich) wird als Standard für Echtzeit-Kollaboration angenommen; OT wird nicht verwendet.
|
||||||
|
6. **Canvas 2D als primärer Renderer:** Canvas 2D mit räumlichem Index wird als primäre Rendering-Technologie angenommen; WebGL ist optional.
|
||||||
|
7. **Einzelner Server-Deployment:** Initiales Deployment ist ein einzelner Server (Docker/Coolify); horizontale Skalierung ist später möglich.
|
||||||
|
8. **SVG-Import als Bibliothekserweiterung:** SVG-Dateien werden primär für die Bibliothek importiert; SVG als Projekt-Import ist sekundär.
|
||||||
|
9. **Möglichst Open Source:** Soweit möglich sollen Open-Source-Bibliotheken verwendet werden, um Lizenzkosten zu minimieren.
|
||||||
|
10. **Bestuhlungs-Stühle als Bibliothek-Blöcke:** Stühle für Bestuhlungs-Tools werden als Blöcke in der Bibliothek definiert, nicht fest codiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Non-Goals (Nicht im Scope der initialen Version)
|
||||||
|
|
||||||
|
1. **Keine 3D-Modellierung** – rein 2D; keine 3D-Ansicht, keine Höhen, keine 3D-Renderings.
|
||||||
|
2. **Keine mobile App oder mobile-optimierte UI** – Desktop first; mobile wird erst bei Bedarf addressed.
|
||||||
|
3. **Keine lokalen Druck-/Plot-Konfigurationen** – PDF-Export ersetzt direktes Drucken in der ersten Version.
|
||||||
|
4. **Keine AutoCAD-LISP-/Macro-Skripting-Engine** – keine benutzerdefinierten Skripte in LISP/AutoLISP.
|
||||||
|
5. **Keine parametrische Modellierung** – keine Constraint-basierte Geometrie oder parametrischen Relationen.
|
||||||
|
6. **Keine BIM-Integration** – keine IFC-Importe, keine BIM-Datenmodellierung.
|
||||||
|
7. **Keine Desktop-Installation** – rein web-basiert; keine Electron-/NW.js-Desktop-App.
|
||||||
|
8. **Keine KI-gestützten Funktionen** – keine automatische Bestuhlungsvorschläge oder KI-Features in der ersten Version.
|
||||||
|
9. **Keine Multi-Tenant-Isolierung auf Organisationsebene** – initiale Version ist Single-Tenant; Multi-Tenant später.
|
||||||
|
10. **Keine erweiterten Druck-Layouts** – keine Plot-Styles, Linienstärken-Tabellen oder Print-Studio-Konfigurationen.
|
||||||
|
11. **Keine Video-/Audio-Kommunikation** – keine integrierte Video- oder Audio-Calls zwischen Nutzern.
|
||||||
|
12. **Keine automatische Flächenberechnung / Mengenermittlung** – in der ersten Version nicht enthalten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Offene Fragen
|
||||||
|
|
||||||
|
| # | Frage | Priorität | Auswirkung bei Nicht-Beantwortung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Q-01 | Soll die Anwendung Open Source oder Closed Source sein? Welche Lizenz? | Hoch | Lizenzierung, Bibliotheksauswahl |
|
||||||
|
| Q-02 | Welche Authentifizierungsmethode wird bevorzugt (E-Mail/Passwort, OAuth2/OIDC, SSO)? | Hoch | Architektur, Security-Design |
|
||||||
|
| Q-03 | Welche Datenbank soll verwendet werden (PostgreSQL, SQLite, MongoDB)? | Mittel | Persistenz-Design, Deployment |
|
||||||
|
| Q-04 | Gibt es eine erwartete Nutzerzahl / Anzahl gleichzeitiger Kollaborateure pro Zeichnung? | Mittel | Skalierungs-Architektur, WebSocket-Server-Design |
|
||||||
|
| Q-05 | Soll DWG-Import unterstützt werden? Wenn ja, welche DWG-Versionen? | Mittel | Library-Auswahl, Lizenzkosten (ODA/Teigha) |
|
||||||
|
| Q-06 | Welche Stuhl-Typen / Bibliothekseinträge werden initial benötigt? | Mittel | Initial-Bibliothek, Bestuhlungs-Tool-Design |
|
||||||
|
| Q-07 | Sollen Bestuhlungs-Tools weitere Event-Elemente unterstützen (Tische, Bühnen, Absperrungen)? | Mittel | Tool-Scope, Bibliothek |
|
||||||
|
| Q-08 | Ist eine Kommentar-/Annotations-Funktion für Kollaborateure gewünscht? | Niedrig | UI-Design, Kollaborations-Features |
|
||||||
|
| Q-09 | Sollen Projekte in Ordnern/Projektgruppen organisiert werden können? | Niedrig | Projekt-Management-UI |
|
||||||
|
| Q-10 | Gibt es Anforderungen an Barrierefreiheit (WCAG 2.1 AA)? | Niedrig | UI-Design, Testing-Aufwand |
|
||||||
|
| Q-11 | Soll es ein API für externe Integrationen geben (REST/GraphQL)? | Niedrig | Architektur-Erweiterung |
|
||||||
|
| Q-12 | Welche Maßeinheiten sollen unterstützt werden (metrisch, imperial, beides)? | Niedrig | Bemaßungs-Logic, UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Technologie-Recherche & Empfehlungen
|
||||||
|
|
||||||
|
### 8.1 Rendering-Technologie
|
||||||
|
|
||||||
|
**Recherche-Ergebnisse (Stand 2025/2026):**
|
||||||
|
|
||||||
|
- **SVG:** Gut für einfache Grafiken; DOM-Overhead bei > 3.000-5.000 Elementen → Performance-Einbruch. **Nicht geeignet für CAD mit großen Zeichnungen.**
|
||||||
|
- **Canvas 2D:** Kann 50.000+ Elemente bei 60fps rendern, wenn räumliche Indexierung (Quadtree/R-Tree) und Layer-basiertes Culling eingesetzt werden. **Empfohlen als primärer Renderer.**
|
||||||
|
- **WebGL:** Höchste Performance für sehr große Szenen (> 100k Elemente); GPU-Beschleunigung; jedoch höherer Entwicklungs-Aufwand. **Als optionale Hybrid-Schicht reservieren.**
|
||||||
|
- **WebAssembly:** Nahe-native Performance für Berechnungen (DXF-Parsing, geometrische Operationen). **Empfohlen für rechenintensive Aufgaben.**
|
||||||
|
|
||||||
|
**Quellen:**
|
||||||
|
- SVG Genie Blog: SVG vs Canvas vs WebGL Performance Comparison (2026)
|
||||||
|
- AlterSquare: WebGL vs Canvas for Browser-Based CAD Tools
|
||||||
|
- Medium (@codetip.top): SVG vs Canvas vs WebGL for Diagram Viewers
|
||||||
|
- PMC: Cross-Device Benchmark of Modern Web Animation Systems
|
||||||
|
|
||||||
|
### 8.2 Multi-User Kollaboration
|
||||||
|
|
||||||
|
**Recherche-Ergebnisse (Stand 2025/2026):**
|
||||||
|
|
||||||
|
- **CRDT (Conflict-free Replicated Data Types):** Moderner Standard für kollaborative Anwendungen; verwendet von Figma und vielen modernen Apps; leichter korrekt zu implementieren als OT.
|
||||||
|
- **Yjs:** Beliebteste CRDT-Bibliothek; unterstützt Text, Arrays, Maps, XML; WebSocket- und WebRTC-Bindings vorhanden.
|
||||||
|
- **Automerge:** Alternative CRDT-Bibliothek; ähnliche Features.
|
||||||
|
- **OT (Operational Transformation):** Älterer Ansatz (Google Docs); komplexer korrekt zu implementieren; erfordert zentrale Server-Logik.
|
||||||
|
- **Transport:** WebSocket für Dokument-Synchronisation; WebRTC für Cursor-Positionen (niedrigere Latenz).
|
||||||
|
- **Persistenz:** Server-seitige Speicherung der CRDT-Dokumente für Single Source of Truth und Versionshistorie.
|
||||||
|
|
||||||
|
**Empfehlung:** CRDT (Yjs) über WebSocket als primäre Kollaborations-Architektur.
|
||||||
|
|
||||||
|
**Quellen:**
|
||||||
|
- Medium (toonsquare.tech): Real-Time Collaborative Editor with CRDT and Durable Objects
|
||||||
|
- Velt Blog: OT vs CRDT in 2026; Yjs WebSocket Server Guide
|
||||||
|
- Daydreamsoft: Real-Time Collaboration Using CRDTs and OT
|
||||||
|
- Onshape: Cloud-native CAD Collaboration
|
||||||
|
|
||||||
|
### 8.3 Architektur-Referenzen
|
||||||
|
|
||||||
|
- **AutoCAD Web:** Browser-basierte CAD-Oberfläche mit Ribbon-UI, DWG-Viewing, Markup-Tools.
|
||||||
|
- **Onshape:** Cloud-native CAD mit echter Multi-User-Kollaboration; Single Source of Truth; keine Datei-Kopien.
|
||||||
|
- **Figma:** Browser-basierte Design-Tool mit CRDT-basierter Kollaboration; inspirierend für UI und Sync-Architektur.
|
||||||
|
- **xDraftSight:** Cloud-basierter 2D-CAD; browser-basierte Architektur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Abhängigkeiten & Risiken
|
||||||
|
|
||||||
|
| Risiko | Beschreibung | Mitigation |
|
||||||
|
|---|---|---|
|
||||||
|
| DXF/DWG-Kompatibilität | DXF-Parser in JavaScript/WebAssembly muss verschiedene DXF-Versionen korrekt verarbeiten | Open-Source-Library (dxf-parser) verwenden; Tests mit realen DXF-Dateien |
|
||||||
|
| Performance bei großen Zeichnungen | 50k+ Elemente können Browser überlasten | Canvas 2D mit räumlichem Index; Viewport-Culling; Virtualisierte Layer |
|
||||||
|
| Kollaborations-Komplexität | CRDT-Synchronisation für komplexe CAD-Daten ist nicht trivial | Yjs als bewährte Bibliothek; inkrementelle Implementierung; erst einfache Operationen, dann komplexe |
|
||||||
|
| Bibliotheks-Lizenzen | Einige CAD-Bibliotheken sind kommerziell (z. B. ODA für DWG) | Open-Source-Alternativen evaluieren; DWG als optional markieren |
|
||||||
|
| Browser-Speicherlimit | Sehr große Projekte können Browser-Speicherlimit überschreiten | Lazy-Loading; Kompression; IndexedDB für Persistenz |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Deployment-Erwartungen
|
||||||
|
|
||||||
|
| Aspekt | Erwartung |
|
||||||
|
|---|---|
|
||||||
|
| **Containerisierung** | Docker (Dockerfile + docker-compose.yml) |
|
||||||
|
| **Orchestrierung** | Coolify auf coolify-01 (46.225.91.159) |
|
||||||
|
| **Domain** | TBD – z. B. cad.media-on.de |
|
||||||
|
| **SSL** | Let's Encrypt via Coolify/Traefik |
|
||||||
|
| **Datenbank** | PostgreSQL (empfohlen) oder SQLite (für kleinere Deployments) |
|
||||||
|
| **WebSocket-Server** | Separater Container oder integriert; WebSocket-Proxy via Traefik |
|
||||||
|
| **Persistenz** | Docker-Volume für Datenbank und Datei-Storage (Grundrisse, Bibliothek) |
|
||||||
|
| **Skalierung** | Initiale Version: Einzelner Server; später horizontal skalierbar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Handoff
|
||||||
|
|
||||||
|
### Requirements Status
|
||||||
|
- **Draft erstellt:** Ja (2026-06-19)
|
||||||
|
- **Testbare Anforderungen:** Ja – alle funktionalen und nicht-funktionalen Anforderungen haben konkrete Akzeptanzkriterien
|
||||||
|
- **Akzeptanzkriterien konkret:** Ja
|
||||||
|
- **Annahmen explizit:** Ja (10 Annahmen dokumentiert)
|
||||||
|
- **Non-Goals dokumentiert:** Ja (12 Non-Goals)
|
||||||
|
- **Offene Fragen:** 12 (davon 2 hoch, 3 mittel, 7 niedrig priorisiert)
|
||||||
|
|
||||||
|
### Ready for Architecture
|
||||||
|
- **Bedingt bereit** – nach Beantwortung von Q-01 (Lizenz) und Q-02 (Authentifizierung) kann die Architektur-Phase starten
|
||||||
|
- Die technologischen Empfehlungen (Canvas 2D + CRDT/Yjs) sind bereits fundiert recherchiert und können als Architektur-Vorgaben dienen
|
||||||
|
|
||||||
|
### Empfohlene nächste Schritte
|
||||||
|
1. User-Review der requirements.md
|
||||||
|
2. Beantwortung der hoch-priorisierten offenen Fragen (Q-01, Q-02)
|
||||||
|
3. Freigabe für Architektur-Phase (Phase 2)
|
||||||
|
4. Delegation an Solution Architect
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
# HMS Licht & Ton – UI Design Dokumentation
|
|
||||||
|
|
||||||
## 1. Projekt-Überblick
|
|
||||||
|
|
||||||
**Projekt:** Neue Homepage für HMS Licht & Ton GbR, Veranstaltungstechnik aus Leipheim/Ellzee
|
|
||||||
**Domain:** hms.media-on.de (nicht öffentlich indexiert)
|
|
||||||
**Technologie:** Nuxt 3 (Vue 3 + Tailwind CSS, SSR/SSG) + FastAPI Backend + Coolify Deployment
|
|
||||||
|
|
||||||
## 2. Design-Philosophie
|
|
||||||
|
|
||||||
Das Design folgt einer **dunklen, professionellen Ästhetik** orientiert an den Agent Zero WebUI Grautönen. Orange (#EC6925) aus dem HMS-Logo wird **dezent als Akzentfarbe** eingesetzt – ausschließlich für Buttons, Badges, kleine Text-Highlights und den Logo-Rahmen. Keine großen Orange-Flächen.
|
|
||||||
|
|
||||||
**Tonalität:** Professionell, erfahren, zuverlässig, technisch kompetent – nicht werblich oder übertrieben.
|
|
||||||
|
|
||||||
## 3. Design Tokens
|
|
||||||
|
|
||||||
### 3.1 Farbpalette
|
|
||||||
|
|
||||||
#### Agent Zero Dark Theme (Hauptfarben)
|
|
||||||
| Token | Hex | Verwendung |
|
|
||||||
|-------|-----|-----------|
|
|
||||||
| `--bg` | `#131313` | Seiten-Hintergrund |
|
|
||||||
| `--panel` | `#1a1a1a` | Panels, Cards, Header |
|
|
||||||
| `--surface` | `#212121` | Input-Felder, Surface-Level |
|
|
||||||
| `--row` | `#272727` | List-Items, Zwischenebenen |
|
|
||||||
| `--card` | `#2d2d2d` | Card-Hintergrund |
|
|
||||||
| `--border` | `#444444a8` | Borders (mit Alpha) |
|
|
||||||
| `--secondary` | `#656565` | Sekundärfarbe, Icons |
|
|
||||||
| `--primary` | `#737a81` | Primärfarbe (leicht bläuliches Grau) |
|
|
||||||
| `--text` | `#ffffff` | Haupttext |
|
|
||||||
| `--text-muted` | `#d4d4d4` | Gedimmter Text |
|
|
||||||
|
|
||||||
#### Orange Akzent (Dezent)
|
|
||||||
| Token | Wert | Verwendung |
|
|
||||||
|-------|------|-----------|
|
|
||||||
| `--color-accent` | `#EC6925` | Buttons, Badges, Text-Akzente, Logo-Rahmen |
|
|
||||||
| `--color-accent-hover` | `#d4581a` | Button Hover |
|
|
||||||
| `--color-accent-light` | `rgba(236, 105, 37, 0.08)` | Badge-Hintergrund, Nav-Active |
|
|
||||||
| `--color-accent-border` | `rgba(236, 105, 37, 0.25)` | Card Hover Border |
|
|
||||||
| `--color-accent-dark` | `#b8461a` | Dunkle Variante |
|
|
||||||
|
|
||||||
#### Status-Farben (Dark Theme adaptiert)
|
|
||||||
| Token | Hex | Verwendung |
|
|
||||||
|-------|-----|-----------|
|
|
||||||
| `--color-success` | `#4ade80` | Erfolg-Meldungen |
|
|
||||||
| `--color-success-bg` | `rgba(74, 222, 128, 0.08)` | Erfolg-Hintergrund |
|
|
||||||
| `--color-error` | `#f87171` | Fehler-Meldungen, Pflichtfelder |
|
|
||||||
| `--color-error-bg` | `rgba(248, 113, 113, 0.08)` | Fehler-Hintergrund |
|
|
||||||
| `--color-warning` | `#fbbf24` | Warnungen |
|
|
||||||
| `--color-info` | `#60a5fa` | Info-Meldungen |
|
|
||||||
|
|
||||||
### 3.2 Border Radius (Minimal)
|
|
||||||
| Token | Wert |
|
|
||||||
|-------|------|
|
|
||||||
| `--radius-sm` | `2px` |
|
|
||||||
| `--radius-md` | `3px` |
|
|
||||||
| `--radius-lg` | `4px` |
|
|
||||||
| `--radius-xl` | `4px` |
|
|
||||||
| `--radius-full` | `9999px` (Badges, Pills) |
|
|
||||||
|
|
||||||
### 3.3 Typography
|
|
||||||
- **Font Family:** Inter (Google Fonts)
|
|
||||||
- **Weights:** 400 (Regular), 500 (Medium), 600 (Semibold), 700 (Bold), 800 (Extrabold)
|
|
||||||
- **Größen:** xs: 0.75rem bis 6xl: 3.75rem
|
|
||||||
- **Line Height:** 1.6 (Body), 1.1-1.3 (Headings)
|
|
||||||
|
|
||||||
### 3.4 Spacing
|
|
||||||
| Token | Wert |
|
|
||||||
|-------|------|
|
|
||||||
| `--space-xs` | 0.25rem (4px) |
|
|
||||||
| `--space-sm` | 0.5rem (8px) |
|
|
||||||
| `--space-md` | 1rem (16px) |
|
|
||||||
| `--space-lg` | 1.5rem (24px) |
|
|
||||||
| `--space-xl` | 2rem (32px) |
|
|
||||||
| `--space-2xl` | 3rem (48px) |
|
|
||||||
| `--space-3xl` | 4rem (64px) |
|
|
||||||
| `--space-4xl` | 6rem (96px) |
|
|
||||||
|
|
||||||
### 3.5 Shadows (Dark Theme)
|
|
||||||
| Token | Wert |
|
|
||||||
|-------|------|
|
|
||||||
| `--shadow-sm` | `0 1px 2px 0 rgb(0 0 0 / 0.3)` |
|
|
||||||
| `--shadow-md` | `0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3)` |
|
|
||||||
| `--shadow-lg` | `0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)` |
|
|
||||||
| `--shadow-xl` | `0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.3)` |
|
|
||||||
|
|
||||||
### 3.6 Transitions
|
|
||||||
| Token | Dauer |
|
|
||||||
|-------|-------|
|
|
||||||
| `--transition-fast` | 150ms ease |
|
|
||||||
| `--transition-base` | 250ms ease |
|
|
||||||
| `--transition-slow` | 400ms ease |
|
|
||||||
|
|
||||||
## 4. Layout-System
|
|
||||||
|
|
||||||
### 4.1 Header
|
|
||||||
- Sticky, Höhe: 4rem (Desktop), 3.5rem (Mobile)
|
|
||||||
- Logo links, Desktop-Nav mittig/rechts, Mobile Hamburger rechts
|
|
||||||
- Background: `var(--panel)`, Border-Bottom: `var(--border)`
|
|
||||||
|
|
||||||
### 4.2 Main Content
|
|
||||||
- Max-Width: 1280px (`max-w-7xl`)
|
|
||||||
- Padding: 1rem (Mobile), 1.5rem (sm), 2rem (lg)
|
|
||||||
|
|
||||||
### 4.3 Footer
|
|
||||||
- 4-Spalten Grid (1-Spalte auf Mobile)
|
|
||||||
- Background: `var(--bg)`, Border-Top: `var(--border)`
|
|
||||||
|
|
||||||
### 4.4 Responsive Breakpoints
|
|
||||||
- **sm:** 640px (Tablet Portrait)
|
|
||||||
- **md:** 768px (Tablet Landscape, Desktop Nav visible)
|
|
||||||
- **lg:** 1024px (Desktop, 3-4 Column Grids)
|
|
||||||
- **Mobile-First:** Alle Layouts starten mit 1-Spalte, expandieren bei Breakpoints
|
|
||||||
|
|
||||||
## 5. Seiten-Struktur (Sitemap)
|
|
||||||
|
|
||||||
```
|
|
||||||
Home (#/)
|
|
||||||
Referenzen (#/referenzen)
|
|
||||||
Mietkatalog (#/mietkatalog)
|
|
||||||
Equipment Detail (#/mietkatalog/:id)
|
|
||||||
Warenkorb (#/warenkorb)
|
|
||||||
Kontakt (#/kontakt)
|
|
||||||
Admin (#/admin)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Komponenten-Design
|
|
||||||
|
|
||||||
### 6.1 Cards
|
|
||||||
- Background: `var(--panel)`
|
|
||||||
- Border: `1px solid var(--border)`
|
|
||||||
- Border-Radius: `4px` (--radius-lg)
|
|
||||||
- Hover: Border wird heller, Shadow wird stärker
|
|
||||||
|
|
||||||
### 6.2 Buttons
|
|
||||||
- **Primary:** Orange bg, white text, 3px radius
|
|
||||||
- **Secondary:** Surface bg, muted text, border, 3px radius
|
|
||||||
- **Ghost:** Transparent, muted text, hover surface bg
|
|
||||||
|
|
||||||
### 6.3 Inputs
|
|
||||||
- Background: `var(--surface)`
|
|
||||||
- Border: `1px solid var(--border-strong)` (#555555)
|
|
||||||
- Focus: Orange border + subtle orange box-shadow
|
|
||||||
- Error: Red border + red box-shadow
|
|
||||||
|
|
||||||
### 6.4 Badges
|
|
||||||
- Border-Radius: `9999px` (pill shape)
|
|
||||||
- Primary: Orange bg (8% alpha), orange text, orange border (20% alpha)
|
|
||||||
|
|
||||||
### 6.5 Speaker Grid
|
|
||||||
- CSS Grid: `repeat(auto-fill, minmax(160px, 1fr))`
|
|
||||||
- Mobile: 2 columns
|
|
||||||
- Icons: 48x48px SVG, gray default, orange on hover
|
|
||||||
|
|
||||||
## 7. UX States
|
|
||||||
|
|
||||||
Alle Views implementieren Loading, Empty, Error und Success States:
|
|
||||||
- **Loading:** Skeleton-Shimmer Animationen
|
|
||||||
- **Empty:** Icon + Titel + Message + CTA Button
|
|
||||||
- **Error:** Alert-Icon + Titel + Message + Retry Button
|
|
||||||
- **Success:** Checkmark + Bestätigungstext + Weiter-Button
|
|
||||||
|
|
||||||
## 8. Accessibility (WCAG 2.1 AA)
|
|
||||||
|
|
||||||
- **Skip Link:** Zum Hauptinhalt springen am Seitenanfang
|
|
||||||
- **Semantic HTML:** header, main, footer, nav, section, article
|
|
||||||
- **ARIA:** aria-label, aria-current, aria-expanded, aria-controls, aria-invalid, aria-describedby, role=alert, role=status
|
|
||||||
- **Focus Visible:** 2px Orange Outline mit 2px Offset
|
|
||||||
- **Form Labels:** Jedes Input hat label for=..., Pflichtfelder markiert mit *
|
|
||||||
- **Heading Hierarchy:** h1 > h2 > h3, keine Levels übersprungen
|
|
||||||
- **Keyboard Navigation:** Alle interaktiven Elemente per Tab erreichbar, Enter auf Cards
|
|
||||||
- **Color Contrast:** Text #ffffff auf #131313 = 17.9:1 (AAA), Muted #d4d4d4 auf #131313 = 12.6:1 (AAA)
|
|
||||||
|
|
||||||
## 9. KI-/SEO-Features
|
|
||||||
|
|
||||||
- **robots:** noindex, nofollow, noarchive, nosnippet
|
|
||||||
- **JSON-LD:** LocalBusiness Schema mit Adresse, Öffnungszeiten, Services (Schema.org)
|
|
||||||
- **OpenGraph:** type, title, description, locale, site_name, url
|
|
||||||
- **Twitter Card:** summary_large_image
|
|
||||||
- **Semantic HTML5:** Korrekte Struktur für KI-Crawler
|
|
||||||
|
|
||||||
## 10. Bilder
|
|
||||||
|
|
||||||
- **Hero:** Unsplash Konzert-Bild (background-image, 35% opacity, dark gradient overlay)
|
|
||||||
- **Über uns:** Unsplash Veranstaltungstechnik-Setup Bild
|
|
||||||
- **Referenzen:** 9 echte HMS-Fotos (von hms-licht-ton.de, lokal auf Webspace gespeichert)
|
|
||||||
- **Equipment:** Platzhalter-Icons, Felder für echte Bilder vorbereitet (item.image)
|
|
||||||
- **Speaker Grid:** SVG-Icons (6 Lautsprecher-Typen)
|
|
||||||
|
|
||||||
## 11. Prototyp-URLs
|
|
||||||
|
|
||||||
- **Aktuell:** https://webspace.media-on.de/hms-prototype/index.html?v=8
|
|
||||||
- **Dateien:** index.html, style.css, app.js, logo.svg, img/ref1-9.jpg
|
|
||||||
|
|
||||||
## 12. Design-Entscheidungen
|
|
||||||
|
|
||||||
| Entscheidung | Begründung |
|
|
||||||
|-------------|------------|
|
|
||||||
| Dark Theme (Agent Zero Grautöne) | User-Präferenz, professionelle Wirkung |
|
|
||||||
| Orange nur als Akzent | User-Feedback: Übergänge zu Orange sollten subtil sein |
|
|
||||||
| Minimale Border-Radius (2-4px) | User-Feedback: abgerundete Ecken nur ganz minimal |
|
|
||||||
| Vue 3 SPA für Prototyp | Gleiche Framework wie Produktion (Nuxt 3) |
|
|
||||||
| Echte HMS-Fotos für Referenzen | Authentizität, keine Stock-Fotos |
|
|
||||||
| SVG-Icons für Speaker Grid | Kompakt, skalierbar, theme-bar |
|
|
||||||
| Inter Font | Modern, clean, gute Lesbarkeit |
|
|
||||||
| noindex meta | Domain hms.media-on.de nicht öffentlich |
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dmVyc2lvbjogJzMuOCcKCnNlcnZpY2VzOgogIGJhY2tlbmQ6CiAgICBidWlsZDogLi9iYWNrZW5kCiAgICBwb3J0czoKICAgICAgLSAiNTAwMDo1MDAwIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIEpXVF9TRUNSRVQ9ZjJiMjI3MWU1OWJmY2MxNWMxZjE3MzI4YjUxMGRiMDg2NjIwNTJjZGRhMDJkYjk4NWM5NDhkOWY1MzJhMjgyMgogICAgdm9sdW1lczoKICAgICAgLSBzcWxpdGVfZGF0YTovYXBwL2RhdGEKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogWyJDTUQtU0hFTEwiLCAid2dldCAtcU8tIGh0dHA6Ly9sb2NhbGhvc3Q6NTAwMC9hcGkvaGVhbHRoIHx8IGV4aXQgMSJdCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQoKICBmcm9udGVuZDoKICAgIGJ1aWxkOiAuL2Zyb250ZW5kCiAgICBleHBvc2U6CiAgICAgIC0gIjgwIgogICAgZGVwZW5kc19vbjoKICAgICAgLSBiYWNrZW5kCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6IFsiQ01ELVNIRUxMIiwgIndnZXQgLXFPLSBodHRwOi8vbG9jYWxob3N0OjgwIHx8IGV4aXQgMSJdCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQoKdm9sdW1lczoKICBzcWxpdGVfZGF0YToK
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.git
|
|
||||||
*.md
|
|
||||||
*.log
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
+13
-3
@@ -1,10 +1,20 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
|
||||||
RUN npm install --legacy-peer-deps
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
+5
-4
@@ -1,12 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Web CAD</title>
|
<title>frontend</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+14
-13
@@ -1,24 +1,25 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
resolver 127.0.0.11 valid=30s;
|
# Serve static frontend
|
||||||
|
location / {
|
||||||
location / { try_files $uri $uri/ /index.html; }
|
try_files $uri /index.html;
|
||||||
|
|
||||||
location /api/ {
|
|
||||||
set $backend http://backend:3001;
|
|
||||||
proxy_pass $backend;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ws {
|
# Proxy API requests to backend
|
||||||
set $backend http://backend:3001;
|
location /api/ {
|
||||||
proxy_pass $backend;
|
proxy_pass http://backend:5000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2215
-1869
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user