Files
perinco-Pulse/server/src/routes/settings.js
T
schakal00783andClaude Opus 5 e908db2788 feat(assistant): Fragen an die Daten im Kopf der Anwendung
Management und Administration koennen oben rechts eine Frage in Worten
stellen - "wie viele Stunden im August", "welches Projekt hat noch
Budget", "wie ist die Auslastung".

Aufbau: das Modell bekommt eine Uebersicht (Kunden, laufende Projekte,
Eckdaten) und vier Werkzeuge - auswertung, projekte, buchungen,
personen. Damit fragt es gezielt nach, statt dass ihm der ganze Bestand
vorgelegt wird; 19.000 Buchungen passen in keinen Kontext. Die Werkzeuge
rechnen mit loadEntries und loadProjects, also mit denselben Funktionen
wie die Reports - der Assistent kann so keine zweite Wahrheit erzeugen.
Was einfliesst, begrenzt visibleUserIds wie ueberall sonst.

Benutzt wird die Responses-API von OpenAI: die neueren Modelle nehmen
Werkzeuge in /v1/chat/completions nur ohne Nachdenken an. Mit
"store: false" bleibt nichts bei OpenAI liegen, der Verlauf geht deshalb
je Runde komplett mit. Der Schluessel liegt verschluesselt in den
Einstellungen (AES-256-GCM wie das SSO-Secret) und wird nie ausgeliefert;
das Modell ist einstellbar, Vorgabe gpt-5.6-luna.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:31:51 +02:00

