Initial commit: IT Nexus Web-App

This commit is contained in:
2026-06-01 20:49:07 +02:00
commit 8023765e6c
387 changed files with 106900 additions and 0 deletions

7
backend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
npm-debug.log
.env
.git
.gitignore
*.md
database.sqlite

20
backend/.env.example Normal file
View File

@@ -0,0 +1,20 @@
# Server Configuration
NODE_ENV=development
PORT=5000
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRATION=8h
# Database
DATABASE_PATH=./database.sqlite
# Frontend URL (for CORS)
FRONTEND_URL=http://localhost:3000
# Security
BCRYPT_ROUNDS=12
# Rate Limiting
RATE_LIMIT_WINDOW=15
RATE_LIMIT_MAX_REQUESTS=100

26
backend/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# Backend Dockerfile für FIDO-Key Management System
FROM node:18-alpine
# Arbeitsverzeichnis erstellen
WORKDIR /app
# Python und Build-Tools für better-sqlite3 installieren
RUN apk add --no-cache python3 make g++
# Package files kopieren
COPY package*.json ./
# Dependencies installieren
RUN npm install --only=production
# Source Code kopieren
COPY . .
# Port freigeben
EXPOSE 5000
# Umgebungsvariablen
ENV NODE_ENV=production
# Server starten
CMD ["node", "src/server.js"]

3469
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
backend/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "fido-key-manager-backend",
"version": "1.0.0",
"description": "Backend for FIDO-Key Management System",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"init-db": "node src/db/seed.js"
},
"keywords": [
"fido",
"key-management",
"authentication"
],
"author": "",
"license": "ISC",
"dependencies": {
"bcrypt": "^5.1.1",
"better-sqlite3": "^12.6.2",
"bwip-js": "^4.8.0",
"imapflow": "^1.0.0",
"node-cron": "^3.0.0",
"nodemailer": "^6.9.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"pdfkit": "^0.15.0",
"qrcode": "^1.5.4",
"@anthropic-ai/sdk": "^0.39.0",
"botbuilder": "^4.23.3",
"pdf-parse": "^1.1.1",
"mammoth": "^1.8.0",
"mailparser": "^3.7.1",
"net-snmp": "^3.11.0",
"multer": "^1.4.5-lts.1"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

128
backend/seed-knowledge.js Normal file
View File

@@ -0,0 +1,128 @@
const db = require('./src/config/database').getDatabase();
const entries = [
['IT Nexus Server', `IP: 192.168.0.194
SSH: root@192.168.0.194
Projekt: /opt/it-nexus/
Container: fido-backend (Port 5000), fido-frontend (Port 443/80)
URL: https://it-nexus.cereda-systems.de
Agent-Script: /opt/it-nexus/agent/ (Volume-Mount ./agent:/app/agent:ro)
Deploy: scp → Server → docker compose build X && docker compose up -d X`, 'Infrastruktur'],
['Proxmox Host', `IP: 192.168.0.184
SSH: root@192.168.0.184
Node: hve-01
API Token: root@pam!monitoring=ca287965-3d33-49ec-a069-1bb80fd2e95d
Web UI: https://192.168.0.184:8006`, 'Proxmox'],
['LXC Container Übersicht', `CT 102 | IT Nexus | 192.168.0.194 | Hauptproduktion
CT 103 | Diverses | - | Docker-Container
CT 110 | CT-TBO-AI | 192.168.0.61 | AI-Projekt (Björn Enders)
CT 111 | Unbekannt | - | ?
CT-110 SSH: root@192.168.0.61
Key-Datei: C:/Users/gruessing/OneDrive - Cereda Systems GmbH/Desktop/ct110_key`, 'Proxmox'],
['Storage & Netzwerk', `Synology NAS: 192.168.0.60
iSCSI-LUN: 500GB → iscsi-synology auf Proxmox → iscsi-lvm (LVM on top)
IT Nexus CT: läuft auf local-lvm (NICHT auf NAS - wichtig!)
Gateway: 192.168.0.254
Domain: cereda-systems.de`, 'Infrastruktur'],
['Deploy-Workflow', `SCHNELL (einzelne Datei, kein Rebuild):
scp datei.js root@192.168.0.194:/opt/it-nexus/pfad/datei.js
docker cp /opt/it-nexus/pfad/datei.js fido-backend:/app/pfad/datei.js
docker restart fido-backend
VOLLSTÄNDIG (nach vielen Änderungen):
scp ... root@192.168.0.194:/opt/it-nexus/...
ssh root@192.168.0.194 "cd /opt/it-nexus && docker compose build backend frontend && docker compose up -d backend frontend"
WICHTIG: docker compose restart rebuildet NICHT → immer build + up -d
Agent-Dateien: Volume-Mount → kein Rebuild nötig`, 'IT Nexus'],
['IT Nexus Rollen & Architektur', `Rollen:
- super_admin, admin → Vollzugriff
- support, bearbeiter → Helpdesk + IT-Verwaltung
- benutzer, hr_personal, buchhaltung, produktion → eingeschränkt
WICHTIG: user.role_name im Frontend (NICHT user.role!)
Stack: Node.js + Express + SQLite + React (CRA) + nginx
KI: Claude claude-sonnet-4-6 via Anthropic API
Integrationen: Microsoft Graph API, Proxmox REST API, Azure AD`, 'IT Nexus'],
['Windows Agent v1.3.0', `Check-in: alle 1 Minute als Scheduled Task (SYSTEM)
Announcement Watcher: alle 15 Sekunden (separater Task)
Auto-Update: beim Check-in wenn agent_version in Response abweicht
Staged Rollout: per Gruppe (Test → Pilot → Produktion)
Endpunkte ohne JWT (nur X-Agent-Key):
POST /api/monitoring/checkin
POST /api/monitoring/announcements-poll
POST /api/patch/commands/result
POST /api/announcements/:id/ack-agent
GET /api/monitoring/agent-script
WICHTIG: Läuft als SYSTEM → Dialoge NIE direkt anzeigen!
Fix: ScheduledTask als eingeloggter User erstellen`, 'Agent'],
['Intune Agent-Deployment', `Erkennungsregel (Registry):
Pfad: HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{B3F7A2C1-4E8D-4F2A-9B1C-7D3E5F6A8B2C}
Wertname: DisplayVersion
Operator: Größer oder gleich (aktuelle Version)
32-Bit auf 64-Bit: JA (Pflicht!)
Fehler 0x87D1041C = Erkennungsregel-Pfad falsch (kein _is1 Suffix!)
Install: msiexec /i Agent.msi /quiet /norestart`, 'Agent'],
['Docker in LXC - Fix runc', `PROBLEM: Unprivileged LXC + Docker 29.x + runc 1.3.x = BRICHT
Fehler: open sysctl net.ipv4.ip_unprivileged_port_start: permission denied
FIX - runc 1.2.5 von GitHub installieren:
curl -L https://github.com/opencontainers/runc/releases/download/v1.2.5/runc.amd64 -o /usr/bin/runc
chmod +x /usr/bin/runc
CT-102: Docker 27.5.1 + Ubuntu 24.04 runc 1.2.5 → funktioniert
CT-110: Docker 29.4.2 + manuell runc 1.2.5 → funktioniert nach Fix
WICHTIG: unprivileged lässt sich nach Erstellung NICHT mehr ändern!
LXC Config für Docker: features: nesting=1 (nur das reicht!)`, 'Erkenntnisse'],
['Microsoft Graph & Azure', `Benötigte Application Permissions:
- Mail.ReadWrite (Postfach lesen/schreiben)
- SecurityAlert.Read.All (MDO Alerts)
- Group.Read.All, User.Read.All (Entra-Verwaltung)
- Team.ReadBasic.All (fehlt noch → Teams-Tab nicht voll funktionsfähig)
MDO: SecurityAlert.Read.All braucht Admin Consent!
Monitoring-Mailbox: helpdesk@cereda-systems.de`, 'Sicherheit'],
['Aktive Projekte Mai 2026', `✅ Monitoring (CheckMK-Style + Proxmox integriert)
✅ Patch Management (Staged Rollout Test→Pilot→Produktion)
✅ Windows Agent v1.3.0
✅ Ankündigungen (Desktop-Popup via Agent, 15-Sek Poll)
✅ Microsoft Defender / MDO Tab
✅ Docker CT-110 Fix (runc 1.2.5)
✅ KI Wissendatenbank
🔄 Agent v1.4.0: Software-Inventar, BitLocker, Defender-Status (geplant)
⏳ VM-Migrationen: CT102, CT103, VM109 noch auf CeredaNAS NFS`, 'Projekte'],
['API Keys & Zugänge', `Agent API Key: itx-4CPJPTHmCfdrL9D62WacCATEvuvULXcp7ECMpSaNUjsS344F6_L4Ug
Anthropic API: in docker-compose.yml als ANTHROPIC_API_KEY
Proxmox API Token: root@pam!monitoring=ca287965-3d33-49ec-a069-1bb80fd2e95d
Teams Bot App ID: bd39de7e
Teams Manifest-ID: b86adea3 (NIE ändern!)
CT-110 SSH: root@192.168.0.61 | Key: Desktop/ct110_key`, 'Zugänge'],
];
const existing = db.prepare('SELECT COUNT(*) as c FROM ai_knowledge').get().c;
if (existing > 0) {
console.log('Einträge bereits vorhanden:', existing);
process.exit(0);
}
const stmt = db.prepare('INSERT INTO ai_knowledge (title, content, category) VALUES (?, ?, ?)');
entries.forEach(([t, c, cat]) => stmt.run(t, c, cat));
console.log('Fertig - Einträge eingefügt:', entries.length);

View File

@@ -0,0 +1,41 @@
const Database = require('better-sqlite3');
const path = require('path');
require('dotenv').config();
const DB_PATH = process.env.DATABASE_PATH || './database.sqlite';
let db = null;
function getDatabase() {
if (!db) {
try {
db = new Database(DB_PATH);
// Enable foreign keys
db.pragma('foreign_keys = ON');
// Performance optimizations
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
console.log('📦 Database connected successfully');
} catch (error) {
console.error('❌ Database connection failed:', error.message);
throw error;
}
}
return db;
}
function closeDatabase() {
if (db) {
db.close();
db = null;
console.log('📦 Database connection closed');
}
}
module.exports = {
getDatabase,
closeDatabase
};

14
backend/src/config/jwt.js Normal file
View File

@@ -0,0 +1,14 @@
require('dotenv').config();
const JWT_CONFIG = {
secret: process.env.JWT_SECRET || 'default-secret-change-in-production',
expiresIn: process.env.JWT_EXPIRATION || '8h',
algorithm: 'HS256'
};
// Validate that JWT_SECRET is set
if (!process.env.JWT_SECRET) {
console.warn('⚠️ WARNING: JWT_SECRET not set in .env file. Using default secret (INSECURE!)');
}
module.exports = JWT_CONFIG;

View File

@@ -0,0 +1,357 @@
const { asyncHandler, AppError } = require('../middleware/errorHandler');
const AiService = require('../services/ai.service');
const { getDatabase } = require('../config/database');
const path = require('path');
const fs = require('fs');
class AiController {
/**
* GET /api/ai/status
* Prüft ob AI konfiguriert ist
*/
static getStatus = asyncHandler(async (req, res) => {
res.json({
status: 'success',
data: { configured: AiService.isConfigured() }
});
});
/**
* POST /api/ai/chat
* Body: { messages: [{role, content}] }
*/
static chat = asyncHandler(async (req, res) => {
if (!AiService.isConfigured()) {
throw new AppError('KI-Integration ist nicht konfiguriert (ANTHROPIC_API_KEY fehlt)', 503);
}
const { messages, userMode } = req.body;
if (!messages || !Array.isArray(messages) || messages.length === 0) {
throw new AppError('messages Array ist erforderlich', 400);
}
// Nur erlaubte Rollen: user und assistant
const cleaned = messages
.filter(m => ['user', 'assistant'].includes(m.role) && typeof m.content === 'string')
.map(m => ({ role: m.role, content: m.content.slice(0, 4000) }));
if (cleaned.length === 0) {
throw new AppError('Keine gültigen Nachrichten', 400);
}
const reply = await AiService.chat(cleaned, !!userMode);
res.json({ status: 'success', data: { reply } });
});
/**
* GET /api/ai/knowledge-base
*/
static getKnowledgeBase = asyncHandler(async (req, res) => {
const db = getDatabase();
const entries = db.prepare(`
SELECT kb.*, u.username as created_by_username
FROM knowledge_base kb
LEFT JOIN users u ON kb.created_by_user_id = u.id
ORDER BY kb.created_at DESC
`).all();
res.json({ status: 'success', data: entries });
});
/**
* POST /api/ai/knowledge-base
* Body: { problem, solution, category, tags }
*/
static addKnowledgeEntry = asyncHandler(async (req, res) => {
const { problem, solution, category, tags } = req.body;
if (!problem?.trim() || !solution?.trim()) {
throw new AppError('problem und solution sind erforderlich', 400);
}
const db = getDatabase();
const result = db.prepare(`
INSERT INTO knowledge_base (problem, solution, category, tags, created_by_user_id)
VALUES (?, ?, ?, ?, ?)
`).run(
problem.trim(),
solution.trim(),
category || 'Allgemein',
tags?.trim() || null,
req.user.id
);
const entry = db.prepare('SELECT * FROM knowledge_base WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ status: 'success', data: entry });
});
/**
* PUT /api/ai/knowledge-base/:id
* Body: { problem, solution, category, tags }
*/
static updateKnowledgeEntry = asyncHandler(async (req, res) => {
const db = getDatabase();
const entry = db.prepare('SELECT id FROM knowledge_base WHERE id = ?').get(parseInt(req.params.id));
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
const { problem, solution, category, tags } = req.body;
if (!problem?.trim() || !solution?.trim()) {
throw new AppError('problem und solution sind erforderlich', 400);
}
db.prepare(`
UPDATE knowledge_base SET problem = ?, solution = ?, category = ?, tags = ? WHERE id = ?
`).run(problem.trim(), solution.trim(), category || 'Allgemein', tags?.trim() || null, parseInt(req.params.id));
const updated = db.prepare(`
SELECT kb.*, u.username as created_by_username
FROM knowledge_base kb LEFT JOIN users u ON kb.created_by_user_id = u.id
WHERE kb.id = ?
`).get(parseInt(req.params.id));
res.json({ status: 'success', data: updated });
});
/**
* POST /api/ai/knowledge-base/import-text
* Body: { text } OR { file: "<base64>", filename: "doc.pdf" }
*/
static importTextToKb = asyncHandler(async (req, res) => {
const AiService = require('../services/ai.service');
let text = req.body.text;
// Handle file upload (base64 encoded)
if (!text && req.body.file && req.body.filename) {
const { file, filename } = req.body;
const ext = filename.split('.').pop().toLowerCase();
const match = file.match(/^data:[^;]+;base64,(.+)$/);
const buf = Buffer.from(match ? match[1] : file, 'base64');
if (ext === 'pdf') {
const pdfParse = require('pdf-parse');
const parsed = await pdfParse(buf);
text = parsed.text;
} else if (ext === 'docx' || ext === 'doc') {
const mammoth = require('mammoth');
const result = await mammoth.extractRawText({ buffer: buf });
text = result.value;
} else if (ext === 'eml' || ext === 'msg') {
const { simpleParser } = require('mailparser');
const parsed = await simpleParser(buf);
text = `Betreff: ${parsed.subject || ''}\nVon: ${parsed.from?.text || ''}\n\n${parsed.text || parsed.html || ''}`;
} else if (ext === 'txt') {
text = buf.toString('utf-8');
} else {
throw new AppError('Unterstützte Formate: PDF, DOCX, EML, TXT', 400);
}
}
if (!text?.trim()) throw new AppError('text oder Datei ist erforderlich', 400);
const ids = await AiService.importTextToKb(text, req.user.id);
res.json({ status: 'success', data: { created: ids.length, ids } });
});
/**
* POST /api/ai/knowledge-base/import-url
* Body: { url }
* Fetches a web page, strips HTML, extracts text, and imports it via AI
*/
static importUrlToKb = asyncHandler(async (req, res) => {
const AiService = require('../services/ai.service');
const { url } = req.body;
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
// Only allow http/https
if (!/^https?:\/\//i.test(url)) throw new AppError('Nur HTTP/HTTPS URLs erlaubt', 400);
let html;
try {
const response = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 IT-Nexus KnowledgeBase Importer' },
signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new AppError(`Seite nicht erreichbar: HTTP ${response.status}`, 400);
html = await response.text();
} catch (err) {
if (err instanceof AppError) throw err;
throw new AppError(`Fehler beim Abrufen der URL: ${err.message}`, 400);
}
// Strip scripts, styles, nav, header, footer then all HTML tags
const text = html
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
.replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim();
if (text.length < 100) throw new AppError('Zu wenig Inhalt auf der Seite gefunden', 400);
const ids = await AiService.importTextToKb(text, req.user.id);
res.json({ status: 'success', data: { created: ids.length, ids } });
});
/**
* POST /api/ai/knowledge-base/import-crawl
* Body: { url, maxPages }
* Crawls a website (BFS within same domain+path) and imports all pages via AI
*/
static importCrawlToKb = asyncHandler(async (req, res) => {
const AiService = require('../services/ai.service');
const { url, maxPages = 20 } = req.body;
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
if (!/^https?:\/\//i.test(url)) throw new AppError('Nur HTTP/HTTPS URLs erlaubt', 400);
const limit = Math.min(Math.max(1, parseInt(maxPages) || 20), 100);
const baseUrl = new URL(url);
const baseDomain = baseUrl.hostname;
// basePath: bei index-Seiten oder explizit deaktiviertem Pfad-Filter nur nach Hostname filtern
let basePath = baseUrl.pathname.substring(0, baseUrl.pathname.lastIndexOf('/') + 1);
if (/\/index\.(html?|htm)$/i.test(baseUrl.pathname)) {
basePath = '/'; // Gesamte Domain crawlen wenn Index-Seite
}
const stripHtml = (html) => html
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
.replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/\s+/g, ' ').trim();
const visited = new Set();
const queue = [url];
const texts = [];
while (queue.length > 0 && visited.size < limit) {
const currentUrl = queue.shift();
if (visited.has(currentUrl)) continue;
visited.add(currentUrl);
try {
const response = await fetch(currentUrl, {
headers: { 'User-Agent': 'Mozilla/5.0 IT-Nexus KnowledgeBase Importer' },
signal: AbortSignal.timeout(10000),
});
if (!response.ok) continue;
const html = await response.text();
const text = stripHtml(html);
if (text.length > 100) texts.push(text);
// Extract links within same domain + path prefix
const linkRegex = /href="([^"#?]+)"/gi;
let match;
while ((match = linkRegex.exec(html)) !== null) {
try {
const linkUrl = new URL(match[1], currentUrl);
if (
linkUrl.hostname === baseDomain &&
linkUrl.pathname.startsWith(basePath) &&
!visited.has(linkUrl.href) &&
!queue.includes(linkUrl.href)
) {
queue.push(linkUrl.href);
}
} catch { /* ignore invalid URLs */ }
}
} catch { /* ignore fetch errors for individual pages */ }
}
if (texts.length === 0) throw new AppError('Keine Inhalte gefunden', 400);
// Process in chunks of 40k chars to stay within AI context limits
const CHUNK = 40000;
const combined = texts.join('\n\n---\n\n');
let allIds = [];
for (let i = 0; i < combined.length; i += CHUNK) {
const chunk = combined.slice(i, i + CHUNK);
if (chunk.trim().length > 100) {
const ids = await AiService.importTextToKb(chunk, req.user.id);
allIds = allIds.concat(ids);
}
}
res.json({ status: 'success', data: { created: allIds.length, pages: visited.size, ids: allIds } });
});
/**
* POST /api/ai/knowledge-base/:id/images
* Body: { image: "<base64>", filename: "foto.png" }
*/
static uploadKbImage = asyncHandler(async (req, res) => {
const db = getDatabase();
const id = parseInt(req.params.id);
const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(id);
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
const { image, filename } = req.body;
if (!image || !filename) throw new AppError('image und filename erforderlich', 400);
// Validate base64 image
const match = image.match(/^data:(image\/(png|jpe?g|gif|webp));base64,(.+)$/);
if (!match) throw new AppError('Ungültiges Bildformat (PNG, JPG, GIF, WEBP erlaubt)', 400);
const ext = match[2].replace('jpeg', 'jpg');
const safeName = `kb-${id}-${Date.now()}.${ext}`;
const uploadDir = path.join(__dirname, '../../uploads/kb');
fs.mkdirSync(uploadDir, { recursive: true });
fs.writeFileSync(path.join(uploadDir, safeName), Buffer.from(match[3], 'base64'));
const images = JSON.parse(entry.images || '[]');
images.push(safeName);
db.prepare('UPDATE knowledge_base SET images = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(JSON.stringify(images), id);
res.json({ status: 'success', data: { filename: safeName, url: `/uploads/kb/${safeName}` } });
});
/**
* DELETE /api/ai/knowledge-base/:id/images/:filename
*/
static deleteKbImage = asyncHandler(async (req, res) => {
const db = getDatabase();
const id = parseInt(req.params.id);
const { filename } = req.params;
const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(id);
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
const images = JSON.parse(entry.images || '[]').filter(f => f !== filename);
db.prepare('UPDATE knowledge_base SET images = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(JSON.stringify(images), id);
// Delete file
const filePath = path.join(__dirname, '../../uploads/kb', filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
res.json({ status: 'success', message: 'Bild gelöscht' });
});
/**
* DELETE /api/ai/knowledge-base/:id
*/
static deleteKnowledgeEntry = asyncHandler(async (req, res) => {
const db = getDatabase();
const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(parseInt(req.params.id));
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
// Delete all associated images
const images = JSON.parse(entry.images || '[]');
for (const f of images) {
const fp = path.join(__dirname, '../../uploads/kb', f);
if (fs.existsSync(fp)) fs.unlinkSync(fp);
}
db.prepare('DELETE FROM knowledge_base WHERE id = ?').run(parseInt(req.params.id));
res.json({ status: 'success', message: 'Eintrag gelöscht' });
});
}
module.exports = AiController;

View File

@@ -0,0 +1,169 @@
const { getDatabase } = require('../config/database');
// Hilfsfunktion: welche Ankündigungen sind für einen Agenten relevant (nach Gruppe)?
function getForAgent(agentId) {
const db = getDatabase();
const now = new Date().toISOString();
// Gruppe des Agenten ermitteln
const groupRow = db.prepare(`
SELECT group_id FROM patch_agent_groups WHERE agent_id = ? LIMIT 1
`).get(agentId);
const groupId = groupRow?.group_id ?? null;
const all = db.prepare(`
SELECT * FROM announcements
WHERE active = 1
AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC
`).all(now);
return all.filter(a => {
// Spezifische Agent-IDs haben höchste Priorität (type-safe: immer als Number vergleichen)
const agentIds = JSON.parse(a.target_agent_ids || '[]').map(Number).filter(Boolean);
if (agentIds.length > 0) return agentIds.includes(Number(agentId));
// Gruppen-Filter
const groups = JSON.parse(a.target_groups || '[]');
return groups.length === 0 || (groupId && groups.includes(groupId));
}).filter(a => {
// Noch nicht vom Agenten bestätigt?
const ack = db.prepare('SELECT 1 FROM announcement_acks WHERE announcement_id=? AND agent_id=?').get(a.id, agentId);
return !ack;
}).map(a => ({ id: a.id, title: a.title, message: a.message, type: a.type }));
}
// Agent meldet Bestätigung (kein JWT, nur Agent-Key)
const agentAck = (req, res) => {
const agentKey = req.headers['x-agent-key'];
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { hostname } = req.body;
if (!hostname) return res.status(400).json({ error: 'hostname required' });
const db = getDatabase();
const agent = db.prepare('SELECT id FROM monitoring_agents WHERE hostname=?').get(hostname);
if (!agent) return res.status(404).json({ error: 'Agent not found' });
try {
db.prepare('INSERT OR IGNORE INTO announcement_acks (announcement_id, agent_id, hostname) VALUES (?,?,?)')
.run(req.params.id, agent.id, hostname);
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
};
// Admin: alle Ankündigungen
const getAll = (req, res) => {
const db = getDatabase();
const rows = db.prepare(`
SELECT a.*, u.username as created_by_name,
COUNT(ak.agent_id) as ack_count
FROM announcements a
LEFT JOIN users u ON u.id = a.created_by_user_id
LEFT JOIN announcement_acks ak ON ak.announcement_id = a.id
GROUP BY a.id
ORDER BY a.created_at DESC
`).all();
const totalAgents = db.prepare('SELECT COUNT(*) as c FROM monitoring_agents').get().c;
res.json(rows.map(r => {
const groups = JSON.parse(r.target_groups || '[]');
const agentIds = JSON.parse(r.target_agent_ids || '[]').map(Number).filter(Boolean);
let relevant = totalAgents;
if (agentIds.length > 0) {
relevant = agentIds.length;
} else if (groups.length > 0) {
const placeholders = groups.map(() => '?').join(',');
relevant = db.prepare(`
SELECT COUNT(DISTINCT ma.id) as c FROM monitoring_agents ma
JOIN patch_agent_groups pag ON pag.agent_id = ma.id
WHERE pag.group_id IN (${placeholders})
`).get(...groups).c;
}
return { ...r, target_groups: groups, target_agent_ids: agentIds, total_agents: relevant };
}));
};
const create = (req, res) => {
const { title, message, type = 'info', target_groups = [], target_agent_ids = [], expires_at } = req.body;
if (!title?.trim() || !message?.trim()) return res.status(400).json({ error: 'Titel und Nachricht erforderlich' });
const db = getDatabase();
const result = db.prepare(`
INSERT INTO announcements (title, message, type, target_groups, target_agent_ids, created_by_user_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(title.trim(), message.trim(), type, JSON.stringify(target_groups), JSON.stringify(target_agent_ids), req.user.id, expires_at || null);
res.status(201).json(db.prepare('SELECT * FROM announcements WHERE id=?').get(result.lastInsertRowid));
};
const update = (req, res) => {
const db = getDatabase();
const existing = db.prepare('SELECT * FROM announcements WHERE id=?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
const { title, message, type, target_groups, expires_at, active } = req.body;
db.prepare(`UPDATE announcements SET title=?, message=?, type=?, target_groups=?, expires_at=?, active=? WHERE id=?`)
.run(
title ?? existing.title,
message ?? existing.message,
type ?? existing.type,
target_groups !== undefined ? JSON.stringify(target_groups) : existing.target_groups,
expires_at !== undefined ? (expires_at || null) : existing.expires_at,
active !== undefined ? (active ? 1 : 0) : existing.active,
req.params.id
);
res.json(db.prepare('SELECT * FROM announcements WHERE id=?').get(req.params.id));
};
const remove = (req, res) => {
const db = getDatabase();
if (!db.prepare('SELECT id FROM announcements WHERE id=?').get(req.params.id)) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM announcements WHERE id=?').run(req.params.id);
res.json({ success: true });
};
const getAcks = (req, res) => {
const db = getDatabase();
const acks = db.prepare(`
SELECT ak.hostname, ak.acknowledged_at
FROM announcement_acks ak
WHERE ak.announcement_id = ?
ORDER BY ak.acknowledged_at ASC
`).all(req.params.id);
res.json(acks);
};
// Aktive Ankündigungen für eingeloggte User (AppLayout Notification Bell)
const getActive = (req, res) => {
const db = getDatabase();
const now = new Date().toISOString();
const userKey = `user:${req.user.id}`;
const rows = db.prepare(`
SELECT a.id, a.title, a.message, a.type, a.created_at, a.target_groups
FROM announcements a
WHERE a.active = 1 AND (a.expires_at IS NULL OR a.expires_at > ?)
AND NOT EXISTS (
SELECT 1 FROM announcement_acks ak
WHERE ak.announcement_id = a.id AND ak.hostname = ?
)
ORDER BY a.created_at DESC
`).all(now, userKey);
res.json(rows.map(r => ({ ...r, target_groups: JSON.parse(r.target_groups || '[]') })));
};
// Web-User bestätigt Ankündigung (JWT Auth)
const userAck = (req, res) => {
const db = getDatabase();
if (!db.prepare('SELECT id FROM announcements WHERE id=?').get(req.params.id)) {
return res.status(404).json({ error: 'Nicht gefunden' });
}
try {
db.prepare('INSERT OR IGNORE INTO announcement_acks (announcement_id, agent_id, hostname) VALUES (?,?,?)')
.run(req.params.id, null, `user:${req.user.id}`);
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
};
module.exports = { getAll, getActive, create, update, remove, agentAck, getAcks, getForAgent, userAck };

View File

@@ -0,0 +1,525 @@
const AssetService = require('../services/asset.service');
const Asset = require('../models/Asset');
const LabelService = require('../services/label.service');
const { asyncHandler } = require('../middleware/errorHandler');
const { getIntuneManagedDevices } = require('../services/graph.service');
const { ROLES, TECHNIKER_DEPARTMENTS } = require('../middleware/roleCheck');
// Returns department filter for the current user (null = all departments)
function getDeptFilter(user) {
if (user.role === ROLES.TECHNIKER) return TECHNIKER_DEPARTMENTS;
return null;
}
class AssetController {
/**
* Get all assets
* GET /api/assets
*/
static getAllAssets = asyncHandler(async (req, res) => {
const departments = getDeptFilter(req.user);
const assets = AssetService.getAllAssets(departments);
res.json({
status: 'success',
data: assets
});
});
/**
* Get assets assigned to the current user
* GET /api/assets/mine
*/
static getMyAssets = asyncHandler(async (req, res) => {
const assets = Asset.getByAssignedUser(req.user.id);
res.json({ status: 'success', data: assets });
});
/**
* Get asset by ID
* GET /api/assets/:id
*/
static getAssetById = asyncHandler(async (req, res) => {
const { id } = req.params;
const asset = AssetService.getAssetById(parseInt(id));
res.json({
status: 'success',
data: asset
});
});
/**
* Get asset by serial number
* GET /api/assets/serial/:serialNumber
*/
static getAssetBySerial = asyncHandler(async (req, res) => {
const { serialNumber } = req.params;
const asset = AssetService.getAssetBySerial(serialNumber);
res.json({
status: 'success',
data: asset
});
});
/**
* Get assets by status
* GET /api/assets/status/:status
*/
static getAssetsByStatus = asyncHandler(async (req, res) => {
const { status } = req.params;
const assets = AssetService.getAssetsByStatus(status);
res.json({
status: 'success',
data: assets
});
});
/**
* Get assets by type
* GET /api/assets/type/:type
*/
static getAssetsByType = asyncHandler(async (req, res) => {
const { type } = req.params;
const assets = AssetService.getAssetsByType(type);
res.json({
status: 'success',
data: assets
});
});
/**
* Create new asset
* POST /api/assets
*/
static createAsset = asyncHandler(async (req, res) => {
const asset = AssetService.createAsset(
req.body,
req.user.id,
req.ip
);
res.status(201).json({
status: 'success',
data: asset
});
});
/**
* Update asset
* PUT /api/assets/:id
*/
static updateAsset = asyncHandler(async (req, res) => {
const { id } = req.params;
const asset = AssetService.updateAsset(
parseInt(id),
req.body,
req.user.id,
req.ip
);
res.json({
status: 'success',
data: asset
});
});
/**
* Delete asset
* DELETE /api/assets/:id
*/
static deleteAsset = asyncHandler(async (req, res) => {
const { id } = req.params;
const deleted = AssetService.deleteAsset(
parseInt(id),
req.user.id,
req.ip
);
if (!deleted) {
return res.status(404).json({
status: 'error',
message: 'Asset nicht gefunden'
});
}
res.json({
status: 'success',
message: 'Asset erfolgreich gelöscht'
});
});
/**
* Assign asset to user
* POST /api/assets/:id/assign
*/
static assignAsset = asyncHandler(async (req, res) => {
const { id } = req.params;
const { user_id, notes } = req.body;
if (!user_id) {
return res.status(400).json({
status: 'error',
message: 'Benutzer-ID ist erforderlich'
});
}
const assignment = AssetService.assignAsset(
parseInt(id),
parseInt(user_id),
req.user.id,
notes,
req.ip
);
res.json({
status: 'success',
data: assignment
});
});
/**
* Unassign asset from user
* POST /api/assets/:id/unassign
*/
static unassignAsset = asyncHandler(async (req, res) => {
const { id } = req.params;
const { new_status } = req.body;
if (!new_status) {
return res.status(400).json({
status: 'error',
message: 'Neuer Status ist erforderlich'
});
}
const asset = AssetService.unassignAsset(
parseInt(id),
req.user.id,
new_status,
req.ip
);
res.json({
status: 'success',
data: asset
});
});
/**
* Get assignment history for an asset
* GET /api/assets/:id/history
*/
static getAssetAssignmentHistory = asyncHandler(async (req, res) => {
const { id } = req.params;
const history = AssetService.getAssetAssignmentHistory(parseInt(id));
res.json({
status: 'success',
data: history
});
});
/**
* Get asset statistics
* GET /api/assets/stats
*/
static getStatistics = asyncHandler(async (req, res) => {
const departments = getDeptFilter(req.user);
const stats = AssetService.getStatistics(departments);
res.json({
status: 'success',
data: stats
});
});
/**
* Generate handover protocol PDF for an assigned asset
* GET /api/assets/:id/handover-protocol
*/
static generateHandoverProtocol = asyncHandler(async (req, res) => {
const { id } = req.params;
const asset = AssetService.getAssetById(parseInt(id));
if (!asset.assigned_to_user_id) {
return res.status(400).json({
status: 'error',
message: 'Das Asset ist keinem Mitarbeiter zugewiesen'
});
}
const PDFDocument = require('pdfkit');
const doc = new PDFDocument({ size: 'A4', margin: 50 });
const filename = `uebergabeprotokoll_${(asset.serial_number || asset.id).replace(/[^a-z0-9]/gi, '_')}.pdf`;
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${filename}"`,
});
doc.pipe(res);
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
const BLUE = '#2563eb';
const GRAY = '#6b7280';
const BLACK = '#111827';
const pageW = doc.page.width - 100; // usable width (margins 50 each side)
// ── Header ────────────────────────────────────────────────────────────
doc.fontSize(20).fillColor(BLUE).text('Cereda Systems GmbH', 50, 50);
doc.fontSize(10).fillColor(GRAY).text('IT-Abteilung · IT-Nexus', 50, 75);
doc.fontSize(16).fillColor(BLACK).text('Übergabeprotokoll', 50, 100);
doc.fontSize(10).fillColor(GRAY).text(`Erstellt am: ${today}`, 50, 120);
doc.moveTo(50, 140).lineTo(545, 140).strokeColor(BLUE).lineWidth(1.5).stroke();
// ── Helper: draw a labeled row ────────────────────────────────────────
const row = (label, value, y) => {
doc.fontSize(9).fillColor(GRAY).text(label, 50, y);
doc.fontSize(10).fillColor(BLACK).text(value || '—', 200, y);
};
// ── Asset-Informationen ───────────────────────────────────────────────
let y = 160;
doc.fontSize(12).fillColor(BLUE).text('Arbeitsmittel', 50, y);
y += 20;
row('Bezeichnung', asset.name, y); y += 18;
row('Typ', asset.type, y); y += 18;
row('Modell', asset.model, y); y += 18;
row('Seriennummer', asset.serial_number, y); y += 18;
row('Kaufdatum', asset.purchase_date
? new Date(asset.purchase_date).toLocaleDateString('de-DE')
: null, y); y += 18;
if (asset.description) { row('Beschreibung', asset.description, y); y += 18; }
y += 10;
doc.moveTo(50, y).lineTo(545, y).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
y += 15;
// ── Mitarbeiter-Informationen ─────────────────────────────────────────
doc.fontSize(12).fillColor(BLUE).text('Empfänger', 50, y);
y += 20;
const assignedName = [asset.assigned_to_first_name, asset.assigned_to_last_name]
.filter(Boolean).join(' ') || asset.assigned_to_username || '—';
row('Name', assignedName, y); y += 18;
row('E-Mail', asset.assigned_to_email, y); y += 18;
row('Übergabedatum', today, y); y += 18;
y += 10;
doc.moveTo(50, y).lineTo(545, y).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
y += 15;
// ── Nutzungshinweis ───────────────────────────────────────────────────
doc.fontSize(12).fillColor(BLUE).text('Nutzungshinweis', 50, y);
y += 18;
doc.fontSize(9).fillColor(GRAY).text(
'Das übergebene Arbeitsmittel ist ausschließlich für dienstliche Zwecke zu verwenden. ' +
'Es ist sachgerecht zu behandeln und vor Verlust, Beschädigung sowie unbefugtem Zugriff zu schützen. ' +
'Defekte oder Verluste sind unverzüglich der IT-Abteilung zu melden (helpdesk@cereda-systems.de). ' +
'Bei Austritt aus dem Unternehmen ist das Arbeitsmittel vollständig zurückzugeben.',
50, y, { width: pageW, lineGap: 3 }
);
y += 70;
doc.moveTo(50, y).lineTo(545, y).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
y += 20;
// ── Unterschriften ────────────────────────────────────────────────────
doc.fontSize(12).fillColor(BLUE).text('Unterschriften', 50, y);
y += 30;
const sigW = (pageW - 40) / 2;
// Left: Mitarbeiter
doc.fontSize(9).fillColor(GRAY).text('Mitarbeiter/in', 50, y);
doc.moveTo(50, y + 40).lineTo(50 + sigW, y + 40).strokeColor(BLACK).lineWidth(0.8).stroke();
doc.fontSize(8).fillColor(GRAY).text(`${assignedName}`, 50, y + 45);
// Right: IT-Verantwortlicher
const rightX = 50 + sigW + 40;
doc.fontSize(9).fillColor(GRAY).text('IT-Verantwortliche/r', rightX, y);
doc.moveTo(rightX, y + 40).lineTo(rightX + sigW, y + 40).strokeColor(BLACK).lineWidth(0.8).stroke();
doc.fontSize(8).fillColor(GRAY).text('Cereda Systems GmbH · IT', rightX, y + 45);
// ── Footer ────────────────────────────────────────────────────────────
doc.fontSize(7).fillColor(GRAY)
.text('Dieses Dokument wurde automatisch durch IT-Nexus generiert · SUP-006 Infrastruktur & Arbeitsmittel',
50, doc.page.height - 40, { align: 'center', width: pageW });
doc.end();
});
/**
* Import managed devices from Microsoft Intune as assets
* POST /api/assets/import/intune
*/
static importFromIntune = asyncHandler(async (req, res) => {
let devices;
try {
devices = await getIntuneManagedDevices();
} catch (err) {
return res.status(502).json({
status: 'error',
message: err.message
});
}
const results = { imported: 0, skipped: 0, errors: [] };
for (const device of devices) {
try {
const serial = (device.serialNumber || '').trim();
if (!serial) {
results.skipped++;
continue;
}
// Skip if already exists by serial number
const existing = Asset.getBySerialNumber(serial);
if (existing) {
results.skipped++;
continue;
}
// Map OS to asset type
const os = (device.operatingSystem || '').toLowerCase();
let type = 'Other';
if (os.includes('windows') || os.includes('macos') || os.includes('mac os')) {
type = 'Notebook';
}
const name = device.deviceName || serial;
const manufacturer = device.manufacturer || '';
const model = device.model || '';
const descParts = [`Quelle: Microsoft Intune`];
if (manufacturer) descParts.push(`Hersteller: ${manufacturer}`);
if (device.operatingSystem) descParts.push(`OS: ${device.operatingSystem}`);
if (device.managedDeviceOwnerType) descParts.push(`Typ: ${device.managedDeviceOwnerType}`);
AssetService.createAsset({
name,
type,
serial_number: serial,
model: model || null,
status: 'verfuegbar',
description: descParts.join(' | '),
}, req.user.id, req.ip);
results.imported++;
} catch (err) {
results.errors.push({ device: device.deviceName || device.serialNumber, error: err.message });
}
}
res.json({ status: 'success', data: results });
});
/**
* Get all inspections for an asset
* GET /api/assets/:id/inspections
*/
static getInspections = asyncHandler(async (req, res) => {
const AssetInspection = require('../models/AssetInspection');
const items = AssetInspection.getByAsset(parseInt(req.params.id));
res.json({ status: 'success', data: items });
});
/**
* Create an inspection for an asset
* POST /api/assets/:id/inspections
*/
static createInspection = asyncHandler(async (req, res) => {
const AssetInspection = require('../models/AssetInspection');
const { inspection_date, result, notes, next_due_date } = req.body;
if (!inspection_date || !result) {
return res.status(400).json({ status: 'error', message: 'inspection_date und result sind Pflichtfelder' });
}
const item = AssetInspection.create({
asset_id: parseInt(req.params.id),
inspected_by_user_id: req.user.id,
inspection_date, result, notes, next_due_date
});
// If next_due_date provided, also update the asset's next_maintenance_date
if (next_due_date) {
const Asset = require('../models/Asset');
Asset.update(parseInt(req.params.id), { next_maintenance_date: next_due_date }, req.user.id);
}
res.status(201).json({ status: 'success', data: item });
});
/**
* Generate printable label PDF for an asset
* GET /api/assets/:id/label?copies=1&size=medium
*/
static generateLabel = asyncHandler(async (req, res) => {
const { id } = req.params;
const copies = Math.min(parseInt(req.query.copies) || 1, 100);
const size = ['small', 'medium', 'large', 'ptouch12'].includes(req.query.size)
? req.query.size
: 'medium';
const asset = AssetService.getAssetById(parseInt(id));
const pdfBuffer = await LabelService.generateAssetLabel(asset, copies, size);
const filename = `label_${(asset.serial_number || asset.id).replace(/[^a-z0-9]/gi, '_')}.pdf`;
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${filename}"`,
'Content-Length': pdfBuffer.length,
});
res.send(pdfBuffer);
});
/**
* POST /api/assets/:id/sync-agent
* Holt aktuelle Daten vom letzten Agent-Checkin und schreibt sie ins Asset
*/
static syncFromAgent = asyncHandler(async (req, res) => {
const { id } = req.params;
const asset = AssetService.getAssetById(parseInt(id));
const db = require('../config/database').getDatabase();
const MonitoringAgent = require('../models/MonitoringAgent');
const agent = MonitoringAgent.getByHostname(asset.name);
if (!agent) {
return res.status(404).json({ status: 'error', message: 'Kein Agent für dieses Asset gefunden. Hostname muss mit Asset-Name übereinstimmen.' });
}
const syncData = {
os: agent.os_name || null,
ip_address: agent.ip_address || null,
last_agent_sync: new Date().toISOString(),
};
if (agent.hardware_serial) syncData.serial_number = agent.hardware_serial;
// Hersteller aus Modell extrahieren falls noch nicht gesetzt
if (!asset.manufacturer && asset.model) {
const known = ['HP', 'Dell', 'Lenovo', 'Apple', 'Asus', 'Acer', 'Microsoft', 'Samsung', 'Toshiba', 'Fujitsu', 'Panasonic', 'Getac'];
const upper = asset.model.toUpperCase();
for (const b of known) {
if (upper.startsWith(b.toUpperCase())) { syncData.manufacturer = b; break; }
}
}
// Benutzer anhand last_user suchen
if (agent.last_user) {
const cleanUser = agent.last_user.replace(/^[^\\]+\\/, '');
const userRow = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND is_active = 1').get(cleanUser);
if (userRow) {
syncData.assigned_to_user_id = userRow.id;
syncData.status = 'zugewiesen';
}
}
const updated = Asset.update(parseInt(id), syncData, req.user.id);
res.json({ status: 'success', data: updated, synced_from: agent.hostname, last_checkin: agent.last_checkin });
});
}
module.exports = AssetController;

View File

@@ -0,0 +1,49 @@
const { getDatabase } = require('../config/database');
const getAll = (req, res) => {
const db = getDatabase();
const rows = db.prepare('SELECT * FROM asset_types ORDER BY sort_order, name').all();
res.json(rows);
};
const create = (req, res) => {
const { name, icon = 'devices_other' } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
const db = getDatabase();
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM asset_types').get();
try {
const result = db.prepare(
'INSERT INTO asset_types (name, icon, sort_order) VALUES (?, ?, ?)'
).run(name.trim(), icon, (maxOrder.m || 0) + 1);
const row = db.prepare('SELECT * FROM asset_types WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(row);
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Typ existiert bereits' });
throw e;
}
};
const update = (req, res) => {
const db = getDatabase();
const { name, icon } = req.body;
const existing = db.prepare('SELECT * FROM asset_types WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
try {
db.prepare('UPDATE asset_types SET name = ?, icon = ? WHERE id = ?')
.run(name?.trim() ?? existing.name, icon ?? existing.icon, req.params.id);
res.json(db.prepare('SELECT * FROM asset_types WHERE id = ?').get(req.params.id));
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Typ existiert bereits' });
throw e;
}
};
const remove = (req, res) => {
const db = getDatabase();
const existing = db.prepare('SELECT * FROM asset_types WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM asset_types WHERE id = ?').run(req.params.id);
res.json({ success: true });
};
module.exports = { getAll, create, update, remove };

View File

@@ -0,0 +1,116 @@
const AuthService = require('../services/auth.service');
const User = require('../models/User');
const { asyncHandler } = require('../middleware/errorHandler');
class AuthController {
/**
* Login user
* POST /api/auth/login
*/
static login = asyncHandler(async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
status: 'error',
message: 'Username and password are required'
});
}
const result = await AuthService.login(username, password);
res.json({
status: 'success',
data: result
});
});
/**
* Get current user info
* GET /api/auth/me
*/
static getCurrentUser = asyncHandler(async (req, res) => {
const user = User.getById(req.user.id);
if (!user) {
return res.status(404).json({
status: 'error',
message: 'User not found'
});
}
const { password_hash, ...userWithoutPassword } = user;
res.json({
status: 'success',
data: userWithoutPassword
});
});
/**
* Change password
* POST /api/auth/change-password
*/
static changePassword = asyncHandler(async (req, res) => {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({
status: 'error',
message: 'Current password and new password are required'
});
}
const result = await AuthService.changePassword(
req.user.id,
currentPassword,
newPassword
);
res.json({
status: 'success',
data: result
});
});
/**
* Update email notification preference
* PUT /api/auth/notifications
*/
static updateNotifications = asyncHandler(async (req, res) => {
const { email_notifications } = req.body;
const updated = User.updateEmailNotifications(req.user.id, !!email_notifications);
const { password_hash, ...userWithoutPassword } = updated;
res.json({ status: 'success', data: userWithoutPassword });
});
/**
* PUT /api/auth/staff-notifications
*/
static updateStaffNotifications = asyncHandler(async (req, res) => {
const { notif_ticket_created, notif_ticket_assigned, notif_new_comment, notif_weekly_report } = req.body;
const updated = User.updateStaffNotifications(req.user.id, {
notif_ticket_created,
notif_ticket_assigned,
notif_new_comment,
notif_weekly_report,
});
const { password_hash, ...userWithoutPassword } = updated;
res.json({ status: 'success', data: userWithoutPassword });
});
/**
* Logout user
* POST /api/auth/logout
*/
static logout = asyncHandler(async (req, res) => {
// Client-side will handle token removal
// This endpoint is just for consistency and potential future server-side session handling
res.json({
status: 'success',
message: 'Logged out successfully'
});
});
}
module.exports = AuthController;

View File

@@ -0,0 +1,404 @@
/**
* Teams Bot Controller
* Empfängt Nachrichten von Teams, antwortet via KI, erstellt Tickets, leitet Support-Antworten weiter.
*/
const { CloudAdapter, ConfigurationBotFrameworkAuthentication, TurnContext } = require('botbuilder');
const aiService = require('../services/ai.service');
const Ticket = require('../models/Ticket');
const TicketComment = require('../models/TicketComment');
const User = require('../models/User');
const { getDatabase } = require('../config/database');
const sseManager = require('../services/sseManager');
// ── Adapter ────────────────────────────────────────────────────────────────
const botAuth = new ConfigurationBotFrameworkAuthentication({
MicrosoftAppId: process.env.TEAMS_BOT_APP_ID,
MicrosoftAppPassword: process.env.TEAMS_BOT_APP_SECRET,
MicrosoftAppType: 'SingleTenant',
MicrosoftAppTenantId: process.env.AZURE_TENANT_ID,
});
const adapter = new CloudAdapter(botAuth);
adapter.onTurnError = async (context, error) => {
console.error('[TeamsBot] Fehler:', error);
try {
await context.sendActivity('Es ist ein Fehler aufgetreten. Bitte versuche es erneut.');
} catch (e) {
console.error('[TeamsBot] Konnte Fehlermeldung nicht senden:', e.message);
}
};
// ── In-Memory Conversation State ───────────────────────────────────────────
// Key: Teams-User-ID, Value: { state, messages, ticketId, conversationRef }
const sessions = new Map();
const getSession = (userId) => sessions.get(userId) || {
state: 'ai', // 'ai' | 'ticket'
messages: [], // KI-Chat-Verlauf
ticketId: null,
conversationRef: null,
};
const saveSession = (userId, data) => sessions.set(userId, data);
// ── Proaktive Nachricht an Teams senden (wenn Support antwortet) ───────────
async function notifyUser(teamsUserId, text) {
const session = sessions.get(teamsUserId);
if (!session?.conversationRef) return false;
try {
await adapter.continueConversationAsync(
process.env.TEAMS_BOT_APP_ID,
session.conversationRef,
async (ctx) => {
await ctx.sendActivity(text);
}
);
return true;
} catch (e) {
console.error('[TeamsBot] Proaktive Nachricht fehlgeschlagen:', e.message);
return false;
}
}
// ── Hilfsfunktionen ────────────────────────────────────────────────────────
function getUserFromTeams(teamsAccount) {
if (teamsAccount?.aadObjectId) {
return User.getByAzureId(teamsAccount.aadObjectId) || null;
}
if (teamsAccount?.email) {
const db = getDatabase();
return db.prepare('SELECT * FROM users WHERE email = ? LIMIT 1').get(teamsAccount.email) || null;
}
return null;
}
function findSupportUser() {
const db = getDatabase();
return db.prepare("SELECT id FROM users WHERE role_id IN (SELECT id FROM roles WHERE name IN ('super_admin','admin','support')) LIMIT 1").get();
}
// ── Bild-Download von Teams ────────────────────────────────────────────────
async function downloadTeamsImage(contentUrl) {
try {
const { ConfidentialClientApplication } = require('@azure/msal-node');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const msalApp = new ConfidentialClientApplication({
auth: {
clientId: process.env.TEAMS_BOT_APP_ID,
clientSecret: process.env.TEAMS_BOT_APP_SECRET,
authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`,
}
});
const tokenResult = await msalApp.acquireTokenByClientCredential({
scopes: ['https://api.botframework.com/.default'],
});
const response = await fetch(contentUrl, {
headers: { 'Authorization': `Bearer ${tokenResult.accessToken}` }
});
if (!response.ok) return null;
const buffer = Buffer.from(await response.arrayBuffer());
// Echten Typ aus Magic Bytes erkennen
let mediaType = 'image/jpeg';
let ext = 'jpg';
if (buffer[0] === 0x89 && buffer[1] === 0x50) { mediaType = 'image/png'; ext = 'png'; }
else if (buffer[0] === 0xFF && buffer[1] === 0xD8) { mediaType = 'image/jpeg'; ext = 'jpg'; }
else if (buffer[0] === 0x47 && buffer[1] === 0x49) { mediaType = 'image/gif'; ext = 'gif'; }
else if (buffer.slice(8, 12).toString() === 'WEBP') { mediaType = 'image/webp'; ext = 'webp'; }
// Bild auf Server speichern
const uploadDir = path.join(__dirname, '../../uploads/tickets');
fs.mkdirSync(uploadDir, { recursive: true });
const filename = `teams-${crypto.randomBytes(8).toString('hex')}.${ext}`;
const filepath = path.join(uploadDir, filename);
fs.writeFileSync(filepath, buffer);
const publicUrl = `/uploads/tickets/${filename}`;
return { base64: buffer.toString('base64'), mediaType, publicUrl };
} catch (e) {
console.error('[TeamsBot] Bild-Download fehlgeschlagen:', e.message);
return null;
}
}
// Baut Claude-kompatiblen Message-Content aus Text + Bild-Anhängen
// Gibt { content, imageUrls } zurück
async function buildMessageContent(text, attachments) {
const content = [];
const imageUrls = [];
for (const att of (attachments || [])) {
if (att.contentType?.startsWith('image/') && att.contentUrl) {
const img = await downloadTeamsImage(att.contentUrl);
if (img) {
content.push({ type: 'image', source: { type: 'base64', media_type: img.mediaType, data: img.base64 } });
if (img.publicUrl) imageUrls.push(img.publicUrl);
}
}
}
if (text) content.push({ type: 'text', text });
if (content.length === 0) return null;
const msgContent = content.length === 1 && content[0].type === 'text' ? text : content;
if (Array.isArray(msgContent) && !text && imageUrls.length > 0) {
content.push({ type: 'text', text: 'Was siehst du auf diesem Bild? Beschreibe das Problem.' });
}
return { content: Array.isArray(msgContent) ? content : text, imageUrls };
}
// ── Bot Logik ──────────────────────────────────────────────────────────────
async function handleMessage(context) {
const teamsUserId = context.activity.from.id;
const teamsName = context.activity.from.name || 'Mitarbeiter';
const text = (context.activity.text || '').trim();
const attachments = context.activity.attachments || [];
const hasImages = attachments.some(a => a.contentType?.startsWith('image/'));
const session = getSession(teamsUserId);
// Conversation Reference + aadObjectId speichern (für proaktive Nachrichten)
session.conversationRef = TurnContext.getConversationReference(context.activity);
if (context.activity.from.aadObjectId) {
session.aadObjectId = context.activity.from.aadObjectId;
}
// ── Begrüßung / Hilfe ── (nur wenn NICHT im Ticket-Modus)
if (session.state !== 'ticket' && (!text && !hasImages || /^(hallo|hi|hey|hilfe|help|start|menu|menü)$/i.test(text))) {
await context.sendActivity(
`👋 Hallo **${teamsName}**! Ich bin dein IT-Support-Assistent.\n\n` +
`**Was kann ich tun?**\n` +
`• Beschreibe dein Problem ich helfe direkt oder erstelle ein Ticket\n` +
`• **meine tickets** deine offenen Tickets anzeigen\n` +
`• **ticket TK-2026-0042** Status eines Tickets abfragen\n` +
`• **neu** Gespräch zurücksetzen\n\n` +
`_Einfach losschreiben!_`
);
saveSession(teamsUserId, { ...session, state: 'ai', messages: [] });
return;
}
// ── Reset ──
if (/^(reset|neu|neues ticket|neu starten)$/i.test(text)) {
saveSession(teamsUserId, { state: 'ai', messages: [], ticketId: null, conversationRef: session.conversationRef });
await context.sendActivity('✅ Gespräch zurückgesetzt. Wie kann ich dir helfen?');
return;
}
// ── Meine Tickets ──
if (/^meine tickets?$/i.test(text)) {
const dbUser = getUserFromTeams(context.activity.from);
if (!dbUser) {
await context.sendActivity('❌ Ich konnte dein IT-Nexus-Konto nicht finden. Bitte stelle sicher, dass deine Teams-E-Mail im IT Nexus hinterlegt ist.');
return;
}
const db = getDatabase();
const tickets = db.prepare(
`SELECT ticket_number, title, status, priority, created_at FROM tickets
WHERE created_by_user_id = ? AND status != 'geschlossen'
ORDER BY created_at DESC LIMIT 10`
).all(dbUser.id);
if (!tickets.length) {
await context.sendActivity('✅ Du hast keine offenen Tickets.');
return;
}
const statusLabel = { offen: '🟡 Offen', in_bearbeitung: '🔵 In Bearbeitung', warten_auf_mitarbeiter: '🟠 Warte auf dich', warten_auf_support: '🟣 Beim Support' };
const lines = tickets.map(t =>
`**${t.ticket_number}** ${t.title}\n ${statusLabel[t.status] || t.status} | Priorität: ${t.priority}`
).join('\n\n');
await context.sendActivity(`📋 **Deine offenen Tickets:**\n\n${lines}\n\n_Tippe z.B. \`ticket TK-2026-0001\` für Details._`);
return;
}
// ── Ticket Status abfragen ──
const statusMatch = text.match(/^(?:ticket\s+)?(TK-\d{4}-\d+)$/i);
if (statusMatch) {
const ticketNumber = statusMatch[1].toUpperCase();
const db = getDatabase();
const ticket = db.prepare('SELECT * FROM tickets WHERE ticket_number = ? LIMIT 1').get(ticketNumber);
if (!ticket) {
await context.sendActivity(`❌ Ticket **${ticketNumber}** nicht gefunden.`);
return;
}
const statusLabel = { offen: '🟡 Offen', in_bearbeitung: '🔵 In Bearbeitung', warten_auf_mitarbeiter: '🟠 Warte auf Mitarbeiter', warten_auf_support: '🟣 Beim Support', geschlossen: '✅ Geschlossen' };
const comments = db.prepare('SELECT COUNT(*) as cnt FROM ticket_comments WHERE ticket_id = ?').get(ticket.id);
await context.sendActivity(
`📋 **${ticket.ticket_number}**: ${ticket.title}\n\n` +
`**Status:** ${statusLabel[ticket.status] || ticket.status}\n` +
`**Priorität:** ${ticket.priority}\n` +
`**Kategorie:** ${ticket.category}\n` +
`**Kommentare:** ${comments.cnt}\n` +
`**Erstellt:** ${new Date(ticket.created_at).toLocaleDateString('de-DE')}\n\n` +
`_Schreibe eine Nachricht um auf dieses Ticket zu antworten (Ticket wird aktiviert)._`
);
saveSession(teamsUserId, { ...session, state: 'ticket', ticketId: ticket.id });
return;
}
// ── Ticket-Modus: Nachricht als Kommentar hinzufügen ──
if (session.state === 'ticket' && session.ticketId) {
const dbUser = getUserFromTeams(context.activity.from);
const userId = dbUser?.id || null;
const ticket = Ticket.getById(session.ticketId);
if (!ticket) {
await context.sendActivity('❌ Ticket nicht mehr gefunden. Tippe `neu` für ein neues Gespräch.');
return;
}
const newComment = TicketComment.create(session.ticketId, userId, text, false, false);
sseManager.broadcast(session.ticketId, newComment);
await context.sendActivity(`✉️ _Nachricht gesendet → **${ticket.ticket_number}**_`);
saveSession(teamsUserId, session);
return;
}
// ── KI-Modus ──
const built = await buildMessageContent(text, attachments);
if (!built) return;
const updatedMessages = [...session.messages, { role: 'user', content: built.content, _imageUrls: built.imageUrls }];
await context.sendActivity({ type: 'typing' });
if (hasImages) await context.sendActivity('🖼️ Bild wird analysiert...');
try {
const messagesForAI = updatedMessages.map(({ role, content }) => ({ role, content }));
const aiReply = await aiService.chat(messagesForAI, true);
updatedMessages.push({ role: 'assistant', content: aiReply });
saveSession(teamsUserId, { ...session, messages: updatedMessages });
const ticketHint = /ticket|support|weiterleiten|kann.*nicht.*helfen|nicht.*lösen/i.test(aiReply);
if (ticketHint) {
await context.sendActivity(aiReply);
await context.sendActivity(
`📋 Soll ich automatisch ein **Support-Ticket** erstellen?\n\nTippe **ja** zum Erstellen oder stelle eine weitere Frage.`
);
saveSession(teamsUserId, { ...session, messages: updatedMessages, state: 'confirm_ticket' });
} else {
await context.sendActivity(aiReply);
}
} catch (err) {
console.error('[TeamsBot] KI-Fehler:', err.message);
await context.sendActivity('⚠️ KI momentan nicht verfügbar. Soll ich ein Ticket erstellen? Tippe **ja**.');
saveSession(teamsUserId, { ...session, state: 'confirm_ticket' });
}
}
async function handleConfirmTicket(context, session) {
const teamsUserId = context.activity.from.id;
const text = (context.activity.text || '').trim().toLowerCase();
const teamsName = context.activity.from.name || 'Mitarbeiter';
if (!/^(ja|yes|ok|erstellen|ticket)/.test(text)) {
saveSession(teamsUserId, { ...session, state: 'ai' });
await handleMessage(context);
return;
}
const dbUser = getUserFromTeams(context.activity.from);
const supportUser = findSupportUser();
const allMessages = session.messages.filter(m => m.role === 'user' || m.role === 'assistant');
const userMessages = allMessages.filter(m => m.role === 'user');
const summary = userMessages
.map(m => typeof m.content === 'string' ? m.content : m.content.filter(c => c.type === 'text').map(c => c.text).join(' '))
.join('\n');
// Alle gespeicherten Bild-URLs sammeln
const allImageUrls = userMessages.flatMap(m => m._imageUrls || []);
const imageSection = allImageUrls.length > 0
? '\n\n**Anhänge:**\n' + allImageUrls.map(u => `![Bild](${process.env.FRONTEND_URL}${u})`).join('\n')
: '';
const title = summary.split('\n')[0].substring(0, 100) || 'IT-Problem via Teams';
try {
const ticket = Ticket.create({
title,
description: `📱 **Erstellt via Teams Bot**\n\nBenutzer: ${teamsName}\n\n${summary}${imageSection}`,
category: 'Allgemein',
priority: 'mittel',
status: 'offen',
created_by_user_id: dbUser?.id || null,
requester_name: teamsName,
requester_email: context.activity.from.aadObjectId
? `teams-${context.activity.from.aadObjectId}@teams.bot`
: null,
});
// Konversation als Kommentar speichern
try {
const conversationText = allMessages.map(m => {
const prefix = m.role === 'user' ? `👤 **${teamsName}:**` : '🤖 **IT-Support KI:**';
const text = typeof m.content === 'string'
? m.content
: m.content.filter(c => c.type === 'text').map(c => c.text).join(' ');
return `${prefix}\n${text}`;
}).join('\n\n');
if (conversationText.trim()) {
const convComment = TicketComment.create(ticket.id, dbUser?.id || null, `📱 **Teams-Konversation:**\n\n${conversationText}`, false, false);
sseManager.broadcast(ticket.id, convComment);
}
} catch (e) {
console.error('[TeamsBot] Konversation-Kommentar fehlgeschlagen:', e.message);
}
saveSession(teamsUserId, {
...session,
state: 'ticket',
ticketId: ticket.id,
});
await context.sendActivity(
`─────────────────────────────\n` +
`✅ **Ticket ${ticket.ticket_number} erstellt**\n` +
`Der Support kümmert sich darum.\n` +
`─────────────────────────────\n\n` +
`Du kannst hier direkt antworten deine Nachrichten gehen an den Support.\n` +
`_Tippe \`neu\` für ein neues Anliegen._`
);
} catch (err) {
console.error('[TeamsBot] Ticket-Fehler:', err.message);
await context.sendActivity('❌ Fehler beim Erstellen des Tickets. Bitte versuche es erneut.');
}
}
// ── Express Handler ────────────────────────────────────────────────────────
async function messages(req, res) {
try {
await adapter.process(req, res, async (context) => {
if (context.activity.type !== 'message') return;
const teamsUserId = context.activity.from.id;
const session = getSession(teamsUserId);
if (session.state === 'confirm_ticket') {
await handleConfirmTicket(context, session);
} else {
await handleMessage(context);
}
});
} catch (err) {
console.error('[TeamsBot] processActivity Fehler:', err.message);
if (!res.headersSent) {
res.status(500).json({ error: 'Bot processing error' });
}
}
}
// Benachrichtigung via Azure AD Object ID (für ticket.controller.js)
async function notifyByAadId(aadObjectId, text) {
for (const [userId, session] of sessions.entries()) {
if (session.aadObjectId === aadObjectId) {
return notifyUser(userId, text);
}
}
return false;
}
module.exports = { messages, notifyUser, notifyByAadId };

View File

@@ -0,0 +1,122 @@
const http = require('http');
function dockerRequest(method, path, body = null) {
return new Promise((resolve, reject) => {
const opts = {
socketPath: '/var/run/docker.sock',
method,
path,
headers: { 'Content-Type': 'application/json' }
};
const req = http.request(opts, res => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
try { resolve({ status: res.statusCode, data: data ? JSON.parse(data) : null }); }
catch { resolve({ status: res.statusCode, data }); }
});
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
const getContainers = async (req, res) => {
try {
const { data } = await dockerRequest('GET', '/containers/json?all=1');
const containers = (data || []).map(c => ({
id: c.Id.substring(0, 12),
name: (c.Names[0] || '').replace(/^\//, ''),
image: c.Image,
status: c.Status,
state: c.State,
created: c.Created,
ports: c.Ports || [],
}));
res.json(containers);
} catch (e) {
res.status(503).json({ error: 'Docker nicht erreichbar', detail: e.message });
}
};
const getStats = async (req, res) => {
try {
const { data: list } = await dockerRequest('GET', '/containers/json?all=0');
const running = (list || []).filter(c => c.State === 'running');
const stats = await Promise.all(running.map(async c => {
try {
const { data } = await dockerRequest('GET', `/containers/${c.Id}/stats?stream=0`);
const cpuDelta = (data.cpu_stats?.cpu_usage?.total_usage || 0) - (data.precpu_stats?.cpu_usage?.total_usage || 0);
const sysDelta = (data.cpu_stats?.system_cpu_usage || 0) - (data.precpu_stats?.system_cpu_usage || 0);
const cpus = data.cpu_stats?.online_cpus || 1;
const cpuPct = sysDelta > 0 ? (cpuDelta / sysDelta) * cpus * 100 : 0;
const memUsed = data.memory_stats?.usage || 0;
const memLimit = data.memory_stats?.limit || 1;
return {
id: c.Id.substring(0, 12),
name: (c.Names[0] || '').replace(/^\//, ''),
cpuPct: Math.round(cpuPct * 10) / 10,
memUsed,
memLimit,
memPct: Math.round((memUsed / memLimit) * 1000) / 10,
};
} catch { return null; }
}));
res.json(stats.filter(Boolean));
} catch (e) {
res.status(503).json({ error: 'Docker nicht erreichbar', detail: e.message });
}
};
const getLogs = async (req, res) => {
try {
const lines = parseInt(req.query.lines) || 100;
const opts = {
socketPath: '/var/run/docker.sock',
method: 'GET',
path: `/containers/${req.params.id}/logs?stdout=1&stderr=1&tail=${lines}&timestamps=1`,
};
const raw = await new Promise((resolve, reject) => {
const req2 = http.request(opts, r => {
const chunks = [];
r.on('data', c => chunks.push(c));
r.on('end', () => resolve(Buffer.concat(chunks)));
});
req2.on('error', reject);
req2.end();
});
// Strip Docker log stream headers (8-byte prefix per line)
let out = '';
let i = 0;
while (i < raw.length) {
if (i + 8 > raw.length) break;
const size = raw.readUInt32BE(i + 4);
if (i + 8 + size > raw.length) break;
out += raw.slice(i + 8, i + 8 + size).toString('utf8');
i += 8 + size;
}
res.json({ logs: out || raw.toString('utf8') });
} catch (e) {
res.status(500).json({ error: e.message });
}
};
const containerAction = async (req, res) => {
const { id } = req.params;
const { action } = req.params;
const map = { start: 'POST', stop: 'POST', restart: 'POST' };
if (!map[action]) return res.status(400).json({ error: 'Unbekannte Aktion' });
try {
const { status } = await dockerRequest('POST', `/containers/${id}/${action}`);
if (status === 204 || status === 200 || status === 304) {
res.json({ ok: true });
} else {
res.status(status).json({ error: `Docker returned ${status}` });
}
} catch (e) {
res.status(500).json({ error: e.message });
}
};
module.exports = { getContainers, getStats, getLogs, containerAction };

View File

@@ -0,0 +1,198 @@
const {
getEntraUsers,
getAllMfaRegistrationDetails,
getAzureGroups,
getAzureGroupMembers,
getEntraUserGroups,
getDirectoryRolesWithMembers,
addGroupMember,
removeGroupMember,
getUserEntraProfile,
getUserLicenseDetails,
getEntraSubscribedSkus,
getUserSignInActivity,
getUserSignInRisk,
getConditionalAccessPolicies,
invalidateUserSessions,
} = require('../services/graph.service');
/**
* GET /api/entra/users
* Returns all Entra users merged with MFA registration info.
*/
exports.getUsers = async (req, res) => {
try {
const [users, mfaDetails] = await Promise.all([
getEntraUsers(),
getAllMfaRegistrationDetails().catch(() => []), // graceful fallback if AuditLog permission missing
]);
// Build MFA lookup map by userPrincipalName
const mfaMap = {};
for (const m of mfaDetails) {
mfaMap[m.userPrincipalName?.toLowerCase()] = m;
}
const merged = users.map(u => {
const mfa = mfaMap[u.userPrincipalName?.toLowerCase()] || null;
return {
id: u.id,
displayName: u.displayName,
mail: u.mail || u.userPrincipalName,
userPrincipalName: u.userPrincipalName,
jobTitle: u.jobTitle || null,
department: u.department || null,
accountEnabled: u.accountEnabled,
mfaRegistered: mfa ? mfa.isMfaRegistered : null,
mfaCapable: mfa ? mfa.isMfaCapable : null,
mfaMethods: mfa ? (mfa.methodsRegistered || []) : [],
};
});
res.json({ success: true, data: merged });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
};
/**
* GET /api/entra/users/:id/groups
* Returns groups for a specific user.
*/
exports.getUserGroups = async (req, res) => {
try {
const groups = await getEntraUserGroups(req.params.id);
res.json({ success: true, data: groups });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
};
/**
* GET /api/entra/groups
* Returns all groups (without members — lazy-loaded per group).
*/
exports.getGroups = async (req, res) => {
try {
const groups = await getAzureGroups();
res.json({ success: true, data: groups });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
};
/**
* GET /api/entra/groups/:id/members
* Returns members of a specific group.
*/
exports.getGroupMembers = async (req, res) => {
try {
const members = await getAzureGroupMembers(req.params.id);
res.json({ success: true, data: members });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
};
/**
* POST /api/entra/groups/:id/members
* Adds a user to a group. Body: { userId }
* Requires GroupMember.ReadWrite.All
*/
exports.addGroupMember = async (req, res) => {
const { userId } = req.body;
if (!userId) return res.status(400).json({ success: false, message: 'userId fehlt' });
try {
await addGroupMember(req.params.id, userId);
res.json({ success: true });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
};
/**
* DELETE /api/entra/groups/:id/members/:userId
* Removes a user from a group.
* Requires GroupMember.ReadWrite.All
*/
exports.removeGroupMember = async (req, res) => {
try {
await removeGroupMember(req.params.id, req.params.userId);
res.json({ success: true });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
};
/**
* GET /api/entra/roles
* Returns all activated directory roles with their members.
*/
exports.getRoles = async (req, res) => {
try {
const roles = await getDirectoryRolesWithMembers();
res.json({ success: true, data: roles });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
};
exports.getUserProfile = async (req, res) => {
try {
const profile = await getUserEntraProfile(req.params.azureId);
res.json({ success: true, data: profile });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
};
exports.getUserLicenses = async (req, res) => {
try {
const [licenses, skus] = await Promise.all([
getUserLicenseDetails(req.params.azureId),
getEntraSubscribedSkus().catch(() => []),
]);
const skuMap = {};
for (const s of skus) skuMap[s.skuId] = s;
const enriched = licenses.map(l => ({ ...l, skuInfo: skuMap[l.skuId] || null }));
res.json({ success: true, data: enriched });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
};
exports.getUserSignInLog = async (req, res) => {
try {
const logs = await getUserSignInActivity(req.params.azureId);
res.json({ success: true, data: logs });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
};
exports.getUserSignInRisk = async (req, res) => {
try {
const risk = await getUserSignInRisk(req.params.azureId);
res.json({ success: true, data: risk });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
};
exports.getCAP = async (req, res) => {
try {
const policies = await getConditionalAccessPolicies();
res.json({ success: true, data: policies });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
};
exports.invalidateSessions = async (req, res) => {
try {
await invalidateUserSessions(req.params.azureId);
res.json({ success: true, message: 'Sessions invalidiert. Benutzer muss sich neu anmelden.' });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
};

View File

@@ -0,0 +1,73 @@
const ExternalAlert = require('../models/ExternalAlert');
const Ticket = require('../models/Ticket');
const getAll = (req, res) => {
try {
const acknowledged = req.query.acknowledged !== undefined
? req.query.acknowledged === 'true'
: undefined;
const alerts = ExternalAlert.getAll({ acknowledged });
res.json({ status: 'success', data: alerts });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
const acknowledge = (req, res) => {
try {
const alert = ExternalAlert.acknowledge(req.params.id);
if (!alert) return res.status(404).json({ status: 'error', message: 'Alert nicht gefunden' });
res.json({ status: 'success', data: alert });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
const createTicket = (req, res) => {
try {
const alert = ExternalAlert.getById(req.params.id);
if (!alert) return res.status(404).json({ status: 'error', message: 'Alert nicht gefunden' });
const priorityMap = { CRIT: 'kritisch', WARN: 'hoch', OK: 'niedrig', UNKNOWN: 'mittel' };
const title = alert.device
? `[${alert.source?.toUpperCase()}] ${alert.device} ${alert.message || alert.state_transition}`.substring(0, 200)
: (alert.message || 'Monitoring Alert').substring(0, 200);
const description = [
alert.device ? `**Gerät:** ${alert.device}` : '',
alert.service ? `**Service:** ${alert.service}` : '',
alert.state_transition ? `**Status:** ${alert.state_transition}` : '',
alert.severity ? `**Severity:** ${alert.severity}` : '',
alert.message ? `\n**Meldung:** ${alert.message}` : '',
alert.customer ? `**Kunde:** ${alert.customer}` : '',
alert.monitored_by ? `**Überwacht von:** ${alert.monitored_by}` : '',
alert.state_time ? `**Zeitpunkt:** ${alert.state_time}` : '',
].filter(Boolean).join('\n');
const ticket = Ticket.create({
title,
description,
source: 'monitoring',
category: 'Netzwerk',
priority: priorityMap[alert.severity] || 'mittel',
status: 'offen',
});
ExternalAlert.setTicket(alert.id, ticket.id);
res.json({ status: 'success', data: { ticket, alert: ExternalAlert.getById(alert.id) } });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
const remove = (req, res) => {
try {
ExternalAlert.delete(req.params.id);
res.json({ status: 'success' });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
module.exports = { getAll, acknowledge, createTicket, remove };

View File

@@ -0,0 +1,165 @@
const FidoKeyService = require('../services/fidoKey.service');
const { asyncHandler } = require('../middleware/errorHandler');
class FidoKeyController {
/**
* Get all FIDO keys
* GET /api/fido-keys
*/
static getAllKeys = asyncHandler(async (req, res) => {
const keys = FidoKeyService.getAllKeys();
res.json({
status: 'success',
data: keys
});
});
/**
* Get FIDO key by ID
* GET /api/fido-keys/:id
*/
static getKeyById = asyncHandler(async (req, res) => {
const { id } = req.params;
const key = FidoKeyService.getKeyById(parseInt(id));
res.json({
status: 'success',
data: key
});
});
/**
* Get FIDO key by serial number
* GET /api/fido-keys/serial/:serialNumber
*/
static getKeyBySerial = asyncHandler(async (req, res) => {
const { serialNumber } = req.params;
const key = FidoKeyService.getKeyBySerial(serialNumber);
res.json({
status: 'success',
data: key
});
});
/**
* Get FIDO keys by status
* GET /api/fido-keys/status/:status
*/
static getKeysByStatus = asyncHandler(async (req, res) => {
const { status } = req.params;
const keys = FidoKeyService.getKeysByStatus(status);
res.json({
status: 'success',
data: keys
});
});
/**
* Create new FIDO key
* POST /api/fido-keys
*/
static createKey = asyncHandler(async (req, res) => {
const key = FidoKeyService.createKey(
req.body,
req.user.id,
req.ip
);
res.status(201).json({
status: 'success',
data: key
});
});
/**
* Update FIDO key
* PUT /api/fido-keys/:id
*/
static updateKey = asyncHandler(async (req, res) => {
const { id } = req.params;
const key = FidoKeyService.updateKey(
parseInt(id),
req.body,
req.user.id,
req.ip
);
res.json({
status: 'success',
data: key
});
});
/**
* Update FIDO key status
* PUT /api/fido-keys/:id/status
*/
static updateKeyStatus = asyncHandler(async (req, res) => {
const { id } = req.params;
const { status } = req.body;
if (!status) {
return res.status(400).json({
status: 'error',
message: 'Status is required'
});
}
const key = FidoKeyService.updateKeyStatus(
parseInt(id),
status,
req.user.id,
req.ip
);
res.json({
status: 'success',
data: key
});
});
/**
* Delete FIDO key
* DELETE /api/fido-keys/:id
*/
static deleteKey = asyncHandler(async (req, res) => {
const { id } = req.params;
const deleted = FidoKeyService.deleteKey(
parseInt(id),
req.user.id,
req.ip
);
if (!deleted) {
return res.status(404).json({
status: 'error',
message: 'FIDO key not found'
});
}
res.json({
status: 'success',
message: 'FIDO key deleted successfully'
});
});
/**
* Get FIDO key statistics
* GET /api/fido-keys/stats
*/
static getStatistics = asyncHandler(async (req, res) => {
const stats = FidoKeyService.getStatistics();
res.json({
status: 'success',
data: stats
});
});
}
module.exports = FidoKeyController;

View File

@@ -0,0 +1,30 @@
const IsoTask = require('../models/IsoTask');
exports.getAll = (req, res) => {
const { category, status } = req.query;
const data = IsoTask.getAll({ category, status });
res.json({ success: true, data });
};
exports.getOne = (req, res) => {
const task = IsoTask.getById(req.params.id);
if (!task) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
res.json({ success: true, data: task });
};
exports.create = (req, res) => {
const task = IsoTask.create(req.body);
res.status(201).json({ success: true, data: task });
};
exports.update = (req, res) => {
const task = IsoTask.update(req.params.id, req.body);
if (!task) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
res.json({ success: true, data: task });
};
exports.remove = (req, res) => {
const ok = IsoTask.delete(req.params.id);
if (!ok) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
res.json({ success: true });
};

View File

@@ -0,0 +1,45 @@
const ItTopic = require('../models/ItTopic');
const { syncItTopicsToPlanner } = require('../services/graph.service');
exports.getAll = (req, res) => {
const { category, status } = req.query;
const data = ItTopic.getAll({ category, status });
res.json({ status: 'success', data });
};
exports.getOne = (req, res) => {
const topic = ItTopic.getById(req.params.id);
if (!topic) return res.status(404).json({ status: 'error', message: 'Thema nicht gefunden' });
res.json({ status: 'success', data: topic });
};
exports.create = (req, res) => {
const { title, category, status, priority, responsible, target_date, description, notes } = req.body;
if (!title?.trim()) {
return res.status(400).json({ status: 'error', message: 'Titel ist erforderlich' });
}
const topic = ItTopic.create({ title: title.trim(), category, status, priority, responsible, target_date, description, notes });
res.status(201).json({ status: 'success', data: topic });
};
exports.update = (req, res) => {
const topic = ItTopic.getById(req.params.id);
if (!topic) return res.status(404).json({ status: 'error', message: 'Thema nicht gefunden' });
const { title, category, status, priority, responsible, target_date, description, notes, sort_order } = req.body;
const updated = ItTopic.update(req.params.id, { title, category, status, priority, responsible, target_date, description, notes, sort_order });
res.json({ status: 'success', data: updated });
};
exports.remove = (req, res) => {
const ok = ItTopic.delete(req.params.id);
if (!ok) return res.status(404).json({ status: 'error', message: 'Thema nicht gefunden' });
res.json({ status: 'success', message: 'Thema gelöscht' });
};
exports.syncToPlanner = async (req, res) => {
const planId = process.env.PLANNER_PLAN_ID;
if (!planId) return res.status(400).json({ status: 'error', message: 'PLANNER_PLAN_ID nicht konfiguriert' });
const topics = ItTopic.getAll({});
const result = await syncItTopicsToPlanner(topics, planId);
res.json({ status: 'success', data: result });
};

View File

@@ -0,0 +1,55 @@
const { getDatabase } = require('../config/database');
const getAll = (req, res) => {
const db = getDatabase();
const entries = db.prepare(`
SELECT k.*, u.username as author_name
FROM ai_knowledge k
LEFT JOIN users u ON u.id = k.created_by
ORDER BY k.category ASC, k.updated_at DESC
`).all();
res.json(entries);
};
const getByCategory = (req, res) => {
const db = getDatabase();
const entries = db.prepare(`
SELECT k.*, u.username as author_name
FROM ai_knowledge k
LEFT JOIN users u ON u.id = k.created_by
WHERE k.category = ?
ORDER BY k.updated_at DESC
`).all(req.params.category);
res.json(entries);
};
const create = (req, res) => {
const { title, content, category, tags } = req.body;
if (!title?.trim() || !content?.trim()) return res.status(400).json({ error: 'Titel und Inhalt erforderlich' });
const db = getDatabase();
const result = db.prepare(`
INSERT INTO ai_knowledge (title, content, category, tags, created_by)
VALUES (?, ?, ?, ?, ?)
`).run(title.trim(), content.trim(), category || 'Allgemein', JSON.stringify(tags || []), req.user?.id || null);
res.status(201).json(db.prepare('SELECT * FROM ai_knowledge WHERE id=?').get(result.lastInsertRowid));
};
const update = (req, res) => {
const db = getDatabase();
const existing = db.prepare('SELECT * FROM ai_knowledge WHERE id=?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
const { title, content, category, tags } = req.body;
db.prepare(`UPDATE ai_knowledge SET title=?, content=?, category=?, tags=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`)
.run(title ?? existing.title, content ?? existing.content, category ?? existing.category,
tags !== undefined ? JSON.stringify(tags) : existing.tags, req.params.id);
res.json(db.prepare('SELECT * FROM ai_knowledge WHERE id=?').get(req.params.id));
};
const remove = (req, res) => {
const db = getDatabase();
if (!db.prepare('SELECT id FROM ai_knowledge WHERE id=?').get(req.params.id)) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM ai_knowledge WHERE id=?').run(req.params.id);
res.json({ success: true });
};
module.exports = { getAll, getByCategory, create, update, remove };

View File

@@ -0,0 +1,180 @@
const License = require('../models/License');
const { asyncHandler } = require('../middleware/errorHandler');
const { getEntraSubscribedSkus } = require('../services/graph.service');
// Friendly display names for common Microsoft SKU part numbers
const SKU_NAMES = {
// Microsoft 365
SPB: 'Microsoft 365 Business Premium',
O365_BUSINESS_PREMIUM: 'Microsoft 365 Business Standard',
O365_BUSINESS_ESSENTIALS: 'Microsoft 365 Business Basic',
SMB_BUSINESS: 'Microsoft 365 Apps for Business',
OFFICESUBSCRIPTION: 'Microsoft 365 Apps for Enterprise',
ENTERPRISEPACK: 'Microsoft 365 E3',
ENTERPRISEPREMIUM: 'Microsoft 365 E5',
SPE_E3: 'Microsoft 365 E3',
SPE_E5: 'Microsoft 365 E5',
M365EDU_A3_FACULTY: 'Microsoft 365 A3 (Bildung)',
M365EDU_A5_FACULTY: 'Microsoft 365 A5 (Bildung)',
// Exchange
EXCHANGESTANDARD: 'Exchange Online (Plan 1)',
EXCHANGEENTERPRISE: 'Exchange Online (Plan 2)',
EXCHANGE_S_DESKLESS: 'Exchange Online Kiosk',
EXCHANGEARCHIVE_ADDON: 'Exchange Online Archivierung',
// Teams & Kommunikation
TEAMS_ESSENTIALS: 'Microsoft Teams Essentials',
MCOSTANDARD: 'Skype for Business Online (Plan 2)',
MCOEV: 'Microsoft Teams Phone Standard',
MCOEV_VIRTUALUSER: 'Microsoft Teams Phone Resource Account',
MCOMEETADV: 'Microsoft Teams Audio Conferencing',
MCOMEETADV_BYON: 'Teams Audio Conferencing (BYOT)',
MCOTEAMS_ESSENTIALS: 'Microsoft Teams Essentials',
// Intune & Security
INTUNE_A: 'Microsoft Intune Plan 1',
INTUNE_P2: 'Microsoft Intune Plan 2',
AAD_PREMIUM: 'Azure AD Premium P1',
AAD_PREMIUM_P2: 'Azure AD Premium P2',
EMS: 'Enterprise Mobility + Security E3',
EMSPREMIUM: 'Enterprise Mobility + Security E5',
DEFENDER_ENDPOINT_P1: 'Microsoft Defender for Endpoint P1',
MDATP_XPLAT: 'Microsoft Defender for Endpoint P2',
ATP_ENTERPRISE: 'Microsoft Defender for Office 365 (Plan 1)',
THREAT_INTELLIGENCE: 'Microsoft Defender for Office 365 (Plan 2)',
// Power Platform
POWER_BI_PRO: 'Power BI Pro',
POWER_BI_PREMIUM_PER_USER: 'Power BI Premium Per User',
POWERAPPS_PER_USER: 'Power Apps Per User Plan',
FLOW_PER_USER: 'Power Automate Per User Plan',
FLOW_FREE: 'Power Automate Free',
DYN365_ENTERPRISE_PLAN1: 'Dynamics 365 Customer Engagement Plan',
// Windows & Devices
WIN10_PRO_ENT_SUB: 'Windows 10/11 Enterprise E3',
WIN_ENT_E5: 'Windows 10/11 Enterprise E5',
// Project & Visio
PROJECTPROFESSIONAL: 'Project Plan 5',
PROJECTPREMIUM: 'Project Plan 3',
PROJECT_PLAN1: 'Project Plan 1',
VISIOCLIENT: 'Visio Plan 2',
VISIOONLINE_PLAN1: 'Visio Plan 1',
// Sonstiges
CCIBOTS_PRIVPREV_VIRAL: 'Microsoft Copilot für Microsoft 365',
MICROSOFT_COPILOT_SECP: 'Copilot for Security',
RIGHTSMANAGEMENT: 'Azure Information Protection Plan 1',
RIGHTSMANAGEMENT_PREMIUM: 'Azure Information Protection Plan 2',
CRMSTANDARD: 'Microsoft Dynamics CRM Online',
STREAM: 'Microsoft Stream',
};
class LicenseController {
static getAll = asyncHandler(async (req, res) => {
const licenses = License.getAll();
res.json({ status: 'success', data: licenses });
});
static getById = asyncHandler(async (req, res) => {
const license = License.getById(parseInt(req.params.id));
if (!license) {
return res.status(404).json({ status: 'error', message: 'Lizenz nicht gefunden' });
}
res.json({ status: 'success', data: license });
});
static create = asyncHandler(async (req, res) => {
const { name, vendor, license_type, product_key, seats, purchase_date, expiry_date, cost, notes } = req.body;
if (!name) {
return res.status(400).json({ status: 'error', message: 'Name ist erforderlich' });
}
const license = License.create({
name, vendor, license_type, product_key, seats, purchase_date, expiry_date, cost, notes,
created_by_user_id: req.user.id,
});
res.status(201).json({ status: 'success', data: license });
});
static update = asyncHandler(async (req, res) => {
const existing = License.getById(parseInt(req.params.id));
if (!existing) {
return res.status(404).json({ status: 'error', message: 'Lizenz nicht gefunden' });
}
const license = License.update(parseInt(req.params.id), req.body);
res.json({ status: 'success', data: license });
});
static delete = asyncHandler(async (req, res) => {
const existing = License.getById(parseInt(req.params.id));
if (!existing) {
return res.status(404).json({ status: 'error', message: 'Lizenz nicht gefunden' });
}
License.delete(parseInt(req.params.id));
res.json({ status: 'success', message: 'Lizenz gelöscht' });
});
static getStatistics = asyncHandler(async (req, res) => {
const stats = License.getStatistics();
res.json({ status: 'success', data: stats });
});
static importFromEntra = asyncHandler(async (req, res) => {
const skus = await getEntraSubscribedSkus();
const existing = License.getAll();
// Map by sku_id for fast dedup; fallback to name+vendor
const existingBySkuId = new Map(existing.filter(l => l.sku_id).map(l => [l.sku_id, l]));
const existingByName = new Set(existing.map(l => `${l.name}||${l.vendor}`));
let imported = 0;
let updated = 0;
let skipped = 0;
const errors = [];
for (const sku of skus) {
try {
const seats = sku.prepaidUnits?.enabled || 0;
if (seats === 0) { skipped++; continue; }
const name = SKU_NAMES[sku.skuPartNumber] || sku.skuPartNumber;
const notes = `SKU: ${sku.skuPartNumber} | Genutzt: ${sku.consumedUnits || 0} / ${seats} | Status: ${sku.capabilityStatus}`;
// Already exists by sku_id → update seats/notes
if (existingBySkuId.has(sku.skuId)) {
License.update(existingBySkuId.get(sku.skuId).id, { seats, notes });
updated++;
continue;
}
// Already exists by name+vendor (imported before sku_id existed)
if (existingByName.has(`${name}||Microsoft`)) {
// Link sku_id retroactively
const match = existing.find(l => l.name === name && l.vendor === 'Microsoft');
if (match) License.update(match.id, { seats, notes, sku_id: sku.skuId });
updated++;
continue;
}
License.create({
name, vendor: 'Microsoft', license_type: 'subscription',
seats, notes, sku_id: sku.skuId,
created_by_user_id: req.user.id,
});
existingByName.add(`${name}||Microsoft`);
imported++;
} catch (err) {
errors.push(sku.skuPartNumber + ': ' + err.message);
}
}
res.json({ status: 'success', data: { imported, updated, skipped, errors } });
});
static getLicenseUsers = asyncHandler(async (req, res) => {
const license = License.getById(parseInt(req.params.id));
if (!license) return res.status(404).json({ status: 'error', message: 'Lizenz nicht gefunden' });
if (!license.sku_id) return res.json({ status: 'success', data: [] });
const { getEntraLicenseUsers } = require('../services/graph.service');
const users = await getEntraLicenseUsers(license.sku_id);
res.json({ status: 'success', data: users });
});
}
module.exports = LicenseController;

View File

@@ -0,0 +1,226 @@
const MonitoringAgent = require('../models/MonitoringAgent');
const Asset = require('../models/Asset');
const { asyncHandler } = require('../middleware/errorHandler');
const { getPendingCommands } = require('./patch.controller');
const { getDatabase } = require('../config/database');
const { getForAgent } = require('./announcement.controller');
function extractManufacturer(modelStr) {
if (!modelStr) return null;
const known = ['HP', 'Dell', 'Lenovo', 'Apple', 'Asus', 'Acer', 'Microsoft', 'Samsung', 'Toshiba', 'Fujitsu', 'Panasonic', 'Getac'];
const upper = modelStr.toUpperCase();
for (const b of known) {
if (upper.startsWith(b.toUpperCase())) return b;
}
return modelStr.trim().split(/\s+/)[0] || null;
}
function syncAgentToAsset(data) {
try {
const db = getDatabase();
const hostname = data.hostname;
if (!hostname) return;
let asset = Asset.getByName(hostname);
// Benutzer anhand last_user (Username) suchen
let assignedUserId = null;
if (data.last_user) {
const cleanUser = data.last_user.replace(/^[^\\]+\\/, ''); // Domain\User → User
const userRow = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND is_active = 1').get(cleanUser);
if (userRow) assignedUserId = userRow.id;
}
const syncData = {
serial_number: data.hardware_serial || undefined,
os: data.os_name || data.os || undefined,
ip_address: data.ip_address || undefined,
last_agent_sync: new Date().toISOString(),
};
if (assignedUserId) {
syncData.assigned_to_user_id = assignedUserId;
syncData.status = 'zugewiesen';
}
if (asset) {
// Hersteller nur setzen wenn noch nicht gesetzt
if (!asset.manufacturer && asset.model) {
syncData.manufacturer = extractManufacturer(asset.model);
}
Asset.update(asset.id, syncData, null);
} else {
// Neues Asset anlegen
const manufacturer = data.hardware_serial ? null : null; // Modell noch unbekannt
Asset.create({
name: hostname,
type: 'Notebook',
serial_number: data.hardware_serial || null,
model: null,
status: 'zugewiesen',
os: data.os_name || data.os || null,
ip_address: data.ip_address || null,
manufacturer: null,
assigned_to_user_id: assignedUserId,
department: 'IT',
last_agent_sync: new Date().toISOString(),
created_by_user_id: null,
});
console.log(`[AgentSync] Neues Asset erstellt: ${hostname}`);
}
} catch (err) {
console.error('[AgentSync] Fehler beim Asset-Sync:', err.message);
}
}
// POST /api/monitoring/checkin — called by agent (no JWT, uses API key)
const checkin = asyncHandler(async (req, res) => {
const agentKey = req.headers['x-agent-key'];
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
}
const { hostname } = req.body;
if (!hostname) {
return res.status(400).json({ status: 'error', message: 'hostname required' });
}
const agent = MonitoringAgent.upsert(req.body);
// Asset-Sync im Hintergrund (nicht blockend)
setImmediate(() => syncAgentToAsset(req.body));
// Pending patch commands für diesen Agenten zurückgeben
const { pending, running } = getPendingCommands(agent.id);
// Zielversion: Gruppen-spezifisch (staged rollout) oder globaler Default
const GLOBAL_VERSION = process.env.AGENT_VERSION || '1.2.4';
let targetVersion = GLOBAL_VERSION;
try {
const db = getDatabase();
const groupRow = db.prepare(`
SELECT pg.target_agent_version
FROM patch_agent_groups pag
JOIN patch_groups pg ON pg.id = pag.group_id
WHERE pag.agent_id = ?
ORDER BY pg.sort_order ASC
LIMIT 1
`).get(agent.id);
if (groupRow?.target_agent_version) targetVersion = groupRow.target_agent_version;
} catch { /* kein Gruppe zugewiesen → global */ }
const announcements = getForAgent(agent.id);
res.json({
status: 'success',
data: agent,
commands: pending,
running_commands: running,
agent_version: targetVersion,
announcements,
});
});
// GET /api/monitoring — list all agents (requires JWT)
const getAll = asyncHandler(async (req, res) => {
MonitoringAgent.markOffline();
const agents = MonitoringAgent.getAll().map(a => ({
...a,
installed_software: a.installed_software ? JSON.parse(a.installed_software) : []
}));
res.json({ status: 'success', data: agents });
});
// POST /api/monitoring/announcements-poll — leichter Poll nur für Ankündigungen
const announcementsPoll = asyncHandler(async (req, res) => {
const agentKey = req.headers['x-agent-key'];
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { hostname } = req.body;
if (!hostname) return res.status(400).json({ error: 'hostname required' });
const agent = MonitoringAgent.getByHostname(hostname);
if (!agent) return res.json({ announcements: [] });
const announcements = getForAgent(agent.id);
res.json({ announcements });
});
// GET /api/monitoring/statistics
const getStatistics = asyncHandler(async (req, res) => {
MonitoringAgent.markOffline();
const stats = MonitoringAgent.getStatistics();
res.json({ status: 'success', data: stats });
});
// GET /api/monitoring/:id
const getById = asyncHandler(async (req, res) => {
const agent = MonitoringAgent.getById(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'Agent not found' });
res.json({
status: 'success',
data: { ...agent, installed_software: agent.installed_software ? JSON.parse(agent.installed_software) : [] }
});
});
// DELETE /api/monitoring/:id
const deleteAgent = asyncHandler(async (req, res) => {
const deleted = MonitoringAgent.delete(req.params.id);
if (!deleted) return res.status(404).json({ status: 'error', message: 'Agent not found' });
res.json({ status: 'success', message: 'Agent removed' });
});
// GET /api/monitoring/agent-script — agent downloads its own update (X-Agent-Key auth)
const downloadScript = asyncHandler(async (req, res) => {
const agentKey = req.headers['x-agent-key'];
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
}
const path = require('path');
const fs = require('fs');
const scriptPath = path.join(__dirname, '../../agent/it-nexus-agent.ps1');
if (!fs.existsSync(scriptPath)) {
return res.status(404).json({ status: 'error', message: 'Script nicht gefunden' });
}
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('X-Agent-Version', process.env.AGENT_VERSION || '1.2.3');
res.sendFile(scriptPath);
});
// GET /api/monitoring/ann-watcher — agent downloads announcement watcher script
const downloadAnnWatcher = asyncHandler(async (req, res) => {
const agentKey = req.headers['x-agent-key'];
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
}
const path = require('path');
const fs = require('fs');
const scriptPath = path.join(__dirname, '../../agent/ann-watcher.ps1');
if (!fs.existsSync(scriptPath)) {
return res.status(404).json({ status: 'error', message: 'Script nicht gefunden' });
}
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.sendFile(scriptPath);
});
const downloadSetup = asyncHandler(async (req, res) => {
// Agent-Key Auth für auto-update vom Agent selbst
const agentKey = req.headers['x-agent-key'];
const isAgent = agentKey && agentKey === process.env.AGENT_API_KEY;
const isAdmin = req.user?.role_name === 'admin' || req.user?.role_name === 'super_admin';
if (!isAgent && !isAdmin) {
return res.status(401).json({ status: 'error', message: 'Unauthorized' });
}
const path = require('path');
const fs = require('fs');
const version = process.env.AGENT_VERSION || '2.0.0';
const setupPath = path.join(__dirname, `../../agent/IT-Nexus-Agent-Setup-v${version}.exe`);
if (!fs.existsSync(setupPath)) {
return res.status(404).json({ status: 'error', message: 'Setup nicht gefunden' });
}
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Agent-Version', version);
res.setHeader('Content-Disposition', `attachment; filename="IT-Nexus-Agent-Setup-v${version}.exe"`);
res.sendFile(setupPath);
});
module.exports = { checkin, announcementsPoll, getAll, getStatistics, getById, deleteAgent, downloadScript, downloadAnnWatcher, downloadSetup };

View File

@@ -0,0 +1,239 @@
const NetworkDevice = require('../models/NetworkDevice');
const { subscribe, unsubscribe, broadcast } = require('../services/networkSseManager');
const { scheduleDevice, cancelDevice, runCheck } = require('../services/networkPoller.service');
const jwt = require('jsonwebtoken');
const JWT_CONFIG = require('../config/jwt');
const { spawn } = require('child_process');
const net = require('net');
const dns = require('dns').promises;
// ─── Auto-Discovery ───────────────────────────────────────────────────────────
function pingHost(ip, timeoutMs = 800) {
return new Promise(resolve => {
const proc = spawn('ping', ['-c', '1', '-W', '1', ip]);
const kill = setTimeout(() => { proc.kill(); resolve(false); }, timeoutMs);
proc.on('close', code => { clearTimeout(kill); resolve(code === 0); });
proc.on('error', () => { clearTimeout(kill); resolve(false); });
});
}
function checkPort(ip, port, timeoutMs = 500) {
return new Promise(resolve => {
const sock = new net.Socket();
sock.setTimeout(timeoutMs);
sock.connect(port, ip, () => { sock.destroy(); resolve(true); });
sock.on('error', () => resolve(false));
sock.on('timeout', () => { sock.destroy(); resolve(false); });
});
}
async function resolveHostname(ip) {
try {
const names = await dns.reverse(ip);
return names[0]?.replace(/\.$/, '') || null;
} catch { return null; }
}
function guessType(openPorts) {
if (openPorts.includes(9100)) return 'printer';
if (openPorts.includes(23) || openPorts.includes(161)) return 'switch';
if (openPorts.includes(5000) || openPorts.includes(5001) || openPorts.includes(2049)) return 'nas';
if (openPorts.includes(445) || openPorts.includes(139)) return 'nas';
if (openPorts.includes(80) || openPorts.includes(443) || openPorts.includes(22)) return 'host';
return 'host';
}
async function scanHost(ip) {
const alive = await pingHost(ip);
if (!alive) return null;
// Check common ports in parallel
const PORT_MAP = [22, 23, 80, 443, 445, 161, 9100, 5000, 5001, 8080, 8443, 3389];
const portResults = await Promise.all(PORT_MAP.map(p => checkPort(ip, p).then(ok => ok ? p : null)));
const openPorts = portResults.filter(Boolean);
const hostname = await resolveHostname(ip);
const type = guessType(openPorts);
const checkType = openPorts.includes(443) ? 'https' : openPorts.includes(80) ? 'http' : 'icmp';
return { ip, hostname, type, check_type: checkType, open_ports: openPorts };
}
async function runInBatches(items, batchSize, fn) {
const results = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const res = await Promise.all(batch.map(fn));
results.push(...res);
}
return results;
}
const discover = async (req, res) => {
try {
const { subnet } = req.body; // e.g. "192.168.0" or "192.168.0.0/24"
if (!subnet) return res.status(400).json({ status: 'error', message: 'subnet fehlt' });
// Parse subnet to base (first 3 octets)
let base = subnet.replace(/\/\d+$/, '').trim();
const parts = base.split('.');
if (parts.length === 4) base = parts.slice(0, 3).join('.');
if (parts.length < 3) return res.status(400).json({ status: 'error', message: 'Ungültiges Subnet' });
const ips = Array.from({ length: 254 }, (_, i) => `${base}.${i + 1}`);
// Existing devices to skip
const existing = NetworkDevice.getAll().map(d => d.host);
// Scan in batches of 40 parallel pings
const raw = await runInBatches(ips, 40, scanHost);
const found = raw
.filter(r => r !== null && !existing.includes(r.ip))
.sort((a, b) => {
const ai = parseInt(a.ip.split('.').pop());
const bi = parseInt(b.ip.split('.').pop());
return ai - bi;
});
res.json({ status: 'success', data: found, scanned: ips.length });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
// ─── SSE Stream ───────────────────────────────────────────────────────────────
const sseStream = (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
// Send initial state
const devices = NetworkDevice.getAll();
res.write(`event: initial_state\ndata: ${JSON.stringify(devices)}\n\n`);
// Heartbeat
const hb = setInterval(() => { try { res.write(': heartbeat\n\n'); } catch (_) {} }, 15000);
subscribe(res);
req.on('close', () => { clearInterval(hb); unsubscribe(res); });
};
// ─── CRUD ─────────────────────────────────────────────────────────────────────
const getAll = (req, res) => {
try {
const devices = NetworkDevice.getAll();
res.json({ status: 'success', data: devices });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
const getStatistics = (req, res) => {
try {
const stats = NetworkDevice.getStatistics();
res.json({ status: 'success', data: stats });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
const getChecks = (req, res) => {
try {
const hours = parseInt(req.query.hours) || 24;
const checks = NetworkDevice.getChecks(req.params.id, hours);
const uptime = NetworkDevice.getUptimeStats(req.params.id, hours);
res.json({ status: 'success', data: { checks, uptime } });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
const createDevice = (req, res) => {
try {
const data = sanitize(req.body);
const device = NetworkDevice.create(data);
scheduleDevice(device);
broadcast('device_update', device);
res.status(201).json({ status: 'success', data: device });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
const updateDevice = (req, res) => {
try {
const data = sanitize(req.body);
const device = NetworkDevice.update(req.params.id, data);
if (!device) return res.status(404).json({ status: 'error', message: 'Nicht gefunden' });
if (device.enabled) scheduleDevice(device);
else cancelDevice(device.id);
broadcast('device_update', device);
res.json({ status: 'success', data: device });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
const deleteDevice = (req, res) => {
try {
cancelDevice(parseInt(req.params.id));
NetworkDevice.delete(req.params.id);
broadcast('device_deleted', { id: parseInt(req.params.id) });
res.json({ status: 'success' });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
const checkNow = async (req, res) => {
try {
const device = NetworkDevice.getById(req.params.id);
if (!device) return res.status(404).json({ status: 'error', message: 'Nicht gefunden' });
await runCheck(device);
const updated = NetworkDevice.getById(req.params.id);
res.json({ status: 'success', data: updated });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
const getAllUptimeStats = (req, res) => {
try {
const devices = NetworkDevice.getAll();
const stats = {};
devices.forEach(d => { stats[d.id] = NetworkDevice.getUptimeStats(d.id, 24); });
res.json({ status: 'success', data: stats });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
// ─── Helpers ─────────────────────────────────────────────────────────────────
function sanitize(body) {
return {
name: body.name?.trim() || '',
type: body.type || 'host',
host: body.host?.trim() || '',
check_type: body.check_type || 'icmp',
port: body.port ? parseInt(body.port) : null,
http_path: body.http_path || '/',
http_keyword: body.http_keyword || null,
snmp_community: body.snmp_community?.trim() || 'public',
snmp_version: ['1', '2c'].includes(body.snmp_version) ? body.snmp_version : '2c',
interval_sec: parseInt(body.interval_sec) || 60,
timeout_sec: parseInt(body.timeout_sec) || 5,
enabled: body.enabled === false || body.enabled === 0 ? 0 : 1,
notify_email: body.notify_email || null,
location: body.location || null,
};
}
module.exports = { sseStream, getAll, getStatistics, getChecks, getAllUptimeStats, createDevice, updateDevice, deleteDevice, checkNow, discover };

View File

@@ -0,0 +1,69 @@
const OffboardingService = require('../services/offboarding.service');
const { asyncHandler } = require('../middleware/errorHandler');
class OffboardingController {
static getAllProtocols = asyncHandler(async (req, res) => {
const protocols = OffboardingService.getAllProtocols();
res.json({ status: 'success', data: protocols });
});
static getProtocolById = asyncHandler(async (req, res) => {
const protocol = OffboardingService.getProtocolById(parseInt(req.params.id));
res.json({ status: 'success', data: protocol });
});
static createProtocol = asyncHandler(async (req, res) => {
const protocol = await OffboardingService.createProtocol(req.body, req.user.id, req.ip);
res.status(201).json({ status: 'success', data: protocol });
});
static updateProtocol = asyncHandler(async (req, res) => {
const protocol = await OffboardingService.updateProtocol(
parseInt(req.params.id),
req.body,
req.user.id,
req.ip
);
res.json({ status: 'success', data: protocol });
});
static returnAssets = asyncHandler(async (req, res) => {
const protocol = await OffboardingService.returnAssets(
parseInt(req.params.id),
req.body.asset_returns,
req.user.id,
req.ip
);
res.json({ status: 'success', data: protocol });
});
static regeneratePdf = asyncHandler(async (req, res) => {
const protocol = await OffboardingService.regeneratePdf(
parseInt(req.params.id),
req.user.id,
req.ip
);
res.json({ status: 'success', data: protocol });
});
static deleteProtocol = asyncHandler(async (req, res) => {
const deleted = OffboardingService.deleteProtocol(
parseInt(req.params.id),
req.user.id,
req.ip
);
if (!deleted) {
return res.status(404).json({ status: 'error', message: 'Protokoll nicht gefunden' });
}
res.json({ status: 'success', message: 'Protokoll erfolgreich gelöscht' });
});
static getStatistics = asyncHandler(async (req, res) => {
const stats = OffboardingService.getStatistics();
res.json({ status: 'success', data: stats });
});
}
module.exports = OffboardingController;

View File

@@ -0,0 +1,85 @@
const OnboardingService = require('../services/onboarding.service');
const { asyncHandler } = require('../middleware/errorHandler');
const { sendOnboardingConfirmationEmail } = require('../services/email.service');
class OnboardingController {
static getAllProtocols = asyncHandler(async (req, res) => {
const protocols = OnboardingService.getAllProtocols();
res.json({ status: 'success', data: protocols });
});
static getProtocolById = asyncHandler(async (req, res) => {
const protocol = OnboardingService.getProtocolById(parseInt(req.params.id));
res.json({ status: 'success', data: protocol });
});
static createProtocol = asyncHandler(async (req, res) => {
const protocol = await OnboardingService.createProtocol(req.body, req.user.id, req.ip);
res.status(201).json({ status: 'success', data: protocol });
});
static updateProtocol = asyncHandler(async (req, res) => {
const protocol = await OnboardingService.updateProtocol(
parseInt(req.params.id),
req.body,
req.user.id,
req.ip
);
res.json({ status: 'success', data: protocol });
});
static regeneratePdf = asyncHandler(async (req, res) => {
const protocol = await OnboardingService.regeneratePdf(
parseInt(req.params.id),
req.user.id,
req.ip
);
res.json({ status: 'success', data: protocol });
});
static deleteProtocol = asyncHandler(async (req, res) => {
const deleted = OnboardingService.deleteProtocol(
parseInt(req.params.id),
req.user.id,
req.ip
);
if (!deleted) {
return res.status(404).json({ status: 'error', message: 'Protokoll nicht gefunden' });
}
res.json({ status: 'success', message: 'Protokoll erfolgreich gelöscht' });
});
static sendConfirmationEmail = asyncHandler(async (req, res) => {
const crypto = require('crypto');
const OnboardingProtocol = require('../models/OnboardingProtocol');
const protocol = await OnboardingService.getProtocolById(parseInt(req.params.id));
if (!protocol.emp_private_email) {
return res.status(400).json({ status: 'error', message: 'Keine private E-Mail-Adresse beim Mitarbeiter hinterlegt' });
}
const token = crypto.randomBytes(32).toString('hex');
OnboardingProtocol.setConfirmToken(protocol.id, token);
await sendOnboardingConfirmationEmail(protocol, token);
res.json({ status: 'success', message: `Bestätigungsmail an ${protocol.emp_private_email} gesendet` });
});
static confirmByToken = asyncHandler(async (req, res) => {
const OnboardingProtocol = require('../models/OnboardingProtocol');
const { token } = req.params;
const protocol = OnboardingProtocol.getByConfirmToken(token);
if (!protocol) {
return res.status(404).json({ status: 'error', message: 'Ungültiger oder bereits verwendeter Bestätigungslink' });
}
OnboardingProtocol.confirmByToken(token);
const name = `${protocol.emp_first_name || ''} ${protocol.emp_last_name || ''}`.trim();
res.json({ status: 'success', message: 'Übergabe erfolgreich bestätigt', data: { name, department: protocol.department } });
});
static getStatistics = asyncHandler(async (req, res) => {
const stats = OnboardingService.getStatistics();
res.json({ status: 'success', data: stats });
});
}
module.exports = OnboardingController;

View File

@@ -0,0 +1,95 @@
const OnboardingProcess = require('../models/OnboardingProcess');
// ── Departments ──────────────────────────────────────────────────────────────
exports.getDepartments = (req, res) => {
const data = OnboardingProcess.getAllDepartments();
res.json({ status: 'success', data });
};
exports.createDepartment = (req, res) => {
const { name, icon, sort_order } = req.body;
if (!name?.trim()) {
return res.status(400).json({ status: 'error', message: 'Name ist erforderlich' });
}
try {
const dept = OnboardingProcess.createDepartment({ name: name.trim(), icon, sort_order });
res.status(201).json({ status: 'success', data: dept });
} catch (err) {
if (err.message?.includes('UNIQUE')) {
return res.status(409).json({ status: 'error', message: 'Abteilung existiert bereits' });
}
throw err;
}
};
exports.updateDepartment = (req, res) => {
const { id } = req.params;
const { name, icon, sort_order } = req.body;
const dept = OnboardingProcess.updateDepartment(id, { name, icon, sort_order });
if (!dept) return res.status(404).json({ status: 'error', message: 'Abteilung nicht gefunden' });
res.json({ status: 'success', data: dept });
};
exports.deleteDepartment = (req, res) => {
const { id } = req.params;
const ok = OnboardingProcess.deleteDepartment(id);
if (!ok) return res.status(404).json({ status: 'error', message: 'Abteilung nicht gefunden' });
res.json({ status: 'success', message: 'Abteilung gelöscht' });
};
exports.reorderDepartments = (req, res) => {
const { ids } = req.body;
if (!Array.isArray(ids)) {
return res.status(400).json({ status: 'error', message: 'ids muss ein Array sein' });
}
OnboardingProcess.reorderDepartments(ids);
res.json({ status: 'success', message: 'Reihenfolge gespeichert' });
};
// ── Processes ────────────────────────────────────────────────────────────────
exports.getProcesses = (req, res) => {
const { department_id, applies_to, responsible_team } = req.query;
const data = OnboardingProcess.getAllProcesses({
departmentId: department_id ? Number(department_id) : undefined,
appliesTo: applies_to || undefined,
responsibleTeam: responsible_team || undefined,
});
res.json({ status: 'success', data });
};
exports.createProcess = (req, res) => {
const { department_id, responsible_team, title, description, applies_to, tag, sort_order } = req.body;
if (!department_id || !title?.trim()) {
return res.status(400).json({ status: 'error', message: 'department_id und title sind erforderlich' });
}
const proc = OnboardingProcess.createProcess({
department_id, responsible_team, title: title.trim(), description, applies_to, tag, sort_order,
});
res.status(201).json({ status: 'success', data: proc });
};
exports.updateProcess = (req, res) => {
const { id } = req.params;
const { title, description, applies_to, tag, sort_order, department_id, responsible_team } = req.body;
const proc = OnboardingProcess.updateProcess(id, { title, description, applies_to, tag, sort_order, department_id, responsible_team });
if (!proc) return res.status(404).json({ status: 'error', message: 'Prozess nicht gefunden' });
res.json({ status: 'success', data: proc });
};
exports.deleteProcess = (req, res) => {
const { id } = req.params;
const ok = OnboardingProcess.deleteProcess(id);
if (!ok) return res.status(404).json({ status: 'error', message: 'Prozess nicht gefunden' });
res.json({ status: 'success', message: 'Prozess gelöscht' });
};
exports.reorderProcesses = (req, res) => {
const { department_id, ids } = req.body;
if (!department_id || !Array.isArray(ids)) {
return res.status(400).json({ status: 'error', message: 'department_id und ids erforderlich' });
}
OnboardingProcess.reorderProcesses(department_id, ids);
res.json({ status: 'success', message: 'Reihenfolge gespeichert' });
};

View File

@@ -0,0 +1,303 @@
const { getDatabase } = require('../config/database');
// ── Patch Groups ──────────────────────────────────────────────────────────────
const getGroups = (req, res) => {
const db = getDatabase();
const groups = db.prepare('SELECT * FROM patch_groups ORDER BY sort_order, name').all();
const policies = db.prepare('SELECT * FROM patch_policies ORDER BY group_id').all();
const agentCounts = db.prepare(`
SELECT group_id, COUNT(*) as count FROM patch_agent_groups GROUP BY group_id
`).all();
res.json(groups.map(g => ({
...g,
policies: policies.filter(p => p.group_id === g.id),
agent_count: agentCounts.find(a => a.group_id === g.id)?.count || 0,
})));
};
const createGroup = (req, res) => {
const { name, description, color = '#3B82F6' } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
const db = getDatabase();
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM patch_groups').get();
try {
const result = db.prepare(
'INSERT INTO patch_groups (name, description, color, sort_order) VALUES (?, ?, ?, ?)'
).run(name.trim(), description || null, color, (maxOrder.m || 0) + 1);
res.status(201).json(db.prepare('SELECT * FROM patch_groups WHERE id = ?').get(result.lastInsertRowid));
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Gruppe existiert bereits' });
throw e;
}
};
const updateGroup = (req, res) => {
const db = getDatabase();
const existing = db.prepare('SELECT * FROM patch_groups WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
const { name, description, color, target_agent_version } = req.body;
db.prepare('UPDATE patch_groups SET name=?, description=?, color=?, target_agent_version=? WHERE id=?')
.run(name?.trim() ?? existing.name, description ?? existing.description, color ?? existing.color,
target_agent_version !== undefined ? (target_agent_version || null) : existing.target_agent_version,
req.params.id);
res.json(db.prepare('SELECT * FROM patch_groups WHERE id = ?').get(req.params.id));
};
// Rollout-Reihenfolge: Test → Pilot → Produktion
// Eine Gruppe darf eine Version nur bekommen wenn die Vorgänger-Gruppe sie bereits hat
const ROLLOUT_ORDER = ['Test', 'Pilot', 'Produktion'];
const releaseVersionToAll = (req, res) => {
const db = getDatabase();
const { version } = req.body;
if (!version) return res.status(400).json({ error: 'version erforderlich' });
db.prepare('UPDATE patch_groups SET target_agent_version=?').run(version);
res.json({ success: true, version });
};
const releaseVersionToGroup = (req, res) => {
const db = getDatabase();
const { version } = req.body;
if (!version) return res.status(400).json({ error: 'version erforderlich' });
const group = db.prepare('SELECT * FROM patch_groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Gruppe nicht gefunden' });
const pos = ROLLOUT_ORDER.indexOf(group.name);
// Vorgänger-Gruppe muss dieselbe oder höhere Version haben
if (pos > 0) {
const predecessorName = ROLLOUT_ORDER[pos - 1];
const predecessor = db.prepare('SELECT target_agent_version FROM patch_groups WHERE name = ?').get(predecessorName);
if (!predecessor?.target_agent_version || predecessor.target_agent_version !== version) {
return res.status(403).json({
error: `Version ${version} muss zuerst für "${predecessorName}" freigegeben sein bevor "${group.name}" aktualisiert werden kann.`,
blocked_by: predecessorName,
predecessor_version: predecessor?.target_agent_version || null
});
}
}
db.prepare('UPDATE patch_groups SET target_agent_version=? WHERE id=?').run(version, req.params.id);
res.json({ success: true, group_id: group.id, group_name: group.name, version });
};
const deleteGroup = (req, res) => {
const db = getDatabase();
if (!db.prepare('SELECT id FROM patch_groups WHERE id = ?').get(req.params.id)) {
return res.status(404).json({ error: 'Nicht gefunden' });
}
db.prepare('DELETE FROM patch_groups WHERE id = ?').run(req.params.id);
res.json({ success: true });
};
// ── Patch Policies ────────────────────────────────────────────────────────────
const upsertPolicy = (req, res) => {
const { group_id, severity, max_days, notify_email } = req.body;
if (!group_id || !severity || !max_days) return res.status(400).json({ error: 'group_id, severity und max_days erforderlich' });
const db = getDatabase();
const existing = db.prepare('SELECT id FROM patch_policies WHERE group_id=? AND severity=?').get(group_id, severity);
if (existing) {
db.prepare('UPDATE patch_policies SET max_days=?, notify_email=? WHERE id=?')
.run(max_days, notify_email || null, existing.id);
res.json(db.prepare('SELECT * FROM patch_policies WHERE id=?').get(existing.id));
} else {
const result = db.prepare(
'INSERT INTO patch_policies (group_id, severity, max_days, notify_email) VALUES (?,?,?,?)'
).run(group_id, severity, max_days, notify_email || null);
res.status(201).json(db.prepare('SELECT * FROM patch_policies WHERE id=?').get(result.lastInsertRowid));
}
};
const deletePolicy = (req, res) => {
const db = getDatabase();
db.prepare('DELETE FROM patch_policies WHERE id=?').run(req.params.id);
res.json({ success: true });
};
// ── Agent Assignment ──────────────────────────────────────────────────────────
const assignAgent = (req, res) => {
const { agent_id, group_id } = req.body;
if (!agent_id || !group_id) return res.status(400).json({ error: 'agent_id und group_id erforderlich' });
const db = getDatabase();
// Ein Gerät kann nur in einer Gruppe sein → alte Zuweisung entfernen
db.prepare('DELETE FROM patch_agent_groups WHERE agent_id=?').run(agent_id);
if (group_id !== null && group_id !== 'none') {
db.prepare('INSERT OR REPLACE INTO patch_agent_groups (agent_id, group_id) VALUES (?,?)').run(agent_id, group_id);
}
res.json({ success: true });
};
// ── Overview / Compliance ─────────────────────────────────────────────────────
const getOverview = (req, res) => {
const db = getDatabase();
const agents = db.prepare(`
SELECT
a.*,
pg.id as group_id,
pg.name as group_name,
pg.color as group_color
FROM monitoring_agents a
LEFT JOIN patch_agent_groups pag ON a.id = pag.agent_id
LEFT JOIN patch_groups pg ON pag.group_id = pg.id
ORDER BY pg.sort_order NULLS LAST, a.hostname
`).all();
const policies = db.prepare('SELECT * FROM patch_policies').all();
const result = agents.map(agent => {
let updates = [];
try { updates = JSON.parse(agent.pending_updates || '[]'); } catch {}
const agentPolicies = policies.filter(p => p.group_id === agent.group_id);
const updateCount = typeof agent.windows_updates_pending === 'number'
? agent.windows_updates_pending
: 0;
// Compliance-Status berechnen
let compliance = 'ok';
let violationReason = null;
if (agentPolicies.length > 0 && updateCount > 0) {
compliance = 'warn';
violationReason = `${updateCount} ausstehende Update(s)`;
}
// last_checkin ist UTC ohne Timezone-Marker → explizit als UTC parsen
const lastCheckin = agent.last_checkin ? new Date(agent.last_checkin + 'Z') : null;
const isOffline = !lastCheckin || (Date.now() - lastCheckin.getTime()) > 15 * 60 * 1000;
return {
id: agent.id,
hostname: agent.hostname,
os: agent.os_name,
os_version: agent.os_version,
last_seen: agent.last_checkin,
is_offline: isOffline,
pending_updates: updateCount,
agent_version: agent.agent_version || '?',
tpm_present: !!agent.tpm_present,
tpm_version: agent.tpm_version || null,
tpm_v2: !!agent.tpm_v2,
secure_boot: !!agent.secure_boot,
win11_ready: !!agent.win11_ready,
last_user: agent.last_user || null,
bitlocker_status: agent.bitlocker_status || null,
defender_enabled: agent.defender_enabled != null ? !!agent.defender_enabled : null,
defender_signatures_age: agent.defender_signatures_age ?? null,
hardware_serial: agent.hardware_serial || null,
ip_address: agent.ip_address || null,
group_id: agent.group_id,
group_name: agent.group_name || 'Keine Gruppe',
group_color: agent.group_color || '#6B7280',
compliance,
violation_reason: violationReason,
};
});
const stats = {
total: result.length,
compliant: result.filter(a => a.compliance === 'ok' && !a.is_offline).length,
warnings: result.filter(a => a.compliance === 'warn').length,
offline: result.filter(a => a.is_offline).length,
total_pending_updates: result.reduce((s, a) => s + (a.pending_updates || 0), 0),
};
res.json({ agents: result, stats });
};
// ── Commands ──────────────────────────────────────────────────────────────────
const triggerCommand = (req, res) => {
const { agent_id, command } = req.body;
if (!agent_id || !command) return res.status(400).json({ error: 'agent_id und command erforderlich' });
const db = getDatabase();
// Duplikate vermeiden: nur ein pending/sent Command pro Agent+Type
db.prepare("DELETE FROM patch_commands WHERE agent_id=? AND command=? AND status IN ('pending','sent')").run(agent_id, command);
const result = db.prepare(
'INSERT INTO patch_commands (agent_id, command, triggered_by_user_id) VALUES (?,?,?)'
).run(agent_id, command, req.user.id);
res.status(201).json(db.prepare('SELECT * FROM patch_commands WHERE id=?').get(result.lastInsertRowid));
};
const triggerGroupCommand = (req, res) => {
const { group_id, command } = req.body;
if (!group_id || !command) return res.status(400).json({ error: 'group_id und command erforderlich' });
const db = getDatabase();
const agents = db.prepare('SELECT agent_id FROM patch_agent_groups WHERE group_id=?').all(group_id);
let count = 0;
for (const { agent_id } of agents) {
db.prepare("DELETE FROM patch_commands WHERE agent_id=? AND command=? AND status IN ('pending','sent')").run(agent_id, command);
db.prepare('INSERT INTO patch_commands (agent_id, command, triggered_by_user_id) VALUES (?,?,?)').run(agent_id, command, req.user.id);
count++;
}
res.json({ success: true, triggered: count });
};
const getCommands = (req, res) => {
const db = getDatabase();
const { agent_id } = req.query;
const rows = agent_id
? db.prepare(`
SELECT pc.*, a.hostname, u.username as triggered_by_username
FROM patch_commands pc
JOIN monitoring_agents a ON pc.agent_id = a.id
LEFT JOIN users u ON pc.triggered_by_user_id = u.id
WHERE pc.agent_id = ?
ORDER BY pc.created_at DESC
LIMIT 30
`).all(agent_id)
: db.prepare(`
SELECT pc.*, a.hostname, u.username as triggered_by_username
FROM patch_commands pc
JOIN monitoring_agents a ON pc.agent_id = a.id
LEFT JOIN users u ON pc.triggered_by_user_id = u.id
ORDER BY pc.created_at DESC
LIMIT 100
`).all();
res.json(rows);
};
// Called by the agent after execution
const reportCommandResult = (req, res) => {
const agentKey = req.headers['x-agent-key'];
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { command_id, status, result } = req.body;
if (!command_id) return res.status(400).json({ error: 'command_id erforderlich' });
const db = getDatabase();
db.prepare("UPDATE patch_commands SET status=?, result=?, completed_at=CURRENT_TIMESTAMP WHERE id=?")
.run(status || 'done', result || null, command_id);
res.json({ success: true });
};
// Called by agent check-in returns pending + running commands for this agent
const getPendingCommands = (agentId) => {
const db = getDatabase();
const cmds = db.prepare("SELECT * FROM patch_commands WHERE agent_id=? AND status='pending'").all(agentId);
if (cmds.length > 0) {
db.prepare("UPDATE patch_commands SET status='sent', sent_at=CURRENT_TIMESTAMP WHERE agent_id=? AND status='pending'")
.run(agentId);
}
const running = db.prepare("SELECT * FROM patch_commands WHERE agent_id=? AND status='running'").all(agentId);
return { pending: cmds, running };
};
module.exports = {
getGroups, createGroup, updateGroup, deleteGroup, releaseVersionToGroup, releaseVersionToAll,
upsertPolicy, deletePolicy,
assignAgent,
getOverview,
triggerCommand, triggerGroupCommand, getCommands, reportCommandResult,
getPendingCommands,
};

View File

@@ -0,0 +1,106 @@
const { getDatabase } = require('../config/database');
const ADMIN_ROLES = ['super_admin', 'admin'];
// Prüft ob ein User eine Anleitung sehen darf
function canView(visibleRoles, userRole) {
if (!visibleRoles || visibleRoles === '[]' || visibleRoles === '["all"]') return true;
try {
const roles = JSON.parse(visibleRoles);
if (!roles.length || roles.includes('all')) return true;
return roles.includes(userRole);
} catch { return true; }
}
const getAll = (req, res) => {
const db = getDatabase();
const userRole = req.user?.role_name || req.user?.role || '';
const isAdmin = ADMIN_ROLES.includes(userRole);
const rows = db.prepare(`
SELECT pg.*, u.username as created_by_username
FROM portal_guides pg
LEFT JOIN users u ON pg.created_by_user_id = u.id
ORDER BY pg.sort_order, pg.title
`).all();
// Admins sehen alles, andere nur erlaubte
const filtered = isAdmin
? rows
: rows.filter(r => canView(r.visible_roles, userRole));
res.json(filtered.map(({ html_content, ...r }) => r));
};
const getById = (req, res) => {
const db = getDatabase();
const row = db.prepare('SELECT * FROM portal_guides WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Nicht gefunden' });
const userRole = req.user?.role_name || req.user?.role || '';
const isAdmin = ADMIN_ROLES.includes(userRole);
if (!isAdmin && !canView(row.visible_roles, userRole)) {
return res.status(403).json({ error: 'Keine Berechtigung' });
}
res.json(row);
};
const getHtml = (req, res) => {
const db = getDatabase();
const row = db.prepare('SELECT html_content, title, visible_roles FROM portal_guides WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).send('Nicht gefunden');
const userRole = req.user?.role_name || req.user?.role || '';
const isAdmin = ADMIN_ROLES.includes(userRole);
if (!isAdmin && !canView(row.visible_roles, userRole)) {
return res.status(403).send('Keine Berechtigung');
}
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.send(row.html_content);
};
const create = (req, res) => {
const { title, category = 'Allgemein', description, html_content, icon = '📄', sort_order = 0, visible_roles = [] } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' });
if (!html_content?.trim()) return res.status(400).json({ error: 'HTML-Inhalt erforderlich' });
const db = getDatabase();
const result = db.prepare(`
INSERT INTO portal_guides (title, category, description, html_content, icon, sort_order, visible_roles, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(title.trim(), category, description || null, html_content, icon, sort_order,
JSON.stringify(Array.isArray(visible_roles) ? visible_roles : []), req.user.id);
res.status(201).json(db.prepare('SELECT * FROM portal_guides WHERE id = ?').get(result.lastInsertRowid));
};
const update = (req, res) => {
const db = getDatabase();
const existing = db.prepare('SELECT * FROM portal_guides WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
const { title, category, description, html_content, icon, sort_order, visible_roles } = req.body;
db.prepare(`
UPDATE portal_guides
SET title = ?, category = ?, description = ?, html_content = ?, icon = ?, sort_order = ?, visible_roles = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
title?.trim() ?? existing.title,
category ?? existing.category,
description !== undefined ? description : existing.description,
html_content ?? existing.html_content,
icon ?? existing.icon,
sort_order ?? existing.sort_order,
visible_roles !== undefined ? JSON.stringify(Array.isArray(visible_roles) ? visible_roles : []) : existing.visible_roles,
req.params.id
);
const { html_content: _, ...row } = db.prepare('SELECT * FROM portal_guides WHERE id = ?').get(req.params.id);
res.json(row);
};
const remove = (req, res) => {
const db = getDatabase();
if (!db.prepare('SELECT id FROM portal_guides WHERE id = ?').get(req.params.id)) {
return res.status(404).json({ error: 'Nicht gefunden' });
}
db.prepare('DELETE FROM portal_guides WHERE id = ?').run(req.params.id);
res.json({ success: true });
};
module.exports = { getAll, getById, getHtml, create, update, remove };

View File

@@ -0,0 +1,12 @@
const ProxmoxService = require('../services/proxmox.service');
const getOverview = async (req, res) => {
try {
const data = await ProxmoxService.getAll();
res.json(data);
} catch (e) {
res.status(503).json({ error: 'Proxmox nicht erreichbar', detail: e.message });
}
};
module.exports = { getOverview };

View File

@@ -0,0 +1,153 @@
const Risk = require('../models/Risk');
const { getSecureScore, getRiskyUsers, getDefenderAlerts, getNonCompliantDevices, getUsersWithoutMfa } = require('../services/graph.service');
exports.getAll = (req, res) => {
const data = Risk.getAll(req.query);
res.json({ success: true, data });
};
exports.getStats = (req, res) => {
res.json({ success: true, data: Risk.getStats() });
};
exports.create = (req, res) => {
const risk = Risk.create(req.body);
res.status(201).json({ success: true, data: risk });
};
exports.update = (req, res) => {
const risk = Risk.update(req.params.id, req.body);
if (!risk) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
res.json({ success: true, data: risk });
};
exports.remove = (req, res) => {
const ok = Risk.delete(req.params.id);
if (!ok) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
res.json({ success: true });
};
exports.sync = async (req, res) => {
const result = { created: 0, updated: 0, errors: [] };
// 1. Secure Score
try {
const score = await getSecureScore();
if (score) {
const pct = Math.round((score.currentScore / score.maxScore) * 100);
const prob = pct < 40 ? 5 : pct < 60 ? 4 : pct < 75 ? 3 : pct < 90 ? 2 : 1;
const r = Risk.upsertAuto({
source_type: 'secure_score',
external_id: 'tenant_secure_score',
title: `Microsoft Secure Score: ${pct}% (${Math.round(score.currentScore)}/${Math.round(score.maxScore)})`,
description: `Aktueller Microsoft Secure Score. Je niedriger der Score, desto höher das Sicherheitsrisiko. Letzte Messung: ${new Date(score.createdDateTime).toLocaleDateString('de-DE')}.`,
category: 'IT-Sicherheit',
probability: prob,
impact: 4,
});
result[r.last_synced ? 'updated' : 'created']++;
}
} catch (e) { result.errors.push(`Secure Score: ${e.message}`); }
// 2. Risky Users (requires Azure AD Premium P2 - skip if not available)
try {
const users = await getRiskyUsers();
for (const u of users) {
const levelMap = { high: 5, medium: 3, low: 2 };
const prob = levelMap[u.riskLevel] || 3;
Risk.upsertAuto({
source_type: 'risky_user',
external_id: u.id,
title: `Risikobehafteter Benutzer: ${u.userDisplayName || u.userPrincipalName}`,
description: `Entra ID Identity Protection hat diesen Benutzer als risikoreich eingestuft. Risiko-Level: ${u.riskLevel}, Detail: ${u.riskDetail || 'unbekannt'}.`,
category: 'Identität & Zugriff',
probability: prob,
impact: 4,
});
}
result.created += users.length;
} catch (e) {
// Risky Users requires Azure AD Premium P2 silently skip if not licensed
if (!e.message.includes('Forbidden') && !e.message.includes('missing in the token')) {
result.errors.push(`Risky Users: ${e.message}`);
}
}
// 3. Defender Alerts
try {
const alerts = await getDefenderAlerts();
const sevMap = { high: 5, medium: 3, low: 2, informational: 1 };
for (const a of alerts) {
Risk.upsertAuto({
source_type: 'defender_alert',
external_id: a.id,
title: `Defender Alert: ${a.title}`,
description: `${a.description || ''}`,
category: 'Incident / Bedrohung',
probability: sevMap[a.severity] || 3,
impact: sevMap[a.severity] || 3,
metadata: {
severity: a.severity,
status: a.status,
category: a.category,
detected: a.createdDateTime,
entities: (a.evidence || []).map(e => e.userAccount?.displayName || e.deviceDnsName || e.fileName).filter(Boolean),
recommendation: a.recommendedActions,
alertWebUrl: a.alertWebUrl,
},
});
}
result.created += alerts.length;
} catch (e) { result.errors.push(`Defender Alerts: ${e.message}`); }
// 4. Non-Compliant Devices
try {
const devices = await getNonCompliantDevices();
for (const d of devices) {
Risk.upsertAuto({
source_type: 'noncompliant_device',
external_id: d.id,
title: `Nicht-konformes Gerät: ${d.deviceName}`,
description: `Gerät erfüllt die Intune-Compliance-Richtlinien nicht.`,
category: 'Geräte & Endpunkte',
probability: 3,
impact: 3,
metadata: {
deviceName: d.deviceName,
user: d.userDisplayName,
os: d.operatingSystem,
serialNumber: d.serialNumber,
lastSync: d.lastSyncDateTime,
},
});
}
result.created += devices.length;
} catch (e) { result.errors.push(`Non-Compliant Devices: ${e.message}`); }
// 5. Users without MFA
try {
const noMfa = await getUsersWithoutMfa();
if (noMfa.length > 0) {
Risk.upsertAuto({
source_type: 'no_mfa',
external_id: 'users_without_mfa',
title: `${noMfa.length} Benutzer ohne MFA registriert`,
description: `${noMfa.length} Benutzer haben keine Multi-Faktor-Authentifizierung eingerichtet.`,
category: 'Identität & Zugriff',
probability: 4,
impact: 4,
metadata: {
users: noMfa.map(u => ({
name: u.userDisplayName,
email: u.userPrincipalName,
mfaRegistered: u.isMfaRegistered,
methods: u.authMethods,
})),
},
});
result.created++;
}
} catch (e) { result.errors.push(`MFA-Status: ${e.message}`); }
res.json({ success: true, data: result });
};

View File

@@ -0,0 +1,150 @@
const { getDatabase } = require('../config/database');
const { asyncHandler } = require('../middleware/errorHandler');
// Validate X-Scanner-Key header
const validateScannerKey = (req, res) => {
const key = req.headers['x-scanner-key'];
if (!key || key !== process.env.SCANNER_API_KEY) {
res.status(401).json({ status: 'error', message: 'Invalid scanner key' });
return false;
}
return true;
};
// POST /api/scanner/assets — called by nexus-scanner (no JWT)
const reportAssets = asyncHandler(async (req, res) => {
if (!validateScannerKey(req, res)) return;
const { site, scanner_version, reported_at, assets } = req.body;
if (!site || !Array.isArray(assets)) {
return res.status(400).json({ status: 'error', message: 'site and assets[] required' });
}
const db = getDatabase();
const now = new Date().toISOString();
// Upsert scanner site
db.prepare(`
INSERT INTO scanner_sites (site_id, last_seen, scanner_version, host_count)
VALUES (?, ?, ?, ?)
ON CONFLICT(site_id) DO UPDATE SET
last_seen = excluded.last_seen,
scanner_version = excluded.scanner_version,
host_count = excluded.host_count
`).run(site, now, scanner_version || '1.0', assets.length);
// Upsert each asset
const upsert = db.prepare(`
INSERT INTO scanner_assets (site_id, ip, mac, hostname, vendor, status, first_seen, last_seen, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(site_id, ip) DO UPDATE SET
mac = excluded.mac,
hostname = CASE WHEN excluded.hostname != '' THEN excluded.hostname ELSE hostname END,
vendor = CASE WHEN excluded.vendor != '' THEN excluded.vendor ELSE vendor END,
status = excluded.status,
last_seen = excluded.last_seen,
updated_at = excluded.updated_at
`);
const insertMany = db.transaction((items) => {
for (const a of items) {
upsert.run(
site,
a.ip,
a.mac || '',
a.hostname || '',
a.vendor || '',
a.status || 'online',
a.first_seen || now,
a.last_seen || now,
now
);
}
});
insertMany(assets);
res.json({ status: 'success', received: assets.length });
});
// POST /api/scanner/alert — called by nexus-scanner (no JWT)
const reportAlert = asyncHandler(async (req, res) => {
if (!validateScannerKey(req, res)) return;
const { site, check, type, target, status, latency, error, subject } = req.body;
if (!site || !check || !status) {
return res.status(400).json({ status: 'error', message: 'site, check, status required' });
}
const db = getDatabase();
db.prepare(`
INSERT INTO scanner_alerts (site_id, check_name, check_type, target, status, latency_ms, error_msg, subject)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(site, check, type || '', target || '', status, latency || 0, error || '', subject || '');
// Update check_count on site
db.prepare(`UPDATE scanner_sites SET check_count = check_count + 1 WHERE site_id = ?`).run(site);
res.json({ status: 'success' });
});
// GET /api/scanner/sites — JWT required
const getSites = asyncHandler(async (req, res) => {
const db = getDatabase();
const sites = db.prepare(`SELECT * FROM scanner_sites ORDER BY site_id`).all();
res.json({ status: 'success', data: sites });
});
// GET /api/scanner/assets — JWT required
const getAssets = asyncHandler(async (req, res) => {
const db = getDatabase();
const { site, status, q } = req.query;
let query = `SELECT * FROM scanner_assets WHERE 1=1`;
const params = [];
if (site) { query += ` AND site_id = ?`; params.push(site); }
if (status) { query += ` AND status = ?`; params.push(status); }
if (q) {
query += ` AND (ip LIKE ? OR hostname LIKE ? OR mac LIKE ? OR vendor LIKE ?)`;
const like = `%${q}%`;
params.push(like, like, like, like);
}
query += ` ORDER BY last_seen DESC LIMIT 500`;
const assets = db.prepare(query).all(...params);
res.json({ status: 'success', data: assets });
});
// GET /api/scanner/alerts — JWT required
const getAlerts = asyncHandler(async (req, res) => {
const db = getDatabase();
const { site, limit = 100 } = req.query;
let query = `SELECT * FROM scanner_alerts WHERE 1=1`;
const params = [];
if (site) { query += ` AND site_id = ?`; params.push(site); }
query += ` ORDER BY triggered_at DESC LIMIT ?`;
params.push(Number(limit));
const alerts = db.prepare(query).all(...params);
res.json({ status: 'success', data: alerts });
});
// GET /api/scanner/stats — JWT required, summary numbers
const getStats = asyncHandler(async (req, res) => {
const db = getDatabase();
const sites = db.prepare(`SELECT COUNT(*) as cnt FROM scanner_sites`).get().cnt;
const total = db.prepare(`SELECT COUNT(*) as cnt FROM scanner_assets`).get().cnt;
const online = db.prepare(`SELECT COUNT(*) as cnt FROM scanner_assets WHERE status='online'`).get().cnt;
const alerts24h = db.prepare(`
SELECT COUNT(*) as cnt FROM scanner_alerts
WHERE triggered_at >= datetime('now','-24 hours') AND status='offline'
`).get().cnt;
res.json({ status: 'success', data: { sites, total, online, offline: total - online, alerts24h } });
});
module.exports = { reportAssets, reportAlert, getSites, getAssets, getAlerts, getStats };

View File

@@ -0,0 +1,148 @@
const path = require('path');
const fs = require('fs');
const { getDatabase } = require('../config/database');
const multer = require('multer');
const pdfParse = require('pdf-parse');
const Anthropic = require('@anthropic-ai/sdk');
const UPLOAD_DIR = path.join(__dirname, '../../uploads/security-reports');
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`),
});
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/pdf') cb(null, true);
else cb(new Error('Nur PDF-Dateien erlaubt'));
},
});
const ANALYSIS_PROMPT = `Du analysierst einen wöchentlichen Microsoft 365 Security Report von Arctic Wolf für Cereda Systems GmbH.
Extrahiere alle relevanten Informationen und gib sie als JSON zurück. Antworte NUR mit validem JSON, kein Text darum herum.
JSON-Struktur:
{
"report_period": "DD.MM.YYYY - DD.MM.YYYY",
"risk_level": "niedrig|mittel|hoch",
"summary": "Kurze 2-3 Satz Zusammenfassung auf Deutsch",
"login_stats": {
"total": 0,
"successful": 0,
"failed": 0,
"success_rate": 0.0
},
"top_login_failures": [
{ "user": "email@cereda-systems.de", "count": 0, "type": "Interactive|Non-Interactive" }
],
"non_european_logins": [
{ "user": "email", "country": "Land", "ip": "IP", "count": 0, "flagged": true }
],
"sharepoint_anonymous_links": 0,
"identity_protection_events": 0,
"new_groups_created": [],
"key_findings": ["Befund 1", "Befund 2"],
"recommendations": ["Empfehlung 1", "Empfehlung 2"],
"all_clear": true
}
Wichtig:
- risk_level "hoch" wenn: nicht-europäische Logins von unbekannten IPs, viele Login-Fehler bei kritischen Accounts (server@, admin@)
- risk_level "mittel" wenn: einige Auffälligkeiten aber keine aktiven Bedrohungen
- risk_level "niedrig" wenn: alles normal
- flagged: true bei nicht-europäischen Logins die verdächtig aussehen
- all_clear: false wenn risk_level hoch oder mittel`;
const analyzeWithClaude = async (text) => {
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const response = await client.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 2000,
messages: [{
role: 'user',
content: `${ANALYSIS_PROMPT}\n\nReport-Inhalt:\n${text.slice(0, 15000)}`,
}],
});
const content = response.content[0].text.trim();
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error('Claude hat kein gültiges JSON zurückgegeben');
return JSON.parse(jsonMatch[0]);
};
// ── Upload + Analysieren ──────────────────────────────────────────────────────
const uploadReport = [
upload.single('pdf'),
async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Keine PDF-Datei' });
try {
const pdfBuffer = fs.readFileSync(req.file.path);
const pdfData = await pdfParse(pdfBuffer);
const text = pdfData.text;
if (text.length < 100) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'PDF konnte nicht gelesen werden oder ist leer' });
}
const analysis = await analyzeWithClaude(text);
const db = getDatabase();
const result = db.prepare(`
INSERT INTO security_reports
(filename, original_filename, report_period, risk_level, summary_json, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
req.file.filename,
req.file.originalname,
analysis.report_period || 'Unbekannt',
analysis.risk_level || 'unbekannt',
JSON.stringify(analysis),
req.user.id
);
const report = db.prepare('SELECT * FROM security_reports WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ ...report, analysis });
} catch (e) {
if (req.file?.path && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
console.error('[SecurityReport] Upload error:', e.message);
res.status(500).json({ error: `Analyse fehlgeschlagen: ${e.message}` });
}
}
];
// ── Alle Reports ──────────────────────────────────────────────────────────────
const getAllReports = (req, res) => {
const db = getDatabase();
const rows = db.prepare(`
SELECT sr.*, u.username as created_by_username
FROM security_reports sr
LEFT JOIN users u ON sr.created_by_user_id = u.id
ORDER BY sr.created_at DESC
`).all();
res.json(rows.map(r => ({ ...r, analysis: JSON.parse(r.summary_json || '{}') })));
};
// ── Einzelner Report ──────────────────────────────────────────────────────────
const getReport = (req, res) => {
const db = getDatabase();
const report = db.prepare('SELECT * FROM security_reports WHERE id = ?').get(req.params.id);
if (!report) return res.status(404).json({ error: 'Nicht gefunden' });
res.json({ ...report, analysis: JSON.parse(report.summary_json || '{}') });
};
// ── Report löschen ────────────────────────────────────────────────────────────
const deleteReport = (req, res) => {
const db = getDatabase();
const report = db.prepare('SELECT * FROM security_reports WHERE id = ?').get(req.params.id);
if (!report) return res.status(404).json({ error: 'Nicht gefunden' });
const filePath = path.join(UPLOAD_DIR, report.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
db.prepare('DELETE FROM security_reports WHERE id = ?').run(req.params.id);
res.json({ success: true });
};
module.exports = { uploadReport, getAllReports, getReport, deleteReport };

View File

@@ -0,0 +1,162 @@
const TicketSettings = require('../models/TicketSettings');
const { EmailTemplate } = require('../models/EmailTemplate');
const { EmailDesign } = require('../models/EmailDesign');
const { buildPreviewHtml } = require('../services/email.service');
// ── Categories ────────────────────────────────────────────────────────────────
exports.getCategories = (req, res) => {
try {
res.json(TicketSettings.getAllCategories());
} catch (e) {
res.status(500).json({ message: e.message });
}
};
exports.createCategory = (req, res) => {
const { name, icon } = req.body;
if (!name?.trim()) return res.status(400).json({ message: 'Name erforderlich' });
try {
const cat = TicketSettings.createCategory({ name, icon });
res.status(201).json(cat);
} catch (e) {
if (e.message?.includes('UNIQUE')) return res.status(409).json({ message: 'Kategorie existiert bereits' });
res.status(500).json({ message: e.message });
}
};
exports.updateCategory = (req, res) => {
try {
const cat = TicketSettings.updateCategory(req.params.id, req.body);
if (!cat) return res.status(404).json({ message: 'Nicht gefunden' });
res.json(cat);
} catch (e) {
if (e.message?.includes('UNIQUE')) return res.status(409).json({ message: 'Name bereits vergeben' });
res.status(500).json({ message: e.message });
}
};
exports.deleteCategory = (req, res) => {
try {
TicketSettings.deleteCategory(req.params.id);
res.json({ success: true });
} catch (e) {
res.status(500).json({ message: e.message });
}
};
exports.reorderCategories = (req, res) => {
const { ids } = req.body;
if (!Array.isArray(ids)) return res.status(400).json({ message: 'ids array erforderlich' });
try {
TicketSettings.reorderCategories(ids);
res.json({ success: true });
} catch (e) {
res.status(500).json({ message: e.message });
}
};
// ── Templates ─────────────────────────────────────────────────────────────────
exports.getTemplates = (req, res) => {
try {
res.json(TicketSettings.getAllTemplates());
} catch (e) {
res.status(500).json({ message: e.message });
}
};
exports.createTemplate = (req, res) => {
const { label, title, description, category, priority } = req.body;
if (!label?.trim()) return res.status(400).json({ message: 'Label erforderlich' });
try {
const tpl = TicketSettings.createTemplate({ label, title, description, category, priority });
res.status(201).json(tpl);
} catch (e) {
res.status(500).json({ message: e.message });
}
};
exports.updateTemplate = (req, res) => {
try {
const tpl = TicketSettings.updateTemplate(req.params.id, req.body);
if (!tpl) return res.status(404).json({ message: 'Nicht gefunden' });
res.json(tpl);
} catch (e) {
res.status(500).json({ message: e.message });
}
};
exports.deleteTemplate = (req, res) => {
try {
TicketSettings.deleteTemplate(req.params.id);
res.json({ success: true });
} catch (e) {
res.status(500).json({ message: e.message });
}
};
// ── Email Templates ────────────────────────────────────────────────────────────
exports.getEmailTemplates = (req, res) => {
try {
res.json(EmailTemplate.getAll());
} catch (e) {
res.status(500).json({ message: e.message });
}
};
exports.updateEmailTemplate = (req, res) => {
const { type } = req.params;
const { subject, intro } = req.body;
if (!subject?.trim() || !intro?.trim()) {
return res.status(400).json({ message: 'Betreff und Intro-Text sind erforderlich' });
}
try {
const tpl = EmailTemplate.update(type, { subject, intro });
if (!tpl) return res.status(404).json({ message: 'Vorlage nicht gefunden' });
res.json(tpl);
} catch (e) {
res.status(500).json({ message: e.message });
}
};
exports.resetEmailTemplate = (req, res) => {
try {
const tpl = EmailTemplate.reset(req.params.type);
if (!tpl) return res.status(404).json({ message: 'Vorlage nicht gefunden' });
res.json(tpl);
} catch (e) {
res.status(500).json({ message: e.message });
}
};
// ── Email Design ───────────────────────────────────────────────────────────────
exports.getEmailDesign = (req, res) => {
try { res.json(EmailDesign.getDesign()); }
catch (e) { res.status(500).json({ message: e.message }); }
};
exports.updateEmailDesign = (req, res) => {
try { res.json(EmailDesign.updateDesign(req.body)); }
catch (e) { res.status(500).json({ message: e.message }); }
};
exports.resetEmailDesign = (req, res) => {
try { res.json(EmailDesign.reset()); }
catch (e) { res.status(500).json({ message: e.message }); }
};
exports.previewEmail = (req, res) => {
try {
const { type } = req.params;
const design = req.body?.design || {};
const darkMode = !!req.body?.darkMode;
const html = buildPreviewHtml(type, design, darkMode);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
} catch (e) {
res.status(500).json({ message: e.message });
}
};

View File

@@ -0,0 +1,209 @@
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const { getDatabase } = require('../config/database');
const UPLOAD_DIR = path.join(__dirname, '../../uploads/shares');
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
// multer konfiguriert in routes
const multer = require('multer');
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, crypto.randomBytes(16).toString('hex') + ext);
},
});
const upload = multer({ storage, limits: { fileSize: 100 * 1024 * 1024 } }); // 100 MB
// ── Admin: Share erstellen ────────────────────────────────────────────────────
const createShare = [
upload.single('file'),
async (req, res) => {
try {
const { type, text_content, password, expires_in_hours, max_downloads } = req.body;
if (!type || !['file', 'text'].includes(type)) {
return res.status(400).json({ error: 'type muss "file" oder "text" sein' });
}
if (type === 'text' && !text_content?.trim()) {
return res.status(400).json({ error: 'text_content erforderlich' });
}
if (type === 'file' && !req.file) {
return res.status(400).json({ error: 'Datei erforderlich' });
}
const token = crypto.randomBytes(24).toString('base64url');
const password_hash = password ? await bcrypt.hash(password, 10) : null;
const expires_at = expires_in_hours
? new Date(Date.now() + parseInt(expires_in_hours) * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19)
: null;
const db = getDatabase();
const result = db.prepare(`
INSERT INTO shares (token, type, filename, stored_filename, text_content,
password_hash, expires_at, max_downloads, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
token, type,
req.file?.originalname || null,
req.file?.filename || null,
type === 'text' ? text_content.trim() : null,
password_hash,
expires_at,
max_downloads ? parseInt(max_downloads) : null,
req.user.id
);
const share = db.prepare('SELECT * FROM shares WHERE id = ?').get(result.lastInsertRowid);
const publicUrl = `${process.env.FRONTEND_URL || ''}/s/${token}`;
res.status(201).json({ ...share, public_url: publicUrl });
} catch (e) {
console.error('[Share] createShare error:', e.message);
res.status(500).json({ error: 'Interner Fehler' });
}
}
];
// ── Admin: Alle Shares ────────────────────────────────────────────────────────
const getAllShares = (req, res) => {
const db = getDatabase();
const rows = db.prepare(`
SELECT s.*, u.username as created_by_username
FROM shares s
LEFT JOIN users u ON s.created_by_user_id = u.id
WHERE s.deleted_at IS NULL
ORDER BY s.created_at DESC
`).all();
const base = process.env.FRONTEND_URL || '';
res.json(rows.map(r => ({ ...r, public_url: `${base}/s/${r.token}` })));
};
// ── Admin: Share löschen ──────────────────────────────────────────────────────
const deleteShare = (req, res) => {
const db = getDatabase();
const share = db.prepare('SELECT * FROM shares WHERE id = ? AND deleted_at IS NULL').get(req.params.id);
if (!share) return res.status(404).json({ error: 'Nicht gefunden' });
if (share.stored_filename) {
const filePath = path.join(UPLOAD_DIR, share.stored_filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
db.prepare("UPDATE shares SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id);
res.json({ success: true });
};
// ── Public: Share-Info (kein Auth) ───────────────────────────────────────────
const getPublicShare = (req, res) => {
const db = getDatabase();
const share = db.prepare(`
SELECT id, token, type, filename, text_content, expires_at, max_downloads,
download_count, created_at, password_hash IS NOT NULL as has_password
FROM shares
WHERE token = ? AND deleted_at IS NULL
`).get(req.params.token);
if (!share) return res.status(404).json({ error: 'Link nicht gefunden oder abgelaufen' });
if (share.expires_at && new Date(share.expires_at + 'Z') < new Date()) {
return res.status(410).json({ error: 'Dieser Link ist abgelaufen' });
}
if (share.max_downloads && share.download_count >= share.max_downloads) {
return res.status(410).json({ error: 'Maximale Anzahl Downloads erreicht' });
}
res.json({
id: share.id,
token: share.token,
type: share.type,
filename: share.filename,
expires_at: share.expires_at,
max_downloads: share.max_downloads,
download_count: share.download_count,
has_password: !!share.has_password,
// Text nur senden wenn kein Passwort gesetzt
text_content: share.has_password ? null : share.text_content,
});
};
// ── Public: Passwort prüfen + Text freischalten ───────────────────────────────
const accessShare = async (req, res) => {
const { password } = req.body;
const db = getDatabase();
const share = db.prepare(`
SELECT * FROM shares WHERE token = ? AND deleted_at IS NULL
`).get(req.params.token);
if (!share) return res.status(404).json({ error: 'Nicht gefunden' });
if (share.expires_at && new Date(share.expires_at + 'Z') < new Date()) {
return res.status(410).json({ error: 'Abgelaufen' });
}
if (share.password_hash) {
if (!password) return res.status(401).json({ error: 'Passwort erforderlich' });
const ok = await bcrypt.compare(password, share.password_hash);
if (!ok) return res.status(401).json({ error: 'Falsches Passwort' });
}
if (share.type === 'text') {
res.json({ text_content: share.text_content });
} else {
res.json({ ready: true });
}
};
// ── Public: Datei-Download ────────────────────────────────────────────────────
const downloadFile = async (req, res) => {
const { password } = req.query;
const db = getDatabase();
const share = db.prepare('SELECT * FROM shares WHERE token = ? AND deleted_at IS NULL').get(req.params.token);
if (!share || share.type !== 'file') return res.status(404).json({ error: 'Nicht gefunden' });
if (share.expires_at && new Date(share.expires_at + 'Z') < new Date()) {
return res.status(410).json({ error: 'Abgelaufen' });
}
if (share.max_downloads && share.download_count >= share.max_downloads) {
return res.status(410).json({ error: 'Download-Limit erreicht' });
}
if (share.password_hash) {
if (!password) return res.status(401).json({ error: 'Passwort erforderlich' });
const ok = await bcrypt.compare(password, share.password_hash);
if (!ok) return res.status(401).json({ error: 'Falsches Passwort' });
}
const filePath = path.join(UPLOAD_DIR, share.stored_filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht vorhanden' });
db.prepare('UPDATE shares SET download_count = download_count + 1 WHERE id = ?').run(share.id);
res.download(filePath, share.filename);
};
// ── Cleanup-Cron: abgelaufene/verbrauchte Shares löschen ────────────────────
const cleanupExpired = () => {
try {
const db = getDatabase();
const expired = db.prepare(`
SELECT * FROM shares
WHERE deleted_at IS NULL AND (
(expires_at IS NOT NULL AND expires_at < datetime('now'))
OR (max_downloads IS NOT NULL AND download_count >= max_downloads)
)
`).all();
for (const share of expired) {
if (share.stored_filename) {
const filePath = path.join(UPLOAD_DIR, share.stored_filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
db.prepare("UPDATE shares SET deleted_at = datetime('now') WHERE id = ?").run(share.id);
}
if (expired.length > 0) console.log(`[Shares] ${expired.length} abgelaufene Share(s) bereinigt`);
} catch (e) {
console.error('[Shares] Cleanup-Fehler:', e.message);
}
};
module.exports = { createShare, getAllShares, deleteShare, getPublicShare, accessShare, downloadFile, cleanupExpired };

View File

@@ -0,0 +1,33 @@
const { asyncHandler } = require('../middleware/errorHandler');
const { getDatabase } = require('../config/database');
class TeamsActivityController {
/**
* GET /api/teams-activity/new-channels
* Returns channels discovered since last notification (notified=0).
*/
static getNewChannels = asyncHandler(async (req, res) => {
const db = getDatabase();
const channels = db.prepare(`
SELECT id, team_name, channel_name, first_seen_at
FROM teams_channels
WHERE notified = 0
ORDER BY first_seen_at DESC
LIMIT 50
`).all();
res.json({ status: 'success', data: channels });
});
/**
* POST /api/teams-activity/mark-read
* Marks all unread channel notifications as read.
*/
static markRead = asyncHandler(async (req, res) => {
const db = getDatabase();
db.prepare('UPDATE teams_channels SET notified = 1 WHERE notified = 0').run();
res.json({ status: 'success' });
});
}
module.exports = TeamsActivityController;

View File

@@ -0,0 +1,781 @@
const { asyncHandler, AppError } = require('../middleware/errorHandler');
const Ticket = require('../models/Ticket');
const TicketComment = require('../models/TicketComment');
const AuditLog = require('../models/AuditLog');
const EmailService = require('../services/email.service');
const AiService = require('../services/ai.service');
const sseManager = require('../services/sseManager');
const { getDatabase } = require('../config/database');
const jwt = require('jsonwebtoken');
const JWT_CONFIG = require('../config/jwt');
const PDFDocument = require('pdfkit');
const { notifyByAadId } = require('./bot.controller');
class TicketController {
/**
* GET /api/tickets
*/
static getAll = asyncHandler(async (req, res) => {
const filters = {
status: req.query.status || null,
priority: req.query.priority || null,
category: req.query.category || null,
assigned_to: req.query.assigned_to || null,
search: req.query.search || null,
};
// Remove null filters
Object.keys(filters).forEach(k => filters[k] === null && delete filters[k]);
// Non-staff users only see tickets they created OR where they are the requester (by email)
const isStaff = ['super_admin', 'admin', 'support'].includes(req.user.role);
if (!isStaff) {
filters.user_scope = { userId: req.user.id, email: req.user.email };
}
const tickets = Ticket.getAll(filters);
res.json({ status: 'success', data: tickets });
});
/**
* GET /api/tickets/stats
*/
static getStats = asyncHandler(async (req, res) => {
const stats = Ticket.getStats();
res.json({ status: 'success', data: stats });
});
/**
* GET /api/tickets/:id
*/
static getById = asyncHandler(async (req, res) => {
const ticket = Ticket.getById(parseInt(req.params.id));
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
const comments = TicketComment.getByTicketId(ticket.id);
// Filter internal comments for non-support/admin users
const userRole = req.user.role;
const canSeeInternal = ['super_admin', 'admin', 'support'].includes(userRole);
const filteredComments = canSeeInternal
? comments
: comments.filter(c => !c.is_internal);
res.json({ status: 'success', data: { ...ticket, comments: filteredComments } });
});
/**
* POST /api/tickets
* All authenticated users can create tickets
*/
static create = asyncHandler(async (req, res) => {
const { title, description, category, priority, asset_id,
requester_name, requester_email } = req.body;
if (!title || !title.trim()) {
throw new AppError('Titel ist erforderlich', 400);
}
const ticket = Ticket.create({
title: title.trim(),
description: description?.trim() || null,
category: category || 'Allgemein',
priority: priority || 'mittel',
asset_id: asset_id ? parseInt(asset_id) : null,
source: 'web',
requester_name: requester_name?.trim() || `${req.user.first_name || ''} ${req.user.last_name || ''}`.trim() || req.user.username,
requester_email: requester_email?.trim() || req.user.email,
created_by_user_id: req.user.id,
});
// KI: Analyse + Auto-Erstantwort non-blocking
if (AiService.isConfigured()) {
AiService.analyzeTicket(title.trim(), description?.trim() || '')
.then(analysis => {
const updates = { ai_suggestion: analysis.suggestion };
if (!category) updates.category = analysis.category;
if (!priority) updates.priority = analysis.priority;
Ticket.update(ticket.id, updates);
console.log(`[AI] Ticket ${ticket.ticket_number} analysiert: ${analysis.category} / ${analysis.priority}`);
})
.catch(err => console.error('[AI] Ticket-Analyse fehlgeschlagen:', err.message));
// KI-Erstantwort: automatisch auf die Ticket-Beschreibung antworten
const refreshedTicket = Ticket.getById(ticket.id);
const firstMessage = description?.trim()
? description.trim()
: title.trim();
AiService.chatInTicket(refreshedTicket, [{ role: 'user', content: firstMessage }])
.then(aiText => {
const aiComment = TicketComment.create(ticket.id, null, aiText, false, true);
sseManager.broadcast(ticket.id, aiComment);
console.log(`[AI] Erstantwort für Ticket ${ticket.ticket_number} gespeichert`);
})
.catch(err => console.error('[AI] Erstantwort fehlgeschlagen:', err.message));
}
// Auto-Zuweisung basierend auf Kategorie
const routing = getDatabase().prepare('SELECT assigned_to_user_id FROM ticket_routing WHERE category = ?').get(ticket.category);
if (routing?.assigned_to_user_id) {
Ticket.update(ticket.id, { assigned_to_user_id: routing.assigned_to_user_id });
const assignedUser = getDatabase().prepare('SELECT * FROM users WHERE id = ?').get(routing.assigned_to_user_id);
if (assignedUser) {
const routed = Ticket.getById(ticket.id);
EmailService.sendTicketAssignedNotification(routed, assignedUser).catch(() => {});
console.log(`[Routing] Ticket ${ticket.ticket_number} auto-zugewiesen an ${assignedUser.username}`);
}
}
// Send confirmation email (non-blocking ticket creation succeeds even if email fails)
if (ticket.requester_email) {
EmailService.sendTicketCreatedConfirmation(ticket).catch(err =>
console.error('[Email] Confirmation failed:', err.message)
);
}
// Notify staff members with notif_ticket_created enabled
EmailService.sendStaffTicketCreatedNotification(ticket).catch(err =>
console.error('[Email] Staff ticket-created notification failed:', err.message)
);
res.status(201).json({ status: 'success', data: ticket });
});
/**
* PUT /api/tickets/:id
*/
static update = asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
const existing = Ticket.getById(id);
if (!existing) throw new AppError('Ticket nicht gefunden', 404);
const oldStatus = existing.status;
const updated = Ticket.update(id, req.body);
// AuditLog schreiben
const changedFields = {};
const trackFields = ['status', 'priority', 'category', 'assigned_to_user_id', 'asset_id'];
for (const f of trackFields) {
if (req.body[f] !== undefined && req.body[f] != existing[f]) {
changedFields[f] = { from: existing[f], to: req.body[f] };
}
}
if (Object.keys(changedFields).length > 0) {
AuditLog.create({
user_id: req.user.id,
action: 'UPDATE',
entity_type: 'ticket',
entity_id: id,
old_value: Object.fromEntries(Object.entries(changedFields).map(([k, v]) => [k, v.from])),
new_value: Object.fromEntries(Object.entries(changedFields).map(([k, v]) => [k, v.to])),
ip_address: req.ip,
});
}
// Email notifications on status or assignment change
const tasks = [];
if (req.body.status && req.body.status !== oldStatus) {
tasks.push(EmailService.sendStatusChangeNotification(updated, oldStatus, req.body.status));
// Zufriedenheits-Feedback wenn Ticket geschlossen wird
if (req.body.status === 'geschlossen' && updated.requester_email) {
EmailService.sendSatisfactionEmail(updated).catch(err =>
console.error('[Email] Satisfaction failed:', err.message)
);
}
// Auto KB-Artikel generieren wenn Ticket geschlossen wird
if (req.body.status === 'geschlossen') {
AiService.generateKbArticleFromTicket(updated.id).catch(err =>
console.error('[KB] Auto-generation failed:', err.message)
);
}
}
if (req.body.assigned_to_user_id && req.body.assigned_to_user_id !== existing.assigned_to_user_id) {
const db = getDatabase();
const assignedUser = db.prepare('SELECT * FROM users WHERE id = ?').get(req.body.assigned_to_user_id);
if (assignedUser) {
tasks.push(EmailService.sendTicketAssignedNotification(updated, assignedUser));
// Teams-Benachrichtigung wenn Ticket via Bot erstellt wurde
const aadMatch = updated.requester_email?.match(/^teams-(.+)@teams\.bot$/);
if (aadMatch) {
const agentName = assignedUser.first_name
? `${assignedUser.first_name} ${assignedUser.last_name}`.trim()
: assignedUser.username;
notifyByAadId(aadMatch[1],
`👤 **${agentName}** übernimmt dein Ticket **${updated.ticket_number}** und kümmert sich darum.\n\n_Du kannst hier direkt antworten deine Nachrichten gehen direkt an den Support._`
).catch(() => {});
}
}
}
await Promise.all(tasks);
res.json({ status: 'success', data: updated });
});
/**
* DELETE /api/tickets/:id (super_admin only)
*/
static delete = asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
const ticket = Ticket.getById(id);
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
Ticket.delete(id);
res.json({ status: 'success', message: 'Ticket gelöscht' });
});
/**
* POST /api/tickets/:id/comments
*/
static addComment = asyncHandler(async (req, res) => {
const ticketId = parseInt(req.params.id);
const ticket = Ticket.getById(ticketId);
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
const { comment, is_internal } = req.body;
if (!comment || !comment.trim()) {
throw new AppError('Kommentar darf nicht leer sein', 400);
}
const userRole = req.user.role;
const canSetInternal = ['super_admin', 'admin', 'support'].includes(userRole);
const internal = canSetInternal && !!is_internal;
const newComment = TicketComment.create(ticketId, req.user.id, comment.trim(), internal);
// Notify requester on public comments only when Support/Admin writes
const isSupportAuthor = ['super_admin', 'admin', 'support'].includes(userRole);
if (!internal && isSupportAuthor) {
await EmailService.sendCommentNotification(ticket, newComment);
// Teams-Benachrichtigung wenn Ticket via Bot erstellt wurde
const aadMatch = ticket.requester_email?.match(/^teams-(.+)@teams\.bot$/);
if (aadMatch) {
const authorName = req.user.first_name
? `${req.user.first_name} ${req.user.last_name}`.trim()
: req.user.username;
notifyByAadId(aadMatch[1],
`📨 **Antwort vom IT-Support**\n` +
`**${authorName}** · ${ticket.ticket_number}\n\n` +
`${comment.trim()}\n\n` +
`_Tippe deine Antwort um direkt zu antworten._`
).catch(() => {});
}
}
// Notify assigned staff when requester replies
if (!internal && !isSupportAuthor) {
await EmailService.sendStaffCommentNotification(ticket, newComment);
}
// Auto-Status: Kommentar setzt Status basierend auf Rolle
const updateData = {};
const activeStatuses = ['offen', 'in_bearbeitung', 'warten_auf_mitarbeiter', 'warten_auf_support'];
if (!internal && ticket.status !== 'geschlossen') {
if (isSupportAuthor) {
// Support schreibt → wartet auf Antwort des Mitarbeiters
updateData.status = 'warten_auf_mitarbeiter';
} else {
// Mitarbeiter schreibt → wartet auf Support
if (ticket.status === 'geschlossen') {
updateData.status = 'offen'; // Reopen
} else {
updateData.status = 'warten_auf_support';
}
}
} else if (!isSupportAuthor && ticket.status === 'geschlossen') {
// User antwortet auf geschlossenes Ticket → wieder öffnen
updateData.status = 'offen';
}
// Support übernimmt → KI deaktivieren (nur bei öffentlichem Kommentar von Staff)
if (!internal && isSupportAuthor && ticket.ai_active !== 0) {
updateData.ai_active = 0;
}
if (Object.keys(updateData).length > 0) Ticket.update(ticketId, updateData);
// Broadcast to all SSE clients watching this ticket
sseManager.broadcast(ticketId, newComment);
res.status(201).json({ status: 'success', data: newComment });
});
/**
* POST /api/tickets/:id/ai-reply
* Benutzer schickt Nachricht → KI antwortet, beide als Kommentare gespeichert
*/
static aiReply = asyncHandler(async (req, res) => {
if (!AiService.isConfigured()) {
throw new AppError('KI-Integration nicht konfiguriert', 503);
}
const ticketId = parseInt(req.params.id);
const ticket = Ticket.getById(ticketId);
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
const { message, history } = req.body;
if (!message?.trim()) throw new AppError('Nachricht darf nicht leer sein', 400);
if (ticket.ai_active === 0) throw new AppError('Support hat die Bearbeitung übernommen', 403);
// User-Nachricht als Kommentar speichern
const userComment = TicketComment.create(ticketId, req.user.id, message.trim(), false, false);
sseManager.broadcast(ticketId, userComment);
// Konversationshistorie für KI aufbauen (max. 10 letzte Nachrichten)
const aiMessages = [
...(Array.isArray(history) ? history.slice(-10) : []),
{ role: 'user', content: message.trim() },
];
// KI-Antwort generieren
const aiText = await AiService.chatInTicket(ticket, aiMessages);
// KI-Antwort als Kommentar speichern (is_ai_comment = true)
const aiComment = TicketComment.create(ticketId, null, aiText, false, true);
sseManager.broadcast(ticketId, aiComment);
Ticket.update(ticketId, {});
res.status(201).json({ status: 'success', data: { userComment, aiComment } });
});
/**
* GET /api/tickets/:id/events (SSE kein authenticateToken Middleware,
* da EventSource keine custom Headers unterstützt → Token via ?token=)
*/
static streamComments = (req, res) => {
// Verify token from query param
const token = req.query.token;
if (!token) return res.status(401).end();
let user;
try {
user = jwt.verify(token, JWT_CONFIG.secret);
} catch (_) {
return res.status(403).end();
}
const ticketId = parseInt(req.params.id);
if (!ticketId) return res.status(400).end();
const canSeeInternal = ['super_admin', 'admin', 'support'].includes(user.role);
// SSE Headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// Initial handshake
res.write('event: connected\ndata: {}\n\n');
// Heartbeat alle 25s damit nginx/Proxies die Verbindung offen lassen
const heartbeat = setInterval(() => {
try { res.write(':ping\n\n'); } catch (_) {}
}, 25000);
sseManager.subscribe(ticketId, res, canSeeInternal);
req.on('close', () => {
clearInterval(heartbeat);
sseManager.unsubscribe(ticketId, res);
});
};
/**
* DELETE /api/tickets/:id/comments/:commentId
*/
static deleteComment = asyncHandler(async (req, res) => {
const deleted = TicketComment.delete(parseInt(req.params.commentId));
if (!deleted) throw new AppError('Kommentar nicht gefunden', 404);
res.json({ status: 'success', message: 'Kommentar gelöscht' });
});
/**
* GET /api/tickets/:id/history
*/
static getHistory = asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
const ticket = Ticket.getById(id);
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
const history = AuditLog.getByEntity('ticket', id, 200, 0);
res.json({ status: 'success', data: history });
});
/**
* PUT /api/tickets/bulk
*/
static bulkUpdate = asyncHandler(async (req, res) => {
const { ids, data } = req.body;
if (!Array.isArray(ids) || ids.length === 0) throw new AppError('Keine IDs angegeben', 400);
if (!data || Object.keys(data).length === 0) throw new AppError('Keine Änderungen angegeben', 400);
const results = [];
for (const id of ids) {
try {
const existing = Ticket.getById(parseInt(id));
if (!existing) continue;
const updated = Ticket.update(parseInt(id), data);
// AuditLog
const changedFields = {};
const trackFields = ['status', 'priority', 'category', 'assigned_to_user_id'];
for (const f of trackFields) {
if (data[f] !== undefined && data[f] != existing[f]) {
changedFields[f] = { from: existing[f], to: data[f] };
}
}
if (Object.keys(changedFields).length > 0) {
AuditLog.create({
user_id: req.user.id,
action: 'BULK_UPDATE',
entity_type: 'ticket',
entity_id: parseInt(id),
old_value: Object.fromEntries(Object.entries(changedFields).map(([k, v]) => [k, v.from])),
new_value: Object.fromEntries(Object.entries(changedFields).map(([k, v]) => [k, v.to])),
ip_address: req.ip,
});
}
results.push(updated);
} catch (_) {}
}
res.json({ status: 'success', data: results, count: results.length });
});
/**
* GET /api/tickets/:id/pdf
*/
static exportPdf = asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
const ticket = Ticket.getById(id);
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
const comments = TicketComment.getByTicketId(id)
.filter(c => !c.is_internal);
const doc = new PDFDocument({ margin: 40, size: 'A4' });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="ticket-${ticket.ticket_number}.pdf"`);
doc.pipe(res);
// Header
doc.fontSize(18).font('Helvetica-Bold').text('IT Nexus Ticket-Export', { align: 'left' });
doc.moveDown(0.3);
doc.fontSize(11).font('Helvetica').fillColor('#666')
.text(`Exportiert am: ${new Date().toLocaleString('de-DE')}`, { align: 'left' });
doc.moveDown(0.8);
// Divider
doc.moveTo(40, doc.y).lineTo(555, doc.y).stroke('#ddd');
doc.moveDown(0.8);
// Ticket-Info
doc.fontSize(20).font('Helvetica-Bold').fillColor('#1a1a1a')
.text(`${ticket.ticket_number}: ${ticket.title}`);
doc.moveDown(0.5);
const statusMap = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', warten_auf_mitarbeiter: 'Warten auf Mitarbeiter', warten_auf_support: 'Warten auf Support', geschlossen: 'Geschlossen' };
const prioMap = { niedrig: 'Niedrig', mittel: 'Mittel', hoch: 'Hoch', kritisch: 'Kritisch' };
const meta = [
['Status', statusMap[ticket.status] || ticket.status],
['Priorität', prioMap[ticket.priority] || ticket.priority],
['Kategorie', ticket.category],
['Erstellt am', new Date(ticket.created_at).toLocaleString('de-DE')],
['Anfragender', `${ticket.requester_name || ''}${ticket.requester_email ? ` <${ticket.requester_email}>` : ''}`],
['Zugewiesen an', ticket.assigned_to_first_name ? `${ticket.assigned_to_first_name} ${ticket.assigned_to_last_name || ''}` : (ticket.assigned_to_username || '')],
];
if (ticket.asset_name) meta.push(['Asset', `${ticket.asset_name} (${ticket.asset_serial || ''})`]);
doc.fontSize(10).font('Helvetica');
for (const [label, value] of meta) {
doc.fillColor('#888').text(`${label}: `, { continued: true }).fillColor('#1a1a1a').text(value);
}
doc.moveDown(0.8);
// Beschreibung
if (ticket.description) {
doc.fontSize(12).font('Helvetica-Bold').fillColor('#1a1a1a').text('Beschreibung');
doc.moveDown(0.3);
doc.fontSize(10).font('Helvetica').fillColor('#333').text(ticket.description, { lineGap: 3 });
doc.moveDown(0.8);
}
// Kommentare
if (comments.length > 0) {
doc.moveTo(40, doc.y).lineTo(555, doc.y).stroke('#ddd');
doc.moveDown(0.5);
doc.fontSize(12).font('Helvetica-Bold').fillColor('#1a1a1a').text('Kommentare');
doc.moveDown(0.5);
for (const c of comments) {
const author = c.first_name ? `${c.first_name} ${c.last_name || ''}`.trim() : (c.username || 'Unbekannt');
doc.fontSize(9).font('Helvetica-Bold').fillColor('#555')
.text(`${author}${new Date(c.created_at).toLocaleString('de-DE')}`);
doc.fontSize(10).font('Helvetica').fillColor('#1a1a1a')
.text(c.comment, { lineGap: 2 });
doc.moveDown(0.6);
}
}
// Footer
doc.fontSize(8).fillColor('#aaa').text(
`IT Nexus · Ticket ${ticket.ticket_number} · ${new Date().toLocaleDateString('de-DE')}`,
{ align: 'center' }
);
doc.end();
});
/**
* GET /api/tickets/metrics
*/
static getMetrics = asyncHandler(async (req, res) => {
const days = parseInt(req.query.days) || 30;
const data = Ticket.getMetrics(days);
res.json({ status: 'success', data });
});
/**
* GET /api/tickets/:id/links
*/
static getLinks = asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
const ticket = Ticket.getById(id);
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
const links = Ticket.getLinks(id);
res.json({ status: 'success', data: links });
});
/**
* POST /api/tickets/:id/links
*/
static addLink = asyncHandler(async (req, res) => {
const ticketId = parseInt(req.params.id);
const { linked_ticket_number, link_type } = req.body;
if (!linked_ticket_number) throw new AppError('Ticket-Nummer erforderlich', 400);
const ticket = Ticket.getById(ticketId);
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
// Find linked ticket by number
const db = getDatabase();
const linked = db.prepare('SELECT id FROM tickets WHERE ticket_number = ?').get(linked_ticket_number.trim());
if (!linked) throw new AppError(`Ticket ${linked_ticket_number} nicht gefunden`, 404);
if (linked.id === ticketId) throw new AppError('Ein Ticket kann nicht mit sich selbst verknüpft werden', 400);
Ticket.addLink(ticketId, linked.id, link_type || 'related', req.user.id);
const links = Ticket.getLinks(ticketId);
res.status(201).json({ status: 'success', data: links });
});
/**
* DELETE /api/tickets/:id/links/:linkId
*/
static removeLink = asyncHandler(async (req, res) => {
const deleted = Ticket.removeLink(parseInt(req.params.linkId));
if (!deleted) throw new AppError('Verknüpfung nicht gefunden', 404);
res.json({ status: 'success', message: 'Verknüpfung entfernt' });
});
/**
* PUT /api/tickets/:id/snooze
*/
static snooze = asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
const ticket = Ticket.getById(id);
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
const { snoozed_until } = req.body;
const updated = Ticket.update(id, { snoozed_until: snoozed_until || null });
res.json({ status: 'success', data: updated });
});
/** POST /api/tickets/:id/assignees */
static addAssignee = asyncHandler(async (req, res) => {
const ticketId = parseInt(req.params.id);
const ticket = Ticket.getById(ticketId);
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
const { user_id } = req.body;
if (!user_id) throw new AppError('user_id fehlt', 400);
Ticket.addAssignee(ticketId, parseInt(user_id), req.user.id);
res.json({ status: 'success', data: Ticket.getAssignees(ticketId) });
});
/** DELETE /api/tickets/:id/assignees/:userId */
static removeAssignee = asyncHandler(async (req, res) => {
const ticketId = parseInt(req.params.id);
Ticket.removeAssignee(ticketId, parseInt(req.params.userId));
res.json({ status: 'success', data: Ticket.getAssignees(ticketId) });
});
/**
* GET /api/tickets/:id/feedback?rating=gut|schlecht
* Public no auth required (link from satisfaction email)
*/
static feedback = asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
const { rating } = req.query;
if (!['gut', 'schlecht'].includes(rating)) {
return res.status(400).send('<h2>Ungültige Bewertung</h2>');
}
const ticket = Ticket.getById(id);
if (!ticket) return res.status(404).send('<h2>Ticket nicht gefunden</h2>');
const emoji = rating === 'gut' ? '👍' : '👎';
const color = rating === 'gut' ? '#16a34a' : '#dc2626';
const bg = rating === 'gut' ? '#dcfce7' : '#fee2e2';
const border= rating === 'gut' ? '#86efac' : '#fca5a5';
res.send(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Feedback IT Nexus</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Inter,system-ui,sans-serif;background:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08);padding:40px 36px;max-width:480px;width:100%;text-align:center}
.emoji{font-size:3.5rem;margin-bottom:12px}
h1{font-size:1.4rem;font-weight:800;color:#111827;margin-bottom:8px}
.sub{color:#6b7280;font-size:0.9rem;margin-bottom:28px}
.rating-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 16px;border-radius:20px;font-size:0.85rem;font-weight:700;background:${bg};color:${color};border:1px solid ${border};margin-bottom:24px}
textarea{width:100%;border:1px solid #e2e8f0;border-radius:10px;padding:12px 14px;font-size:0.9rem;font-family:inherit;resize:vertical;min-height:110px;color:#374151;transition:border-color .2s}
textarea:focus{outline:none;border-color:${color}}
label{display:block;text-align:left;font-size:0.8rem;font-weight:600;color:#374151;margin-bottom:6px}
.hint{text-align:left;font-size:0.75rem;color:#9ca3af;margin-top:6px;margin-bottom:20px}
button{width:100%;padding:12px;border:none;border-radius:10px;background:${color};color:#fff;font-size:1rem;font-weight:700;cursor:pointer;transition:opacity .15s}
button:hover{opacity:0.88}
.ticket-nr{font-family:monospace;font-size:0.75rem;color:#9ca3af;margin-top:20px}
</style>
</head>
<body>
<div class="card">
<div class="emoji">${emoji}</div>
<h1>Wie war Ihr Support-Erlebnis?</h1>
<p class="sub">Ticket ${ticket.ticket_number} ${ticket.title.substring(0, 60)}${ticket.title.length > 60 ? '…' : ''}</p>
<div class="rating-badge">${emoji} ${rating === 'gut' ? 'Positiv' : 'Negativ'}</div>
<form method="POST" action="/api/tickets/${id}/feedback">
<input type="hidden" name="rating" value="${rating}" />
<label for="comment">Möchten Sie noch etwas hinzufügen? <span style="font-weight:400;color:#9ca3af">(optional)</span></label>
<textarea id="comment" name="comment" placeholder="z. B. Was hat gut funktioniert, was könnte besser sein …"></textarea>
<div class="hint">Ihr Kommentar hilft uns, den IT-Support kontinuierlich zu verbessern.</div>
<button type="submit">Feedback absenden</button>
</form>
<div class="ticket-nr">IT Nexus · ${ticket.ticket_number}</div>
</div>
</body></html>`);
});
/**
* POST /api/tickets/:id/feedback
* Public speichert Rating + optionalen Kommentar
*/
static submitFeedback = asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
const { rating, comment } = req.body;
if (!['gut', 'schlecht'].includes(rating)) {
return res.status(400).send('<h2>Ungültige Bewertung</h2>');
}
const ticket = Ticket.getById(id);
if (!ticket) return res.status(404).send('<h2>Ticket nicht gefunden</h2>');
Ticket.update(id, {
satisfaction_rating: rating,
satisfaction_comment: comment?.trim() || null,
});
const emoji = rating === 'gut' ? '👍' : '👎';
const color = rating === 'gut' ? '#16a34a' : '#dc2626';
res.send(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Danke IT Nexus</title>
<style>
body{font-family:Inter,system-ui,sans-serif;background:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08);padding:48px 36px;max-width:420px;width:100%;text-align:center}
.emoji{font-size:4rem;margin-bottom:16px}
h1{font-size:1.5rem;font-weight:800;color:#111827;margin-bottom:10px}
p{color:#6b7280;font-size:0.9rem;line-height:1.6}
.nr{font-family:monospace;font-size:0.75rem;color:#9ca3af;margin-top:24px}
</style>
</head>
<body>
<div class="card">
<div class="emoji">${emoji}</div>
<h1 style="color:${color}">Vielen Dank!</h1>
<p>Ihr Feedback wurde erfolgreich gespeichert.<br>Sie können dieses Fenster schließen.</p>
<div class="nr">IT Nexus · ${ticket.ticket_number}</div>
</div>
</body></html>`);
});
/**
* POST /api/tickets/public
* Public no auth required (from QR-code defect page)
*/
static createPublic = asyncHandler(async (req, res) => {
const { title, description, category, priority, requester_name, requester_email, serial_number } = req.body;
if (!title?.trim()) throw new AppError('Titel ist erforderlich', 400);
let asset_id = null;
if (serial_number) {
const asset = getDatabase().prepare('SELECT id FROM assets WHERE serial_number = ?').get(serial_number);
if (asset) asset_id = asset.id;
}
const ticket = Ticket.create({
title: title.trim(),
description: description?.trim() || null,
category: category || 'Hardware',
priority: priority || 'mittel',
asset_id,
source: 'web',
requester_name: requester_name?.trim() || 'Anonym',
requester_email: requester_email?.trim() || null,
created_by_user_id: null,
});
if (ticket.requester_email) {
EmailService.sendTicketCreatedConfirmation(ticket).catch(() => {});
}
res.status(201).json({ status: 'success', data: { ticket_number: ticket.ticket_number } });
});
/**
* GET /api/tickets/routing
*/
static getRouting = asyncHandler(async (req, res) => {
const routing = getDatabase().prepare(`
SELECT r.id, r.category, r.assigned_to_user_id,
u.username, u.first_name, u.last_name
FROM ticket_routing r
LEFT JOIN users u ON u.id = r.assigned_to_user_id
`).all();
res.json({ status: 'success', data: routing });
});
/**
* POST /api/tickets/routing
* Body: { category, assigned_to_user_id }
*/
static saveRouting = asyncHandler(async (req, res) => {
const { category, assigned_to_user_id } = req.body;
if (!category) throw new AppError('Kategorie fehlt', 400);
const db = getDatabase();
db.prepare(`
INSERT INTO ticket_routing (category, assigned_to_user_id)
VALUES (?, ?)
ON CONFLICT(category) DO UPDATE SET assigned_to_user_id = excluded.assigned_to_user_id
`).run(category, assigned_to_user_id || null);
const routing = db.prepare(`
SELECT r.id, r.category, r.assigned_to_user_id,
u.username, u.first_name, u.last_name
FROM ticket_routing r
LEFT JOIN users u ON u.id = r.assigned_to_user_id
`).all();
res.json({ status: 'success', data: routing });
});
}
module.exports = TicketController;

View File

@@ -0,0 +1,76 @@
const { getDatabase } = require('../config/database');
async function getStats(req, res) {
try {
const db = getDatabase();
const now = Date.now();
const fifteenMin = 15 * 60 * 1000;
// Agents
const agents = db.prepare('SELECT hostname, last_checkin, cpu_usage_percent, ram_total_gb, ram_used_gb, bitlocker_status FROM monitoring_agents').all();
const agentList = agents.map(a => {
const ms = a.last_checkin ? new Date(a.last_checkin + (a.last_checkin.includes('Z') ? '' : 'Z')).getTime() : 0;
const ram_usage_percent = a.ram_total_gb > 0 ? Math.round((a.ram_used_gb / a.ram_total_gb) * 100) : 0;
return { ...a, ram_usage_percent, online: ms && (now - ms) < fifteenMin };
});
const online = agentList.filter(a => a.online).length;
// Tickets
const ticketStats = db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as open,
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as inProgress,
SUM(CASE WHEN date(created_at) = date('now') AND status IN ('closed','resolved') THEN 1 ELSE 0 END) as closedToday,
SUM(CASE WHEN priority = 'critical' AND status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as critical,
SUM(CASE WHEN priority = 'high' AND status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as high,
SUM(CASE WHEN priority = 'medium' AND status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as medium,
SUM(CASE WHEN priority = 'low' AND status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as low
FROM tickets
`).get();
// Patch overview
const patchStats = db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN windows_updates_pending = 0 THEN 1 ELSE 0 END) as compliant,
SUM(CASE WHEN windows_updates_pending > 0 THEN 1 ELSE 0 END) as warnings,
SUM(COALESCE(windows_updates_pending, 0)) as total_pending_updates
FROM monitoring_agents
`).get();
// Last security report
const lastSecReport = db.prepare('SELECT * FROM security_reports ORDER BY created_at DESC LIMIT 1').get();
// Ticket trend last 7 days
const perDay7 = db.prepare(`
SELECT date(created_at) as day, COUNT(*) as count
FROM tickets
WHERE created_at >= date('now', '-7 days')
GROUP BY date(created_at)
ORDER BY day ASC
`).all();
res.json({
status: 'success',
data: {
agentList: agentList.map(a => ({
hostname: a.hostname,
last_checkin: a.last_checkin,
cpu_usage_percent: a.cpu_usage_percent,
ram_usage_percent: a.ram_usage_percent,
bitlocker_status: a.bitlocker_status,
})),
monitoring: { total: agents.length, online, offline: agents.length - online },
tickets: ticketStats,
patch: patchStats,
lastSecReport: lastSecReport || null,
ticketMetrics: { perDay7: perDay7.map(r => r.count) },
},
});
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
}
module.exports = { getStats };

View File

@@ -0,0 +1,56 @@
const unifi = require('../services/unifiService');
const getConfig = (req, res) => {
try {
const cfg = unifi.getConfig();
// Don't send password in plaintext
res.json({ status: 'success', data: { ...cfg, password: cfg?.password ? '***' : '' } });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
const saveConfig = async (req, res) => {
try {
const { controller_url, username, password, site, poll_interval_min, enabled } = req.body;
// If password is '***', keep existing
const existing = unifi.getConfig();
const pwd = (password && password !== '***') ? password : (existing?.password || '');
const cfg = unifi.saveConfig({
controller_url: controller_url?.trim() || '',
username: username?.trim() || '',
password: pwd,
site: site?.trim() || 'default',
poll_interval_min: parseInt(poll_interval_min) || 5,
enabled: enabled ? 1 : 0,
});
unifi.restartUnifiPoller();
res.json({ status: 'success', data: { ...cfg, password: cfg.password ? '***' : '' } });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
const getDevices = (req, res) => {
try {
const devices = unifi.getAllDevices().map(d => ({
...d,
ports: JSON.parse(d.ports_json || '[]'),
radios: JSON.parse(d.radio_json || '[]'),
}));
res.json({ status: 'success', data: devices });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
const sync = async (req, res) => {
try {
const result = await unifi.syncUnifi();
res.json({ status: 'success', data: result });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
module.exports = { getConfig, saveConfig, getDevices, sync };

View File

@@ -0,0 +1,420 @@
const UserService = require('../services/user.service');
const Role = require('../models/Role');
const AuditLog = require('../models/AuditLog');
const User = require('../models/User');
const { asyncHandler } = require('../middleware/errorHandler');
const { getAzureGroups, getAzureGroupMembers, invalidateUserSessions } = require('../services/graph.service');
const Database = require('better-sqlite3');
const path = require('path');
const DB_PATH = process.env.DATABASE_PATH || './database.sqlite';
class UserController {
/**
* Get all users
* GET /api/users
*/
static getAllUsers = asyncHandler(async (req, res) => {
const users = UserService.getAllUsers();
res.json({
status: 'success',
data: users
});
});
/**
* Get user by ID
* GET /api/users/:id
*/
static getUserById = asyncHandler(async (req, res) => {
const { id } = req.params;
const user = UserService.getUserById(parseInt(id));
res.json({
status: 'success',
data: user
});
});
/**
* Create new user
* POST /api/users
*/
static createUser = asyncHandler(async (req, res) => {
const user = await UserService.createUser(
req.body,
req.user.id,
req.ip
);
res.status(201).json({
status: 'success',
data: user
});
});
/**
* Update user
* PUT /api/users/:id
*/
static updateUser = asyncHandler(async (req, res) => {
const { id } = req.params;
const user = await UserService.updateUser(
parseInt(id),
req.body,
req.user.id,
req.ip
);
res.json({
status: 'success',
data: user
});
});
/**
* Delete user
* DELETE /api/users/:id
*/
static deleteUser = asyncHandler(async (req, res) => {
const { id } = req.params;
const deleted = UserService.deleteUser(
parseInt(id),
req.user.id,
req.ip
);
if (!deleted) {
return res.status(404).json({
status: 'error',
message: 'User not found'
});
}
res.json({
status: 'success',
message: 'User deleted successfully'
});
});
/**
* Assign role to user
* PUT /api/users/:id/role
*/
static assignRole = asyncHandler(async (req, res) => {
const { id } = req.params;
const { role_id } = req.body;
if (!role_id) {
return res.status(400).json({
status: 'error',
message: 'role_id is required'
});
}
const user = UserService.assignRole(
parseInt(id),
role_id,
req.user.id,
req.ip
);
res.json({
status: 'success',
data: user
});
});
/**
* Activate/Deactivate user
* PUT /api/users/:id/activate
*/
static toggleUserStatus = asyncHandler(async (req, res) => {
const { id } = req.params;
const { is_active } = req.body;
if (is_active === undefined) {
return res.status(400).json({
status: 'error',
message: 'is_active is required'
});
}
const user = UserService.toggleUserStatus(
parseInt(id),
is_active,
req.user.id,
req.ip
);
res.json({
status: 'success',
data: user
});
});
/**
* Get all roles
* GET /api/roles
*/
static getAllRoles = asyncHandler(async (req, res) => {
const roles = Role.getAll();
res.json({
status: 'success',
data: roles
});
});
/**
* Get audit logs
* GET /api/audit-logs
*/
static getAuditLogs = asyncHandler(async (req, res) => {
const limit = parseInt(req.query.limit) || 100;
const offset = parseInt(req.query.offset) || 0;
const logs = AuditLog.getAll(limit, offset);
res.json({
status: 'success',
data: logs
});
});
/**
* Get audit logs for specific user
* GET /api/audit-logs/user/:userId
*/
static getAuditLogsByUser = asyncHandler(async (req, res) => {
const { userId } = req.params;
const limit = parseInt(req.query.limit) || 100;
const offset = parseInt(req.query.offset) || 0;
const logs = AuditLog.getByUserId(parseInt(userId), limit, offset);
res.json({
status: 'success',
data: logs
});
});
/**
* Get audit logs for specific entity
* GET /api/audit-logs/entity/:type/:id
*/
static getAuditLogsByEntity = asyncHandler(async (req, res) => {
const { type, id } = req.params;
const limit = parseInt(req.query.limit) || 100;
const offset = parseInt(req.query.offset) || 0;
const logs = AuditLog.getByEntity(type, parseInt(id), limit, offset);
res.json({
status: 'success',
data: logs
});
});
/**
* List available Azure AD groups for import selection
* GET /api/users/import/azure/groups
*/
static getAzureGroupsList = asyncHandler(async (req, res) => {
let groups;
try {
groups = await getAzureGroups();
} catch (err) {
return res.status(502).json({ status: 'error', message: err.message });
}
res.json({ status: 'success', data: groups });
});
/**
* Import users from an Azure AD group
* POST /api/users/import/azure
* Body: { group_id, role_id }
*/
static importFromAzure = asyncHandler(async (req, res) => {
const { group_id, role_id } = req.body;
if (!group_id) {
return res.status(400).json({ status: 'error', message: 'group_id ist erforderlich' });
}
if (!role_id) {
return res.status(400).json({ status: 'error', message: 'role_id ist erforderlich' });
}
let members;
try {
members = await getAzureGroupMembers(group_id);
} catch (err) {
return res.status(502).json({ status: 'error', message: err.message });
}
const results = { imported: 0, skipped: 0, errors: [] };
for (const member of members) {
try {
const email = (member.mail || member.userPrincipalName || '').toLowerCase().trim();
if (!email) { results.skipped++; continue; }
// Skip if already exists (by azure_id or email)
const byAzureId = member.id ? User.getByAzureId(member.id) : null;
if (byAzureId) { results.skipped++; continue; }
const byEmail = User.getByEmail(email);
if (byEmail) {
// Link azure_id if not yet set
if (!byEmail.azure_id && member.id) {
User.update(byEmail.id, { azure_id: member.id });
}
results.skipped++;
continue;
}
// Derive username from email prefix, ensure uniqueness by appending number if needed
let baseUsername = email.split('@')[0].replace(/[^a-z0-9._-]/gi, '').toLowerCase();
let username = baseUsername;
let suffix = 1;
while (User.getByUsername(username)) {
username = `${baseUsername}${suffix++}`;
}
const newUser = User.create({
username,
email,
password_hash: 'AZURE_SSO_NO_PASSWORD',
role_id: parseInt(role_id),
first_name: member.givenName || null,
last_name: member.surname || null,
is_active: true,
must_change_password: false,
});
if (member.id) {
User.update(newUser.id, { azure_id: member.id });
}
results.imported++;
} catch (err) {
results.errors.push({ user: member.mail || member.userPrincipalName, error: err.message });
}
}
res.json({ status: 'success', data: results });
});
/**
* Get full user profile: user + asset count + fido count + lifecycle status
* GET /api/users/:id/full
*/
static getUserFull = asyncHandler(async (req, res) => {
const { id } = req.params;
const db = new Database(DB_PATH);
const user = db.prepare(`
SELECT u.*, r.name as role_name,
m.username as manager_username,
m.first_name as manager_first_name,
m.last_name as manager_last_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
LEFT JOIN users m ON u.manager_id = m.id
WHERE u.id = ?
`).get(parseInt(id));
if (!user) return res.status(404).json({ status: 'error', message: 'User not found' });
const assetCount = db.prepare(`
SELECT COUNT(*) as cnt FROM assets
WHERE assigned_to_user_id = ? OR assigned_to_username = ?
`).get(parseInt(id), user.username);
const assetStats = db.prepare(`
SELECT
COUNT(*) as total,
COALESCE(SUM(purchase_price), 0) as total_value,
MIN(purchase_date) as oldest_date
FROM assets
WHERE assigned_to_user_id = ? OR assigned_to_username = ?
`).get(parseInt(id), user.username);
const fidoCount = db.prepare(
`SELECT COUNT(*) as cnt FROM fido_keys WHERE assigned_to_user_id = ? AND status = 'aktiv'`
).get(parseInt(id));
const onboarding = db.prepare(
`SELECT * FROM onboarding_protocols WHERE employee_user_id = ? ORDER BY created_at DESC LIMIT 1`
).get(parseInt(id));
const offboarding = db.prepare(
`SELECT * FROM offboarding_protocols WHERE employee_user_id = ? ORDER BY created_at DESC LIMIT 1`
).get(parseInt(id));
let lifecycleStage = 3; // Aktiv (default for existing staff)
if (offboarding) lifecycleStage = 5;
else if (onboarding?.status === 'in_progress') lifecycleStage = 2;
else if (onboarding?.status === 'pending') lifecycleStage = 1;
delete user.password_hash;
res.json({
status: 'success',
data: {
...user,
asset_count: assetCount?.cnt || 0,
asset_total_value: assetStats?.total_value || 0,
asset_oldest_date: assetStats?.oldest_date || null,
fido_count: fidoCount?.cnt || 0,
lifecycle_stage: lifecycleStage,
onboarding_protocol: onboarding || null,
offboarding_protocol: offboarding || null,
}
});
});
/**
* Invalidate Entra sessions (password reset via Entra SSPR)
* POST /api/users/:id/reset-entra
*/
static resetPasswordEntra = asyncHandler(async (req, res) => {
const { id } = req.params;
const user = UserService.getUserById(parseInt(id));
if (!user?.azure_id) {
return res.status(400).json({
status: 'error',
message: 'Kein Azure-Konto verknüpft. Passwort-Reset nur für Entra-Benutzer möglich.'
});
}
try {
await invalidateUserSessions(user.azure_id);
res.json({ status: 'success', message: 'Entra-Sessions invalidiert. Benutzer wird beim nächsten Login zu Passwort-Reset aufgefordert.' });
} catch (err) {
res.status(502).json({ status: 'error', message: err.message });
}
});
/**
* Export all users as CSV
* GET /api/users/export/csv
*/
static exportCsv = asyncHandler(async (req, res) => {
const users = UserService.getAllUsers();
const header = 'ID,Username,Email,Vorname,Nachname,Rolle,Abteilung,Position,Telefon,Standort,Eintrittsdatum,Aktiv';
const rows = users.map(u => [
u.id, u.username, u.email, u.first_name || '', u.last_name || '',
u.role_name || '', u.department || '', u.position || '',
u.phone || '', u.location || '', u.joined_date || '', u.is_active ? 'Ja' : 'Nein'
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(','));
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="benutzer.csv"');
res.send('' + header + '\n' + rows.join('\n'));
});
}
module.exports = UserController;

View File

@@ -0,0 +1,182 @@
const WarehouseLocation = require('../models/WarehouseLocation');
const AssetMovement = require('../models/AssetMovement');
const StockThreshold = require('../models/StockThreshold');
const PurchaseOrder = require('../models/PurchaseOrder');
const Asset = require('../models/Asset');
const { getDatabase } = require('../config/database');
// ─── Locations ────────────────────────────────────────────────────────────────
exports.getLocations = (req, res) => {
res.json(WarehouseLocation.getAll());
};
exports.createLocation = (req, res) => {
const { name, description } = req.body;
if (!name) return res.status(400).json({ error: 'Name ist erforderlich' });
try {
const loc = WarehouseLocation.create({ name, description });
res.status(201).json(loc);
} catch (e) {
if (e.message?.includes('UNIQUE')) return res.status(409).json({ error: 'Lagerort existiert bereits' });
res.status(500).json({ error: e.message });
}
};
exports.updateLocation = (req, res) => {
const loc = WarehouseLocation.update(parseInt(req.params.id), req.body);
if (!loc) return res.status(404).json({ error: 'Nicht gefunden' });
res.json(loc);
};
exports.deleteLocation = (req, res) => {
const ok = WarehouseLocation.delete(parseInt(req.params.id));
if (!ok) return res.status(404).json({ error: 'Nicht gefunden' });
res.json({ success: true });
};
// ─── Movements ────────────────────────────────────────────────────────────────
exports.getMovements = (req, res) => {
const asset_id = req.query.asset_id ? parseInt(req.query.asset_id) : undefined;
res.json(AssetMovement.getAll({ asset_id, limit: 500 }));
};
exports.createMovement = (req, res) => {
const { asset_id, type, to_location_id, assigned_user_id, ticket_id, reason, notes, new_status } = req.body;
if (!asset_id || !type) return res.status(400).json({ error: 'asset_id und type sind erforderlich' });
const asset = Asset.getById(parseInt(asset_id));
if (!asset) return res.status(404).json({ error: 'Asset nicht gefunden' });
const db = getDatabase();
const updateAsset = db.transaction(() => {
// Create movement record
const movement = AssetMovement.create({
asset_id,
type,
from_location_id: asset.location_id,
to_location_id: to_location_id || null,
assigned_user_id: assigned_user_id || null,
ticket_id: ticket_id || null,
reason: reason || '',
notes: notes || '',
performed_by: req.user.id,
});
// Update asset location + status if needed
const updates = {};
if (to_location_id) updates.location_id = to_location_id;
if (new_status) updates.status = new_status;
if (assigned_user_id) {
updates.assigned_to_user_id = assigned_user_id;
updates.status = 'zugewiesen';
}
if (type === 'in') {
updates.status = 'verfuegbar';
updates.assigned_to_user_id = null;
}
if (Object.keys(updates).length > 0) {
Asset.update(asset_id, updates, req.user.id);
}
return movement;
});
try {
const movement = updateAsset();
res.status(201).json(movement);
} catch (e) {
res.status(500).json({ error: e.message });
}
};
// ─── Stock Thresholds ─────────────────────────────────────────────────────────
exports.getThresholds = (req, res) => {
res.json(StockThreshold.getAll());
};
exports.upsertThreshold = (req, res) => {
const { category, min_stock, notify_email } = req.body;
if (!category) return res.status(400).json({ error: 'Kategorie ist erforderlich' });
const threshold = StockThreshold.upsert({ category, min_stock, notify_email });
res.json(threshold);
};
exports.deleteThreshold = (req, res) => {
const ok = StockThreshold.delete(req.params.category);
if (!ok) return res.status(404).json({ error: 'Nicht gefunden' });
res.json({ success: true });
};
exports.getStockViolations = (req, res) => {
res.json(StockThreshold.getViolations());
};
// ─── Purchase Orders ──────────────────────────────────────────────────────────
exports.getPurchaseOrders = (req, res) => {
const { status } = req.query;
res.json(PurchaseOrder.getAll({ status }));
};
exports.createPurchaseOrder = (req, res) => {
const { category, item_name, quantity, notes } = req.body;
if (!category || !item_name) return res.status(400).json({ error: 'Kategorie und Artikelname sind erforderlich' });
const order = PurchaseOrder.create({ category, item_name, quantity, notes, created_by: req.user.id });
res.status(201).json(order);
};
exports.updatePurchaseOrder = (req, res) => {
const order = PurchaseOrder.update(parseInt(req.params.id), req.body);
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
res.json(order);
};
exports.deletePurchaseOrder = (req, res) => {
const ok = PurchaseOrder.delete(parseInt(req.params.id));
if (!ok) return res.status(404).json({ error: 'Nicht gefunden' });
res.json({ success: true });
};
// ─── Dashboard Summary ────────────────────────────────────────────────────────
exports.getSummary = (req, res) => {
const db = getDatabase();
const stockByLocation = db.prepare(`
SELECT wl.name AS location, COUNT(a.id) AS count
FROM warehouse_locations wl
LEFT JOIN assets a ON a.location_id = wl.id AND a.status = 'verfuegbar'
GROUP BY wl.id ORDER BY wl.name
`).all();
const stockByCategory = db.prepare(`
SELECT a.type AS category, a.status, COUNT(*) AS count
FROM assets a
GROUP BY a.type, a.status
ORDER BY a.type, a.status
`).all();
const violations = StockThreshold.getViolations();
const openOrders = PurchaseOrder.getOpenCount();
const recentMovements = AssetMovement.getAll({ limit: 10 });
res.json({ stockByLocation, stockByCategory, violations, openOrders, recentMovements });
};
// ─── QR Code ──────────────────────────────────────────────────────────────────
exports.getQrCode = async (req, res) => {
const asset = Asset.getById(parseInt(req.params.id));
if (!asset) return res.status(404).json({ error: 'Asset nicht gefunden' });
const QRCode = require('qrcode');
const url = `${process.env.FRONTEND_URL || 'https://it-nexus.cereda-systems.de'}/assets?scan=${asset.id}`;
const svg = await QRCode.toString(url, { type: 'svg', width: 200, margin: 1 });
res.setHeader('Content-Type', 'image/svg+xml');
res.send(svg);
};

341
backend/src/db/schema.sql Normal file
View File

@@ -0,0 +1,341 @@
-- FIDO-Key Management System - Database Schema
-- ============================================================================
-- ROLES TABLE
-- ============================================================================
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================================
-- USERS TABLE
-- ============================================================================
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255),
role_id INTEGER NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
is_active BOOLEAN DEFAULT 1,
must_change_password BOOLEAN DEFAULT 1,
email_notifications INTEGER NOT NULL DEFAULT 1,
last_login DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (role_id) REFERENCES roles(id)
);
-- ============================================================================
-- FIDO KEYS TABLE
-- ============================================================================
CREATE TABLE IF NOT EXISTS fido_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
serial_number VARCHAR(255) UNIQUE NOT NULL,
status VARCHAR(20) NOT NULL CHECK(status IN ('aktiv', 'inaktiv')),
description TEXT,
assigned_to_user_id INTEGER,
created_by_user_id INTEGER NOT NULL,
updated_by_user_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (assigned_to_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (created_by_user_id) REFERENCES users(id),
FOREIGN KEY (updated_by_user_id) REFERENCES users(id)
);
-- ============================================================================
-- ASSETS TABLE
-- ============================================================================
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL CHECK(type IN ('Notebook', 'Monitor', 'Headset', 'Other')),
serial_number VARCHAR(255) UNIQUE NOT NULL,
model VARCHAR(255),
status VARCHAR(20) NOT NULL CHECK(status IN ('verfuegbar', 'zugewiesen', 'inaktiv', 'beschaedigt')),
purchase_date DATE,
description TEXT,
teamviewer_id TEXT,
assigned_to_user_id INTEGER,
created_by_user_id INTEGER NOT NULL,
updated_by_user_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (assigned_to_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (created_by_user_id) REFERENCES users(id),
FOREIGN KEY (updated_by_user_id) REFERENCES users(id)
);
-- ============================================================================
-- ASSET ASSIGNMENTS TABLE
-- ============================================================================
CREATE TABLE IF NOT EXISTS asset_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
assigned_by_user_id INTEGER NOT NULL,
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
returned_at DATETIME,
notes TEXT,
onboarding_protocol_id INTEGER,
offboarding_protocol_id INTEGER,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (assigned_by_user_id) REFERENCES users(id)
);
-- ============================================================================
-- ONBOARDING PROTOCOLS TABLE
-- ============================================================================
CREATE TABLE IF NOT EXISTS onboarding_protocols (
id INTEGER PRIMARY KEY AUTOINCREMENT,
employee_user_id INTEGER NOT NULL,
status VARCHAR(20) NOT NULL CHECK(status IN ('pending', 'in_progress', 'completed')),
start_date DATE NOT NULL,
completion_date DATE,
checklist_data TEXT,
notes TEXT,
pdf_file_path VARCHAR(500),
created_by_user_id INTEGER NOT NULL,
updated_by_user_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (employee_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (created_by_user_id) REFERENCES users(id),
FOREIGN KEY (updated_by_user_id) REFERENCES users(id)
);
-- ============================================================================
-- OFFBOARDING PROTOCOLS TABLE
-- ============================================================================
CREATE TABLE IF NOT EXISTS offboarding_protocols (
id INTEGER PRIMARY KEY AUTOINCREMENT,
employee_user_id INTEGER NOT NULL,
status VARCHAR(20) NOT NULL CHECK(status IN ('pending', 'in_progress', 'completed')),
exit_date DATE NOT NULL,
completion_date DATE,
checklist_data TEXT,
notes TEXT,
pdf_file_path VARCHAR(500),
created_by_user_id INTEGER NOT NULL,
updated_by_user_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (employee_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (created_by_user_id) REFERENCES users(id),
FOREIGN KEY (updated_by_user_id) REFERENCES users(id)
);
-- ============================================================================
-- AUDIT LOG TABLE
-- ============================================================================
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id INTEGER NOT NULL,
old_value TEXT,
new_value TEXT,
ip_address VARCHAR(45),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- ============================================================================
-- INDEXES FOR PERFORMANCE
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_users_role_id ON users(role_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);
CREATE INDEX IF NOT EXISTS idx_fido_keys_serial ON fido_keys(serial_number);
CREATE INDEX IF NOT EXISTS idx_fido_keys_status ON fido_keys(status);
CREATE INDEX IF NOT EXISTS idx_fido_keys_assigned_to ON fido_keys(assigned_to_user_id);
CREATE INDEX IF NOT EXISTS idx_fido_keys_created_by ON fido_keys(created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_assets_serial ON assets(serial_number);
CREATE INDEX IF NOT EXISTS idx_assets_type ON assets(type);
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
CREATE INDEX IF NOT EXISTS idx_assets_assigned_to ON assets(assigned_to_user_id);
CREATE INDEX IF NOT EXISTS idx_asset_assignments_asset ON asset_assignments(asset_id);
CREATE INDEX IF NOT EXISTS idx_asset_assignments_user ON asset_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_asset_assignments_dates ON asset_assignments(assigned_at, returned_at);
CREATE INDEX IF NOT EXISTS idx_onboarding_employee ON onboarding_protocols(employee_user_id);
CREATE INDEX IF NOT EXISTS idx_onboarding_status ON onboarding_protocols(status);
CREATE INDEX IF NOT EXISTS idx_onboarding_dates ON onboarding_protocols(start_date, completion_date);
CREATE INDEX IF NOT EXISTS idx_offboarding_employee ON offboarding_protocols(employee_user_id);
CREATE INDEX IF NOT EXISTS idx_offboarding_status ON offboarding_protocols(status);
CREATE INDEX IF NOT EXISTS idx_offboarding_dates ON offboarding_protocols(exit_date, completion_date);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
-- ============================================================================
-- TICKETS TABLE
-- ============================================================================
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_number TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
status VARCHAR(30) NOT NULL DEFAULT 'offen'
CHECK(status IN ('offen','in_bearbeitung','warten_auf_mitarbeiter','warten_auf_support','geschlossen')),
priority VARCHAR(20) NOT NULL DEFAULT 'mittel'
CHECK(priority IN ('niedrig','mittel','hoch','kritisch')),
category VARCHAR(50) NOT NULL DEFAULT 'Allgemein'
CHECK(category IN ('Software','Allgemein','SelectLine','Hardware')),
source VARCHAR(10) NOT NULL DEFAULT 'web'
CHECK(source IN ('web','email')),
requester_name TEXT,
requester_email TEXT,
assigned_to_user_id INTEGER,
created_by_user_id INTEGER,
asset_id INTEGER,
email_message_id TEXT,
resolved_at DATETIME,
closed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (assigned_to_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE SET NULL
);
-- ============================================================================
-- TICKET COMMENTS TABLE
-- ============================================================================
CREATE TABLE IF NOT EXISTS ticket_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
user_id INTEGER,
comment TEXT NOT NULL,
is_internal INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
-- ============================================================================
-- LICENSES TABLE
-- ============================================================================
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
vendor TEXT,
license_type TEXT NOT NULL DEFAULT 'subscription'
CHECK(license_type IN ('subscription', 'oem', 'volume', 'perpetual')),
product_key TEXT,
seats INTEGER,
purchase_date DATE,
expiry_date DATE,
cost REAL,
notes TEXT,
sku_id TEXT,
created_by_user_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_licenses_expiry ON licenses(expiry_date);
CREATE INDEX IF NOT EXISTS idx_licenses_type ON licenses(license_type);
-- ============================================================================
-- INDEXES FOR TICKETS
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to_user_id);
CREATE INDEX IF NOT EXISTS idx_tickets_created_by ON tickets(created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_tickets_asset ON tickets(asset_id);
CREATE INDEX IF NOT EXISTS idx_ticket_comments_ticket ON ticket_comments(ticket_id);
-- ============================================================================
-- MONITORING AGENTS TABLE
-- ============================================================================
CREATE TABLE IF NOT EXISTS monitoring_agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname VARCHAR(255) UNIQUE NOT NULL,
ip_address VARCHAR(50),
mac_address VARCHAR(100),
os_name VARCHAR(255),
os_version VARCHAR(255),
cpu_model VARCHAR(255),
cpu_cores INTEGER,
cpu_usage_percent REAL,
ram_total_gb REAL,
ram_used_gb REAL,
disk_total_gb REAL,
disk_free_gb REAL,
last_user VARCHAR(255),
uptime_hours REAL,
domain VARCHAR(255),
agent_version VARCHAR(50),
installed_software TEXT,
windows_updates_pending INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'online',
last_checkin DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_monitoring_agents_hostname ON monitoring_agents(hostname);
CREATE INDEX IF NOT EXISTS idx_monitoring_agents_status ON monitoring_agents(status);
CREATE INDEX IF NOT EXISTS idx_monitoring_agents_last_checkin ON monitoring_agents(last_checkin);
-- ============================================================================
-- NEXUS SCANNER TABLES
-- ============================================================================
CREATE TABLE IF NOT EXISTS scanner_sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id VARCHAR(10) NOT NULL UNIQUE,
last_seen DATETIME,
scanner_version VARCHAR(50),
host_count INTEGER DEFAULT 0,
check_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS scanner_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id VARCHAR(10) NOT NULL,
ip VARCHAR(50) NOT NULL,
mac VARCHAR(100),
hostname VARCHAR(255),
vendor VARCHAR(255),
status VARCHAR(20) DEFAULT 'online',
first_seen DATETIME,
last_seen DATETIME,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(site_id, ip)
);
CREATE TABLE IF NOT EXISTS scanner_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id VARCHAR(10) NOT NULL,
check_name VARCHAR(255) NOT NULL,
check_type VARCHAR(50),
target VARCHAR(255),
status VARCHAR(20) NOT NULL,
latency_ms INTEGER DEFAULT 0,
error_msg TEXT,
subject TEXT,
triggered_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_scanner_assets_site ON scanner_assets(site_id);
CREATE INDEX IF NOT EXISTS idx_scanner_assets_status ON scanner_assets(status);
CREATE INDEX IF NOT EXISTS idx_scanner_alerts_site ON scanner_alerts(site_id, triggered_at DESC);

726
backend/src/db/seed.js Normal file
View File

@@ -0,0 +1,726 @@
const Database = require('better-sqlite3');
const bcrypt = require('bcrypt');
const path = require('path');
const fs = require('fs');
require('dotenv').config();
const DB_PATH = process.env.DATABASE_PATH || './database.sqlite';
const BCRYPT_ROUNDS = parseInt(process.env.BCRYPT_ROUNDS) || 12;
// Default Super Admin credentials
const DEFAULT_SUPERADMIN = {
username: 'superadmin',
email: 'admin@fido-manager.local',
password: 'Admin123!',
first_name: 'Super',
last_name: 'Administrator'
};
async function initializeDatabase() {
try {
console.log('🔧 Initializing database...');
// Create database connection
const db = new Database(DB_PATH);
// Read and execute schema
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf8');
// Execute schema (split by semicolon and execute each statement)
const statements = schema.split(';').filter(stmt => stmt.trim());
for (const statement of statements) {
if (statement.trim()) {
db.exec(statement);
}
}
console.log('✅ Database schema created successfully');
// Create uploads directories
console.log('📁 Creating uploads directories...');
const uploadsDir = path.join(__dirname, '../../uploads');
const onboardingDir = path.join(uploadsDir, 'onboarding');
const offboardingDir = path.join(uploadsDir, 'offboarding');
const currentYear = new Date().getFullYear().toString();
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
if (!fs.existsSync(onboardingDir)) {
fs.mkdirSync(onboardingDir, { recursive: true });
}
if (!fs.existsSync(offboardingDir)) {
fs.mkdirSync(offboardingDir, { recursive: true });
}
// Create year subdirectories
const onboardingYearDir = path.join(onboardingDir, currentYear);
const offboardingYearDir = path.join(offboardingDir, currentYear);
if (!fs.existsSync(onboardingYearDir)) {
fs.mkdirSync(onboardingYearDir, { recursive: true });
}
if (!fs.existsSync(offboardingYearDir)) {
fs.mkdirSync(offboardingYearDir, { recursive: true });
}
console.log('✅ Uploads directories created successfully');
// Insert roles
console.log('📝 Inserting default roles...');
const insertRole = db.prepare(`
INSERT OR IGNORE INTO roles (name, description)
VALUES (?, ?)
`);
const roles = [
['super_admin', 'Super Administrator - Full system access including user management'],
['admin', 'Administrator - Can manage FIDO keys and view users'],
['bearbeiter', 'Editor - Can create and edit FIDO keys'],
['benutzer', 'User - Can only view FIDO keys'],
['support', 'Support - Kann Tickets sehen und bearbeiten'],
['hr_personal', 'HR / Personal - Sieht HR-Aufgaben im Onboarding'],
['buchhaltung', 'Buchhaltung / Lohn - Sieht Buchhaltungs-Aufgaben im Onboarding'],
['produktion', 'Produktion - Sieht und verwaltet Produktions-Assets'],
];
for (const [name, description] of roles) {
insertRole.run(name, description);
}
console.log('✅ Roles created successfully');
// Run migrations for existing databases
console.log('🔄 Running database migrations...');
const migrations = [
`ALTER TABLE assets ADD COLUMN teamviewer_id TEXT`,
`ALTER TABLE users ADD COLUMN azure_id TEXT`,
`ALTER TABLE users ADD COLUMN email_notifications INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE assets ADD COLUMN last_maintenance_date DATE`,
`ALTER TABLE assets ADD COLUMN next_maintenance_date DATE`,
`ALTER TABLE assets ADD COLUMN maintenance_interval_months INTEGER`,
`ALTER TABLE assets ADD COLUMN maintenance_notes TEXT`,
`CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
vendor TEXT,
license_type TEXT NOT NULL DEFAULT 'subscription',
product_key TEXT,
seats INTEGER,
purchase_date DATE,
expiry_date DATE,
cost REAL,
notes TEXT,
created_by_user_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_licenses_expiry ON licenses(expiry_date)`,
`CREATE INDEX IF NOT EXISTS idx_licenses_type ON licenses(license_type)`,
`ALTER TABLE licenses ADD COLUMN sku_id TEXT`,
`ALTER TABLE tickets ADD COLUMN snoozed_until DATETIME`,
`CREATE TABLE IF NOT EXISTS ticket_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
linked_ticket_id INTEGER NOT NULL,
link_type TEXT NOT NULL DEFAULT 'related',
created_by_user_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
FOREIGN KEY (linked_ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
UNIQUE(ticket_id, linked_ticket_id)
)`,
`ALTER TABLE tickets ADD COLUMN ai_suggestion TEXT`,
`ALTER TABLE ticket_comments ADD COLUMN is_ai_comment INTEGER DEFAULT 0`,
`ALTER TABLE tickets ADD COLUMN ai_active INTEGER DEFAULT 1`,
`CREATE TABLE IF NOT EXISTS ticket_assignees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
assigned_by_user_id INTEGER,
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(ticket_id, user_id)
)`,
`CREATE TABLE IF NOT EXISTS knowledge_base (
id INTEGER PRIMARY KEY AUTOINCREMENT,
problem TEXT NOT NULL,
solution TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'Allgemein',
tags TEXT,
created_by_user_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
)`,
`ALTER TABLE tickets ADD COLUMN escalated_at DATETIME`,
`ALTER TABLE tickets ADD COLUMN satisfaction_rating TEXT`,
`CREATE TABLE IF NOT EXISTS ticket_routing (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL UNIQUE,
assigned_to_user_id INTEGER,
FOREIGN KEY (assigned_to_user_id) REFERENCES users(id) ON DELETE SET NULL
)`,
`ALTER TABLE onboarding_protocols ADD COLUMN dept_contacts TEXT`,
`ALTER TABLE knowledge_base ADD COLUMN source_ticket_id INTEGER REFERENCES tickets(id) ON DELETE SET NULL`,
`ALTER TABLE knowledge_base ADD COLUMN auto_generated INTEGER DEFAULT 0`,
`ALTER TABLE knowledge_base ADD COLUMN images TEXT DEFAULT '[]'`,
`ALTER TABLE onboarding_protocols ADD COLUMN confirm_token TEXT`,
`ALTER TABLE onboarding_protocols ADD COLUMN employee_confirmed_at DATETIME`,
`ALTER TABLE users ADD COLUMN notif_ticket_created INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE users ADD COLUMN notif_ticket_assigned INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE users ADD COLUMN notif_new_comment INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE users ADD COLUMN notif_weekly_report INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE assets ADD COLUMN department TEXT NOT NULL DEFAULT 'IT'`,
`UPDATE roles SET name = 'produktion', description = 'Produktion - Sieht und verwaltet Produktions-Assets' WHERE name = 'techniker'`,
`ALTER TABLE assets ADD COLUMN inventory_number TEXT`,
`ALTER TABLE assets ADD COLUMN purchase_price REAL`,
`ALTER TABLE assets ADD COLUMN useful_life_years INTEGER`,
`ALTER TABLE assets ADD COLUMN residual_value REAL DEFAULT 0`,
`CREATE TABLE IF NOT EXISTS network_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'host' CHECK(type IN ('host','switch','router','printer','nas','service')),
host TEXT NOT NULL,
check_type TEXT NOT NULL DEFAULT 'icmp' CHECK(check_type IN ('icmp','http','https','tcp')),
port INTEGER,
http_path TEXT DEFAULT '/',
http_keyword TEXT,
interval_sec INTEGER NOT NULL DEFAULT 60,
timeout_sec INTEGER NOT NULL DEFAULT 5,
enabled INTEGER NOT NULL DEFAULT 1,
notify_email TEXT,
location TEXT,
last_status TEXT DEFAULT 'unknown',
last_checked DATETIME,
last_rtt_ms REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_network_devices_enabled ON network_devices(enabled)`,
`CREATE TABLE IF NOT EXISTS device_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id INTEGER NOT NULL,
status TEXT NOT NULL CHECK(status IN ('up','down')),
rtt_ms REAL,
error_msg TEXT,
checked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (device_id) REFERENCES network_devices(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS idx_device_checks_time ON device_checks(device_id, checked_at)`,
`CREATE TABLE IF NOT EXISTS teams_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id TEXT NOT NULL,
team_name TEXT,
channel_id TEXT NOT NULL UNIQUE,
channel_name TEXT,
created_at_teams TEXT,
first_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,
notified INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_teams_channels_notified ON teams_channels(notified)`,
`CREATE TABLE IF NOT EXISTS unifi_config (
id INTEGER PRIMARY KEY DEFAULT 1,
controller_url TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
site TEXT NOT NULL DEFAULT 'default',
poll_interval_min INTEGER NOT NULL DEFAULT 5,
enabled INTEGER NOT NULL DEFAULT 0
)`,
`INSERT OR IGNORE INTO unifi_config (id) VALUES (1)`,
`CREATE TABLE IF NOT EXISTS unifi_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
unifi_id TEXT UNIQUE,
mac TEXT,
name TEXT,
ip TEXT,
type TEXT,
model TEXT,
version TEXT,
state INTEGER DEFAULT 0,
uptime INTEGER DEFAULT 0,
num_sta INTEGER DEFAULT 0,
cpu_pct REAL,
ram_pct REAL,
ports_json TEXT DEFAULT '[]',
radio_json TEXT DEFAULT '[]',
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_unifi_devices_type ON unifi_devices(type)`,
`CREATE TABLE IF NOT EXISTS external_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT DEFAULT 'netgo',
device TEXT,
service TEXT,
state_transition TEXT,
severity TEXT,
message TEXT,
customer TEXT,
monitored_by TEXT,
state_time TEXT,
raw_body TEXT,
acknowledged INTEGER DEFAULT 0,
ticket_id INTEGER,
email_message_id TEXT UNIQUE,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_external_alerts_acknowledged ON external_alerts(acknowledged)`,
`ALTER TABLE tickets ADD COLUMN satisfaction_comment TEXT`,
`ALTER TABLE external_alerts ADD COLUMN ai_analysis TEXT`,
// === LAGER-MODUL MIGRATIONS ===
`ALTER TABLE assets ADD COLUMN location_id INTEGER REFERENCES warehouse_locations(id) ON DELETE SET NULL`,
`ALTER TABLE assets ADD COLUMN min_stock INTEGER DEFAULT 0`,
`CREATE TABLE IF NOT EXISTS warehouse_locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`INSERT OR IGNORE INTO warehouse_locations (name, description) VALUES ('Büro', 'Allgemeines Büro')`,
`INSERT OR IGNORE INTO warehouse_locations (name, description) VALUES ('Serverraum', 'Serverraum / Technikraum')`,
`INSERT OR IGNORE INTO warehouse_locations (name, description) VALUES ('Lager', 'Hauptlager')`,
`CREATE TABLE IF NOT EXISTS asset_movements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL,
type TEXT NOT NULL CHECK(type IN ('in','out','transfer','status_change')),
from_location_id INTEGER REFERENCES warehouse_locations(id) ON DELETE SET NULL,
to_location_id INTEGER REFERENCES warehouse_locations(id) ON DELETE SET NULL,
assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
ticket_id INTEGER REFERENCES tickets(id) ON DELETE SET NULL,
reason TEXT,
notes TEXT,
performed_by INTEGER NOT NULL REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS idx_asset_movements_asset ON asset_movements(asset_id)`,
`CREATE INDEX IF NOT EXISTS idx_asset_movements_created ON asset_movements(created_at)`,
`CREATE TABLE IF NOT EXISTS stock_thresholds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL UNIQUE,
min_stock INTEGER NOT NULL DEFAULT 1,
notify_email TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS purchase_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
item_name TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'offen' CHECK(status IN ('offen','bestellt','erledigt')),
notes TEXT,
created_by INTEGER NOT NULL REFERENCES users(id),
ordered_at DATETIME,
completed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_purchase_orders_status ON purchase_orders(status)`,
`CREATE TABLE IF NOT EXISTS asset_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
icon TEXT DEFAULT 'devices_other',
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`INSERT OR IGNORE INTO asset_types (name, icon, sort_order) VALUES ('Notebook', 'laptop', 1)`,
`INSERT OR IGNORE INTO asset_types (name, icon, sort_order) VALUES ('Monitor', 'monitor', 2)`,
`INSERT OR IGNORE INTO asset_types (name, icon, sort_order) VALUES ('Headset', 'headset', 3)`,
`INSERT OR IGNORE INTO asset_types (name, icon, sort_order) VALUES ('Drucker', 'print', 4)`,
`INSERT OR IGNORE INTO asset_types (name, icon, sort_order) VALUES ('Smartphone', 'smartphone', 5)`,
`INSERT OR IGNORE INTO asset_types (name, icon, sort_order) VALUES ('Tablet', 'tablet', 6)`,
`INSERT OR IGNORE INTO asset_types (name, icon, sort_order) VALUES ('Server', 'storage', 7)`,
`INSERT OR IGNORE INTO asset_types (name, icon, sort_order) VALUES ('Switch', 'device_hub', 8)`,
`INSERT OR IGNORE INTO asset_types (name, icon, sort_order) VALUES ('Sonstiges', 'devices_other', 99)`,
`ALTER TABLE monitoring_agents ADD COLUMN agent_version TEXT DEFAULT '1.0.0'`,
`ALTER TABLE monitoring_agents ADD COLUMN tpm_present INTEGER DEFAULT 0`,
`ALTER TABLE monitoring_agents ADD COLUMN tpm_version TEXT`,
`ALTER TABLE monitoring_agents ADD COLUMN tpm_v2 INTEGER DEFAULT 0`,
`ALTER TABLE monitoring_agents ADD COLUMN secure_boot INTEGER DEFAULT 0`,
`ALTER TABLE monitoring_agents ADD COLUMN win11_ready INTEGER DEFAULT 0`,
`DROP TABLE IF EXISTS patch_commands_v2`,
`CREATE TABLE patch_commands_v2 (id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id INTEGER NOT NULL, command TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','sent','done','failed')), triggered_by_user_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, sent_at DATETIME, completed_at DATETIME, result TEXT)`,
`INSERT OR IGNORE INTO patch_commands_v2 SELECT id,agent_id,command,status,triggered_by_user_id,created_at,sent_at,completed_at,result FROM patch_commands`,
`DROP TABLE patch_commands`,
`ALTER TABLE patch_commands_v2 RENAME TO patch_commands`,
`CREATE INDEX IF NOT EXISTS idx_patch_commands_agent ON patch_commands(agent_id, status)`,
`DROP TABLE IF EXISTS patch_commands_v3`,
`CREATE TABLE patch_commands_v3 (id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id INTEGER NOT NULL, command TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','sent','done','failed','running')), triggered_by_user_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, sent_at DATETIME, completed_at DATETIME, result TEXT)`,
`INSERT OR IGNORE INTO patch_commands_v3 SELECT id,agent_id,command,status,triggered_by_user_id,created_at,sent_at,completed_at,result FROM patch_commands`,
`DROP TABLE patch_commands`,
`ALTER TABLE patch_commands_v3 RENAME TO patch_commands`,
`CREATE INDEX IF NOT EXISTS idx_patch_commands_agent_v3 ON patch_commands(agent_id, status)`,
`ALTER TABLE portal_guides ADD COLUMN visible_roles TEXT DEFAULT '[]'`,
`CREATE TABLE IF NOT EXISTS patch_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
color TEXT DEFAULT '#3B82F6',
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`INSERT OR IGNORE INTO patch_groups (name, description, color, sort_order) VALUES ('Test', 'Testgeräte zuerst patchen', '#8B5CF6', 1)`,
`INSERT OR IGNORE INTO patch_groups (name, description, color, sort_order) VALUES ('Pilot', 'Pilotgruppe frühe Nutzer', '#F59E0B', 2)`,
`INSERT OR IGNORE INTO patch_groups (name, description, color, sort_order) VALUES ('Produktion', 'Produktionsgeräte zuletzt patchen', '#10B981', 3)`,
`CREATE TABLE IF NOT EXISTS patch_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES patch_groups(id) ON DELETE CASCADE,
severity TEXT NOT NULL DEFAULT 'critical' CHECK(severity IN ('critical','important','moderate','low','all')),
max_days INTEGER NOT NULL DEFAULT 7,
notify_email TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS patch_agent_groups (
agent_id INTEGER NOT NULL REFERENCES monitoring_agents(id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES patch_groups(id) ON DELETE CASCADE,
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (agent_id, group_id)
)`,
`CREATE TABLE IF NOT EXISTS patch_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL REFERENCES monitoring_agents(id) ON DELETE CASCADE,
command TEXT NOT NULL CHECK(command IN ('install_updates','check_updates','reboot')),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','sent','done','failed')),
triggered_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME,
completed_at DATETIME,
result TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_patch_commands_agent ON patch_commands(agent_id, status)`,
`ALTER TABLE patch_groups ADD COLUMN target_agent_version TEXT DEFAULT NULL`,
`CREATE TABLE IF NOT EXISTS ai_knowledge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'Allgemein',
tags TEXT NOT NULL DEFAULT '[]',
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`DROP TABLE IF EXISTS announcement_acks`,
`DROP TABLE IF EXISTS announcements`,
`CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
message TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'info' CHECK(type IN ('info','warning','maintenance')),
target_groups TEXT NOT NULL DEFAULT '[]',
created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME DEFAULT NULL,
active INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS announcement_acks (
announcement_id INTEGER NOT NULL REFERENCES announcements(id) ON DELETE CASCADE,
agent_id INTEGER NOT NULL REFERENCES monitoring_agents(id) ON DELETE CASCADE,
hostname TEXT NOT NULL,
acknowledged_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (announcement_id, agent_id)
)`,
`CREATE TABLE IF NOT EXISTS health_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
checked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
api_status TEXT NOT NULL DEFAULT 'ok',
db_status TEXT NOT NULL DEFAULT 'ok',
response_ms INTEGER
)`,
`CREATE INDEX IF NOT EXISTS idx_health_history_date ON health_history(checked_at)`,
`CREATE TABLE IF NOT EXISTS portal_guides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'Allgemein',
description TEXT,
html_content TEXT NOT NULL,
icon TEXT DEFAULT '📄',
sort_order INTEGER DEFAULT 0,
is_public INTEGER DEFAULT 1,
created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
// Agent v2.0.0 — neue Felder
`ALTER TABLE monitoring_agents ADD COLUMN bitlocker_status TEXT DEFAULT 'unknown'`,
`ALTER TABLE monitoring_agents ADD COLUMN defender_enabled INTEGER DEFAULT 0`,
`ALTER TABLE monitoring_agents ADD COLUMN defender_signatures_age INTEGER DEFAULT -1`,
`ALTER TABLE monitoring_agents ADD COLUMN hardware_serial TEXT`,
// Sichere Links (Shares)
`CREATE TABLE IF NOT EXISTS shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
type TEXT NOT NULL CHECK(type IN ('file','text')),
filename TEXT,
stored_filename TEXT,
text_content TEXT,
password_hash TEXT,
expires_at DATETIME,
max_downloads INTEGER,
download_count INTEGER DEFAULT 0,
created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
)`,
`CREATE INDEX IF NOT EXISTS idx_shares_token ON shares(token)`,
// Benutzerverwaltung — erweiterte User-Felder
`ALTER TABLE users ADD COLUMN department TEXT`,
`ALTER TABLE users ADD COLUMN position TEXT`,
`ALTER TABLE users ADD COLUMN phone TEXT`,
`ALTER TABLE users ADD COLUMN location TEXT`,
`ALTER TABLE users ADD COLUMN manager_id INTEGER REFERENCES users(id)`,
`ALTER TABLE users ADD COLUMN cost_center TEXT`,
`ALTER TABLE users ADD COLUMN employment_type TEXT`,
`ALTER TABLE users ADD COLUMN work_hours INTEGER`,
`ALTER TABLE users ADD COLUMN joined_date DATE`,
// Benutzerverwaltung — erweiterte FIDO-Key-Felder
`ALTER TABLE fido_keys ADD COLUMN key_type TEXT DEFAULT 'primary'`,
`ALTER TABLE fido_keys ADD COLUMN last_used_at DATETIME`,
`ALTER TABLE fido_keys ADD COLUMN manufacturer TEXT`,
`ALTER TABLE fido_keys ADD COLUMN connection_type TEXT`,
// Asset-Agent-Sync
`ALTER TABLE assets ADD COLUMN os TEXT`,
`ALTER TABLE assets ADD COLUMN ip_address TEXT`,
`ALTER TABLE assets ADD COLUMN manufacturer TEXT`,
`ALTER TABLE assets ADD COLUMN last_agent_sync DATETIME`,
`ALTER TABLE announcements ADD COLUMN target_agent_ids TEXT NOT NULL DEFAULT '[]'`,
`CREATE TABLE IF NOT EXISTS security_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
original_filename TEXT NOT NULL,
report_period TEXT,
risk_level TEXT DEFAULT 'unbekannt',
summary_json TEXT,
created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
];
for (const migration of migrations) {
try {
db.exec(migration);
} catch (e) {
// Column already exists ignore
}
}
console.log('✅ Database migrations completed');
// Special migration: rebuild network_devices to add SNMP + AP support
try {
const ndDef = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='network_devices'").get();
if (ndDef && !ndDef.sql.includes('snmp_community')) {
console.log('🔄 Migrating network_devices (adding SNMP + AP support)...');
db.pragma('foreign_keys = OFF');
db.exec(`CREATE TABLE network_devices_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'host' CHECK(type IN ('host','switch','router','printer','nas','service','ap')),
host TEXT NOT NULL,
check_type TEXT NOT NULL DEFAULT 'icmp' CHECK(check_type IN ('icmp','http','https','tcp','snmp')),
port INTEGER,
http_path TEXT DEFAULT '/',
http_keyword TEXT,
snmp_community TEXT DEFAULT 'public',
snmp_version TEXT DEFAULT '2c',
interval_sec INTEGER NOT NULL DEFAULT 60,
timeout_sec INTEGER NOT NULL DEFAULT 5,
enabled INTEGER NOT NULL DEFAULT 1,
notify_email TEXT,
location TEXT,
last_status TEXT DEFAULT 'unknown',
last_checked DATETIME,
last_rtt_ms REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.exec(`INSERT INTO network_devices_new
(id, name, type, host, check_type, port, http_path, http_keyword,
snmp_community, snmp_version, interval_sec, timeout_sec, enabled,
notify_email, location, last_status, last_checked, last_rtt_ms, created_at)
SELECT id, name, type, host, check_type, port, http_path, http_keyword,
'public', '2c', interval_sec, timeout_sec, enabled,
notify_email, location, last_status, last_checked, last_rtt_ms, created_at
FROM network_devices`);
db.exec('DROP TABLE network_devices');
db.exec('ALTER TABLE network_devices_new RENAME TO network_devices');
db.pragma('foreign_keys = ON');
db.exec('CREATE INDEX IF NOT EXISTS idx_network_devices_enabled ON network_devices(enabled)');
console.log('✅ network_devices SNMP migration complete');
}
} catch (e) {
console.error('❌ network_devices SNMP migration failed:', e.message);
}
// Special migration: add personal data columns to onboarding_protocols
try {
const obDef = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='onboarding_protocols'").get();
if (obDef && !obDef.sql.includes('emp_first_name')) {
console.log('🔄 Migrating onboarding_protocols (adding personal data columns)...');
db.pragma('foreign_keys = OFF');
db.exec(`CREATE TABLE onboarding_protocols_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
employee_user_id INTEGER,
emp_first_name TEXT,
emp_last_name TEXT,
emp_private_email TEXT,
emp_phone TEXT,
emp_address TEXT,
department TEXT,
position TEXT,
work_location TEXT,
hours_model TEXT,
vacation_model TEXT,
status VARCHAR(20) NOT NULL CHECK(status IN ('pending', 'in_progress', 'completed')),
start_date DATE NOT NULL,
completion_date DATE,
checklist_data TEXT,
notes TEXT,
pdf_file_path VARCHAR(500),
created_by_user_id INTEGER NOT NULL,
updated_by_user_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (employee_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (created_by_user_id) REFERENCES users(id),
FOREIGN KEY (updated_by_user_id) REFERENCES users(id)
)`);
db.exec(`INSERT INTO onboarding_protocols_new
(id, employee_user_id, status, start_date, completion_date, checklist_data, notes, pdf_file_path, created_by_user_id, updated_by_user_id, created_at, updated_at)
SELECT id, employee_user_id, status, start_date, completion_date, checklist_data, notes, pdf_file_path, created_by_user_id, updated_by_user_id, created_at, updated_at
FROM onboarding_protocols`);
db.exec('DROP TABLE onboarding_protocols');
db.exec('ALTER TABLE onboarding_protocols_new RENAME TO onboarding_protocols');
db.pragma('foreign_keys = ON');
db.exec('CREATE INDEX IF NOT EXISTS idx_onboarding_employee ON onboarding_protocols(employee_user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_onboarding_status ON onboarding_protocols(status)');
console.log('✅ Onboarding personal data migration complete');
}
} catch (e) {
console.error('❌ Onboarding migration failed:', e.message);
}
// Special migration: update ticket status CHECK constraint to add response-based statuses
try {
const ticketDef = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='tickets'").get();
if (ticketDef && !ticketDef.sql.includes('warten_auf_mitarbeiter')) {
console.log('🔄 Migrating ticket status constraint (adding warten_auf_mitarbeiter/support)...');
db.pragma('foreign_keys = OFF');
db.exec(`CREATE TABLE tickets_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_number TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
status VARCHAR(30) NOT NULL DEFAULT 'offen'
CHECK(status IN ('offen','in_bearbeitung','warten_auf_mitarbeiter','warten_auf_support','geschlossen')),
priority VARCHAR(20) NOT NULL DEFAULT 'mittel'
CHECK(priority IN ('niedrig','mittel','hoch','kritisch')),
category VARCHAR(50) NOT NULL DEFAULT 'Allgemein'
CHECK(category IN ('Software','Allgemein','SelectLine','Hardware')),
source VARCHAR(10) NOT NULL DEFAULT 'web'
CHECK(source IN ('web','email')),
requester_name TEXT,
requester_email TEXT,
assigned_to_user_id INTEGER,
created_by_user_id INTEGER,
asset_id INTEGER,
email_message_id TEXT,
snoozed_until DATETIME,
ai_suggestion TEXT,
ai_active INTEGER DEFAULT 1,
resolved_at DATETIME,
closed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (assigned_to_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE SET NULL
)`);
db.exec(`INSERT INTO tickets_new
SELECT id, ticket_number, title, description,
CASE status WHEN 'geloest' THEN 'in_bearbeitung' ELSE status END,
priority, category, source, requester_name, requester_email,
assigned_to_user_id, created_by_user_id, asset_id, email_message_id,
snoozed_until, ai_suggestion, ai_active, resolved_at, closed_at,
created_at, updated_at
FROM tickets`);
db.exec('DROP TABLE tickets');
db.exec('ALTER TABLE tickets_new RENAME TO tickets');
db.pragma('foreign_keys = ON');
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status)');
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority)');
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to_user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_created_by ON tickets(created_by_user_id)');
console.log('✅ Ticket status migration complete');
}
} catch (e) {
console.error('❌ Ticket status migration failed:', e.message);
}
// Check if super admin already exists
const existingAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get(DEFAULT_SUPERADMIN.username);
if (!existingAdmin) {
console.log('👤 Creating default Super Admin user...');
// Hash password
const passwordHash = await bcrypt.hash(DEFAULT_SUPERADMIN.password, BCRYPT_ROUNDS);
// Get super_admin role ID
const superAdminRole = db.prepare('SELECT id FROM roles WHERE name = ?').get('super_admin');
// Insert super admin user
const insertUser = db.prepare(`
INSERT INTO users (
username,
email,
password_hash,
role_id,
first_name,
last_name,
is_active,
must_change_password
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
insertUser.run(
DEFAULT_SUPERADMIN.username,
DEFAULT_SUPERADMIN.email,
passwordHash,
superAdminRole.id,
DEFAULT_SUPERADMIN.first_name,
DEFAULT_SUPERADMIN.last_name,
1, // is_active
1 // must_change_password
);
console.log('✅ Super Admin user created successfully');
console.log('');
console.log('═══════════════════════════════════════════');
console.log('🔐 DEFAULT SUPER ADMIN CREDENTIALS');
console.log('═══════════════════════════════════════════');
console.log(`Username: ${DEFAULT_SUPERADMIN.username}`);
console.log(`Password: ${DEFAULT_SUPERADMIN.password}`);
console.log('');
console.log('⚠️ IMPORTANT: Please change this password on first login!');
console.log('═══════════════════════════════════════════');
console.log('');
} else {
console.log(' Super Admin user already exists, skipping creation');
}
db.close();
console.log('✅ Database initialization completed successfully!');
} catch (error) {
console.error('❌ Database initialization failed:', error.message);
process.exit(1);
}
}
// Run if called directly
if (require.main === module) {
initializeDatabase();
}
module.exports = { initializeDatabase };

View File

@@ -0,0 +1,62 @@
const jwt = require('jsonwebtoken');
const JWT_CONFIG = require('../config/jwt');
/**
* Middleware to verify JWT token and attach user to request
*/
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({
status: 'error',
message: 'Access token required'
});
}
try {
const decoded = jwt.verify(token, JWT_CONFIG.secret);
req.user = decoded; // { id, username, email, role, roleId }
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
status: 'error',
message: 'Token expired'
});
}
return res.status(403).json({
status: 'error',
message: 'Invalid token'
});
}
}
/**
* Optional authentication - doesn't fail if no token provided
* But validates token if present
*/
function optionalAuth(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
req.user = null;
return next();
}
try {
const decoded = jwt.verify(token, JWT_CONFIG.secret);
req.user = decoded;
next();
} catch (error) {
req.user = null;
next();
}
}
module.exports = {
authenticateToken,
optionalAuth
};

View File

@@ -0,0 +1,67 @@
/**
* Custom error class for application errors
*/
class AppError extends Error {
constructor(message, statusCode = 500, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
/**
* Global error handling middleware
*/
function errorHandler(err, req, res, next) {
let { statusCode = 500, message } = err;
// Log error for debugging
if (process.env.NODE_ENV === 'development') {
console.error('❌ Error:', err);
}
// Don't leak error details in production for operational errors
if (process.env.NODE_ENV === 'production' && !err.isOperational) {
message = 'Something went wrong';
}
// Prepare response
const response = {
status: 'error',
message
};
// Add stack trace in development
if (process.env.NODE_ENV === 'development') {
response.stack = err.stack;
}
res.status(statusCode).json(response);
}
/**
* Handle 404 errors
*/
function notFoundHandler(req, res, next) {
res.status(404).json({
status: 'error',
message: 'Route not found'
});
}
/**
* Async wrapper to catch errors in async route handlers
*/
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
module.exports = {
AppError,
errorHandler,
notFoundHandler,
asyncHandler
};

View File

@@ -0,0 +1,124 @@
/**
* Role-based access control middleware
*
* Usage: requireRole(['super_admin', 'admin'])
*/
const ROLES = {
SUPER_ADMIN: 'super_admin',
ADMIN: 'admin',
BEARBEITER: 'bearbeiter',
BENUTZER: 'benutzer',
SUPPORT: 'support',
HR_PERSONAL: 'hr_personal',
BUCHHALTUNG: 'buchhaltung',
TECHNIKER: 'produktion',
};
/**
* Middleware to check if user has one of the required roles
* @param {Array<string>} allowedRoles - Array of role names that are allowed
*/
function requireRole(allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
status: 'error',
message: 'Authentication required'
});
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
status: 'error',
message: 'Insufficient permissions'
});
}
next();
};
}
/**
* Check if user is Super Admin
*/
function requireSuperAdmin(req, res, next) {
return requireRole([ROLES.SUPER_ADMIN])(req, res, next);
}
/**
* Check if user is Super Admin or Admin
*/
function requireAdmin(req, res, next) {
return requireRole([ROLES.SUPER_ADMIN, ROLES.ADMIN])(req, res, next);
}
/**
* Check if user can modify FIDO keys (Super Admin, Admin, or Bearbeiter)
*/
function canModifyFidoKeys(req, res, next) {
return requireRole([ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.BEARBEITER])(req, res, next);
}
/**
* Check if user can view FIDO keys (all authenticated users)
*/
function canViewFidoKeys(req, res, next) {
return requireRole([ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.BEARBEITER, ROLES.BENUTZER])(req, res, next);
}
/**
* Check if user can view assets (Super Admin, Admin, Bearbeiter)
*/
function canViewAssets(req, res, next) {
return requireRole([ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.TECHNIKER])(req, res, next);
}
/**
* Check if user can modify assets (Super Admin, Admin only)
*/
function canModifyAssets(req, res, next) {
return requireRole([ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.TECHNIKER])(req, res, next);
}
/**
* Check if user can view tickets (all authenticated users)
*/
function canViewTickets(req, res, next) {
return requireRole([ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.SUPPORT, ROLES.BEARBEITER, ROLES.BENUTZER, ROLES.HR_PERSONAL, ROLES.BUCHHALTUNG, ROLES.TECHNIKER])(req, res, next);
}
/**
* Check if user can modify tickets (Super Admin, Admin, Support)
*/
function canModifyTickets(req, res, next) {
return requireRole([ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.SUPPORT])(req, res, next);
}
/**
* Can view onboarding: admins + dept roles (hr_personal, buchhaltung, support used for IT)
*/
function canViewOnboarding(req, res, next) {
return requireRole([
ROLES.SUPER_ADMIN, ROLES.ADMIN,
ROLES.HR_PERSONAL, ROLES.BUCHHALTUNG, ROLES.SUPPORT,
])(req, res, next);
}
// Departments visible per role
const TECHNIKER_DEPARTMENTS = ['Produktion', 'Techniker'];
module.exports = {
ROLES,
TECHNIKER_DEPARTMENTS,
requireRole,
requireSuperAdmin,
requireAdmin,
canModifyFidoKeys,
canViewFidoKeys,
canViewAssets,
canModifyAssets,
canViewTickets,
canModifyTickets,
canViewOnboarding,
};

356
backend/src/models/Asset.js Normal file
View File

@@ -0,0 +1,356 @@
const { getDatabase } = require('../config/database');
class Asset {
/**
* Get all assets with related user information
*/
static getAll({ departments } = {}) {
const db = getDatabase();
const where = departments && departments.length
? `WHERE a.department IN (${departments.map(() => '?').join(',')})`
: '';
const stmt = db.prepare(`
SELECT
a.*,
au.username as assigned_to_username,
au.email as assigned_to_email,
cu.username as created_by_username,
uu.username as updated_by_username
FROM assets a
LEFT JOIN users au ON a.assigned_to_user_id = au.id
LEFT JOIN users cu ON a.created_by_user_id = cu.id
LEFT JOIN users uu ON a.updated_by_user_id = uu.id
${where}
ORDER BY a.created_at DESC
`);
return departments && departments.length ? stmt.all(...departments) : stmt.all();
}
/**
* Get asset by ID
*/
static getById(id) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
a.*,
au.username as assigned_to_username,
au.email as assigned_to_email,
cu.username as created_by_username,
uu.username as updated_by_username,
wl.name as location_name
FROM assets a
LEFT JOIN users au ON a.assigned_to_user_id = au.id
LEFT JOIN users cu ON a.created_by_user_id = cu.id
LEFT JOIN users uu ON a.updated_by_user_id = uu.id
LEFT JOIN warehouse_locations wl ON a.location_id = wl.id
WHERE a.id = ?
`);
return stmt.get(id);
}
/**
* Get asset by name (hostname match)
*/
static getByName(name) {
const db = getDatabase();
return db.prepare('SELECT * FROM assets WHERE LOWER(name) = LOWER(?)').get(name);
}
/**
* Get asset by serial number
*/
static getBySerialNumber(serialNumber) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
a.*,
au.username as assigned_to_username,
cu.username as created_by_username,
wl.name as location_name
FROM assets a
LEFT JOIN users au ON a.assigned_to_user_id = au.id
LEFT JOIN users cu ON a.created_by_user_id = cu.id
LEFT JOIN warehouse_locations wl ON a.location_id = wl.id
WHERE a.serial_number = ?
`);
return stmt.get(serialNumber);
}
/**
* Get assets by status
*/
static getByStatus(status) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
a.*,
au.username as assigned_to_username,
cu.username as created_by_username
FROM assets a
LEFT JOIN users au ON a.assigned_to_user_id = au.id
LEFT JOIN users cu ON a.created_by_user_id = cu.id
WHERE a.status = ?
ORDER BY a.created_at DESC
`);
return stmt.all(status);
}
/**
* Get assets by type
*/
static getByType(type) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
a.*,
au.username as assigned_to_username,
cu.username as created_by_username
FROM assets a
LEFT JOIN users au ON a.assigned_to_user_id = au.id
LEFT JOIN users cu ON a.created_by_user_id = cu.id
WHERE a.type = ?
ORDER BY a.created_at DESC
`);
return stmt.all(type);
}
/**
* Get assets assigned to a specific user
*/
static getByAssignedUser(userId) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
a.*,
cu.username as created_by_username
FROM assets a
LEFT JOIN users cu ON a.created_by_user_id = cu.id
WHERE a.assigned_to_user_id = ?
ORDER BY a.created_at DESC
`);
return stmt.all(userId);
}
/**
* Create a new asset
*/
static create(assetData) {
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO assets (
name,
type,
serial_number,
model,
status,
purchase_date,
description,
teamviewer_id,
assigned_to_user_id,
created_by_user_id,
last_maintenance_date,
next_maintenance_date,
maintenance_interval_months,
maintenance_notes,
department,
inventory_number,
purchase_price,
useful_life_years,
residual_value
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
assetData.name,
assetData.type,
assetData.serial_number,
assetData.model || null,
assetData.status,
assetData.purchase_date || null,
assetData.description || null,
assetData.teamviewer_id || null,
assetData.assigned_to_user_id || null,
assetData.created_by_user_id,
assetData.last_maintenance_date || null,
assetData.next_maintenance_date || null,
assetData.maintenance_interval_months ? parseInt(assetData.maintenance_interval_months) : null,
assetData.maintenance_notes || null,
assetData.department || 'IT',
assetData.inventory_number || null,
assetData.purchase_price ? parseFloat(assetData.purchase_price) : null,
assetData.useful_life_years ? parseInt(assetData.useful_life_years) : null,
assetData.residual_value ? parseFloat(assetData.residual_value) : 0
);
return this.getById(result.lastInsertRowid);
}
/**
* Update asset
*/
static update(id, assetData, updatedByUserId) {
const db = getDatabase();
const fields = [];
const values = [];
if (assetData.name !== undefined) {
fields.push('name = ?');
values.push(assetData.name);
}
if (assetData.type !== undefined) {
fields.push('type = ?');
values.push(assetData.type);
}
if (assetData.serial_number !== undefined) {
fields.push('serial_number = ?');
values.push(assetData.serial_number);
}
if (assetData.model !== undefined) {
fields.push('model = ?');
values.push(assetData.model);
}
if (assetData.status !== undefined) {
fields.push('status = ?');
values.push(assetData.status);
}
if (assetData.purchase_date !== undefined) {
fields.push('purchase_date = ?');
values.push(assetData.purchase_date);
}
if (assetData.description !== undefined) {
fields.push('description = ?');
values.push(assetData.description);
}
if (assetData.teamviewer_id !== undefined) {
fields.push('teamviewer_id = ?');
values.push(assetData.teamviewer_id || null);
}
if (assetData.assigned_to_user_id !== undefined) {
fields.push('assigned_to_user_id = ?');
values.push(assetData.assigned_to_user_id);
}
if (assetData.last_maintenance_date !== undefined) {
fields.push('last_maintenance_date = ?');
values.push(assetData.last_maintenance_date || null);
}
if (assetData.next_maintenance_date !== undefined) {
fields.push('next_maintenance_date = ?');
values.push(assetData.next_maintenance_date || null);
}
if (assetData.maintenance_interval_months !== undefined) {
fields.push('maintenance_interval_months = ?');
values.push(assetData.maintenance_interval_months ? parseInt(assetData.maintenance_interval_months) : null);
}
if (assetData.maintenance_notes !== undefined) {
fields.push('maintenance_notes = ?');
values.push(assetData.maintenance_notes || null);
}
if (assetData.department !== undefined) {
fields.push('department = ?');
values.push(assetData.department || 'IT');
}
if (assetData.inventory_number !== undefined) {
fields.push('inventory_number = ?');
values.push(assetData.inventory_number || null);
}
if (assetData.purchase_price !== undefined) {
fields.push('purchase_price = ?');
values.push(assetData.purchase_price ? parseFloat(assetData.purchase_price) : null);
}
if (assetData.useful_life_years !== undefined) {
fields.push('useful_life_years = ?');
values.push(assetData.useful_life_years ? parseInt(assetData.useful_life_years) : null);
}
if (assetData.residual_value !== undefined) {
fields.push('residual_value = ?');
values.push(assetData.residual_value ? parseFloat(assetData.residual_value) : 0);
}
if (assetData.os !== undefined) {
fields.push('os = ?');
values.push(assetData.os || null);
}
if (assetData.ip_address !== undefined) {
fields.push('ip_address = ?');
values.push(assetData.ip_address || null);
}
if (assetData.manufacturer !== undefined) {
fields.push('manufacturer = ?');
values.push(assetData.manufacturer || null);
}
if (assetData.last_agent_sync !== undefined) {
fields.push('last_agent_sync = ?');
values.push(assetData.last_agent_sync || null);
}
fields.push('updated_by_user_id = ?');
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(updatedByUserId, id);
const stmt = db.prepare(`
UPDATE assets
SET ${fields.join(', ')}
WHERE id = ?
`);
stmt.run(...values);
return this.getById(id);
}
/**
* Update only the status
*/
static updateStatus(id, status, updatedByUserId) {
const db = getDatabase();
const stmt = db.prepare(`
UPDATE assets
SET status = ?,
updated_by_user_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
stmt.run(status, updatedByUserId, id);
return this.getById(id);
}
/**
* Delete asset
*/
static delete(id) {
const db = getDatabase();
const stmt = db.prepare('DELETE FROM assets WHERE id = ?');
const result = stmt.run(id);
return result.changes > 0;
}
/**
* Get statistics
*/
static getStatistics({ departments } = {}) {
const db = getDatabase();
const dw = departments && departments.length
? `AND department IN (${departments.map(() => '?').join(',')})`
: '';
const dp = departments && departments.length ? departments : [];
const q = (sql) => db.prepare(sql).get(...dp);
return {
total: q(`SELECT COUNT(*) as v FROM assets WHERE 1=1 ${dw}`).v,
verfuegbar: q(`SELECT COUNT(*) as v FROM assets WHERE status = 'verfuegbar' ${dw}`).v,
zugewiesen: q(`SELECT COUNT(*) as v FROM assets WHERE status = 'zugewiesen' ${dw}`).v,
inaktiv: q(`SELECT COUNT(*) as v FROM assets WHERE status = 'inaktiv' ${dw}`).v,
beschaedigt:q(`SELECT COUNT(*) as v FROM assets WHERE status = 'beschaedigt' ${dw}`).v,
byType: {
notebook: q(`SELECT COUNT(*) as v FROM assets WHERE type = 'Notebook' ${dw}`).v,
monitor: q(`SELECT COUNT(*) as v FROM assets WHERE type = 'Monitor' ${dw}`).v,
headset: q(`SELECT COUNT(*) as v FROM assets WHERE type = 'Headset' ${dw}`).v,
other: q(`SELECT COUNT(*) as v FROM assets WHERE type = 'Other' ${dw}`).v,
}
};
}
}
module.exports = Asset;

View File

@@ -0,0 +1,203 @@
const { getDatabase } = require('../config/database');
class AssetAssignment {
/**
* Get all assignments with related information
*/
static getAll() {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
aa.*,
a.name as asset_name,
a.type as asset_type,
a.serial_number as asset_serial_number,
u.username as user_username,
u.email as user_email,
ab.username as assigned_by_username
FROM asset_assignments aa
INNER JOIN assets a ON aa.asset_id = a.id
INNER JOIN users u ON aa.user_id = u.id
INNER JOIN users ab ON aa.assigned_by_user_id = ab.id
ORDER BY aa.assigned_at DESC
`);
return stmt.all();
}
/**
* Get assignment by ID
*/
static getById(id) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
aa.*,
a.name as asset_name,
a.type as asset_type,
a.serial_number as asset_serial_number,
u.username as user_username,
u.email as user_email,
ab.username as assigned_by_username
FROM asset_assignments aa
INNER JOIN assets a ON aa.asset_id = a.id
INNER JOIN users u ON aa.user_id = u.id
INNER JOIN users ab ON aa.assigned_by_user_id = ab.id
WHERE aa.id = ?
`);
return stmt.get(id);
}
/**
* Get assignments for a specific asset
*/
static getByAssetId(assetId) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
aa.*,
u.username as user_username,
u.email as user_email,
ab.username as assigned_by_username
FROM asset_assignments aa
INNER JOIN users u ON aa.user_id = u.id
INNER JOIN users ab ON aa.assigned_by_user_id = ab.id
WHERE aa.asset_id = ?
ORDER BY aa.assigned_at DESC
`);
return stmt.all(assetId);
}
/**
* Get assignments for a specific user
*/
static getByUserId(userId) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
aa.*,
a.name as asset_name,
a.type as asset_type,
a.serial_number as asset_serial_number,
ab.username as assigned_by_username
FROM asset_assignments aa
INNER JOIN assets a ON aa.asset_id = a.id
INNER JOIN users ab ON aa.assigned_by_user_id = ab.id
WHERE aa.user_id = ?
ORDER BY aa.assigned_at DESC
`);
return stmt.all(userId);
}
/**
* Get current assignment for an asset (not returned yet)
*/
static getCurrentAssignment(assetId) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
aa.*,
u.username as user_username,
u.email as user_email,
ab.username as assigned_by_username
FROM asset_assignments aa
INNER JOIN users u ON aa.user_id = u.id
INNER JOIN users ab ON aa.assigned_by_user_id = ab.id
WHERE aa.asset_id = ?
AND aa.returned_at IS NULL
ORDER BY aa.assigned_at DESC
LIMIT 1
`);
return stmt.get(assetId);
}
/**
* Get active assignments for a user (not returned yet)
*/
static getActiveAssignmentsByUser(userId) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
aa.*,
a.name as asset_name,
a.type as asset_type,
a.serial_number as asset_serial_number,
ab.username as assigned_by_username
FROM asset_assignments aa
INNER JOIN assets a ON aa.asset_id = a.id
INNER JOIN users ab ON aa.assigned_by_user_id = ab.id
WHERE aa.user_id = ?
AND aa.returned_at IS NULL
ORDER BY aa.assigned_at DESC
`);
return stmt.all(userId);
}
/**
* Create a new assignment
*/
static create(assignmentData) {
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO asset_assignments (
asset_id,
user_id,
assigned_by_user_id,
notes,
onboarding_protocol_id
) VALUES (?, ?, ?, ?, ?)
`);
const result = stmt.run(
assignmentData.asset_id,
assignmentData.user_id,
assignmentData.assigned_by_user_id,
assignmentData.notes || null,
assignmentData.onboarding_protocol_id || null
);
return this.getById(result.lastInsertRowid);
}
/**
* Mark assignment as returned
*/
static markAsReturned(id, offboardingProtocolId = null) {
const db = getDatabase();
const stmt = db.prepare(`
UPDATE asset_assignments
SET returned_at = CURRENT_TIMESTAMP,
offboarding_protocol_id = ?
WHERE id = ?
`);
stmt.run(offboardingProtocolId, id);
return this.getById(id);
}
/**
* Update assignment notes
*/
static updateNotes(id, notes) {
const db = getDatabase();
const stmt = db.prepare(`
UPDATE asset_assignments
SET notes = ?
WHERE id = ?
`);
stmt.run(notes, id);
return this.getById(id);
}
/**
* Delete assignment
*/
static delete(id) {
const db = getDatabase();
const stmt = db.prepare('DELETE FROM asset_assignments WHERE id = ?');
const result = stmt.run(id);
return result.changes > 0;
}
}
module.exports = AssetAssignment;

View File

@@ -0,0 +1,73 @@
const { getDatabase } = require('../config/database');
class AssetInspection {
/**
* Create the asset_inspections table if it doesn't exist
*/
static migrate() {
const db = getDatabase();
db.prepare(`
CREATE TABLE IF NOT EXISTS asset_inspections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
inspected_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
inspection_date DATE NOT NULL,
result TEXT CHECK(result IN ('bestanden', 'nicht_bestanden')) NOT NULL,
notes TEXT,
next_due_date DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`).run();
}
/**
* Create a new inspection record
*/
static create({ asset_id, inspected_by_user_id, inspection_date, result, notes, next_due_date }) {
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO asset_inspections
(asset_id, inspected_by_user_id, inspection_date, result, notes, next_due_date)
VALUES
(?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(
asset_id,
inspected_by_user_id || null,
inspection_date,
result,
notes || null,
next_due_date || null
);
return this.getById(info.lastInsertRowid);
}
/**
* Get a single inspection by ID
*/
static getById(id) {
const db = getDatabase();
return db.prepare(`
SELECT ai.*, u.username AS inspector_name
FROM asset_inspections ai
LEFT JOIN users u ON ai.inspected_by_user_id = u.id
WHERE ai.id = ?
`).get(id);
}
/**
* Get all inspections for a given asset, newest first
*/
static getByAsset(assetId) {
const db = getDatabase();
return db.prepare(`
SELECT ai.*, u.username AS inspector_name
FROM asset_inspections ai
LEFT JOIN users u ON ai.inspected_by_user_id = u.id
WHERE ai.asset_id = ?
ORDER BY ai.inspection_date DESC, ai.created_at DESC
`).all(assetId);
}
}
module.exports = AssetInspection;

View File

@@ -0,0 +1,73 @@
const { getDatabase } = require('../config/database');
const AssetMovement = {
getAll({ limit = 200, asset_id } = {}) {
const db = getDatabase();
let q = `
SELECT m.*,
a.name AS asset_name, a.inventory_number, a.serial_number,
u.first_name || ' ' || u.last_name AS performed_by_name,
au.first_name || ' ' || au.last_name AS assigned_user_name,
fl.name AS from_location_name,
tl.name AS to_location_name
FROM asset_movements m
LEFT JOIN assets a ON m.asset_id = a.id
LEFT JOIN users u ON m.performed_by = u.id
LEFT JOIN users au ON m.assigned_user_id = au.id
LEFT JOIN warehouse_locations fl ON m.from_location_id = fl.id
LEFT JOIN warehouse_locations tl ON m.to_location_id = tl.id
`;
const params = [];
if (asset_id) {
q += ` WHERE m.asset_id = ?`;
params.push(asset_id);
}
q += ` ORDER BY m.created_at DESC LIMIT ?`;
params.push(limit);
return db.prepare(q).all(...params);
},
getById(id) {
const db = getDatabase();
return db.prepare(`
SELECT m.*,
a.name AS asset_name, a.inventory_number,
u.first_name || ' ' || u.last_name AS performed_by_name,
fl.name AS from_location_name,
tl.name AS to_location_name
FROM asset_movements m
LEFT JOIN assets a ON m.asset_id = a.id
LEFT JOIN users u ON m.performed_by = u.id
LEFT JOIN warehouse_locations fl ON m.from_location_id = fl.id
LEFT JOIN warehouse_locations tl ON m.to_location_id = tl.id
WHERE m.id = ?
`).get(id);
},
create(data) {
const db = getDatabase();
const result = db.prepare(`
INSERT INTO asset_movements
(asset_id, type, from_location_id, to_location_id, assigned_user_id, ticket_id, reason, notes, performed_by)
VALUES
(@asset_id, @type, @from_location_id, @to_location_id, @assigned_user_id, @ticket_id, @reason, @notes, @performed_by)
`).run({
asset_id: data.asset_id,
type: data.type,
from_location_id: data.from_location_id || null,
to_location_id: data.to_location_id || null,
assigned_user_id: data.assigned_user_id || null,
ticket_id: data.ticket_id || null,
reason: data.reason || '',
notes: data.notes || '',
performed_by: data.performed_by,
});
return this.getById(result.lastInsertRowid);
},
getRecentByAsset(asset_id, limit = 20) {
return this.getAll({ asset_id, limit });
},
};
module.exports = AssetMovement;

View File

@@ -0,0 +1,124 @@
const { getDatabase } = require('../config/database');
class AuditLog {
/**
* Create an audit log entry
*/
static create(logData) {
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO audit_log (
user_id,
action,
entity_type,
entity_id,
old_value,
new_value,
ip_address
) VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
logData.user_id,
logData.action,
logData.entity_type,
logData.entity_id,
logData.old_value ? JSON.stringify(logData.old_value) : null,
logData.new_value ? JSON.stringify(logData.new_value) : null,
logData.ip_address || null
);
return result.lastInsertRowid;
}
/**
* Get all audit logs with pagination
*/
static getAll(limit = 100, offset = 0) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
al.*,
u.username,
u.email
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
ORDER BY al.created_at DESC
LIMIT ? OFFSET ?
`);
return stmt.all(limit, offset);
}
/**
* Get audit logs for a specific user
*/
static getByUserId(userId, limit = 100, offset = 0) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
al.*,
u.username,
u.email
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
WHERE al.user_id = ?
ORDER BY al.created_at DESC
LIMIT ? OFFSET ?
`);
return stmt.all(userId, limit, offset);
}
/**
* Get audit logs for a specific entity
*/
static getByEntity(entityType, entityId, limit = 100, offset = 0) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
al.*,
u.username,
u.email
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
WHERE al.entity_type = ? AND al.entity_id = ?
ORDER BY al.created_at DESC
LIMIT ? OFFSET ?
`);
return stmt.all(entityType, entityId, limit, offset);
}
/**
* Get recent audit logs
*/
static getRecent(limit = 10) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
al.*,
u.username,
u.email
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
ORDER BY al.created_at DESC
LIMIT ?
`);
return stmt.all(limit);
}
/**
* Helper method to log user actions
*/
static logUserAction(userId, action, entityType, entityId, oldValue = null, newValue = null, ipAddress = null) {
return this.create({
user_id: userId,
action,
entity_type: entityType,
entity_id: entityId,
old_value: oldValue,
new_value: newValue,
ip_address: ipAddress
});
}
}
module.exports = AuditLog;

View File

@@ -0,0 +1,71 @@
const { getDatabase } = require('../config/database');
const DEFAULT_DESIGN = {
brand_name: 'CEREDA SYSTEMS',
brand_subtitle:'IT Support',
brand_icon: '💻',
logo_url: '',
primary_color: '#0d9488',
button_color: '#0d9488',
bg_color: '#f1f5f9',
company_name: 'Cereda Systems GmbH',
footer_text: 'Automatisch generiert von IT Nexus · Bitte nicht direkt antworten.',
};
const ALLOWED_FIELDS = Object.keys(DEFAULT_DESIGN);
class EmailDesign {
static migrate() {
const db = getDatabase();
db.exec(`
CREATE TABLE IF NOT EXISTS email_design (
id INTEGER PRIMARY KEY DEFAULT 1,
brand_name TEXT NOT NULL DEFAULT 'CEREDA SYSTEMS',
brand_subtitle TEXT NOT NULL DEFAULT 'IT Support',
brand_icon TEXT NOT NULL DEFAULT '💻',
logo_url TEXT NOT NULL DEFAULT '',
primary_color TEXT NOT NULL DEFAULT '#0d9488',
button_color TEXT NOT NULL DEFAULT '#0d9488',
bg_color TEXT NOT NULL DEFAULT '#f1f5f9',
company_name TEXT NOT NULL DEFAULT 'Cereda Systems GmbH',
footer_text TEXT NOT NULL DEFAULT 'Automatisch generiert von IT Nexus · Bitte nicht direkt antworten.'
);
`);
db.prepare('INSERT OR IGNORE INTO email_design (id) VALUES (1)').run();
}
static getDesign() {
try {
const row = getDatabase().prepare('SELECT * FROM email_design WHERE id = 1').get();
return row ? { ...DEFAULT_DESIGN, ...row } : { ...DEFAULT_DESIGN };
} catch (_) {
return { ...DEFAULT_DESIGN };
}
}
static updateDesign(fields) {
const updates = Object.entries(fields).filter(([k]) => ALLOWED_FIELDS.includes(k));
if (!updates.length) return EmailDesign.getDesign();
const db = getDatabase();
const sql = `UPDATE email_design SET ${updates.map(([k]) => `${k}=?`).join(',')} WHERE id=1`;
db.prepare(sql).run(...updates.map(([, v]) => v));
return EmailDesign.getDesign();
}
static reset() {
const db = getDatabase();
db.prepare(`
UPDATE email_design SET
brand_name=?, brand_subtitle=?, brand_icon=?, logo_url=?,
primary_color=?, button_color=?, bg_color=?, company_name=?, footer_text=?
WHERE id=1
`).run(
DEFAULT_DESIGN.brand_name, DEFAULT_DESIGN.brand_subtitle, DEFAULT_DESIGN.brand_icon,
DEFAULT_DESIGN.logo_url, DEFAULT_DESIGN.primary_color, DEFAULT_DESIGN.button_color,
DEFAULT_DESIGN.bg_color, DEFAULT_DESIGN.company_name, DEFAULT_DESIGN.footer_text
);
return EmailDesign.getDesign();
}
}
module.exports = { EmailDesign, DEFAULT_DESIGN };

View File

@@ -0,0 +1,91 @@
const { getDatabase } = require('../config/database');
const DEFAULTS = [
{
type: 'ticket_created',
label: 'Ticket-Bestätigung',
subject: '[{{ticket_number}}] Ticket erstellt: {{ticket_title}}',
intro: 'Danke, {{requester_name}}! Wir haben Ihre Anfrage erhalten und melden uns so schnell wie möglich.',
},
{
type: 'ticket_assigned',
label: 'Ticket-Zuweisung',
subject: '[{{ticket_number}}] Ticket zugewiesen: {{ticket_title}}',
intro: 'Hallo {{assignee_name}}, dir wurde ein neues Ticket zugewiesen. Bitte bearbeite es zeitnah.',
},
{
type: 'comment_added',
label: 'Neuer Kommentar',
subject: '[{{ticket_number}}] Neue Antwort: {{ticket_title}}',
intro: 'Das Support-Team hat auf Ihr Ticket {{ticket_number}} geantwortet.',
},
{
type: 'status_changed',
label: 'Statusänderung',
subject: '[{{ticket_number}}] Status: {{new_status}} {{ticket_title}}',
intro: 'Der Status Ihres Tickets {{ticket_number}} hat sich geändert.',
},
{
type: 'escalation',
label: 'Eskalation',
subject: '⚠️ Eskalation: {{ticket_number}} {{ticket_title}}',
intro: 'Ein Ticket wartet seit {{age_hours}} Stunden auf Bearbeitung und wurde noch nicht bearbeitet.',
},
{
type: 'satisfaction',
label: 'Zufriedenheits-Feedback',
subject: 'War Ihr Problem gelöst? Ticket {{ticket_number}}',
intro: 'Ihr Ticket {{ticket_number}} {{ticket_title}} wurde soeben geschlossen. War die Lösung hilfreich?',
},
];
class EmailTemplate {
static migrate() {
const db = getDatabase();
db.exec(`
CREATE TABLE IF NOT EXISTS email_templates (
type TEXT PRIMARY KEY,
label TEXT NOT NULL,
subject TEXT NOT NULL,
intro TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
const stmt = db.prepare(
'INSERT OR IGNORE INTO email_templates (type, label, subject, intro) VALUES (?, ?, ?, ?)'
);
for (const d of DEFAULTS) {
stmt.run(d.type, d.label, d.subject, d.intro);
}
}
static getAll() {
const db = getDatabase();
return db.prepare('SELECT * FROM email_templates ORDER BY type').all();
}
static getByType(type) {
const db = getDatabase();
return db.prepare('SELECT * FROM email_templates WHERE type = ?').get(type);
}
static update(type, { subject, intro }) {
const db = getDatabase();
db.prepare(
'UPDATE email_templates SET subject = ?, intro = ?, updated_at = CURRENT_TIMESTAMP WHERE type = ?'
).run(subject, intro, type);
return db.prepare('SELECT * FROM email_templates WHERE type = ?').get(type);
}
static reset(type) {
const d = DEFAULTS.find(x => x.type === type);
if (!d) return null;
const db = getDatabase();
db.prepare(
'UPDATE email_templates SET subject = ?, intro = ?, updated_at = CURRENT_TIMESTAMP WHERE type = ?'
).run(d.subject, d.intro, type);
return db.prepare('SELECT * FROM email_templates WHERE type = ?').get(type);
}
}
module.exports = { EmailTemplate, EMAIL_TEMPLATE_DEFAULTS: DEFAULTS };

View File

@@ -0,0 +1,82 @@
const { getDatabase } = require('../config/database');
const ExternalAlert = {
getAll({ limit = 100, acknowledged } = {}) {
const db = getDatabase();
let q = `SELECT * FROM external_alerts`;
const params = [];
if (acknowledged !== undefined) {
q += ` WHERE acknowledged = ?`;
params.push(acknowledged ? 1 : 0);
}
q += ` ORDER BY created_at DESC LIMIT ?`;
params.push(limit);
return db.prepare(q).all(...params);
},
getById(id) {
const db = getDatabase();
return db.prepare(`SELECT * FROM external_alerts WHERE id = ?`).get(id);
},
getByEmailMessageId(messageId) {
const db = getDatabase();
return db.prepare(`SELECT id FROM external_alerts WHERE email_message_id = ?`).get(messageId);
},
create(data) {
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO external_alerts
(source, device, service, state_transition, severity, message, customer, monitored_by, state_time, raw_body, email_message_id)
VALUES
(@source, @device, @service, @state_transition, @severity, @message, @customer, @monitored_by, @state_time, @raw_body, @email_message_id)
`);
const result = stmt.run({
source: data.source || 'netgo',
device: data.device || '',
service: data.service || '',
state_transition: data.state_transition || '',
severity: data.severity || 'UNKNOWN',
message: data.message || '',
customer: data.customer || '',
monitored_by: data.monitored_by || '',
state_time: data.state_time || '',
raw_body: data.raw_body || '',
email_message_id: data.email_message_id || null,
});
return this.getById(result.lastInsertRowid);
},
setAiAnalysis(id, analysis) {
const db = getDatabase();
db.prepare(`UPDATE external_alerts SET ai_analysis = ? WHERE id = ?`).run(
typeof analysis === 'string' ? analysis : JSON.stringify(analysis),
id
);
},
acknowledge(id) {
const db = getDatabase();
db.prepare(`UPDATE external_alerts SET acknowledged = 1 WHERE id = ?`).run(id);
return this.getById(id);
},
setTicket(id, ticketId) {
const db = getDatabase();
db.prepare(`UPDATE external_alerts SET ticket_id = ?, acknowledged = 1 WHERE id = ?`).run(ticketId, id);
return this.getById(id);
},
delete(id) {
const db = getDatabase();
db.prepare(`DELETE FROM external_alerts WHERE id = ?`).run(id);
},
getUnacknowledgedCount() {
const db = getDatabase();
return db.prepare(`SELECT COUNT(*) as count FROM external_alerts WHERE acknowledged = 0`).get().count;
},
};
module.exports = ExternalAlert;

View File

@@ -0,0 +1,219 @@
const { getDatabase } = require('../config/database');
class FidoKey {
/**
* Get all FIDO keys with related user information
*/
static getAll() {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
fk.*,
au.username as assigned_to_username,
au.email as assigned_to_email,
cu.username as created_by_username,
uu.username as updated_by_username
FROM fido_keys fk
LEFT JOIN users au ON fk.assigned_to_user_id = au.id
LEFT JOIN users cu ON fk.created_by_user_id = cu.id
LEFT JOIN users uu ON fk.updated_by_user_id = uu.id
ORDER BY fk.created_at DESC
`);
return stmt.all();
}
/**
* Get FIDO key by ID
*/
static getById(id) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
fk.*,
au.username as assigned_to_username,
au.email as assigned_to_email,
cu.username as created_by_username,
uu.username as updated_by_username
FROM fido_keys fk
LEFT JOIN users au ON fk.assigned_to_user_id = au.id
LEFT JOIN users cu ON fk.created_by_user_id = cu.id
LEFT JOIN users uu ON fk.updated_by_user_id = uu.id
WHERE fk.id = ?
`);
return stmt.get(id);
}
/**
* Get FIDO key by serial number
*/
static getBySerialNumber(serialNumber) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
fk.*,
au.username as assigned_to_username,
cu.username as created_by_username
FROM fido_keys fk
LEFT JOIN users au ON fk.assigned_to_user_id = au.id
LEFT JOIN users cu ON fk.created_by_user_id = cu.id
WHERE fk.serial_number = ?
`);
return stmt.get(serialNumber);
}
/**
* Get FIDO keys by status
*/
static getByStatus(status) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
fk.*,
au.username as assigned_to_username,
cu.username as created_by_username
FROM fido_keys fk
LEFT JOIN users au ON fk.assigned_to_user_id = au.id
LEFT JOIN users cu ON fk.created_by_user_id = cu.id
WHERE fk.status = ?
ORDER BY fk.created_at DESC
`);
return stmt.all(status);
}
/**
* Get FIDO keys assigned to a specific user
*/
static getByAssignedUser(userId) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
fk.*,
cu.username as created_by_username
FROM fido_keys fk
LEFT JOIN users cu ON fk.created_by_user_id = cu.id
WHERE fk.assigned_to_user_id = ?
ORDER BY fk.created_at DESC
`);
return stmt.all(userId);
}
/**
* Create a new FIDO key
*/
static create(keyData) {
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO fido_keys (
name,
serial_number,
status,
description,
assigned_to_user_id,
created_by_user_id
) VALUES (?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
keyData.name,
keyData.serial_number,
keyData.status,
keyData.description || null,
keyData.assigned_to_user_id || null,
keyData.created_by_user_id
);
return this.getById(result.lastInsertRowid);
}
/**
* Update FIDO key
*/
static update(id, keyData, updatedByUserId) {
const db = getDatabase();
const fields = [];
const values = [];
if (keyData.name !== undefined) {
fields.push('name = ?');
values.push(keyData.name);
}
if (keyData.serial_number !== undefined) {
fields.push('serial_number = ?');
values.push(keyData.serial_number);
}
if (keyData.status !== undefined) {
fields.push('status = ?');
values.push(keyData.status);
}
if (keyData.description !== undefined) {
fields.push('description = ?');
values.push(keyData.description);
}
if (keyData.assigned_to_user_id !== undefined) {
fields.push('assigned_to_user_id = ?');
values.push(keyData.assigned_to_user_id);
}
fields.push('updated_by_user_id = ?');
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(updatedByUserId, id);
const stmt = db.prepare(`
UPDATE fido_keys
SET ${fields.join(', ')}
WHERE id = ?
`);
stmt.run(...values);
return this.getById(id);
}
/**
* Update only the status
*/
static updateStatus(id, status, updatedByUserId) {
const db = getDatabase();
const stmt = db.prepare(`
UPDATE fido_keys
SET status = ?,
updated_by_user_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
stmt.run(status, updatedByUserId, id);
return this.getById(id);
}
/**
* Delete FIDO key
*/
static delete(id) {
const db = getDatabase();
const stmt = db.prepare('DELETE FROM fido_keys WHERE id = ?');
const result = stmt.run(id);
return result.changes > 0;
}
/**
* Get statistics
*/
static getStatistics() {
const db = getDatabase();
const totalStmt = db.prepare('SELECT COUNT(*) as total FROM fido_keys');
const activeStmt = db.prepare("SELECT COUNT(*) as active FROM fido_keys WHERE status = 'aktiv'");
const inactiveStmt = db.prepare("SELECT COUNT(*) as inactive FROM fido_keys WHERE status = 'inaktiv'");
const assignedStmt = db.prepare('SELECT COUNT(*) as assigned FROM fido_keys WHERE assigned_to_user_id IS NOT NULL');
return {
total: totalStmt.get().total,
active: activeStmt.get().active,
inactive: inactiveStmt.get().inactive,
assigned: assignedStmt.get().assigned
};
}
}
module.exports = FidoKey;

View File

@@ -0,0 +1,105 @@
const { getDatabase } = require('../config/database');
const CATEGORIES = [
'Risikomanagement',
'TOM',
'Incident Management',
'Business Continuity',
'Dokumentation',
'Awareness & Schulung',
];
const DEFAULT_TASKS = [
{ category: 'Risikomanagement', title: 'IT-Assets erfassen', description: 'Alle relevanten Systeme, Server, Cloud-Dienste, Schnittstellen und kritischen Prozesse inventarisieren.' },
{ category: 'Risikomanagement', title: 'Risikoanalyse durchführen', description: 'Bedrohungen und Schwachstellen identifizieren und nach Eintrittswahrscheinlichkeit/Schaden bewerten.' },
{ category: 'Risikomanagement', title: 'Maßnahmenplan ableiten', description: 'Für priorisierte Risiken konkrete technische oder organisatorische Maßnahmen definieren und terminieren.' },
{ category: 'TOM', title: 'Zugriffskonzept erstellen', description: 'Regeln, wer auf welche Systeme zugreifen darf (Least Privilege Prinzip) und regelmäßige Rechteprüfung einführen.' },
{ category: 'TOM', title: 'Patchmanagement regeln', description: 'Prozess definieren, wie Sicherheitsupdates erkannt, bewertet und zeitnah eingespielt werden.' },
{ category: 'TOM', title: 'Backup-Konzept implementieren', description: 'Regelmäßige Datensicherung durchführen und mindestens jährlich Wiederherstellung testen.' },
{ category: 'TOM', title: 'Monitoring einführen', description: 'Sicherheitsrelevante Ereignisse protokollieren und automatisiert überwachen (z. B. SOC/SIEM).' },
{ category: 'Incident Management', title: 'Incident-Response-Plan erstellen', description: 'Definieren, wie Sicherheitsvorfälle erkannt, bewertet, eskaliert und dokumentiert werden.' },
{ category: 'Incident Management', title: 'Meldeprozess definieren (24h/72h)', description: 'Interne Abläufe festlegen, um Fristen für Erstmeldung und Detailmeldung einhalten zu können.' },
{ category: 'Incident Management', title: 'Test durchführen', description: 'Einmal jährlich eine simulierte Cyberattacke als Übung durchführen.' },
{ category: 'Business Continuity', title: 'Kritische Prozesse identifizieren', description: 'Festlegen, welche Geschäftsprozesse bei IT-Ausfall zuerst wiederhergestellt werden müssen.' },
{ category: 'Business Continuity', title: 'Wiederanlaufplan definieren', description: 'Schriftlich festhalten, wie Systeme und Betrieb im Notfall wieder hochgefahren werden.' },
{ category: 'Dokumentation', title: 'Sicherheitsrichtlinie erstellen', description: 'Grundlegende IT-Sicherheitsleitlinie verabschieden und veröffentlichen.' },
{ category: 'Dokumentation', title: 'Nachweisführung etablieren', description: 'Alle Maßnahmen, Schulungen, Prüfungen dokumentieren und versionieren.' },
{ category: 'Awareness & Schulung', title: 'Mitarbeiterschulung durchführen', description: 'Regelmäßige Schulungen zu Phishing, Passwortsicherheit und Umgang mit IT-Vorfällen durchführen.' },
{ category: 'Awareness & Schulung', title: 'Onboarding-Prozess erweitern', description: 'Informationssicherheitsregeln verpflichtend ins Onboarding aufnehmen.' },
];
class IsoTask {
static migrate() {
const db = getDatabase();
db.exec(`
CREATE TABLE IF NOT EXISTS iso_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'Offen'
CHECK(status IN ('Offen','In Bearbeitung','Abgeschlossen')),
responsible TEXT,
notes TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Seed if empty
const count = db.prepare('SELECT COUNT(*) as c FROM iso_tasks').get().c;
if (count === 0) {
const stmt = db.prepare(
'INSERT INTO iso_tasks (category, title, description, sort_order) VALUES (?, ?, ?, ?)'
);
DEFAULT_TASKS.forEach((t, i) => stmt.run(t.category, t.title, t.description, i));
}
}
static getAll({ category, status } = {}) {
const db = getDatabase();
const where = [];
const vals = [];
if (category) { where.push('category = ?'); vals.push(category); }
if (status) { where.push('status = ?'); vals.push(status); }
return db.prepare(
`SELECT * FROM iso_tasks ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY sort_order, id`
).all(...vals);
}
static getById(id) {
return getDatabase().prepare('SELECT * FROM iso_tasks WHERE id = ?').get(id);
}
static create({ category, title, description, status = 'Offen', responsible, notes, sort_order = 0 }) {
const db = getDatabase();
const r = db.prepare(
'INSERT INTO iso_tasks (category, title, description, status, responsible, notes, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(category, title, description ?? null, status, responsible ?? null, notes ?? null, sort_order);
return this.getById(r.lastInsertRowid);
}
static update(id, fields) {
const db = getDatabase();
const allowed = ['category', 'title', 'description', 'status', 'responsible', 'notes', 'sort_order'];
const setClauses = [];
const vals = [];
for (const key of allowed) {
if (fields[key] !== undefined) {
setClauses.push(`${key} = ?`);
vals.push(fields[key]);
}
}
if (!setClauses.length) return this.getById(id);
setClauses.push("updated_at = datetime('now')");
vals.push(id);
db.prepare(`UPDATE iso_tasks SET ${setClauses.join(', ')} WHERE id = ?`).run(...vals);
return this.getById(id);
}
static delete(id) {
return getDatabase().prepare('DELETE FROM iso_tasks WHERE id = ?').run(id).changes > 0;
}
}
module.exports = IsoTask;

View File

@@ -0,0 +1,81 @@
const { getDatabase } = require('../config/database');
class ItTopic {
static migrate() {
const db = getDatabase();
db.exec(`
CREATE TABLE IF NOT EXISTS it_topics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'Infrastruktur & Betrieb'
CHECK(category IN ('Infrastruktur & Betrieb','Benutzer & Arbeitsplätze','Systeme','IT-Governance & Compliance','Archiv')),
status TEXT NOT NULL DEFAULT 'Offen'
CHECK(status IN ('Offen','In Planung','In Umsetzung','Abgeschlossen')),
priority TEXT NOT NULL DEFAULT 'Normal'
CHECK(priority IN ('Niedrig','Normal','Hoch','Kritisch')),
responsible TEXT,
target_date TEXT,
description TEXT,
notes TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
planner_task_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Migration: add planner_task_id to existing tables
try { db.exec(`ALTER TABLE it_topics ADD COLUMN planner_task_id TEXT`); } catch {}
}
static getAll({ category, status } = {}) {
const db = getDatabase();
const where = [];
const vals = [];
if (category) { where.push('category = ?'); vals.push(category); }
if (status) { where.push('status = ?'); vals.push(status); }
const sql = `
SELECT * FROM it_topics
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY sort_order, created_at DESC
`;
return db.prepare(sql).all(...vals);
}
static getById(id) {
const db = getDatabase();
return db.prepare('SELECT * FROM it_topics WHERE id = ?').get(id);
}
static create({ title, category = 'Infrastruktur & Betrieb', status = 'Offen', priority = 'Normal', responsible, target_date, description, notes, sort_order = 0 }) {
const db = getDatabase();
const r = db.prepare(
'INSERT INTO it_topics (title, category, status, priority, responsible, target_date, description, notes, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(title, category, status, priority, responsible ?? null, target_date ?? null, description ?? null, notes ?? null, sort_order);
return this.getById(r.lastInsertRowid);
}
static update(id, fields) {
const db = getDatabase();
const allowed = ['title','category','status','priority','responsible','target_date','description','notes','sort_order','planner_task_id'];
const setClauses = [];
const vals = [];
for (const key of allowed) {
if (fields[key] !== undefined) {
setClauses.push(`${key} = ?`);
vals.push(fields[key]);
}
}
if (!setClauses.length) return this.getById(id);
setClauses.push("updated_at = datetime('now')");
vals.push(id);
db.prepare(`UPDATE it_topics SET ${setClauses.join(', ')} WHERE id = ?`).run(...vals);
return this.getById(id);
}
static delete(id) {
const db = getDatabase();
return db.prepare('DELETE FROM it_topics WHERE id = ?').run(id).changes > 0;
}
}
module.exports = ItTopic;

View File

@@ -0,0 +1,98 @@
const { getDatabase } = require('../config/database');
class License {
static getAll() {
const db = getDatabase();
const stmt = db.prepare(`
SELECT l.*, u.username as created_by_username
FROM licenses l
LEFT JOIN users u ON l.created_by_user_id = u.id
ORDER BY l.name ASC
`);
return stmt.all();
}
static getById(id) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT l.*, u.username as created_by_username
FROM licenses l
LEFT JOIN users u ON l.created_by_user_id = u.id
WHERE l.id = ?
`);
return stmt.get(id);
}
static create(data) {
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO licenses (
name, vendor, license_type, product_key,
seats, purchase_date, expiry_date, cost, notes,
sku_id, created_by_user_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
data.name,
data.vendor || null,
data.license_type || 'subscription',
data.product_key || null,
data.seats ? parseInt(data.seats) : null,
data.purchase_date || null,
data.expiry_date || null,
data.cost ? parseFloat(data.cost) : null,
data.notes || null,
data.sku_id || null,
data.created_by_user_id
);
return this.getById(result.lastInsertRowid);
}
static update(id, data) {
const db = getDatabase();
const fields = [];
const values = [];
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
if (data.vendor !== undefined) { fields.push('vendor = ?'); values.push(data.vendor || null); }
if (data.license_type !== undefined) { fields.push('license_type = ?'); values.push(data.license_type); }
if (data.product_key !== undefined) { fields.push('product_key = ?'); values.push(data.product_key || null); }
if (data.seats !== undefined) { fields.push('seats = ?'); values.push(data.seats ? parseInt(data.seats) : null); }
if (data.purchase_date !== undefined) { fields.push('purchase_date = ?'); values.push(data.purchase_date || null); }
if (data.expiry_date !== undefined) { fields.push('expiry_date = ?'); values.push(data.expiry_date || null); }
if (data.cost !== undefined) { fields.push('cost = ?'); values.push(data.cost ? parseFloat(data.cost) : null); }
if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); }
if (data.sku_id !== undefined) { fields.push('sku_id = ?'); values.push(data.sku_id || null); }
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
const stmt = db.prepare(`UPDATE licenses SET ${fields.join(', ')} WHERE id = ?`);
stmt.run(...values);
return this.getById(id);
}
static delete(id) {
const db = getDatabase();
const result = db.prepare('DELETE FROM licenses WHERE id = ?').run(id);
return result.changes > 0;
}
static getStatistics() {
const db = getDatabase();
const today = new Date().toISOString().slice(0, 10);
const in30 = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
const total = db.prepare('SELECT COUNT(*) as count FROM licenses').get().count;
const expired = db.prepare(
`SELECT COUNT(*) as count FROM licenses WHERE expiry_date IS NOT NULL AND expiry_date < ?`
).get(today).count;
const expiringSoon = db.prepare(
`SELECT COUNT(*) as count FROM licenses WHERE expiry_date IS NOT NULL AND expiry_date >= ? AND expiry_date <= ?`
).get(today, in30).count;
return { total, expired, expiringSoon };
}
}
module.exports = License;

View File

@@ -0,0 +1,138 @@
const { getDatabase } = require('../config/database');
class MonitoringAgent {
static getAll() {
const db = getDatabase();
return db.prepare(`
SELECT * FROM monitoring_agents ORDER BY hostname ASC
`).all();
}
static getById(id) {
const db = getDatabase();
return db.prepare('SELECT * FROM monitoring_agents WHERE id = ?').get(id);
}
static getByHostname(hostname) {
const db = getDatabase();
return db.prepare('SELECT * FROM monitoring_agents WHERE hostname = ?').get(hostname);
}
static upsert(data) {
const db = getDatabase();
const existing = this.getByHostname(data.hostname);
if (existing) {
db.prepare(`
UPDATE monitoring_agents SET
ip_address = ?, mac_address = ?, os_name = ?, os_version = ?,
cpu_model = ?, cpu_cores = ?, cpu_usage_percent = ?,
ram_total_gb = ?, ram_used_gb = ?,
disk_total_gb = ?, disk_free_gb = ?,
last_user = ?, uptime_hours = ?, domain = ?,
agent_version = ?, installed_software = ?,
windows_updates_pending = ?,
tpm_present = ?, tpm_version = ?, tpm_v2 = ?,
secure_boot = ?, win11_ready = ?,
bitlocker_status = ?, defender_enabled = ?,
defender_signatures_age = ?, hardware_serial = ?,
status = 'online',
last_checkin = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE hostname = ?
`).run(
data.ip_address || null,
data.mac_address || null,
data.os_name || data.os || null,
data.os_version || null,
data.cpu_model || null,
data.cpu_cores || null,
data.cpu_usage_percent != null ? parseFloat(data.cpu_usage_percent) : (data.cpu_usage != null ? parseFloat(data.cpu_usage) : null),
data.ram_total_gb != null ? parseFloat(data.ram_total_gb) : (data.ram_total != null ? parseFloat(data.ram_total) : null),
data.ram_used_gb != null ? parseFloat(data.ram_used_gb) : (data.ram_used != null ? parseFloat(data.ram_used) : null),
data.disk_total_gb != null ? parseFloat(data.disk_total_gb) : (data.disk_total != null ? parseFloat(data.disk_total) : null),
data.disk_free_gb != null ? parseFloat(data.disk_free_gb) : (data.disk_free != null ? parseFloat(data.disk_free) : null),
data.last_user || null,
data.uptime_hours != null ? parseFloat(data.uptime_hours) : null,
data.domain || null,
data.agent_version || null,
data.installed_software ? JSON.stringify(data.installed_software) : null,
data.windows_updates_pending != null ? parseInt(data.windows_updates_pending) : (data.pending_updates != null ? parseInt(data.pending_updates) : 0),
data.tpm_present ? 1 : 0, data.tpm_version || null, data.tpm_v2 ? 1 : 0,
data.secure_boot ? 1 : 0, data.win11_ready ? 1 : 0,
data.bitlocker_status || 'unknown',
data.defender_enabled ? 1 : 0,
data.defender_signatures_age != null ? parseInt(data.defender_signatures_age) : -1,
data.hardware_serial || null,
data.hostname
);
return this.getByHostname(data.hostname);
} else {
const result = db.prepare(`
INSERT INTO monitoring_agents (
hostname, ip_address, mac_address, os_name, os_version,
cpu_model, cpu_cores, cpu_usage_percent,
ram_total_gb, ram_used_gb, disk_total_gb, disk_free_gb,
last_user, uptime_hours, domain, agent_version,
installed_software, windows_updates_pending,
tpm_present, tpm_version, tpm_v2, secure_boot, win11_ready,
bitlocker_status, defender_enabled, defender_signatures_age, hardware_serial
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
data.hostname,
data.ip_address || null,
data.mac_address || null,
data.os_name || data.os || null,
data.os_version || null,
data.cpu_model || null,
data.cpu_cores || null,
data.cpu_usage_percent != null ? parseFloat(data.cpu_usage_percent) : (data.cpu_usage != null ? parseFloat(data.cpu_usage) : null),
data.ram_total_gb != null ? parseFloat(data.ram_total_gb) : (data.ram_total != null ? parseFloat(data.ram_total) : null),
data.ram_used_gb != null ? parseFloat(data.ram_used_gb) : (data.ram_used != null ? parseFloat(data.ram_used) : null),
data.disk_total_gb != null ? parseFloat(data.disk_total_gb) : (data.disk_total != null ? parseFloat(data.disk_total) : null),
data.disk_free_gb != null ? parseFloat(data.disk_free_gb) : (data.disk_free != null ? parseFloat(data.disk_free) : null),
data.last_user || null,
data.uptime_hours != null ? parseFloat(data.uptime_hours) : null,
data.domain || null,
data.agent_version || null,
data.installed_software ? JSON.stringify(data.installed_software) : null,
data.windows_updates_pending != null ? parseInt(data.windows_updates_pending) : (data.pending_updates != null ? parseInt(data.pending_updates) : 0),
data.tpm_present ? 1 : 0, data.tpm_version || null, data.tpm_v2 ? 1 : 0,
data.secure_boot ? 1 : 0, data.win11_ready ? 1 : 0,
data.bitlocker_status || 'unknown',
data.defender_enabled ? 1 : 0,
data.defender_signatures_age != null ? parseInt(data.defender_signatures_age) : -1,
data.hardware_serial || null
);
return this.getById(result.lastInsertRowid);
}
}
static markOffline() {
const db = getDatabase();
// Mark agents offline if no checkin in last 15 minutes
db.prepare(`
UPDATE monitoring_agents
SET status = 'offline'
WHERE status = 'online'
AND last_checkin < datetime('now', '-15 minutes')
`).run();
}
static delete(id) {
const db = getDatabase();
return db.prepare('DELETE FROM monitoring_agents WHERE id = ?').run(id).changes > 0;
}
static getStatistics() {
const db = getDatabase();
const total = db.prepare('SELECT COUNT(*) as c FROM monitoring_agents').get().c;
const online = db.prepare("SELECT COUNT(*) as c FROM monitoring_agents WHERE status = 'online'").get().c;
const offline = db.prepare("SELECT COUNT(*) as c FROM monitoring_agents WHERE status = 'offline'").get().c;
const highCpu = db.prepare("SELECT COUNT(*) as c FROM monitoring_agents WHERE cpu_usage_percent > 80 AND status = 'online'").get().c;
const lowDisk = db.prepare("SELECT COUNT(*) as c FROM monitoring_agents WHERE disk_free_gb < 10 AND status = 'online'").get().c;
return { total, online, offline, highCpu, lowDisk };
}
}
module.exports = MonitoringAgent;

View File

@@ -0,0 +1,105 @@
const { getDatabase } = require('../config/database');
class NetworkDevice {
static getAll() {
const db = getDatabase();
return db.prepare(`SELECT * FROM network_devices ORDER BY type, name`).all();
}
static getById(id) {
const db = getDatabase();
return db.prepare(`SELECT * FROM network_devices WHERE id = ?`).get(id);
}
static getEnabled() {
const db = getDatabase();
return db.prepare(`SELECT * FROM network_devices WHERE enabled = 1`).all();
}
static create(data) {
const db = getDatabase();
const result = db.prepare(`
INSERT INTO network_devices (name, type, host, check_type, port, http_path, http_keyword,
snmp_community, snmp_version, interval_sec, timeout_sec, enabled, notify_email, location)
VALUES (@name, @type, @host, @check_type, @port, @http_path, @http_keyword,
@snmp_community, @snmp_version, @interval_sec, @timeout_sec, @enabled, @notify_email, @location)
`).run(data);
return this.getById(result.lastInsertRowid);
}
static update(id, data) {
const db = getDatabase();
db.prepare(`
UPDATE network_devices SET
name = @name, type = @type, host = @host, check_type = @check_type,
port = @port, http_path = @http_path, http_keyword = @http_keyword,
snmp_community = @snmp_community, snmp_version = @snmp_version,
interval_sec = @interval_sec, timeout_sec = @timeout_sec,
enabled = @enabled, notify_email = @notify_email, location = @location
WHERE id = @id
`).run({ ...data, id });
return this.getById(id);
}
static updateStatus(id, status, rtt_ms) {
const db = getDatabase();
db.prepare(`
UPDATE network_devices SET last_status = ?, last_checked = datetime('now'), last_rtt_ms = ?
WHERE id = ?
`).run(status, rtt_ms ?? null, id);
}
static delete(id) {
const db = getDatabase();
const result = db.prepare(`DELETE FROM network_devices WHERE id = ?`).run(id);
return result.changes > 0;
}
static addCheck(deviceId, status, rtt_ms, error_msg) {
const db = getDatabase();
db.prepare(`
INSERT INTO device_checks (device_id, status, rtt_ms, error_msg)
VALUES (?, ?, ?, ?)
`).run(deviceId, status, rtt_ms ?? null, error_msg ?? null);
}
static getChecks(deviceId, hoursBack = 24) {
const db = getDatabase();
return db.prepare(`
SELECT status, rtt_ms, error_msg, checked_at
FROM device_checks
WHERE device_id = ? AND checked_at >= datetime('now', '-' || ? || ' hours')
ORDER BY checked_at ASC
`).all(deviceId, hoursBack);
}
static getUptimeStats(deviceId, hoursBack = 24) {
const db = getDatabase();
const row = db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as up_count
FROM device_checks
WHERE device_id = ? AND checked_at >= datetime('now', '-' || ? || ' hours')
`).get(deviceId, hoursBack);
const pct = row.total > 0 ? Math.round((row.up_count / row.total) * 100 * 10) / 10 : null;
return { up: row.up_count, total: row.total, pct };
}
static getStatistics() {
const db = getDatabase();
const all = db.prepare(`SELECT last_status FROM network_devices`).all();
const total = all.length;
const up = all.filter(d => d.last_status === 'up').length;
const down = all.filter(d => d.last_status === 'down').length;
const unknown = all.filter(d => d.last_status === 'unknown').length;
return { total, up, down, unknown };
}
static cleanupOldChecks() {
const db = getDatabase();
db.prepare(`DELETE FROM device_checks WHERE checked_at < datetime('now', '-7 days')`).run();
}
}
module.exports = NetworkDevice;

View File

@@ -0,0 +1,233 @@
const { getDatabase } = require('../config/database');
class OffboardingProtocol {
/**
* Get all offboarding protocols with related information
*/
static getAll() {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
op.*,
e.username as employee_username,
e.first_name as employee_first_name,
e.last_name as employee_last_name,
e.email as employee_email,
r.name as employee_role_name,
cu.username as created_by_username,
uu.username as updated_by_username
FROM offboarding_protocols op
INNER JOIN users e ON op.employee_user_id = e.id
INNER JOIN roles r ON e.role_id = r.id
LEFT JOIN users cu ON op.created_by_user_id = cu.id
LEFT JOIN users uu ON op.updated_by_user_id = uu.id
ORDER BY op.created_at DESC
`);
return stmt.all();
}
/**
* Get offboarding protocol by ID
*/
static getById(id) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
op.*,
e.username as employee_username,
e.first_name as employee_first_name,
e.last_name as employee_last_name,
e.email as employee_email,
r.name as employee_role_name,
cu.username as created_by_username,
uu.username as updated_by_username
FROM offboarding_protocols op
INNER JOIN users e ON op.employee_user_id = e.id
INNER JOIN roles r ON e.role_id = r.id
LEFT JOIN users cu ON op.created_by_user_id = cu.id
LEFT JOIN users uu ON op.updated_by_user_id = uu.id
WHERE op.id = ?
`);
return stmt.get(id);
}
/**
* Get offboarding protocols by status
*/
static getByStatus(status) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
op.*,
e.username as employee_username,
e.first_name as employee_first_name,
e.last_name as employee_last_name,
e.email as employee_email,
cu.username as created_by_username
FROM offboarding_protocols op
INNER JOIN users e ON op.employee_user_id = e.id
LEFT JOIN users cu ON op.created_by_user_id = cu.id
WHERE op.status = ?
ORDER BY op.created_at DESC
`);
return stmt.all(status);
}
/**
* Get offboarding protocols for an employee
*/
static getByEmployee(employeeUserId) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
op.*,
cu.username as created_by_username
FROM offboarding_protocols op
LEFT JOIN users cu ON op.created_by_user_id = cu.id
WHERE op.employee_user_id = ?
ORDER BY op.created_at DESC
`);
return stmt.all(employeeUserId);
}
/**
* Create a new offboarding protocol
*/
static create(protocolData) {
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO offboarding_protocols (
employee_user_id,
status,
exit_date,
checklist_data,
notes,
created_by_user_id
) VALUES (?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
protocolData.employee_user_id,
protocolData.status,
protocolData.exit_date,
protocolData.checklist_data || null,
protocolData.notes || null,
protocolData.created_by_user_id
);
return this.getById(result.lastInsertRowid);
}
/**
* Update offboarding protocol
*/
static update(id, protocolData, updatedByUserId) {
const db = getDatabase();
const fields = [];
const values = [];
if (protocolData.status !== undefined) {
fields.push('status = ?');
values.push(protocolData.status);
}
if (protocolData.exit_date !== undefined) {
fields.push('exit_date = ?');
values.push(protocolData.exit_date);
}
if (protocolData.completion_date !== undefined) {
fields.push('completion_date = ?');
values.push(protocolData.completion_date);
}
if (protocolData.checklist_data !== undefined) {
fields.push('checklist_data = ?');
values.push(protocolData.checklist_data);
}
if (protocolData.notes !== undefined) {
fields.push('notes = ?');
values.push(protocolData.notes);
}
if (protocolData.pdf_file_path !== undefined) {
fields.push('pdf_file_path = ?');
values.push(protocolData.pdf_file_path);
}
fields.push('updated_by_user_id = ?');
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(updatedByUserId, id);
const stmt = db.prepare(`
UPDATE offboarding_protocols
SET ${fields.join(', ')}
WHERE id = ?
`);
stmt.run(...values);
return this.getById(id);
}
/**
* Update checklist data
*/
static updateChecklist(id, checklistData, updatedByUserId) {
const db = getDatabase();
const stmt = db.prepare(`
UPDATE offboarding_protocols
SET checklist_data = ?,
updated_by_user_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
stmt.run(checklistData, updatedByUserId, id);
return this.getById(id);
}
/**
* Update PDF file path
*/
static updatePdfPath(id, pdfFilePath, updatedByUserId) {
const db = getDatabase();
const stmt = db.prepare(`
UPDATE offboarding_protocols
SET pdf_file_path = ?,
updated_by_user_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
stmt.run(pdfFilePath, updatedByUserId, id);
return this.getById(id);
}
/**
* Delete offboarding protocol
*/
static delete(id) {
const db = getDatabase();
const stmt = db.prepare('DELETE FROM offboarding_protocols WHERE id = ?');
const result = stmt.run(id);
return result.changes > 0;
}
/**
* Get statistics
*/
static getStatistics() {
const db = getDatabase();
const totalStmt = db.prepare('SELECT COUNT(*) as total FROM offboarding_protocols');
const pendingStmt = db.prepare("SELECT COUNT(*) as pending FROM offboarding_protocols WHERE status = 'pending'");
const inProgressStmt = db.prepare("SELECT COUNT(*) as in_progress FROM offboarding_protocols WHERE status = 'in_progress'");
const completedStmt = db.prepare("SELECT COUNT(*) as completed FROM offboarding_protocols WHERE status = 'completed'");
return {
total: totalStmt.get().total,
pending: pendingStmt.get().pending,
in_progress: inProgressStmt.get().in_progress,
completed: completedStmt.get().completed
};
}
}
module.exports = OffboardingProtocol;

View File

@@ -0,0 +1,257 @@
const { getDatabase } = require('../config/database');
class OnboardingProcess {
// ── Migration ────────────────────────────────────────────────────────────
static migrate() {
const db = getDatabase();
// Detect old schema (IT / HR / Buchhaltung as dept names) → reset
try {
const depts = db.prepare('SELECT name FROM onboarding_departments ORDER BY id LIMIT 5').all();
const oldNames = ['IT', 'HR / Personal', 'Buchhaltung'];
if (depts.length > 0 && depts.some(d => oldNames.includes(d.name))) {
console.log('🔄 Migriere Onboarding-Abteilungen auf neues Schema...');
db.prepare('DROP TABLE IF EXISTS onboarding_processes').run();
db.prepare('DROP TABLE IF EXISTS onboarding_departments').run();
}
} catch (_) { /* tables don't exist yet */ }
db.exec(`
CREATE TABLE IF NOT EXISTS onboarding_departments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
icon TEXT NOT NULL DEFAULT '🏢',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS onboarding_processes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
department_id INTEGER NOT NULL REFERENCES onboarding_departments(id) ON DELETE CASCADE,
responsible_team TEXT NOT NULL DEFAULT 'it'
CHECK(responsible_team IN ('it','hr','buchhaltung')),
title TEXT NOT NULL,
description TEXT,
applies_to TEXT NOT NULL DEFAULT 'both'
CHECK(applies_to IN ('onboarding','offboarding','both')),
tag TEXT NOT NULL DEFAULT 'normal'
CHECK(tag IN ('normal','important','critical')),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Add responsible_team if missing (in case of partial migration)
try {
db.prepare("ALTER TABLE onboarding_processes ADD COLUMN responsible_team TEXT NOT NULL DEFAULT 'it'").run();
} catch (_) { /* already exists */ }
const deptCount = db.prepare('SELECT COUNT(*) as n FROM onboarding_departments').get().n;
if (deptCount === 0) {
this._seedDefaults(db);
}
}
static _seedDefaults(db) {
const insertDept = db.prepare(
'INSERT INTO onboarding_departments (name, icon, sort_order) VALUES (?, ?, ?)'
);
const insertProc = db.prepare(
'INSERT INTO onboarding_processes (department_id, responsible_team, title, description, applies_to, tag, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
// Standard-Prozesse (gleich für alle Firmenabteilungen)
const itOn = [
['AD-Account erstellen', 'Benutzername nach Konvention · Temp-Passwort · Gruppe/Abteilung', 'onboarding', 'critical', 1],
['E-Mail-Adresse einrichten', 'Postfach anlegen · Signatur · Verteiler aufnehmen', 'onboarding', 'critical', 2],
['Hardware prüfen / bestellen', 'Verfügbaren Laptop prüfen · ggf. Neubestellung', 'onboarding', 'critical', 3],
['Hardware aufsetzen', 'OS-Image · Updates · Antivirus · Asset erfassen', 'onboarding', 'important', 4],
['Software installieren', 'Office 365 · VPN · Teams · abteilungsspez. Tools', 'onboarding', 'critical', 5],
['Telefon / Durchwahl einrichten', 'IP-Telefon oder Softphone · Durchwahl zuweisen', 'onboarding', 'important', 6],
['Hardware-Übergabe mit Protokoll', 'Laptop + Zubehör übergeben · Protokoll unterschreiben', 'onboarding', 'critical', 7],
['Ersten Login begleiten', 'Passwort ändern · MFA einrichten · VPN + Teams testen', 'onboarding', 'critical', 8],
];
const itOff = [
['Benutzerkonten deaktivieren', 'AD-Account · E-Mail · alle Systeme deaktivieren', 'offboarding', 'critical', 1],
['Zugriffsrechte entziehen', 'VPN · Cloud-Dienste · Remote-Zugriff widerrufen', 'offboarding', 'critical', 2],
['Hardware zurücknehmen', 'Laptop, Handy, Token · Zustand prüfen · Asset aktualisieren', 'offboarding', 'critical', 3],
['E-Mail-Weiterleitung einrichten', 'Eingehende Mails an Vorgesetzten weiterleiten (mind. 3 Mon.)', 'offboarding', 'important', 4],
];
const hrOn = [
['Personalakte anlegen', 'Digitale Akte · Vertragskopie · Eintrittsdatum', 'onboarding', 'critical', 1],
['Alle Abteilungen informieren', 'IT, Buchhaltung: Name, Position, Startdatum', 'onboarding', 'critical', 2],
['Willkommens-E-Mail senden', 'Startzeit · Ansprechpartner · Parkplatz · Dresscode', 'onboarding', 'important', 3],
['Formulare vorbereiten', 'Datenschutz · IT-Nutzungsordnung · Betriebsordnung', 'onboarding', 'normal', 4],
['Pflichtunterweisungen planen', 'Arbeitssicherheit · Brandschutz · Datenschutz (KW 1)', 'onboarding', 'important', 5],
['Empfang & Begrüßung', 'MA am Empfang abholen · Rundgang · Schlüssel/Badge', 'onboarding', 'critical', 6],
['Alle Formulare unterzeichnen', 'Betriebsordnung · Datenschutz · IT-Nutzungsordnung', 'onboarding', 'critical', 7],
];
const hrOff = [
['Abschlussgespräch führen', 'Exit-Interview · Feedback einholen', 'offboarding', 'important', 1],
['Arbeitszeugnis erstellen', 'Entwurf · vom Vorgesetzten prüfen lassen · ausstellen', 'offboarding', 'important', 2],
['Personalakte abschließen', 'Löschfristen prüfen · DSGVO-konform archivieren', 'offboarding', 'normal', 3],
['Sozialversicherung abmelden', 'SV-Abmeldung bei Krankenkasse einreichen', 'offboarding', 'normal', 4],
];
const bkOn = [
['Mitarbeiter in DATEV anlegen', 'Stammdaten · Eintrittsdatum · Kostenstelle', 'onboarding', 'critical', 1],
['Bankdaten & Steuerklasse anfordern', 'IBAN-Formular · Steuerklasse · SV-Nummer', 'onboarding', 'important', 2],
['Lohnkonto einrichten', 'Gehaltsgruppe · Krankenkasse · Urlaubsanspruch', 'onboarding', 'important', 3],
['Sozialversicherung anmelden', 'SV-Anmeldung bei Krankenkasse einreichen', 'onboarding', 'normal', 4],
['Gehaltsabrechnung vorbereiten', 'Erstes Gehalt anteilig berechnen · Auszahlungstermin', 'onboarding', 'important', 5],
];
const bkOff = [
['Letzte Gehaltsabrechnung', 'Anteiliges Gehalt · Urlaubsabgeltung · Auszahlungsdatum', 'offboarding', 'critical', 1],
['DATEV-Austritt buchen', 'Austrittsdatum eintragen · Kostenstelle abmelden', 'offboarding', 'important', 2],
['Offene Spesen prüfen', 'Reisekostenabrechnungen abschließen · Firmenkarte sperren', 'offboarding', 'normal', 3],
];
const allProcesses = [
...itOn.map(p => ['it', ...p]),
...itOff.map(p => ['it', ...p]),
...hrOn.map(p => ['hr', ...p]),
...hrOff.map(p => ['hr', ...p]),
...bkOn.map(p => ['buchhaltung', ...p]),
...bkOff.map(p => ['buchhaltung', ...p]),
];
const departments = [
['Wartung', '🔧', 1],
['TBO/Service', '🛠️', 2],
['Vertrieb AD', '💼', 3],
['Vertrieb ID', '📊', 4],
['KAW', '📦', 5],
['IT', '💻', 6],
['Verwaltung', '🏢', 7],
['Produktion', '🏭', 8],
];
const seed = db.transaction(() => {
departments.forEach(([name, icon, order]) => {
const deptId = insertDept.run(name, icon, order).lastInsertRowid;
allProcesses.forEach(([team, title, desc, applies, tag, sort]) => {
insertProc.run(deptId, team, title, desc, applies, tag, sort);
});
});
});
seed();
}
// ── Departments ──────────────────────────────────────────────────────────
static getAllDepartments() {
const db = getDatabase();
return db.prepare(
'SELECT * FROM onboarding_departments ORDER BY sort_order, name'
).all();
}
static getDepartmentById(id) {
const db = getDatabase();
return db.prepare('SELECT * FROM onboarding_departments WHERE id = ?').get(id);
}
static createDepartment({ name, icon = '🏢', sort_order = 0 }) {
const db = getDatabase();
const r = db.prepare(
'INSERT INTO onboarding_departments (name, icon, sort_order) VALUES (?, ?, ?)'
).run(name, icon, sort_order);
return this.getDepartmentById(r.lastInsertRowid);
}
static updateDepartment(id, { name, icon, sort_order }) {
const db = getDatabase();
const fields = [];
const vals = [];
if (name !== undefined) { fields.push('name = ?'); vals.push(name); }
if (icon !== undefined) { fields.push('icon = ?'); vals.push(icon); }
if (sort_order !== undefined) { fields.push('sort_order = ?'); vals.push(sort_order); }
if (!fields.length) return this.getDepartmentById(id);
vals.push(id);
db.prepare(`UPDATE onboarding_departments SET ${fields.join(', ')} WHERE id = ?`).run(...vals);
return this.getDepartmentById(id);
}
static deleteDepartment(id) {
const db = getDatabase();
return db.prepare('DELETE FROM onboarding_departments WHERE id = ?').run(id).changes > 0;
}
static reorderDepartments(orderedIds) {
const db = getDatabase();
const stmt = db.prepare('UPDATE onboarding_departments SET sort_order = ? WHERE id = ?');
db.transaction(() => {
orderedIds.forEach((id, idx) => stmt.run(idx, id));
})();
}
// ── Processes ────────────────────────────────────────────────────────────
static getAllProcesses({ departmentId, appliesTo, responsibleTeam } = {}) {
const db = getDatabase();
const where = [];
const vals = [];
if (departmentId) { where.push('p.department_id = ?'); vals.push(departmentId); }
if (appliesTo) { where.push("(p.applies_to = ? OR p.applies_to = 'both')"); vals.push(appliesTo); }
if (responsibleTeam) { where.push('p.responsible_team = ?'); vals.push(responsibleTeam); }
const sql = `
SELECT p.*, d.name as department_name, d.icon as department_icon
FROM onboarding_processes p
JOIN onboarding_departments d ON p.department_id = d.id
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY d.sort_order, d.name, p.responsible_team, p.sort_order, p.title
`;
return db.prepare(sql).all(...vals);
}
static getProcessesForChecklist(appliesTo, departmentId) {
return this.getAllProcesses({ appliesTo, departmentId });
}
static getProcessById(id) {
const db = getDatabase();
return db.prepare(
'SELECT p.*, d.name as department_name FROM onboarding_processes p JOIN onboarding_departments d ON p.department_id = d.id WHERE p.id = ?'
).get(id);
}
static createProcess({ department_id, responsible_team = 'it', title, description = null, applies_to = 'both', tag = 'normal', sort_order = 0 }) {
const db = getDatabase();
const r = db.prepare(
'INSERT INTO onboarding_processes (department_id, responsible_team, title, description, applies_to, tag, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(department_id, responsible_team, title, description, applies_to, tag, sort_order);
return this.getProcessById(r.lastInsertRowid);
}
static updateProcess(id, { title, description, applies_to, tag, sort_order, department_id, responsible_team }) {
const db = getDatabase();
const fields = [];
const vals = [];
if (title !== undefined) { fields.push('title = ?'); vals.push(title); }
if (description !== undefined) { fields.push('description = ?'); vals.push(description); }
if (applies_to !== undefined) { fields.push('applies_to = ?'); vals.push(applies_to); }
if (tag !== undefined) { fields.push('tag = ?'); vals.push(tag); }
if (sort_order !== undefined) { fields.push('sort_order = ?'); vals.push(sort_order); }
if (department_id !== undefined) { fields.push('department_id = ?'); vals.push(department_id); }
if (responsible_team !== undefined) { fields.push('responsible_team = ?'); vals.push(responsible_team); }
if (!fields.length) return this.getProcessById(id);
vals.push(id);
db.prepare(`UPDATE onboarding_processes SET ${fields.join(', ')} WHERE id = ?`).run(...vals);
return this.getProcessById(id);
}
static deleteProcess(id) {
const db = getDatabase();
return db.prepare('DELETE FROM onboarding_processes WHERE id = ?').run(id).changes > 0;
}
static reorderProcesses(departmentId, orderedIds) {
const db = getDatabase();
const stmt = db.prepare('UPDATE onboarding_processes SET sort_order = ? WHERE id = ? AND department_id = ?');
db.transaction(() => {
orderedIds.forEach((id, idx) => stmt.run(idx, id, departmentId));
})();
}
}
module.exports = OnboardingProcess;

View File

@@ -0,0 +1,292 @@
const { getDatabase } = require('../config/database');
class OnboardingProtocol {
/**
* Get all onboarding protocols with related information
*/
static getAll() {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
op.*,
e.username as employee_username,
e.first_name as employee_sys_first_name,
e.last_name as employee_sys_last_name,
e.email as employee_email,
r.name as employee_role_name,
cu.username as created_by_username,
uu.username as updated_by_username
FROM onboarding_protocols op
LEFT JOIN users e ON op.employee_user_id = e.id
LEFT JOIN roles r ON e.role_id = r.id
LEFT JOIN users cu ON op.created_by_user_id = cu.id
LEFT JOIN users uu ON op.updated_by_user_id = uu.id
ORDER BY op.created_at DESC
`);
return stmt.all();
}
/**
* Get onboarding protocol by ID
*/
static getById(id) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
op.*,
e.username as employee_username,
e.first_name as employee_sys_first_name,
e.last_name as employee_sys_last_name,
e.email as employee_email,
r.name as employee_role_name,
cu.username as created_by_username,
uu.username as updated_by_username
FROM onboarding_protocols op
LEFT JOIN users e ON op.employee_user_id = e.id
LEFT JOIN roles r ON e.role_id = r.id
LEFT JOIN users cu ON op.created_by_user_id = cu.id
LEFT JOIN users uu ON op.updated_by_user_id = uu.id
WHERE op.id = ?
`);
return stmt.get(id);
}
/**
* Get onboarding protocols by status
*/
static getByStatus(status) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
op.*,
e.username as employee_username,
e.first_name as employee_first_name,
e.last_name as employee_last_name,
e.email as employee_email,
cu.username as created_by_username
FROM onboarding_protocols op
INNER JOIN users e ON op.employee_user_id = e.id
LEFT JOIN users cu ON op.created_by_user_id = cu.id
WHERE op.status = ?
ORDER BY op.created_at DESC
`);
return stmt.all(status);
}
/**
* Get onboarding protocols for an employee
*/
static getByEmployee(employeeUserId) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
op.*,
cu.username as created_by_username
FROM onboarding_protocols op
LEFT JOIN users cu ON op.created_by_user_id = cu.id
WHERE op.employee_user_id = ?
ORDER BY op.created_at DESC
`);
return stmt.all(employeeUserId);
}
/**
* Create a new onboarding protocol
*/
static create(protocolData) {
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO onboarding_protocols (
employee_user_id,
emp_first_name,
emp_last_name,
emp_private_email,
emp_phone,
emp_address,
department,
position,
work_location,
hours_model,
vacation_model,
dept_contacts,
status,
start_date,
checklist_data,
notes,
created_by_user_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
protocolData.employee_user_id || null,
protocolData.emp_first_name || null,
protocolData.emp_last_name || null,
protocolData.emp_private_email || null,
protocolData.emp_phone || null,
protocolData.emp_address || null,
protocolData.department || null,
protocolData.position || null,
protocolData.work_location || null,
protocolData.hours_model || null,
protocolData.vacation_model || null,
protocolData.dept_contacts || null,
protocolData.status,
protocolData.start_date,
protocolData.checklist_data || null,
protocolData.notes || null,
protocolData.created_by_user_id
);
return this.getById(result.lastInsertRowid);
}
/**
* Update onboarding protocol
*/
static update(id, protocolData, updatedByUserId) {
const db = getDatabase();
const fields = [];
const values = [];
const personalFields = ['emp_first_name', 'emp_last_name', 'emp_private_email', 'emp_phone',
'emp_address', 'department', 'position', 'work_location', 'hours_model', 'vacation_model',
'dept_contacts', 'employee_user_id'];
for (const f of personalFields) {
if (protocolData[f] !== undefined) {
fields.push(`${f} = ?`);
values.push(protocolData[f]);
}
}
if (protocolData.status !== undefined) {
fields.push('status = ?');
values.push(protocolData.status);
}
if (protocolData.start_date !== undefined) {
fields.push('start_date = ?');
values.push(protocolData.start_date);
}
if (protocolData.completion_date !== undefined) {
fields.push('completion_date = ?');
values.push(protocolData.completion_date);
}
if (protocolData.checklist_data !== undefined) {
fields.push('checklist_data = ?');
values.push(protocolData.checklist_data);
}
if (protocolData.notes !== undefined) {
fields.push('notes = ?');
values.push(protocolData.notes);
}
if (protocolData.pdf_file_path !== undefined) {
fields.push('pdf_file_path = ?');
values.push(protocolData.pdf_file_path);
}
fields.push('updated_by_user_id = ?');
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(updatedByUserId, id);
const stmt = db.prepare(`
UPDATE onboarding_protocols
SET ${fields.join(', ')}
WHERE id = ?
`);
stmt.run(...values);
return this.getById(id);
}
/**
* Update checklist data
*/
static updateChecklist(id, checklistData, updatedByUserId) {
const db = getDatabase();
const stmt = db.prepare(`
UPDATE onboarding_protocols
SET checklist_data = ?,
updated_by_user_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
stmt.run(checklistData, updatedByUserId, id);
return this.getById(id);
}
/**
* Update PDF file path
*/
static updatePdfPath(id, pdfFilePath, updatedByUserId) {
const db = getDatabase();
const stmt = db.prepare(`
UPDATE onboarding_protocols
SET pdf_file_path = ?,
updated_by_user_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
stmt.run(pdfFilePath, updatedByUserId, id);
return this.getById(id);
}
/**
* Delete onboarding protocol
*/
static delete(id) {
const db = getDatabase();
const stmt = db.prepare('DELETE FROM onboarding_protocols WHERE id = ?');
const result = stmt.run(id);
return result.changes > 0;
}
/**
* Set confirm token for employee confirmation email
*/
static setConfirmToken(id, token) {
const db = getDatabase();
db.prepare('UPDATE onboarding_protocols SET confirm_token = ? WHERE id = ?').run(token, id);
}
/**
* Get protocol by confirm token (public, no auth)
*/
static getByConfirmToken(token) {
const db = getDatabase();
return db.prepare('SELECT * FROM onboarding_protocols WHERE confirm_token = ?').get(token);
}
/**
* Mark employee confirmation
*/
static confirmByToken(token) {
const db = getDatabase();
const result = db.prepare(
`UPDATE onboarding_protocols SET employee_confirmed_at = CURRENT_TIMESTAMP, status = 'completed', confirm_token = NULL WHERE confirm_token = ?`
).run(token);
return result.changes > 0;
}
/**
* Get statistics
*/
static getStatistics() {
const db = getDatabase();
const totalStmt = db.prepare('SELECT COUNT(*) as total FROM onboarding_protocols');
const pendingStmt = db.prepare("SELECT COUNT(*) as pending FROM onboarding_protocols WHERE status = 'pending'");
const inProgressStmt = db.prepare("SELECT COUNT(*) as in_progress FROM onboarding_protocols WHERE status = 'in_progress'");
const completedStmt = db.prepare("SELECT COUNT(*) as completed FROM onboarding_protocols WHERE status = 'completed'");
return {
total: totalStmt.get().total,
pending: pendingStmt.get().pending,
in_progress: inProgressStmt.get().in_progress,
completed: completedStmt.get().completed
};
}
}
module.exports = OnboardingProtocol;

View File

@@ -0,0 +1,79 @@
const { getDatabase } = require('../config/database');
const PurchaseOrder = {
getAll({ status } = {}) {
const db = getDatabase();
let q = `
SELECT p.*, u.first_name || ' ' || u.last_name AS created_by_name
FROM purchase_orders p
LEFT JOIN users u ON p.created_by = u.id
`;
const params = [];
if (status) { q += ` WHERE p.status = ?`; params.push(status); }
q += ` ORDER BY p.created_at DESC`;
return db.prepare(q).all(...params);
},
getById(id) {
const db = getDatabase();
return db.prepare(`
SELECT p.*, u.first_name || ' ' || u.last_name AS created_by_name
FROM purchase_orders p
LEFT JOIN users u ON p.created_by = u.id
WHERE p.id = ?
`).get(id);
},
create(data) {
const db = getDatabase();
const result = db.prepare(`
INSERT INTO purchase_orders (category, item_name, quantity, status, notes, created_by)
VALUES (@category, @item_name, @quantity, @status, @notes, @created_by)
`).run({
category: data.category,
item_name: data.item_name,
quantity: data.quantity || 1,
status: data.status || 'offen',
notes: data.notes || '',
created_by: data.created_by,
});
return this.getById(result.lastInsertRowid);
},
update(id, data) {
const db = getDatabase();
const fields = [];
const params = {};
if (data.status !== undefined) {
fields.push(`status = @status`);
params.status = data.status;
if (data.status === 'bestellt') {
fields.push(`ordered_at = CURRENT_TIMESTAMP`);
} else if (data.status === 'erledigt') {
fields.push(`completed_at = CURRENT_TIMESTAMP`);
}
}
if (data.notes !== undefined) { fields.push(`notes = @notes`); params.notes = data.notes; }
if (data.quantity !== undefined) { fields.push(`quantity = @quantity`); params.quantity = data.quantity; }
if (data.item_name !== undefined) { fields.push(`item_name = @item_name`); params.item_name = data.item_name; }
fields.push(`updated_at = CURRENT_TIMESTAMP`);
params.id = id;
db.prepare(`UPDATE purchase_orders SET ${fields.join(', ')} WHERE id = @id`).run(params);
return this.getById(id);
},
delete(id) {
const db = getDatabase();
return db.prepare(`DELETE FROM purchase_orders WHERE id = ?`).run(id).changes > 0;
},
getOpenCount() {
const db = getDatabase();
return db.prepare(`SELECT COUNT(*) AS count FROM purchase_orders WHERE status = 'offen'`).get().count;
},
};
module.exports = PurchaseOrder;

142
backend/src/models/Risk.js Normal file
View File

@@ -0,0 +1,142 @@
const { getDatabase } = require('../config/database');
class Risk {
static migrate() {
const db = getDatabase();
db.exec(`
CREATE TABLE IF NOT EXISTS risks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT 'manual'
CHECK(source IN ('manual','auto')),
source_type TEXT NOT NULL DEFAULT 'manual',
external_id TEXT,
title TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'Sonstiges',
probability INTEGER NOT NULL DEFAULT 3 CHECK(probability BETWEEN 1 AND 5),
impact INTEGER NOT NULL DEFAULT 3 CHECK(impact BETWEEN 1 AND 5),
status TEXT NOT NULL DEFAULT 'offen'
CHECK(status IN ('offen','in_bearbeitung','akzeptiert','behoben')),
responsible TEXT,
mitigation TEXT,
due_date TEXT,
metadata TEXT,
last_synced TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS risks_external_id ON risks(source_type, external_id)
WHERE external_id IS NOT NULL;
`);
// Migration: add metadata column to existing tables
try { db.exec(`ALTER TABLE risks ADD COLUMN metadata TEXT`); } catch {}
}
static _parse(row) {
if (!row) return null;
if (row.metadata) {
try { row.metadata = JSON.parse(row.metadata); } catch { row.metadata = null; }
}
return row;
}
static getAll({ category, status, source } = {}) {
const db = getDatabase();
const where = [];
const vals = [];
if (category) { where.push('category = ?'); vals.push(category); }
if (status) { where.push('status = ?'); vals.push(status); }
if (source) { where.push('source = ?'); vals.push(source); }
const rows = db.prepare(
`SELECT *, (probability * impact) as score FROM risks
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY (probability * impact) DESC, created_at DESC`
).all(...vals);
return rows.map(r => this._parse(r));
}
static getById(id) {
return this._parse(getDatabase().prepare(
'SELECT *, (probability * impact) as score FROM risks WHERE id = ?'
).get(id));
}
static create(fields) {
const db = getDatabase();
const r = db.prepare(`
INSERT INTO risks (source, source_type, external_id, title, description, category, probability, impact, status, responsible, mitigation, due_date, metadata, last_synced)
VALUES (@source, @source_type, @external_id, @title, @description, @category, @probability, @impact, @status, @responsible, @mitigation, @due_date, @metadata, @last_synced)
`).run({
source: fields.source ?? 'manual',
source_type: fields.source_type ?? 'manual',
external_id: fields.external_id ?? null,
title: fields.title,
description: fields.description ?? null,
category: fields.category ?? 'Sonstiges',
probability: fields.probability ?? 3,
impact: fields.impact ?? 3,
status: fields.status ?? 'offen',
responsible: fields.responsible ?? null,
mitigation: fields.mitigation ?? null,
due_date: fields.due_date ?? null,
metadata: fields.metadata ? JSON.stringify(fields.metadata) : null,
last_synced: fields.last_synced ?? null,
});
return this.getById(r.lastInsertRowid);
}
static upsertAuto({ source_type, external_id, title, description, category, probability, impact, metadata }) {
const db = getDatabase();
const existing = db.prepare(
'SELECT id FROM risks WHERE source_type = ? AND external_id = ?'
).get(source_type, external_id);
const now = new Date().toISOString();
const metaStr = metadata ? JSON.stringify(metadata) : null;
if (existing) {
db.prepare(`
UPDATE risks SET title=?, description=?, probability=?, impact=?, metadata=?, last_synced=?, updated_at=datetime('now')
WHERE id=?
`).run(title, description, probability, impact, metaStr, now, existing.id);
return this.getById(existing.id);
}
return this.create({ source: 'auto', source_type, external_id, title, description, category, probability, impact, metadata, last_synced: now });
}
static update(id, fields) {
const db = getDatabase();
const allowed = ['title','description','category','probability','impact','status','responsible','mitigation','due_date'];
const setClauses = [];
const vals = [];
for (const key of allowed) {
if (fields[key] !== undefined) {
setClauses.push(`${key} = ?`);
vals.push(fields[key]);
}
}
if (!setClauses.length) return this.getById(id);
setClauses.push("updated_at = datetime('now')");
vals.push(id);
db.prepare(`UPDATE risks SET ${setClauses.join(', ')} WHERE id = ?`).run(...vals);
return this.getById(id);
}
static delete(id) {
return getDatabase().prepare('DELETE FROM risks WHERE id = ?').run(id).changes > 0;
}
static getStats() {
const db = getDatabase();
const all = db.prepare('SELECT probability * impact as score, status, source FROM risks').all();
return {
total: all.length,
kritisch: all.filter(r => r.score >= 20).length,
hoch: all.filter(r => r.score >= 12 && r.score < 20).length,
mittel: all.filter(r => r.score >= 6 && r.score < 12).length,
niedrig: all.filter(r => r.score < 6).length,
behoben: all.filter(r => r.status === 'behoben').length,
auto: all.filter(r => r.source === 'auto').length,
};
}
}
module.exports = Risk;

View File

@@ -0,0 +1,32 @@
const { getDatabase } = require('../config/database');
class Role {
/**
* Get all roles
*/
static getAll() {
const db = getDatabase();
const stmt = db.prepare('SELECT * FROM roles ORDER BY id');
return stmt.all();
}
/**
* Get role by ID
*/
static getById(id) {
const db = getDatabase();
const stmt = db.prepare('SELECT * FROM roles WHERE id = ?');
return stmt.get(id);
}
/**
* Get role by name
*/
static getByName(name) {
const db = getDatabase();
const stmt = db.prepare('SELECT * FROM roles WHERE name = ?');
return stmt.get(name);
}
}
module.exports = Role;

View File

@@ -0,0 +1,54 @@
const { getDatabase } = require('../config/database');
const StockThreshold = {
getAll() {
const db = getDatabase();
return db.prepare(`SELECT * FROM stock_thresholds ORDER BY category`).all();
},
getByCategory(category) {
const db = getDatabase();
return db.prepare(`SELECT * FROM stock_thresholds WHERE category = ?`).get(category);
},
upsert(data) {
const db = getDatabase();
db.prepare(`
INSERT INTO stock_thresholds (category, min_stock, notify_email, updated_at)
VALUES (@category, @min_stock, @notify_email, CURRENT_TIMESTAMP)
ON CONFLICT(category) DO UPDATE SET
min_stock = excluded.min_stock,
notify_email = excluded.notify_email,
updated_at = CURRENT_TIMESTAMP
`).run({
category: data.category,
min_stock: data.min_stock || 1,
notify_email: data.notify_email || null,
});
return this.getByCategory(data.category);
},
delete(category) {
const db = getDatabase();
return db.prepare(`DELETE FROM stock_thresholds WHERE category = ?`).run(category).changes > 0;
},
// Returns categories where current count <= min_stock
getViolations() {
const db = getDatabase();
return db.prepare(`
SELECT
t.category,
t.min_stock,
t.notify_email,
COUNT(a.id) AS current_stock
FROM stock_thresholds t
LEFT JOIN assets a ON a.type = t.category AND a.status = 'verfuegbar'
GROUP BY t.category
HAVING current_stock <= t.min_stock
ORDER BY current_stock ASC
`).all();
},
};
module.exports = StockThreshold;

View File

@@ -0,0 +1,384 @@
const { getDatabase } = require('../config/database');
class Ticket {
/**
* Get all tickets with filters and related data
*/
static getAll(filters = {}) {
const db = getDatabase();
let where = [];
let params = [];
if (filters.status) {
where.push('t.status = ?');
params.push(filters.status);
}
if (filters.priority) {
where.push('t.priority = ?');
params.push(filters.priority);
}
if (filters.category) {
where.push('t.category = ?');
params.push(filters.category);
}
if (filters.assigned_to) {
where.push('t.assigned_to_user_id = ?');
params.push(filters.assigned_to);
}
if (filters.created_by_user_id) {
where.push('t.created_by_user_id = ?');
params.push(filters.created_by_user_id);
}
// User scope: tickets created by user OR where user is the requester (by email)
if (filters.user_scope) {
where.push('(t.created_by_user_id = ? OR LOWER(t.requester_email) = LOWER(?))');
params.push(filters.user_scope.userId, filters.user_scope.email);
}
if (filters.search) {
where.push('(t.title LIKE ? OR t.description LIKE ? OR t.ticket_number LIKE ? OR t.requester_name LIKE ?)');
const s = `%${filters.search}%`;
params.push(s, s, s, s);
}
const whereClause = where.length > 0 ? 'WHERE ' + where.join(' AND ') : '';
const stmt = db.prepare(`
SELECT
t.*,
au.username as assigned_to_username,
au.first_name as assigned_to_first_name,
au.last_name as assigned_to_last_name,
cu.username as created_by_username,
a.name as asset_name,
a.serial_number as asset_serial,
a.teamviewer_id as asset_teamviewer_id
FROM tickets t
LEFT JOIN users au ON t.assigned_to_user_id = au.id
LEFT JOIN users cu ON t.created_by_user_id = cu.id
LEFT JOIN assets a ON t.asset_id = a.id
${whereClause}
ORDER BY
CASE t.priority
WHEN 'kritisch' THEN 1
WHEN 'hoch' THEN 2
WHEN 'mittel' THEN 3
WHEN 'niedrig' THEN 4
END,
t.created_at DESC
`);
return stmt.all(...params);
}
/**
* Get a single ticket by ID with all related data
*/
static getById(id) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
t.*,
au.username as assigned_to_username,
au.first_name as assigned_to_first_name,
au.last_name as assigned_to_last_name,
au.email as assigned_to_email,
cu.username as created_by_username,
cu.first_name as created_by_first_name,
cu.last_name as created_by_last_name,
a.name as asset_name,
a.serial_number as asset_serial,
a.type as asset_type,
a.teamviewer_id as asset_teamviewer_id
FROM tickets t
LEFT JOIN users au ON t.assigned_to_user_id = au.id
LEFT JOIN users cu ON t.created_by_user_id = cu.id
LEFT JOIN assets a ON t.asset_id = a.id
WHERE t.id = ?
`);
const ticket = stmt.get(id);
if (ticket) ticket.assignees = Ticket.getAssignees(id);
return ticket;
}
/**
* Get ticket by email Message-ID (for deduplication)
*/
static getByEmailMessageId(messageId) {
const db = getDatabase();
return db.prepare('SELECT id FROM tickets WHERE email_message_id = ?').get(messageId);
}
static getByTicketNumber(ticketNumber) {
const db = getDatabase();
return db.prepare('SELECT id, status FROM tickets WHERE ticket_number = ?').get(ticketNumber);
}
static getAssignees(ticketId) {
const db = getDatabase();
return db.prepare(`
SELECT u.id, u.username, u.first_name, u.last_name, u.email, ta.assigned_at
FROM ticket_assignees ta
JOIN users u ON ta.user_id = u.id
WHERE ta.ticket_id = ?
ORDER BY ta.assigned_at ASC
`).all(ticketId);
}
static addAssignee(ticketId, userId, assignedByUserId) {
const db = getDatabase();
try {
db.prepare(`INSERT OR IGNORE INTO ticket_assignees (ticket_id, user_id, assigned_by_user_id) VALUES (?, ?, ?)`)
.run(ticketId, userId, assignedByUserId || null);
return true;
} catch { return false; }
}
static removeAssignee(ticketId, userId) {
const db = getDatabase();
return db.prepare('DELETE FROM ticket_assignees WHERE ticket_id = ? AND user_id = ?').run(ticketId, userId);
}
/**
* Generate next ticket number: TK-YYYY-NNNN
*/
static _generateTicketNumber(db) {
const year = new Date().getFullYear();
const prefix = `TK-${year}-`;
const last = db.prepare(`
SELECT ticket_number FROM tickets
WHERE ticket_number LIKE ?
ORDER BY id DESC LIMIT 1
`).get(`${prefix}%`);
let seq = 1;
if (last) {
const parts = last.ticket_number.split('-');
seq = parseInt(parts[parts.length - 1]) + 1;
}
return `${prefix}${String(seq).padStart(4, '0')}`;
}
/**
* Create a new ticket
*/
static create(data) {
const db = getDatabase();
const ticketNumber = this._generateTicketNumber(db);
const stmt = db.prepare(`
INSERT INTO tickets (
ticket_number, title, description, status, priority, category,
source, requester_name, requester_email,
assigned_to_user_id, created_by_user_id, asset_id, email_message_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
ticketNumber,
data.title,
data.description || null,
data.status || 'offen',
data.priority || 'mittel',
data.category || 'Allgemein',
data.source || 'web',
data.requester_name || null,
data.requester_email || null,
data.assigned_to_user_id || null,
data.created_by_user_id || null,
data.asset_id || null,
data.email_message_id || null
);
return this.getById(result.lastInsertRowid);
}
/**
* Update a ticket
*/
static update(id, data) {
const db = getDatabase();
const fields = [];
const values = [];
const allowed = [
'title', 'description', 'status', 'priority', 'category',
'assigned_to_user_id', 'asset_id', 'requester_name', 'requester_email',
'snoozed_until', 'ai_suggestion', 'ai_active',
'satisfaction_rating', 'satisfaction_comment'
];
for (const key of allowed) {
if (data[key] !== undefined) {
fields.push(`${key} = ?`);
values.push(data[key] === '' ? null : data[key]);
}
}
if (fields.length === 0) return this.getById(id);
// Set resolved_at / closed_at timestamps
if (data.status === 'geschlossen' && !fields.includes('resolved_at')) {
fields.push('resolved_at = CURRENT_TIMESTAMP');
}
if (data.status === 'geschlossen') {
fields.push('closed_at = CURRENT_TIMESTAMP');
}
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return this.getById(id);
}
/**
* Delete a ticket
*/
static delete(id) {
const db = getDatabase();
return db.prepare('DELETE FROM tickets WHERE id = ?').run(id).changes > 0;
}
/**
* Get ticket links
*/
static getLinks(ticketId) {
const db = getDatabase();
return db.prepare(`
SELECT tl.*, t.ticket_number, t.title, t.status, t.priority
FROM ticket_links tl
JOIN tickets t ON tl.linked_ticket_id = t.id
WHERE tl.ticket_id = ?
ORDER BY tl.created_at DESC
`).all(ticketId);
}
static addLink(ticketId, linkedTicketId, linkType, userId) {
const db = getDatabase();
const result = db.prepare(`
INSERT OR IGNORE INTO ticket_links (ticket_id, linked_ticket_id, link_type, created_by_user_id)
VALUES (?, ?, ?, ?)
`).run(ticketId, linkedTicketId, linkType || 'related', userId || null);
return result.lastInsertRowid;
}
static removeLink(linkId) {
const db = getDatabase();
return db.prepare('DELETE FROM ticket_links WHERE id = ?').run(linkId).changes > 0;
}
/**
* Get detailed metrics for the metrics page
*/
static getMetrics(days = 30) {
const db = getDatabase();
// Tickets pro Tag
const perDay = db.prepare(`
SELECT DATE(created_at) as date, COUNT(*) as created,
COUNT(CASE WHEN status IN ('geschlossen') THEN 1 END) as resolved
FROM tickets
WHERE created_at >= datetime('now', '-${parseInt(days)} days')
GROUP BY DATE(created_at)
ORDER BY date
`).all();
// Avg Lösungszeit in Stunden (nur gelöste/geschlossene)
const avgResolution = db.prepare(`
SELECT
priority,
ROUND(AVG(
(JULIANDAY(COALESCE(resolved_at, closed_at)) - JULIANDAY(created_at)) * 24
), 1) as avg_hours,
COUNT(*) as count
FROM tickets
WHERE status IN ('geschlossen')
AND COALESCE(resolved_at, closed_at) IS NOT NULL
GROUP BY priority
`).all();
// Top-Bearbeiter
const topAssignees = db.prepare(`
SELECT
u.username,
u.first_name,
u.last_name,
COUNT(*) as total,
COUNT(CASE WHEN t.status IN ('geschlossen') THEN 1 END) as resolved
FROM tickets t
JOIN users u ON t.assigned_to_user_id = u.id
GROUP BY t.assigned_to_user_id
ORDER BY resolved DESC
LIMIT 5
`).all();
// Nach Kategorie
const byCategory = db.prepare(`
SELECT category, COUNT(*) as count,
COUNT(CASE WHEN status NOT IN ('geschlossen') THEN 1 END) as open
FROM tickets
GROUP BY category
ORDER BY count DESC
`).all();
// SLA-Verletzungen (offen und über SLA-Grenze)
const slaBreaches = db.prepare(`
SELECT priority, COUNT(*) as count
FROM tickets
WHERE status NOT IN ('geschlossen')
AND (
(priority = 'kritisch' AND created_at <= datetime('now', '-24 hours')) OR
(priority = 'hoch' AND created_at <= datetime('now', '-48 hours')) OR
(priority = 'mittel' AND created_at <= datetime('now', '-168 hours')) OR
(priority = 'niedrig' AND created_at <= datetime('now', '-336 hours'))
)
GROUP BY priority
`).all();
// Kundenfeedback
const satisfactionTotal = db.prepare(`
SELECT
COUNT(CASE WHEN satisfaction_rating = 'gut' THEN 1 END) as gut,
COUNT(CASE WHEN satisfaction_rating = 'schlecht' THEN 1 END) as schlecht,
COUNT(satisfaction_rating) as total
FROM tickets
`).get();
const recentFeedback = db.prepare(`
SELECT ticket_number, title, satisfaction_rating, satisfaction_comment,
requester_name, requester_email, updated_at
FROM tickets
WHERE satisfaction_rating IS NOT NULL
ORDER BY updated_at DESC
LIMIT 10
`).all();
return { perDay, avgResolution, topAssignees, byCategory, slaBreaches, satisfactionTotal, recentFeedback };
}
/**
* Get ticket statistics for dashboard
*/
static getStats() {
const db = getDatabase();
const byStatus = db.prepare(`
SELECT status, COUNT(*) as count FROM tickets GROUP BY status
`).all();
const byPriority = db.prepare(`
SELECT priority, COUNT(*) as count FROM tickets
WHERE status NOT IN ('geschlossen')
GROUP BY priority
`).all();
const total = db.prepare('SELECT COUNT(*) as count FROM tickets').get().count;
const open = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE status = 'offen'").get().count;
const inProgress = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE status = 'in_bearbeitung'").get().count;
const critical = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE priority = 'kritisch' AND status NOT IN ('geschlossen')").get().count;
return { total, open, inProgress, critical, byStatus, byPriority };
}
}
module.exports = Ticket;

View File

@@ -0,0 +1,50 @@
const { getDatabase } = require('../config/database');
class TicketComment {
/**
* Get all comments for a ticket
*/
static getByTicketId(ticketId) {
const db = getDatabase();
return db.prepare(`
SELECT
tc.*,
u.username,
u.first_name,
u.last_name,
u.role_id
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.id
WHERE tc.ticket_id = ?
ORDER BY tc.created_at ASC
`).all(ticketId);
}
/**
* Create a new comment
*/
static create(ticketId, userId, comment, isInternal = false, isAiComment = false) {
const db = getDatabase();
const result = db.prepare(`
INSERT INTO ticket_comments (ticket_id, user_id, comment, is_internal, is_ai_comment)
VALUES (?, ?, ?, ?, ?)
`).run(ticketId, userId || null, comment, isInternal ? 1 : 0, isAiComment ? 1 : 0);
return db.prepare(`
SELECT tc.*, u.username, u.first_name, u.last_name, u.role_id
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.id
WHERE tc.id = ?
`).get(result.lastInsertRowid);
}
/**
* Delete a comment
*/
static delete(id) {
const db = getDatabase();
return db.prepare('DELETE FROM ticket_comments WHERE id = ?').run(id).changes > 0;
}
}
module.exports = TicketComment;

View File

@@ -0,0 +1,137 @@
const { getDatabase } = require('../config/database');
class TicketSettings {
static migrate() {
const db = getDatabase();
db.exec(`
CREATE TABLE IF NOT EXISTS ticket_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
icon TEXT NOT NULL DEFAULT '📁',
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS ticket_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT 'Allgemein',
priority TEXT NOT NULL DEFAULT 'mittel',
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// Seed categories if empty
const catCount = db.prepare('SELECT COUNT(*) as c FROM ticket_categories').get();
if (catCount.c === 0) {
const insertCat = db.prepare('INSERT INTO ticket_categories (name, icon, sort_order) VALUES (?, ?, ?)');
[
['Allgemein', '📋', 0],
['Software', '💻', 1],
['Hardware', '🖥️', 2],
['Netzwerk', '🌐', 3],
['SelectLine', '📊', 4],
].forEach(([name, icon, order]) => insertCat.run(name, icon, order));
}
// Seed templates if empty
const tplCount = db.prepare('SELECT COUNT(*) as c FROM ticket_templates').get();
if (tplCount.c === 0) {
const insertTpl = db.prepare(
'INSERT INTO ticket_templates (label, title, description, category, priority, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
);
[
['Passwort-Reset', 'Passwort zurücksetzen', 'Bitte setzen Sie mein Passwort zurück.\n\nBetroffenes Konto: ', 'Software', 'mittel', 0],
['VPN-Probleme', 'VPN verbindet nicht', 'VPN lässt sich nicht verbinden oder trennt sich ständig.\n\nBetriebssystem: \nFehlermeldung: ', 'Software', 'mittel', 1],
['Drucker defekt', 'Drucker funktioniert nicht', 'Der Drucker reagiert nicht / druckt nicht korrekt.\n\nDrucker-Modell: \nStandort: \nFehlermeldung: ', 'Hardware', 'mittel', 2],
['Neues Gerät einrichten', 'Einrichtung neues Endgerät', 'Neues Gerät muss eingerichtet und konfiguriert werden.\n\nGerätetyp: \nFür Mitarbeiter: \nBenötigte Software: ', 'Hardware', 'mittel', 3],
['Software-Installation', 'Software installieren', 'Bitte folgende Software installieren:\n\nSoftware: \nVersion: \nLizenz vorhanden: ', 'Software', 'niedrig', 4],
['SelectLine Fehler', 'SelectLine Fehler/Problem', 'Folgender Fehler tritt in SelectLine auf:\n\nModul: \nFehlermeldung: \nWiederholbar: ', 'SelectLine', 'hoch', 5],
['E-Mail Problem', 'E-Mail funktioniert nicht', 'Problem mit E-Mail-Postfach oder Outlook.\n\nProblem: \nFehlermeldung: ', 'Software', 'mittel', 6],
['Netzwerk-Ausfall', 'Kein Internetzugang', 'Kein Internetzugang oder Netzwerkverbindung unterbrochen.\n\nStandort/Raum: \nBetroffene Geräte: \nSeit wann: ', 'Netzwerk', 'hoch', 7],
].forEach(args => insertTpl.run(...args));
}
}
// ── Categories ─────────────────────────────────────────────────────────────
static getAllCategories() {
return getDatabase()
.prepare('SELECT * FROM ticket_categories ORDER BY sort_order, name')
.all();
}
static createCategory({ name, icon = '📁' }) {
const db = getDatabase();
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM ticket_categories').get().m ?? -1;
const stmt = db.prepare('INSERT INTO ticket_categories (name, icon, sort_order) VALUES (?, ?, ?)');
const result = stmt.run(name.trim(), icon, maxOrder + 1);
return db.prepare('SELECT * FROM ticket_categories WHERE id = ?').get(result.lastInsertRowid);
}
static updateCategory(id, { name, icon }) {
const db = getDatabase();
const fields = [];
const params = [];
if (name !== undefined) { fields.push('name = ?'); params.push(name.trim()); }
if (icon !== undefined) { fields.push('icon = ?'); params.push(icon); }
if (!fields.length) return db.prepare('SELECT * FROM ticket_categories WHERE id = ?').get(id);
params.push(id);
db.prepare(`UPDATE ticket_categories SET ${fields.join(', ')} WHERE id = ?`).run(...params);
return db.prepare('SELECT * FROM ticket_categories WHERE id = ?').get(id);
}
static deleteCategory(id) {
return getDatabase().prepare('DELETE FROM ticket_categories WHERE id = ?').run(id);
}
static reorderCategories(orderedIds) {
const db = getDatabase();
const stmt = db.prepare('UPDATE ticket_categories SET sort_order = ? WHERE id = ?');
orderedIds.forEach((id, idx) => stmt.run(idx, id));
}
// ── Templates ──────────────────────────────────────────────────────────────
static getAllTemplates() {
return getDatabase()
.prepare('SELECT * FROM ticket_templates ORDER BY sort_order, label')
.all();
}
static createTemplate({ label, title = '', description = '', category = 'Allgemein', priority = 'mittel' }) {
const db = getDatabase();
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM ticket_templates').get().m ?? -1;
const stmt = db.prepare(
'INSERT INTO ticket_templates (label, title, description, category, priority, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(label.trim(), title, description, category, priority, maxOrder + 1);
return db.prepare('SELECT * FROM ticket_templates WHERE id = ?').get(result.lastInsertRowid);
}
static updateTemplate(id, { label, title, description, category, priority }) {
const db = getDatabase();
const fields = [];
const params = [];
if (label !== undefined) { fields.push('label = ?'); params.push(label.trim()); }
if (title !== undefined) { fields.push('title = ?'); params.push(title); }
if (description !== undefined) { fields.push('description = ?'); params.push(description); }
if (category !== undefined) { fields.push('category = ?'); params.push(category); }
if (priority !== undefined) { fields.push('priority = ?'); params.push(priority); }
if (!fields.length) return db.prepare('SELECT * FROM ticket_templates WHERE id = ?').get(id);
params.push(id);
db.prepare(`UPDATE ticket_templates SET ${fields.join(', ')} WHERE id = ?`).run(...params);
return db.prepare('SELECT * FROM ticket_templates WHERE id = ?').get(id);
}
static deleteTemplate(id) {
return getDatabase().prepare('DELETE FROM ticket_templates WHERE id = ?').run(id);
}
}
module.exports = TicketSettings;

179
backend/src/models/User.js Normal file
View File

@@ -0,0 +1,179 @@
const { getDatabase } = require('../config/database');
class User {
static getAll() {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
u.id, u.username, u.email, u.first_name, u.last_name,
u.is_active, u.must_change_password, u.email_notifications, u.last_login,
u.created_at, u.updated_at, u.role_id, u.azure_id,
r.name as role_name, r.description as role_description
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
ORDER BY u.created_at DESC
`);
return stmt.all();
}
static getById(id) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT
u.id, u.username, u.email, u.first_name, u.last_name,
u.is_active, u.must_change_password, u.email_notifications, u.last_login,
u.created_at, u.updated_at, u.role_id, u.azure_id,
r.name as role_name, r.description as role_description
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.id = ?
`);
return stmt.get(id);
}
static getByIdWithPassword(id) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT u.*, r.name as role_name, r.description as role_description
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.id = ?
`);
return stmt.get(id);
}
static getByUsername(username) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT u.*, r.name as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.username = ?
`);
return stmt.get(username);
}
static getByEmail(email) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT u.*, r.name as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.email = ?
`);
return stmt.get(email);
}
static getByAzureId(azureId) {
const db = getDatabase();
const stmt = db.prepare(`
SELECT u.*, r.name as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.azure_id = ?
`);
return stmt.get(azureId);
}
static createFromAzure({ azure_id, email, first_name, last_name, username }) {
const db = getDatabase();
const role = db.prepare("SELECT id FROM roles WHERE name = 'benutzer'").get();
const stmt = db.prepare(`
INSERT INTO users (username, email, password_hash, role_id, first_name, last_name, is_active, must_change_password, azure_id)
VALUES (?, ?, 'AZURE_SSO_NO_PASSWORD', ?, ?, ?, 1, 0, ?)
`);
const result = stmt.run(username, email, role.id, first_name || null, last_name || null, azure_id);
return this.getById(result.lastInsertRowid);
}
static create(userData) {
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO users (
username, email, password_hash, role_id,
first_name, last_name, is_active, must_change_password
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
userData.username,
userData.email,
userData.password_hash,
userData.role_id,
userData.first_name || null,
userData.last_name || null,
userData.is_active !== undefined ? (userData.is_active ? 1 : 0) : 1,
userData.must_change_password !== undefined ? (userData.must_change_password ? 1 : 0) : 1
);
return this.getById(result.lastInsertRowid);
}
static update(id, userData) {
const db = getDatabase();
const fields = [];
const values = [];
if (userData.username !== undefined) { fields.push('username = ?'); values.push(userData.username); }
if (userData.email !== undefined) { fields.push('email = ?'); values.push(userData.email); }
if (userData.password_hash !== undefined) { fields.push('password_hash = ?'); values.push(userData.password_hash); }
if (userData.role_id !== undefined) { fields.push('role_id = ?'); values.push(userData.role_id); }
if (userData.first_name !== undefined) { fields.push('first_name = ?'); values.push(userData.first_name); }
if (userData.last_name !== undefined) { fields.push('last_name = ?'); values.push(userData.last_name); }
if (userData.is_active !== undefined) { fields.push('is_active = ?'); values.push(userData.is_active ? 1 : 0); }
if (userData.must_change_password !== undefined) { fields.push('must_change_password = ?'); values.push(userData.must_change_password ? 1 : 0); }
if (userData.azure_id !== undefined) { fields.push('azure_id = ?'); values.push(userData.azure_id); }
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
const stmt = db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`);
stmt.run(...values);
return this.getById(id);
}
static delete(id) {
const db = getDatabase();
const result = db.prepare('DELETE FROM users WHERE id = ?').run(id);
return result.changes > 0;
}
static updateLastLogin(id) {
const db = getDatabase();
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(id);
}
static changePassword(id, passwordHash, mustChange = false) {
const db = getDatabase();
db.prepare(`
UPDATE users SET password_hash = ?, must_change_password = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(passwordHash, mustChange ? 1 : 0, id);
}
static updateEmailNotifications(id, enabled) {
const db = getDatabase();
db.prepare('UPDATE users SET email_notifications = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(enabled ? 1 : 0, id);
return this.getById(id);
}
static updateStaffNotifications(id, prefs) {
const db = getDatabase();
db.prepare(`UPDATE users SET
notif_ticket_created = ?,
notif_ticket_assigned = ?,
notif_new_comment = ?,
notif_weekly_report = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`
).run(
prefs.notif_ticket_created ? 1 : 0,
prefs.notif_ticket_assigned ? 1 : 0,
prefs.notif_new_comment ? 1 : 0,
prefs.notif_weekly_report ? 1 : 0,
id
);
return this.getById(id);
}
}
module.exports = User;

View File

@@ -0,0 +1,37 @@
const { getDatabase } = require('../config/database');
const WarehouseLocation = {
getAll() {
const db = getDatabase();
return db.prepare(`SELECT * FROM warehouse_locations ORDER BY name`).all();
},
getById(id) {
const db = getDatabase();
return db.prepare(`SELECT * FROM warehouse_locations WHERE id = ?`).get(id);
},
create(data) {
const db = getDatabase();
const result = db.prepare(
`INSERT INTO warehouse_locations (name, description) VALUES (@name, @description)`
).run({ name: data.name, description: data.description || '' });
return this.getById(result.lastInsertRowid);
},
update(id, data) {
const db = getDatabase();
db.prepare(
`UPDATE warehouse_locations SET name = @name, description = @description WHERE id = @id`
).run({ id, name: data.name, description: data.description || '' });
return this.getById(id);
},
delete(id) {
const db = getDatabase();
const result = db.prepare(`DELETE FROM warehouse_locations WHERE id = ?`).run(id);
return result.changes > 0;
},
};
module.exports = WarehouseLocation;

View File

@@ -0,0 +1,29 @@
const express = require('express');
const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin, canModifyTickets } = require('../middleware/roleCheck');
const AiController = require('../controllers/ai.controller');
// Alle AI-Routen erfordern Login
router.use(authenticateToken);
// Status (alle eingeloggten User)
router.get('/status', AiController.getStatus);
// Chat (alle Staff-Rollen)
router.post('/chat', AiController.chat);
// Wissensdatenbank lesen: alle Staff
router.get('/knowledge-base', AiController.getKnowledgeBase);
// Wissensdatenbank schreiben/bearbeiten/löschen: nur Admins
router.post('/knowledge-base/import-text', requireAdmin, AiController.importTextToKb);
router.post('/knowledge-base/import-url', requireAdmin, AiController.importUrlToKb);
router.post('/knowledge-base/import-crawl', requireAdmin, AiController.importCrawlToKb);
router.post('/knowledge-base', requireAdmin, AiController.addKnowledgeEntry);
router.put('/knowledge-base/:id', requireAdmin, AiController.updateKnowledgeEntry);
router.post('/knowledge-base/:id/images', requireAdmin, AiController.uploadKbImage);
router.delete('/knowledge-base/:id/images/:filename', requireAdmin, AiController.deleteKbImage);
router.delete('/knowledge-base/:id', requireAdmin, AiController.deleteKnowledgeEntry);
module.exports = router;

View File

@@ -0,0 +1,19 @@
const express = require('express');
const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
const ctrl = require('../controllers/announcement.controller');
// Agent-Callback (kein JWT, nur X-Agent-Key)
router.post('/:id/ack-agent', ctrl.agentAck);
router.use(authenticateToken);
router.get('/active', ctrl.getActive); // alle eingeloggten User (Notification Bell)
router.post('/:id/ack', ctrl.userAck); // Web-User bestätigt Ankündigung
router.get('/', requireAdmin, ctrl.getAll);
router.post('/', requireAdmin, ctrl.create);
router.put('/:id', requireAdmin, ctrl.update);
router.delete('/:id', requireAdmin, ctrl.remove);
router.get('/:id/acks', requireAdmin, ctrl.getAcks);
module.exports = router;

View File

@@ -0,0 +1,39 @@
const express = require('express');
const router = express.Router();
const AssetController = require('../controllers/asset.controller');
const { authenticateToken } = require('../middleware/auth');
const { canViewAssets, canModifyAssets } = require('../middleware/roleCheck');
// All asset routes require authentication
router.use(authenticateToken);
// Own assets any authenticated user
router.get('/mine', AssetController.getMyAssets);
// Statistics (Super Admin, Admin, Bearbeiter can view)
router.get('/stats', canViewAssets, AssetController.getStatistics);
// View routes (Super Admin, Admin, Bearbeiter can view)
router.get('/', canViewAssets, AssetController.getAllAssets);
router.get('/status/:status', canViewAssets, AssetController.getAssetsByStatus);
router.get('/type/:type', canViewAssets, AssetController.getAssetsByType);
router.get('/serial/:serialNumber', canViewAssets, AssetController.getAssetBySerial);
router.get('/:id/label', canViewAssets, AssetController.generateLabel);
router.get('/:id/handover-protocol', canViewAssets, AssetController.generateHandoverProtocol);
router.get('/:id/history', canViewAssets, AssetController.getAssetAssignmentHistory);
router.get('/:id', canViewAssets, AssetController.getAssetById);
// Inspection routes
router.get('/:id/inspections', canViewAssets, AssetController.getInspections);
router.post('/:id/inspections', canModifyAssets, AssetController.createInspection);
// Modification routes (Super Admin, Admin only)
router.post('/:id/sync-agent', canModifyAssets, AssetController.syncFromAgent);
router.post('/import/intune', canModifyAssets, AssetController.importFromIntune);
router.post('/', canModifyAssets, AssetController.createAsset);
router.put('/:id', canModifyAssets, AssetController.updateAsset);
router.post('/:id/assign', canModifyAssets, AssetController.assignAsset);
router.post('/:id/unassign', canModifyAssets, AssetController.unassignAsset);
router.delete('/:id', canModifyAssets, AssetController.deleteAsset);
module.exports = router;

View File

@@ -0,0 +1,14 @@
const express = require('express');
const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
const ctrl = require('../controllers/assetType.controller');
router.use(authenticateToken);
router.get('/', ctrl.getAll);
router.post('/', requireAdmin, ctrl.create);
router.put('/:id', requireAdmin, ctrl.update);
router.delete('/:id', requireAdmin, ctrl.remove);
module.exports = router;

View File

@@ -0,0 +1,16 @@
const express = require('express');
const router = express.Router();
const AuthController = require('../controllers/auth.controller');
const { authenticateToken } = require('../middleware/auth');
// Public routes
router.post('/login', AuthController.login);
router.post('/logout', AuthController.logout);
// Protected routes
router.get('/me', authenticateToken, AuthController.getCurrentUser);
router.post('/change-password', authenticateToken, AuthController.changePassword);
router.put('/notifications', authenticateToken, AuthController.updateNotifications);
router.put('/staff-notifications', authenticateToken, AuthController.updateStaffNotifications);
module.exports = router;

View File

@@ -0,0 +1,8 @@
const express = require('express');
const router = express.Router();
const { messages } = require('../controllers/bot.controller');
// Teams sendet POST an /api/bot/messages — kein Auth-Middleware (botbuilder prüft selbst)
router.post('/messages', messages);
module.exports = router;

View File

@@ -0,0 +1,13 @@
const express = require('express');
const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
const ctrl = require('../controllers/docker.controller');
router.use(authenticateToken, requireAdmin);
router.get('/containers', ctrl.getContainers);
router.get('/stats', ctrl.getStats);
router.get('/containers/:id/logs', ctrl.getLogs);
router.post('/containers/:id/:action', ctrl.containerAction);
module.exports = router;

View File

@@ -0,0 +1,26 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/entra.controller');
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
const { asyncHandler } = require('../middleware/errorHandler');
router.use(authenticateToken);
router.use(requireAdmin);
router.get('/users', asyncHandler(ctrl.getUsers));
router.get('/users/:id/groups', asyncHandler(ctrl.getUserGroups));
router.get('/groups', asyncHandler(ctrl.getGroups));
router.get('/groups/:id/members', asyncHandler(ctrl.getGroupMembers));
router.post('/groups/:id/members', asyncHandler(ctrl.addGroupMember));
router.delete('/groups/:id/members/:userId', asyncHandler(ctrl.removeGroupMember));
router.get('/roles', asyncHandler(ctrl.getRoles));
// Benutzerverwaltung — pro User
router.get('/users/:azureId/profile', asyncHandler(ctrl.getUserProfile));
router.get('/users/:azureId/licenses', asyncHandler(ctrl.getUserLicenses));
router.get('/users/:azureId/signin-log', asyncHandler(ctrl.getUserSignInLog));
router.get('/users/:azureId/signin-risk', asyncHandler(ctrl.getUserSignInRisk));
router.post('/users/:azureId/invalidate-sessions', asyncHandler(ctrl.invalidateSessions));
router.get('/conditional-access-policies', asyncHandler(ctrl.getCAP));
module.exports = router;

View File

@@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
const ctrl = require('../controllers/externalAlert.controller');
router.get('/', authenticateToken, requireAdmin, ctrl.getAll);
router.post('/:id/acknowledge', authenticateToken, requireAdmin, ctrl.acknowledge);
router.post('/:id/create-ticket', authenticateToken, requireAdmin, ctrl.createTicket);
router.delete('/:id', authenticateToken, requireAdmin, ctrl.remove);
module.exports = router;

View File

@@ -0,0 +1,27 @@
const express = require('express');
const router = express.Router();
const FidoKeyController = require('../controllers/fidoKey.controller');
const { authenticateToken } = require('../middleware/auth');
const { canViewFidoKeys, canModifyFidoKeys, requireAdmin } = require('../middleware/roleCheck');
// All FIDO key routes require authentication
router.use(authenticateToken);
// Statistics (all authenticated users can view)
router.get('/stats', canViewFidoKeys, FidoKeyController.getStatistics);
// View routes (all authenticated users can view)
router.get('/', canViewFidoKeys, FidoKeyController.getAllKeys);
router.get('/status/:status', canViewFidoKeys, FidoKeyController.getKeysByStatus);
router.get('/serial/:serialNumber', canViewFidoKeys, FidoKeyController.getKeyBySerial);
router.get('/:id', canViewFidoKeys, FidoKeyController.getKeyById);
// Modification routes (Super Admin, Admin, Bearbeiter)
router.post('/', canModifyFidoKeys, FidoKeyController.createKey);
router.put('/:id', canModifyFidoKeys, FidoKeyController.updateKey);
router.put('/:id/status', canModifyFidoKeys, FidoKeyController.updateKeyStatus);
// Delete route (Super Admin and Admin only)
router.delete('/:id', requireAdmin, FidoKeyController.deleteKey);
module.exports = router;

View File

@@ -0,0 +1,16 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/isoTask.controller');
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
const { asyncHandler } = require('../middleware/errorHandler');
router.use(authenticateToken);
router.get('/', asyncHandler(ctrl.getAll));
router.get('/:id', asyncHandler(ctrl.getOne));
router.post('/', requireAdmin, asyncHandler(ctrl.create));
router.put('/:id', requireAdmin, asyncHandler(ctrl.update));
router.delete('/:id', requireAdmin, asyncHandler(ctrl.remove));
module.exports = router;

View File

@@ -0,0 +1,18 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/itTopic.controller');
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
const { asyncHandler } = require('../middleware/errorHandler');
router.use(authenticateToken);
// Read: admin + support; Write: admin only
router.post('/planner/sync', requireAdmin, asyncHandler(ctrl.syncToPlanner));
router.get('/', asyncHandler(ctrl.getAll));
router.get('/:id', asyncHandler(ctrl.getOne));
router.post('/', requireAdmin, asyncHandler(ctrl.create));
router.put('/:id', requireAdmin, asyncHandler(ctrl.update));
router.delete('/:id', requireAdmin, asyncHandler(ctrl.remove));
module.exports = router;

View File

@@ -0,0 +1,14 @@
const express = require('express');
const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
const ctrl = require('../controllers/knowledge.controller');
router.use(authenticateToken);
router.get('/', ctrl.getAll);
router.get('/category/:category', ctrl.getByCategory);
router.post('/', requireAdmin, ctrl.create);
router.put('/:id', requireAdmin, ctrl.update);
router.delete('/:id', requireAdmin, ctrl.remove);
module.exports = router;

View File

@@ -0,0 +1,18 @@
const express = require('express');
const router = express.Router();
const LicenseController = require('../controllers/license.controller');
const { authenticateToken } = require('../middleware/auth');
const { canViewAssets, canModifyAssets } = require('../middleware/roleCheck');
router.use(authenticateToken);
router.get('/statistics', canViewAssets, LicenseController.getStatistics);
router.post('/import/entra', canModifyAssets, LicenseController.importFromEntra);
router.get('/', canViewAssets, LicenseController.getAll);
router.get('/:id/users', canViewAssets, LicenseController.getLicenseUsers);
router.get('/:id', canViewAssets, LicenseController.getById);
router.post('/', canModifyAssets, LicenseController.create);
router.put('/:id', canModifyAssets, LicenseController.update);
router.delete('/:id', canModifyAssets, LicenseController.delete);
module.exports = router;

View File

@@ -0,0 +1,290 @@
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const TENANT_ID = () => process.env.AZURE_TENANT_ID;
const CLIENT_ID = () => process.env.AZURE_CLIENT_ID;
const CLIENT_SECRET = () => process.env.AZURE_CLIENT_SECRET;
const REDIRECT_URI = () => process.env.AZURE_REDIRECT_URI;
const FRONTEND_URL = () => process.env.FRONTEND_URL || 'https://localhost';
// In-memory CSRF state storage (cleared after 10 minutes)
const pendingStates = new Map();
// In-memory result storage for addin polling (sessionId -> result, TTL 10min)
const addinResults = new Map();
// ── GET /api/auth/microsoft ────────────────────────────────────────────────
// Redirects user to Microsoft login page
router.get('/', (req, res) => {
if (!TENANT_ID() || !CLIENT_ID()) {
return res.redirect(`${FRONTEND_URL()}/login?error=azure_not_configured`);
}
const state = crypto.randomBytes(16).toString('hex');
const redirect = req.query.redirect || null;
pendingStates.set(state, { ts: Date.now(), redirect });
setTimeout(() => pendingStates.delete(state), 10 * 60 * 1000);
const params = new URLSearchParams({
client_id: CLIENT_ID(),
response_type: 'code',
redirect_uri: REDIRECT_URI(),
scope: 'openid profile email User.Read',
state,
response_mode: 'query',
prompt: 'select_account',
});
res.redirect(`https://login.microsoftonline.com/${TENANT_ID()}/oauth2/v2.0/authorize?${params}`);
});
// ── GET /api/auth/microsoft/callback ──────────────────────────────────────
// Microsoft redirects here after login
router.get('/callback', async (req, res) => {
const { code, state, error, error_description } = req.query;
if (error) {
console.error('[Azure] OAuth error:', error, error_description);
return res.redirect(`${FRONTEND_URL()}/login?error=auth_failed`);
}
if (!code || !pendingStates.has(state)) {
return res.redirect(`${FRONTEND_URL()}/login?error=invalid_state`);
}
const stateData = pendingStates.get(state);
const redirectTarget = stateData?.redirect || null;
pendingStates.delete(state);
try {
// 1. Exchange code for access token
const tokenRes = await fetch(
`https://login.microsoftonline.com/${TENANT_ID()}/oauth2/v2.0/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: CLIENT_ID(),
client_secret: CLIENT_SECRET(),
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI(),
scope: 'openid profile email User.Read',
}),
}
);
if (!tokenRes.ok) {
const errText = await tokenRes.text();
console.error('[Azure] Token exchange failed:', errText);
return res.redirect(`${FRONTEND_URL()}/login?error=token_failed`);
}
const tokens = await tokenRes.json();
// 2. Get user profile from Microsoft Graph
const meRes = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
if (!meRes.ok) {
console.error('[Azure] Graph /me failed');
return res.redirect(`${FRONTEND_URL()}/login?error=graph_failed`);
}
const msUser = await meRes.json();
const email = (msUser.mail || msUser.userPrincipalName || '').toLowerCase();
const azureId = msUser.id;
if (!email || !azureId) {
return res.redirect(`${FRONTEND_URL()}/login?error=no_email`);
}
// 3. Find or create local user
let user = User.getByAzureId(azureId);
if (!user) {
user = User.getByEmail(email);
if (user) {
// Link existing user to Azure
User.update(user.id, { azure_id: azureId });
user = User.getById(user.id);
} else {
// Create new user with 'benutzer' role
const baseUsername = email.split('@')[0].replace(/[^a-zA-Z0-9._-]/g, '');
let username = baseUsername;
let counter = 1;
while (User.getByUsername(username)) {
username = `${baseUsername}${counter++}`;
}
user = User.createFromAzure({
azure_id: azureId,
email,
first_name: msUser.givenName || '',
last_name: msUser.surname || '',
username,
});
}
}
if (!user.is_active) {
return res.redirect(`${FRONTEND_URL()}/login?error=account_inactive`);
}
// 4. Issue our own JWT
User.updateLastLogin(user.id);
const token = jwt.sign(
{ id: user.id, username: user.username, email: user.email, role: user.role_name, roleId: user.role_id },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRATION || '8h' }
);
const userPayload = encodeURIComponent(JSON.stringify({
id: user.id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role_name: user.role_name,
must_change_password: user.must_change_password || false,
}));
// 5. Redirect to correct target
if (redirectTarget === 'share-tool') {
return res.redirect(`${FRONTEND_URL()}/share-tool.html?token=${token}`);
}
res.redirect(`${FRONTEND_URL()}/login?token=${token}&user=${userPayload}`);
} catch (err) {
console.error('[Azure] Callback error:', err);
res.redirect(`${FRONTEND_URL()}/login?error=internal_error`);
}
});
// ── GET /api/auth/microsoft/addin ─────────────────────────────────────────
// Startet OAuth-Flow für Outlook Add-in (sessionId wird mitgegeben)
router.get('/addin', (req, res) => {
if (!TENANT_ID() || !CLIENT_ID()) {
return res.send('<p>Azure nicht konfiguriert.</p>');
}
const sessionId = req.query.sessionId || '';
const state = crypto.randomBytes(16).toString('hex');
// Store sessionId alongside state so callback can find it
pendingStates.set(state, { ts: Date.now(), sessionId });
setTimeout(() => pendingStates.delete(state), 10 * 60 * 1000);
const params = new URLSearchParams({
client_id: CLIENT_ID(),
response_type: 'code',
redirect_uri: `${FRONTEND_URL()}/api/auth/microsoft/addin-callback`,
scope: 'openid profile email User.Read',
state,
response_mode: 'query',
prompt: 'select_account',
});
res.redirect(`https://login.microsoftonline.com/${TENANT_ID()}/oauth2/v2.0/authorize?${params}`);
});
// ── GET /api/auth/microsoft/addin-callback ────────────────────────────────
// Callback speichert Ergebnis für Polling, kein messageParent nötig
router.get('/addin-callback', async (req, res) => {
const { code, state, error } = req.query;
const storeResult = (sessionId, payload) => {
if (sessionId) {
addinResults.set(sessionId, payload);
setTimeout(() => addinResults.delete(sessionId), 10 * 60 * 1000);
}
res.send(`<!DOCTYPE html>
<html><head><meta charset="UTF-8">
<style>body{background:#fff;color:#333;font-family:sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;margin:0;gap:12px;}
p{font-size:15px;}small{color:#888;font-size:12px;}</style>
</head><body>
<p>${payload.error ? '&#10060; Anmeldung fehlgeschlagen: ' + payload.error : '&#9989; Anmeldung erfolgreich!'}</p>
<small>${payload.error ? 'Bitte schließen Sie dieses Fenster.' : 'Wird automatisch geschlossen\u2026'}</small>
</body></html>`);
};
const stateData = pendingStates.get(state);
if (error || !code || !stateData) {
const sessionId = stateData?.sessionId || '';
pendingStates.delete(state);
return storeResult(sessionId, { error: error || 'invalid_state' });
}
const sessionId = stateData.sessionId;
pendingStates.delete(state);
try {
const tokenRes = await fetch(
`https://login.microsoftonline.com/${TENANT_ID()}/oauth2/v2.0/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: CLIENT_ID(),
client_secret: CLIENT_SECRET(),
grant_type: 'authorization_code',
code,
redirect_uri: `${FRONTEND_URL()}/api/auth/microsoft/addin-callback`,
scope: 'openid profile email User.Read',
}),
}
);
if (!tokenRes.ok) throw new Error('token_failed');
const tokens = await tokenRes.json();
const meRes = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
if (!meRes.ok) throw new Error('graph_failed');
const msUser = await meRes.json();
const email = (msUser.mail || msUser.userPrincipalName || '').toLowerCase();
const azureId = msUser.id;
if (!email || !azureId) return storeResult(sessionId, { error: 'no_email' });
let user = User.getByAzureId(azureId) || User.getByEmail(email);
if (!user) return storeResult(sessionId, { error: 'user_not_found' });
if (!user.is_active) return storeResult(sessionId, { error: 'account_inactive' });
User.updateLastLogin(user.id);
const token = jwt.sign(
{ id: user.id, username: user.username, email: user.email, role: user.role_name, roleId: user.role_id },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRATION || '8h' }
);
storeResult(sessionId, {
token,
user: {
id: user.id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role_name: user.role_name,
},
});
} catch (err) {
storeResult(sessionId, { error: err.message || 'internal_error' });
}
});
// ── GET /api/auth/microsoft/addin-result/:sessionId ───────────────────────
// Taskpane pollt diesen Endpoint bis das Login-Ergebnis vorliegt
router.get('/addin-result/:sessionId', (req, res) => {
const { sessionId } = req.params;
const result = addinResults.get(sessionId);
if (!result) return res.json({ pending: true });
addinResults.delete(sessionId);
res.json(result);
});
module.exports = router;

View File

@@ -0,0 +1,29 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/monitoringAgent.controller');
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
// Agent checkin — no JWT, uses X-Agent-Key header
router.post('/checkin', ctrl.checkin);
// Schneller Announcement-Poll (leichtgewichtig, alle 15 Sek)
router.post('/announcements-poll', ctrl.announcementsPoll);
// Agent self-update script download — X-Agent-Key auth
router.get('/agent-script', ctrl.downloadScript);
// Announcement Watcher Script download — X-Agent-Key auth
router.get('/ann-watcher', ctrl.downloadAnnWatcher);
// Agent Setup EXE download — public, kein Auth nötig
router.get('/agent-setup', ctrl.downloadSetup);
// Dashboard endpoints — require JWT + admin
router.use(authenticateToken);
router.get('/statistics', requireAdmin, ctrl.getStatistics);
router.get('/', requireAdmin, ctrl.getAll);
router.get('/:id', requireAdmin, ctrl.getById);
router.delete('/:id', requireAdmin, ctrl.deleteAgent);
module.exports = router;

View File

@@ -0,0 +1,32 @@
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const JWT_CONFIG = require('../config/jwt');
const { requireAdmin } = require('../middleware/roleCheck');
const ctrl = require('../controllers/networkMonitor.controller');
// Auth that also accepts token from query param (needed for SSE / EventSource)
function authTokenOrQuery(req, res, next) {
const authHeader = req.headers['authorization'];
const token = (authHeader && authHeader.split(' ')[1]) || req.query.token;
if (!token) return res.status(401).json({ status: 'error', message: 'Token required' });
try {
req.user = jwt.verify(token, JWT_CONFIG.secret);
next();
} catch {
res.status(403).json({ status: 'error', message: 'Invalid token' });
}
}
router.post('/discover', authTokenOrQuery, requireAdmin, ctrl.discover);
router.get('/sse', authTokenOrQuery, requireAdmin, ctrl.sseStream);
router.get('/statistics', authTokenOrQuery, requireAdmin, ctrl.getStatistics);
router.get('/uptime-stats', authTokenOrQuery, requireAdmin, ctrl.getAllUptimeStats);
router.get('/', authTokenOrQuery, requireAdmin, ctrl.getAll);
router.post('/', authTokenOrQuery, requireAdmin, ctrl.createDevice);
router.get('/:id/checks', authTokenOrQuery, requireAdmin, ctrl.getChecks);
router.post('/:id/check-now', authTokenOrQuery, requireAdmin, ctrl.checkNow);
router.put('/:id', authTokenOrQuery, requireAdmin, ctrl.updateDevice);
router.delete('/:id', authTokenOrQuery, requireAdmin, ctrl.deleteDevice);
module.exports = router;

View File

@@ -0,0 +1,18 @@
const express = require('express');
const router = express.Router();
const OffboardingController = require('../controllers/offboarding.controller');
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin, canViewOnboarding } = require('../middleware/roleCheck');
router.use(authenticateToken);
router.get('/stats', canViewOnboarding, OffboardingController.getStatistics);
router.get('/', canViewOnboarding, OffboardingController.getAllProtocols);
router.get('/:id', canViewOnboarding, OffboardingController.getProtocolById);
router.post('/', canViewOnboarding, OffboardingController.createProtocol);
router.put('/:id', canViewOnboarding, OffboardingController.updateProtocol);
router.post('/:id/return-assets', requireAdmin, OffboardingController.returnAssets);
router.post('/:id/regenerate-pdf', requireAdmin, OffboardingController.regeneratePdf);
router.delete('/:id', canViewOnboarding, OffboardingController.deleteProtocol);
module.exports = router;

View File

@@ -0,0 +1,40 @@
const express = require('express');
const router = express.Router();
const OnboardingController = require('../controllers/onboarding.controller');
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin, canViewOnboarding } = require('../middleware/roleCheck');
const { getDepartmentManager, searchEntraUserByName } = require('../services/graph.service');
const { asyncHandler } = require('../middleware/errorHandler');
// Public: employee confirms handover via token link (no auth needed)
router.get('/confirm/:token', OnboardingController.confirmByToken);
router.use(authenticateToken);
// Entra: find manager for a department (accessible to all onboarding roles)
router.get('/entra-manager', canViewOnboarding, asyncHandler(async (req, res) => {
const { department } = req.query;
const manager = await getDepartmentManager(department);
res.json({ status: 'success', data: manager });
}));
// Entra: search user by name
router.get('/entra-search', canViewOnboarding, asyncHandler(async (req, res) => {
const { name } = req.query;
const results = await searchEntraUserByName(name);
res.json({ status: 'success', data: results });
}));
// Stats + read endpoints: accessible to dept roles
router.get('/stats', canViewOnboarding, OnboardingController.getStatistics);
router.get('/', canViewOnboarding, OnboardingController.getAllProtocols);
router.get('/:id', canViewOnboarding, OnboardingController.getProtocolById);
// Write endpoints: admins only
router.post('/', canViewOnboarding, OnboardingController.createProtocol);
router.put('/:id', canViewOnboarding, OnboardingController.updateProtocol); // dept roles can save checklist
router.post('/:id/send-confirmation', canViewOnboarding, OnboardingController.sendConfirmationEmail);
router.post('/:id/regenerate-pdf', requireAdmin, OnboardingController.regeneratePdf);
router.delete('/:id', canViewOnboarding, OnboardingController.deleteProtocol);
module.exports = router;

View File

@@ -0,0 +1,24 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/onboardingProcess.controller');
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin, canViewOnboarding } = require('../middleware/roleCheck');
const { asyncHandler } = require('../middleware/errorHandler');
router.use(authenticateToken);
// Departments read: all onboarding roles, write: admin only
router.get('/departments', canViewOnboarding, asyncHandler(ctrl.getDepartments));
router.post('/departments', requireAdmin, asyncHandler(ctrl.createDepartment));
router.put('/departments/reorder', requireAdmin, asyncHandler(ctrl.reorderDepartments));
router.put('/departments/:id', requireAdmin, asyncHandler(ctrl.updateDepartment));
router.delete('/departments/:id', requireAdmin, asyncHandler(ctrl.deleteDepartment));
// Processes read: all onboarding roles, write: all onboarding roles (dept groups manage own)
router.get('/processes', canViewOnboarding, asyncHandler(ctrl.getProcesses));
router.post('/processes', canViewOnboarding, asyncHandler(ctrl.createProcess));
router.put('/processes/reorder', canViewOnboarding, asyncHandler(ctrl.reorderProcesses));
router.put('/processes/:id', canViewOnboarding, asyncHandler(ctrl.updateProcess));
router.delete('/processes/:id', canViewOnboarding, asyncHandler(ctrl.deleteProcess));
module.exports = router;

View File

@@ -0,0 +1,29 @@
const express = require('express');
const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
const ctrl = require('../controllers/patch.controller');
// Agent-Callback — kein JWT, nur X-Agent-Key
router.post('/commands/result', ctrl.reportCommandResult);
router.use(authenticateToken);
router.get('/overview', ctrl.getOverview);
router.get('/groups', ctrl.getGroups);
router.post('/groups', requireAdmin, ctrl.createGroup);
router.put('/groups/:id', requireAdmin, ctrl.updateGroup);
router.delete('/groups/:id', requireAdmin, ctrl.deleteGroup);
router.post('/policies', requireAdmin, ctrl.upsertPolicy);
router.delete('/policies/:id', requireAdmin, ctrl.deletePolicy);
router.post('/assign', requireAdmin, ctrl.assignAgent);
router.post('/groups/:id/release', requireAdmin, ctrl.releaseVersionToGroup);
router.get('/commands', ctrl.getCommands);
router.post('/commands/trigger', requireAdmin, ctrl.triggerCommand);
router.post('/commands/trigger-group', requireAdmin, ctrl.triggerGroupCommand);
// (result route is before authenticateToken above)
module.exports = router;

View File

@@ -0,0 +1,16 @@
const express = require('express');
const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
const ctrl = require('../controllers/portalGuide.controller');
router.use(authenticateToken);
router.get('/', ctrl.getAll);
router.get('/:id', ctrl.getById);
router.get('/:id/html', ctrl.getHtml);
router.post('/', requireAdmin, ctrl.create);
router.put('/:id', requireAdmin, ctrl.update);
router.delete('/:id', requireAdmin, ctrl.remove);
module.exports = router;

View File

@@ -0,0 +1,10 @@
const express = require('express');
const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
const ctrl = require('../controllers/proxmox.controller');
router.use(authenticateToken);
router.get('/overview', requireAdmin, ctrl.getOverview);
module.exports = router;

View File

@@ -0,0 +1,17 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/risk.controller');
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
const { asyncHandler } = require('../middleware/errorHandler');
router.use(authenticateToken);
router.get('/stats', asyncHandler(ctrl.getStats));
router.get('/', asyncHandler(ctrl.getAll));
router.post('/sync', requireAdmin, asyncHandler(ctrl.sync));
router.post('/', requireAdmin, asyncHandler(ctrl.create));
router.put('/:id', requireAdmin, asyncHandler(ctrl.update));
router.delete('/:id', requireAdmin, asyncHandler(ctrl.remove));
module.exports = router;

View File

@@ -0,0 +1,17 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/scanner.controller');
const { authenticateToken } = require('../middleware/auth');
// Scanner push endpoints — no JWT, uses X-Scanner-Key header
router.post('/assets', ctrl.reportAssets);
router.post('/alert', ctrl.reportAlert);
// Read endpoints — require JWT
router.use(authenticateToken);
router.get('/sites', ctrl.getSites);
router.get('/assets', ctrl.getAssets);
router.get('/alerts', ctrl.getAlerts);
router.get('/stats', ctrl.getStats);
module.exports = router;

View File

@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/securityReport.controller');
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
router.use(authenticateToken);
router.use(requireAdmin);
router.post('/upload', ctrl.uploadReport);
router.get('/', ctrl.getAllReports);
router.get('/:id', ctrl.getReport);
router.delete('/:id', ctrl.deleteReport);
module.exports = router;

View File

@@ -0,0 +1,33 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/settings.controller');
const { authenticateToken } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roleCheck');
router.use(authenticateToken);
// Categories GET is open to all authenticated users (needed for ticket creation)
router.get('/categories', ctrl.getCategories);
router.post('/categories', requireAdmin, ctrl.createCategory);
router.put('/categories/reorder', requireAdmin, ctrl.reorderCategories);
router.put('/categories/:id', requireAdmin, ctrl.updateCategory);
router.delete('/categories/:id', requireAdmin, ctrl.deleteCategory);
// Templates GET open to all, write admin only
router.get('/templates', ctrl.getTemplates);
router.post('/templates', requireAdmin, ctrl.createTemplate);
router.put('/templates/:id', requireAdmin, ctrl.updateTemplate);
router.delete('/templates/:id', requireAdmin, ctrl.deleteTemplate);
// Email templates admin only
router.get('/email-templates', requireAdmin, ctrl.getEmailTemplates);
router.put('/email-templates/:type', requireAdmin, ctrl.updateEmailTemplate);
router.post('/email-templates/:type/reset', requireAdmin, ctrl.resetEmailTemplate);
// Email design admin only
router.get('/email-design', requireAdmin, ctrl.getEmailDesign);
router.put('/email-design', requireAdmin, ctrl.updateEmailDesign);
router.post('/email-design/reset', requireAdmin, ctrl.resetEmailDesign);
router.post('/email-preview/:type', requireAdmin, ctrl.previewEmail);
module.exports = router;

View File

@@ -0,0 +1,24 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/share.controller');
const { authenticateToken } = require('../middleware/auth');
const requireAdmin = (req, res, next) => {
if (!['super_admin', 'admin'].includes(req.user?.role_name)) {
return res.status(403).json({ error: 'Kein Zugriff' });
}
next();
};
// Public routes (kein Login nötig)
router.get('/public/:token', ctrl.getPublicShare);
router.post('/public/:token/access', ctrl.accessShare);
router.get('/public/:token/file', ctrl.downloadFile);
// Protected routes
router.use(authenticateToken);
router.post('/', ctrl.createShare);
router.get('/', requireAdmin, ctrl.getAllShares);
router.delete('/:id', requireAdmin, ctrl.deleteShare);
module.exports = router;

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