Initial commit: IT Nexus Web-App
This commit is contained in:
7
backend/.dockerignore
Normal file
7
backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
database.sqlite
|
||||
20
backend/.env.example
Normal file
20
backend/.env.example
Normal 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
26
backend/Dockerfile
Normal 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
3469
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
backend/package.json
Normal file
46
backend/package.json
Normal 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
128
backend/seed-knowledge.js
Normal 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);
|
||||
41
backend/src/config/database.js
Normal file
41
backend/src/config/database.js
Normal 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
14
backend/src/config/jwt.js
Normal 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;
|
||||
357
backend/src/controllers/ai.controller.js
Normal file
357
backend/src/controllers/ai.controller.js
Normal 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(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/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(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/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;
|
||||
169
backend/src/controllers/announcement.controller.js
Normal file
169
backend/src/controllers/announcement.controller.js
Normal 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 };
|
||||
525
backend/src/controllers/asset.controller.js
Normal file
525
backend/src/controllers/asset.controller.js
Normal 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;
|
||||
49
backend/src/controllers/assetType.controller.js
Normal file
49
backend/src/controllers/assetType.controller.js
Normal 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 };
|
||||
116
backend/src/controllers/auth.controller.js
Normal file
116
backend/src/controllers/auth.controller.js
Normal 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;
|
||||
404
backend/src/controllers/bot.controller.js
Normal file
404
backend/src/controllers/bot.controller.js
Normal 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 => ``).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 };
|
||||
122
backend/src/controllers/docker.controller.js
Normal file
122
backend/src/controllers/docker.controller.js
Normal 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}×tamps=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 };
|
||||
198
backend/src/controllers/entra.controller.js
Normal file
198
backend/src/controllers/entra.controller.js
Normal 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 });
|
||||
}
|
||||
};
|
||||
73
backend/src/controllers/externalAlert.controller.js
Normal file
73
backend/src/controllers/externalAlert.controller.js
Normal 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 };
|
||||
165
backend/src/controllers/fidoKey.controller.js
Normal file
165
backend/src/controllers/fidoKey.controller.js
Normal 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;
|
||||
30
backend/src/controllers/isoTask.controller.js
Normal file
30
backend/src/controllers/isoTask.controller.js
Normal 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 });
|
||||
};
|
||||
45
backend/src/controllers/itTopic.controller.js
Normal file
45
backend/src/controllers/itTopic.controller.js
Normal 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 });
|
||||
};
|
||||
55
backend/src/controllers/knowledge.controller.js
Normal file
55
backend/src/controllers/knowledge.controller.js
Normal 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 };
|
||||
180
backend/src/controllers/license.controller.js
Normal file
180
backend/src/controllers/license.controller.js
Normal 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;
|
||||
226
backend/src/controllers/monitoringAgent.controller.js
Normal file
226
backend/src/controllers/monitoringAgent.controller.js
Normal 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 };
|
||||
239
backend/src/controllers/networkMonitor.controller.js
Normal file
239
backend/src/controllers/networkMonitor.controller.js
Normal 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 };
|
||||
69
backend/src/controllers/offboarding.controller.js
Normal file
69
backend/src/controllers/offboarding.controller.js
Normal 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;
|
||||
85
backend/src/controllers/onboarding.controller.js
Normal file
85
backend/src/controllers/onboarding.controller.js
Normal 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;
|
||||
95
backend/src/controllers/onboardingProcess.controller.js
Normal file
95
backend/src/controllers/onboardingProcess.controller.js
Normal 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' });
|
||||
};
|
||||
303
backend/src/controllers/patch.controller.js
Normal file
303
backend/src/controllers/patch.controller.js
Normal 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,
|
||||
};
|
||||
106
backend/src/controllers/portalGuide.controller.js
Normal file
106
backend/src/controllers/portalGuide.controller.js
Normal 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 };
|
||||
12
backend/src/controllers/proxmox.controller.js
Normal file
12
backend/src/controllers/proxmox.controller.js
Normal 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 };
|
||||
153
backend/src/controllers/risk.controller.js
Normal file
153
backend/src/controllers/risk.controller.js
Normal 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 });
|
||||
};
|
||||
150
backend/src/controllers/scanner.controller.js
Normal file
150
backend/src/controllers/scanner.controller.js
Normal 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 };
|
||||
148
backend/src/controllers/securityReport.controller.js
Normal file
148
backend/src/controllers/securityReport.controller.js
Normal 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 };
|
||||
162
backend/src/controllers/settings.controller.js
Normal file
162
backend/src/controllers/settings.controller.js
Normal 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 });
|
||||
}
|
||||
};
|
||||
209
backend/src/controllers/share.controller.js
Normal file
209
backend/src/controllers/share.controller.js
Normal 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 };
|
||||
33
backend/src/controllers/teamsActivity.controller.js
Normal file
33
backend/src/controllers/teamsActivity.controller.js
Normal 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;
|
||||
781
backend/src/controllers/ticket.controller.js
Normal file
781
backend/src/controllers/ticket.controller.js
Normal 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;
|
||||
76
backend/src/controllers/tv.controller.js
Normal file
76
backend/src/controllers/tv.controller.js
Normal 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 };
|
||||
56
backend/src/controllers/unifi.controller.js
Normal file
56
backend/src/controllers/unifi.controller.js
Normal 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 };
|
||||
420
backend/src/controllers/user.controller.js
Normal file
420
backend/src/controllers/user.controller.js
Normal 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;
|
||||
182
backend/src/controllers/warehouse.controller.js
Normal file
182
backend/src/controllers/warehouse.controller.js
Normal 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
341
backend/src/db/schema.sql
Normal 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
726
backend/src/db/seed.js
Normal 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 };
|
||||
62
backend/src/middleware/auth.js
Normal file
62
backend/src/middleware/auth.js
Normal 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
|
||||
};
|
||||
67
backend/src/middleware/errorHandler.js
Normal file
67
backend/src/middleware/errorHandler.js
Normal 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
|
||||
};
|
||||
124
backend/src/middleware/roleCheck.js
Normal file
124
backend/src/middleware/roleCheck.js
Normal 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
356
backend/src/models/Asset.js
Normal 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;
|
||||
203
backend/src/models/AssetAssignment.js
Normal file
203
backend/src/models/AssetAssignment.js
Normal 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;
|
||||
73
backend/src/models/AssetInspection.js
Normal file
73
backend/src/models/AssetInspection.js
Normal 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;
|
||||
73
backend/src/models/AssetMovement.js
Normal file
73
backend/src/models/AssetMovement.js
Normal 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;
|
||||
124
backend/src/models/AuditLog.js
Normal file
124
backend/src/models/AuditLog.js
Normal 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;
|
||||
71
backend/src/models/EmailDesign.js
Normal file
71
backend/src/models/EmailDesign.js
Normal 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 };
|
||||
91
backend/src/models/EmailTemplate.js
Normal file
91
backend/src/models/EmailTemplate.js
Normal 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 };
|
||||
82
backend/src/models/ExternalAlert.js
Normal file
82
backend/src/models/ExternalAlert.js
Normal 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;
|
||||
219
backend/src/models/FidoKey.js
Normal file
219
backend/src/models/FidoKey.js
Normal 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;
|
||||
105
backend/src/models/IsoTask.js
Normal file
105
backend/src/models/IsoTask.js
Normal 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;
|
||||
81
backend/src/models/ItTopic.js
Normal file
81
backend/src/models/ItTopic.js
Normal 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;
|
||||
98
backend/src/models/License.js
Normal file
98
backend/src/models/License.js
Normal 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;
|
||||
138
backend/src/models/MonitoringAgent.js
Normal file
138
backend/src/models/MonitoringAgent.js
Normal 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;
|
||||
105
backend/src/models/NetworkDevice.js
Normal file
105
backend/src/models/NetworkDevice.js
Normal 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;
|
||||
233
backend/src/models/OffboardingProtocol.js
Normal file
233
backend/src/models/OffboardingProtocol.js
Normal 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;
|
||||
257
backend/src/models/OnboardingProcess.js
Normal file
257
backend/src/models/OnboardingProcess.js
Normal 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;
|
||||
292
backend/src/models/OnboardingProtocol.js
Normal file
292
backend/src/models/OnboardingProtocol.js
Normal 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;
|
||||
79
backend/src/models/PurchaseOrder.js
Normal file
79
backend/src/models/PurchaseOrder.js
Normal 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
142
backend/src/models/Risk.js
Normal 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;
|
||||
32
backend/src/models/Role.js
Normal file
32
backend/src/models/Role.js
Normal 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;
|
||||
54
backend/src/models/StockThreshold.js
Normal file
54
backend/src/models/StockThreshold.js
Normal 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;
|
||||
384
backend/src/models/Ticket.js
Normal file
384
backend/src/models/Ticket.js
Normal 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;
|
||||
50
backend/src/models/TicketComment.js
Normal file
50
backend/src/models/TicketComment.js
Normal 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;
|
||||
137
backend/src/models/TicketSettings.js
Normal file
137
backend/src/models/TicketSettings.js
Normal 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
179
backend/src/models/User.js
Normal 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;
|
||||
37
backend/src/models/WarehouseLocation.js
Normal file
37
backend/src/models/WarehouseLocation.js
Normal 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;
|
||||
29
backend/src/routes/ai.routes.js
Normal file
29
backend/src/routes/ai.routes.js
Normal 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;
|
||||
19
backend/src/routes/announcement.routes.js
Normal file
19
backend/src/routes/announcement.routes.js
Normal 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;
|
||||
39
backend/src/routes/asset.routes.js
Normal file
39
backend/src/routes/asset.routes.js
Normal 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;
|
||||
14
backend/src/routes/assetType.routes.js
Normal file
14
backend/src/routes/assetType.routes.js
Normal 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;
|
||||
16
backend/src/routes/auth.routes.js
Normal file
16
backend/src/routes/auth.routes.js
Normal 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;
|
||||
8
backend/src/routes/bot.routes.js
Normal file
8
backend/src/routes/bot.routes.js
Normal 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;
|
||||
13
backend/src/routes/docker.routes.js
Normal file
13
backend/src/routes/docker.routes.js
Normal 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;
|
||||
26
backend/src/routes/entra.routes.js
Normal file
26
backend/src/routes/entra.routes.js
Normal 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;
|
||||
12
backend/src/routes/externalAlert.routes.js
Normal file
12
backend/src/routes/externalAlert.routes.js
Normal 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;
|
||||
27
backend/src/routes/fidoKey.routes.js
Normal file
27
backend/src/routes/fidoKey.routes.js
Normal 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;
|
||||
16
backend/src/routes/isoTask.routes.js
Normal file
16
backend/src/routes/isoTask.routes.js
Normal 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;
|
||||
18
backend/src/routes/itTopic.routes.js
Normal file
18
backend/src/routes/itTopic.routes.js
Normal 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;
|
||||
14
backend/src/routes/knowledge.routes.js
Normal file
14
backend/src/routes/knowledge.routes.js
Normal 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;
|
||||
18
backend/src/routes/license.routes.js
Normal file
18
backend/src/routes/license.routes.js
Normal 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;
|
||||
290
backend/src/routes/microsoft.auth.routes.js
Normal file
290
backend/src/routes/microsoft.auth.routes.js
Normal 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 ? '❌ Anmeldung fehlgeschlagen: ' + payload.error : '✅ 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;
|
||||
29
backend/src/routes/monitoringAgent.routes.js
Normal file
29
backend/src/routes/monitoringAgent.routes.js
Normal 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;
|
||||
32
backend/src/routes/networkMonitor.routes.js
Normal file
32
backend/src/routes/networkMonitor.routes.js
Normal 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;
|
||||
18
backend/src/routes/offboarding.routes.js
Normal file
18
backend/src/routes/offboarding.routes.js
Normal 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;
|
||||
40
backend/src/routes/onboarding.routes.js
Normal file
40
backend/src/routes/onboarding.routes.js
Normal 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;
|
||||
24
backend/src/routes/onboardingProcess.routes.js
Normal file
24
backend/src/routes/onboardingProcess.routes.js
Normal 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;
|
||||
29
backend/src/routes/patch.routes.js
Normal file
29
backend/src/routes/patch.routes.js
Normal 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;
|
||||
16
backend/src/routes/portalGuide.routes.js
Normal file
16
backend/src/routes/portalGuide.routes.js
Normal 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;
|
||||
10
backend/src/routes/proxmox.routes.js
Normal file
10
backend/src/routes/proxmox.routes.js
Normal 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;
|
||||
17
backend/src/routes/risk.routes.js
Normal file
17
backend/src/routes/risk.routes.js
Normal 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;
|
||||
17
backend/src/routes/scanner.routes.js
Normal file
17
backend/src/routes/scanner.routes.js
Normal 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;
|
||||
15
backend/src/routes/securityReport.routes.js
Normal file
15
backend/src/routes/securityReport.routes.js
Normal 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;
|
||||
33
backend/src/routes/settings.routes.js
Normal file
33
backend/src/routes/settings.routes.js
Normal 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;
|
||||
24
backend/src/routes/share.routes.js
Normal file
24
backend/src/routes/share.routes.js
Normal 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
Reference in New Issue
Block a user