Files
perinco-Pulse/server/src/lib/settings.js
T
schakal00783andClaude Opus 5.5 c0eb00faee feat(kalender): Kalender mit Urlauben und Feiertagen fuer alle
Neuer Menuepunkt "Kalender" fuer alle Rollen: genehmigte Urlaube aller
Mitarbeiter, auch in der Zukunft, dazu Feiertage. Monatsansicht (Standard)
und Zeitleiste, filterbar nach Team und Name.

- Eigene Tabelle pulse_calendar_days, gefuellt von lib/calendarSync.js im
  HRworks-Lauf (Fenster bis 400 Tage voraus). Buchungen entstehen weiter
  erst am jeweiligen Tag.
- Nur validatedOk und nur freigegebene Arten (calendar_types, Haken in den
  HRworks-Einstellungen). Krankmeldungen werden dafuer nicht abgefragt.
- GET /api/calendar fuer alle Angemeldeten, ohne Mails und Profilbilder.
- Timer laeuft zusaetzlich um 12:00 Uhr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:37:26 +02:00

168 lines
6.1 KiB
JavaScript

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:<key>" bzw.
* "sick:<key>" 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 '';
}
}