Initial commit: IT Nexus Web-App
This commit is contained in:
357
backend/src/controllers/ai.controller.js
Normal file
357
backend/src/controllers/ai.controller.js
Normal file
@@ -0,0 +1,357 @@
|
||||
const { asyncHandler, AppError } = require('../middleware/errorHandler');
|
||||
const AiService = require('../services/ai.service');
|
||||
const { getDatabase } = require('../config/database');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
class AiController {
|
||||
|
||||
/**
|
||||
* GET /api/ai/status
|
||||
* Prüft ob AI konfiguriert ist
|
||||
*/
|
||||
static getStatus = asyncHandler(async (req, res) => {
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: { configured: AiService.isConfigured() }
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ai/chat
|
||||
* Body: { messages: [{role, content}] }
|
||||
*/
|
||||
static chat = asyncHandler(async (req, res) => {
|
||||
if (!AiService.isConfigured()) {
|
||||
throw new AppError('KI-Integration ist nicht konfiguriert (ANTHROPIC_API_KEY fehlt)', 503);
|
||||
}
|
||||
|
||||
const { messages, userMode } = req.body;
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
throw new AppError('messages Array ist erforderlich', 400);
|
||||
}
|
||||
|
||||
// Nur erlaubte Rollen: user und assistant
|
||||
const cleaned = messages
|
||||
.filter(m => ['user', 'assistant'].includes(m.role) && typeof m.content === 'string')
|
||||
.map(m => ({ role: m.role, content: m.content.slice(0, 4000) }));
|
||||
|
||||
if (cleaned.length === 0) {
|
||||
throw new AppError('Keine gültigen Nachrichten', 400);
|
||||
}
|
||||
|
||||
const reply = await AiService.chat(cleaned, !!userMode);
|
||||
res.json({ status: 'success', data: { reply } });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ai/knowledge-base
|
||||
*/
|
||||
static getKnowledgeBase = asyncHandler(async (req, res) => {
|
||||
const db = getDatabase();
|
||||
const entries = db.prepare(`
|
||||
SELECT kb.*, u.username as created_by_username
|
||||
FROM knowledge_base kb
|
||||
LEFT JOIN users u ON kb.created_by_user_id = u.id
|
||||
ORDER BY kb.created_at DESC
|
||||
`).all();
|
||||
res.json({ status: 'success', data: entries });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ai/knowledge-base
|
||||
* Body: { problem, solution, category, tags }
|
||||
*/
|
||||
static addKnowledgeEntry = asyncHandler(async (req, res) => {
|
||||
const { problem, solution, category, tags } = req.body;
|
||||
if (!problem?.trim() || !solution?.trim()) {
|
||||
throw new AppError('problem und solution sind erforderlich', 400);
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO knowledge_base (problem, solution, category, tags, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
problem.trim(),
|
||||
solution.trim(),
|
||||
category || 'Allgemein',
|
||||
tags?.trim() || null,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
const entry = db.prepare('SELECT * FROM knowledge_base WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json({ status: 'success', data: entry });
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/ai/knowledge-base/:id
|
||||
* Body: { problem, solution, category, tags }
|
||||
*/
|
||||
static updateKnowledgeEntry = asyncHandler(async (req, res) => {
|
||||
const db = getDatabase();
|
||||
const entry = db.prepare('SELECT id FROM knowledge_base WHERE id = ?').get(parseInt(req.params.id));
|
||||
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
|
||||
|
||||
const { problem, solution, category, tags } = req.body;
|
||||
if (!problem?.trim() || !solution?.trim()) {
|
||||
throw new AppError('problem und solution sind erforderlich', 400);
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE knowledge_base SET problem = ?, solution = ?, category = ?, tags = ? WHERE id = ?
|
||||
`).run(problem.trim(), solution.trim(), category || 'Allgemein', tags?.trim() || null, parseInt(req.params.id));
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT kb.*, u.username as created_by_username
|
||||
FROM knowledge_base kb LEFT JOIN users u ON kb.created_by_user_id = u.id
|
||||
WHERE kb.id = ?
|
||||
`).get(parseInt(req.params.id));
|
||||
res.json({ status: 'success', data: updated });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ai/knowledge-base/import-text
|
||||
* Body: { text } OR { file: "<base64>", filename: "doc.pdf" }
|
||||
*/
|
||||
static importTextToKb = asyncHandler(async (req, res) => {
|
||||
const AiService = require('../services/ai.service');
|
||||
let text = req.body.text;
|
||||
|
||||
// Handle file upload (base64 encoded)
|
||||
if (!text && req.body.file && req.body.filename) {
|
||||
const { file, filename } = req.body;
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
const match = file.match(/^data:[^;]+;base64,(.+)$/);
|
||||
const buf = Buffer.from(match ? match[1] : file, 'base64');
|
||||
|
||||
if (ext === 'pdf') {
|
||||
const pdfParse = require('pdf-parse');
|
||||
const parsed = await pdfParse(buf);
|
||||
text = parsed.text;
|
||||
} else if (ext === 'docx' || ext === 'doc') {
|
||||
const mammoth = require('mammoth');
|
||||
const result = await mammoth.extractRawText({ buffer: buf });
|
||||
text = result.value;
|
||||
} else if (ext === 'eml' || ext === 'msg') {
|
||||
const { simpleParser } = require('mailparser');
|
||||
const parsed = await simpleParser(buf);
|
||||
text = `Betreff: ${parsed.subject || ''}\nVon: ${parsed.from?.text || ''}\n\n${parsed.text || parsed.html || ''}`;
|
||||
} else if (ext === 'txt') {
|
||||
text = buf.toString('utf-8');
|
||||
} else {
|
||||
throw new AppError('Unterstützte Formate: PDF, DOCX, EML, TXT', 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (!text?.trim()) throw new AppError('text oder Datei ist erforderlich', 400);
|
||||
|
||||
const ids = await AiService.importTextToKb(text, req.user.id);
|
||||
res.json({ status: 'success', data: { created: ids.length, ids } });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ai/knowledge-base/import-url
|
||||
* Body: { url }
|
||||
* Fetches a web page, strips HTML, extracts text, and imports it via AI
|
||||
*/
|
||||
static importUrlToKb = asyncHandler(async (req, res) => {
|
||||
const AiService = require('../services/ai.service');
|
||||
const { url } = req.body;
|
||||
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
|
||||
|
||||
// Only allow http/https
|
||||
if (!/^https?:\/\//i.test(url)) throw new AppError('Nur HTTP/HTTPS URLs erlaubt', 400);
|
||||
|
||||
let html;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 IT-Nexus KnowledgeBase Importer' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) throw new AppError(`Seite nicht erreichbar: HTTP ${response.status}`, 400);
|
||||
html = await response.text();
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) throw err;
|
||||
throw new AppError(`Fehler beim Abrufen der URL: ${err.message}`, 400);
|
||||
}
|
||||
|
||||
// Strip scripts, styles, nav, header, footer then all HTML tags
|
||||
const text = html
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
|
||||
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
|
||||
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (text.length < 100) throw new AppError('Zu wenig Inhalt auf der Seite gefunden', 400);
|
||||
|
||||
const ids = await AiService.importTextToKb(text, req.user.id);
|
||||
res.json({ status: 'success', data: { created: ids.length, ids } });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ai/knowledge-base/import-crawl
|
||||
* Body: { url, maxPages }
|
||||
* Crawls a website (BFS within same domain+path) and imports all pages via AI
|
||||
*/
|
||||
static importCrawlToKb = asyncHandler(async (req, res) => {
|
||||
const AiService = require('../services/ai.service');
|
||||
const { url, maxPages = 20 } = req.body;
|
||||
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
|
||||
if (!/^https?:\/\//i.test(url)) throw new AppError('Nur HTTP/HTTPS URLs erlaubt', 400);
|
||||
|
||||
const limit = Math.min(Math.max(1, parseInt(maxPages) || 20), 100);
|
||||
const baseUrl = new URL(url);
|
||||
const baseDomain = baseUrl.hostname;
|
||||
// basePath: bei index-Seiten oder explizit deaktiviertem Pfad-Filter nur nach Hostname filtern
|
||||
let basePath = baseUrl.pathname.substring(0, baseUrl.pathname.lastIndexOf('/') + 1);
|
||||
if (/\/index\.(html?|htm)$/i.test(baseUrl.pathname)) {
|
||||
basePath = '/'; // Gesamte Domain crawlen wenn Index-Seite
|
||||
}
|
||||
|
||||
const stripHtml = (html) => html
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
|
||||
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
|
||||
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ').trim();
|
||||
|
||||
const visited = new Set();
|
||||
const queue = [url];
|
||||
const texts = [];
|
||||
|
||||
while (queue.length > 0 && visited.size < limit) {
|
||||
const currentUrl = queue.shift();
|
||||
if (visited.has(currentUrl)) continue;
|
||||
visited.add(currentUrl);
|
||||
|
||||
try {
|
||||
const response = await fetch(currentUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 IT-Nexus KnowledgeBase Importer' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) continue;
|
||||
const html = await response.text();
|
||||
|
||||
const text = stripHtml(html);
|
||||
if (text.length > 100) texts.push(text);
|
||||
|
||||
// Extract links within same domain + path prefix
|
||||
const linkRegex = /href="([^"#?]+)"/gi;
|
||||
let match;
|
||||
while ((match = linkRegex.exec(html)) !== null) {
|
||||
try {
|
||||
const linkUrl = new URL(match[1], currentUrl);
|
||||
if (
|
||||
linkUrl.hostname === baseDomain &&
|
||||
linkUrl.pathname.startsWith(basePath) &&
|
||||
!visited.has(linkUrl.href) &&
|
||||
!queue.includes(linkUrl.href)
|
||||
) {
|
||||
queue.push(linkUrl.href);
|
||||
}
|
||||
} catch { /* ignore invalid URLs */ }
|
||||
}
|
||||
} catch { /* ignore fetch errors for individual pages */ }
|
||||
}
|
||||
|
||||
if (texts.length === 0) throw new AppError('Keine Inhalte gefunden', 400);
|
||||
|
||||
// Process in chunks of 40k chars to stay within AI context limits
|
||||
const CHUNK = 40000;
|
||||
const combined = texts.join('\n\n---\n\n');
|
||||
let allIds = [];
|
||||
for (let i = 0; i < combined.length; i += CHUNK) {
|
||||
const chunk = combined.slice(i, i + CHUNK);
|
||||
if (chunk.trim().length > 100) {
|
||||
const ids = await AiService.importTextToKb(chunk, req.user.id);
|
||||
allIds = allIds.concat(ids);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ status: 'success', data: { created: allIds.length, pages: visited.size, ids: allIds } });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ai/knowledge-base/:id/images
|
||||
* Body: { image: "<base64>", filename: "foto.png" }
|
||||
*/
|
||||
static uploadKbImage = asyncHandler(async (req, res) => {
|
||||
const db = getDatabase();
|
||||
const id = parseInt(req.params.id);
|
||||
const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(id);
|
||||
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
|
||||
|
||||
const { image, filename } = req.body;
|
||||
if (!image || !filename) throw new AppError('image und filename erforderlich', 400);
|
||||
|
||||
// Validate base64 image
|
||||
const match = image.match(/^data:(image\/(png|jpe?g|gif|webp));base64,(.+)$/);
|
||||
if (!match) throw new AppError('Ungültiges Bildformat (PNG, JPG, GIF, WEBP erlaubt)', 400);
|
||||
|
||||
const ext = match[2].replace('jpeg', 'jpg');
|
||||
const safeName = `kb-${id}-${Date.now()}.${ext}`;
|
||||
const uploadDir = path.join(__dirname, '../../uploads/kb');
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(uploadDir, safeName), Buffer.from(match[3], 'base64'));
|
||||
|
||||
const images = JSON.parse(entry.images || '[]');
|
||||
images.push(safeName);
|
||||
db.prepare('UPDATE knowledge_base SET images = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||
.run(JSON.stringify(images), id);
|
||||
|
||||
res.json({ status: 'success', data: { filename: safeName, url: `/uploads/kb/${safeName}` } });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/ai/knowledge-base/:id/images/:filename
|
||||
*/
|
||||
static deleteKbImage = asyncHandler(async (req, res) => {
|
||||
const db = getDatabase();
|
||||
const id = parseInt(req.params.id);
|
||||
const { filename } = req.params;
|
||||
const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(id);
|
||||
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
|
||||
|
||||
const images = JSON.parse(entry.images || '[]').filter(f => f !== filename);
|
||||
db.prepare('UPDATE knowledge_base SET images = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||
.run(JSON.stringify(images), id);
|
||||
|
||||
// Delete file
|
||||
const filePath = path.join(__dirname, '../../uploads/kb', filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
|
||||
res.json({ status: 'success', message: 'Bild gelöscht' });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/ai/knowledge-base/:id
|
||||
*/
|
||||
static deleteKnowledgeEntry = asyncHandler(async (req, res) => {
|
||||
const db = getDatabase();
|
||||
const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(parseInt(req.params.id));
|
||||
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
|
||||
|
||||
// Delete all associated images
|
||||
const images = JSON.parse(entry.images || '[]');
|
||||
for (const f of images) {
|
||||
const fp = path.join(__dirname, '../../uploads/kb', f);
|
||||
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM knowledge_base WHERE id = ?').run(parseInt(req.params.id));
|
||||
res.json({ status: 'success', message: 'Eintrag gelöscht' });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = AiController;
|
||||
169
backend/src/controllers/announcement.controller.js
Normal file
169
backend/src/controllers/announcement.controller.js
Normal file
@@ -0,0 +1,169 @@
|
||||
const { getDatabase } = require('../config/database');
|
||||
|
||||
// Hilfsfunktion: welche Ankündigungen sind für einen Agenten relevant (nach Gruppe)?
|
||||
function getForAgent(agentId) {
|
||||
const db = getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Gruppe des Agenten ermitteln
|
||||
const groupRow = db.prepare(`
|
||||
SELECT group_id FROM patch_agent_groups WHERE agent_id = ? LIMIT 1
|
||||
`).get(agentId);
|
||||
const groupId = groupRow?.group_id ?? null;
|
||||
|
||||
const all = db.prepare(`
|
||||
SELECT * FROM announcements
|
||||
WHERE active = 1
|
||||
AND (expires_at IS NULL OR expires_at > ?)
|
||||
ORDER BY created_at DESC
|
||||
`).all(now);
|
||||
|
||||
return all.filter(a => {
|
||||
// Spezifische Agent-IDs haben höchste Priorität (type-safe: immer als Number vergleichen)
|
||||
const agentIds = JSON.parse(a.target_agent_ids || '[]').map(Number).filter(Boolean);
|
||||
if (agentIds.length > 0) return agentIds.includes(Number(agentId));
|
||||
// Gruppen-Filter
|
||||
const groups = JSON.parse(a.target_groups || '[]');
|
||||
return groups.length === 0 || (groupId && groups.includes(groupId));
|
||||
}).filter(a => {
|
||||
// Noch nicht vom Agenten bestätigt?
|
||||
const ack = db.prepare('SELECT 1 FROM announcement_acks WHERE announcement_id=? AND agent_id=?').get(a.id, agentId);
|
||||
return !ack;
|
||||
}).map(a => ({ id: a.id, title: a.title, message: a.message, type: a.type }));
|
||||
}
|
||||
|
||||
// Agent meldet Bestätigung (kein JWT, nur Agent-Key)
|
||||
const agentAck = (req, res) => {
|
||||
const agentKey = req.headers['x-agent-key'];
|
||||
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
const { hostname } = req.body;
|
||||
if (!hostname) return res.status(400).json({ error: 'hostname required' });
|
||||
|
||||
const db = getDatabase();
|
||||
const agent = db.prepare('SELECT id FROM monitoring_agents WHERE hostname=?').get(hostname);
|
||||
if (!agent) return res.status(404).json({ error: 'Agent not found' });
|
||||
|
||||
try {
|
||||
db.prepare('INSERT OR IGNORE INTO announcement_acks (announcement_id, agent_id, hostname) VALUES (?,?,?)')
|
||||
.run(req.params.id, agent.id, hostname);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Admin: alle Ankündigungen
|
||||
const getAll = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const rows = db.prepare(`
|
||||
SELECT a.*, u.username as created_by_name,
|
||||
COUNT(ak.agent_id) as ack_count
|
||||
FROM announcements a
|
||||
LEFT JOIN users u ON u.id = a.created_by_user_id
|
||||
LEFT JOIN announcement_acks ak ON ak.announcement_id = a.id
|
||||
GROUP BY a.id
|
||||
ORDER BY a.created_at DESC
|
||||
`).all();
|
||||
|
||||
const totalAgents = db.prepare('SELECT COUNT(*) as c FROM monitoring_agents').get().c;
|
||||
res.json(rows.map(r => {
|
||||
const groups = JSON.parse(r.target_groups || '[]');
|
||||
const agentIds = JSON.parse(r.target_agent_ids || '[]').map(Number).filter(Boolean);
|
||||
let relevant = totalAgents;
|
||||
if (agentIds.length > 0) {
|
||||
relevant = agentIds.length;
|
||||
} else if (groups.length > 0) {
|
||||
const placeholders = groups.map(() => '?').join(',');
|
||||
relevant = db.prepare(`
|
||||
SELECT COUNT(DISTINCT ma.id) as c FROM monitoring_agents ma
|
||||
JOIN patch_agent_groups pag ON pag.agent_id = ma.id
|
||||
WHERE pag.group_id IN (${placeholders})
|
||||
`).get(...groups).c;
|
||||
}
|
||||
return { ...r, target_groups: groups, target_agent_ids: agentIds, total_agents: relevant };
|
||||
}));
|
||||
};
|
||||
|
||||
const create = (req, res) => {
|
||||
const { title, message, type = 'info', target_groups = [], target_agent_ids = [], expires_at } = req.body;
|
||||
if (!title?.trim() || !message?.trim()) return res.status(400).json({ error: 'Titel und Nachricht erforderlich' });
|
||||
const db = getDatabase();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO announcements (title, message, type, target_groups, target_agent_ids, created_by_user_id, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(title.trim(), message.trim(), type, JSON.stringify(target_groups), JSON.stringify(target_agent_ids), req.user.id, expires_at || null);
|
||||
res.status(201).json(db.prepare('SELECT * FROM announcements WHERE id=?').get(result.lastInsertRowid));
|
||||
};
|
||||
|
||||
const update = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const existing = db.prepare('SELECT * FROM announcements WHERE id=?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { title, message, type, target_groups, expires_at, active } = req.body;
|
||||
db.prepare(`UPDATE announcements SET title=?, message=?, type=?, target_groups=?, expires_at=?, active=? WHERE id=?`)
|
||||
.run(
|
||||
title ?? existing.title,
|
||||
message ?? existing.message,
|
||||
type ?? existing.type,
|
||||
target_groups !== undefined ? JSON.stringify(target_groups) : existing.target_groups,
|
||||
expires_at !== undefined ? (expires_at || null) : existing.expires_at,
|
||||
active !== undefined ? (active ? 1 : 0) : existing.active,
|
||||
req.params.id
|
||||
);
|
||||
res.json(db.prepare('SELECT * FROM announcements WHERE id=?').get(req.params.id));
|
||||
};
|
||||
|
||||
const remove = (req, res) => {
|
||||
const db = getDatabase();
|
||||
if (!db.prepare('SELECT id FROM announcements WHERE id=?').get(req.params.id)) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM announcements WHERE id=?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
const getAcks = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const acks = db.prepare(`
|
||||
SELECT ak.hostname, ak.acknowledged_at
|
||||
FROM announcement_acks ak
|
||||
WHERE ak.announcement_id = ?
|
||||
ORDER BY ak.acknowledged_at ASC
|
||||
`).all(req.params.id);
|
||||
res.json(acks);
|
||||
};
|
||||
|
||||
// Aktive Ankündigungen für eingeloggte User (AppLayout Notification Bell)
|
||||
const getActive = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
const userKey = `user:${req.user.id}`;
|
||||
const rows = db.prepare(`
|
||||
SELECT a.id, a.title, a.message, a.type, a.created_at, a.target_groups
|
||||
FROM announcements a
|
||||
WHERE a.active = 1 AND (a.expires_at IS NULL OR a.expires_at > ?)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM announcement_acks ak
|
||||
WHERE ak.announcement_id = a.id AND ak.hostname = ?
|
||||
)
|
||||
ORDER BY a.created_at DESC
|
||||
`).all(now, userKey);
|
||||
res.json(rows.map(r => ({ ...r, target_groups: JSON.parse(r.target_groups || '[]') })));
|
||||
};
|
||||
|
||||
// Web-User bestätigt Ankündigung (JWT Auth)
|
||||
const userAck = (req, res) => {
|
||||
const db = getDatabase();
|
||||
if (!db.prepare('SELECT id FROM announcements WHERE id=?').get(req.params.id)) {
|
||||
return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
}
|
||||
try {
|
||||
db.prepare('INSERT OR IGNORE INTO announcement_acks (announcement_id, agent_id, hostname) VALUES (?,?,?)')
|
||||
.run(req.params.id, null, `user:${req.user.id}`);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getAll, getActive, create, update, remove, agentAck, getAcks, getForAgent, userAck };
|
||||
525
backend/src/controllers/asset.controller.js
Normal file
525
backend/src/controllers/asset.controller.js
Normal file
@@ -0,0 +1,525 @@
|
||||
const AssetService = require('../services/asset.service');
|
||||
const Asset = require('../models/Asset');
|
||||
const LabelService = require('../services/label.service');
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
const { getIntuneManagedDevices } = require('../services/graph.service');
|
||||
const { ROLES, TECHNIKER_DEPARTMENTS } = require('../middleware/roleCheck');
|
||||
|
||||
// Returns department filter for the current user (null = all departments)
|
||||
function getDeptFilter(user) {
|
||||
if (user.role === ROLES.TECHNIKER) return TECHNIKER_DEPARTMENTS;
|
||||
return null;
|
||||
}
|
||||
|
||||
class AssetController {
|
||||
/**
|
||||
* Get all assets
|
||||
* GET /api/assets
|
||||
*/
|
||||
static getAllAssets = asyncHandler(async (req, res) => {
|
||||
const departments = getDeptFilter(req.user);
|
||||
const assets = AssetService.getAllAssets(departments);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: assets
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get assets assigned to the current user
|
||||
* GET /api/assets/mine
|
||||
*/
|
||||
static getMyAssets = asyncHandler(async (req, res) => {
|
||||
const assets = Asset.getByAssignedUser(req.user.id);
|
||||
res.json({ status: 'success', data: assets });
|
||||
});
|
||||
|
||||
/**
|
||||
* Get asset by ID
|
||||
* GET /api/assets/:id
|
||||
*/
|
||||
static getAssetById = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const asset = AssetService.getAssetById(parseInt(id));
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: asset
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get asset by serial number
|
||||
* GET /api/assets/serial/:serialNumber
|
||||
*/
|
||||
static getAssetBySerial = asyncHandler(async (req, res) => {
|
||||
const { serialNumber } = req.params;
|
||||
const asset = AssetService.getAssetBySerial(serialNumber);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: asset
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get assets by status
|
||||
* GET /api/assets/status/:status
|
||||
*/
|
||||
static getAssetsByStatus = asyncHandler(async (req, res) => {
|
||||
const { status } = req.params;
|
||||
const assets = AssetService.getAssetsByStatus(status);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: assets
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get assets by type
|
||||
* GET /api/assets/type/:type
|
||||
*/
|
||||
static getAssetsByType = asyncHandler(async (req, res) => {
|
||||
const { type } = req.params;
|
||||
const assets = AssetService.getAssetsByType(type);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: assets
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Create new asset
|
||||
* POST /api/assets
|
||||
*/
|
||||
static createAsset = asyncHandler(async (req, res) => {
|
||||
const asset = AssetService.createAsset(
|
||||
req.body,
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
status: 'success',
|
||||
data: asset
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Update asset
|
||||
* PUT /api/assets/:id
|
||||
*/
|
||||
static updateAsset = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const asset = AssetService.updateAsset(
|
||||
parseInt(id),
|
||||
req.body,
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: asset
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete asset
|
||||
* DELETE /api/assets/:id
|
||||
*/
|
||||
static deleteAsset = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = AssetService.deleteAsset(
|
||||
parseInt(id),
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'Asset nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: 'Asset erfolgreich gelöscht'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Assign asset to user
|
||||
* POST /api/assets/:id/assign
|
||||
*/
|
||||
static assignAsset = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { user_id, notes } = req.body;
|
||||
|
||||
if (!user_id) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Benutzer-ID ist erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const assignment = AssetService.assignAsset(
|
||||
parseInt(id),
|
||||
parseInt(user_id),
|
||||
req.user.id,
|
||||
notes,
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: assignment
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unassign asset from user
|
||||
* POST /api/assets/:id/unassign
|
||||
*/
|
||||
static unassignAsset = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { new_status } = req.body;
|
||||
|
||||
if (!new_status) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Neuer Status ist erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const asset = AssetService.unassignAsset(
|
||||
parseInt(id),
|
||||
req.user.id,
|
||||
new_status,
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: asset
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get assignment history for an asset
|
||||
* GET /api/assets/:id/history
|
||||
*/
|
||||
static getAssetAssignmentHistory = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const history = AssetService.getAssetAssignmentHistory(parseInt(id));
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: history
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get asset statistics
|
||||
* GET /api/assets/stats
|
||||
*/
|
||||
static getStatistics = asyncHandler(async (req, res) => {
|
||||
const departments = getDeptFilter(req.user);
|
||||
const stats = AssetService.getStatistics(departments);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: stats
|
||||
});
|
||||
});
|
||||
/**
|
||||
* Generate handover protocol PDF for an assigned asset
|
||||
* GET /api/assets/:id/handover-protocol
|
||||
*/
|
||||
static generateHandoverProtocol = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const asset = AssetService.getAssetById(parseInt(id));
|
||||
|
||||
if (!asset.assigned_to_user_id) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Das Asset ist keinem Mitarbeiter zugewiesen'
|
||||
});
|
||||
}
|
||||
|
||||
const PDFDocument = require('pdfkit');
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||||
|
||||
const filename = `uebergabeprotokoll_${(asset.serial_number || asset.id).replace(/[^a-z0-9]/gi, '_')}.pdf`;
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `inline; filename="${filename}"`,
|
||||
});
|
||||
doc.pipe(res);
|
||||
|
||||
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const BLUE = '#2563eb';
|
||||
const GRAY = '#6b7280';
|
||||
const BLACK = '#111827';
|
||||
const pageW = doc.page.width - 100; // usable width (margins 50 each side)
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────
|
||||
doc.fontSize(20).fillColor(BLUE).text('Cereda Systems GmbH', 50, 50);
|
||||
doc.fontSize(10).fillColor(GRAY).text('IT-Abteilung · IT-Nexus', 50, 75);
|
||||
doc.fontSize(16).fillColor(BLACK).text('Übergabeprotokoll', 50, 100);
|
||||
doc.fontSize(10).fillColor(GRAY).text(`Erstellt am: ${today}`, 50, 120);
|
||||
|
||||
doc.moveTo(50, 140).lineTo(545, 140).strokeColor(BLUE).lineWidth(1.5).stroke();
|
||||
|
||||
// ── Helper: draw a labeled row ────────────────────────────────────────
|
||||
const row = (label, value, y) => {
|
||||
doc.fontSize(9).fillColor(GRAY).text(label, 50, y);
|
||||
doc.fontSize(10).fillColor(BLACK).text(value || '—', 200, y);
|
||||
};
|
||||
|
||||
// ── Asset-Informationen ───────────────────────────────────────────────
|
||||
let y = 160;
|
||||
doc.fontSize(12).fillColor(BLUE).text('Arbeitsmittel', 50, y);
|
||||
y += 20;
|
||||
row('Bezeichnung', asset.name, y); y += 18;
|
||||
row('Typ', asset.type, y); y += 18;
|
||||
row('Modell', asset.model, y); y += 18;
|
||||
row('Seriennummer', asset.serial_number, y); y += 18;
|
||||
row('Kaufdatum', asset.purchase_date
|
||||
? new Date(asset.purchase_date).toLocaleDateString('de-DE')
|
||||
: null, y); y += 18;
|
||||
if (asset.description) { row('Beschreibung', asset.description, y); y += 18; }
|
||||
|
||||
y += 10;
|
||||
doc.moveTo(50, y).lineTo(545, y).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
|
||||
y += 15;
|
||||
|
||||
// ── Mitarbeiter-Informationen ─────────────────────────────────────────
|
||||
doc.fontSize(12).fillColor(BLUE).text('Empfänger', 50, y);
|
||||
y += 20;
|
||||
|
||||
const assignedName = [asset.assigned_to_first_name, asset.assigned_to_last_name]
|
||||
.filter(Boolean).join(' ') || asset.assigned_to_username || '—';
|
||||
row('Name', assignedName, y); y += 18;
|
||||
row('E-Mail', asset.assigned_to_email, y); y += 18;
|
||||
row('Übergabedatum', today, y); y += 18;
|
||||
|
||||
y += 10;
|
||||
doc.moveTo(50, y).lineTo(545, y).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
|
||||
y += 15;
|
||||
|
||||
// ── Nutzungshinweis ───────────────────────────────────────────────────
|
||||
doc.fontSize(12).fillColor(BLUE).text('Nutzungshinweis', 50, y);
|
||||
y += 18;
|
||||
doc.fontSize(9).fillColor(GRAY).text(
|
||||
'Das übergebene Arbeitsmittel ist ausschließlich für dienstliche Zwecke zu verwenden. ' +
|
||||
'Es ist sachgerecht zu behandeln und vor Verlust, Beschädigung sowie unbefugtem Zugriff zu schützen. ' +
|
||||
'Defekte oder Verluste sind unverzüglich der IT-Abteilung zu melden (helpdesk@cereda-systems.de). ' +
|
||||
'Bei Austritt aus dem Unternehmen ist das Arbeitsmittel vollständig zurückzugeben.',
|
||||
50, y, { width: pageW, lineGap: 3 }
|
||||
);
|
||||
y += 70;
|
||||
|
||||
doc.moveTo(50, y).lineTo(545, y).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
|
||||
y += 20;
|
||||
|
||||
// ── Unterschriften ────────────────────────────────────────────────────
|
||||
doc.fontSize(12).fillColor(BLUE).text('Unterschriften', 50, y);
|
||||
y += 30;
|
||||
|
||||
const sigW = (pageW - 40) / 2;
|
||||
|
||||
// Left: Mitarbeiter
|
||||
doc.fontSize(9).fillColor(GRAY).text('Mitarbeiter/in', 50, y);
|
||||
doc.moveTo(50, y + 40).lineTo(50 + sigW, y + 40).strokeColor(BLACK).lineWidth(0.8).stroke();
|
||||
doc.fontSize(8).fillColor(GRAY).text(`${assignedName}`, 50, y + 45);
|
||||
|
||||
// Right: IT-Verantwortlicher
|
||||
const rightX = 50 + sigW + 40;
|
||||
doc.fontSize(9).fillColor(GRAY).text('IT-Verantwortliche/r', rightX, y);
|
||||
doc.moveTo(rightX, y + 40).lineTo(rightX + sigW, y + 40).strokeColor(BLACK).lineWidth(0.8).stroke();
|
||||
doc.fontSize(8).fillColor(GRAY).text('Cereda Systems GmbH · IT', rightX, y + 45);
|
||||
|
||||
// ── Footer ────────────────────────────────────────────────────────────
|
||||
doc.fontSize(7).fillColor(GRAY)
|
||||
.text('Dieses Dokument wurde automatisch durch IT-Nexus generiert · SUP-006 Infrastruktur & Arbeitsmittel',
|
||||
50, doc.page.height - 40, { align: 'center', width: pageW });
|
||||
|
||||
doc.end();
|
||||
});
|
||||
|
||||
/**
|
||||
* Import managed devices from Microsoft Intune as assets
|
||||
* POST /api/assets/import/intune
|
||||
*/
|
||||
static importFromIntune = asyncHandler(async (req, res) => {
|
||||
let devices;
|
||||
try {
|
||||
devices = await getIntuneManagedDevices();
|
||||
} catch (err) {
|
||||
return res.status(502).json({
|
||||
status: 'error',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
|
||||
const results = { imported: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (const device of devices) {
|
||||
try {
|
||||
const serial = (device.serialNumber || '').trim();
|
||||
if (!serial) {
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already exists by serial number
|
||||
const existing = Asset.getBySerialNumber(serial);
|
||||
if (existing) {
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map OS to asset type
|
||||
const os = (device.operatingSystem || '').toLowerCase();
|
||||
let type = 'Other';
|
||||
if (os.includes('windows') || os.includes('macos') || os.includes('mac os')) {
|
||||
type = 'Notebook';
|
||||
}
|
||||
|
||||
const name = device.deviceName || serial;
|
||||
const manufacturer = device.manufacturer || '';
|
||||
const model = device.model || '';
|
||||
const descParts = [`Quelle: Microsoft Intune`];
|
||||
if (manufacturer) descParts.push(`Hersteller: ${manufacturer}`);
|
||||
if (device.operatingSystem) descParts.push(`OS: ${device.operatingSystem}`);
|
||||
if (device.managedDeviceOwnerType) descParts.push(`Typ: ${device.managedDeviceOwnerType}`);
|
||||
|
||||
AssetService.createAsset({
|
||||
name,
|
||||
type,
|
||||
serial_number: serial,
|
||||
model: model || null,
|
||||
status: 'verfuegbar',
|
||||
description: descParts.join(' | '),
|
||||
}, req.user.id, req.ip);
|
||||
|
||||
results.imported++;
|
||||
} catch (err) {
|
||||
results.errors.push({ device: device.deviceName || device.serialNumber, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ status: 'success', data: results });
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all inspections for an asset
|
||||
* GET /api/assets/:id/inspections
|
||||
*/
|
||||
static getInspections = asyncHandler(async (req, res) => {
|
||||
const AssetInspection = require('../models/AssetInspection');
|
||||
const items = AssetInspection.getByAsset(parseInt(req.params.id));
|
||||
res.json({ status: 'success', data: items });
|
||||
});
|
||||
|
||||
/**
|
||||
* Create an inspection for an asset
|
||||
* POST /api/assets/:id/inspections
|
||||
*/
|
||||
static createInspection = asyncHandler(async (req, res) => {
|
||||
const AssetInspection = require('../models/AssetInspection');
|
||||
const { inspection_date, result, notes, next_due_date } = req.body;
|
||||
if (!inspection_date || !result) {
|
||||
return res.status(400).json({ status: 'error', message: 'inspection_date und result sind Pflichtfelder' });
|
||||
}
|
||||
const item = AssetInspection.create({
|
||||
asset_id: parseInt(req.params.id),
|
||||
inspected_by_user_id: req.user.id,
|
||||
inspection_date, result, notes, next_due_date
|
||||
});
|
||||
// If next_due_date provided, also update the asset's next_maintenance_date
|
||||
if (next_due_date) {
|
||||
const Asset = require('../models/Asset');
|
||||
Asset.update(parseInt(req.params.id), { next_maintenance_date: next_due_date }, req.user.id);
|
||||
}
|
||||
res.status(201).json({ status: 'success', data: item });
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate printable label PDF for an asset
|
||||
* GET /api/assets/:id/label?copies=1&size=medium
|
||||
*/
|
||||
static generateLabel = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const copies = Math.min(parseInt(req.query.copies) || 1, 100);
|
||||
const size = ['small', 'medium', 'large', 'ptouch12'].includes(req.query.size)
|
||||
? req.query.size
|
||||
: 'medium';
|
||||
|
||||
const asset = AssetService.getAssetById(parseInt(id));
|
||||
|
||||
const pdfBuffer = await LabelService.generateAssetLabel(asset, copies, size);
|
||||
const filename = `label_${(asset.serial_number || asset.id).replace(/[^a-z0-9]/gi, '_')}.pdf`;
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `inline; filename="${filename}"`,
|
||||
'Content-Length': pdfBuffer.length,
|
||||
});
|
||||
res.send(pdfBuffer);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/assets/:id/sync-agent
|
||||
* Holt aktuelle Daten vom letzten Agent-Checkin und schreibt sie ins Asset
|
||||
*/
|
||||
static syncFromAgent = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const asset = AssetService.getAssetById(parseInt(id));
|
||||
const db = require('../config/database').getDatabase();
|
||||
const MonitoringAgent = require('../models/MonitoringAgent');
|
||||
|
||||
const agent = MonitoringAgent.getByHostname(asset.name);
|
||||
if (!agent) {
|
||||
return res.status(404).json({ status: 'error', message: 'Kein Agent für dieses Asset gefunden. Hostname muss mit Asset-Name übereinstimmen.' });
|
||||
}
|
||||
|
||||
const syncData = {
|
||||
os: agent.os_name || null,
|
||||
ip_address: agent.ip_address || null,
|
||||
last_agent_sync: new Date().toISOString(),
|
||||
};
|
||||
if (agent.hardware_serial) syncData.serial_number = agent.hardware_serial;
|
||||
|
||||
// Hersteller aus Modell extrahieren falls noch nicht gesetzt
|
||||
if (!asset.manufacturer && asset.model) {
|
||||
const known = ['HP', 'Dell', 'Lenovo', 'Apple', 'Asus', 'Acer', 'Microsoft', 'Samsung', 'Toshiba', 'Fujitsu', 'Panasonic', 'Getac'];
|
||||
const upper = asset.model.toUpperCase();
|
||||
for (const b of known) {
|
||||
if (upper.startsWith(b.toUpperCase())) { syncData.manufacturer = b; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// Benutzer anhand last_user suchen
|
||||
if (agent.last_user) {
|
||||
const cleanUser = agent.last_user.replace(/^[^\\]+\\/, '');
|
||||
const userRow = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND is_active = 1').get(cleanUser);
|
||||
if (userRow) {
|
||||
syncData.assigned_to_user_id = userRow.id;
|
||||
syncData.status = 'zugewiesen';
|
||||
}
|
||||
}
|
||||
|
||||
const updated = Asset.update(parseInt(id), syncData, req.user.id);
|
||||
res.json({ status: 'success', data: updated, synced_from: agent.hostname, last_checkin: agent.last_checkin });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = AssetController;
|
||||
49
backend/src/controllers/assetType.controller.js
Normal file
49
backend/src/controllers/assetType.controller.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const { getDatabase } = require('../config/database');
|
||||
|
||||
const getAll = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const rows = db.prepare('SELECT * FROM asset_types ORDER BY sort_order, name').all();
|
||||
res.json(rows);
|
||||
};
|
||||
|
||||
const create = (req, res) => {
|
||||
const { name, icon = 'devices_other' } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
|
||||
const db = getDatabase();
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM asset_types').get();
|
||||
try {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO asset_types (name, icon, sort_order) VALUES (?, ?, ?)'
|
||||
).run(name.trim(), icon, (maxOrder.m || 0) + 1);
|
||||
const row = db.prepare('SELECT * FROM asset_types WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json(row);
|
||||
} catch (e) {
|
||||
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Typ existiert bereits' });
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const update = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const { name, icon } = req.body;
|
||||
const existing = db.prepare('SELECT * FROM asset_types WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
try {
|
||||
db.prepare('UPDATE asset_types SET name = ?, icon = ? WHERE id = ?')
|
||||
.run(name?.trim() ?? existing.name, icon ?? existing.icon, req.params.id);
|
||||
res.json(db.prepare('SELECT * FROM asset_types WHERE id = ?').get(req.params.id));
|
||||
} catch (e) {
|
||||
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Typ existiert bereits' });
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const remove = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const existing = db.prepare('SELECT * FROM asset_types WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM asset_types WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
module.exports = { getAll, create, update, remove };
|
||||
116
backend/src/controllers/auth.controller.js
Normal file
116
backend/src/controllers/auth.controller.js
Normal file
@@ -0,0 +1,116 @@
|
||||
const AuthService = require('../services/auth.service');
|
||||
const User = require('../models/User');
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
|
||||
class AuthController {
|
||||
/**
|
||||
* Login user
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
static login = asyncHandler(async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Username and password are required'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await AuthService.login(username, password);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: result
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get current user info
|
||||
* GET /api/auth/me
|
||||
*/
|
||||
static getCurrentUser = asyncHandler(async (req, res) => {
|
||||
const user = User.getById(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
const { password_hash, ...userWithoutPassword } = user;
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: userWithoutPassword
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Change password
|
||||
* POST /api/auth/change-password
|
||||
*/
|
||||
static changePassword = asyncHandler(async (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Current password and new password are required'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await AuthService.changePassword(
|
||||
req.user.id,
|
||||
currentPassword,
|
||||
newPassword
|
||||
);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: result
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Update email notification preference
|
||||
* PUT /api/auth/notifications
|
||||
*/
|
||||
static updateNotifications = asyncHandler(async (req, res) => {
|
||||
const { email_notifications } = req.body;
|
||||
const updated = User.updateEmailNotifications(req.user.id, !!email_notifications);
|
||||
const { password_hash, ...userWithoutPassword } = updated;
|
||||
res.json({ status: 'success', data: userWithoutPassword });
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/auth/staff-notifications
|
||||
*/
|
||||
static updateStaffNotifications = asyncHandler(async (req, res) => {
|
||||
const { notif_ticket_created, notif_ticket_assigned, notif_new_comment, notif_weekly_report } = req.body;
|
||||
const updated = User.updateStaffNotifications(req.user.id, {
|
||||
notif_ticket_created,
|
||||
notif_ticket_assigned,
|
||||
notif_new_comment,
|
||||
notif_weekly_report,
|
||||
});
|
||||
const { password_hash, ...userWithoutPassword } = updated;
|
||||
res.json({ status: 'success', data: userWithoutPassword });
|
||||
});
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
static logout = asyncHandler(async (req, res) => {
|
||||
// Client-side will handle token removal
|
||||
// This endpoint is just for consistency and potential future server-side session handling
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: 'Logged out successfully'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = AuthController;
|
||||
404
backend/src/controllers/bot.controller.js
Normal file
404
backend/src/controllers/bot.controller.js
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Teams Bot Controller
|
||||
* Empfängt Nachrichten von Teams, antwortet via KI, erstellt Tickets, leitet Support-Antworten weiter.
|
||||
*/
|
||||
const { CloudAdapter, ConfigurationBotFrameworkAuthentication, TurnContext } = require('botbuilder');
|
||||
const aiService = require('../services/ai.service');
|
||||
const Ticket = require('../models/Ticket');
|
||||
const TicketComment = require('../models/TicketComment');
|
||||
const User = require('../models/User');
|
||||
const { getDatabase } = require('../config/database');
|
||||
const sseManager = require('../services/sseManager');
|
||||
|
||||
// ── Adapter ────────────────────────────────────────────────────────────────
|
||||
const botAuth = new ConfigurationBotFrameworkAuthentication({
|
||||
MicrosoftAppId: process.env.TEAMS_BOT_APP_ID,
|
||||
MicrosoftAppPassword: process.env.TEAMS_BOT_APP_SECRET,
|
||||
MicrosoftAppType: 'SingleTenant',
|
||||
MicrosoftAppTenantId: process.env.AZURE_TENANT_ID,
|
||||
});
|
||||
|
||||
const adapter = new CloudAdapter(botAuth);
|
||||
|
||||
adapter.onTurnError = async (context, error) => {
|
||||
console.error('[TeamsBot] Fehler:', error);
|
||||
try {
|
||||
await context.sendActivity('Es ist ein Fehler aufgetreten. Bitte versuche es erneut.');
|
||||
} catch (e) {
|
||||
console.error('[TeamsBot] Konnte Fehlermeldung nicht senden:', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ── In-Memory Conversation State ───────────────────────────────────────────
|
||||
// Key: Teams-User-ID, Value: { state, messages, ticketId, conversationRef }
|
||||
const sessions = new Map();
|
||||
|
||||
const getSession = (userId) => sessions.get(userId) || {
|
||||
state: 'ai', // 'ai' | 'ticket'
|
||||
messages: [], // KI-Chat-Verlauf
|
||||
ticketId: null,
|
||||
conversationRef: null,
|
||||
};
|
||||
|
||||
const saveSession = (userId, data) => sessions.set(userId, data);
|
||||
|
||||
// ── Proaktive Nachricht an Teams senden (wenn Support antwortet) ───────────
|
||||
async function notifyUser(teamsUserId, text) {
|
||||
const session = sessions.get(teamsUserId);
|
||||
if (!session?.conversationRef) return false;
|
||||
try {
|
||||
await adapter.continueConversationAsync(
|
||||
process.env.TEAMS_BOT_APP_ID,
|
||||
session.conversationRef,
|
||||
async (ctx) => {
|
||||
await ctx.sendActivity(text);
|
||||
}
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[TeamsBot] Proaktive Nachricht fehlgeschlagen:', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen ────────────────────────────────────────────────────────
|
||||
function getUserFromTeams(teamsAccount) {
|
||||
if (teamsAccount?.aadObjectId) {
|
||||
return User.getByAzureId(teamsAccount.aadObjectId) || null;
|
||||
}
|
||||
if (teamsAccount?.email) {
|
||||
const db = getDatabase();
|
||||
return db.prepare('SELECT * FROM users WHERE email = ? LIMIT 1').get(teamsAccount.email) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findSupportUser() {
|
||||
const db = getDatabase();
|
||||
return db.prepare("SELECT id FROM users WHERE role_id IN (SELECT id FROM roles WHERE name IN ('super_admin','admin','support')) LIMIT 1").get();
|
||||
}
|
||||
|
||||
// ── Bild-Download von Teams ────────────────────────────────────────────────
|
||||
async function downloadTeamsImage(contentUrl) {
|
||||
try {
|
||||
const { ConfidentialClientApplication } = require('@azure/msal-node');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const msalApp = new ConfidentialClientApplication({
|
||||
auth: {
|
||||
clientId: process.env.TEAMS_BOT_APP_ID,
|
||||
clientSecret: process.env.TEAMS_BOT_APP_SECRET,
|
||||
authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`,
|
||||
}
|
||||
});
|
||||
const tokenResult = await msalApp.acquireTokenByClientCredential({
|
||||
scopes: ['https://api.botframework.com/.default'],
|
||||
});
|
||||
const response = await fetch(contentUrl, {
|
||||
headers: { 'Authorization': `Bearer ${tokenResult.accessToken}` }
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
// Echten Typ aus Magic Bytes erkennen
|
||||
let mediaType = 'image/jpeg';
|
||||
let ext = 'jpg';
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50) { mediaType = 'image/png'; ext = 'png'; }
|
||||
else if (buffer[0] === 0xFF && buffer[1] === 0xD8) { mediaType = 'image/jpeg'; ext = 'jpg'; }
|
||||
else if (buffer[0] === 0x47 && buffer[1] === 0x49) { mediaType = 'image/gif'; ext = 'gif'; }
|
||||
else if (buffer.slice(8, 12).toString() === 'WEBP') { mediaType = 'image/webp'; ext = 'webp'; }
|
||||
|
||||
// Bild auf Server speichern
|
||||
const uploadDir = path.join(__dirname, '../../uploads/tickets');
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
const filename = `teams-${crypto.randomBytes(8).toString('hex')}.${ext}`;
|
||||
const filepath = path.join(uploadDir, filename);
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
const publicUrl = `/uploads/tickets/${filename}`;
|
||||
|
||||
return { base64: buffer.toString('base64'), mediaType, publicUrl };
|
||||
} catch (e) {
|
||||
console.error('[TeamsBot] Bild-Download fehlgeschlagen:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Baut Claude-kompatiblen Message-Content aus Text + Bild-Anhängen
|
||||
// Gibt { content, imageUrls } zurück
|
||||
async function buildMessageContent(text, attachments) {
|
||||
const content = [];
|
||||
const imageUrls = [];
|
||||
for (const att of (attachments || [])) {
|
||||
if (att.contentType?.startsWith('image/') && att.contentUrl) {
|
||||
const img = await downloadTeamsImage(att.contentUrl);
|
||||
if (img) {
|
||||
content.push({ type: 'image', source: { type: 'base64', media_type: img.mediaType, data: img.base64 } });
|
||||
if (img.publicUrl) imageUrls.push(img.publicUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text) content.push({ type: 'text', text });
|
||||
if (content.length === 0) return null;
|
||||
const msgContent = content.length === 1 && content[0].type === 'text' ? text : content;
|
||||
if (Array.isArray(msgContent) && !text && imageUrls.length > 0) {
|
||||
content.push({ type: 'text', text: 'Was siehst du auf diesem Bild? Beschreibe das Problem.' });
|
||||
}
|
||||
return { content: Array.isArray(msgContent) ? content : text, imageUrls };
|
||||
}
|
||||
|
||||
// ── Bot Logik ──────────────────────────────────────────────────────────────
|
||||
async function handleMessage(context) {
|
||||
const teamsUserId = context.activity.from.id;
|
||||
const teamsName = context.activity.from.name || 'Mitarbeiter';
|
||||
const text = (context.activity.text || '').trim();
|
||||
const attachments = context.activity.attachments || [];
|
||||
const hasImages = attachments.some(a => a.contentType?.startsWith('image/'));
|
||||
const session = getSession(teamsUserId);
|
||||
|
||||
// Conversation Reference + aadObjectId speichern (für proaktive Nachrichten)
|
||||
session.conversationRef = TurnContext.getConversationReference(context.activity);
|
||||
if (context.activity.from.aadObjectId) {
|
||||
session.aadObjectId = context.activity.from.aadObjectId;
|
||||
}
|
||||
|
||||
// ── Begrüßung / Hilfe ── (nur wenn NICHT im Ticket-Modus)
|
||||
if (session.state !== 'ticket' && (!text && !hasImages || /^(hallo|hi|hey|hilfe|help|start|menu|menü)$/i.test(text))) {
|
||||
await context.sendActivity(
|
||||
`👋 Hallo **${teamsName}**! Ich bin dein IT-Support-Assistent.\n\n` +
|
||||
`**Was kann ich tun?**\n` +
|
||||
`• Beschreibe dein Problem – ich helfe direkt oder erstelle ein Ticket\n` +
|
||||
`• **meine tickets** – deine offenen Tickets anzeigen\n` +
|
||||
`• **ticket TK-2026-0042** – Status eines Tickets abfragen\n` +
|
||||
`• **neu** – Gespräch zurücksetzen\n\n` +
|
||||
`_Einfach losschreiben!_`
|
||||
);
|
||||
saveSession(teamsUserId, { ...session, state: 'ai', messages: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Reset ──
|
||||
if (/^(reset|neu|neues ticket|neu starten)$/i.test(text)) {
|
||||
saveSession(teamsUserId, { state: 'ai', messages: [], ticketId: null, conversationRef: session.conversationRef });
|
||||
await context.sendActivity('✅ Gespräch zurückgesetzt. Wie kann ich dir helfen?');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Meine Tickets ──
|
||||
if (/^meine tickets?$/i.test(text)) {
|
||||
const dbUser = getUserFromTeams(context.activity.from);
|
||||
if (!dbUser) {
|
||||
await context.sendActivity('❌ Ich konnte dein IT-Nexus-Konto nicht finden. Bitte stelle sicher, dass deine Teams-E-Mail im IT Nexus hinterlegt ist.');
|
||||
return;
|
||||
}
|
||||
const db = getDatabase();
|
||||
const tickets = db.prepare(
|
||||
`SELECT ticket_number, title, status, priority, created_at FROM tickets
|
||||
WHERE created_by_user_id = ? AND status != 'geschlossen'
|
||||
ORDER BY created_at DESC LIMIT 10`
|
||||
).all(dbUser.id);
|
||||
|
||||
if (!tickets.length) {
|
||||
await context.sendActivity('✅ Du hast keine offenen Tickets.');
|
||||
return;
|
||||
}
|
||||
const statusLabel = { offen: '🟡 Offen', in_bearbeitung: '🔵 In Bearbeitung', warten_auf_mitarbeiter: '🟠 Warte auf dich', warten_auf_support: '🟣 Beim Support' };
|
||||
const lines = tickets.map(t =>
|
||||
`**${t.ticket_number}** – ${t.title}\n ${statusLabel[t.status] || t.status} | Priorität: ${t.priority}`
|
||||
).join('\n\n');
|
||||
await context.sendActivity(`📋 **Deine offenen Tickets:**\n\n${lines}\n\n_Tippe z.B. \`ticket TK-2026-0001\` für Details._`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Ticket Status abfragen ──
|
||||
const statusMatch = text.match(/^(?:ticket\s+)?(TK-\d{4}-\d+)$/i);
|
||||
if (statusMatch) {
|
||||
const ticketNumber = statusMatch[1].toUpperCase();
|
||||
const db = getDatabase();
|
||||
const ticket = db.prepare('SELECT * FROM tickets WHERE ticket_number = ? LIMIT 1').get(ticketNumber);
|
||||
if (!ticket) {
|
||||
await context.sendActivity(`❌ Ticket **${ticketNumber}** nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
const statusLabel = { offen: '🟡 Offen', in_bearbeitung: '🔵 In Bearbeitung', warten_auf_mitarbeiter: '🟠 Warte auf Mitarbeiter', warten_auf_support: '🟣 Beim Support', geschlossen: '✅ Geschlossen' };
|
||||
const comments = db.prepare('SELECT COUNT(*) as cnt FROM ticket_comments WHERE ticket_id = ?').get(ticket.id);
|
||||
await context.sendActivity(
|
||||
`📋 **${ticket.ticket_number}**: ${ticket.title}\n\n` +
|
||||
`**Status:** ${statusLabel[ticket.status] || ticket.status}\n` +
|
||||
`**Priorität:** ${ticket.priority}\n` +
|
||||
`**Kategorie:** ${ticket.category}\n` +
|
||||
`**Kommentare:** ${comments.cnt}\n` +
|
||||
`**Erstellt:** ${new Date(ticket.created_at).toLocaleDateString('de-DE')}\n\n` +
|
||||
`_Schreibe eine Nachricht um auf dieses Ticket zu antworten (Ticket wird aktiviert)._`
|
||||
);
|
||||
saveSession(teamsUserId, { ...session, state: 'ticket', ticketId: ticket.id });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Ticket-Modus: Nachricht als Kommentar hinzufügen ──
|
||||
if (session.state === 'ticket' && session.ticketId) {
|
||||
const dbUser = getUserFromTeams(context.activity.from);
|
||||
const userId = dbUser?.id || null;
|
||||
const ticket = Ticket.getById(session.ticketId);
|
||||
|
||||
if (!ticket) {
|
||||
await context.sendActivity('❌ Ticket nicht mehr gefunden. Tippe `neu` für ein neues Gespräch.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newComment = TicketComment.create(session.ticketId, userId, text, false, false);
|
||||
sseManager.broadcast(session.ticketId, newComment);
|
||||
await context.sendActivity(`✉️ _Nachricht gesendet → **${ticket.ticket_number}**_`);
|
||||
saveSession(teamsUserId, session);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── KI-Modus ──
|
||||
const built = await buildMessageContent(text, attachments);
|
||||
if (!built) return;
|
||||
|
||||
const updatedMessages = [...session.messages, { role: 'user', content: built.content, _imageUrls: built.imageUrls }];
|
||||
|
||||
await context.sendActivity({ type: 'typing' });
|
||||
if (hasImages) await context.sendActivity('🖼️ Bild wird analysiert...');
|
||||
|
||||
try {
|
||||
const messagesForAI = updatedMessages.map(({ role, content }) => ({ role, content }));
|
||||
const aiReply = await aiService.chat(messagesForAI, true);
|
||||
updatedMessages.push({ role: 'assistant', content: aiReply });
|
||||
saveSession(teamsUserId, { ...session, messages: updatedMessages });
|
||||
|
||||
const ticketHint = /ticket|support|weiterleiten|kann.*nicht.*helfen|nicht.*lösen/i.test(aiReply);
|
||||
|
||||
if (ticketHint) {
|
||||
await context.sendActivity(aiReply);
|
||||
await context.sendActivity(
|
||||
`📋 Soll ich automatisch ein **Support-Ticket** erstellen?\n\nTippe **ja** zum Erstellen oder stelle eine weitere Frage.`
|
||||
);
|
||||
saveSession(teamsUserId, { ...session, messages: updatedMessages, state: 'confirm_ticket' });
|
||||
} else {
|
||||
await context.sendActivity(aiReply);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TeamsBot] KI-Fehler:', err.message);
|
||||
await context.sendActivity('⚠️ KI momentan nicht verfügbar. Soll ich ein Ticket erstellen? Tippe **ja**.');
|
||||
saveSession(teamsUserId, { ...session, state: 'confirm_ticket' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmTicket(context, session) {
|
||||
const teamsUserId = context.activity.from.id;
|
||||
const text = (context.activity.text || '').trim().toLowerCase();
|
||||
const teamsName = context.activity.from.name || 'Mitarbeiter';
|
||||
|
||||
if (!/^(ja|yes|ok|erstellen|ticket)/.test(text)) {
|
||||
saveSession(teamsUserId, { ...session, state: 'ai' });
|
||||
await handleMessage(context);
|
||||
return;
|
||||
}
|
||||
|
||||
const dbUser = getUserFromTeams(context.activity.from);
|
||||
const supportUser = findSupportUser();
|
||||
|
||||
const allMessages = session.messages.filter(m => m.role === 'user' || m.role === 'assistant');
|
||||
const userMessages = allMessages.filter(m => m.role === 'user');
|
||||
|
||||
const summary = userMessages
|
||||
.map(m => typeof m.content === 'string' ? m.content : m.content.filter(c => c.type === 'text').map(c => c.text).join(' '))
|
||||
.join('\n');
|
||||
|
||||
// Alle gespeicherten Bild-URLs sammeln
|
||||
const allImageUrls = userMessages.flatMap(m => m._imageUrls || []);
|
||||
const imageSection = allImageUrls.length > 0
|
||||
? '\n\n**Anhänge:**\n' + allImageUrls.map(u => ``).join('\n')
|
||||
: '';
|
||||
|
||||
const title = summary.split('\n')[0].substring(0, 100) || 'IT-Problem via Teams';
|
||||
|
||||
try {
|
||||
const ticket = Ticket.create({
|
||||
title,
|
||||
description: `📱 **Erstellt via Teams Bot**\n\nBenutzer: ${teamsName}\n\n${summary}${imageSection}`,
|
||||
category: 'Allgemein',
|
||||
priority: 'mittel',
|
||||
status: 'offen',
|
||||
created_by_user_id: dbUser?.id || null,
|
||||
requester_name: teamsName,
|
||||
requester_email: context.activity.from.aadObjectId
|
||||
? `teams-${context.activity.from.aadObjectId}@teams.bot`
|
||||
: null,
|
||||
});
|
||||
|
||||
// Konversation als Kommentar speichern
|
||||
try {
|
||||
const conversationText = allMessages.map(m => {
|
||||
const prefix = m.role === 'user' ? `👤 **${teamsName}:**` : '🤖 **IT-Support KI:**';
|
||||
const text = typeof m.content === 'string'
|
||||
? m.content
|
||||
: m.content.filter(c => c.type === 'text').map(c => c.text).join(' ');
|
||||
return `${prefix}\n${text}`;
|
||||
}).join('\n\n');
|
||||
|
||||
if (conversationText.trim()) {
|
||||
const convComment = TicketComment.create(ticket.id, dbUser?.id || null, `📱 **Teams-Konversation:**\n\n${conversationText}`, false, false);
|
||||
sseManager.broadcast(ticket.id, convComment);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[TeamsBot] Konversation-Kommentar fehlgeschlagen:', e.message);
|
||||
}
|
||||
|
||||
saveSession(teamsUserId, {
|
||||
...session,
|
||||
state: 'ticket',
|
||||
ticketId: ticket.id,
|
||||
});
|
||||
|
||||
await context.sendActivity(
|
||||
`─────────────────────────────\n` +
|
||||
`✅ **Ticket ${ticket.ticket_number} erstellt**\n` +
|
||||
`Der Support kümmert sich darum.\n` +
|
||||
`─────────────────────────────\n\n` +
|
||||
`Du kannst hier direkt antworten – deine Nachrichten gehen an den Support.\n` +
|
||||
`_Tippe \`neu\` für ein neues Anliegen._`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[TeamsBot] Ticket-Fehler:', err.message);
|
||||
await context.sendActivity('❌ Fehler beim Erstellen des Tickets. Bitte versuche es erneut.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Express Handler ────────────────────────────────────────────────────────
|
||||
async function messages(req, res) {
|
||||
try {
|
||||
await adapter.process(req, res, async (context) => {
|
||||
if (context.activity.type !== 'message') return;
|
||||
|
||||
const teamsUserId = context.activity.from.id;
|
||||
const session = getSession(teamsUserId);
|
||||
|
||||
if (session.state === 'confirm_ticket') {
|
||||
await handleConfirmTicket(context, session);
|
||||
} else {
|
||||
await handleMessage(context);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[TeamsBot] processActivity Fehler:', err.message);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Bot processing error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Benachrichtigung via Azure AD Object ID (für ticket.controller.js)
|
||||
async function notifyByAadId(aadObjectId, text) {
|
||||
for (const [userId, session] of sessions.entries()) {
|
||||
if (session.aadObjectId === aadObjectId) {
|
||||
return notifyUser(userId, text);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = { messages, notifyUser, notifyByAadId };
|
||||
122
backend/src/controllers/docker.controller.js
Normal file
122
backend/src/controllers/docker.controller.js
Normal file
@@ -0,0 +1,122 @@
|
||||
const http = require('http');
|
||||
|
||||
function dockerRequest(method, path, body = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const opts = {
|
||||
socketPath: '/var/run/docker.sock',
|
||||
method,
|
||||
path,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
};
|
||||
const req = http.request(opts, res => {
|
||||
let data = '';
|
||||
res.on('data', c => data += c);
|
||||
res.on('end', () => {
|
||||
try { resolve({ status: res.statusCode, data: data ? JSON.parse(data) : null }); }
|
||||
catch { resolve({ status: res.statusCode, data }); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
const getContainers = async (req, res) => {
|
||||
try {
|
||||
const { data } = await dockerRequest('GET', '/containers/json?all=1');
|
||||
const containers = (data || []).map(c => ({
|
||||
id: c.Id.substring(0, 12),
|
||||
name: (c.Names[0] || '').replace(/^\//, ''),
|
||||
image: c.Image,
|
||||
status: c.Status,
|
||||
state: c.State,
|
||||
created: c.Created,
|
||||
ports: c.Ports || [],
|
||||
}));
|
||||
res.json(containers);
|
||||
} catch (e) {
|
||||
res.status(503).json({ error: 'Docker nicht erreichbar', detail: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const getStats = async (req, res) => {
|
||||
try {
|
||||
const { data: list } = await dockerRequest('GET', '/containers/json?all=0');
|
||||
const running = (list || []).filter(c => c.State === 'running');
|
||||
const stats = await Promise.all(running.map(async c => {
|
||||
try {
|
||||
const { data } = await dockerRequest('GET', `/containers/${c.Id}/stats?stream=0`);
|
||||
const cpuDelta = (data.cpu_stats?.cpu_usage?.total_usage || 0) - (data.precpu_stats?.cpu_usage?.total_usage || 0);
|
||||
const sysDelta = (data.cpu_stats?.system_cpu_usage || 0) - (data.precpu_stats?.system_cpu_usage || 0);
|
||||
const cpus = data.cpu_stats?.online_cpus || 1;
|
||||
const cpuPct = sysDelta > 0 ? (cpuDelta / sysDelta) * cpus * 100 : 0;
|
||||
const memUsed = data.memory_stats?.usage || 0;
|
||||
const memLimit = data.memory_stats?.limit || 1;
|
||||
return {
|
||||
id: c.Id.substring(0, 12),
|
||||
name: (c.Names[0] || '').replace(/^\//, ''),
|
||||
cpuPct: Math.round(cpuPct * 10) / 10,
|
||||
memUsed,
|
||||
memLimit,
|
||||
memPct: Math.round((memUsed / memLimit) * 1000) / 10,
|
||||
};
|
||||
} catch { return null; }
|
||||
}));
|
||||
res.json(stats.filter(Boolean));
|
||||
} catch (e) {
|
||||
res.status(503).json({ error: 'Docker nicht erreichbar', detail: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const getLogs = async (req, res) => {
|
||||
try {
|
||||
const lines = parseInt(req.query.lines) || 100;
|
||||
const opts = {
|
||||
socketPath: '/var/run/docker.sock',
|
||||
method: 'GET',
|
||||
path: `/containers/${req.params.id}/logs?stdout=1&stderr=1&tail=${lines}×tamps=1`,
|
||||
};
|
||||
const raw = await new Promise((resolve, reject) => {
|
||||
const req2 = http.request(opts, r => {
|
||||
const chunks = [];
|
||||
r.on('data', c => chunks.push(c));
|
||||
r.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
req2.on('error', reject);
|
||||
req2.end();
|
||||
});
|
||||
// Strip Docker log stream headers (8-byte prefix per line)
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < raw.length) {
|
||||
if (i + 8 > raw.length) break;
|
||||
const size = raw.readUInt32BE(i + 4);
|
||||
if (i + 8 + size > raw.length) break;
|
||||
out += raw.slice(i + 8, i + 8 + size).toString('utf8');
|
||||
i += 8 + size;
|
||||
}
|
||||
res.json({ logs: out || raw.toString('utf8') });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const containerAction = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { action } = req.params;
|
||||
const map = { start: 'POST', stop: 'POST', restart: 'POST' };
|
||||
if (!map[action]) return res.status(400).json({ error: 'Unbekannte Aktion' });
|
||||
try {
|
||||
const { status } = await dockerRequest('POST', `/containers/${id}/${action}`);
|
||||
if (status === 204 || status === 200 || status === 304) {
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(status).json({ error: `Docker returned ${status}` });
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getContainers, getStats, getLogs, containerAction };
|
||||
198
backend/src/controllers/entra.controller.js
Normal file
198
backend/src/controllers/entra.controller.js
Normal file
@@ -0,0 +1,198 @@
|
||||
const {
|
||||
getEntraUsers,
|
||||
getAllMfaRegistrationDetails,
|
||||
getAzureGroups,
|
||||
getAzureGroupMembers,
|
||||
getEntraUserGroups,
|
||||
getDirectoryRolesWithMembers,
|
||||
addGroupMember,
|
||||
removeGroupMember,
|
||||
getUserEntraProfile,
|
||||
getUserLicenseDetails,
|
||||
getEntraSubscribedSkus,
|
||||
getUserSignInActivity,
|
||||
getUserSignInRisk,
|
||||
getConditionalAccessPolicies,
|
||||
invalidateUserSessions,
|
||||
} = require('../services/graph.service');
|
||||
|
||||
/**
|
||||
* GET /api/entra/users
|
||||
* Returns all Entra users merged with MFA registration info.
|
||||
*/
|
||||
exports.getUsers = async (req, res) => {
|
||||
try {
|
||||
const [users, mfaDetails] = await Promise.all([
|
||||
getEntraUsers(),
|
||||
getAllMfaRegistrationDetails().catch(() => []), // graceful fallback if AuditLog permission missing
|
||||
]);
|
||||
|
||||
// Build MFA lookup map by userPrincipalName
|
||||
const mfaMap = {};
|
||||
for (const m of mfaDetails) {
|
||||
mfaMap[m.userPrincipalName?.toLowerCase()] = m;
|
||||
}
|
||||
|
||||
const merged = users.map(u => {
|
||||
const mfa = mfaMap[u.userPrincipalName?.toLowerCase()] || null;
|
||||
return {
|
||||
id: u.id,
|
||||
displayName: u.displayName,
|
||||
mail: u.mail || u.userPrincipalName,
|
||||
userPrincipalName: u.userPrincipalName,
|
||||
jobTitle: u.jobTitle || null,
|
||||
department: u.department || null,
|
||||
accountEnabled: u.accountEnabled,
|
||||
mfaRegistered: mfa ? mfa.isMfaRegistered : null,
|
||||
mfaCapable: mfa ? mfa.isMfaCapable : null,
|
||||
mfaMethods: mfa ? (mfa.methodsRegistered || []) : [],
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ success: true, data: merged });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/entra/users/:id/groups
|
||||
* Returns groups for a specific user.
|
||||
*/
|
||||
exports.getUserGroups = async (req, res) => {
|
||||
try {
|
||||
const groups = await getEntraUserGroups(req.params.id);
|
||||
res.json({ success: true, data: groups });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/entra/groups
|
||||
* Returns all groups (without members — lazy-loaded per group).
|
||||
*/
|
||||
exports.getGroups = async (req, res) => {
|
||||
try {
|
||||
const groups = await getAzureGroups();
|
||||
res.json({ success: true, data: groups });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/entra/groups/:id/members
|
||||
* Returns members of a specific group.
|
||||
*/
|
||||
exports.getGroupMembers = async (req, res) => {
|
||||
try {
|
||||
const members = await getAzureGroupMembers(req.params.id);
|
||||
res.json({ success: true, data: members });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/entra/groups/:id/members
|
||||
* Adds a user to a group. Body: { userId }
|
||||
* Requires GroupMember.ReadWrite.All
|
||||
*/
|
||||
exports.addGroupMember = async (req, res) => {
|
||||
const { userId } = req.body;
|
||||
if (!userId) return res.status(400).json({ success: false, message: 'userId fehlt' });
|
||||
try {
|
||||
await addGroupMember(req.params.id, userId);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/entra/groups/:id/members/:userId
|
||||
* Removes a user from a group.
|
||||
* Requires GroupMember.ReadWrite.All
|
||||
*/
|
||||
exports.removeGroupMember = async (req, res) => {
|
||||
try {
|
||||
await removeGroupMember(req.params.id, req.params.userId);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/entra/roles
|
||||
* Returns all activated directory roles with their members.
|
||||
*/
|
||||
exports.getRoles = async (req, res) => {
|
||||
try {
|
||||
const roles = await getDirectoryRolesWithMembers();
|
||||
res.json({ success: true, data: roles });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUserProfile = async (req, res) => {
|
||||
try {
|
||||
const profile = await getUserEntraProfile(req.params.azureId);
|
||||
res.json({ success: true, data: profile });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUserLicenses = async (req, res) => {
|
||||
try {
|
||||
const [licenses, skus] = await Promise.all([
|
||||
getUserLicenseDetails(req.params.azureId),
|
||||
getEntraSubscribedSkus().catch(() => []),
|
||||
]);
|
||||
const skuMap = {};
|
||||
for (const s of skus) skuMap[s.skuId] = s;
|
||||
const enriched = licenses.map(l => ({ ...l, skuInfo: skuMap[l.skuId] || null }));
|
||||
res.json({ success: true, data: enriched });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUserSignInLog = async (req, res) => {
|
||||
try {
|
||||
const logs = await getUserSignInActivity(req.params.azureId);
|
||||
res.json({ success: true, data: logs });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUserSignInRisk = async (req, res) => {
|
||||
try {
|
||||
const risk = await getUserSignInRisk(req.params.azureId);
|
||||
res.json({ success: true, data: risk });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getCAP = async (req, res) => {
|
||||
try {
|
||||
const policies = await getConditionalAccessPolicies();
|
||||
res.json({ success: true, data: policies });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.invalidateSessions = async (req, res) => {
|
||||
try {
|
||||
await invalidateUserSessions(req.params.azureId);
|
||||
res.json({ success: true, message: 'Sessions invalidiert. Benutzer muss sich neu anmelden.' });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
};
|
||||
73
backend/src/controllers/externalAlert.controller.js
Normal file
73
backend/src/controllers/externalAlert.controller.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const ExternalAlert = require('../models/ExternalAlert');
|
||||
const Ticket = require('../models/Ticket');
|
||||
|
||||
const getAll = (req, res) => {
|
||||
try {
|
||||
const acknowledged = req.query.acknowledged !== undefined
|
||||
? req.query.acknowledged === 'true'
|
||||
: undefined;
|
||||
const alerts = ExternalAlert.getAll({ acknowledged });
|
||||
res.json({ status: 'success', data: alerts });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const acknowledge = (req, res) => {
|
||||
try {
|
||||
const alert = ExternalAlert.acknowledge(req.params.id);
|
||||
if (!alert) return res.status(404).json({ status: 'error', message: 'Alert nicht gefunden' });
|
||||
res.json({ status: 'success', data: alert });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const createTicket = (req, res) => {
|
||||
try {
|
||||
const alert = ExternalAlert.getById(req.params.id);
|
||||
if (!alert) return res.status(404).json({ status: 'error', message: 'Alert nicht gefunden' });
|
||||
|
||||
const priorityMap = { CRIT: 'kritisch', WARN: 'hoch', OK: 'niedrig', UNKNOWN: 'mittel' };
|
||||
|
||||
const title = alert.device
|
||||
? `[${alert.source?.toUpperCase()}] ${alert.device} – ${alert.message || alert.state_transition}`.substring(0, 200)
|
||||
: (alert.message || 'Monitoring Alert').substring(0, 200);
|
||||
|
||||
const description = [
|
||||
alert.device ? `**Gerät:** ${alert.device}` : '',
|
||||
alert.service ? `**Service:** ${alert.service}` : '',
|
||||
alert.state_transition ? `**Status:** ${alert.state_transition}` : '',
|
||||
alert.severity ? `**Severity:** ${alert.severity}` : '',
|
||||
alert.message ? `\n**Meldung:** ${alert.message}` : '',
|
||||
alert.customer ? `**Kunde:** ${alert.customer}` : '',
|
||||
alert.monitored_by ? `**Überwacht von:** ${alert.monitored_by}` : '',
|
||||
alert.state_time ? `**Zeitpunkt:** ${alert.state_time}` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const ticket = Ticket.create({
|
||||
title,
|
||||
description,
|
||||
source: 'monitoring',
|
||||
category: 'Netzwerk',
|
||||
priority: priorityMap[alert.severity] || 'mittel',
|
||||
status: 'offen',
|
||||
});
|
||||
|
||||
ExternalAlert.setTicket(alert.id, ticket.id);
|
||||
res.json({ status: 'success', data: { ticket, alert: ExternalAlert.getById(alert.id) } });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const remove = (req, res) => {
|
||||
try {
|
||||
ExternalAlert.delete(req.params.id);
|
||||
res.json({ status: 'success' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getAll, acknowledge, createTicket, remove };
|
||||
165
backend/src/controllers/fidoKey.controller.js
Normal file
165
backend/src/controllers/fidoKey.controller.js
Normal file
@@ -0,0 +1,165 @@
|
||||
const FidoKeyService = require('../services/fidoKey.service');
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
|
||||
class FidoKeyController {
|
||||
/**
|
||||
* Get all FIDO keys
|
||||
* GET /api/fido-keys
|
||||
*/
|
||||
static getAllKeys = asyncHandler(async (req, res) => {
|
||||
const keys = FidoKeyService.getAllKeys();
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: keys
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get FIDO key by ID
|
||||
* GET /api/fido-keys/:id
|
||||
*/
|
||||
static getKeyById = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const key = FidoKeyService.getKeyById(parseInt(id));
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: key
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get FIDO key by serial number
|
||||
* GET /api/fido-keys/serial/:serialNumber
|
||||
*/
|
||||
static getKeyBySerial = asyncHandler(async (req, res) => {
|
||||
const { serialNumber } = req.params;
|
||||
const key = FidoKeyService.getKeyBySerial(serialNumber);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: key
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get FIDO keys by status
|
||||
* GET /api/fido-keys/status/:status
|
||||
*/
|
||||
static getKeysByStatus = asyncHandler(async (req, res) => {
|
||||
const { status } = req.params;
|
||||
const keys = FidoKeyService.getKeysByStatus(status);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: keys
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Create new FIDO key
|
||||
* POST /api/fido-keys
|
||||
*/
|
||||
static createKey = asyncHandler(async (req, res) => {
|
||||
const key = FidoKeyService.createKey(
|
||||
req.body,
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
status: 'success',
|
||||
data: key
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Update FIDO key
|
||||
* PUT /api/fido-keys/:id
|
||||
*/
|
||||
static updateKey = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const key = FidoKeyService.updateKey(
|
||||
parseInt(id),
|
||||
req.body,
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: key
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Update FIDO key status
|
||||
* PUT /api/fido-keys/:id/status
|
||||
*/
|
||||
static updateKeyStatus = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Status is required'
|
||||
});
|
||||
}
|
||||
|
||||
const key = FidoKeyService.updateKeyStatus(
|
||||
parseInt(id),
|
||||
status,
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: key
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete FIDO key
|
||||
* DELETE /api/fido-keys/:id
|
||||
*/
|
||||
static deleteKey = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = FidoKeyService.deleteKey(
|
||||
parseInt(id),
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'FIDO key not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: 'FIDO key deleted successfully'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get FIDO key statistics
|
||||
* GET /api/fido-keys/stats
|
||||
*/
|
||||
static getStatistics = asyncHandler(async (req, res) => {
|
||||
const stats = FidoKeyService.getStatistics();
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: stats
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = FidoKeyController;
|
||||
30
backend/src/controllers/isoTask.controller.js
Normal file
30
backend/src/controllers/isoTask.controller.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const IsoTask = require('../models/IsoTask');
|
||||
|
||||
exports.getAll = (req, res) => {
|
||||
const { category, status } = req.query;
|
||||
const data = IsoTask.getAll({ category, status });
|
||||
res.json({ success: true, data });
|
||||
};
|
||||
|
||||
exports.getOne = (req, res) => {
|
||||
const task = IsoTask.getById(req.params.id);
|
||||
if (!task) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
|
||||
res.json({ success: true, data: task });
|
||||
};
|
||||
|
||||
exports.create = (req, res) => {
|
||||
const task = IsoTask.create(req.body);
|
||||
res.status(201).json({ success: true, data: task });
|
||||
};
|
||||
|
||||
exports.update = (req, res) => {
|
||||
const task = IsoTask.update(req.params.id, req.body);
|
||||
if (!task) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
|
||||
res.json({ success: true, data: task });
|
||||
};
|
||||
|
||||
exports.remove = (req, res) => {
|
||||
const ok = IsoTask.delete(req.params.id);
|
||||
if (!ok) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
};
|
||||
45
backend/src/controllers/itTopic.controller.js
Normal file
45
backend/src/controllers/itTopic.controller.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const ItTopic = require('../models/ItTopic');
|
||||
const { syncItTopicsToPlanner } = require('../services/graph.service');
|
||||
|
||||
exports.getAll = (req, res) => {
|
||||
const { category, status } = req.query;
|
||||
const data = ItTopic.getAll({ category, status });
|
||||
res.json({ status: 'success', data });
|
||||
};
|
||||
|
||||
exports.getOne = (req, res) => {
|
||||
const topic = ItTopic.getById(req.params.id);
|
||||
if (!topic) return res.status(404).json({ status: 'error', message: 'Thema nicht gefunden' });
|
||||
res.json({ status: 'success', data: topic });
|
||||
};
|
||||
|
||||
exports.create = (req, res) => {
|
||||
const { title, category, status, priority, responsible, target_date, description, notes } = req.body;
|
||||
if (!title?.trim()) {
|
||||
return res.status(400).json({ status: 'error', message: 'Titel ist erforderlich' });
|
||||
}
|
||||
const topic = ItTopic.create({ title: title.trim(), category, status, priority, responsible, target_date, description, notes });
|
||||
res.status(201).json({ status: 'success', data: topic });
|
||||
};
|
||||
|
||||
exports.update = (req, res) => {
|
||||
const topic = ItTopic.getById(req.params.id);
|
||||
if (!topic) return res.status(404).json({ status: 'error', message: 'Thema nicht gefunden' });
|
||||
const { title, category, status, priority, responsible, target_date, description, notes, sort_order } = req.body;
|
||||
const updated = ItTopic.update(req.params.id, { title, category, status, priority, responsible, target_date, description, notes, sort_order });
|
||||
res.json({ status: 'success', data: updated });
|
||||
};
|
||||
|
||||
exports.remove = (req, res) => {
|
||||
const ok = ItTopic.delete(req.params.id);
|
||||
if (!ok) return res.status(404).json({ status: 'error', message: 'Thema nicht gefunden' });
|
||||
res.json({ status: 'success', message: 'Thema gelöscht' });
|
||||
};
|
||||
|
||||
exports.syncToPlanner = async (req, res) => {
|
||||
const planId = process.env.PLANNER_PLAN_ID;
|
||||
if (!planId) return res.status(400).json({ status: 'error', message: 'PLANNER_PLAN_ID nicht konfiguriert' });
|
||||
const topics = ItTopic.getAll({});
|
||||
const result = await syncItTopicsToPlanner(topics, planId);
|
||||
res.json({ status: 'success', data: result });
|
||||
};
|
||||
55
backend/src/controllers/knowledge.controller.js
Normal file
55
backend/src/controllers/knowledge.controller.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const { getDatabase } = require('../config/database');
|
||||
|
||||
const getAll = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const entries = db.prepare(`
|
||||
SELECT k.*, u.username as author_name
|
||||
FROM ai_knowledge k
|
||||
LEFT JOIN users u ON u.id = k.created_by
|
||||
ORDER BY k.category ASC, k.updated_at DESC
|
||||
`).all();
|
||||
res.json(entries);
|
||||
};
|
||||
|
||||
const getByCategory = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const entries = db.prepare(`
|
||||
SELECT k.*, u.username as author_name
|
||||
FROM ai_knowledge k
|
||||
LEFT JOIN users u ON u.id = k.created_by
|
||||
WHERE k.category = ?
|
||||
ORDER BY k.updated_at DESC
|
||||
`).all(req.params.category);
|
||||
res.json(entries);
|
||||
};
|
||||
|
||||
const create = (req, res) => {
|
||||
const { title, content, category, tags } = req.body;
|
||||
if (!title?.trim() || !content?.trim()) return res.status(400).json({ error: 'Titel und Inhalt erforderlich' });
|
||||
const db = getDatabase();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO ai_knowledge (title, content, category, tags, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(title.trim(), content.trim(), category || 'Allgemein', JSON.stringify(tags || []), req.user?.id || null);
|
||||
res.status(201).json(db.prepare('SELECT * FROM ai_knowledge WHERE id=?').get(result.lastInsertRowid));
|
||||
};
|
||||
|
||||
const update = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const existing = db.prepare('SELECT * FROM ai_knowledge WHERE id=?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { title, content, category, tags } = req.body;
|
||||
db.prepare(`UPDATE ai_knowledge SET title=?, content=?, category=?, tags=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`)
|
||||
.run(title ?? existing.title, content ?? existing.content, category ?? existing.category,
|
||||
tags !== undefined ? JSON.stringify(tags) : existing.tags, req.params.id);
|
||||
res.json(db.prepare('SELECT * FROM ai_knowledge WHERE id=?').get(req.params.id));
|
||||
};
|
||||
|
||||
const remove = (req, res) => {
|
||||
const db = getDatabase();
|
||||
if (!db.prepare('SELECT id FROM ai_knowledge WHERE id=?').get(req.params.id)) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM ai_knowledge WHERE id=?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
module.exports = { getAll, getByCategory, create, update, remove };
|
||||
180
backend/src/controllers/license.controller.js
Normal file
180
backend/src/controllers/license.controller.js
Normal file
@@ -0,0 +1,180 @@
|
||||
const License = require('../models/License');
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
const { getEntraSubscribedSkus } = require('../services/graph.service');
|
||||
|
||||
// Friendly display names for common Microsoft SKU part numbers
|
||||
const SKU_NAMES = {
|
||||
// Microsoft 365
|
||||
SPB: 'Microsoft 365 Business Premium',
|
||||
O365_BUSINESS_PREMIUM: 'Microsoft 365 Business Standard',
|
||||
O365_BUSINESS_ESSENTIALS: 'Microsoft 365 Business Basic',
|
||||
SMB_BUSINESS: 'Microsoft 365 Apps for Business',
|
||||
OFFICESUBSCRIPTION: 'Microsoft 365 Apps for Enterprise',
|
||||
ENTERPRISEPACK: 'Microsoft 365 E3',
|
||||
ENTERPRISEPREMIUM: 'Microsoft 365 E5',
|
||||
SPE_E3: 'Microsoft 365 E3',
|
||||
SPE_E5: 'Microsoft 365 E5',
|
||||
M365EDU_A3_FACULTY: 'Microsoft 365 A3 (Bildung)',
|
||||
M365EDU_A5_FACULTY: 'Microsoft 365 A5 (Bildung)',
|
||||
// Exchange
|
||||
EXCHANGESTANDARD: 'Exchange Online (Plan 1)',
|
||||
EXCHANGEENTERPRISE: 'Exchange Online (Plan 2)',
|
||||
EXCHANGE_S_DESKLESS: 'Exchange Online Kiosk',
|
||||
EXCHANGEARCHIVE_ADDON: 'Exchange Online Archivierung',
|
||||
// Teams & Kommunikation
|
||||
TEAMS_ESSENTIALS: 'Microsoft Teams Essentials',
|
||||
MCOSTANDARD: 'Skype for Business Online (Plan 2)',
|
||||
MCOEV: 'Microsoft Teams Phone Standard',
|
||||
MCOEV_VIRTUALUSER: 'Microsoft Teams Phone Resource Account',
|
||||
MCOMEETADV: 'Microsoft Teams Audio Conferencing',
|
||||
MCOMEETADV_BYON: 'Teams Audio Conferencing (BYOT)',
|
||||
MCOTEAMS_ESSENTIALS: 'Microsoft Teams Essentials',
|
||||
// Intune & Security
|
||||
INTUNE_A: 'Microsoft Intune Plan 1',
|
||||
INTUNE_P2: 'Microsoft Intune Plan 2',
|
||||
AAD_PREMIUM: 'Azure AD Premium P1',
|
||||
AAD_PREMIUM_P2: 'Azure AD Premium P2',
|
||||
EMS: 'Enterprise Mobility + Security E3',
|
||||
EMSPREMIUM: 'Enterprise Mobility + Security E5',
|
||||
DEFENDER_ENDPOINT_P1: 'Microsoft Defender for Endpoint P1',
|
||||
MDATP_XPLAT: 'Microsoft Defender for Endpoint P2',
|
||||
ATP_ENTERPRISE: 'Microsoft Defender for Office 365 (Plan 1)',
|
||||
THREAT_INTELLIGENCE: 'Microsoft Defender for Office 365 (Plan 2)',
|
||||
// Power Platform
|
||||
POWER_BI_PRO: 'Power BI Pro',
|
||||
POWER_BI_PREMIUM_PER_USER: 'Power BI Premium Per User',
|
||||
POWERAPPS_PER_USER: 'Power Apps Per User Plan',
|
||||
FLOW_PER_USER: 'Power Automate Per User Plan',
|
||||
FLOW_FREE: 'Power Automate Free',
|
||||
DYN365_ENTERPRISE_PLAN1: 'Dynamics 365 Customer Engagement Plan',
|
||||
// Windows & Devices
|
||||
WIN10_PRO_ENT_SUB: 'Windows 10/11 Enterprise E3',
|
||||
WIN_ENT_E5: 'Windows 10/11 Enterprise E5',
|
||||
// Project & Visio
|
||||
PROJECTPROFESSIONAL: 'Project Plan 5',
|
||||
PROJECTPREMIUM: 'Project Plan 3',
|
||||
PROJECT_PLAN1: 'Project Plan 1',
|
||||
VISIOCLIENT: 'Visio Plan 2',
|
||||
VISIOONLINE_PLAN1: 'Visio Plan 1',
|
||||
// Sonstiges
|
||||
CCIBOTS_PRIVPREV_VIRAL: 'Microsoft Copilot für Microsoft 365',
|
||||
MICROSOFT_COPILOT_SECP: 'Copilot for Security',
|
||||
RIGHTSMANAGEMENT: 'Azure Information Protection Plan 1',
|
||||
RIGHTSMANAGEMENT_PREMIUM: 'Azure Information Protection Plan 2',
|
||||
CRMSTANDARD: 'Microsoft Dynamics CRM Online',
|
||||
STREAM: 'Microsoft Stream',
|
||||
};
|
||||
|
||||
class LicenseController {
|
||||
static getAll = asyncHandler(async (req, res) => {
|
||||
const licenses = License.getAll();
|
||||
res.json({ status: 'success', data: licenses });
|
||||
});
|
||||
|
||||
static getById = asyncHandler(async (req, res) => {
|
||||
const license = License.getById(parseInt(req.params.id));
|
||||
if (!license) {
|
||||
return res.status(404).json({ status: 'error', message: 'Lizenz nicht gefunden' });
|
||||
}
|
||||
res.json({ status: 'success', data: license });
|
||||
});
|
||||
|
||||
static create = asyncHandler(async (req, res) => {
|
||||
const { name, vendor, license_type, product_key, seats, purchase_date, expiry_date, cost, notes } = req.body;
|
||||
if (!name) {
|
||||
return res.status(400).json({ status: 'error', message: 'Name ist erforderlich' });
|
||||
}
|
||||
const license = License.create({
|
||||
name, vendor, license_type, product_key, seats, purchase_date, expiry_date, cost, notes,
|
||||
created_by_user_id: req.user.id,
|
||||
});
|
||||
res.status(201).json({ status: 'success', data: license });
|
||||
});
|
||||
|
||||
static update = asyncHandler(async (req, res) => {
|
||||
const existing = License.getById(parseInt(req.params.id));
|
||||
if (!existing) {
|
||||
return res.status(404).json({ status: 'error', message: 'Lizenz nicht gefunden' });
|
||||
}
|
||||
const license = License.update(parseInt(req.params.id), req.body);
|
||||
res.json({ status: 'success', data: license });
|
||||
});
|
||||
|
||||
static delete = asyncHandler(async (req, res) => {
|
||||
const existing = License.getById(parseInt(req.params.id));
|
||||
if (!existing) {
|
||||
return res.status(404).json({ status: 'error', message: 'Lizenz nicht gefunden' });
|
||||
}
|
||||
License.delete(parseInt(req.params.id));
|
||||
res.json({ status: 'success', message: 'Lizenz gelöscht' });
|
||||
});
|
||||
|
||||
static getStatistics = asyncHandler(async (req, res) => {
|
||||
const stats = License.getStatistics();
|
||||
res.json({ status: 'success', data: stats });
|
||||
});
|
||||
|
||||
static importFromEntra = asyncHandler(async (req, res) => {
|
||||
const skus = await getEntraSubscribedSkus();
|
||||
|
||||
const existing = License.getAll();
|
||||
// Map by sku_id for fast dedup; fallback to name+vendor
|
||||
const existingBySkuId = new Map(existing.filter(l => l.sku_id).map(l => [l.sku_id, l]));
|
||||
const existingByName = new Set(existing.map(l => `${l.name}||${l.vendor}`));
|
||||
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const sku of skus) {
|
||||
try {
|
||||
const seats = sku.prepaidUnits?.enabled || 0;
|
||||
if (seats === 0) { skipped++; continue; }
|
||||
|
||||
const name = SKU_NAMES[sku.skuPartNumber] || sku.skuPartNumber;
|
||||
const notes = `SKU: ${sku.skuPartNumber} | Genutzt: ${sku.consumedUnits || 0} / ${seats} | Status: ${sku.capabilityStatus}`;
|
||||
|
||||
// Already exists by sku_id → update seats/notes
|
||||
if (existingBySkuId.has(sku.skuId)) {
|
||||
License.update(existingBySkuId.get(sku.skuId).id, { seats, notes });
|
||||
updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Already exists by name+vendor (imported before sku_id existed)
|
||||
if (existingByName.has(`${name}||Microsoft`)) {
|
||||
// Link sku_id retroactively
|
||||
const match = existing.find(l => l.name === name && l.vendor === 'Microsoft');
|
||||
if (match) License.update(match.id, { seats, notes, sku_id: sku.skuId });
|
||||
updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
License.create({
|
||||
name, vendor: 'Microsoft', license_type: 'subscription',
|
||||
seats, notes, sku_id: sku.skuId,
|
||||
created_by_user_id: req.user.id,
|
||||
});
|
||||
existingByName.add(`${name}||Microsoft`);
|
||||
imported++;
|
||||
} catch (err) {
|
||||
errors.push(sku.skuPartNumber + ': ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ status: 'success', data: { imported, updated, skipped, errors } });
|
||||
});
|
||||
|
||||
static getLicenseUsers = asyncHandler(async (req, res) => {
|
||||
const license = License.getById(parseInt(req.params.id));
|
||||
if (!license) return res.status(404).json({ status: 'error', message: 'Lizenz nicht gefunden' });
|
||||
if (!license.sku_id) return res.json({ status: 'success', data: [] });
|
||||
|
||||
const { getEntraLicenseUsers } = require('../services/graph.service');
|
||||
const users = await getEntraLicenseUsers(license.sku_id);
|
||||
res.json({ status: 'success', data: users });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = LicenseController;
|
||||
226
backend/src/controllers/monitoringAgent.controller.js
Normal file
226
backend/src/controllers/monitoringAgent.controller.js
Normal file
@@ -0,0 +1,226 @@
|
||||
const MonitoringAgent = require('../models/MonitoringAgent');
|
||||
const Asset = require('../models/Asset');
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
const { getPendingCommands } = require('./patch.controller');
|
||||
const { getDatabase } = require('../config/database');
|
||||
const { getForAgent } = require('./announcement.controller');
|
||||
|
||||
function extractManufacturer(modelStr) {
|
||||
if (!modelStr) return null;
|
||||
const known = ['HP', 'Dell', 'Lenovo', 'Apple', 'Asus', 'Acer', 'Microsoft', 'Samsung', 'Toshiba', 'Fujitsu', 'Panasonic', 'Getac'];
|
||||
const upper = modelStr.toUpperCase();
|
||||
for (const b of known) {
|
||||
if (upper.startsWith(b.toUpperCase())) return b;
|
||||
}
|
||||
return modelStr.trim().split(/\s+/)[0] || null;
|
||||
}
|
||||
|
||||
function syncAgentToAsset(data) {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const hostname = data.hostname;
|
||||
if (!hostname) return;
|
||||
|
||||
let asset = Asset.getByName(hostname);
|
||||
|
||||
// Benutzer anhand last_user (Username) suchen
|
||||
let assignedUserId = null;
|
||||
if (data.last_user) {
|
||||
const cleanUser = data.last_user.replace(/^[^\\]+\\/, ''); // Domain\User → User
|
||||
const userRow = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND is_active = 1').get(cleanUser);
|
||||
if (userRow) assignedUserId = userRow.id;
|
||||
}
|
||||
|
||||
const syncData = {
|
||||
serial_number: data.hardware_serial || undefined,
|
||||
os: data.os_name || data.os || undefined,
|
||||
ip_address: data.ip_address || undefined,
|
||||
last_agent_sync: new Date().toISOString(),
|
||||
};
|
||||
if (assignedUserId) {
|
||||
syncData.assigned_to_user_id = assignedUserId;
|
||||
syncData.status = 'zugewiesen';
|
||||
}
|
||||
|
||||
if (asset) {
|
||||
// Hersteller nur setzen wenn noch nicht gesetzt
|
||||
if (!asset.manufacturer && asset.model) {
|
||||
syncData.manufacturer = extractManufacturer(asset.model);
|
||||
}
|
||||
Asset.update(asset.id, syncData, null);
|
||||
} else {
|
||||
// Neues Asset anlegen
|
||||
const manufacturer = data.hardware_serial ? null : null; // Modell noch unbekannt
|
||||
Asset.create({
|
||||
name: hostname,
|
||||
type: 'Notebook',
|
||||
serial_number: data.hardware_serial || null,
|
||||
model: null,
|
||||
status: 'zugewiesen',
|
||||
os: data.os_name || data.os || null,
|
||||
ip_address: data.ip_address || null,
|
||||
manufacturer: null,
|
||||
assigned_to_user_id: assignedUserId,
|
||||
department: 'IT',
|
||||
last_agent_sync: new Date().toISOString(),
|
||||
created_by_user_id: null,
|
||||
});
|
||||
console.log(`[AgentSync] Neues Asset erstellt: ${hostname}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AgentSync] Fehler beim Asset-Sync:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/monitoring/checkin — called by agent (no JWT, uses API key)
|
||||
const checkin = asyncHandler(async (req, res) => {
|
||||
const agentKey = req.headers['x-agent-key'];
|
||||
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
|
||||
}
|
||||
|
||||
const { hostname } = req.body;
|
||||
if (!hostname) {
|
||||
return res.status(400).json({ status: 'error', message: 'hostname required' });
|
||||
}
|
||||
|
||||
const agent = MonitoringAgent.upsert(req.body);
|
||||
|
||||
// Asset-Sync im Hintergrund (nicht blockend)
|
||||
setImmediate(() => syncAgentToAsset(req.body));
|
||||
|
||||
// Pending patch commands für diesen Agenten zurückgeben
|
||||
const { pending, running } = getPendingCommands(agent.id);
|
||||
|
||||
// Zielversion: Gruppen-spezifisch (staged rollout) oder globaler Default
|
||||
const GLOBAL_VERSION = process.env.AGENT_VERSION || '1.2.4';
|
||||
let targetVersion = GLOBAL_VERSION;
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const groupRow = db.prepare(`
|
||||
SELECT pg.target_agent_version
|
||||
FROM patch_agent_groups pag
|
||||
JOIN patch_groups pg ON pg.id = pag.group_id
|
||||
WHERE pag.agent_id = ?
|
||||
ORDER BY pg.sort_order ASC
|
||||
LIMIT 1
|
||||
`).get(agent.id);
|
||||
if (groupRow?.target_agent_version) targetVersion = groupRow.target_agent_version;
|
||||
} catch { /* kein Gruppe zugewiesen → global */ }
|
||||
|
||||
const announcements = getForAgent(agent.id);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: agent,
|
||||
commands: pending,
|
||||
running_commands: running,
|
||||
agent_version: targetVersion,
|
||||
announcements,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/monitoring — list all agents (requires JWT)
|
||||
const getAll = asyncHandler(async (req, res) => {
|
||||
MonitoringAgent.markOffline();
|
||||
const agents = MonitoringAgent.getAll().map(a => ({
|
||||
...a,
|
||||
installed_software: a.installed_software ? JSON.parse(a.installed_software) : []
|
||||
}));
|
||||
res.json({ status: 'success', data: agents });
|
||||
});
|
||||
|
||||
// POST /api/monitoring/announcements-poll — leichter Poll nur für Ankündigungen
|
||||
const announcementsPoll = asyncHandler(async (req, res) => {
|
||||
const agentKey = req.headers['x-agent-key'];
|
||||
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
const { hostname } = req.body;
|
||||
if (!hostname) return res.status(400).json({ error: 'hostname required' });
|
||||
const agent = MonitoringAgent.getByHostname(hostname);
|
||||
if (!agent) return res.json({ announcements: [] });
|
||||
const announcements = getForAgent(agent.id);
|
||||
res.json({ announcements });
|
||||
});
|
||||
|
||||
// GET /api/monitoring/statistics
|
||||
const getStatistics = asyncHandler(async (req, res) => {
|
||||
MonitoringAgent.markOffline();
|
||||
const stats = MonitoringAgent.getStatistics();
|
||||
res.json({ status: 'success', data: stats });
|
||||
});
|
||||
|
||||
// GET /api/monitoring/:id
|
||||
const getById = asyncHandler(async (req, res) => {
|
||||
const agent = MonitoringAgent.getById(req.params.id);
|
||||
if (!agent) return res.status(404).json({ status: 'error', message: 'Agent not found' });
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: { ...agent, installed_software: agent.installed_software ? JSON.parse(agent.installed_software) : [] }
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/monitoring/:id
|
||||
const deleteAgent = asyncHandler(async (req, res) => {
|
||||
const deleted = MonitoringAgent.delete(req.params.id);
|
||||
if (!deleted) return res.status(404).json({ status: 'error', message: 'Agent not found' });
|
||||
res.json({ status: 'success', message: 'Agent removed' });
|
||||
});
|
||||
|
||||
// GET /api/monitoring/agent-script — agent downloads its own update (X-Agent-Key auth)
|
||||
const downloadScript = asyncHandler(async (req, res) => {
|
||||
const agentKey = req.headers['x-agent-key'];
|
||||
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
|
||||
}
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const scriptPath = path.join(__dirname, '../../agent/it-nexus-agent.ps1');
|
||||
if (!fs.existsSync(scriptPath)) {
|
||||
return res.status(404).json({ status: 'error', message: 'Script nicht gefunden' });
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('X-Agent-Version', process.env.AGENT_VERSION || '1.2.3');
|
||||
res.sendFile(scriptPath);
|
||||
});
|
||||
|
||||
// GET /api/monitoring/ann-watcher — agent downloads announcement watcher script
|
||||
const downloadAnnWatcher = asyncHandler(async (req, res) => {
|
||||
const agentKey = req.headers['x-agent-key'];
|
||||
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
|
||||
}
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const scriptPath = path.join(__dirname, '../../agent/ann-watcher.ps1');
|
||||
if (!fs.existsSync(scriptPath)) {
|
||||
return res.status(404).json({ status: 'error', message: 'Script nicht gefunden' });
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.sendFile(scriptPath);
|
||||
});
|
||||
|
||||
const downloadSetup = asyncHandler(async (req, res) => {
|
||||
// Agent-Key Auth für auto-update vom Agent selbst
|
||||
const agentKey = req.headers['x-agent-key'];
|
||||
const isAgent = agentKey && agentKey === process.env.AGENT_API_KEY;
|
||||
const isAdmin = req.user?.role_name === 'admin' || req.user?.role_name === 'super_admin';
|
||||
if (!isAgent && !isAdmin) {
|
||||
return res.status(401).json({ status: 'error', message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const version = process.env.AGENT_VERSION || '2.0.0';
|
||||
const setupPath = path.join(__dirname, `../../agent/IT-Nexus-Agent-Setup-v${version}.exe`);
|
||||
if (!fs.existsSync(setupPath)) {
|
||||
return res.status(404).json({ status: 'error', message: 'Setup nicht gefunden' });
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('X-Agent-Version', version);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="IT-Nexus-Agent-Setup-v${version}.exe"`);
|
||||
res.sendFile(setupPath);
|
||||
});
|
||||
|
||||
module.exports = { checkin, announcementsPoll, getAll, getStatistics, getById, deleteAgent, downloadScript, downloadAnnWatcher, downloadSetup };
|
||||
239
backend/src/controllers/networkMonitor.controller.js
Normal file
239
backend/src/controllers/networkMonitor.controller.js
Normal file
@@ -0,0 +1,239 @@
|
||||
const NetworkDevice = require('../models/NetworkDevice');
|
||||
const { subscribe, unsubscribe, broadcast } = require('../services/networkSseManager');
|
||||
const { scheduleDevice, cancelDevice, runCheck } = require('../services/networkPoller.service');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const JWT_CONFIG = require('../config/jwt');
|
||||
const { spawn } = require('child_process');
|
||||
const net = require('net');
|
||||
const dns = require('dns').promises;
|
||||
|
||||
// ─── Auto-Discovery ───────────────────────────────────────────────────────────
|
||||
|
||||
function pingHost(ip, timeoutMs = 800) {
|
||||
return new Promise(resolve => {
|
||||
const proc = spawn('ping', ['-c', '1', '-W', '1', ip]);
|
||||
const kill = setTimeout(() => { proc.kill(); resolve(false); }, timeoutMs);
|
||||
proc.on('close', code => { clearTimeout(kill); resolve(code === 0); });
|
||||
proc.on('error', () => { clearTimeout(kill); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
function checkPort(ip, port, timeoutMs = 500) {
|
||||
return new Promise(resolve => {
|
||||
const sock = new net.Socket();
|
||||
sock.setTimeout(timeoutMs);
|
||||
sock.connect(port, ip, () => { sock.destroy(); resolve(true); });
|
||||
sock.on('error', () => resolve(false));
|
||||
sock.on('timeout', () => { sock.destroy(); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveHostname(ip) {
|
||||
try {
|
||||
const names = await dns.reverse(ip);
|
||||
return names[0]?.replace(/\.$/, '') || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function guessType(openPorts) {
|
||||
if (openPorts.includes(9100)) return 'printer';
|
||||
if (openPorts.includes(23) || openPorts.includes(161)) return 'switch';
|
||||
if (openPorts.includes(5000) || openPorts.includes(5001) || openPorts.includes(2049)) return 'nas';
|
||||
if (openPorts.includes(445) || openPorts.includes(139)) return 'nas';
|
||||
if (openPorts.includes(80) || openPorts.includes(443) || openPorts.includes(22)) return 'host';
|
||||
return 'host';
|
||||
}
|
||||
|
||||
async function scanHost(ip) {
|
||||
const alive = await pingHost(ip);
|
||||
if (!alive) return null;
|
||||
|
||||
// Check common ports in parallel
|
||||
const PORT_MAP = [22, 23, 80, 443, 445, 161, 9100, 5000, 5001, 8080, 8443, 3389];
|
||||
const portResults = await Promise.all(PORT_MAP.map(p => checkPort(ip, p).then(ok => ok ? p : null)));
|
||||
const openPorts = portResults.filter(Boolean);
|
||||
|
||||
const hostname = await resolveHostname(ip);
|
||||
const type = guessType(openPorts);
|
||||
const checkType = openPorts.includes(443) ? 'https' : openPorts.includes(80) ? 'http' : 'icmp';
|
||||
|
||||
return { ip, hostname, type, check_type: checkType, open_ports: openPorts };
|
||||
}
|
||||
|
||||
async function runInBatches(items, batchSize, fn) {
|
||||
const results = [];
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const batch = items.slice(i, i + batchSize);
|
||||
const res = await Promise.all(batch.map(fn));
|
||||
results.push(...res);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const discover = async (req, res) => {
|
||||
try {
|
||||
const { subnet } = req.body; // e.g. "192.168.0" or "192.168.0.0/24"
|
||||
if (!subnet) return res.status(400).json({ status: 'error', message: 'subnet fehlt' });
|
||||
|
||||
// Parse subnet to base (first 3 octets)
|
||||
let base = subnet.replace(/\/\d+$/, '').trim();
|
||||
const parts = base.split('.');
|
||||
if (parts.length === 4) base = parts.slice(0, 3).join('.');
|
||||
if (parts.length < 3) return res.status(400).json({ status: 'error', message: 'Ungültiges Subnet' });
|
||||
|
||||
const ips = Array.from({ length: 254 }, (_, i) => `${base}.${i + 1}`);
|
||||
|
||||
// Existing devices to skip
|
||||
const existing = NetworkDevice.getAll().map(d => d.host);
|
||||
|
||||
// Scan in batches of 40 parallel pings
|
||||
const raw = await runInBatches(ips, 40, scanHost);
|
||||
const found = raw
|
||||
.filter(r => r !== null && !existing.includes(r.ip))
|
||||
.sort((a, b) => {
|
||||
const ai = parseInt(a.ip.split('.').pop());
|
||||
const bi = parseInt(b.ip.split('.').pop());
|
||||
return ai - bi;
|
||||
});
|
||||
|
||||
res.json({ status: 'success', data: found, scanned: ips.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── SSE Stream ───────────────────────────────────────────────────────────────
|
||||
|
||||
const sseStream = (req, res) => {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.flushHeaders();
|
||||
|
||||
// Send initial state
|
||||
const devices = NetworkDevice.getAll();
|
||||
res.write(`event: initial_state\ndata: ${JSON.stringify(devices)}\n\n`);
|
||||
|
||||
// Heartbeat
|
||||
const hb = setInterval(() => { try { res.write(': heartbeat\n\n'); } catch (_) {} }, 15000);
|
||||
|
||||
subscribe(res);
|
||||
req.on('close', () => { clearInterval(hb); unsubscribe(res); });
|
||||
};
|
||||
|
||||
// ─── CRUD ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const getAll = (req, res) => {
|
||||
try {
|
||||
const devices = NetworkDevice.getAll();
|
||||
res.json({ status: 'success', data: devices });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const getStatistics = (req, res) => {
|
||||
try {
|
||||
const stats = NetworkDevice.getStatistics();
|
||||
res.json({ status: 'success', data: stats });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const getChecks = (req, res) => {
|
||||
try {
|
||||
const hours = parseInt(req.query.hours) || 24;
|
||||
const checks = NetworkDevice.getChecks(req.params.id, hours);
|
||||
const uptime = NetworkDevice.getUptimeStats(req.params.id, hours);
|
||||
res.json({ status: 'success', data: { checks, uptime } });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const createDevice = (req, res) => {
|
||||
try {
|
||||
const data = sanitize(req.body);
|
||||
const device = NetworkDevice.create(data);
|
||||
scheduleDevice(device);
|
||||
broadcast('device_update', device);
|
||||
res.status(201).json({ status: 'success', data: device });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const updateDevice = (req, res) => {
|
||||
try {
|
||||
const data = sanitize(req.body);
|
||||
const device = NetworkDevice.update(req.params.id, data);
|
||||
if (!device) return res.status(404).json({ status: 'error', message: 'Nicht gefunden' });
|
||||
if (device.enabled) scheduleDevice(device);
|
||||
else cancelDevice(device.id);
|
||||
broadcast('device_update', device);
|
||||
res.json({ status: 'success', data: device });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteDevice = (req, res) => {
|
||||
try {
|
||||
cancelDevice(parseInt(req.params.id));
|
||||
NetworkDevice.delete(req.params.id);
|
||||
broadcast('device_deleted', { id: parseInt(req.params.id) });
|
||||
res.json({ status: 'success' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const checkNow = async (req, res) => {
|
||||
try {
|
||||
const device = NetworkDevice.getById(req.params.id);
|
||||
if (!device) return res.status(404).json({ status: 'error', message: 'Nicht gefunden' });
|
||||
await runCheck(device);
|
||||
const updated = NetworkDevice.getById(req.params.id);
|
||||
res.json({ status: 'success', data: updated });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const getAllUptimeStats = (req, res) => {
|
||||
try {
|
||||
const devices = NetworkDevice.getAll();
|
||||
const stats = {};
|
||||
devices.forEach(d => { stats[d.id] = NetworkDevice.getUptimeStats(d.id, 24); });
|
||||
res.json({ status: 'success', data: stats });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function sanitize(body) {
|
||||
return {
|
||||
name: body.name?.trim() || '',
|
||||
type: body.type || 'host',
|
||||
host: body.host?.trim() || '',
|
||||
check_type: body.check_type || 'icmp',
|
||||
port: body.port ? parseInt(body.port) : null,
|
||||
http_path: body.http_path || '/',
|
||||
http_keyword: body.http_keyword || null,
|
||||
snmp_community: body.snmp_community?.trim() || 'public',
|
||||
snmp_version: ['1', '2c'].includes(body.snmp_version) ? body.snmp_version : '2c',
|
||||
interval_sec: parseInt(body.interval_sec) || 60,
|
||||
timeout_sec: parseInt(body.timeout_sec) || 5,
|
||||
enabled: body.enabled === false || body.enabled === 0 ? 0 : 1,
|
||||
notify_email: body.notify_email || null,
|
||||
location: body.location || null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { sseStream, getAll, getStatistics, getChecks, getAllUptimeStats, createDevice, updateDevice, deleteDevice, checkNow, discover };
|
||||
69
backend/src/controllers/offboarding.controller.js
Normal file
69
backend/src/controllers/offboarding.controller.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const OffboardingService = require('../services/offboarding.service');
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
|
||||
class OffboardingController {
|
||||
static getAllProtocols = asyncHandler(async (req, res) => {
|
||||
const protocols = OffboardingService.getAllProtocols();
|
||||
res.json({ status: 'success', data: protocols });
|
||||
});
|
||||
|
||||
static getProtocolById = asyncHandler(async (req, res) => {
|
||||
const protocol = OffboardingService.getProtocolById(parseInt(req.params.id));
|
||||
res.json({ status: 'success', data: protocol });
|
||||
});
|
||||
|
||||
static createProtocol = asyncHandler(async (req, res) => {
|
||||
const protocol = await OffboardingService.createProtocol(req.body, req.user.id, req.ip);
|
||||
res.status(201).json({ status: 'success', data: protocol });
|
||||
});
|
||||
|
||||
static updateProtocol = asyncHandler(async (req, res) => {
|
||||
const protocol = await OffboardingService.updateProtocol(
|
||||
parseInt(req.params.id),
|
||||
req.body,
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
res.json({ status: 'success', data: protocol });
|
||||
});
|
||||
|
||||
static returnAssets = asyncHandler(async (req, res) => {
|
||||
const protocol = await OffboardingService.returnAssets(
|
||||
parseInt(req.params.id),
|
||||
req.body.asset_returns,
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
res.json({ status: 'success', data: protocol });
|
||||
});
|
||||
|
||||
static regeneratePdf = asyncHandler(async (req, res) => {
|
||||
const protocol = await OffboardingService.regeneratePdf(
|
||||
parseInt(req.params.id),
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
res.json({ status: 'success', data: protocol });
|
||||
});
|
||||
|
||||
static deleteProtocol = asyncHandler(async (req, res) => {
|
||||
const deleted = OffboardingService.deleteProtocol(
|
||||
parseInt(req.params.id),
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ status: 'error', message: 'Protokoll nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({ status: 'success', message: 'Protokoll erfolgreich gelöscht' });
|
||||
});
|
||||
|
||||
static getStatistics = asyncHandler(async (req, res) => {
|
||||
const stats = OffboardingService.getStatistics();
|
||||
res.json({ status: 'success', data: stats });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = OffboardingController;
|
||||
85
backend/src/controllers/onboarding.controller.js
Normal file
85
backend/src/controllers/onboarding.controller.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const OnboardingService = require('../services/onboarding.service');
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
const { sendOnboardingConfirmationEmail } = require('../services/email.service');
|
||||
|
||||
class OnboardingController {
|
||||
static getAllProtocols = asyncHandler(async (req, res) => {
|
||||
const protocols = OnboardingService.getAllProtocols();
|
||||
res.json({ status: 'success', data: protocols });
|
||||
});
|
||||
|
||||
static getProtocolById = asyncHandler(async (req, res) => {
|
||||
const protocol = OnboardingService.getProtocolById(parseInt(req.params.id));
|
||||
res.json({ status: 'success', data: protocol });
|
||||
});
|
||||
|
||||
static createProtocol = asyncHandler(async (req, res) => {
|
||||
const protocol = await OnboardingService.createProtocol(req.body, req.user.id, req.ip);
|
||||
res.status(201).json({ status: 'success', data: protocol });
|
||||
});
|
||||
|
||||
static updateProtocol = asyncHandler(async (req, res) => {
|
||||
const protocol = await OnboardingService.updateProtocol(
|
||||
parseInt(req.params.id),
|
||||
req.body,
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
res.json({ status: 'success', data: protocol });
|
||||
});
|
||||
|
||||
static regeneratePdf = asyncHandler(async (req, res) => {
|
||||
const protocol = await OnboardingService.regeneratePdf(
|
||||
parseInt(req.params.id),
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
res.json({ status: 'success', data: protocol });
|
||||
});
|
||||
|
||||
static deleteProtocol = asyncHandler(async (req, res) => {
|
||||
const deleted = OnboardingService.deleteProtocol(
|
||||
parseInt(req.params.id),
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ status: 'error', message: 'Protokoll nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({ status: 'success', message: 'Protokoll erfolgreich gelöscht' });
|
||||
});
|
||||
|
||||
static sendConfirmationEmail = asyncHandler(async (req, res) => {
|
||||
const crypto = require('crypto');
|
||||
const OnboardingProtocol = require('../models/OnboardingProtocol');
|
||||
const protocol = await OnboardingService.getProtocolById(parseInt(req.params.id));
|
||||
if (!protocol.emp_private_email) {
|
||||
return res.status(400).json({ status: 'error', message: 'Keine private E-Mail-Adresse beim Mitarbeiter hinterlegt' });
|
||||
}
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
OnboardingProtocol.setConfirmToken(protocol.id, token);
|
||||
await sendOnboardingConfirmationEmail(protocol, token);
|
||||
res.json({ status: 'success', message: `Bestätigungsmail an ${protocol.emp_private_email} gesendet` });
|
||||
});
|
||||
|
||||
static confirmByToken = asyncHandler(async (req, res) => {
|
||||
const OnboardingProtocol = require('../models/OnboardingProtocol');
|
||||
const { token } = req.params;
|
||||
const protocol = OnboardingProtocol.getByConfirmToken(token);
|
||||
if (!protocol) {
|
||||
return res.status(404).json({ status: 'error', message: 'Ungültiger oder bereits verwendeter Bestätigungslink' });
|
||||
}
|
||||
OnboardingProtocol.confirmByToken(token);
|
||||
const name = `${protocol.emp_first_name || ''} ${protocol.emp_last_name || ''}`.trim();
|
||||
res.json({ status: 'success', message: 'Übergabe erfolgreich bestätigt', data: { name, department: protocol.department } });
|
||||
});
|
||||
|
||||
static getStatistics = asyncHandler(async (req, res) => {
|
||||
const stats = OnboardingService.getStatistics();
|
||||
res.json({ status: 'success', data: stats });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = OnboardingController;
|
||||
95
backend/src/controllers/onboardingProcess.controller.js
Normal file
95
backend/src/controllers/onboardingProcess.controller.js
Normal file
@@ -0,0 +1,95 @@
|
||||
const OnboardingProcess = require('../models/OnboardingProcess');
|
||||
|
||||
// ── Departments ──────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getDepartments = (req, res) => {
|
||||
const data = OnboardingProcess.getAllDepartments();
|
||||
res.json({ status: 'success', data });
|
||||
};
|
||||
|
||||
exports.createDepartment = (req, res) => {
|
||||
const { name, icon, sort_order } = req.body;
|
||||
if (!name?.trim()) {
|
||||
return res.status(400).json({ status: 'error', message: 'Name ist erforderlich' });
|
||||
}
|
||||
try {
|
||||
const dept = OnboardingProcess.createDepartment({ name: name.trim(), icon, sort_order });
|
||||
res.status(201).json({ status: 'success', data: dept });
|
||||
} catch (err) {
|
||||
if (err.message?.includes('UNIQUE')) {
|
||||
return res.status(409).json({ status: 'error', message: 'Abteilung existiert bereits' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateDepartment = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name, icon, sort_order } = req.body;
|
||||
const dept = OnboardingProcess.updateDepartment(id, { name, icon, sort_order });
|
||||
if (!dept) return res.status(404).json({ status: 'error', message: 'Abteilung nicht gefunden' });
|
||||
res.json({ status: 'success', data: dept });
|
||||
};
|
||||
|
||||
exports.deleteDepartment = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const ok = OnboardingProcess.deleteDepartment(id);
|
||||
if (!ok) return res.status(404).json({ status: 'error', message: 'Abteilung nicht gefunden' });
|
||||
res.json({ status: 'success', message: 'Abteilung gelöscht' });
|
||||
};
|
||||
|
||||
exports.reorderDepartments = (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids)) {
|
||||
return res.status(400).json({ status: 'error', message: 'ids muss ein Array sein' });
|
||||
}
|
||||
OnboardingProcess.reorderDepartments(ids);
|
||||
res.json({ status: 'success', message: 'Reihenfolge gespeichert' });
|
||||
};
|
||||
|
||||
// ── Processes ────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getProcesses = (req, res) => {
|
||||
const { department_id, applies_to, responsible_team } = req.query;
|
||||
const data = OnboardingProcess.getAllProcesses({
|
||||
departmentId: department_id ? Number(department_id) : undefined,
|
||||
appliesTo: applies_to || undefined,
|
||||
responsibleTeam: responsible_team || undefined,
|
||||
});
|
||||
res.json({ status: 'success', data });
|
||||
};
|
||||
|
||||
exports.createProcess = (req, res) => {
|
||||
const { department_id, responsible_team, title, description, applies_to, tag, sort_order } = req.body;
|
||||
if (!department_id || !title?.trim()) {
|
||||
return res.status(400).json({ status: 'error', message: 'department_id und title sind erforderlich' });
|
||||
}
|
||||
const proc = OnboardingProcess.createProcess({
|
||||
department_id, responsible_team, title: title.trim(), description, applies_to, tag, sort_order,
|
||||
});
|
||||
res.status(201).json({ status: 'success', data: proc });
|
||||
};
|
||||
|
||||
exports.updateProcess = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { title, description, applies_to, tag, sort_order, department_id, responsible_team } = req.body;
|
||||
const proc = OnboardingProcess.updateProcess(id, { title, description, applies_to, tag, sort_order, department_id, responsible_team });
|
||||
if (!proc) return res.status(404).json({ status: 'error', message: 'Prozess nicht gefunden' });
|
||||
res.json({ status: 'success', data: proc });
|
||||
};
|
||||
|
||||
exports.deleteProcess = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const ok = OnboardingProcess.deleteProcess(id);
|
||||
if (!ok) return res.status(404).json({ status: 'error', message: 'Prozess nicht gefunden' });
|
||||
res.json({ status: 'success', message: 'Prozess gelöscht' });
|
||||
};
|
||||
|
||||
exports.reorderProcesses = (req, res) => {
|
||||
const { department_id, ids } = req.body;
|
||||
if (!department_id || !Array.isArray(ids)) {
|
||||
return res.status(400).json({ status: 'error', message: 'department_id und ids erforderlich' });
|
||||
}
|
||||
OnboardingProcess.reorderProcesses(department_id, ids);
|
||||
res.json({ status: 'success', message: 'Reihenfolge gespeichert' });
|
||||
};
|
||||
303
backend/src/controllers/patch.controller.js
Normal file
303
backend/src/controllers/patch.controller.js
Normal file
@@ -0,0 +1,303 @@
|
||||
const { getDatabase } = require('../config/database');
|
||||
|
||||
// ── Patch Groups ──────────────────────────────────────────────────────────────
|
||||
|
||||
const getGroups = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const groups = db.prepare('SELECT * FROM patch_groups ORDER BY sort_order, name').all();
|
||||
const policies = db.prepare('SELECT * FROM patch_policies ORDER BY group_id').all();
|
||||
const agentCounts = db.prepare(`
|
||||
SELECT group_id, COUNT(*) as count FROM patch_agent_groups GROUP BY group_id
|
||||
`).all();
|
||||
|
||||
res.json(groups.map(g => ({
|
||||
...g,
|
||||
policies: policies.filter(p => p.group_id === g.id),
|
||||
agent_count: agentCounts.find(a => a.group_id === g.id)?.count || 0,
|
||||
})));
|
||||
};
|
||||
|
||||
const createGroup = (req, res) => {
|
||||
const { name, description, color = '#3B82F6' } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
|
||||
const db = getDatabase();
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM patch_groups').get();
|
||||
try {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO patch_groups (name, description, color, sort_order) VALUES (?, ?, ?, ?)'
|
||||
).run(name.trim(), description || null, color, (maxOrder.m || 0) + 1);
|
||||
res.status(201).json(db.prepare('SELECT * FROM patch_groups WHERE id = ?').get(result.lastInsertRowid));
|
||||
} catch (e) {
|
||||
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Gruppe existiert bereits' });
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const updateGroup = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const existing = db.prepare('SELECT * FROM patch_groups WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { name, description, color, target_agent_version } = req.body;
|
||||
db.prepare('UPDATE patch_groups SET name=?, description=?, color=?, target_agent_version=? WHERE id=?')
|
||||
.run(name?.trim() ?? existing.name, description ?? existing.description, color ?? existing.color,
|
||||
target_agent_version !== undefined ? (target_agent_version || null) : existing.target_agent_version,
|
||||
req.params.id);
|
||||
res.json(db.prepare('SELECT * FROM patch_groups WHERE id = ?').get(req.params.id));
|
||||
};
|
||||
|
||||
// Rollout-Reihenfolge: Test → Pilot → Produktion
|
||||
// Eine Gruppe darf eine Version nur bekommen wenn die Vorgänger-Gruppe sie bereits hat
|
||||
const ROLLOUT_ORDER = ['Test', 'Pilot', 'Produktion'];
|
||||
|
||||
const releaseVersionToAll = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const { version } = req.body;
|
||||
if (!version) return res.status(400).json({ error: 'version erforderlich' });
|
||||
db.prepare('UPDATE patch_groups SET target_agent_version=?').run(version);
|
||||
res.json({ success: true, version });
|
||||
};
|
||||
|
||||
const releaseVersionToGroup = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const { version } = req.body;
|
||||
if (!version) return res.status(400).json({ error: 'version erforderlich' });
|
||||
|
||||
const group = db.prepare('SELECT * FROM patch_groups WHERE id = ?').get(req.params.id);
|
||||
if (!group) return res.status(404).json({ error: 'Gruppe nicht gefunden' });
|
||||
|
||||
const pos = ROLLOUT_ORDER.indexOf(group.name);
|
||||
|
||||
// Vorgänger-Gruppe muss dieselbe oder höhere Version haben
|
||||
if (pos > 0) {
|
||||
const predecessorName = ROLLOUT_ORDER[pos - 1];
|
||||
const predecessor = db.prepare('SELECT target_agent_version FROM patch_groups WHERE name = ?').get(predecessorName);
|
||||
if (!predecessor?.target_agent_version || predecessor.target_agent_version !== version) {
|
||||
return res.status(403).json({
|
||||
error: `Version ${version} muss zuerst für "${predecessorName}" freigegeben sein bevor "${group.name}" aktualisiert werden kann.`,
|
||||
blocked_by: predecessorName,
|
||||
predecessor_version: predecessor?.target_agent_version || null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare('UPDATE patch_groups SET target_agent_version=? WHERE id=?').run(version, req.params.id);
|
||||
res.json({ success: true, group_id: group.id, group_name: group.name, version });
|
||||
};
|
||||
|
||||
const deleteGroup = (req, res) => {
|
||||
const db = getDatabase();
|
||||
if (!db.prepare('SELECT id FROM patch_groups WHERE id = ?').get(req.params.id)) {
|
||||
return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
}
|
||||
db.prepare('DELETE FROM patch_groups WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
// ── Patch Policies ────────────────────────────────────────────────────────────
|
||||
|
||||
const upsertPolicy = (req, res) => {
|
||||
const { group_id, severity, max_days, notify_email } = req.body;
|
||||
if (!group_id || !severity || !max_days) return res.status(400).json({ error: 'group_id, severity und max_days erforderlich' });
|
||||
const db = getDatabase();
|
||||
const existing = db.prepare('SELECT id FROM patch_policies WHERE group_id=? AND severity=?').get(group_id, severity);
|
||||
if (existing) {
|
||||
db.prepare('UPDATE patch_policies SET max_days=?, notify_email=? WHERE id=?')
|
||||
.run(max_days, notify_email || null, existing.id);
|
||||
res.json(db.prepare('SELECT * FROM patch_policies WHERE id=?').get(existing.id));
|
||||
} else {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO patch_policies (group_id, severity, max_days, notify_email) VALUES (?,?,?,?)'
|
||||
).run(group_id, severity, max_days, notify_email || null);
|
||||
res.status(201).json(db.prepare('SELECT * FROM patch_policies WHERE id=?').get(result.lastInsertRowid));
|
||||
}
|
||||
};
|
||||
|
||||
const deletePolicy = (req, res) => {
|
||||
const db = getDatabase();
|
||||
db.prepare('DELETE FROM patch_policies WHERE id=?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
// ── Agent Assignment ──────────────────────────────────────────────────────────
|
||||
|
||||
const assignAgent = (req, res) => {
|
||||
const { agent_id, group_id } = req.body;
|
||||
if (!agent_id || !group_id) return res.status(400).json({ error: 'agent_id und group_id erforderlich' });
|
||||
const db = getDatabase();
|
||||
// Ein Gerät kann nur in einer Gruppe sein → alte Zuweisung entfernen
|
||||
db.prepare('DELETE FROM patch_agent_groups WHERE agent_id=?').run(agent_id);
|
||||
if (group_id !== null && group_id !== 'none') {
|
||||
db.prepare('INSERT OR REPLACE INTO patch_agent_groups (agent_id, group_id) VALUES (?,?)').run(agent_id, group_id);
|
||||
}
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
// ── Overview / Compliance ─────────────────────────────────────────────────────
|
||||
|
||||
const getOverview = (req, res) => {
|
||||
const db = getDatabase();
|
||||
|
||||
const agents = db.prepare(`
|
||||
SELECT
|
||||
a.*,
|
||||
pg.id as group_id,
|
||||
pg.name as group_name,
|
||||
pg.color as group_color
|
||||
FROM monitoring_agents a
|
||||
LEFT JOIN patch_agent_groups pag ON a.id = pag.agent_id
|
||||
LEFT JOIN patch_groups pg ON pag.group_id = pg.id
|
||||
ORDER BY pg.sort_order NULLS LAST, a.hostname
|
||||
`).all();
|
||||
|
||||
const policies = db.prepare('SELECT * FROM patch_policies').all();
|
||||
|
||||
const result = agents.map(agent => {
|
||||
let updates = [];
|
||||
try { updates = JSON.parse(agent.pending_updates || '[]'); } catch {}
|
||||
|
||||
const agentPolicies = policies.filter(p => p.group_id === agent.group_id);
|
||||
const updateCount = typeof agent.windows_updates_pending === 'number'
|
||||
? agent.windows_updates_pending
|
||||
: 0;
|
||||
|
||||
// Compliance-Status berechnen
|
||||
let compliance = 'ok';
|
||||
let violationReason = null;
|
||||
|
||||
if (agentPolicies.length > 0 && updateCount > 0) {
|
||||
compliance = 'warn';
|
||||
violationReason = `${updateCount} ausstehende Update(s)`;
|
||||
}
|
||||
|
||||
// last_checkin ist UTC ohne Timezone-Marker → explizit als UTC parsen
|
||||
const lastCheckin = agent.last_checkin ? new Date(agent.last_checkin + 'Z') : null;
|
||||
const isOffline = !lastCheckin || (Date.now() - lastCheckin.getTime()) > 15 * 60 * 1000;
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
hostname: agent.hostname,
|
||||
os: agent.os_name,
|
||||
os_version: agent.os_version,
|
||||
last_seen: agent.last_checkin,
|
||||
is_offline: isOffline,
|
||||
pending_updates: updateCount,
|
||||
agent_version: agent.agent_version || '?',
|
||||
tpm_present: !!agent.tpm_present,
|
||||
tpm_version: agent.tpm_version || null,
|
||||
tpm_v2: !!agent.tpm_v2,
|
||||
secure_boot: !!agent.secure_boot,
|
||||
win11_ready: !!agent.win11_ready,
|
||||
last_user: agent.last_user || null,
|
||||
bitlocker_status: agent.bitlocker_status || null,
|
||||
defender_enabled: agent.defender_enabled != null ? !!agent.defender_enabled : null,
|
||||
defender_signatures_age: agent.defender_signatures_age ?? null,
|
||||
hardware_serial: agent.hardware_serial || null,
|
||||
ip_address: agent.ip_address || null,
|
||||
group_id: agent.group_id,
|
||||
group_name: agent.group_name || 'Keine Gruppe',
|
||||
group_color: agent.group_color || '#6B7280',
|
||||
compliance,
|
||||
violation_reason: violationReason,
|
||||
};
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: result.length,
|
||||
compliant: result.filter(a => a.compliance === 'ok' && !a.is_offline).length,
|
||||
warnings: result.filter(a => a.compliance === 'warn').length,
|
||||
offline: result.filter(a => a.is_offline).length,
|
||||
total_pending_updates: result.reduce((s, a) => s + (a.pending_updates || 0), 0),
|
||||
};
|
||||
|
||||
res.json({ agents: result, stats });
|
||||
};
|
||||
|
||||
// ── Commands ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const triggerCommand = (req, res) => {
|
||||
const { agent_id, command } = req.body;
|
||||
if (!agent_id || !command) return res.status(400).json({ error: 'agent_id und command erforderlich' });
|
||||
const db = getDatabase();
|
||||
|
||||
// Duplikate vermeiden: nur ein pending/sent Command pro Agent+Type
|
||||
db.prepare("DELETE FROM patch_commands WHERE agent_id=? AND command=? AND status IN ('pending','sent')").run(agent_id, command);
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO patch_commands (agent_id, command, triggered_by_user_id) VALUES (?,?,?)'
|
||||
).run(agent_id, command, req.user.id);
|
||||
res.status(201).json(db.prepare('SELECT * FROM patch_commands WHERE id=?').get(result.lastInsertRowid));
|
||||
};
|
||||
|
||||
const triggerGroupCommand = (req, res) => {
|
||||
const { group_id, command } = req.body;
|
||||
if (!group_id || !command) return res.status(400).json({ error: 'group_id und command erforderlich' });
|
||||
const db = getDatabase();
|
||||
|
||||
const agents = db.prepare('SELECT agent_id FROM patch_agent_groups WHERE group_id=?').all(group_id);
|
||||
let count = 0;
|
||||
for (const { agent_id } of agents) {
|
||||
db.prepare("DELETE FROM patch_commands WHERE agent_id=? AND command=? AND status IN ('pending','sent')").run(agent_id, command);
|
||||
db.prepare('INSERT INTO patch_commands (agent_id, command, triggered_by_user_id) VALUES (?,?,?)').run(agent_id, command, req.user.id);
|
||||
count++;
|
||||
}
|
||||
res.json({ success: true, triggered: count });
|
||||
};
|
||||
|
||||
const getCommands = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const { agent_id } = req.query;
|
||||
const rows = agent_id
|
||||
? db.prepare(`
|
||||
SELECT pc.*, a.hostname, u.username as triggered_by_username
|
||||
FROM patch_commands pc
|
||||
JOIN monitoring_agents a ON pc.agent_id = a.id
|
||||
LEFT JOIN users u ON pc.triggered_by_user_id = u.id
|
||||
WHERE pc.agent_id = ?
|
||||
ORDER BY pc.created_at DESC
|
||||
LIMIT 30
|
||||
`).all(agent_id)
|
||||
: db.prepare(`
|
||||
SELECT pc.*, a.hostname, u.username as triggered_by_username
|
||||
FROM patch_commands pc
|
||||
JOIN monitoring_agents a ON pc.agent_id = a.id
|
||||
LEFT JOIN users u ON pc.triggered_by_user_id = u.id
|
||||
ORDER BY pc.created_at DESC
|
||||
LIMIT 100
|
||||
`).all();
|
||||
res.json(rows);
|
||||
};
|
||||
|
||||
// Called by the agent after execution
|
||||
const reportCommandResult = (req, res) => {
|
||||
const agentKey = req.headers['x-agent-key'];
|
||||
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
const { command_id, status, result } = req.body;
|
||||
if (!command_id) return res.status(400).json({ error: 'command_id erforderlich' });
|
||||
const db = getDatabase();
|
||||
db.prepare("UPDATE patch_commands SET status=?, result=?, completed_at=CURRENT_TIMESTAMP WHERE id=?")
|
||||
.run(status || 'done', result || null, command_id);
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
// Called by agent check-in – returns pending + running commands for this agent
|
||||
const getPendingCommands = (agentId) => {
|
||||
const db = getDatabase();
|
||||
const cmds = db.prepare("SELECT * FROM patch_commands WHERE agent_id=? AND status='pending'").all(agentId);
|
||||
if (cmds.length > 0) {
|
||||
db.prepare("UPDATE patch_commands SET status='sent', sent_at=CURRENT_TIMESTAMP WHERE agent_id=? AND status='pending'")
|
||||
.run(agentId);
|
||||
}
|
||||
const running = db.prepare("SELECT * FROM patch_commands WHERE agent_id=? AND status='running'").all(agentId);
|
||||
return { pending: cmds, running };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getGroups, createGroup, updateGroup, deleteGroup, releaseVersionToGroup, releaseVersionToAll,
|
||||
upsertPolicy, deletePolicy,
|
||||
assignAgent,
|
||||
getOverview,
|
||||
triggerCommand, triggerGroupCommand, getCommands, reportCommandResult,
|
||||
getPendingCommands,
|
||||
};
|
||||
106
backend/src/controllers/portalGuide.controller.js
Normal file
106
backend/src/controllers/portalGuide.controller.js
Normal file
@@ -0,0 +1,106 @@
|
||||
const { getDatabase } = require('../config/database');
|
||||
|
||||
const ADMIN_ROLES = ['super_admin', 'admin'];
|
||||
|
||||
// Prüft ob ein User eine Anleitung sehen darf
|
||||
function canView(visibleRoles, userRole) {
|
||||
if (!visibleRoles || visibleRoles === '[]' || visibleRoles === '["all"]') return true;
|
||||
try {
|
||||
const roles = JSON.parse(visibleRoles);
|
||||
if (!roles.length || roles.includes('all')) return true;
|
||||
return roles.includes(userRole);
|
||||
} catch { return true; }
|
||||
}
|
||||
|
||||
const getAll = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const userRole = req.user?.role_name || req.user?.role || '';
|
||||
const isAdmin = ADMIN_ROLES.includes(userRole);
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT pg.*, u.username as created_by_username
|
||||
FROM portal_guides pg
|
||||
LEFT JOIN users u ON pg.created_by_user_id = u.id
|
||||
ORDER BY pg.sort_order, pg.title
|
||||
`).all();
|
||||
|
||||
// Admins sehen alles, andere nur erlaubte
|
||||
const filtered = isAdmin
|
||||
? rows
|
||||
: rows.filter(r => canView(r.visible_roles, userRole));
|
||||
|
||||
res.json(filtered.map(({ html_content, ...r }) => r));
|
||||
};
|
||||
|
||||
const getById = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const row = db.prepare('SELECT * FROM portal_guides WHERE id = ?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const userRole = req.user?.role_name || req.user?.role || '';
|
||||
const isAdmin = ADMIN_ROLES.includes(userRole);
|
||||
if (!isAdmin && !canView(row.visible_roles, userRole)) {
|
||||
return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
}
|
||||
res.json(row);
|
||||
};
|
||||
|
||||
const getHtml = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const row = db.prepare('SELECT html_content, title, visible_roles FROM portal_guides WHERE id = ?').get(req.params.id);
|
||||
if (!row) return res.status(404).send('Nicht gefunden');
|
||||
const userRole = req.user?.role_name || req.user?.role || '';
|
||||
const isAdmin = ADMIN_ROLES.includes(userRole);
|
||||
if (!isAdmin && !canView(row.visible_roles, userRole)) {
|
||||
return res.status(403).send('Keine Berechtigung');
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
res.send(row.html_content);
|
||||
};
|
||||
|
||||
const create = (req, res) => {
|
||||
const { title, category = 'Allgemein', description, html_content, icon = '📄', sort_order = 0, visible_roles = [] } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' });
|
||||
if (!html_content?.trim()) return res.status(400).json({ error: 'HTML-Inhalt erforderlich' });
|
||||
const db = getDatabase();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO portal_guides (title, category, description, html_content, icon, sort_order, visible_roles, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(title.trim(), category, description || null, html_content, icon, sort_order,
|
||||
JSON.stringify(Array.isArray(visible_roles) ? visible_roles : []), req.user.id);
|
||||
res.status(201).json(db.prepare('SELECT * FROM portal_guides WHERE id = ?').get(result.lastInsertRowid));
|
||||
};
|
||||
|
||||
const update = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const existing = db.prepare('SELECT * FROM portal_guides WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { title, category, description, html_content, icon, sort_order, visible_roles } = req.body;
|
||||
db.prepare(`
|
||||
UPDATE portal_guides
|
||||
SET title = ?, category = ?, description = ?, html_content = ?, icon = ?, sort_order = ?, visible_roles = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title?.trim() ?? existing.title,
|
||||
category ?? existing.category,
|
||||
description !== undefined ? description : existing.description,
|
||||
html_content ?? existing.html_content,
|
||||
icon ?? existing.icon,
|
||||
sort_order ?? existing.sort_order,
|
||||
visible_roles !== undefined ? JSON.stringify(Array.isArray(visible_roles) ? visible_roles : []) : existing.visible_roles,
|
||||
req.params.id
|
||||
);
|
||||
const { html_content: _, ...row } = db.prepare('SELECT * FROM portal_guides WHERE id = ?').get(req.params.id);
|
||||
res.json(row);
|
||||
};
|
||||
|
||||
const remove = (req, res) => {
|
||||
const db = getDatabase();
|
||||
if (!db.prepare('SELECT id FROM portal_guides WHERE id = ?').get(req.params.id)) {
|
||||
return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
}
|
||||
db.prepare('DELETE FROM portal_guides WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
module.exports = { getAll, getById, getHtml, create, update, remove };
|
||||
12
backend/src/controllers/proxmox.controller.js
Normal file
12
backend/src/controllers/proxmox.controller.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const ProxmoxService = require('../services/proxmox.service');
|
||||
|
||||
const getOverview = async (req, res) => {
|
||||
try {
|
||||
const data = await ProxmoxService.getAll();
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(503).json({ error: 'Proxmox nicht erreichbar', detail: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getOverview };
|
||||
153
backend/src/controllers/risk.controller.js
Normal file
153
backend/src/controllers/risk.controller.js
Normal file
@@ -0,0 +1,153 @@
|
||||
const Risk = require('../models/Risk');
|
||||
const { getSecureScore, getRiskyUsers, getDefenderAlerts, getNonCompliantDevices, getUsersWithoutMfa } = require('../services/graph.service');
|
||||
|
||||
exports.getAll = (req, res) => {
|
||||
const data = Risk.getAll(req.query);
|
||||
res.json({ success: true, data });
|
||||
};
|
||||
|
||||
exports.getStats = (req, res) => {
|
||||
res.json({ success: true, data: Risk.getStats() });
|
||||
};
|
||||
|
||||
exports.create = (req, res) => {
|
||||
const risk = Risk.create(req.body);
|
||||
res.status(201).json({ success: true, data: risk });
|
||||
};
|
||||
|
||||
exports.update = (req, res) => {
|
||||
const risk = Risk.update(req.params.id, req.body);
|
||||
if (!risk) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
|
||||
res.json({ success: true, data: risk });
|
||||
};
|
||||
|
||||
exports.remove = (req, res) => {
|
||||
const ok = Risk.delete(req.params.id);
|
||||
if (!ok) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
exports.sync = async (req, res) => {
|
||||
const result = { created: 0, updated: 0, errors: [] };
|
||||
|
||||
// 1. Secure Score
|
||||
try {
|
||||
const score = await getSecureScore();
|
||||
if (score) {
|
||||
const pct = Math.round((score.currentScore / score.maxScore) * 100);
|
||||
const prob = pct < 40 ? 5 : pct < 60 ? 4 : pct < 75 ? 3 : pct < 90 ? 2 : 1;
|
||||
const r = Risk.upsertAuto({
|
||||
source_type: 'secure_score',
|
||||
external_id: 'tenant_secure_score',
|
||||
title: `Microsoft Secure Score: ${pct}% (${Math.round(score.currentScore)}/${Math.round(score.maxScore)})`,
|
||||
description: `Aktueller Microsoft Secure Score. Je niedriger der Score, desto höher das Sicherheitsrisiko. Letzte Messung: ${new Date(score.createdDateTime).toLocaleDateString('de-DE')}.`,
|
||||
category: 'IT-Sicherheit',
|
||||
probability: prob,
|
||||
impact: 4,
|
||||
});
|
||||
result[r.last_synced ? 'updated' : 'created']++;
|
||||
}
|
||||
} catch (e) { result.errors.push(`Secure Score: ${e.message}`); }
|
||||
|
||||
// 2. Risky Users (requires Azure AD Premium P2 - skip if not available)
|
||||
try {
|
||||
const users = await getRiskyUsers();
|
||||
for (const u of users) {
|
||||
const levelMap = { high: 5, medium: 3, low: 2 };
|
||||
const prob = levelMap[u.riskLevel] || 3;
|
||||
Risk.upsertAuto({
|
||||
source_type: 'risky_user',
|
||||
external_id: u.id,
|
||||
title: `Risikobehafteter Benutzer: ${u.userDisplayName || u.userPrincipalName}`,
|
||||
description: `Entra ID Identity Protection hat diesen Benutzer als risikoreich eingestuft. Risiko-Level: ${u.riskLevel}, Detail: ${u.riskDetail || 'unbekannt'}.`,
|
||||
category: 'Identität & Zugriff',
|
||||
probability: prob,
|
||||
impact: 4,
|
||||
});
|
||||
}
|
||||
result.created += users.length;
|
||||
} catch (e) {
|
||||
// Risky Users requires Azure AD Premium P2 – silently skip if not licensed
|
||||
if (!e.message.includes('Forbidden') && !e.message.includes('missing in the token')) {
|
||||
result.errors.push(`Risky Users: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Defender Alerts
|
||||
try {
|
||||
const alerts = await getDefenderAlerts();
|
||||
const sevMap = { high: 5, medium: 3, low: 2, informational: 1 };
|
||||
for (const a of alerts) {
|
||||
Risk.upsertAuto({
|
||||
source_type: 'defender_alert',
|
||||
external_id: a.id,
|
||||
title: `Defender Alert: ${a.title}`,
|
||||
description: `${a.description || ''}`,
|
||||
category: 'Incident / Bedrohung',
|
||||
probability: sevMap[a.severity] || 3,
|
||||
impact: sevMap[a.severity] || 3,
|
||||
metadata: {
|
||||
severity: a.severity,
|
||||
status: a.status,
|
||||
category: a.category,
|
||||
detected: a.createdDateTime,
|
||||
entities: (a.evidence || []).map(e => e.userAccount?.displayName || e.deviceDnsName || e.fileName).filter(Boolean),
|
||||
recommendation: a.recommendedActions,
|
||||
alertWebUrl: a.alertWebUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
result.created += alerts.length;
|
||||
} catch (e) { result.errors.push(`Defender Alerts: ${e.message}`); }
|
||||
|
||||
// 4. Non-Compliant Devices
|
||||
try {
|
||||
const devices = await getNonCompliantDevices();
|
||||
for (const d of devices) {
|
||||
Risk.upsertAuto({
|
||||
source_type: 'noncompliant_device',
|
||||
external_id: d.id,
|
||||
title: `Nicht-konformes Gerät: ${d.deviceName}`,
|
||||
description: `Gerät erfüllt die Intune-Compliance-Richtlinien nicht.`,
|
||||
category: 'Geräte & Endpunkte',
|
||||
probability: 3,
|
||||
impact: 3,
|
||||
metadata: {
|
||||
deviceName: d.deviceName,
|
||||
user: d.userDisplayName,
|
||||
os: d.operatingSystem,
|
||||
serialNumber: d.serialNumber,
|
||||
lastSync: d.lastSyncDateTime,
|
||||
},
|
||||
});
|
||||
}
|
||||
result.created += devices.length;
|
||||
} catch (e) { result.errors.push(`Non-Compliant Devices: ${e.message}`); }
|
||||
|
||||
// 5. Users without MFA
|
||||
try {
|
||||
const noMfa = await getUsersWithoutMfa();
|
||||
if (noMfa.length > 0) {
|
||||
Risk.upsertAuto({
|
||||
source_type: 'no_mfa',
|
||||
external_id: 'users_without_mfa',
|
||||
title: `${noMfa.length} Benutzer ohne MFA registriert`,
|
||||
description: `${noMfa.length} Benutzer haben keine Multi-Faktor-Authentifizierung eingerichtet.`,
|
||||
category: 'Identität & Zugriff',
|
||||
probability: 4,
|
||||
impact: 4,
|
||||
metadata: {
|
||||
users: noMfa.map(u => ({
|
||||
name: u.userDisplayName,
|
||||
email: u.userPrincipalName,
|
||||
mfaRegistered: u.isMfaRegistered,
|
||||
methods: u.authMethods,
|
||||
})),
|
||||
},
|
||||
});
|
||||
result.created++;
|
||||
}
|
||||
} catch (e) { result.errors.push(`MFA-Status: ${e.message}`); }
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
};
|
||||
150
backend/src/controllers/scanner.controller.js
Normal file
150
backend/src/controllers/scanner.controller.js
Normal file
@@ -0,0 +1,150 @@
|
||||
const { getDatabase } = require('../config/database');
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
|
||||
// Validate X-Scanner-Key header
|
||||
const validateScannerKey = (req, res) => {
|
||||
const key = req.headers['x-scanner-key'];
|
||||
if (!key || key !== process.env.SCANNER_API_KEY) {
|
||||
res.status(401).json({ status: 'error', message: 'Invalid scanner key' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// POST /api/scanner/assets — called by nexus-scanner (no JWT)
|
||||
const reportAssets = asyncHandler(async (req, res) => {
|
||||
if (!validateScannerKey(req, res)) return;
|
||||
|
||||
const { site, scanner_version, reported_at, assets } = req.body;
|
||||
if (!site || !Array.isArray(assets)) {
|
||||
return res.status(400).json({ status: 'error', message: 'site and assets[] required' });
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Upsert scanner site
|
||||
db.prepare(`
|
||||
INSERT INTO scanner_sites (site_id, last_seen, scanner_version, host_count)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(site_id) DO UPDATE SET
|
||||
last_seen = excluded.last_seen,
|
||||
scanner_version = excluded.scanner_version,
|
||||
host_count = excluded.host_count
|
||||
`).run(site, now, scanner_version || '1.0', assets.length);
|
||||
|
||||
// Upsert each asset
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO scanner_assets (site_id, ip, mac, hostname, vendor, status, first_seen, last_seen, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(site_id, ip) DO UPDATE SET
|
||||
mac = excluded.mac,
|
||||
hostname = CASE WHEN excluded.hostname != '' THEN excluded.hostname ELSE hostname END,
|
||||
vendor = CASE WHEN excluded.vendor != '' THEN excluded.vendor ELSE vendor END,
|
||||
status = excluded.status,
|
||||
last_seen = excluded.last_seen,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
|
||||
const insertMany = db.transaction((items) => {
|
||||
for (const a of items) {
|
||||
upsert.run(
|
||||
site,
|
||||
a.ip,
|
||||
a.mac || '',
|
||||
a.hostname || '',
|
||||
a.vendor || '',
|
||||
a.status || 'online',
|
||||
a.first_seen || now,
|
||||
a.last_seen || now,
|
||||
now
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(assets);
|
||||
|
||||
res.json({ status: 'success', received: assets.length });
|
||||
});
|
||||
|
||||
// POST /api/scanner/alert — called by nexus-scanner (no JWT)
|
||||
const reportAlert = asyncHandler(async (req, res) => {
|
||||
if (!validateScannerKey(req, res)) return;
|
||||
|
||||
const { site, check, type, target, status, latency, error, subject } = req.body;
|
||||
if (!site || !check || !status) {
|
||||
return res.status(400).json({ status: 'error', message: 'site, check, status required' });
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO scanner_alerts (site_id, check_name, check_type, target, status, latency_ms, error_msg, subject)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(site, check, type || '', target || '', status, latency || 0, error || '', subject || '');
|
||||
|
||||
// Update check_count on site
|
||||
db.prepare(`UPDATE scanner_sites SET check_count = check_count + 1 WHERE site_id = ?`).run(site);
|
||||
|
||||
res.json({ status: 'success' });
|
||||
});
|
||||
|
||||
// GET /api/scanner/sites — JWT required
|
||||
const getSites = asyncHandler(async (req, res) => {
|
||||
const db = getDatabase();
|
||||
const sites = db.prepare(`SELECT * FROM scanner_sites ORDER BY site_id`).all();
|
||||
res.json({ status: 'success', data: sites });
|
||||
});
|
||||
|
||||
// GET /api/scanner/assets — JWT required
|
||||
const getAssets = asyncHandler(async (req, res) => {
|
||||
const db = getDatabase();
|
||||
const { site, status, q } = req.query;
|
||||
|
||||
let query = `SELECT * FROM scanner_assets WHERE 1=1`;
|
||||
const params = [];
|
||||
|
||||
if (site) { query += ` AND site_id = ?`; params.push(site); }
|
||||
if (status) { query += ` AND status = ?`; params.push(status); }
|
||||
if (q) {
|
||||
query += ` AND (ip LIKE ? OR hostname LIKE ? OR mac LIKE ? OR vendor LIKE ?)`;
|
||||
const like = `%${q}%`;
|
||||
params.push(like, like, like, like);
|
||||
}
|
||||
query += ` ORDER BY last_seen DESC LIMIT 500`;
|
||||
|
||||
const assets = db.prepare(query).all(...params);
|
||||
res.json({ status: 'success', data: assets });
|
||||
});
|
||||
|
||||
// GET /api/scanner/alerts — JWT required
|
||||
const getAlerts = asyncHandler(async (req, res) => {
|
||||
const db = getDatabase();
|
||||
const { site, limit = 100 } = req.query;
|
||||
|
||||
let query = `SELECT * FROM scanner_alerts WHERE 1=1`;
|
||||
const params = [];
|
||||
|
||||
if (site) { query += ` AND site_id = ?`; params.push(site); }
|
||||
query += ` ORDER BY triggered_at DESC LIMIT ?`;
|
||||
params.push(Number(limit));
|
||||
|
||||
const alerts = db.prepare(query).all(...params);
|
||||
res.json({ status: 'success', data: alerts });
|
||||
});
|
||||
|
||||
// GET /api/scanner/stats — JWT required, summary numbers
|
||||
const getStats = asyncHandler(async (req, res) => {
|
||||
const db = getDatabase();
|
||||
const sites = db.prepare(`SELECT COUNT(*) as cnt FROM scanner_sites`).get().cnt;
|
||||
const total = db.prepare(`SELECT COUNT(*) as cnt FROM scanner_assets`).get().cnt;
|
||||
const online = db.prepare(`SELECT COUNT(*) as cnt FROM scanner_assets WHERE status='online'`).get().cnt;
|
||||
const alerts24h = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM scanner_alerts
|
||||
WHERE triggered_at >= datetime('now','-24 hours') AND status='offline'
|
||||
`).get().cnt;
|
||||
|
||||
res.json({ status: 'success', data: { sites, total, online, offline: total - online, alerts24h } });
|
||||
});
|
||||
|
||||
module.exports = { reportAssets, reportAlert, getSites, getAssets, getAlerts, getStats };
|
||||
148
backend/src/controllers/securityReport.controller.js
Normal file
148
backend/src/controllers/securityReport.controller.js
Normal file
@@ -0,0 +1,148 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { getDatabase } = require('../config/database');
|
||||
const multer = require('multer');
|
||||
const pdfParse = require('pdf-parse');
|
||||
const Anthropic = require('@anthropic-ai/sdk');
|
||||
|
||||
const UPLOAD_DIR = path.join(__dirname, '../../uploads/security-reports');
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
|
||||
filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`),
|
||||
});
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === 'application/pdf') cb(null, true);
|
||||
else cb(new Error('Nur PDF-Dateien erlaubt'));
|
||||
},
|
||||
});
|
||||
|
||||
const ANALYSIS_PROMPT = `Du analysierst einen wöchentlichen Microsoft 365 Security Report von Arctic Wolf für Cereda Systems GmbH.
|
||||
|
||||
Extrahiere alle relevanten Informationen und gib sie als JSON zurück. Antworte NUR mit validem JSON, kein Text darum herum.
|
||||
|
||||
JSON-Struktur:
|
||||
{
|
||||
"report_period": "DD.MM.YYYY - DD.MM.YYYY",
|
||||
"risk_level": "niedrig|mittel|hoch",
|
||||
"summary": "Kurze 2-3 Satz Zusammenfassung auf Deutsch",
|
||||
"login_stats": {
|
||||
"total": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"success_rate": 0.0
|
||||
},
|
||||
"top_login_failures": [
|
||||
{ "user": "email@cereda-systems.de", "count": 0, "type": "Interactive|Non-Interactive" }
|
||||
],
|
||||
"non_european_logins": [
|
||||
{ "user": "email", "country": "Land", "ip": "IP", "count": 0, "flagged": true }
|
||||
],
|
||||
"sharepoint_anonymous_links": 0,
|
||||
"identity_protection_events": 0,
|
||||
"new_groups_created": [],
|
||||
"key_findings": ["Befund 1", "Befund 2"],
|
||||
"recommendations": ["Empfehlung 1", "Empfehlung 2"],
|
||||
"all_clear": true
|
||||
}
|
||||
|
||||
Wichtig:
|
||||
- risk_level "hoch" wenn: nicht-europäische Logins von unbekannten IPs, viele Login-Fehler bei kritischen Accounts (server@, admin@)
|
||||
- risk_level "mittel" wenn: einige Auffälligkeiten aber keine aktiven Bedrohungen
|
||||
- risk_level "niedrig" wenn: alles normal
|
||||
- flagged: true bei nicht-europäischen Logins die verdächtig aussehen
|
||||
- all_clear: false wenn risk_level hoch oder mittel`;
|
||||
|
||||
const analyzeWithClaude = async (text) => {
|
||||
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||||
const response = await client.messages.create({
|
||||
model: 'claude-sonnet-4-6',
|
||||
max_tokens: 2000,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `${ANALYSIS_PROMPT}\n\nReport-Inhalt:\n${text.slice(0, 15000)}`,
|
||||
}],
|
||||
});
|
||||
const content = response.content[0].text.trim();
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) throw new Error('Claude hat kein gültiges JSON zurückgegeben');
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
};
|
||||
|
||||
// ── Upload + Analysieren ──────────────────────────────────────────────────────
|
||||
const uploadReport = [
|
||||
upload.single('pdf'),
|
||||
async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'Keine PDF-Datei' });
|
||||
try {
|
||||
const pdfBuffer = fs.readFileSync(req.file.path);
|
||||
const pdfData = await pdfParse(pdfBuffer);
|
||||
const text = pdfData.text;
|
||||
|
||||
if (text.length < 100) {
|
||||
fs.unlinkSync(req.file.path);
|
||||
return res.status(400).json({ error: 'PDF konnte nicht gelesen werden oder ist leer' });
|
||||
}
|
||||
|
||||
const analysis = await analyzeWithClaude(text);
|
||||
|
||||
const db = getDatabase();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO security_reports
|
||||
(filename, original_filename, report_period, risk_level, summary_json, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
req.file.filename,
|
||||
req.file.originalname,
|
||||
analysis.report_period || 'Unbekannt',
|
||||
analysis.risk_level || 'unbekannt',
|
||||
JSON.stringify(analysis),
|
||||
req.user.id
|
||||
);
|
||||
|
||||
const report = db.prepare('SELECT * FROM security_reports WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json({ ...report, analysis });
|
||||
} catch (e) {
|
||||
if (req.file?.path && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
|
||||
console.error('[SecurityReport] Upload error:', e.message);
|
||||
res.status(500).json({ error: `Analyse fehlgeschlagen: ${e.message}` });
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// ── Alle Reports ──────────────────────────────────────────────────────────────
|
||||
const getAllReports = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const rows = db.prepare(`
|
||||
SELECT sr.*, u.username as created_by_username
|
||||
FROM security_reports sr
|
||||
LEFT JOIN users u ON sr.created_by_user_id = u.id
|
||||
ORDER BY sr.created_at DESC
|
||||
`).all();
|
||||
res.json(rows.map(r => ({ ...r, analysis: JSON.parse(r.summary_json || '{}') })));
|
||||
};
|
||||
|
||||
// ── Einzelner Report ──────────────────────────────────────────────────────────
|
||||
const getReport = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const report = db.prepare('SELECT * FROM security_reports WHERE id = ?').get(req.params.id);
|
||||
if (!report) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ ...report, analysis: JSON.parse(report.summary_json || '{}') });
|
||||
};
|
||||
|
||||
// ── Report löschen ────────────────────────────────────────────────────────────
|
||||
const deleteReport = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const report = db.prepare('SELECT * FROM security_reports WHERE id = ?').get(req.params.id);
|
||||
if (!report) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const filePath = path.join(UPLOAD_DIR, report.filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
db.prepare('DELETE FROM security_reports WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
module.exports = { uploadReport, getAllReports, getReport, deleteReport };
|
||||
162
backend/src/controllers/settings.controller.js
Normal file
162
backend/src/controllers/settings.controller.js
Normal file
@@ -0,0 +1,162 @@
|
||||
const TicketSettings = require('../models/TicketSettings');
|
||||
const { EmailTemplate } = require('../models/EmailTemplate');
|
||||
const { EmailDesign } = require('../models/EmailDesign');
|
||||
const { buildPreviewHtml } = require('../services/email.service');
|
||||
|
||||
// ── Categories ────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getCategories = (req, res) => {
|
||||
try {
|
||||
res.json(TicketSettings.getAllCategories());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.createCategory = (req, res) => {
|
||||
const { name, icon } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ message: 'Name erforderlich' });
|
||||
try {
|
||||
const cat = TicketSettings.createCategory({ name, icon });
|
||||
res.status(201).json(cat);
|
||||
} catch (e) {
|
||||
if (e.message?.includes('UNIQUE')) return res.status(409).json({ message: 'Kategorie existiert bereits' });
|
||||
res.status(500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateCategory = (req, res) => {
|
||||
try {
|
||||
const cat = TicketSettings.updateCategory(req.params.id, req.body);
|
||||
if (!cat) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
res.json(cat);
|
||||
} catch (e) {
|
||||
if (e.message?.includes('UNIQUE')) return res.status(409).json({ message: 'Name bereits vergeben' });
|
||||
res.status(500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.deleteCategory = (req, res) => {
|
||||
try {
|
||||
TicketSettings.deleteCategory(req.params.id);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.reorderCategories = (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids)) return res.status(400).json({ message: 'ids array erforderlich' });
|
||||
try {
|
||||
TicketSettings.reorderCategories(ids);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Templates ─────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getTemplates = (req, res) => {
|
||||
try {
|
||||
res.json(TicketSettings.getAllTemplates());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.createTemplate = (req, res) => {
|
||||
const { label, title, description, category, priority } = req.body;
|
||||
if (!label?.trim()) return res.status(400).json({ message: 'Label erforderlich' });
|
||||
try {
|
||||
const tpl = TicketSettings.createTemplate({ label, title, description, category, priority });
|
||||
res.status(201).json(tpl);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateTemplate = (req, res) => {
|
||||
try {
|
||||
const tpl = TicketSettings.updateTemplate(req.params.id, req.body);
|
||||
if (!tpl) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
res.json(tpl);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.deleteTemplate = (req, res) => {
|
||||
try {
|
||||
TicketSettings.deleteTemplate(req.params.id);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Email Templates ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getEmailTemplates = (req, res) => {
|
||||
try {
|
||||
res.json(EmailTemplate.getAll());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateEmailTemplate = (req, res) => {
|
||||
const { type } = req.params;
|
||||
const { subject, intro } = req.body;
|
||||
if (!subject?.trim() || !intro?.trim()) {
|
||||
return res.status(400).json({ message: 'Betreff und Intro-Text sind erforderlich' });
|
||||
}
|
||||
try {
|
||||
const tpl = EmailTemplate.update(type, { subject, intro });
|
||||
if (!tpl) return res.status(404).json({ message: 'Vorlage nicht gefunden' });
|
||||
res.json(tpl);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.resetEmailTemplate = (req, res) => {
|
||||
try {
|
||||
const tpl = EmailTemplate.reset(req.params.type);
|
||||
if (!tpl) return res.status(404).json({ message: 'Vorlage nicht gefunden' });
|
||||
res.json(tpl);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Email Design ───────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getEmailDesign = (req, res) => {
|
||||
try { res.json(EmailDesign.getDesign()); }
|
||||
catch (e) { res.status(500).json({ message: e.message }); }
|
||||
};
|
||||
|
||||
exports.updateEmailDesign = (req, res) => {
|
||||
try { res.json(EmailDesign.updateDesign(req.body)); }
|
||||
catch (e) { res.status(500).json({ message: e.message }); }
|
||||
};
|
||||
|
||||
exports.resetEmailDesign = (req, res) => {
|
||||
try { res.json(EmailDesign.reset()); }
|
||||
catch (e) { res.status(500).json({ message: e.message }); }
|
||||
};
|
||||
|
||||
exports.previewEmail = (req, res) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
const design = req.body?.design || {};
|
||||
const darkMode = !!req.body?.darkMode;
|
||||
const html = buildPreviewHtml(type, design, darkMode);
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(html);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
209
backend/src/controllers/share.controller.js
Normal file
209
backend/src/controllers/share.controller.js
Normal file
@@ -0,0 +1,209 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { getDatabase } = require('../config/database');
|
||||
|
||||
const UPLOAD_DIR = path.join(__dirname, '../../uploads/shares');
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
// multer konfiguriert in routes
|
||||
const multer = require('multer');
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, crypto.randomBytes(16).toString('hex') + ext);
|
||||
},
|
||||
});
|
||||
const upload = multer({ storage, limits: { fileSize: 100 * 1024 * 1024 } }); // 100 MB
|
||||
|
||||
// ── Admin: Share erstellen ────────────────────────────────────────────────────
|
||||
const createShare = [
|
||||
upload.single('file'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { type, text_content, password, expires_in_hours, max_downloads } = req.body;
|
||||
if (!type || !['file', 'text'].includes(type)) {
|
||||
return res.status(400).json({ error: 'type muss "file" oder "text" sein' });
|
||||
}
|
||||
if (type === 'text' && !text_content?.trim()) {
|
||||
return res.status(400).json({ error: 'text_content erforderlich' });
|
||||
}
|
||||
if (type === 'file' && !req.file) {
|
||||
return res.status(400).json({ error: 'Datei erforderlich' });
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
const password_hash = password ? await bcrypt.hash(password, 10) : null;
|
||||
const expires_at = expires_in_hours
|
||||
? new Date(Date.now() + parseInt(expires_in_hours) * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19)
|
||||
: null;
|
||||
|
||||
const db = getDatabase();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO shares (token, type, filename, stored_filename, text_content,
|
||||
password_hash, expires_at, max_downloads, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
token, type,
|
||||
req.file?.originalname || null,
|
||||
req.file?.filename || null,
|
||||
type === 'text' ? text_content.trim() : null,
|
||||
password_hash,
|
||||
expires_at,
|
||||
max_downloads ? parseInt(max_downloads) : null,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
const share = db.prepare('SELECT * FROM shares WHERE id = ?').get(result.lastInsertRowid);
|
||||
const publicUrl = `${process.env.FRONTEND_URL || ''}/s/${token}`;
|
||||
res.status(201).json({ ...share, public_url: publicUrl });
|
||||
} catch (e) {
|
||||
console.error('[Share] createShare error:', e.message);
|
||||
res.status(500).json({ error: 'Interner Fehler' });
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// ── Admin: Alle Shares ────────────────────────────────────────────────────────
|
||||
const getAllShares = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const rows = db.prepare(`
|
||||
SELECT s.*, u.username as created_by_username
|
||||
FROM shares s
|
||||
LEFT JOIN users u ON s.created_by_user_id = u.id
|
||||
WHERE s.deleted_at IS NULL
|
||||
ORDER BY s.created_at DESC
|
||||
`).all();
|
||||
const base = process.env.FRONTEND_URL || '';
|
||||
res.json(rows.map(r => ({ ...r, public_url: `${base}/s/${r.token}` })));
|
||||
};
|
||||
|
||||
// ── Admin: Share löschen ──────────────────────────────────────────────────────
|
||||
const deleteShare = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const share = db.prepare('SELECT * FROM shares WHERE id = ? AND deleted_at IS NULL').get(req.params.id);
|
||||
if (!share) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
|
||||
if (share.stored_filename) {
|
||||
const filePath = path.join(UPLOAD_DIR, share.stored_filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
db.prepare("UPDATE shares SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id);
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
// ── Public: Share-Info (kein Auth) ───────────────────────────────────────────
|
||||
const getPublicShare = (req, res) => {
|
||||
const db = getDatabase();
|
||||
const share = db.prepare(`
|
||||
SELECT id, token, type, filename, text_content, expires_at, max_downloads,
|
||||
download_count, created_at, password_hash IS NOT NULL as has_password
|
||||
FROM shares
|
||||
WHERE token = ? AND deleted_at IS NULL
|
||||
`).get(req.params.token);
|
||||
|
||||
if (!share) return res.status(404).json({ error: 'Link nicht gefunden oder abgelaufen' });
|
||||
|
||||
if (share.expires_at && new Date(share.expires_at + 'Z') < new Date()) {
|
||||
return res.status(410).json({ error: 'Dieser Link ist abgelaufen' });
|
||||
}
|
||||
if (share.max_downloads && share.download_count >= share.max_downloads) {
|
||||
return res.status(410).json({ error: 'Maximale Anzahl Downloads erreicht' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: share.id,
|
||||
token: share.token,
|
||||
type: share.type,
|
||||
filename: share.filename,
|
||||
expires_at: share.expires_at,
|
||||
max_downloads: share.max_downloads,
|
||||
download_count: share.download_count,
|
||||
has_password: !!share.has_password,
|
||||
// Text nur senden wenn kein Passwort gesetzt
|
||||
text_content: share.has_password ? null : share.text_content,
|
||||
});
|
||||
};
|
||||
|
||||
// ── Public: Passwort prüfen + Text freischalten ───────────────────────────────
|
||||
const accessShare = async (req, res) => {
|
||||
const { password } = req.body;
|
||||
const db = getDatabase();
|
||||
const share = db.prepare(`
|
||||
SELECT * FROM shares WHERE token = ? AND deleted_at IS NULL
|
||||
`).get(req.params.token);
|
||||
|
||||
if (!share) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (share.expires_at && new Date(share.expires_at + 'Z') < new Date()) {
|
||||
return res.status(410).json({ error: 'Abgelaufen' });
|
||||
}
|
||||
|
||||
if (share.password_hash) {
|
||||
if (!password) return res.status(401).json({ error: 'Passwort erforderlich' });
|
||||
const ok = await bcrypt.compare(password, share.password_hash);
|
||||
if (!ok) return res.status(401).json({ error: 'Falsches Passwort' });
|
||||
}
|
||||
|
||||
if (share.type === 'text') {
|
||||
res.json({ text_content: share.text_content });
|
||||
} else {
|
||||
res.json({ ready: true });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Public: Datei-Download ────────────────────────────────────────────────────
|
||||
const downloadFile = async (req, res) => {
|
||||
const { password } = req.query;
|
||||
const db = getDatabase();
|
||||
const share = db.prepare('SELECT * FROM shares WHERE token = ? AND deleted_at IS NULL').get(req.params.token);
|
||||
|
||||
if (!share || share.type !== 'file') return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (share.expires_at && new Date(share.expires_at + 'Z') < new Date()) {
|
||||
return res.status(410).json({ error: 'Abgelaufen' });
|
||||
}
|
||||
if (share.max_downloads && share.download_count >= share.max_downloads) {
|
||||
return res.status(410).json({ error: 'Download-Limit erreicht' });
|
||||
}
|
||||
|
||||
if (share.password_hash) {
|
||||
if (!password) return res.status(401).json({ error: 'Passwort erforderlich' });
|
||||
const ok = await bcrypt.compare(password, share.password_hash);
|
||||
if (!ok) return res.status(401).json({ error: 'Falsches Passwort' });
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, share.stored_filename);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht vorhanden' });
|
||||
|
||||
db.prepare('UPDATE shares SET download_count = download_count + 1 WHERE id = ?').run(share.id);
|
||||
res.download(filePath, share.filename);
|
||||
};
|
||||
|
||||
// ── Cleanup-Cron: abgelaufene/verbrauchte Shares löschen ────────────────────
|
||||
const cleanupExpired = () => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const expired = db.prepare(`
|
||||
SELECT * FROM shares
|
||||
WHERE deleted_at IS NULL AND (
|
||||
(expires_at IS NOT NULL AND expires_at < datetime('now'))
|
||||
OR (max_downloads IS NOT NULL AND download_count >= max_downloads)
|
||||
)
|
||||
`).all();
|
||||
|
||||
for (const share of expired) {
|
||||
if (share.stored_filename) {
|
||||
const filePath = path.join(UPLOAD_DIR, share.stored_filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
}
|
||||
db.prepare("UPDATE shares SET deleted_at = datetime('now') WHERE id = ?").run(share.id);
|
||||
}
|
||||
if (expired.length > 0) console.log(`[Shares] ${expired.length} abgelaufene Share(s) bereinigt`);
|
||||
} catch (e) {
|
||||
console.error('[Shares] Cleanup-Fehler:', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { createShare, getAllShares, deleteShare, getPublicShare, accessShare, downloadFile, cleanupExpired };
|
||||
33
backend/src/controllers/teamsActivity.controller.js
Normal file
33
backend/src/controllers/teamsActivity.controller.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
const { getDatabase } = require('../config/database');
|
||||
|
||||
class TeamsActivityController {
|
||||
|
||||
/**
|
||||
* GET /api/teams-activity/new-channels
|
||||
* Returns channels discovered since last notification (notified=0).
|
||||
*/
|
||||
static getNewChannels = asyncHandler(async (req, res) => {
|
||||
const db = getDatabase();
|
||||
const channels = db.prepare(`
|
||||
SELECT id, team_name, channel_name, first_seen_at
|
||||
FROM teams_channels
|
||||
WHERE notified = 0
|
||||
ORDER BY first_seen_at DESC
|
||||
LIMIT 50
|
||||
`).all();
|
||||
res.json({ status: 'success', data: channels });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/teams-activity/mark-read
|
||||
* Marks all unread channel notifications as read.
|
||||
*/
|
||||
static markRead = asyncHandler(async (req, res) => {
|
||||
const db = getDatabase();
|
||||
db.prepare('UPDATE teams_channels SET notified = 1 WHERE notified = 0').run();
|
||||
res.json({ status: 'success' });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = TeamsActivityController;
|
||||
781
backend/src/controllers/ticket.controller.js
Normal file
781
backend/src/controllers/ticket.controller.js
Normal file
@@ -0,0 +1,781 @@
|
||||
const { asyncHandler, AppError } = require('../middleware/errorHandler');
|
||||
const Ticket = require('../models/Ticket');
|
||||
const TicketComment = require('../models/TicketComment');
|
||||
const AuditLog = require('../models/AuditLog');
|
||||
const EmailService = require('../services/email.service');
|
||||
const AiService = require('../services/ai.service');
|
||||
const sseManager = require('../services/sseManager');
|
||||
const { getDatabase } = require('../config/database');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const JWT_CONFIG = require('../config/jwt');
|
||||
const PDFDocument = require('pdfkit');
|
||||
const { notifyByAadId } = require('./bot.controller');
|
||||
|
||||
class TicketController {
|
||||
|
||||
/**
|
||||
* GET /api/tickets
|
||||
*/
|
||||
static getAll = asyncHandler(async (req, res) => {
|
||||
const filters = {
|
||||
status: req.query.status || null,
|
||||
priority: req.query.priority || null,
|
||||
category: req.query.category || null,
|
||||
assigned_to: req.query.assigned_to || null,
|
||||
search: req.query.search || null,
|
||||
};
|
||||
// Remove null filters
|
||||
Object.keys(filters).forEach(k => filters[k] === null && delete filters[k]);
|
||||
|
||||
// Non-staff users only see tickets they created OR where they are the requester (by email)
|
||||
const isStaff = ['super_admin', 'admin', 'support'].includes(req.user.role);
|
||||
if (!isStaff) {
|
||||
filters.user_scope = { userId: req.user.id, email: req.user.email };
|
||||
}
|
||||
|
||||
const tickets = Ticket.getAll(filters);
|
||||
res.json({ status: 'success', data: tickets });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tickets/stats
|
||||
*/
|
||||
static getStats = asyncHandler(async (req, res) => {
|
||||
const stats = Ticket.getStats();
|
||||
res.json({ status: 'success', data: stats });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tickets/:id
|
||||
*/
|
||||
static getById = asyncHandler(async (req, res) => {
|
||||
const ticket = Ticket.getById(parseInt(req.params.id));
|
||||
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||
|
||||
const comments = TicketComment.getByTicketId(ticket.id);
|
||||
|
||||
// Filter internal comments for non-support/admin users
|
||||
const userRole = req.user.role;
|
||||
const canSeeInternal = ['super_admin', 'admin', 'support'].includes(userRole);
|
||||
const filteredComments = canSeeInternal
|
||||
? comments
|
||||
: comments.filter(c => !c.is_internal);
|
||||
|
||||
res.json({ status: 'success', data: { ...ticket, comments: filteredComments } });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tickets
|
||||
* All authenticated users can create tickets
|
||||
*/
|
||||
static create = asyncHandler(async (req, res) => {
|
||||
const { title, description, category, priority, asset_id,
|
||||
requester_name, requester_email } = req.body;
|
||||
|
||||
if (!title || !title.trim()) {
|
||||
throw new AppError('Titel ist erforderlich', 400);
|
||||
}
|
||||
|
||||
const ticket = Ticket.create({
|
||||
title: title.trim(),
|
||||
description: description?.trim() || null,
|
||||
category: category || 'Allgemein',
|
||||
priority: priority || 'mittel',
|
||||
asset_id: asset_id ? parseInt(asset_id) : null,
|
||||
source: 'web',
|
||||
requester_name: requester_name?.trim() || `${req.user.first_name || ''} ${req.user.last_name || ''}`.trim() || req.user.username,
|
||||
requester_email: requester_email?.trim() || req.user.email,
|
||||
created_by_user_id: req.user.id,
|
||||
});
|
||||
|
||||
// KI: Analyse + Auto-Erstantwort non-blocking
|
||||
if (AiService.isConfigured()) {
|
||||
AiService.analyzeTicket(title.trim(), description?.trim() || '')
|
||||
.then(analysis => {
|
||||
const updates = { ai_suggestion: analysis.suggestion };
|
||||
if (!category) updates.category = analysis.category;
|
||||
if (!priority) updates.priority = analysis.priority;
|
||||
Ticket.update(ticket.id, updates);
|
||||
console.log(`[AI] Ticket ${ticket.ticket_number} analysiert: ${analysis.category} / ${analysis.priority}`);
|
||||
})
|
||||
.catch(err => console.error('[AI] Ticket-Analyse fehlgeschlagen:', err.message));
|
||||
|
||||
// KI-Erstantwort: automatisch auf die Ticket-Beschreibung antworten
|
||||
const refreshedTicket = Ticket.getById(ticket.id);
|
||||
const firstMessage = description?.trim()
|
||||
? description.trim()
|
||||
: title.trim();
|
||||
AiService.chatInTicket(refreshedTicket, [{ role: 'user', content: firstMessage }])
|
||||
.then(aiText => {
|
||||
const aiComment = TicketComment.create(ticket.id, null, aiText, false, true);
|
||||
sseManager.broadcast(ticket.id, aiComment);
|
||||
console.log(`[AI] Erstantwort für Ticket ${ticket.ticket_number} gespeichert`);
|
||||
})
|
||||
.catch(err => console.error('[AI] Erstantwort fehlgeschlagen:', err.message));
|
||||
}
|
||||
|
||||
// Auto-Zuweisung basierend auf Kategorie
|
||||
const routing = getDatabase().prepare('SELECT assigned_to_user_id FROM ticket_routing WHERE category = ?').get(ticket.category);
|
||||
if (routing?.assigned_to_user_id) {
|
||||
Ticket.update(ticket.id, { assigned_to_user_id: routing.assigned_to_user_id });
|
||||
const assignedUser = getDatabase().prepare('SELECT * FROM users WHERE id = ?').get(routing.assigned_to_user_id);
|
||||
if (assignedUser) {
|
||||
const routed = Ticket.getById(ticket.id);
|
||||
EmailService.sendTicketAssignedNotification(routed, assignedUser).catch(() => {});
|
||||
console.log(`[Routing] Ticket ${ticket.ticket_number} auto-zugewiesen an ${assignedUser.username}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Send confirmation email (non-blocking – ticket creation succeeds even if email fails)
|
||||
if (ticket.requester_email) {
|
||||
EmailService.sendTicketCreatedConfirmation(ticket).catch(err =>
|
||||
console.error('[Email] Confirmation failed:', err.message)
|
||||
);
|
||||
}
|
||||
|
||||
// Notify staff members with notif_ticket_created enabled
|
||||
EmailService.sendStaffTicketCreatedNotification(ticket).catch(err =>
|
||||
console.error('[Email] Staff ticket-created notification failed:', err.message)
|
||||
);
|
||||
|
||||
res.status(201).json({ status: 'success', data: ticket });
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/tickets/:id
|
||||
*/
|
||||
static update = asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
const existing = Ticket.getById(id);
|
||||
if (!existing) throw new AppError('Ticket nicht gefunden', 404);
|
||||
|
||||
const oldStatus = existing.status;
|
||||
const updated = Ticket.update(id, req.body);
|
||||
|
||||
// AuditLog schreiben
|
||||
const changedFields = {};
|
||||
const trackFields = ['status', 'priority', 'category', 'assigned_to_user_id', 'asset_id'];
|
||||
for (const f of trackFields) {
|
||||
if (req.body[f] !== undefined && req.body[f] != existing[f]) {
|
||||
changedFields[f] = { from: existing[f], to: req.body[f] };
|
||||
}
|
||||
}
|
||||
if (Object.keys(changedFields).length > 0) {
|
||||
AuditLog.create({
|
||||
user_id: req.user.id,
|
||||
action: 'UPDATE',
|
||||
entity_type: 'ticket',
|
||||
entity_id: id,
|
||||
old_value: Object.fromEntries(Object.entries(changedFields).map(([k, v]) => [k, v.from])),
|
||||
new_value: Object.fromEntries(Object.entries(changedFields).map(([k, v]) => [k, v.to])),
|
||||
ip_address: req.ip,
|
||||
});
|
||||
}
|
||||
|
||||
// Email notifications on status or assignment change
|
||||
const tasks = [];
|
||||
if (req.body.status && req.body.status !== oldStatus) {
|
||||
tasks.push(EmailService.sendStatusChangeNotification(updated, oldStatus, req.body.status));
|
||||
// Zufriedenheits-Feedback wenn Ticket geschlossen wird
|
||||
if (req.body.status === 'geschlossen' && updated.requester_email) {
|
||||
EmailService.sendSatisfactionEmail(updated).catch(err =>
|
||||
console.error('[Email] Satisfaction failed:', err.message)
|
||||
);
|
||||
}
|
||||
// Auto KB-Artikel generieren wenn Ticket geschlossen wird
|
||||
if (req.body.status === 'geschlossen') {
|
||||
AiService.generateKbArticleFromTicket(updated.id).catch(err =>
|
||||
console.error('[KB] Auto-generation failed:', err.message)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (req.body.assigned_to_user_id && req.body.assigned_to_user_id !== existing.assigned_to_user_id) {
|
||||
const db = getDatabase();
|
||||
const assignedUser = db.prepare('SELECT * FROM users WHERE id = ?').get(req.body.assigned_to_user_id);
|
||||
if (assignedUser) {
|
||||
tasks.push(EmailService.sendTicketAssignedNotification(updated, assignedUser));
|
||||
|
||||
// Teams-Benachrichtigung wenn Ticket via Bot erstellt wurde
|
||||
const aadMatch = updated.requester_email?.match(/^teams-(.+)@teams\.bot$/);
|
||||
if (aadMatch) {
|
||||
const agentName = assignedUser.first_name
|
||||
? `${assignedUser.first_name} ${assignedUser.last_name}`.trim()
|
||||
: assignedUser.username;
|
||||
notifyByAadId(aadMatch[1],
|
||||
`👤 **${agentName}** übernimmt dein Ticket **${updated.ticket_number}** und kümmert sich darum.\n\n_Du kannst hier direkt antworten – deine Nachrichten gehen direkt an den Support._`
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
|
||||
res.json({ status: 'success', data: updated });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/tickets/:id (super_admin only)
|
||||
*/
|
||||
static delete = asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
const ticket = Ticket.getById(id);
|
||||
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||
|
||||
Ticket.delete(id);
|
||||
res.json({ status: 'success', message: 'Ticket gelöscht' });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tickets/:id/comments
|
||||
*/
|
||||
static addComment = asyncHandler(async (req, res) => {
|
||||
const ticketId = parseInt(req.params.id);
|
||||
const ticket = Ticket.getById(ticketId);
|
||||
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||
|
||||
const { comment, is_internal } = req.body;
|
||||
if (!comment || !comment.trim()) {
|
||||
throw new AppError('Kommentar darf nicht leer sein', 400);
|
||||
}
|
||||
|
||||
const userRole = req.user.role;
|
||||
const canSetInternal = ['super_admin', 'admin', 'support'].includes(userRole);
|
||||
const internal = canSetInternal && !!is_internal;
|
||||
|
||||
const newComment = TicketComment.create(ticketId, req.user.id, comment.trim(), internal);
|
||||
|
||||
// Notify requester on public comments – only when Support/Admin writes
|
||||
const isSupportAuthor = ['super_admin', 'admin', 'support'].includes(userRole);
|
||||
if (!internal && isSupportAuthor) {
|
||||
await EmailService.sendCommentNotification(ticket, newComment);
|
||||
|
||||
// Teams-Benachrichtigung wenn Ticket via Bot erstellt wurde
|
||||
const aadMatch = ticket.requester_email?.match(/^teams-(.+)@teams\.bot$/);
|
||||
if (aadMatch) {
|
||||
const authorName = req.user.first_name
|
||||
? `${req.user.first_name} ${req.user.last_name}`.trim()
|
||||
: req.user.username;
|
||||
notifyByAadId(aadMatch[1],
|
||||
`📨 **Antwort vom IT-Support**\n` +
|
||||
`**${authorName}** · ${ticket.ticket_number}\n\n` +
|
||||
`${comment.trim()}\n\n` +
|
||||
`_Tippe deine Antwort um direkt zu antworten._`
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Notify assigned staff when requester replies
|
||||
if (!internal && !isSupportAuthor) {
|
||||
await EmailService.sendStaffCommentNotification(ticket, newComment);
|
||||
}
|
||||
|
||||
// Auto-Status: Kommentar setzt Status basierend auf Rolle
|
||||
const updateData = {};
|
||||
const activeStatuses = ['offen', 'in_bearbeitung', 'warten_auf_mitarbeiter', 'warten_auf_support'];
|
||||
if (!internal && ticket.status !== 'geschlossen') {
|
||||
if (isSupportAuthor) {
|
||||
// Support schreibt → wartet auf Antwort des Mitarbeiters
|
||||
updateData.status = 'warten_auf_mitarbeiter';
|
||||
} else {
|
||||
// Mitarbeiter schreibt → wartet auf Support
|
||||
if (ticket.status === 'geschlossen') {
|
||||
updateData.status = 'offen'; // Reopen
|
||||
} else {
|
||||
updateData.status = 'warten_auf_support';
|
||||
}
|
||||
}
|
||||
} else if (!isSupportAuthor && ticket.status === 'geschlossen') {
|
||||
// User antwortet auf geschlossenes Ticket → wieder öffnen
|
||||
updateData.status = 'offen';
|
||||
}
|
||||
// Support übernimmt → KI deaktivieren (nur bei öffentlichem Kommentar von Staff)
|
||||
if (!internal && isSupportAuthor && ticket.ai_active !== 0) {
|
||||
updateData.ai_active = 0;
|
||||
}
|
||||
if (Object.keys(updateData).length > 0) Ticket.update(ticketId, updateData);
|
||||
|
||||
// Broadcast to all SSE clients watching this ticket
|
||||
sseManager.broadcast(ticketId, newComment);
|
||||
|
||||
res.status(201).json({ status: 'success', data: newComment });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tickets/:id/ai-reply
|
||||
* Benutzer schickt Nachricht → KI antwortet, beide als Kommentare gespeichert
|
||||
*/
|
||||
static aiReply = asyncHandler(async (req, res) => {
|
||||
if (!AiService.isConfigured()) {
|
||||
throw new AppError('KI-Integration nicht konfiguriert', 503);
|
||||
}
|
||||
|
||||
const ticketId = parseInt(req.params.id);
|
||||
const ticket = Ticket.getById(ticketId);
|
||||
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||
|
||||
const { message, history } = req.body;
|
||||
if (!message?.trim()) throw new AppError('Nachricht darf nicht leer sein', 400);
|
||||
if (ticket.ai_active === 0) throw new AppError('Support hat die Bearbeitung übernommen', 403);
|
||||
|
||||
// User-Nachricht als Kommentar speichern
|
||||
const userComment = TicketComment.create(ticketId, req.user.id, message.trim(), false, false);
|
||||
sseManager.broadcast(ticketId, userComment);
|
||||
|
||||
// Konversationshistorie für KI aufbauen (max. 10 letzte Nachrichten)
|
||||
const aiMessages = [
|
||||
...(Array.isArray(history) ? history.slice(-10) : []),
|
||||
{ role: 'user', content: message.trim() },
|
||||
];
|
||||
|
||||
// KI-Antwort generieren
|
||||
const aiText = await AiService.chatInTicket(ticket, aiMessages);
|
||||
|
||||
// KI-Antwort als Kommentar speichern (is_ai_comment = true)
|
||||
const aiComment = TicketComment.create(ticketId, null, aiText, false, true);
|
||||
sseManager.broadcast(ticketId, aiComment);
|
||||
|
||||
Ticket.update(ticketId, {});
|
||||
|
||||
res.status(201).json({ status: 'success', data: { userComment, aiComment } });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tickets/:id/events (SSE – kein authenticateToken Middleware,
|
||||
* da EventSource keine custom Headers unterstützt → Token via ?token=)
|
||||
*/
|
||||
static streamComments = (req, res) => {
|
||||
// Verify token from query param
|
||||
const token = req.query.token;
|
||||
if (!token) return res.status(401).end();
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = jwt.verify(token, JWT_CONFIG.secret);
|
||||
} catch (_) {
|
||||
return res.status(403).end();
|
||||
}
|
||||
|
||||
const ticketId = parseInt(req.params.id);
|
||||
if (!ticketId) return res.status(400).end();
|
||||
|
||||
const canSeeInternal = ['super_admin', 'admin', 'support'].includes(user.role);
|
||||
|
||||
// SSE Headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
// Initial handshake
|
||||
res.write('event: connected\ndata: {}\n\n');
|
||||
|
||||
// Heartbeat alle 25s damit nginx/Proxies die Verbindung offen lassen
|
||||
const heartbeat = setInterval(() => {
|
||||
try { res.write(':ping\n\n'); } catch (_) {}
|
||||
}, 25000);
|
||||
|
||||
sseManager.subscribe(ticketId, res, canSeeInternal);
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
sseManager.unsubscribe(ticketId, res);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/tickets/:id/comments/:commentId
|
||||
*/
|
||||
static deleteComment = asyncHandler(async (req, res) => {
|
||||
const deleted = TicketComment.delete(parseInt(req.params.commentId));
|
||||
if (!deleted) throw new AppError('Kommentar nicht gefunden', 404);
|
||||
res.json({ status: 'success', message: 'Kommentar gelöscht' });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tickets/:id/history
|
||||
*/
|
||||
static getHistory = asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
const ticket = Ticket.getById(id);
|
||||
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||
|
||||
const history = AuditLog.getByEntity('ticket', id, 200, 0);
|
||||
res.json({ status: 'success', data: history });
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/tickets/bulk
|
||||
*/
|
||||
static bulkUpdate = asyncHandler(async (req, res) => {
|
||||
const { ids, data } = req.body;
|
||||
if (!Array.isArray(ids) || ids.length === 0) throw new AppError('Keine IDs angegeben', 400);
|
||||
if (!data || Object.keys(data).length === 0) throw new AppError('Keine Änderungen angegeben', 400);
|
||||
|
||||
const results = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const existing = Ticket.getById(parseInt(id));
|
||||
if (!existing) continue;
|
||||
const updated = Ticket.update(parseInt(id), data);
|
||||
|
||||
// AuditLog
|
||||
const changedFields = {};
|
||||
const trackFields = ['status', 'priority', 'category', 'assigned_to_user_id'];
|
||||
for (const f of trackFields) {
|
||||
if (data[f] !== undefined && data[f] != existing[f]) {
|
||||
changedFields[f] = { from: existing[f], to: data[f] };
|
||||
}
|
||||
}
|
||||
if (Object.keys(changedFields).length > 0) {
|
||||
AuditLog.create({
|
||||
user_id: req.user.id,
|
||||
action: 'BULK_UPDATE',
|
||||
entity_type: 'ticket',
|
||||
entity_id: parseInt(id),
|
||||
old_value: Object.fromEntries(Object.entries(changedFields).map(([k, v]) => [k, v.from])),
|
||||
new_value: Object.fromEntries(Object.entries(changedFields).map(([k, v]) => [k, v.to])),
|
||||
ip_address: req.ip,
|
||||
});
|
||||
}
|
||||
|
||||
results.push(updated);
|
||||
} catch (_) {}
|
||||
}
|
||||
res.json({ status: 'success', data: results, count: results.length });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tickets/:id/pdf
|
||||
*/
|
||||
static exportPdf = asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
const ticket = Ticket.getById(id);
|
||||
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||
|
||||
const comments = TicketComment.getByTicketId(id)
|
||||
.filter(c => !c.is_internal);
|
||||
|
||||
const doc = new PDFDocument({ margin: 40, size: 'A4' });
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="ticket-${ticket.ticket_number}.pdf"`);
|
||||
doc.pipe(res);
|
||||
|
||||
// Header
|
||||
doc.fontSize(18).font('Helvetica-Bold').text('IT Nexus – Ticket-Export', { align: 'left' });
|
||||
doc.moveDown(0.3);
|
||||
doc.fontSize(11).font('Helvetica').fillColor('#666')
|
||||
.text(`Exportiert am: ${new Date().toLocaleString('de-DE')}`, { align: 'left' });
|
||||
doc.moveDown(0.8);
|
||||
|
||||
// Divider
|
||||
doc.moveTo(40, doc.y).lineTo(555, doc.y).stroke('#ddd');
|
||||
doc.moveDown(0.8);
|
||||
|
||||
// Ticket-Info
|
||||
doc.fontSize(20).font('Helvetica-Bold').fillColor('#1a1a1a')
|
||||
.text(`${ticket.ticket_number}: ${ticket.title}`);
|
||||
doc.moveDown(0.5);
|
||||
|
||||
const statusMap = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', warten_auf_mitarbeiter: 'Warten auf Mitarbeiter', warten_auf_support: 'Warten auf Support', geschlossen: 'Geschlossen' };
|
||||
const prioMap = { niedrig: 'Niedrig', mittel: 'Mittel', hoch: 'Hoch', kritisch: 'Kritisch' };
|
||||
|
||||
const meta = [
|
||||
['Status', statusMap[ticket.status] || ticket.status],
|
||||
['Priorität', prioMap[ticket.priority] || ticket.priority],
|
||||
['Kategorie', ticket.category],
|
||||
['Erstellt am', new Date(ticket.created_at).toLocaleString('de-DE')],
|
||||
['Anfragender', `${ticket.requester_name || '–'}${ticket.requester_email ? ` <${ticket.requester_email}>` : ''}`],
|
||||
['Zugewiesen an', ticket.assigned_to_first_name ? `${ticket.assigned_to_first_name} ${ticket.assigned_to_last_name || ''}` : (ticket.assigned_to_username || '–')],
|
||||
];
|
||||
if (ticket.asset_name) meta.push(['Asset', `${ticket.asset_name} (${ticket.asset_serial || ''})`]);
|
||||
|
||||
doc.fontSize(10).font('Helvetica');
|
||||
for (const [label, value] of meta) {
|
||||
doc.fillColor('#888').text(`${label}: `, { continued: true }).fillColor('#1a1a1a').text(value);
|
||||
}
|
||||
|
||||
doc.moveDown(0.8);
|
||||
|
||||
// Beschreibung
|
||||
if (ticket.description) {
|
||||
doc.fontSize(12).font('Helvetica-Bold').fillColor('#1a1a1a').text('Beschreibung');
|
||||
doc.moveDown(0.3);
|
||||
doc.fontSize(10).font('Helvetica').fillColor('#333').text(ticket.description, { lineGap: 3 });
|
||||
doc.moveDown(0.8);
|
||||
}
|
||||
|
||||
// Kommentare
|
||||
if (comments.length > 0) {
|
||||
doc.moveTo(40, doc.y).lineTo(555, doc.y).stroke('#ddd');
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(12).font('Helvetica-Bold').fillColor('#1a1a1a').text('Kommentare');
|
||||
doc.moveDown(0.5);
|
||||
|
||||
for (const c of comments) {
|
||||
const author = c.first_name ? `${c.first_name} ${c.last_name || ''}`.trim() : (c.username || 'Unbekannt');
|
||||
doc.fontSize(9).font('Helvetica-Bold').fillColor('#555')
|
||||
.text(`${author} — ${new Date(c.created_at).toLocaleString('de-DE')}`);
|
||||
doc.fontSize(10).font('Helvetica').fillColor('#1a1a1a')
|
||||
.text(c.comment, { lineGap: 2 });
|
||||
doc.moveDown(0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
doc.fontSize(8).fillColor('#aaa').text(
|
||||
`IT Nexus · Ticket ${ticket.ticket_number} · ${new Date().toLocaleDateString('de-DE')}`,
|
||||
{ align: 'center' }
|
||||
);
|
||||
|
||||
doc.end();
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tickets/metrics
|
||||
*/
|
||||
static getMetrics = asyncHandler(async (req, res) => {
|
||||
const days = parseInt(req.query.days) || 30;
|
||||
const data = Ticket.getMetrics(days);
|
||||
res.json({ status: 'success', data });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tickets/:id/links
|
||||
*/
|
||||
static getLinks = asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
const ticket = Ticket.getById(id);
|
||||
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||
const links = Ticket.getLinks(id);
|
||||
res.json({ status: 'success', data: links });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tickets/:id/links
|
||||
*/
|
||||
static addLink = asyncHandler(async (req, res) => {
|
||||
const ticketId = parseInt(req.params.id);
|
||||
const { linked_ticket_number, link_type } = req.body;
|
||||
if (!linked_ticket_number) throw new AppError('Ticket-Nummer erforderlich', 400);
|
||||
|
||||
const ticket = Ticket.getById(ticketId);
|
||||
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||
|
||||
// Find linked ticket by number
|
||||
const db = getDatabase();
|
||||
const linked = db.prepare('SELECT id FROM tickets WHERE ticket_number = ?').get(linked_ticket_number.trim());
|
||||
if (!linked) throw new AppError(`Ticket ${linked_ticket_number} nicht gefunden`, 404);
|
||||
if (linked.id === ticketId) throw new AppError('Ein Ticket kann nicht mit sich selbst verknüpft werden', 400);
|
||||
|
||||
Ticket.addLink(ticketId, linked.id, link_type || 'related', req.user.id);
|
||||
const links = Ticket.getLinks(ticketId);
|
||||
res.status(201).json({ status: 'success', data: links });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/tickets/:id/links/:linkId
|
||||
*/
|
||||
static removeLink = asyncHandler(async (req, res) => {
|
||||
const deleted = Ticket.removeLink(parseInt(req.params.linkId));
|
||||
if (!deleted) throw new AppError('Verknüpfung nicht gefunden', 404);
|
||||
res.json({ status: 'success', message: 'Verknüpfung entfernt' });
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/tickets/:id/snooze
|
||||
*/
|
||||
static snooze = asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
const ticket = Ticket.getById(id);
|
||||
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||
|
||||
const { snoozed_until } = req.body;
|
||||
const updated = Ticket.update(id, { snoozed_until: snoozed_until || null });
|
||||
res.json({ status: 'success', data: updated });
|
||||
});
|
||||
|
||||
/** POST /api/tickets/:id/assignees */
|
||||
static addAssignee = asyncHandler(async (req, res) => {
|
||||
const ticketId = parseInt(req.params.id);
|
||||
const ticket = Ticket.getById(ticketId);
|
||||
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||
const { user_id } = req.body;
|
||||
if (!user_id) throw new AppError('user_id fehlt', 400);
|
||||
Ticket.addAssignee(ticketId, parseInt(user_id), req.user.id);
|
||||
res.json({ status: 'success', data: Ticket.getAssignees(ticketId) });
|
||||
});
|
||||
|
||||
/** DELETE /api/tickets/:id/assignees/:userId */
|
||||
static removeAssignee = asyncHandler(async (req, res) => {
|
||||
const ticketId = parseInt(req.params.id);
|
||||
Ticket.removeAssignee(ticketId, parseInt(req.params.userId));
|
||||
res.json({ status: 'success', data: Ticket.getAssignees(ticketId) });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tickets/:id/feedback?rating=gut|schlecht
|
||||
* Public – no auth required (link from satisfaction email)
|
||||
*/
|
||||
static feedback = asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
const { rating } = req.query;
|
||||
if (!['gut', 'schlecht'].includes(rating)) {
|
||||
return res.status(400).send('<h2>Ungültige Bewertung</h2>');
|
||||
}
|
||||
const ticket = Ticket.getById(id);
|
||||
if (!ticket) return res.status(404).send('<h2>Ticket nicht gefunden</h2>');
|
||||
|
||||
const emoji = rating === 'gut' ? '👍' : '👎';
|
||||
const color = rating === 'gut' ? '#16a34a' : '#dc2626';
|
||||
const bg = rating === 'gut' ? '#dcfce7' : '#fee2e2';
|
||||
const border= rating === 'gut' ? '#86efac' : '#fca5a5';
|
||||
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Feedback – IT Nexus</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:Inter,system-ui,sans-serif;background:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
|
||||
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08);padding:40px 36px;max-width:480px;width:100%;text-align:center}
|
||||
.emoji{font-size:3.5rem;margin-bottom:12px}
|
||||
h1{font-size:1.4rem;font-weight:800;color:#111827;margin-bottom:8px}
|
||||
.sub{color:#6b7280;font-size:0.9rem;margin-bottom:28px}
|
||||
.rating-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 16px;border-radius:20px;font-size:0.85rem;font-weight:700;background:${bg};color:${color};border:1px solid ${border};margin-bottom:24px}
|
||||
textarea{width:100%;border:1px solid #e2e8f0;border-radius:10px;padding:12px 14px;font-size:0.9rem;font-family:inherit;resize:vertical;min-height:110px;color:#374151;transition:border-color .2s}
|
||||
textarea:focus{outline:none;border-color:${color}}
|
||||
label{display:block;text-align:left;font-size:0.8rem;font-weight:600;color:#374151;margin-bottom:6px}
|
||||
.hint{text-align:left;font-size:0.75rem;color:#9ca3af;margin-top:6px;margin-bottom:20px}
|
||||
button{width:100%;padding:12px;border:none;border-radius:10px;background:${color};color:#fff;font-size:1rem;font-weight:700;cursor:pointer;transition:opacity .15s}
|
||||
button:hover{opacity:0.88}
|
||||
.ticket-nr{font-family:monospace;font-size:0.75rem;color:#9ca3af;margin-top:20px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="emoji">${emoji}</div>
|
||||
<h1>Wie war Ihr Support-Erlebnis?</h1>
|
||||
<p class="sub">Ticket ${ticket.ticket_number} – ${ticket.title.substring(0, 60)}${ticket.title.length > 60 ? '…' : ''}</p>
|
||||
<div class="rating-badge">${emoji} ${rating === 'gut' ? 'Positiv' : 'Negativ'}</div>
|
||||
<form method="POST" action="/api/tickets/${id}/feedback">
|
||||
<input type="hidden" name="rating" value="${rating}" />
|
||||
<label for="comment">Möchten Sie noch etwas hinzufügen? <span style="font-weight:400;color:#9ca3af">(optional)</span></label>
|
||||
<textarea id="comment" name="comment" placeholder="z. B. Was hat gut funktioniert, was könnte besser sein …"></textarea>
|
||||
<div class="hint">Ihr Kommentar hilft uns, den IT-Support kontinuierlich zu verbessern.</div>
|
||||
<button type="submit">Feedback absenden</button>
|
||||
</form>
|
||||
<div class="ticket-nr">IT Nexus · ${ticket.ticket_number}</div>
|
||||
</div>
|
||||
</body></html>`);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tickets/:id/feedback
|
||||
* Public – speichert Rating + optionalen Kommentar
|
||||
*/
|
||||
static submitFeedback = asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
const { rating, comment } = req.body;
|
||||
if (!['gut', 'schlecht'].includes(rating)) {
|
||||
return res.status(400).send('<h2>Ungültige Bewertung</h2>');
|
||||
}
|
||||
const ticket = Ticket.getById(id);
|
||||
if (!ticket) return res.status(404).send('<h2>Ticket nicht gefunden</h2>');
|
||||
|
||||
Ticket.update(id, {
|
||||
satisfaction_rating: rating,
|
||||
satisfaction_comment: comment?.trim() || null,
|
||||
});
|
||||
|
||||
const emoji = rating === 'gut' ? '👍' : '👎';
|
||||
const color = rating === 'gut' ? '#16a34a' : '#dc2626';
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Danke – IT Nexus</title>
|
||||
<style>
|
||||
body{font-family:Inter,system-ui,sans-serif;background:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
|
||||
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08);padding:48px 36px;max-width:420px;width:100%;text-align:center}
|
||||
.emoji{font-size:4rem;margin-bottom:16px}
|
||||
h1{font-size:1.5rem;font-weight:800;color:#111827;margin-bottom:10px}
|
||||
p{color:#6b7280;font-size:0.9rem;line-height:1.6}
|
||||
.nr{font-family:monospace;font-size:0.75rem;color:#9ca3af;margin-top:24px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="emoji">${emoji}</div>
|
||||
<h1 style="color:${color}">Vielen Dank!</h1>
|
||||
<p>Ihr Feedback wurde erfolgreich gespeichert.<br>Sie können dieses Fenster schließen.</p>
|
||||
<div class="nr">IT Nexus · ${ticket.ticket_number}</div>
|
||||
</div>
|
||||
</body></html>`);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tickets/public
|
||||
* Public – no auth required (from QR-code defect page)
|
||||
*/
|
||||
static createPublic = asyncHandler(async (req, res) => {
|
||||
const { title, description, category, priority, requester_name, requester_email, serial_number } = req.body;
|
||||
if (!title?.trim()) throw new AppError('Titel ist erforderlich', 400);
|
||||
|
||||
let asset_id = null;
|
||||
if (serial_number) {
|
||||
const asset = getDatabase().prepare('SELECT id FROM assets WHERE serial_number = ?').get(serial_number);
|
||||
if (asset) asset_id = asset.id;
|
||||
}
|
||||
|
||||
const ticket = Ticket.create({
|
||||
title: title.trim(),
|
||||
description: description?.trim() || null,
|
||||
category: category || 'Hardware',
|
||||
priority: priority || 'mittel',
|
||||
asset_id,
|
||||
source: 'web',
|
||||
requester_name: requester_name?.trim() || 'Anonym',
|
||||
requester_email: requester_email?.trim() || null,
|
||||
created_by_user_id: null,
|
||||
});
|
||||
|
||||
if (ticket.requester_email) {
|
||||
EmailService.sendTicketCreatedConfirmation(ticket).catch(() => {});
|
||||
}
|
||||
|
||||
res.status(201).json({ status: 'success', data: { ticket_number: ticket.ticket_number } });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tickets/routing
|
||||
*/
|
||||
static getRouting = asyncHandler(async (req, res) => {
|
||||
const routing = getDatabase().prepare(`
|
||||
SELECT r.id, r.category, r.assigned_to_user_id,
|
||||
u.username, u.first_name, u.last_name
|
||||
FROM ticket_routing r
|
||||
LEFT JOIN users u ON u.id = r.assigned_to_user_id
|
||||
`).all();
|
||||
res.json({ status: 'success', data: routing });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tickets/routing
|
||||
* Body: { category, assigned_to_user_id }
|
||||
*/
|
||||
static saveRouting = asyncHandler(async (req, res) => {
|
||||
const { category, assigned_to_user_id } = req.body;
|
||||
if (!category) throw new AppError('Kategorie fehlt', 400);
|
||||
const db = getDatabase();
|
||||
db.prepare(`
|
||||
INSERT INTO ticket_routing (category, assigned_to_user_id)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(category) DO UPDATE SET assigned_to_user_id = excluded.assigned_to_user_id
|
||||
`).run(category, assigned_to_user_id || null);
|
||||
const routing = db.prepare(`
|
||||
SELECT r.id, r.category, r.assigned_to_user_id,
|
||||
u.username, u.first_name, u.last_name
|
||||
FROM ticket_routing r
|
||||
LEFT JOIN users u ON u.id = r.assigned_to_user_id
|
||||
`).all();
|
||||
res.json({ status: 'success', data: routing });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = TicketController;
|
||||
76
backend/src/controllers/tv.controller.js
Normal file
76
backend/src/controllers/tv.controller.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const { getDatabase } = require('../config/database');
|
||||
|
||||
async function getStats(req, res) {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const now = Date.now();
|
||||
const fifteenMin = 15 * 60 * 1000;
|
||||
|
||||
// Agents
|
||||
const agents = db.prepare('SELECT hostname, last_checkin, cpu_usage_percent, ram_total_gb, ram_used_gb, bitlocker_status FROM monitoring_agents').all();
|
||||
const agentList = agents.map(a => {
|
||||
const ms = a.last_checkin ? new Date(a.last_checkin + (a.last_checkin.includes('Z') ? '' : 'Z')).getTime() : 0;
|
||||
const ram_usage_percent = a.ram_total_gb > 0 ? Math.round((a.ram_used_gb / a.ram_total_gb) * 100) : 0;
|
||||
return { ...a, ram_usage_percent, online: ms && (now - ms) < fifteenMin };
|
||||
});
|
||||
const online = agentList.filter(a => a.online).length;
|
||||
|
||||
// Tickets
|
||||
const ticketStats = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as open,
|
||||
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as inProgress,
|
||||
SUM(CASE WHEN date(created_at) = date('now') AND status IN ('closed','resolved') THEN 1 ELSE 0 END) as closedToday,
|
||||
SUM(CASE WHEN priority = 'critical' AND status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as critical,
|
||||
SUM(CASE WHEN priority = 'high' AND status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as high,
|
||||
SUM(CASE WHEN priority = 'medium' AND status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as medium,
|
||||
SUM(CASE WHEN priority = 'low' AND status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as low
|
||||
FROM tickets
|
||||
`).get();
|
||||
|
||||
// Patch overview
|
||||
const patchStats = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN windows_updates_pending = 0 THEN 1 ELSE 0 END) as compliant,
|
||||
SUM(CASE WHEN windows_updates_pending > 0 THEN 1 ELSE 0 END) as warnings,
|
||||
SUM(COALESCE(windows_updates_pending, 0)) as total_pending_updates
|
||||
FROM monitoring_agents
|
||||
`).get();
|
||||
|
||||
// Last security report
|
||||
const lastSecReport = db.prepare('SELECT * FROM security_reports ORDER BY created_at DESC LIMIT 1').get();
|
||||
|
||||
// Ticket trend last 7 days
|
||||
const perDay7 = db.prepare(`
|
||||
SELECT date(created_at) as day, COUNT(*) as count
|
||||
FROM tickets
|
||||
WHERE created_at >= date('now', '-7 days')
|
||||
GROUP BY date(created_at)
|
||||
ORDER BY day ASC
|
||||
`).all();
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
agentList: agentList.map(a => ({
|
||||
hostname: a.hostname,
|
||||
last_checkin: a.last_checkin,
|
||||
cpu_usage_percent: a.cpu_usage_percent,
|
||||
ram_usage_percent: a.ram_usage_percent,
|
||||
bitlocker_status: a.bitlocker_status,
|
||||
})),
|
||||
monitoring: { total: agents.length, online, offline: agents.length - online },
|
||||
tickets: ticketStats,
|
||||
patch: patchStats,
|
||||
lastSecReport: lastSecReport || null,
|
||||
ticketMetrics: { perDay7: perDay7.map(r => r.count) },
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStats };
|
||||
56
backend/src/controllers/unifi.controller.js
Normal file
56
backend/src/controllers/unifi.controller.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const unifi = require('../services/unifiService');
|
||||
|
||||
const getConfig = (req, res) => {
|
||||
try {
|
||||
const cfg = unifi.getConfig();
|
||||
// Don't send password in plaintext
|
||||
res.json({ status: 'success', data: { ...cfg, password: cfg?.password ? '***' : '' } });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfig = async (req, res) => {
|
||||
try {
|
||||
const { controller_url, username, password, site, poll_interval_min, enabled } = req.body;
|
||||
// If password is '***', keep existing
|
||||
const existing = unifi.getConfig();
|
||||
const pwd = (password && password !== '***') ? password : (existing?.password || '');
|
||||
const cfg = unifi.saveConfig({
|
||||
controller_url: controller_url?.trim() || '',
|
||||
username: username?.trim() || '',
|
||||
password: pwd,
|
||||
site: site?.trim() || 'default',
|
||||
poll_interval_min: parseInt(poll_interval_min) || 5,
|
||||
enabled: enabled ? 1 : 0,
|
||||
});
|
||||
unifi.restartUnifiPoller();
|
||||
res.json({ status: 'success', data: { ...cfg, password: cfg.password ? '***' : '' } });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const getDevices = (req, res) => {
|
||||
try {
|
||||
const devices = unifi.getAllDevices().map(d => ({
|
||||
...d,
|
||||
ports: JSON.parse(d.ports_json || '[]'),
|
||||
radios: JSON.parse(d.radio_json || '[]'),
|
||||
}));
|
||||
res.json({ status: 'success', data: devices });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const sync = async (req, res) => {
|
||||
try {
|
||||
const result = await unifi.syncUnifi();
|
||||
res.json({ status: 'success', data: result });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getConfig, saveConfig, getDevices, sync };
|
||||
420
backend/src/controllers/user.controller.js
Normal file
420
backend/src/controllers/user.controller.js
Normal file
@@ -0,0 +1,420 @@
|
||||
const UserService = require('../services/user.service');
|
||||
const Role = require('../models/Role');
|
||||
const AuditLog = require('../models/AuditLog');
|
||||
const User = require('../models/User');
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
const { getAzureGroups, getAzureGroupMembers, invalidateUserSessions } = require('../services/graph.service');
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const DB_PATH = process.env.DATABASE_PATH || './database.sqlite';
|
||||
|
||||
class UserController {
|
||||
/**
|
||||
* Get all users
|
||||
* GET /api/users
|
||||
*/
|
||||
static getAllUsers = asyncHandler(async (req, res) => {
|
||||
const users = UserService.getAllUsers();
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: users
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
* GET /api/users/:id
|
||||
*/
|
||||
static getUserById = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const user = UserService.getUserById(parseInt(id));
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: user
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Create new user
|
||||
* POST /api/users
|
||||
*/
|
||||
static createUser = asyncHandler(async (req, res) => {
|
||||
const user = await UserService.createUser(
|
||||
req.body,
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
status: 'success',
|
||||
data: user
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Update user
|
||||
* PUT /api/users/:id
|
||||
*/
|
||||
static updateUser = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const user = await UserService.updateUser(
|
||||
parseInt(id),
|
||||
req.body,
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: user
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete user
|
||||
* DELETE /api/users/:id
|
||||
*/
|
||||
static deleteUser = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = UserService.deleteUser(
|
||||
parseInt(id),
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: 'User deleted successfully'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Assign role to user
|
||||
* PUT /api/users/:id/role
|
||||
*/
|
||||
static assignRole = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { role_id } = req.body;
|
||||
|
||||
if (!role_id) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'role_id is required'
|
||||
});
|
||||
}
|
||||
|
||||
const user = UserService.assignRole(
|
||||
parseInt(id),
|
||||
role_id,
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: user
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Activate/Deactivate user
|
||||
* PUT /api/users/:id/activate
|
||||
*/
|
||||
static toggleUserStatus = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { is_active } = req.body;
|
||||
|
||||
if (is_active === undefined) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'is_active is required'
|
||||
});
|
||||
}
|
||||
|
||||
const user = UserService.toggleUserStatus(
|
||||
parseInt(id),
|
||||
is_active,
|
||||
req.user.id,
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: user
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all roles
|
||||
* GET /api/roles
|
||||
*/
|
||||
static getAllRoles = asyncHandler(async (req, res) => {
|
||||
const roles = Role.getAll();
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: roles
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get audit logs
|
||||
* GET /api/audit-logs
|
||||
*/
|
||||
static getAuditLogs = asyncHandler(async (req, res) => {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
|
||||
const logs = AuditLog.getAll(limit, offset);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: logs
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get audit logs for specific user
|
||||
* GET /api/audit-logs/user/:userId
|
||||
*/
|
||||
static getAuditLogsByUser = asyncHandler(async (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
|
||||
const logs = AuditLog.getByUserId(parseInt(userId), limit, offset);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: logs
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get audit logs for specific entity
|
||||
* GET /api/audit-logs/entity/:type/:id
|
||||
*/
|
||||
static getAuditLogsByEntity = asyncHandler(async (req, res) => {
|
||||
const { type, id } = req.params;
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
|
||||
const logs = AuditLog.getByEntity(type, parseInt(id), limit, offset);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: logs
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* List available Azure AD groups for import selection
|
||||
* GET /api/users/import/azure/groups
|
||||
*/
|
||||
static getAzureGroupsList = asyncHandler(async (req, res) => {
|
||||
let groups;
|
||||
try {
|
||||
groups = await getAzureGroups();
|
||||
} catch (err) {
|
||||
return res.status(502).json({ status: 'error', message: err.message });
|
||||
}
|
||||
res.json({ status: 'success', data: groups });
|
||||
});
|
||||
|
||||
/**
|
||||
* Import users from an Azure AD group
|
||||
* POST /api/users/import/azure
|
||||
* Body: { group_id, role_id }
|
||||
*/
|
||||
static importFromAzure = asyncHandler(async (req, res) => {
|
||||
const { group_id, role_id } = req.body;
|
||||
|
||||
if (!group_id) {
|
||||
return res.status(400).json({ status: 'error', message: 'group_id ist erforderlich' });
|
||||
}
|
||||
|
||||
if (!role_id) {
|
||||
return res.status(400).json({ status: 'error', message: 'role_id ist erforderlich' });
|
||||
}
|
||||
|
||||
let members;
|
||||
try {
|
||||
members = await getAzureGroupMembers(group_id);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ status: 'error', message: err.message });
|
||||
}
|
||||
|
||||
const results = { imported: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (const member of members) {
|
||||
try {
|
||||
const email = (member.mail || member.userPrincipalName || '').toLowerCase().trim();
|
||||
if (!email) { results.skipped++; continue; }
|
||||
|
||||
// Skip if already exists (by azure_id or email)
|
||||
const byAzureId = member.id ? User.getByAzureId(member.id) : null;
|
||||
if (byAzureId) { results.skipped++; continue; }
|
||||
|
||||
const byEmail = User.getByEmail(email);
|
||||
if (byEmail) {
|
||||
// Link azure_id if not yet set
|
||||
if (!byEmail.azure_id && member.id) {
|
||||
User.update(byEmail.id, { azure_id: member.id });
|
||||
}
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Derive username from email prefix, ensure uniqueness by appending number if needed
|
||||
let baseUsername = email.split('@')[0].replace(/[^a-z0-9._-]/gi, '').toLowerCase();
|
||||
let username = baseUsername;
|
||||
let suffix = 1;
|
||||
while (User.getByUsername(username)) {
|
||||
username = `${baseUsername}${suffix++}`;
|
||||
}
|
||||
|
||||
const newUser = User.create({
|
||||
username,
|
||||
email,
|
||||
password_hash: 'AZURE_SSO_NO_PASSWORD',
|
||||
role_id: parseInt(role_id),
|
||||
first_name: member.givenName || null,
|
||||
last_name: member.surname || null,
|
||||
is_active: true,
|
||||
must_change_password: false,
|
||||
});
|
||||
if (member.id) {
|
||||
User.update(newUser.id, { azure_id: member.id });
|
||||
}
|
||||
|
||||
results.imported++;
|
||||
} catch (err) {
|
||||
results.errors.push({ user: member.mail || member.userPrincipalName, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ status: 'success', data: results });
|
||||
});
|
||||
|
||||
/**
|
||||
* Get full user profile: user + asset count + fido count + lifecycle status
|
||||
* GET /api/users/:id/full
|
||||
*/
|
||||
static getUserFull = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT u.*, r.name as role_name,
|
||||
m.username as manager_username,
|
||||
m.first_name as manager_first_name,
|
||||
m.last_name as manager_last_name
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
LEFT JOIN users m ON u.manager_id = m.id
|
||||
WHERE u.id = ?
|
||||
`).get(parseInt(id));
|
||||
|
||||
if (!user) return res.status(404).json({ status: 'error', message: 'User not found' });
|
||||
|
||||
const assetCount = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM assets
|
||||
WHERE assigned_to_user_id = ? OR assigned_to_username = ?
|
||||
`).get(parseInt(id), user.username);
|
||||
|
||||
const assetStats = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COALESCE(SUM(purchase_price), 0) as total_value,
|
||||
MIN(purchase_date) as oldest_date
|
||||
FROM assets
|
||||
WHERE assigned_to_user_id = ? OR assigned_to_username = ?
|
||||
`).get(parseInt(id), user.username);
|
||||
|
||||
const fidoCount = db.prepare(
|
||||
`SELECT COUNT(*) as cnt FROM fido_keys WHERE assigned_to_user_id = ? AND status = 'aktiv'`
|
||||
).get(parseInt(id));
|
||||
|
||||
const onboarding = db.prepare(
|
||||
`SELECT * FROM onboarding_protocols WHERE employee_user_id = ? ORDER BY created_at DESC LIMIT 1`
|
||||
).get(parseInt(id));
|
||||
|
||||
const offboarding = db.prepare(
|
||||
`SELECT * FROM offboarding_protocols WHERE employee_user_id = ? ORDER BY created_at DESC LIMIT 1`
|
||||
).get(parseInt(id));
|
||||
|
||||
let lifecycleStage = 3; // Aktiv (default for existing staff)
|
||||
if (offboarding) lifecycleStage = 5;
|
||||
else if (onboarding?.status === 'in_progress') lifecycleStage = 2;
|
||||
else if (onboarding?.status === 'pending') lifecycleStage = 1;
|
||||
|
||||
delete user.password_hash;
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
...user,
|
||||
asset_count: assetCount?.cnt || 0,
|
||||
asset_total_value: assetStats?.total_value || 0,
|
||||
asset_oldest_date: assetStats?.oldest_date || null,
|
||||
fido_count: fidoCount?.cnt || 0,
|
||||
lifecycle_stage: lifecycleStage,
|
||||
onboarding_protocol: onboarding || null,
|
||||
offboarding_protocol: offboarding || null,
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Invalidate Entra sessions (password reset via Entra SSPR)
|
||||
* POST /api/users/:id/reset-entra
|
||||
*/
|
||||
static resetPasswordEntra = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const user = UserService.getUserById(parseInt(id));
|
||||
|
||||
if (!user?.azure_id) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Kein Azure-Konto verknüpft. Passwort-Reset nur für Entra-Benutzer möglich.'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await invalidateUserSessions(user.azure_id);
|
||||
res.json({ status: 'success', message: 'Entra-Sessions invalidiert. Benutzer wird beim nächsten Login zu Passwort-Reset aufgefordert.' });
|
||||
} catch (err) {
|
||||
res.status(502).json({ status: 'error', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Export all users as CSV
|
||||
* GET /api/users/export/csv
|
||||
*/
|
||||
static exportCsv = asyncHandler(async (req, res) => {
|
||||
const users = UserService.getAllUsers();
|
||||
const header = 'ID,Username,Email,Vorname,Nachname,Rolle,Abteilung,Position,Telefon,Standort,Eintrittsdatum,Aktiv';
|
||||
const rows = users.map(u => [
|
||||
u.id, u.username, u.email, u.first_name || '', u.last_name || '',
|
||||
u.role_name || '', u.department || '', u.position || '',
|
||||
u.phone || '', u.location || '', u.joined_date || '', u.is_active ? 'Ja' : 'Nein'
|
||||
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(','));
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="benutzer.csv"');
|
||||
res.send('' + header + '\n' + rows.join('\n'));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = UserController;
|
||||
182
backend/src/controllers/warehouse.controller.js
Normal file
182
backend/src/controllers/warehouse.controller.js
Normal file
@@ -0,0 +1,182 @@
|
||||
const WarehouseLocation = require('../models/WarehouseLocation');
|
||||
const AssetMovement = require('../models/AssetMovement');
|
||||
const StockThreshold = require('../models/StockThreshold');
|
||||
const PurchaseOrder = require('../models/PurchaseOrder');
|
||||
const Asset = require('../models/Asset');
|
||||
const { getDatabase } = require('../config/database');
|
||||
|
||||
// ─── Locations ────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getLocations = (req, res) => {
|
||||
res.json(WarehouseLocation.getAll());
|
||||
};
|
||||
|
||||
exports.createLocation = (req, res) => {
|
||||
const { name, description } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Name ist erforderlich' });
|
||||
try {
|
||||
const loc = WarehouseLocation.create({ name, description });
|
||||
res.status(201).json(loc);
|
||||
} catch (e) {
|
||||
if (e.message?.includes('UNIQUE')) return res.status(409).json({ error: 'Lagerort existiert bereits' });
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateLocation = (req, res) => {
|
||||
const loc = WarehouseLocation.update(parseInt(req.params.id), req.body);
|
||||
if (!loc) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(loc);
|
||||
};
|
||||
|
||||
exports.deleteLocation = (req, res) => {
|
||||
const ok = WarehouseLocation.delete(parseInt(req.params.id));
|
||||
if (!ok) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
// ─── Movements ────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getMovements = (req, res) => {
|
||||
const asset_id = req.query.asset_id ? parseInt(req.query.asset_id) : undefined;
|
||||
res.json(AssetMovement.getAll({ asset_id, limit: 500 }));
|
||||
};
|
||||
|
||||
exports.createMovement = (req, res) => {
|
||||
const { asset_id, type, to_location_id, assigned_user_id, ticket_id, reason, notes, new_status } = req.body;
|
||||
if (!asset_id || !type) return res.status(400).json({ error: 'asset_id und type sind erforderlich' });
|
||||
|
||||
const asset = Asset.getById(parseInt(asset_id));
|
||||
if (!asset) return res.status(404).json({ error: 'Asset nicht gefunden' });
|
||||
|
||||
const db = getDatabase();
|
||||
const updateAsset = db.transaction(() => {
|
||||
// Create movement record
|
||||
const movement = AssetMovement.create({
|
||||
asset_id,
|
||||
type,
|
||||
from_location_id: asset.location_id,
|
||||
to_location_id: to_location_id || null,
|
||||
assigned_user_id: assigned_user_id || null,
|
||||
ticket_id: ticket_id || null,
|
||||
reason: reason || '',
|
||||
notes: notes || '',
|
||||
performed_by: req.user.id,
|
||||
});
|
||||
|
||||
// Update asset location + status if needed
|
||||
const updates = {};
|
||||
if (to_location_id) updates.location_id = to_location_id;
|
||||
if (new_status) updates.status = new_status;
|
||||
if (assigned_user_id) {
|
||||
updates.assigned_to_user_id = assigned_user_id;
|
||||
updates.status = 'zugewiesen';
|
||||
}
|
||||
if (type === 'in') {
|
||||
updates.status = 'verfuegbar';
|
||||
updates.assigned_to_user_id = null;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
Asset.update(asset_id, updates, req.user.id);
|
||||
}
|
||||
|
||||
return movement;
|
||||
});
|
||||
|
||||
try {
|
||||
const movement = updateAsset();
|
||||
res.status(201).json(movement);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Stock Thresholds ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.getThresholds = (req, res) => {
|
||||
res.json(StockThreshold.getAll());
|
||||
};
|
||||
|
||||
exports.upsertThreshold = (req, res) => {
|
||||
const { category, min_stock, notify_email } = req.body;
|
||||
if (!category) return res.status(400).json({ error: 'Kategorie ist erforderlich' });
|
||||
const threshold = StockThreshold.upsert({ category, min_stock, notify_email });
|
||||
res.json(threshold);
|
||||
};
|
||||
|
||||
exports.deleteThreshold = (req, res) => {
|
||||
const ok = StockThreshold.delete(req.params.category);
|
||||
if (!ok) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
exports.getStockViolations = (req, res) => {
|
||||
res.json(StockThreshold.getViolations());
|
||||
};
|
||||
|
||||
// ─── Purchase Orders ──────────────────────────────────────────────────────────
|
||||
|
||||
exports.getPurchaseOrders = (req, res) => {
|
||||
const { status } = req.query;
|
||||
res.json(PurchaseOrder.getAll({ status }));
|
||||
};
|
||||
|
||||
exports.createPurchaseOrder = (req, res) => {
|
||||
const { category, item_name, quantity, notes } = req.body;
|
||||
if (!category || !item_name) return res.status(400).json({ error: 'Kategorie und Artikelname sind erforderlich' });
|
||||
const order = PurchaseOrder.create({ category, item_name, quantity, notes, created_by: req.user.id });
|
||||
res.status(201).json(order);
|
||||
};
|
||||
|
||||
exports.updatePurchaseOrder = (req, res) => {
|
||||
const order = PurchaseOrder.update(parseInt(req.params.id), req.body);
|
||||
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(order);
|
||||
};
|
||||
|
||||
exports.deletePurchaseOrder = (req, res) => {
|
||||
const ok = PurchaseOrder.delete(parseInt(req.params.id));
|
||||
if (!ok) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
// ─── Dashboard Summary ────────────────────────────────────────────────────────
|
||||
|
||||
exports.getSummary = (req, res) => {
|
||||
const db = getDatabase();
|
||||
|
||||
const stockByLocation = db.prepare(`
|
||||
SELECT wl.name AS location, COUNT(a.id) AS count
|
||||
FROM warehouse_locations wl
|
||||
LEFT JOIN assets a ON a.location_id = wl.id AND a.status = 'verfuegbar'
|
||||
GROUP BY wl.id ORDER BY wl.name
|
||||
`).all();
|
||||
|
||||
const stockByCategory = db.prepare(`
|
||||
SELECT a.type AS category, a.status, COUNT(*) AS count
|
||||
FROM assets a
|
||||
GROUP BY a.type, a.status
|
||||
ORDER BY a.type, a.status
|
||||
`).all();
|
||||
|
||||
const violations = StockThreshold.getViolations();
|
||||
const openOrders = PurchaseOrder.getOpenCount();
|
||||
const recentMovements = AssetMovement.getAll({ limit: 10 });
|
||||
|
||||
res.json({ stockByLocation, stockByCategory, violations, openOrders, recentMovements });
|
||||
};
|
||||
|
||||
// ─── QR Code ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getQrCode = async (req, res) => {
|
||||
const asset = Asset.getById(parseInt(req.params.id));
|
||||
if (!asset) return res.status(404).json({ error: 'Asset nicht gefunden' });
|
||||
|
||||
const QRCode = require('qrcode');
|
||||
const url = `${process.env.FRONTEND_URL || 'https://it-nexus.cereda-systems.de'}/assets?scan=${asset.id}`;
|
||||
const svg = await QRCode.toString(url, { type: 'svg', width: 200, margin: 1 });
|
||||
|
||||
res.setHeader('Content-Type', 'image/svg+xml');
|
||||
res.send(svg);
|
||||
};
|
||||
Reference in New Issue
Block a user