Clean working version: deployed TypeScript backend + JSX frontend

- Replace old JS backend with working TypeScript backend from deployed containers
- Backend: Fastify + better-sqlite3 + Yjs CRDT, 17 TS source files, 13 test files
- Frontend: React JSX with fabric.js CAD canvas, vite build, nginx serving
- Fix nginx proxy port 5000→3001 to match backend
- Fix docker-compose port mapping 5000→3001
- Fix vite dev proxy port 5000→3001
- Add backend healthcheck to docker-compose
- Update index.html title to 'Web CAD', lang to 'de'
- Add .a0/ to .gitignore
- Remove old JS backend files (models, routes, config, middleware, seed)
- Remove tracked build artifacts (backend/public/)
This commit is contained in:
Agent Zero
2026-06-28 01:24:31 +02:00
parent 249d5fbdb6
commit 8fa6f795c0
60 changed files with 8988 additions and 3708 deletions
+1
View File
@@ -4,3 +4,4 @@ dist/
*.sqlite *.sqlite
*.db *.db
.DS_Store .DS_Store
.a0/
+7 -11
View File
@@ -1,13 +1,9 @@
FROM node:22-alpine FROM node:20-alpine
RUN apk add --no-cache curl
WORKDIR /app WORKDIR /app
RUN apk add --no-cache wget
COPY package*.json ./ COPY package.json package-lock.json* ./
RUN npm ci --only=production RUN npm install
COPY . . COPY . .
RUN npm run build
EXPOSE 5000 EXPOSE 3001
CMD ["node", "dist/index.js"]
CMD ["node", "server.js"]
-27
View File
@@ -1,27 +0,0 @@
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 };
-23
View File
@@ -1,23 +0,0 @@
const passport = require('passport');
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
const User = require('../models/User');
const opts = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET || 'dev_secret_change_in_production',
};
passport.use(new JwtStrategy(opts, async (jwt_payload, done) => {
try {
const user = await User.findByPk(jwt_payload.id);
if (user) {
return done(null, user);
}
return done(null, false);
} catch (err) {
return done(err, false);
}
}));
module.exports = passport;
-6
View File
@@ -1,6 +0,0 @@
const passport = require('passport');
// Protect routes with JWT
const authenticate = passport.authenticate('jwt', { session: false });
module.exports = authenticate;
-33
View File
@@ -1,33 +0,0 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/database');
const User = require('./User');
const Block = sequelize.define('Block', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
svg_data: {
type: DataTypes.TEXT,
allowNull: false,
},
category: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: 'Allgemein',
},
is_public: {
type: DataTypes.BOOLEAN,
defaultValue: true,
},
});
Block.belongsTo(User, { foreignKey: 'userId', allowNull: true, onDelete: 'SET NULL' });
User.hasMany(Block, { foreignKey: 'userId' });
module.exports = Block;
-29
View File
@@ -1,29 +0,0 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/database');
const User = require('./User');
const Drawing = sequelize.define('Drawing', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'Unbenannt',
},
canvas_json: {
type: DataTypes.TEXT,
allowNull: true,
},
thumbnail: {
type: DataTypes.TEXT,
allowNull: true,
},
});
Drawing.belongsTo(User, { foreignKey: 'userId', onDelete: 'CASCADE' });
User.hasMany(Drawing, { foreignKey: 'userId' });
module.exports = Drawing;
-31
View File
@@ -1,31 +0,0 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/database');
const bcrypt = require('bcryptjs');
const User = sequelize.define('User', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
email: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
validate: { isEmail: true },
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
});
User.beforeCreate(async (user) => {
user.password = await bcrypt.hash(user.password, 10);
});
User.prototype.validatePassword = async function (password) {
return bcrypt.compare(password, this.password);
};
module.exports = User;
-2031
View File
File diff suppressed because it is too large Load Diff
+26 -20
View File
@@ -1,25 +1,31 @@
{ {
"name": "backend", "name": "web-cad-backend",
"version": "1.0.0", "version": "0.1.0",
"description": "", "private": true,
"main": "index.js", "type": "module",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "dev": "tsx watch src/index.ts",
"build": "tsc",
"postbuild": "cp src/database/schema.sql dist/database/schema.sql",
"start": "node dist/index.js",
"test": "vitest run"
}, },
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "@fastify/cors": "^9.0.0",
"cors": "^2.8.6", "@fastify/static": "^7.0.0",
"dxf-parser": "^1.1.2", "@fastify/websocket": "^10.0.0",
"express": "^5.2.1", "bcrypt": "^6.0.0",
"jsonwebtoken": "^9.0.3", "better-sqlite3": "^11.0.0",
"multer": "^2.1.1", "fastify": "^4.28.0",
"passport": "^0.7.0", "y-leveldb": "^0.2.0",
"passport-jwt": "^4.0.1", "yjs": "^13.6.0"
"passport-local": "^1.0.0", },
"pg": "^8.21.0", "devDependencies": {
"sequelize": "^6.37.8" "@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^20.14.0",
"tsx": "^4.16.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
} }
} }
@@ -1,6 +0,0 @@
{
"name": "Dimension Tool",
"version": "1.0.0",
"description": "Fügt ein Bemaßungswerkzeug für horizontale und vertikale Messungen hinzu.",
"author": "Agent Zero"
}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
*{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

Before

Width:  |  Height:  |  Size: 9.3 KiB

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

Before

Width:  |  Height:  |  Size: 4.9 KiB

-14
View File
@@ -1,14 +0,0 @@
<!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>
-53
View File
@@ -1,53 +0,0 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const authenticate = require('../middleware/auth');
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_change_in_production';
// Register
router.post('/register', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
const existing = await User.findOne({ where: { email } });
if (existing) {
return res.status(409).json({ error: 'User already exists' });
}
const user = await User.create({ email, password });
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' });
res.status(201).json({ token, user: { id: user.id, email: user.email } });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Registration failed' });
}
});
// Login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
const user = await User.findOne({ where: { email } });
if (!user || !(await user.validatePassword(password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' });
res.json({ token, user: { id: user.id, email: user.email } });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Login failed' });
}
});
// Get current user
router.get('/me', authenticate, async (req, res) => {
res.json({ id: req.user.id, email: req.user.email });
});
module.exports = router;
-59
View File
@@ -1,59 +0,0 @@
const express = require('express');
const authenticate = require('../middleware/auth');
const Block = require('../models/Block');
const router = express.Router();
// Public blocks (no auth for listing)
router.get('/', async (req, res) => {
try {
const blocks = await Block.findAll({
where: { is_public: true },
attributes: ['id', 'name', 'svg_data', 'category'],
});
res.json(blocks);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to list blocks' });
}
});
// Protected: create a new block (requires auth)
router.post('/', authenticate, async (req, res) => {
try {
const { name, svg_data, category, is_public } = req.body;
if (!name || !svg_data) {
return res.status(400).json({ error: 'Name and svg_data required' });
}
const block = await Block.create({
name,
svg_data,
category: category || 'Allgemein',
is_public: is_public !== undefined ? is_public : true,
userId: req.user.id,
});
res.status(201).json(block);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to create block' });
}
});
// Protected: delete block (only owner)
router.delete('/:id', authenticate, async (req, res) => {
try {
const block = await Block.findOne({
where: { id: req.params.id, userId: req.user.id },
});
if (!block) {
return res.status(404).json({ error: 'Block not found or not authorized' });
}
await block.destroy();
res.json({ message: 'Block deleted' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to delete block' });
}
});
module.exports = router;
-94
View File
@@ -1,94 +0,0 @@
const express = require('express');
const authenticate = require('../middleware/auth');
const Drawing = require('../models/Drawing');
const router = express.Router();
// All routes require authentication
router.use(authenticate);
// List drawings for current user
router.get('/', async (req, res) => {
try {
const drawings = await Drawing.findAll({
where: { userId: req.user.id },
attributes: ['id', 'name', 'updatedAt'],
order: [['updatedAt', 'DESC']],
});
res.json(drawings);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to list drawings' });
}
});
// Create new drawing
router.post('/', async (req, res) => {
try {
const { name, canvas_json } = req.body;
const drawing = await Drawing.create({
name: name || 'Unbenannt',
canvas_json: canvas_json || '',
userId: req.user.id,
});
res.status(201).json(drawing);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to create drawing' });
}
});
// Get single drawing
router.get('/:id', async (req, res) => {
try {
const drawing = await Drawing.findOne({
where: { id: req.params.id, userId: req.user.id },
});
if (!drawing) {
return res.status(404).json({ error: 'Drawing not found' });
}
res.json(drawing);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to get drawing' });
}
});
// Update drawing
router.put('/:id', async (req, res) => {
try {
const drawing = await Drawing.findOne({
where: { id: req.params.id, userId: req.user.id },
});
if (!drawing) {
return res.status(404).json({ error: 'Drawing not found' });
}
const { name, canvas_json } = req.body;
if (name !== undefined) drawing.name = name;
if (canvas_json !== undefined) drawing.canvas_json = canvas_json;
await drawing.save();
res.json(drawing);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update drawing' });
}
});
// Delete drawing
router.delete('/:id', async (req, res) => {
try {
const drawing = await Drawing.findOne({
where: { id: req.params.id, userId: req.user.id },
});
if (!drawing) {
return res.status(404).json({ error: 'Drawing not found' });
}
await drawing.destroy();
res.json({ message: 'Drawing deleted' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to delete drawing' });
}
});
module.exports = router;
-101
View File
@@ -1,101 +0,0 @@
const express = require('express');
const authenticate = require('../middleware/auth');
const multer = require('multer');
const { parseString } = require('dxf-parser');
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
// Upload DXF file -> parse and return JSON representation (Fabric.js compatible)
router.post('/import', authenticate, upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const dxfContent = req.file.buffer.toString('utf-8');
console.log('DXF file size:', dxfContent.length);
const parsed = parseString(dxfContent);
// Convert parsed entities to Fabric.js objects (simplified)
const fabricObjects = [];
if (parsed && parsed.entities) {
parsed.entities.forEach(entity => {
const type = entity.type;
if (type === 'LINE') {
fabricObjects.push({
type: 'line',
x1: entity.start.x, y1: entity.start.y,
x2: entity.end.x, y2: entity.end.y,
stroke: '#000', strokeWidth: 1,
});
} else if (type === 'CIRCLE') {
fabricObjects.push({
type: 'circle',
left: entity.center.x - entity.radius,
top: entity.center.y - entity.radius,
radius: entity.radius,
fill: 'transparent', stroke: '#000',
});
} else if (type === 'ARC') {
// simplified arc handling
fabricObjects.push({
type: 'path',
path: `M ${entity.center.x} ${entity.center.y}`, // placeholder
stroke: '#000', strokeWidth: 1,
});
} else if (type === 'POLYLINE' || type === 'LWPOLYLINE') {
// convert vertices to polygon
if (entity.vertices && entity.vertices.length >= 2) {
const points = entity.vertices.map(v => ({ x: v.x, y: v.y }));
fabricObjects.push({
type: 'polygon',
points,
stroke: '#000', strokeWidth: 1, fill: 'transparent',
});
}
}
});
}
res.json({ objects: fabricObjects });
} catch (err) {
console.error('DXF import error:', err);
res.status(500).json({ error: 'Failed to parse DXF file: ' + err.message });
}
});
// Export canvas JSON to DXF string
router.post('/export', authenticate, (req, res) => {
try {
const { objects } = req.body;
if (!objects || !Array.isArray(objects)) {
return res.status(400).json({ error: 'Invalid objects array' });
}
// Build DXF content
let dxf = '0\nSECTION\n2\nHEADER\n0\nENDSEC\n';
dxf += '0\nSECTION\n2\nTABLES\n0\nENDSEC\n';
dxf += '0\nSECTION\n2\nBLOCKS\n0\nENDSEC\n';
dxf += '0\nSECTION\n2\nENTITIES\n';
objects.forEach(obj => {
if (obj.type === 'line') {
dxf += `0\nLINE\n8\n0\n10\n${obj.x1}\n20\n${obj.y1}\n11\n${obj.x2}\n21\n${obj.y2}\n`;
} else if (obj.type === 'circle') {
const cx = obj.left + obj.radius;
const cy = obj.top + obj.radius;
dxf += `0\nCIRCLE\n8\n0\n10\n${cx}\n20\n${cy}\n40\n${obj.radius}\n`;
} else if (obj.type === 'polygon') {
// LWPOLYLINE
dxf += `0\nLWPOLYLINE\n8\n0\n90\n${obj.points.length}\n`;
obj.points.forEach((pt, i) => {
dxf += `10\n${pt.x}\n20\n${pt.y}\n`;
});
}
});
dxf += '0\nENDSEC\n0\nEOF\n';
res.setHeader('Content-Type', 'application/dxf');
res.setHeader('Content-Disposition', 'attachment; filename=drawing.dxf');
res.send(dxf);
} catch (err) {
console.error('DXF export error:', err);
res.status(500).json({ error: 'Export failed' });
}
});
module.exports = router;
-56
View File
@@ -1,56 +0,0 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const router = express.Router();
const PLUGINS_DIR = path.join(__dirname, '..', 'plugins');
// Ensure plugins directory exists
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
}
// List all available plugins
router.get('/', (req, res) => {
try {
if (!fs.existsSync(PLUGINS_DIR)) {
return res.json([]);
}
const plugins = [];
const dirs = fs.readdirSync(PLUGINS_DIR);
for (const dir of dirs) {
const pluginPath = path.join(PLUGINS_DIR, dir);
const manifestPath = path.join(pluginPath, 'plugin.json');
if (fs.existsSync(manifestPath)) {
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
plugins.push({ id: dir, ...manifest });
} catch (e) {
console.warn(`Invalid plugin manifest: ${dir}`);
}
}
}
res.json(plugins);
} catch (err) {
console.error('Plugin list error:', err);
res.status(500).json({ error: 'Failed to list plugins' });
}
});
// Serve plugin code as JavaScript module
router.get('/:id/code', (req, res) => {
try {
const pluginDir = path.join(PLUGINS_DIR, req.params.id);
const codePath = path.join(pluginDir, 'plugin.js');
if (!fs.existsSync(codePath)) {
return res.status(404).json({ error: 'Plugin code not found' });
}
res.setHeader('Content-Type', 'application/javascript');
res.sendFile(codePath);
} catch (err) {
console.error('Plugin code error:', err);
res.status(500).json({ error: 'Failed to load plugin code' });
}
});
module.exports = router;
-142
View File
@@ -1,142 +0,0 @@
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();
-47
View File
@@ -1,47 +0,0 @@
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);
});
+98
View File
@@ -0,0 +1,98 @@
/**
* AuthService Registration, Login, Session Management
*/
import bcrypt from 'bcrypt';
import { randomUUID } from 'crypto';
import type { DatabaseInterface, DBUser, DBSession } from '../database/DatabaseInterface.js';
const SALT_ROUNDS = 10;
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
export interface Session {
token: string;
userId: string;
expiresAt: number;
}
export interface AuthResult {
user: Omit<DBUser, 'password_hash'>;
session: Session;
}
export class AuthService {
constructor(private db: DatabaseInterface) {}
async register(email: string, password: string, name: string, role: string = 'planer'): Promise<AuthResult> {
const existing = this.db.getUserByEmail(email);
if (existing) {
throw new Error('Email already registered');
}
const password_hash = await bcrypt.hash(password, SALT_ROUNDS);
const user = this.db.createUser({ email, password_hash, name, role });
const session = this.createSession(user.id);
return { user: this.sanitizeUser(user), session };
}
async login(email: string, password: string): Promise<AuthResult> {
const user = this.db.getUserByEmail(email);
if (!user) {
throw new Error('Invalid credentials');
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
throw new Error('Invalid credentials');
}
const session = this.createSession(user.id);
return { user: this.sanitizeUser(user), session };
}
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
const user = this.db.getUser(userId);
if (!user) throw new Error('User not found');
const valid = await bcrypt.compare(oldPassword, user.password_hash);
if (!valid) throw new Error('Invalid current password');
const password_hash = await bcrypt.hash(newPassword, SALT_ROUNDS);
this.db.updateUser(userId, { password_hash });
}
createSession(userId: string): Session {
const token = randomUUID();
const expiresAt = Date.now() + SESSION_TTL_MS;
this.db.createSession({ token, user_id: userId, expires_at: expiresAt });
return { token, userId, expiresAt };
}
validateSession(token: string): Session | null {
const dbSession = this.db.getSession(token);
if (!dbSession) return null;
if (Date.now() > dbSession.expires_at) {
this.db.deleteSession(token);
return null;
}
return {
token: dbSession.token,
userId: dbSession.user_id,
expiresAt: dbSession.expires_at,
};
}
destroySession(token: string): void {
this.db.deleteSession(token);
}
getUserFromSession(token: string): DBUser | null {
const session = this.validateSession(token);
if (!session) return null;
return this.db.getUser(session.userId);
}
private sanitizeUser(user: DBUser): Omit<DBUser, 'password_hash'> {
const { password_hash, ...safe } = user;
return safe;
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Database Interface abstract DB layer for Web CAD
*/
export interface DBProject {
id: string;
name: string;
description: string | null;
owner_id: string;
created_at: string;
updated_at: string;
}
export interface DBDrawing {
id: string;
project_id: string;
name: string;
data_json: string;
created_at: string;
updated_at: string;
}
export interface DBLayer {
id: string;
drawing_id: string;
name: string;
visible: number;
locked: number;
color: string;
line_type: string;
transparency: number;
sort_order: number;
parent_id: string | null;
}
export interface DBElement {
id: string;
drawing_id: string;
layer_id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
properties_json: string;
created_at: string;
}
export interface DBBlock {
id: string;
drawing_id: string;
name: string;
description: string | null;
category: string;
elements_json: string;
thumbnail: string | null;
}
export interface DBSetting {
key: string;
value: string;
updated_at: string;
}
export interface DBUser {
id: string;
email: string;
password_hash: string;
name: string;
role: string;
created_at: string;
updated_at: string;
}
export interface DBSession {
token: string;
user_id: string;
expires_at: number;
created_at: string;
}
export interface DatabaseInterface {
init(): Promise<void>;
close(): void;
// Users
getUser(id: string): DBUser | null;
getUserByEmail(email: string): DBUser | null;
createUser(data: Partial<DBUser>): DBUser;
updateUser(id: string, data: Partial<DBUser>): DBUser | null;
deleteUser(id: string): boolean;
listUsers(): DBUser[];
// Projects
listProjects(): DBProject[];
getProject(id: string): DBProject | null;
createProject(data: Partial<DBProject>): DBProject;
updateProject(id: string, data: Partial<DBProject>): DBProject | null;
deleteProject(id: string): boolean;
// Drawings
listDrawings(projectId: string): DBDrawing[];
getDrawing(id: string): DBDrawing | null;
createDrawing(data: Partial<DBDrawing>): DBDrawing;
updateDrawing(id: string, data: Partial<DBDrawing>): DBDrawing | null;
deleteDrawing(id: string): boolean;
// Layers
listLayers(drawingId: string): DBLayer[];
createLayer(data: Partial<DBLayer>): DBLayer;
updateLayer(id: string, data: Partial<DBLayer>): DBLayer | null;
deleteLayer(id: string): boolean;
// Elements
listElements(drawingId: string): DBElement[];
createElement(data: Partial<DBElement>): DBElement;
updateElement(id: string, data: Partial<DBElement>): DBElement | null;
deleteElement(id: string): boolean;
// Blocks
listBlocks(drawingId: string): DBBlock[];
createBlock(data: Partial<DBBlock>): DBBlock;
updateBlock(id: string, data: Partial<DBBlock>): DBBlock | null;
deleteBlock(id: string): boolean;
// Settings
getSetting(key: string): DBSetting | null;
setSetting(key: string, value: string): void;
// Sessions
createSession(data: Partial<DBSession>): DBSession;
getSession(token: string): DBSession | null;
deleteSession(token: string): boolean;
deleteExpiredSessions(): void;
}
+268
View File
@@ -0,0 +1,268 @@
/**
* 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,
} from './DatabaseInterface.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
export class SqliteAdapter implements DatabaseInterface {
private db: Database.Database;
constructor(dbPath: string = ':memory:') {
this.db = new Database(dbPath);
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
}
async init(): Promise<void> {
const schemaPath = join(__dirname, 'schema.sql');
const schema = readFileSync(schemaPath, 'utf-8');
this.db.exec(schema);
}
close(): void {
this.db.close();
}
// ─── Users ──────────────────────────────────────────
getUser(id: string): DBUser | null {
return (this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DBUser) ?? null;
}
getUserByEmail(email: string): DBUser | null {
return (this.db.prepare('SELECT * FROM users WHERE email = ?').get(email) as DBUser) ?? null;
}
createUser(data: Partial<DBUser>): DBUser {
const id = data.id ?? `user-${Date.now()}`;
this.db.prepare(
'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)',
).run(id, data.email!, data.password_hash!, data.name!, data.role ?? 'planer');
return this.getUser(id)!;
}
updateUser(id: string, data: Partial<DBUser>): DBUser | null {
const sets: string[] = [];
const vals: any[] = [];
if (data.email !== undefined) { sets.push('email = ?'); vals.push(data.email); }
if (data.password_hash !== undefined) { sets.push('password_hash = ?'); vals.push(data.password_hash); }
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.role !== undefined) { sets.push('role = ?'); vals.push(data.role); }
sets.push("updated_at = datetime('now')");
if (sets.length > 1) {
this.db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.getUser(id);
}
deleteUser(id: string): boolean {
return this.db.prepare('DELETE FROM users WHERE id = ?').run(id).changes > 0;
}
listUsers(): DBUser[] {
return this.db.prepare('SELECT * FROM users ORDER BY created_at DESC').all() as DBUser[];
}
// ─── Projects ──────────────────────────────────────
listProjects(): DBProject[] {
return this.db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as DBProject[];
}
getProject(id: string): DBProject | null {
return (this.db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as DBProject) ?? null;
}
createProject(data: Partial<DBProject>): DBProject {
const id = data.id ?? `proj-${Date.now()}`;
this.db.prepare(
'INSERT INTO projects (id, name, description, owner_id) VALUES (?, ?, ?, ?)',
).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default');
return this.getProject(id)!;
}
updateProject(id: string, data: Partial<DBProject>): DBProject | null {
const sets: string[] = [];
const vals: any[] = [];
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.description !== undefined) { sets.push('description = ?'); vals.push(data.description); }
sets.push("updated_at = datetime('now')");
if (sets.length > 1) {
this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.getProject(id);
}
deleteProject(id: string): boolean {
return this.db.prepare('DELETE FROM projects WHERE id = ?').run(id).changes > 0;
}
// ─── Drawings ──────────────────────────────────────
listDrawings(projectId: string): DBDrawing[] {
return this.db.prepare('SELECT * FROM drawings WHERE project_id = ? ORDER BY updated_at DESC').all(projectId) as DBDrawing[];
}
getDrawing(id: string): DBDrawing | null {
return (this.db.prepare('SELECT * FROM drawings WHERE id = ?').get(id) as DBDrawing) ?? null;
}
createDrawing(data: Partial<DBDrawing>): DBDrawing {
const id = data.id ?? `draw-${Date.now()}`;
this.db.prepare(
'INSERT INTO drawings (id, project_id, name, data_json) VALUES (?, ?, ?, ?)',
).run(id, data.project_id!, data.name ?? 'Unbenannt', data.data_json ?? '{}');
return this.getDrawing(id)!;
}
updateDrawing(id: string, data: Partial<DBDrawing>): DBDrawing | null {
const sets: string[] = [];
const vals: any[] = [];
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.data_json !== undefined) { sets.push('data_json = ?'); vals.push(data.data_json); }
sets.push("updated_at = datetime('now')");
if (sets.length > 1) {
this.db.prepare(`UPDATE drawings SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.getDrawing(id);
}
deleteDrawing(id: string): boolean {
return this.db.prepare('DELETE FROM drawings WHERE id = ?').run(id).changes > 0;
}
// ─── Layers ────────────────────────────────────────
listLayers(drawingId: string): DBLayer[] {
return this.db.prepare('SELECT * FROM layers WHERE drawing_id = ? ORDER BY sort_order').all(drawingId) as DBLayer[];
}
createLayer(data: Partial<DBLayer>): DBLayer {
const id = data.id ?? `layer-${Date.now()}`;
this.db.prepare(
'INSERT INTO layers (id, drawing_id, name, visible, locked, color, line_type, transparency, sort_order, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(id, data.drawing_id!, data.name ?? 'Layer', data.visible ?? 1, data.locked ?? 0,
data.color ?? '#ffffff', data.line_type ?? 'solid', data.transparency ?? 0,
data.sort_order ?? 0, data.parent_id ?? null);
return this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer;
}
updateLayer(id: string, data: Partial<DBLayer>): DBLayer | null {
const sets: string[] = [];
const vals: any[] = [];
for (const [k, v] of Object.entries(data)) {
if (['name','visible','locked','color','line_type','transparency','sort_order','parent_id'].includes(k)) {
sets.push(`${k} = ?`); vals.push(v);
}
}
if (sets.length > 0) {
this.db.prepare(`UPDATE layers SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer ?? null;
}
deleteLayer(id: string): boolean {
return this.db.prepare('DELETE FROM layers WHERE id = ?').run(id).changes > 0;
}
// ─── Elements ──────────────────────────────────────
listElements(drawingId: string): DBElement[] {
return this.db.prepare('SELECT * FROM elements WHERE drawing_id = ?').all(drawingId) as DBElement[];
}
createElement(data: Partial<DBElement>): DBElement {
const id = data.id ?? `elem-${Date.now()}`;
this.db.prepare(
'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(id, data.drawing_id!, data.layer_id!, data.type!, data.x ?? 0, data.y ?? 0,
data.width ?? 0, data.height ?? 0, data.properties_json ?? '{}');
return this.db.prepare('SELECT * FROM elements WHERE id = ?').get(id) as DBElement;
}
updateElement(id: string, data: Partial<DBElement>): DBElement | null {
const sets: string[] = [];
const vals: any[] = [];
for (const [k, v] of Object.entries(data)) {
if (['layer_id','type','x','y','width','height','properties_json'].includes(k)) {
sets.push(`${k} = ?`); vals.push(v);
}
}
if (sets.length > 0) {
this.db.prepare(`UPDATE elements SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.db.prepare('SELECT * FROM elements WHERE id = ?').get(id) as DBElement ?? null;
}
deleteElement(id: string): boolean {
return this.db.prepare('DELETE FROM elements WHERE id = ?').run(id).changes > 0;
}
// ─── Blocks ────────────────────────────────────────
listBlocks(drawingId: string): DBBlock[] {
return this.db.prepare('SELECT * FROM blocks WHERE drawing_id = ?').all(drawingId) as DBBlock[];
}
createBlock(data: Partial<DBBlock>): DBBlock {
const id = data.id ?? `block-${Date.now()}`;
this.db.prepare(
'INSERT INTO blocks (id, drawing_id, name, description, category, elements_json, thumbnail) VALUES (?, ?, ?, ?, ?, ?, ?)',
).run(id, data.drawing_id!, data.name ?? 'Block', data.description ?? null,
data.category ?? 'Allgemein', data.elements_json ?? '[]', data.thumbnail ?? null);
return this.db.prepare('SELECT * FROM blocks WHERE id = ?').get(id) as DBBlock;
}
updateBlock(id: string, data: Partial<DBBlock>): DBBlock | null {
const sets: string[] = [];
const vals: any[] = [];
for (const [k, v] of Object.entries(data)) {
if (['name','description','category','elements_json','thumbnail'].includes(k)) {
sets.push(`${k} = ?`); vals.push(v);
}
}
if (sets.length > 0) {
this.db.prepare(`UPDATE blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.db.prepare('SELECT * FROM blocks WHERE id = ?').get(id) as DBBlock ?? null;
}
deleteBlock(id: string): boolean {
return this.db.prepare('DELETE FROM blocks WHERE id = ?').run(id).changes > 0;
}
// ─── Settings ──────────────────────────────────────
getSetting(key: string): DBSetting | null {
return (this.db.prepare('SELECT * FROM settings WHERE key = ?').get(key) as DBSetting) ?? null;
}
setSetting(key: string, value: string): void {
this.db.prepare(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')",
).run(key, value);
}
// ─── 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 {
this.db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now());
}
}
+107
View File
@@ -0,0 +1,107 @@
-- Web CAD Backend SQLite Schema
-- Version 1.0
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- ─── Users ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'planer' CHECK(role IN ('admin','planer','betrachter','gast')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Default User (seed) ────────────────────────────
INSERT OR IGNORE INTO users (id, email, password_hash, name, role) VALUES ('user-default', 'default@webcad.local', '', 'Default User', 'admin');
-- ─── Projects ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
owner_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
);
-- ─── Drawings ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS drawings (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
name TEXT NOT NULL,
data_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
-- ─── Layers ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS layers (
id TEXT PRIMARY KEY,
drawing_id TEXT NOT NULL,
name TEXT NOT NULL,
visible INTEGER NOT NULL DEFAULT 1,
locked INTEGER NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT '#ffffff',
line_type TEXT NOT NULL DEFAULT 'solid',
transparency REAL NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
parent_id TEXT,
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
);
-- ─── Elements ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS elements (
id TEXT PRIMARY KEY,
drawing_id TEXT NOT NULL,
layer_id TEXT NOT NULL,
type TEXT NOT NULL,
x REAL NOT NULL,
y REAL NOT NULL,
width REAL NOT NULL,
height REAL NOT NULL,
properties_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE,
FOREIGN KEY (layer_id) REFERENCES layers(id) ON DELETE CASCADE
);
-- ─── Block Definitions ──────────────────────────────
CREATE TABLE IF NOT EXISTS blocks (
id TEXT PRIMARY KEY,
drawing_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'Allgemein',
elements_json TEXT NOT NULL DEFAULT '[]',
thumbnail TEXT,
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
);
-- ─── Sessions ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- ─── Settings ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Indexes ────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_drawings_project ON drawings(project_id);
CREATE INDEX IF NOT EXISTS idx_layers_drawing ON layers(drawing_id);
CREATE INDEX IF NOT EXISTS idx_elements_drawing ON elements(drawing_id);
CREATE INDEX IF NOT EXISTS idx_elements_layer ON elements(layer_id);
CREATE INDEX IF NOT EXISTS idx_blocks_drawing ON blocks(drawing_id);
+40
View File
@@ -0,0 +1,40 @@
/**
* Web CAD Backend Entry Point
*/
import { createServer } from './server.js';
import { SqliteAdapter } from './database/SqliteAdapter.js';
import { initYjsPersistence, closeYjsPersistence } from './websocket/yjsServer.js';
const PORT = parseInt(process.env.PORT || '3001', 10);
const HOST = process.env.HOST || '0.0.0.0';
const DB_PATH = process.env.DB_PATH || '/data/web-cad.db';
async function main() {
const db = new SqliteAdapter(DB_PATH);
await db.init();
await initYjsPersistence();
const fastify = await createServer({ port: PORT, host: HOST, db });
try {
await fastify.listen({ port: PORT, host: HOST });
console.log(`Web CAD Backend running on http://${HOST}:${PORT}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
// Graceful shutdown
process.on('SIGTERM', async () => {
await fastify.close();
db.close();
process.exit(0);
});
process.on('SIGINT', async () => {
await fastify.close();
db.close();
process.exit(0);
});
}
main();
+132
View File
@@ -0,0 +1,132 @@
/**
* AI Routes KI Copilot chat endpoint via OpenRouter
*/
import type { FastifyInstance } from 'fastify';
import type { AuthService } from '../auth/AuthService.js';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
const MODEL = 'anthropic/claude-sonnet-4.5';
const TIMEOUT_MS = 30_000;
function extractToken(request: any): string | null {
const auth = request.headers?.authorization;
if (auth && auth.startsWith('Bearer ')) {
return auth.slice(7);
}
return null;
}
export function registerAIRoutes(fastify: FastifyInstance, authService: AuthService) {
fastify.post('/api/ai/chat', async (request, reply) => {
// Auth check
const token = extractToken(request);
if (!token) {
return reply.code(401).send({ error: 'Authentication required' });
}
const user = authService.getUserFromSession(token);
if (!user) {
return reply.code(401).send({ error: 'Invalid or expired session' });
}
const { messages, context } = request.body as {
messages?: Array<{ role: string; content: string }>;
context?: {
projectName?: string;
elementCount?: number;
layerCount?: number;
elementTypeSummary?: Record<string, number>;
};
};
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return reply.code(400).send({ error: 'Messages array is required' });
}
const apiKey = process.env.API_KEY_OPENROUTER;
if (!apiKey) {
return reply.code(503).send({ error: 'AI service not configured' });
}
// Build system prompt with CAD context
const ctxParts: string[] = [
'Du bist der KI Copilot für Web CAD, eine webbasierte CAD-Anwendung für Event- und Raumplanung.',
'Du hilfst beim Anlegen von Bestuhlung, Räumen, Bühnen und anderen CAD-Elementen.',
'Antworte auf Deutsch, klar und präzise. Verwende Markdown für Formatierung wenn sinnvoll.',
];
if (context) {
if (context.projectName) ctxParts.push(`Aktuelles Projekt: ${context.projectName}`);
if (context.elementCount !== undefined) ctxParts.push(`Elemente im Projekt: ${context.elementCount}`);
if (context.layerCount !== undefined) ctxParts.push(`Ebenen: ${context.layerCount}`);
if (context.elementTypeSummary) {
const summary = Object.entries(context.elementTypeSummary)
.map(([type, count]) => `${type}: ${count}`)
.join(', ');
ctxParts.push(`Element-Typen: ${summary}`);
}
}
const systemPrompt = ctxParts.join('\n');
// Build OpenRouter request
const payload = {
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
...messages.map((m) => ({ role: m.role, content: m.content })),
],
max_tokens: 1024,
temperature: 0.7,
};
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
const response = await fetch(OPENROUTER_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': 'http://localhost:5173',
'X-Title': 'Web CAD KI Copilot',
},
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
const errText = await response.text();
request.log.error({ errText, status: response.status }, 'OpenRouter error');
return reply.code(502).send({ error: 'AI service returned an error' });
}
const data = await response.json() as any;
const content = data.choices?.[0]?.message?.content ?? '';
if (!content) {
return reply.code(502).send({ error: 'AI service returned empty response' });
}
// Extract suggestions from response if present (simple heuristic)
const suggestions: string[] = [];
const lines = content.split('\n');
for (const line of lines) {
const match = line.match(/^[-*]\s*(.+)/);
if (match && suggestions.length < 3) {
suggestions.push(match[1].trim());
}
}
return reply.send({ content, suggestions: suggestions.length > 0 ? suggestions : undefined });
} catch (err: any) {
if (err.name === 'AbortError') {
return reply.code(504).send({ error: 'AI service timeout' });
}
request.log.error({ err }, 'AI route error');
return reply.code(500).send({ error: 'Internal server error' });
}
});
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Auth Routes Registration, Login, Logout, Profile
*/
import type { FastifyInstance } from 'fastify';
import type { AuthService } from '../auth/AuthService.js';
export function registerAuthRoutes(fastify: FastifyInstance, authService: AuthService) {
// Register new user
fastify.post('/api/auth/register', async (request, reply) => {
const { email, password, name, role } = request.body as {
email?: string; password?: string; name?: string; role?: string;
};
if (!email || !password || !name) {
return reply.code(400).send({ error: 'Email, password and name are required' });
}
try {
const result = await authService.register(email, password, name, role);
return reply.code(201).send(result);
} catch (err: any) { return reply.code(409).send({ error: err.message });
}
});
// Login
fastify.post('/api/auth/login', async (request, reply) => {
const { email, password } = request.body as { email?: string; password?: string };
if (!email || !password) {
return reply.code(400).send({ error: 'Email and password are required' });
}
try {
const result = await authService.login(email, password);
return reply.send(result);
} catch {
return reply.code(401).send({ error: 'Invalid credentials' });
}
});
// Logout
fastify.post('/api/auth/logout', async (request, reply) => {
const token = extractToken(request);
if (token) {
authService.destroySession(token);
}
return reply.code(204).send();
});
// Get current user profile
fastify.get('/api/auth/me', async (request, reply) => {
const token = extractToken(request);
if (!token) return reply.code(401).send({ error: 'Not authenticated' });
const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { password_hash, ...safe } = user;
return safe;
});
// Change password
fastify.patch('/api/auth/password', async (request, reply) => {
const token = extractToken(request);
if (!token) return reply.code(401).send({ error: 'Not authenticated' });
const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { oldPassword, newPassword } = request.body as { oldPassword?: string; newPassword?: string };
if (!oldPassword || !newPassword) {
return reply.code(400).send({ error: 'oldPassword and newPassword are required' });
}
try {
await authService.changePassword(user.id, oldPassword, newPassword);
return reply.code(204).send();
} catch (err: any) {
return reply.code(400).send({ error: err.message });
}
});
}
function extractToken(request: any): string | null {
const auth = request.headers?.authorization;
if (auth && auth.startsWith('Bearer ')) {
return auth.slice(7);
}
return null;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Blocks Routes CRUD for reusable drawing blocks
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBBlock } from '../database/DatabaseInterface.js';
export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
// List all blocks for a drawing
fastify.get('/api/drawings/:drawingId/blocks', async (request) => {
const { drawingId } = request.params as { drawingId: string };
return db.listBlocks(drawingId);
});
// Create a new block
fastify.post('/api/drawings/:drawingId/blocks', async (request, reply) => {
const { drawingId } = request.params as { drawingId: string };
const body = request.body as Partial<DBBlock>;
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
return reply.code(201).send(db.createBlock({ ...body, drawing_id: drawingId }));
});
// Update a block
fastify.patch('/api/blocks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as Partial<DBBlock>;
const updated = db.updateBlock(id, body);
if (!updated) return reply.code(404).send({ error: 'Block not found' });
return updated;
});
// Delete a block
fastify.delete('/api/blocks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = db.deleteBlock(id);
if (!ok) return reply.code(404).send({ error: 'Block not found' });
return reply.code(204).send();
});
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Drawings Routes CRUD for drawings
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBDrawing } from '../database/DatabaseInterface.js';
export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
// List drawings for a project
fastify.get('/api/projects/:projectId/drawings', async (request) => {
const { projectId } = request.params as { projectId: string };
return db.listDrawings(projectId);
});
// Get single drawing
fastify.get('/api/drawings/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const drawing = db.getDrawing(id);
if (!drawing) return reply.code(404).send({ error: 'Drawing not found' });
return drawing;
});
// Create drawing
fastify.post('/api/projects/:projectId/drawings', async (request, reply) => {
const { projectId } = request.params as { projectId: string };
const body = request.body as Partial<DBDrawing>;
return reply.code(201).send(db.createDrawing({ ...body, project_id: projectId }));
});
// Update drawing
fastify.patch('/api/drawings/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as Partial<DBDrawing>;
const updated = db.updateDrawing(id, body);
if (!updated) return reply.code(404).send({ error: 'Drawing not found' });
return updated;
});
// Delete drawing
fastify.delete('/api/drawings/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = db.deleteDrawing(id);
if (!ok) return reply.code(404).send({ error: 'Drawing not found' });
return reply.code(204).send();
});
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Elements Routes CRUD for elements
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBElement } from '../database/DatabaseInterface.js';
export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
// List elements for a drawing
fastify.get('/api/drawings/:drawingId/elements', async (request) => {
const { drawingId } = request.params as { drawingId: string };
return db.listElements(drawingId);
});
// Create element
fastify.post('/api/drawings/:drawingId/elements', async (request, reply) => {
const { drawingId } = request.params as { drawingId: string };
const body = request.body as Partial<DBElement>;
return reply.code(201).send(db.createElement({ ...body, drawing_id: drawingId }));
});
// Update element
fastify.patch('/api/elements/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as Partial<DBElement>;
const updated = db.updateElement(id, body);
if (!updated) return reply.code(404).send({ error: 'Element not found' });
return updated;
});
// Delete element
fastify.delete('/api/elements/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = db.deleteElement(id);
if (!ok) return reply.code(404).send({ error: 'Element not found' });
return reply.code(204).send();
});
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Layers Routes CRUD for layers
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBLayer } from '../database/DatabaseInterface.js';
export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
// List layers for a drawing
fastify.get('/api/drawings/:drawingId/layers', async (request) => {
const { drawingId } = request.params as { drawingId: string };
return db.listLayers(drawingId);
});
// Create layer
fastify.post('/api/drawings/:drawingId/layers', async (request, reply) => {
const { drawingId } = request.params as { drawingId: string };
const body = request.body as Partial<DBLayer>;
return reply.code(201).send(db.createLayer({ ...body, drawing_id: drawingId }));
});
// Update layer
fastify.patch('/api/layers/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as Partial<DBLayer>;
const updated = db.updateLayer(id, body);
if (!updated) return reply.code(404).send({ error: 'Layer not found' });
return updated;
});
// Delete layer
fastify.delete('/api/layers/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = db.deleteLayer(id);
if (!ok) return reply.code(404).send({ error: 'Layer not found' });
return reply.code(204).send();
});
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Projects Routes CRUD for projects
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBProject } from '../database/DatabaseInterface.js';
export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
// List all projects
fastify.get('/api/projects', async () => {
return db.listProjects();
});
// Get single project
fastify.get('/api/projects/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const project = db.getProject(id);
if (!project) return reply.code(404).send({ error: 'Project not found' });
return project;
});
// Create project
fastify.post('/api/projects', async (request, reply) => {
const body = request.body as Partial<DBProject>;
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
return reply.code(201).send(db.createProject(body));
});
// Update project
fastify.patch('/api/projects/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as Partial<DBProject>;
const updated = db.updateProject(id, body);
if (!updated) return reply.code(404).send({ error: 'Project not found' });
return updated;
});
// Delete project
fastify.delete('/api/projects/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = db.deleteProject(id);
if (!ok) return reply.code(404).send({ error: 'Project not found' });
return reply.code(204).send();
});
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Settings Routes key/value application settings
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
// Get a single setting by key
fastify.get('/api/settings/:key', async (request, reply) => {
const { key } = request.params as { key: string };
const setting = db.getSetting(key);
if (!setting) return reply.code(404).send({ error: 'Setting not found' });
return setting;
});
// Create or update a setting (upsert)
fastify.put('/api/settings/:key', async (request) => {
const { key } = request.params as { key: string };
const body = request.body as { value?: string };
if (body.value === undefined) {
return { error: 'Value is required' };
}
db.setSetting(key, body.value);
return { key, value: body.value, updated_at: new Date().toISOString() };
});
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Users Routes Admin user management
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js';
import type { AuthService } from '../auth/AuthService.js';
export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List all users (admin only — TODO: add role check middleware)
fastify.get('/api/users', async () => {
const users = db.listUsers();
return users.map(({ password_hash, ...safe }) => safe);
});
// Get single user
fastify.get('/api/users/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const user = db.getUser(id);
if (!user) return reply.code(404).send({ error: 'User not found' });
const { password_hash, ...safe } = user;
return safe;
});
// Update user (name, role, email)
fastify.patch('/api/users/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as Partial<DBUser>;
// Prevent password update via this endpoint
delete body.password_hash;
const updated = db.updateUser(id, body);
if (!updated) return reply.code(404).send({ error: 'User not found' });
const { password_hash, ...safe } = updated;
return safe;
});
// Delete user
fastify.delete('/api/users/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = db.deleteUser(id);
if (!ok) return reply.code(404).send({ error: 'User not found' });
return reply.code(204).send();
});
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Server Fastify configuration with CORS, static, and WebSocket
*/
import Fastify from 'fastify';
import cors from '@fastify/cors';
import staticPlugin from '@fastify/static';
import websocket from '@fastify/websocket';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import type { DatabaseInterface } from './database/DatabaseInterface.js';
import { AuthService } from './auth/AuthService.js';
import { registerYjsWebSocket, initYjsPersistence, closeYjsPersistence } from './websocket/yjsServer.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
export interface ServerOptions {
port?: number;
host?: string;
db: DatabaseInterface;
}
export async function createServer(opts: ServerOptions) {
const fastify = Fastify({ logger: true });
// CORS
await fastify.register(cors, {
origin: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
});
// WebSocket
await fastify.register(websocket, {
options: { maxPayload: 1048576 },
});
// Static files (serve frontend build if exists)
const frontendDist = join(__dirname, '..', '..', 'frontend', 'dist');
try {
await fastify.register(staticPlugin, {
root: frontendDist,
prefix: '/',
});
} catch {
// Frontend dist may not exist in dev mode
}
// Health check
fastify.get('/api/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));
// Register route modules
const { registerProjectRoutes } = await import('./routes/projects.js');
const { registerDrawingRoutes } = await import('./routes/drawings.js');
const { registerLayerRoutes } = await import('./routes/layers.js');
const { registerElementRoutes } = await import('./routes/elements.js');
const { registerBlockRoutes } = await import('./routes/blocks.js');
const { registerSettingsRoutes } = await import('./routes/settings.js');
const { registerAuthRoutes } = await import('./routes/auth.js');
const { registerUserRoutes } = await import('./routes/users.js');
const { registerAIRoutes } = await import('./routes/ai.js');
const authService = new AuthService(opts.db);
registerAuthRoutes(fastify, authService);
registerProjectRoutes(fastify, opts.db);
registerDrawingRoutes(fastify, opts.db);
registerLayerRoutes(fastify, opts.db);
registerElementRoutes(fastify, opts.db);
registerBlockRoutes(fastify, opts.db);
registerSettingsRoutes(fastify, opts.db);
registerUserRoutes(fastify, opts.db, authService);
registerAIRoutes(fastify, authService);
// Yjs collaboration WebSocket
registerYjsWebSocket(fastify);
return fastify;
}
+11
View File
@@ -0,0 +1,11 @@
declare module 'y-leveldb' {
import type * as Y from 'yjs';
export class LeveldbPersistence {
constructor(dbPath: string, opts?: Record<string, unknown>);
getYDoc(name: string): Promise<Y.Doc>;
storeUpdate(name: string, update: Uint8Array): Promise<number>;
destroy(): Promise<void>;
clearDocument(name: string): Promise<void>;
}
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Yjs WebSocket Server Real-time collaboration with LevelDB persistence
*/
import * as Y from 'yjs';
import { LeveldbPersistence } from 'y-leveldb';
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from '@fastify/websocket';
const PERSISTENCE_DIR = process.env.YJS_PERSISTENCE_DIR || '/tmp/yjs-documents';
// Document store: docName → Y.Doc
const docs = new Map<string, Y.Doc>();
// Connection tracking: docName → Set<WebSocket>
const connections = new Map<string, Set<WebSocket>>();
let persistence: LeveldbPersistence | null = null;
let persistenceReady: Promise<void> | null = null;
export async function initYjsPersistence(): Promise<void> {
try {
persistence = new LeveldbPersistence(PERSISTENCE_DIR);
console.log(`Yjs persistence initialized at ${PERSISTENCE_DIR}`);
} catch (err) {
console.warn('Yjs persistence failed to init (non-fatal):', err);
}
}
export async function closeYjsPersistence(): Promise<void> {
if (persistence) {
await persistence.destroy();
persistence = null;
}
}
async function getOrCreateDoc(docName: string): Promise<Y.Doc> {
if (docs.has(docName)) {
return docs.get(docName)!;
}
const doc = new Y.Doc();
docs.set(docName, doc);
// Load persisted state if available
if (persistence && persistenceReady) {
try {
await persistenceReady;
const persistedYdoc = await persistence.getYDoc(docName);
const update = Y.encodeStateAsUpdate(persistedYdoc);
if (update.length > 0) {
Y.applyUpdate(doc, update);
}
} catch (err) {
console.warn(`Failed to load doc ${docName}:`, err);
}
}
// Persist updates to LevelDB
doc.on('update', (update: Uint8Array) => {
if (persistence) {
persistence.storeUpdate(docName, update).catch((err: any) => {
console.warn(`Failed to persist update for ${docName}:`, err);
});
}
});
return doc;
}
function broadcastUpdate(docName: string, update: Uint8Array, exclude?: WebSocket): void {
const conns = connections.get(docName);
if (!conns) return;
for (const ws of conns) {
if (ws !== exclude && ws.readyState === 1 /* OPEN */) {
try {
ws.send(update);
} catch {
// Connection error — will be cleaned up on close
}
}
}
}
export function registerYjsWebSocket(fastify: FastifyInstance): void {
fastify.get('/ws/collab/:docName', { websocket: true }, async (socket: WebSocket, request: any) => {
const docName = request.params.docName as string;
if (!docName) {
socket.close(4000, 'Missing docName');
return;
}
const doc = await getOrCreateDoc(docName);
// Track connection
if (!connections.has(docName)) {
connections.set(docName, new Set());
}
connections.get(docName)!.add(socket);
// Send current document state to new client
const stateUpdate = Y.encodeStateAsUpdate(doc);
if (stateUpdate.length > 0 && socket.readyState === 1) {
socket.send(stateUpdate);
}
// Listen for updates from this client
socket.on('message', (data: Buffer) => {
try {
const update = new Uint8Array(data);
Y.applyUpdate(doc, update);
broadcastUpdate(docName, update, socket);
} catch (err) {
console.warn(`Invalid update from client for ${docName}:`, err);
}
});
// Clean up on disconnect
socket.on('close', () => {
const conns = connections.get(docName);
if (conns) {
conns.delete(socket);
if (conns.size === 0) {
connections.delete(docName);
// Optionally keep doc in memory for a while
// For now, remove it to free memory
docs.delete(docName);
}
}
});
socket.on('error', () => {
const conns = connections.get(docName);
if (conns) {
conns.delete(socket);
}
});
});
}
+188
View File
@@ -0,0 +1,188 @@
/**
* AuthService Unit Tests Register / Login / ChangePassword / Session lifecycle
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { AuthService } from '../src/auth/AuthService.js';
describe('AuthService', () => {
let db: SqliteAdapter;
let auth: AuthService;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
auth = new AuthService(db);
});
afterAll(() => {
db.close();
});
// ─── Register ───────────────────────────────────────
describe('register()', () => {
it('should register a new user successfully', async () => {
const result = await auth.register('register@example.com', 'Password123!', 'Register User', 'planer');
expect(result.user).toBeDefined();
expect(result.user.email).toBe('register@example.com');
expect(result.user.name).toBe('Register User');
expect(result.user.password_hash).toBeUndefined();
expect(result.session).toBeDefined();
expect(result.session.token).toBeDefined();
expect(result.session.userId).toBe(result.user.id);
});
it('should throw on duplicate email', async () => {
await expect(
auth.register('register@example.com', 'AnotherPass!', 'Another User', 'planer'),
).rejects.toThrow('Email already registered');
});
it('should default role to planer when not specified', async () => {
const result = await auth.register('default-role@example.com', 'Pass123!', 'Default Role User');
expect(result.user.role).toBe('planer');
});
});
// ─── Login ─────────────────────────────────────────
describe('login()', () => {
it('should login with correct credentials', async () => {
const result = await auth.login('register@example.com', 'Password123!');
expect(result.user).toBeDefined();
expect(result.user.email).toBe('register@example.com');
expect(result.session.token).toBeDefined();
expect(result.session.userId).toBe(result.user.id);
});
it('should throw on wrong password', async () => {
await expect(
auth.login('register@example.com', 'WrongPassword!'),
).rejects.toThrow('Invalid credentials');
});
it('should throw on non-existent email', async () => {
await expect(
auth.login('nobody@example.com', 'SomePassword!'),
).rejects.toThrow('Invalid credentials');
});
});
// ─── Change Password ───────────────────────────────
describe('changePassword()', () => {
it('should change password with correct old password', async () => {
const result = await auth.register('changepw@example.com', 'OldPass123!', 'ChangePW User', 'admin');
const userId = result.user.id;
await auth.changePassword(userId, 'OldPass123!', 'NewPass456!');
// Should now be able to login with new password
const loginResult = await auth.login('changepw@example.com', 'NewPass456!');
expect(loginResult.user.id).toBe(userId);
});
it('should throw on wrong old password', async () => {
const result = await auth.register('changepw2@example.com', 'OriginalPass!', 'User2', 'planer');
await expect(
auth.changePassword(result.user.id, 'WrongOldPass!', 'NewPass!'),
).rejects.toThrow('Invalid current password');
});
it('should throw on non-existent user', async () => {
await expect(
auth.changePassword('non-existent-user-id', 'OldPass!', 'NewPass!'),
).rejects.toThrow('User not found');
});
it('should reject old password after change', async () => {
const result = await auth.register('changepw3@example.com', 'Original123!', 'User3', 'planer');
await auth.changePassword(result.user.id, 'Original123!', 'Changed456!');
await expect(
auth.login('changepw3@example.com', 'Original123!'),
).rejects.toThrow('Invalid credentials');
});
});
// ─── Session Management ────────────────────────────
describe('Session lifecycle', () => {
it('should create and validate a session', async () => {
const regResult = await auth.register('session@example.com', 'Pass123!', 'Session User', 'planer');
const token = regResult.session.token;
const session = auth.validateSession(token);
expect(session).not.toBeNull();
expect(session!.token).toBe(token);
expect(session!.userId).toBe(regResult.user.id);
});
it('should return null for invalid session token', () => {
const session = auth.validateSession('invalid-token-xyz');
expect(session).toBeNull();
});
it('should destroy a session and invalidate it', async () => {
const regResult = await auth.register('destroy@example.com', 'Pass123!', 'Destroy User', 'planer');
const token = regResult.session.token;
// Session should be valid
expect(auth.validateSession(token)).not.toBeNull();
// Destroy it
auth.destroySession(token);
// Session should now be invalid
expect(auth.validateSession(token)).toBeNull();
});
it('should handle destroying a non-existent session gracefully', () => {
// Should not throw
auth.destroySession('non-existent-token');
});
});
// ─── getUserFromSession ─────────────────────────────
describe('getUserFromSession()', () => {
it('should return user from valid session', async () => {
const regResult = await auth.register('getuser@example.com', 'Pass123!', 'GetUser User', 'planer');
const token = regResult.session.token;
const user = auth.getUserFromSession(token);
expect(user).not.toBeNull();
expect(user!.email).toBe('getuser@example.com');
expect(user!.password_hash).toBeDefined(); // returns full DBUser including password_hash
});
it('should return null for invalid session token', () => {
const user = auth.getUserFromSession('invalid-token-xyz');
expect(user).toBeNull();
});
it('should return null after session is destroyed', async () => {
const regResult = await auth.register('getuser2@example.com', 'Pass123!', 'GetUser2 User', 'planer');
const token = regResult.session.token;
auth.destroySession(token);
const user = auth.getUserFromSession(token);
expect(user).toBeNull();
});
});
// ─── createSession (standalone) ─────────────────────
describe('createSession()', () => {
it('should create a session with a unique token', async () => {
const regResult = await auth.register('createsession@example.com', 'Pass123!', 'CS User', 'planer');
const session1 = auth.createSession(regResult.user.id);
const session2 = auth.createSession(regResult.user.id);
expect(session1.token).not.toBe(session2.token);
expect(session1.userId).toBe(regResult.user.id);
expect(session2.userId).toBe(regResult.user.id);
expect(session1.expiresAt).toBeGreaterThan(Date.now());
expect(session2.expiresAt).toBeGreaterThan(Date.now());
});
});
});
+383
View File
@@ -0,0 +1,383 @@
/**
* SqliteAdapter Unit Tests init, CRUD for all entities, close
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
let idCounter = 0;
function uniqueId(prefix: string): string {
idCounter++;
return `${prefix}-${Date.now()}-${idCounter}`;
}
describe('SqliteAdapter', () => {
let db: SqliteAdapter;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
});
afterAll(() => {
db.close();
});
// ─── Init & Close ───────────────────────────────────
describe('init() and close()', () => {
it('should initialize the database without errors', async () => {
const testDb = new SqliteAdapter(':memory:');
await testDb.init();
const users = testDb.listUsers();
expect(Array.isArray(users)).toBe(true);
testDb.close();
});
it('should seed a default user on init', async () => {
const defaultUser = db.getUser('user-default');
expect(defaultUser).not.toBeNull();
expect(defaultUser!.email).toBe('default@webcad.local');
expect(defaultUser!.role).toBe('admin');
});
});
// ─── Users CRUD ─────────────────────────────────────
describe('Users CRUD', () => {
it('should create and get a user', () => {
const uid = uniqueId('user');
const user = db.createUser({
id: uid,
email: `${uid}@example.com`,
password_hash: 'hash123',
name: 'CRUD User',
role: 'planer',
});
expect(user.id).toBe(uid);
expect(user.email).toBe(`${uid}@example.com`);
const fetched = db.getUser(user.id);
expect(fetched).not.toBeNull();
expect(fetched!.email).toBe(`${uid}@example.com`);
});
it('should get user by email', () => {
const uid = uniqueId('user');
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Email User', role: 'planer' });
const user = db.getUserByEmail(`${uid}@example.com`);
expect(user).not.toBeNull();
expect(user!.name).toBe('Email User');
});
it('should return null for non-existent user', () => {
expect(db.getUser('non-existent-id')).toBeNull();
expect(db.getUserByEmail('nobody@nowhere.com')).toBeNull();
});
it('should update a user', () => {
const uid = uniqueId('user');
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Original Name', role: 'planer' });
const updated = db.updateUser(uid, { name: 'Updated Name', role: 'admin' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Name');
expect(updated!.role).toBe('admin');
});
it('should list users', () => {
const users = db.listUsers();
expect(users.length).toBeGreaterThanOrEqual(2);
});
it('should delete a user', () => {
const uid = uniqueId('user');
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Delete Me', role: 'planer' });
const ok = db.deleteUser(uid);
expect(ok).toBe(true);
expect(db.getUser(uid)).toBeNull();
});
it('should return false when deleting non-existent user', () => {
expect(db.deleteUser('non-existent-id')).toBe(false);
});
});
// ─── Projects CRUD ──────────────────────────────────
describe('Projects CRUD', () => {
it('should create and get a project', () => {
const pid = uniqueId('proj');
const project = db.createProject({ id: pid, name: 'Test Project', description: 'Test desc' });
expect(project.id).toBe(pid);
expect(project.name).toBe('Test Project');
const fetched = db.getProject(pid);
expect(fetched).not.toBeNull();
expect(fetched!.name).toBe('Test Project');
});
it('should return null for non-existent project', () => {
expect(db.getProject('non-existent-id')).toBeNull();
});
it('should update a project', () => {
const pid = uniqueId('proj');
db.createProject({ id: pid, name: 'Update Project' });
const updated = db.updateProject(pid, { name: 'Updated Project', description: 'New desc' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Project');
expect(updated!.description).toBe('New desc');
});
it('should list projects', () => {
const projects = db.listProjects();
expect(projects.length).toBeGreaterThanOrEqual(1);
});
it('should delete a project', () => {
const pid = uniqueId('proj');
db.createProject({ id: pid, name: 'Delete Project' });
const ok = db.deleteProject(pid);
expect(ok).toBe(true);
expect(db.getProject(pid)).toBeNull();
});
it('should cascade delete drawings when project is deleted', () => {
const pid = uniqueId('proj');
const did = uniqueId('draw');
db.createProject({ id: pid, name: 'Cascade Project' });
db.createDrawing({ id: did, project_id: pid, name: 'Cascade Drawing' });
db.deleteProject(pid);
expect(db.getDrawing(did)).toBeNull();
});
});
// ─── Drawings CRUD ──────────────────────────────────
describe('Drawings CRUD', () => {
let projectId: string;
it('should create and get a drawing', () => {
projectId = uniqueId('proj');
const did = uniqueId('draw');
db.createProject({ id: projectId, name: 'Drawing Project' });
const drawing = db.createDrawing({ id: did, project_id: projectId, name: 'Floor 1' });
expect(drawing.id).toBe(did);
expect(drawing.name).toBe('Floor 1');
expect(drawing.project_id).toBe(projectId);
const fetched = db.getDrawing(did);
expect(fetched).not.toBeNull();
expect(fetched!.name).toBe('Floor 1');
});
it('should list drawings by project', () => {
const did1 = uniqueId('draw');
const did2 = uniqueId('draw');
db.createDrawing({ id: did1, project_id: projectId, name: 'Drawing A' });
db.createDrawing({ id: did2, project_id: projectId, name: 'Drawing B' });
const drawings = db.listDrawings(projectId);
expect(drawings.length).toBeGreaterThanOrEqual(3);
});
it('should update a drawing', () => {
const did = uniqueId('draw');
db.createDrawing({ id: did, project_id: projectId, name: 'Update Me' });
const updated = db.updateDrawing(did, { name: 'Updated Drawing', data_json: '{"x":1}' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Drawing');
expect(updated!.data_json).toBe('{"x":1}');
});
it('should delete a drawing', () => {
const did = uniqueId('draw');
db.createDrawing({ id: did, project_id: projectId, name: 'Delete Me' });
const ok = db.deleteDrawing(did);
expect(ok).toBe(true);
expect(db.getDrawing(did)).toBeNull();
});
it('should cascade delete layers when drawing is deleted', () => {
const did = uniqueId('draw');
const lid = uniqueId('layer');
db.createDrawing({ id: did, project_id: projectId, name: 'Cascade Drawing' });
db.createLayer({ id: lid, drawing_id: did, name: 'Cascade Layer' });
db.deleteDrawing(did);
const layers = db.listLayers(did);
expect(layers.find((l) => l.id === lid)).toBeUndefined();
});
});
// ─── Layers CRUD ────────────────────────────────────
describe('Layers CRUD', () => {
let drawingId: string;
it('should create and get a layer', () => {
const pid = uniqueId('proj');
drawingId = uniqueId('draw');
const lid = uniqueId('layer');
db.createProject({ id: pid, name: 'Layer Project' });
db.createDrawing({ id: drawingId, project_id: pid, name: 'Layer Drawing' });
const layer = db.createLayer({ id: lid, drawing_id: drawingId, name: 'Layer 1', color: '#ff0000' });
expect(layer.id).toBe(lid);
expect(layer.name).toBe('Layer 1');
expect(layer.color).toBe('#ff0000');
expect(layer.visible).toBe(1);
expect(layer.locked).toBe(0);
});
it('should list layers by drawing', () => {
const lid2 = uniqueId('layer');
db.createLayer({ id: lid2, drawing_id: drawingId, name: 'Layer 2' });
const layers = db.listLayers(drawingId);
expect(layers.length).toBeGreaterThanOrEqual(2);
});
it('should update a layer', () => {
const lid = uniqueId('layer');
db.createLayer({ id: lid, drawing_id: drawingId, name: 'Update Layer' });
const updated = db.updateLayer(lid, { name: 'Updated Layer', visible: 0, locked: 1 });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Layer');
expect(updated!.visible).toBe(0);
expect(updated!.locked).toBe(1);
});
it('should delete a layer', () => {
const lid = uniqueId('layer');
db.createLayer({ id: lid, drawing_id: drawingId, name: 'Delete Layer' });
const ok = db.deleteLayer(lid);
expect(ok).toBe(true);
const layers = db.listLayers(drawingId);
expect(layers.find((l) => l.id === lid)).toBeUndefined();
});
});
// ─── Elements CRUD ──────────────────────────────────
describe('Elements CRUD', () => {
let drawingId: string;
let layerId: string;
it('should create and get an element', () => {
const pid = uniqueId('proj');
drawingId = uniqueId('draw');
layerId = uniqueId('layer');
const eid = uniqueId('elem');
db.createProject({ id: pid, name: 'Element Project' });
db.createDrawing({ id: drawingId, project_id: pid, name: 'Element Drawing' });
db.createLayer({ id: layerId, drawing_id: drawingId, name: 'Element Layer' });
const element = db.createElement({
id: eid,
drawing_id: drawingId,
layer_id: layerId,
type: 'rect',
x: 10,
y: 20,
width: 100,
height: 50,
});
expect(element.id).toBe(eid);
expect(element.type).toBe('rect');
expect(element.x).toBe(10);
expect(element.y).toBe(20);
});
it('should list elements by drawing', () => {
const eid2 = uniqueId('elem');
db.createElement({ id: eid2, drawing_id: drawingId, layer_id: layerId, type: 'circle' });
const elements = db.listElements(drawingId);
expect(elements.length).toBeGreaterThanOrEqual(2);
});
it('should update an element', () => {
const eid = uniqueId('elem');
db.createElement({ id: eid, drawing_id: drawingId, layer_id: layerId, type: 'line' });
const updated = db.updateElement(eid, { x: 500, y: 600, width: 200 });
expect(updated).not.toBeNull();
expect(updated!.x).toBe(500);
expect(updated!.y).toBe(600);
expect(updated!.width).toBe(200);
});
it('should delete an element', () => {
const eid = uniqueId('elem');
db.createElement({ id: eid, drawing_id: drawingId, layer_id: layerId, type: 'text' });
const ok = db.deleteElement(eid);
expect(ok).toBe(true);
const elements = db.listElements(drawingId);
expect(elements.find((e) => e.id === eid)).toBeUndefined();
});
});
// ─── Blocks CRUD ────────────────────────────────────
describe('Blocks CRUD', () => {
let drawingId: string;
it('should create and get a block', () => {
const pid = uniqueId('proj');
drawingId = uniqueId('draw');
const bid = uniqueId('block');
db.createProject({ id: pid, name: 'Block Project' });
db.createDrawing({ id: drawingId, project_id: pid, name: 'Block Drawing' });
const block = db.createBlock({ id: bid, drawing_id: drawingId, name: 'Table Block', category: 'Mobiliar' });
expect(block.id).toBe(bid);
expect(block.name).toBe('Table Block');
expect(block.category).toBe('Mobiliar');
expect(block.elements_json).toBe('[]');
});
it('should list blocks by drawing', () => {
const bid2 = uniqueId('block');
db.createBlock({ id: bid2, drawing_id: drawingId, name: 'Chair Block' });
const blocks = db.listBlocks(drawingId);
expect(blocks.length).toBeGreaterThanOrEqual(2);
});
it('should update a block', () => {
const bid = uniqueId('block');
db.createBlock({ id: bid, drawing_id: drawingId, name: 'Update Block' });
const updated = db.updateBlock(bid, { name: 'Updated Block', category: 'Bühne' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Block');
expect(updated!.category).toBe('Bühne');
});
it('should delete a block', () => {
const bid = uniqueId('block');
db.createBlock({ id: bid, drawing_id: drawingId, name: 'Delete Block' });
const ok = db.deleteBlock(bid);
expect(ok).toBe(true);
const blocks = db.listBlocks(drawingId);
expect(blocks.find((b) => b.id === bid)).toBeUndefined();
});
});
// ─── Settings CRUD ──────────────────────────────────
describe('Settings CRUD', () => {
it('should set and get a setting', () => {
db.setSetting('test-key', 'test-value');
const setting = db.getSetting('test-key');
expect(setting).not.toBeNull();
expect(setting!.key).toBe('test-key');
expect(setting!.value).toBe('test-value');
expect(setting!.updated_at).toBeDefined();
});
it('should return null for non-existent setting', () => {
expect(db.getSetting('non-existent-key')).toBeNull();
});
it('should upsert (update existing setting)', () => {
db.setSetting('upsert-key', 'initial');
db.setSetting('upsert-key', 'updated');
const setting = db.getSetting('upsert-key');
expect(setting).not.toBeNull();
expect(setting!.value).toBe('updated');
});
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Backend Stresstest 50.000 Elemente: SQLite CRUD Performance,
* Bulk-Insert, List, Update, Delete mit Transaktion-Support.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import type { DBElement } from '../src/database/DatabaseInterface.js';
const N = 50_000;
describe('Backend Stresstest: 50.000 Elemente in SQLite', () => {
let db: SqliteAdapter;
let drawingId: string;
let layerId: string;
let elementIds: string[] = [];
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
// Create prerequisite project → drawing → layer
const project = db.createProject({ name: 'Stresstest Project' });
const drawing = db.createDrawing({ project_id: project.id, name: 'Stresstest Drawing' });
drawingId = drawing.id;
const layer = db.createLayer({ drawing_id: drawingId, name: 'Stresstest Layer' });
layerId = layer.id;
});
afterAll(() => {
db.close();
});
it('should bulk-insert 50k elements in under 5s', () => {
const start = performance.now();
// Use transaction for bulk insert
const insert = db['db'].prepare(
'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
);
const insertMany = db['db'].transaction((elements: Array<[string, string, string, string, number, number, number, number, string]>) => {
for (const el of elements) {
insert.run(...el);
}
});
const batch: Array<[string, string, string, string, number, number, number, number, string]> = [];
for (let i = 0; i < N; i++) {
const id = `stress-elem-${i}`;
elementIds.push(id);
batch.push([
id,
drawingId,
layerId,
'rect',
(i % 1000) * 1.5,
Math.floor(i / 1000) * 1.5,
1,
1,
'{}',
]);
}
insertMany(batch);
const elapsed = performance.now() - start;
console.log(`Bulk insert ${N} elements: ${elapsed.toFixed(1)}ms`);
expect(elapsed).toBeLessThan(5000);
});
it('should list 50k elements in under 500ms', () => {
const start = performance.now();
const elements = db.listElements(drawingId);
const elapsed = performance.now() - start;
console.log(`List ${N} elements: ${elapsed.toFixed(1)}ms, count=${elements.length}`);
expect(elements.length).toBe(N);
expect(elapsed).toBeLessThan(500);
});
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(500);
});
it('should delete 1000 elements in under 500ms', () => {
const start = performance.now();
const deleteStmt = db['db'].prepare('DELETE FROM elements WHERE id = ?');
const deleteMany = db['db'].transaction((ids: string[]) => {
for (const id of ids) {
deleteStmt.run(id);
}
});
const toDelete = elementIds.slice(0, 1000);
deleteMany(toDelete);
const elapsed = performance.now() - start;
console.log(`Delete 1000 elements: ${elapsed.toFixed(1)}ms`);
expect(elapsed).toBeLessThan(500);
// Verify count
const remaining = db.listElements(drawingId);
expect(remaining.length).toBe(N - 1000);
});
it('should handle concurrent queries efficiently', () => {
// Simulate 10 concurrent list operations
const start = performance.now();
for (let i = 0; i < 10; i++) {
const elements = db.listElements(drawingId);
expect(elements.length).toBe(N - 1000);
}
const elapsed = performance.now() - start;
console.log(`10x list operations on ${N - 1000} elements: ${elapsed.toFixed(1)}ms`);
expect(elapsed).toBeLessThan(3000);
});
});
+140
View File
@@ -0,0 +1,140 @@
/**
* AI API Tests Auth guard, validation, and service availability checks
* Does NOT test actual OpenRouter API calls.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('AI API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let authToken: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Register a user to get an auth token
const registerRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
email: 'ai-test@example.com',
password: 'TestPass123!',
name: 'AI Test User',
role: 'planer',
},
});
const body = JSON.parse(registerRes.body);
authToken = body.session.token;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Auth Guard ─────────────────────────────────────
describe('POST /api/ai/chat Authentication', () => {
it('should return 401 without authorization header', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(401);
const body = JSON.parse(response.body);
expect(body.error).toContain('Authentication required');
});
it('should return 401 with invalid token', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: 'Bearer invalid-token-xxx' },
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(401);
const body = JSON.parse(response.body);
expect(body.error).toContain('Invalid or expired session');
});
it('should return 401 with malformed authorization header', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: 'NotBearer sometoken' },
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(401);
});
});
// ─── Validation ─────────────────────────────────────
describe('POST /api/ai/chat Validation', () => {
it('should return 400 when messages array is missing', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: {},
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Messages array is required');
});
it('should return 400 when messages array is empty', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: { messages: [] },
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Messages array is required');
});
it('should return 400 when messages is not an array', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: { messages: 'not an array' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── Service Availability ───────────────────────────
describe('POST /api/ai/chat Service availability', () => {
it('should return 503 when API_KEY_OPENROUTER is not set', async () => {
// Ensure env var is not set for this test
const originalValue = process.env.API_KEY_OPENROUTER;
delete process.env.API_KEY_OPENROUTER;
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(503);
const body = JSON.parse(response.body);
expect(body.error).toContain('AI service not configured');
// Restore original value if it existed
if (originalValue !== undefined) {
process.env.API_KEY_OPENROUTER = originalValue;
}
});
});
});
+269
View File
@@ -0,0 +1,269 @@
/**
* Auth API Tests Register / Login / Logout / Me / Password Change
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Auth API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let authToken: string | null = null;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Register ────────────────────────────────────────
describe('POST /api/auth/register', () => {
it('should register a new user with 201', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
email: 'testuser@example.com',
password: 'TestPass123!',
name: 'Test User',
role: 'planer',
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.user).toBeDefined();
expect(body.user.email).toBe('testuser@example.com');
expect(body.user.name).toBe('Test User');
expect(body.user.password_hash).toBeUndefined();
expect(body.session).toBeDefined();
expect(body.session.token).toBeDefined();
authToken = body.session.token;
});
it('should reject duplicate registration with 409', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
email: 'testuser@example.com',
password: 'TestPass123!',
name: 'Test User 2',
},
});
expect(response.statusCode).toBe(409);
const body = JSON.parse(response.body);
expect(body.error).toContain('already registered');
});
it('should reject missing fields with 400', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'incomplete@example.com' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── Login ──────────────────────────────────────────
describe('POST /api/auth/login', () => {
it('should login with correct credentials', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'testuser@example.com',
password: 'TestPass123!',
},
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.user).toBeDefined();
expect(body.session.token).toBeDefined();
authToken = body.session.token;
});
it('should reject wrong password with 401', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'testuser@example.com',
password: 'WrongPassword!',
},
});
expect(response.statusCode).toBe(401);
});
it('should reject non-existent email with 401', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'nobody@example.com',
password: 'SomePassword!',
},
});
expect(response.statusCode).toBe(401);
});
it('should reject missing fields with 400', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { email: 'testuser@example.com' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── Me ─────────────────────────────────────────────
describe('GET /api/auth/me', () => {
it('should return current user profile with valid token', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/auth/me',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.email).toBe('testuser@example.com');
expect(body.password_hash).toBeUndefined();
});
it('should reject without token with 401', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/auth/me',
});
expect(response.statusCode).toBe(401);
});
it('should reject invalid token with 401', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/auth/me',
headers: { authorization: 'Bearer invalid-token-xxx' },
});
expect(response.statusCode).toBe(401);
});
});
// ─── Password Change ───────────────────────────────
describe('PATCH /api/auth/password', () => {
it('should change password with correct old password', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/auth/password',
headers: { authorization: `Bearer ${authToken}` },
payload: {
oldPassword: 'TestPass123!',
newPassword: 'NewPassword456!',
},
});
expect(response.statusCode).toBe(204);
});
it('should allow login with new password', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'testuser@example.com',
password: 'NewPassword456!',
},
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.session.token).toBeDefined();
authToken = body.session.token;
});
it('should reject old password after change', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'testuser@example.com',
password: 'TestPass123!',
},
});
expect(response.statusCode).toBe(401);
});
it('should reject password change with wrong old password', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/auth/password',
headers: { authorization: `Bearer ${authToken}` },
payload: {
oldPassword: 'WrongOldPassword!',
newPassword: 'AnotherNew789!',
},
});
expect(response.statusCode).toBe(400);
});
it('should reject password change without auth', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/auth/password',
payload: {
oldPassword: 'NewPassword456!',
newPassword: 'AnotherNew789!',
},
});
expect(response.statusCode).toBe(401);
});
it('should reject missing password fields', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/auth/password',
headers: { authorization: `Bearer ${authToken}` },
payload: { oldPassword: 'NewPassword456!' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── Logout ─────────────────────────────────────────
describe('POST /api/auth/logout', () => {
it('should logout and invalidate token', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/logout',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(204);
// Token should now be invalid
const meResponse = await app.inject({
method: 'GET',
url: '/api/auth/me',
headers: { authorization: `Bearer ${authToken}` },
});
expect(meResponse.statusCode).toBe(401);
});
it('should return 204 even without token', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/logout',
});
expect(response.statusCode).toBe(204);
});
});
});
+216
View File
@@ -0,0 +1,216 @@
/**
* 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;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create project → drawing hierarchy
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Blocks Test Project' },
});
const projectId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: { name: 'Blocks Test Drawing' },
});
drawingId = JSON.parse(drawRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── 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`,
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`,
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`,
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`,
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`,
});
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',
payload: { name: 'Empty Blocks Project' },
});
const projId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projId}/drawings`,
payload: { name: 'Empty Drawing' },
});
const emptyDrawId = JSON.parse(drawRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${emptyDrawId}/blocks`,
});
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}`,
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}`,
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}`,
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',
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`,
payload: { name: 'To Be Deleted' },
});
const blockId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/blocks/${blockId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone via list
const listRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/blocks`,
});
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',
});
expect(response.statusCode).toBe(404);
});
});
});
+191
View File
@@ -0,0 +1,191 @@
/**
* 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;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create a project first (drawings belong to projects)
const projectRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Drawings Test Project' },
});
projectId = JSON.parse(projectRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── 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`,
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`,
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`,
});
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',
payload: { name: 'Empty Project' },
});
const emptyProjId = JSON.parse(projRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/projects/${emptyProjId}/drawings`,
});
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}`,
});
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',
});
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}`,
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}`,
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',
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`,
payload: { name: 'To Be Deleted' },
});
const drawingId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/drawings/${drawingId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone
const getRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}`,
});
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',
});
expect(response.statusCode).toBe(404);
});
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* 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;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create project → drawing → layer hierarchy
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Elements Test Project' },
});
const projectId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: { name: 'Elements Test Drawing' },
});
drawingId = JSON.parse(drawRes.body).id;
const layerRes = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/layers`,
payload: { name: 'Elements Layer' },
});
layerId = JSON.parse(layerRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── 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`,
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`,
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`,
});
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',
payload: { name: 'Empty Elements Project' },
});
const projId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projId}/drawings`,
payload: { name: 'Empty Drawing' },
});
const emptyDrawId = JSON.parse(drawRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${emptyDrawId}/elements`,
});
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}`,
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}`,
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}`,
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',
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`,
payload: { layer_id: layerId, type: 'line' },
});
const elemId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/elements/${elemId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone via list
const listRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/elements`,
});
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',
});
expect(response.statusCode).toBe(404);
});
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Health Endpoint Tests
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Health Endpoint', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
});
afterAll(async () => {
await app.close();
db.close();
});
it('GET /api/health should return ok status', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/health',
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.status).toBe('ok');
expect(body.timestamp).toBeDefined();
});
it('GET /api/health should return valid ISO timestamp', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/health',
});
const body = JSON.parse(response.body);
const date = new Date(body.timestamp);
expect(date.toString()).not.toBe('Invalid Date');
});
});
+199
View File
@@ -0,0 +1,199 @@
/**
* 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;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create project → drawing hierarchy
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Layers Test Project' },
});
const projectId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: { name: 'Layers Test Drawing' },
});
drawingId = JSON.parse(drawRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── 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`,
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`,
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`,
});
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',
payload: { name: 'Empty Layers Project' },
});
const projId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projId}/drawings`,
payload: { name: 'Empty Drawing' },
});
const emptyDrawId = JSON.parse(drawRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${emptyDrawId}/layers`,
});
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}`,
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}`,
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}`,
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',
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`,
payload: { name: 'To Be Deleted' },
});
const layerId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/layers/${layerId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone via list
const listRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/layers`,
});
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',
});
expect(response.statusCode).toBe(404);
});
});
});
+176
View File
@@ -0,0 +1,176 @@
/**
* 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;
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();
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/projects', () => {
it('should create a project with 201', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/projects',
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',
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',
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',
});
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}`,
});
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',
});
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}`,
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}`,
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',
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',
payload: { name: 'To Be Deleted' },
});
const projectId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/projects/${projectId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone
const getRes = await app.inject({
method: 'GET',
url: `/api/projects/${projectId}`,
});
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',
});
expect(response.statusCode).toBe(404);
});
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* 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;
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();
});
// ─── 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',
});
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',
payload: { value: 'test-value' },
});
const response = await app.inject({
method: 'GET',
url: '/api/settings/test-key',
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.key).toBe('test-key');
expect(body.value).toBe('test-value');
expect(body.updated_at).toBeDefined();
});
});
// ─── Put (upsert) ───────────────────────────────────
describe('PUT /api/settings/:key', () => {
it('should create a new setting', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/new-setting',
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',
payload: { value: 'initial' },
});
// Update it
const response = await app.inject({
method: 'PUT',
url: '/api/settings/upsert-key',
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',
});
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',
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',
payload: { value: complexValue },
});
expect(response.statusCode).toBe(200);
const getRes = await app.inject({
method: 'GET',
url: '/api/settings/complex-config',
});
const body = JSON.parse(getRes.body);
expect(body.value).toBe(complexValue);
});
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* 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;
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;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── List ───────────────────────────────────────────
describe('GET /api/users', () => {
it('should list all users', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users',
});
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',
});
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}`,
});
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}`,
});
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',
});
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}`,
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}`,
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}`,
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}`,
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',
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}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone
const getRes = await app.inject({
method: 'GET',
url: `/api/users/${user.id}`,
});
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',
});
expect(response.statusCode).toBe(404);
});
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/types/**', 'src/websocket/**'],
},
},
});
+6 -1
View File
@@ -4,12 +4,17 @@ services:
backend: backend:
build: ./backend build: ./backend
ports: ports:
- "5000:5000" - "3001:3001"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- JWT_SECRET=your-secret-key-change-me - JWT_SECRET=your-secret-key-change-me
volumes: volumes:
- sqlite_data:/app/data - sqlite_data:/app/data
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped restart: unless-stopped
frontend: frontend:
+2 -2
View File
@@ -1,10 +1,10 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>Web CAD</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+19 -20
View File
@@ -1,25 +1,24 @@
server { server {
listen 80; listen 80;
server_name localhost; root /usr/share/nginx/html;
index index.html;
root /usr/share/nginx/html; resolver 127.0.0.11 valid=30s;
index index.html;
# Serve static frontend location / { try_files $uri $uri/ /index.html; }
location / {
try_files $uri /index.html;
}
# Proxy API requests to backend location /api/ {
location /api/ { set $backend http://backend:3001;
proxy_pass http://backend:5000; proxy_pass $backend;
proxy_http_version 1.1; proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Connection 'upgrade'; }
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade; location /ws {
proxy_set_header X-Real-IP $remote_addr; set $backend http://backend:3001;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass $backend;
proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1;
} proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
} }
+5080 -420
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:5000', '/api': 'http://localhost:3001',
}, },
}, },
preview: { preview: {