Initial commit: IT Nexus Web-App

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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