import crypto from 'node:crypto'; import { pool } from '../db.js'; /* * Einstellungen liegen als JSON je Schluessel in pulse_settings. Fehlt ein * Schluessel, gilt der Standard von hier - so muss beim Hinzufuegen neuer * Einstellungen nichts migriert werden. */ export const DEFAULTS = { weekly_hours: 40, hours_per_day: 8, // Platzhalter: {KUERZEL} Kundenkuerzel, {JAHR} vierstellig, {NR} laufende // Nummer je Kunde und Jahr (dreistellig). project_code_pattern: '{KUERZEL}-{JAHR}-{NR}', // Anzeigename der Projekte: {KUNDE} Kundenkuerzel oder -name, {PROJEKT} Name, {CODE}. project_name_pattern: '{KUNDE} - {PROJEKT}', budget_warn_percent: 80, budget_warn_days: 14, company_name: 'perinco GmbH', export: { title: 'Stundennachweis', subtitle: 'Performance Intelligence Consulting', accent: '#6BA04A', logo: null, // Data-URL; null = perinco Logo header_right: 'www.perinco.de', footer_left: 'perinco GmbH', footer_center: '', footer_right: 'www.perinco.de', show_signature: true, signature_left: 'Datum, Unterschrift Berater', signature_right: 'Datum, Unterschrift Kunde', }, /* * Abwesenheiten aus HRworks. Urlaub und Krankheit werden dort gefuehrt; Pulse * holt sie taeglich und buchtsie auf ein nicht abrechenbares Projekt. */ hrworks: { enabled: false, access_key: '', secret_access_key: '', // Projekt, auf das gebucht wird (Projektcode, nicht die id - der Code // bleibt beim Umziehen der Datenbank gleich). project_code: 'PERINCO-2024-001', // Wie viele Tage rueckwaerts jeder Lauf prueft. window_days: 60, /* * Abwesenheitsart -> Taskname in Pulse. Schluessel ist "absence:" bzw. * "sick:" aus HRworks. Fehlt ein Eintrag, gilt der HRworks-Name des * Typs - dann muss in Pulse ein Task mit genau diesem Namen stehen. */ type_map: { 'absence:1': 'Urlaub', 'absence:3': 'Elternzeit', 'absence:5': 'Mutterschutz', 'absence:6': 'Geburt', 'absence:7': 'Hochzeit', 'absence:8': 'Sabbatical', 'absence:9': 'Freistellung', 'absence:10': 'Bildungsurlaub', 'sick:KMA': 'Krankheit', 'sick:KOA': 'Krankheit', 'sick:K28': 'Krankheit', 'sick:KOL': 'Krankheit', 'sick:KWW': 'Krankheit', 'sick:KEK': 'Kind krank', }, /* * Welche Abwesenheitsarten im Kalender stehen, den alle sehen. Nur * Urlaub - Elternzeit, Mutterschutz & Co. sagen mehr ueber eine Person, * als die Kollegen wissen muessen. Krankheit ist nie waehlbar. */ calendar_types: ['absence:1', 'absence:10'], // So weit reicht der Kalender in die Zukunft. calendar_days_ahead: 400, // Task fuer Feiertage; leer = Feiertage nicht buchen. holiday_task: 'Feiertag', // Halber Tag: feste Stundenzahl (0 = aus HRworks uebernehmen). half_day_hours: 4, last_run: null, last_result: null, }, // Letzter Kalenderabgleich - eigener Schluessel, damit sich die beiden // Laeufe ihre Ergebnisse nicht gegenseitig ueberschreiben. calendar_status: { last_run: null, vacation_days: 0, holidays: 0 }, /* * KI-Assistent im Kopf der Anwendung. Der Schluessel liegt verschluesselt, * genau wie das SSO-Secret; das Modell ist einstellbar, weil OpenAI die * Namen regelmaessig wechselt. */ ai: { enabled: false, api_key: '', model: 'gpt-5.6-luna', }, sso: { enabled: false, button_label: 'Mit Microsoft anmelden', tenant_id: '', client_id: '', client_secret: '', redirect_uri: 'https://pulse.perinco.de/api/auth/sso/callback', // Uebernommen wird nur von Hand in der SSO-Verwaltung; Rollen vergibt Pulse. local_login: true, }, }; export async function getSettings() { const [rows] = await pool.query('SELECT k, v FROM pulse_settings'); const stored = {}; for (const row of rows) { try { stored[row.k] = JSON.parse(row.v); } catch { /* kaputter Wert - Standard gilt */ } } const result = {}; for (const [key, def] of Object.entries(DEFAULTS)) { const value = stored[key]; if (def && typeof def === 'object' && !Array.isArray(def)) { result[key] = { ...def, ...(value && typeof value === 'object' ? value : {}) }; } else { result[key] = value ?? def; } } if (result.sso.client_secret) result.sso.client_secret = decrypt(result.sso.client_secret); if (result.hrworks.secret_access_key) result.hrworks.secret_access_key = decrypt(result.hrworks.secret_access_key); if (result.ai.api_key) result.ai.api_key = decrypt(result.ai.api_key); return result; } export async function saveSetting(key, value) { if (!(key in DEFAULTS)) throw new Error(`Unbekannte Einstellung: ${key}`); let stored = value; if (key === 'sso' && value.client_secret) stored = { ...value, client_secret: encrypt(value.client_secret) }; if (key === 'hrworks' && value.secret_access_key) stored = { ...value, secret_access_key: encrypt(value.secret_access_key) }; if (key === 'ai' && value.api_key) stored = { ...value, api_key: encrypt(value.api_key) }; await pool.query( 'INSERT INTO pulse_settings (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)', [key, JSON.stringify(stored)] ); } /* Client Secret verschluesselt ablegen (AES-256-GCM, Schluessel aus SETTINGS_KEY). */ function key() { const base = process.env.SETTINGS_KEY || process.env.JWT_SECRET || ''; return crypto.createHash('sha256').update(base).digest(); } function encrypt(plain) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv); const data = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]); return `enc:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${data.toString('base64')}`; } function decrypt(value) { if (typeof value !== 'string' || !value.startsWith('enc:')) return value; try { const [, iv, tag, data] = value.split(':'); const decipher = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(iv, 'base64')); decipher.setAuthTag(Buffer.from(tag, 'base64')); return Buffer.concat([decipher.update(Buffer.from(data, 'base64')), decipher.final()]).toString('utf8'); } catch { return ''; } }