254 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import express from 'express';
import { requireRole, ROLES } from '../middleware/auth.js';
import { DEFAULTS, getSettings, saveSetting } from '../lib/settings.js';
import { ssoConfigured, testConnection } from '../lib/sso.js';
import { answerText, askModel } from '../lib/ai.js';
import { sendDocument } from '../export/render.js';
const router = express.Router();
// Fuer alle Angemeldeten: was die Oberflaeche zum Rechnen braucht.
router.get('/public', async (req, res, next) => {
try {
const s = await getSettings();
res.json({
weekly_hours: Number(s.weekly_hours),
hours_per_day: Number(s.hours_per_day),
budget_warn_percent: Number(s.budget_warn_percent),
budget_warn_days: Number(s.budget_warn_days),
company_name: s.company_name,
project_code_pattern: s.project_code_pattern,
project_name_pattern: s.project_name_pattern,
});
} catch (err) {
next(err);
}
});
router.get('/', requireRole('admin'), async (req, res, next) => {
try {
const s = await getSettings();
// Geheimnisse werden nie ausgeliefert, nur die Information, dass sie stehen.
res.json({
...s,
sso: { ...s.sso, client_secret: '', client_secret_set: Boolean(s.sso.client_secret), configured: ssoConfigured(s.sso) },
hrworks: {
...s.hrworks,
secret_access_key: '',
secret_set: Boolean(s.hrworks.secret_access_key),
configured: Boolean(s.hrworks.access_key && s.hrworks.secret_access_key),
},
ai: { ...s.ai, api_key: '', key_set: Boolean(s.ai.api_key), configured: Boolean(s.ai.api_key) },
});
} catch (err) {
next(err);
}
});
const text = (v, max) => String(v ?? '').slice(0, max);
router.put('/', requireRole('admin'), async (req, res, next) => {
try {
const body = req.body || {};
const current = await getSettings();
const errors = [];
const numbers = {
weekly_hours: [1, 80],
hours_per_day: [1, 24],
budget_warn_percent: [1, 100],
budget_warn_days: [0, 365],
};
for (const [key, [min, max]] of Object.entries(numbers)) {
if (body[key] === undefined) continue;
const value = Number(body[key]);
if (!(value >= min && value <= max)) errors.push(`${key}: Wert zwischen ${min} und ${max}.`);
else await saveSetting(key, value);
}
if (body.company_name !== undefined) await saveSetting('company_name', text(body.company_name, 160));
if (body.project_code_pattern !== undefined) {
const pattern = text(body.project_code_pattern, 60).trim();
if (!pattern.includes('{NR}')) errors.push('Das Codemuster braucht {NR}, sonst sind Codes nicht eindeutig.');
else await saveSetting('project_code_pattern', pattern);
}
if (body.project_name_pattern !== undefined) {
const pattern = text(body.project_name_pattern, 60).trim();
if (!pattern.includes('{PROJEKT}')) errors.push('Das Namensmuster braucht {PROJEKT}.');
else await saveSetting('project_name_pattern', pattern);
}
if (body.export) {
const next_ = { ...current.export };
for (const key of Object.keys(DEFAULTS.export)) {
if (body.export[key] === undefined) continue;
if (key === 'show_signature') next_[key] = Boolean(body.export[key]);
else if (key === 'logo') {
const logo = body.export.logo;
if (logo === null || logo === '') next_.logo = null;
else if (/^data:image\/(png|jpe?g);base64,/.test(logo) && logo.length < 2_500_000) next_.logo = logo;
else errors.push('Logo: PNG oder JPG bis ca. 1,8 MB.');
} else if (key === 'accent') {
if (/^#[0-9a-f]{6}$/i.test(body.export.accent)) next_.accent = body.export.accent;
else errors.push('Akzentfarbe ist ungültig.');
} else next_[key] = text(body.export[key], 300);
}
await saveSetting('export', next_);
}
if (body.hrworks) {
const next_ = { ...current.hrworks };
for (const key of ['access_key', 'project_code', 'holiday_task']) {
if (body.hrworks[key] !== undefined) next_[key] = text(body.hrworks[key], 200).trim();
}
if (body.hrworks.enabled !== undefined) next_.enabled = Boolean(body.hrworks.enabled);
if (body.hrworks.window_days !== undefined) {
const days = Number(body.hrworks.window_days);
if (days >= 1 && days <= 365) next_.window_days = Math.round(days);
else errors.push('HRworks: Fenster zwischen 1 und 365 Tagen.');
}
if (body.hrworks.half_day_hours !== undefined) {
const hours = Number(body.hrworks.half_day_hours);
if (hours >= 0 && hours <= 12) next_.half_day_hours = hours;
else errors.push('HRworks: Stunden für einen halben Tag zwischen 0 und 12.');
}
if (body.hrworks.type_map && typeof body.hrworks.type_map === 'object') {
const map = {};
for (const [key, value] of Object.entries(body.hrworks.type_map)) {
if (/^(absence|sick):[A-Za-z0-9_-]{1,20}$/.test(key)) map[key] = text(value, 160).trim();
}
next_.type_map = map;
}
// Leer = unveraendert; der geheime Teil wird nie ausgeliefert.
if (body.hrworks.secret_access_key) next_.secret_access_key = text(body.hrworks.secret_access_key, 500).trim();
if (body.hrworks.clear_secret) next_.secret_access_key = '';
if (next_.enabled && !(next_.access_key && next_.secret_access_key)) {
errors.push('HRworks kann erst aktiviert werden, wenn Access Key und Secret gesetzt sind.');
}
if (!errors.length) await saveSetting('hrworks', next_);
}
if (body.ai) {
const next_ = { ...current.ai };
if (body.ai.model !== undefined) next_.model = text(body.ai.model, 80).trim() || DEFAULTS.ai.model;
if (body.ai.enabled !== undefined) next_.enabled = Boolean(body.ai.enabled);
// Leer = unveraendert; der Schluessel wird nie ausgeliefert.
if (body.ai.api_key) next_.api_key = text(body.ai.api_key, 300).trim();
if (body.ai.clear_key) next_.api_key = '';
if (next_.enabled && !next_.api_key) errors.push('Der KI-Assistent braucht einen OpenAI-Schlüssel.');
if (!errors.length) await saveSetting('ai', next_);
}
if (body.sso) {
const next_ = { ...current.sso };
for (const key of ['tenant_id', 'client_id', 'redirect_uri', 'button_label']) {
if (body.sso[key] !== undefined) next_[key] = text(body.sso[key], 300).trim();
}
for (const key of ['enabled', 'local_login']) {
if (body.sso[key] !== undefined) next_[key] = Boolean(body.sso[key]);
}
// Leer = unveraendert lassen; das Secret wird nie ausgeliefert.
if (body.sso.client_secret) next_.client_secret = text(body.sso.client_secret, 500).trim();
if (body.sso.clear_secret) next_.client_secret = '';
if (next_.enabled && !ssoConfigured(next_)) errors.push('SSO kann erst aktiviert werden, wenn Tenant, Client-ID, Secret und Redirect-URI gesetzt sind.');
if (next_.enabled && !ssoConfigured(next_)) next_.enabled = false;
if (!next_.enabled) next_.local_login = true; // ohne SSO braucht es die normale Anmeldung
await saveSetting('sso', next_);
}
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
res.json({ ok: true });
} catch (err) {
next(err);
}
});
/** Schluessel und Modell einmal ausprobieren. */
router.post('/ai/test', requireRole('admin'), async (req, res) => {
try {
const { ai } = await getSettings();
const response = await askModel(ai, {
instructions: 'Antworte mit genau einem Wort: OK.',
input: [{ role: 'user', content: 'Test' }],
maxOutputTokens: 200,
effort: 'low',
});
res.json({ ok: true, model: response.model, answer: answerText(response).slice(0, 200) });
} catch (err) {
res.status(err.status || 502).json({ error: err.message });
}
});
router.post('/sso/test', requireRole('admin'), async (req, res) => {
try {
const { sso } = await getSettings();
if (!ssoConfigured(sso)) return res.status(400).json({ error: 'Tenant, Client-ID und Secret fehlen.' });
const result = await testConnection(sso);
res.json({ ok: true, ...result });
} catch (err) {
res.status(502).json({ error: `Verbindung fehlgeschlagen: ${err.message}` });
}
});
/** Beispiel-Stundennachweis mit Platzhalterdaten - zeigt Kopf und Fuss. */
router.get('/export-preview', requireRole('admin'), async (req, res, next) => {
try {
const settings = await getSettings();
const format = ['pdf', 'xlsx', 'csv'].includes(req.query.format) ? req.query.format : 'pdf';
const rows = [
['2026-09-01', 'Di', 'Projektleitung', 'Jour fixe und Statusbericht', 150],
['2026-09-02', 'Mi', 'Konzeption', 'Workshop Anforderungen', 420],
['2026-09-03', 'Do', 'Konzeption', 'Dokumentation Prozessmodell', 390],
['2026-09-04', 'Fr', 'Projektleitung', '', 240],
].map(([entry_date, weekday, task_name, note, minutes]) => ({
entry_date,
weekday,
project_display: 'Musterkunde - Beispielprojekt',
task_name,
note,
minutes,
duration: `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`,
}));
const total = rows.reduce((s, r) => s + r.minutes, 0);
await sendDocument(
res,
{
title: settings.export.title || 'Stundennachweis',
subtitle: 'Vorschau',
filename: 'Vorschau Stundennachweis',
info: [
['Berater', 'Max Mustermann'],
['Kunde', 'Musterkunde GmbH'],
['Zeitraum', '01.09.2026 – 30.09.2026'],
['Projekt', 'Musterkunde - Beispielprojekt'],
],
kpis: [
['Gesamtstunden', (total / 60).toLocaleString('de-DE', { minimumFractionDigits: 2 })],
['Tage mit Leistung', '4'],
],
tables: [
{
columns: [
{ key: 'entry_date', label: 'Datum', type: 'date', width: 58 },
{ key: 'weekday', label: 'Tag', width: 26 },
{ key: 'project_display', label: 'Projekt', width: 120 },
{ key: 'task_name', label: 'Task', width: 80 },
{ key: 'note', label: 'Tätigkeit', type: 'text' },
{ key: 'duration', label: 'Dauer', align: 'right', width: 38 },
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 46 },
],
rows,
totals: { weekday: 'Summe', minutes: total },
},
],
signature: true,
},
format,
settings
);
} catch (err) {
next(err);
}
});
export default router;