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>
168 lines
6.1 KiB
JavaScript
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 '';
|
|
}
|
|
}
|