feat: perinco Pulse - Zeiterfassung fuer perinco

Erste Fassung, live unter https://pulse.perinco.de.

Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit
mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV.
Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM.

Fachlich:
- Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5
- Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder
  aufgebrauchtem Budget, Buchung nur auf den passenden Topf
- Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte
- Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend)
- SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-17 22:52:20 +02:00
co-authored by Claude Opus 5
commit ef676a95f4
104 changed files with 21030 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# perinco Pulse Backend
# Als .env kopieren und Werte setzen. .env niemals einchecken.
PORT=5010
# Nur lokal binden - von aussen ausschliesslich ueber den Apache-Reverse-Proxy.
HOST=127.0.0.1
DB_HOST=localhost
DB_PORT=3306
DB_USER=pulse
DB_PASSWORD=changeme
DB_NAME=perinco_pulse
# Signiert die Sitzungen. Langer Zufallswert:
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_SECRET=changeme-langer-zufallswert
JWT_EXPIRES_IN=12h
# Verschluesselt das SSO Client Secret in der Datenbank. Nie aendern, sonst ist
# das gespeicherte Secret nicht mehr lesbar (dann in den Einstellungen neu setzen).
SETTINGS_KEY=changeme-zweiter-zufallswert
# Wird beim ersten Start angelegt, falls noch kein Benutzer existiert.
ADMIN_USERNAME=admin
ADMIN_NAME=Administrator
ADMIN_EMAIL=admin@perinco.de
ADMIN_PASSWORD=changeme
# Nur fuer den lokalen Vite-Dev-Server noetig.
# CORS_ORIGINS=http://localhost:3110
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+2902
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "perinco-pulse-backend",
"version": "1.0.0",
"private": true,
"description": "REST-Backend fuer perinco Pulse (Zeiterfassung, MariaDB perinco_pulse)",
"main": "src/server.js",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "node --test test/sso-filter.test.mjs"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"exceljs": "^4.4.0",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.11.5",
"pdfkit": "^0.15.0"
}
}
+94
View File
@@ -0,0 +1,94 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import mysql from 'mysql2/promise';
import bcrypt from 'bcryptjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'pulse',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'perinco_pulse',
waitForConnections: true,
// Klein gehalten: dieselbe MariaDB bedient weitere Anwendungen.
connectionLimit: 8,
charset: 'utf8mb4',
dateStrings: true,
decimalNumbers: true,
});
/** Fuehrt schema.sql aus und legt bei leerer Benutzertabelle den Admin an. */
export async function initDatabase() {
const sql = await fs.readFile(path.join(__dirname, 'schema.sql'), 'utf8');
// Split an Semikolon am Zeilenende - so braucht es kein multipleStatements.
const statements = sql
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n')
.split(/;\s*$/m)
.map((s) => s.trim())
.filter(Boolean);
const conn = await pool.getConnection();
try {
for (const statement of statements) await conn.query(statement);
} finally {
conn.release();
}
await ensureAdminUser();
await ensureInternalCustomer();
}
async function ensureAdminUser() {
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users');
if (rows[0].n > 0) return;
const password = process.env.ADMIN_PASSWORD;
if (!password) throw new Error('ADMIN_PASSWORD fehlt - erster Administrator kann nicht angelegt werden.');
const hash = await bcrypt.hash(password, 12);
await pool.query(
`INSERT INTO pulse_users (username, email, name, password_hash, role)
VALUES (?, ?, ?, ?, 'admin')`,
[
process.env.ADMIN_USERNAME || 'admin',
(process.env.ADMIN_EMAIL || 'admin@perinco.de').toLowerCase(),
process.env.ADMIN_NAME || 'Administrator',
hash,
]
);
console.log('[Pulse] Erster Administrator angelegt.');
}
/*
* perinco selbst ist der Kunde fuer alle internen Taetigkeiten ("perinco
* intern"). Wird einmalig angelegt, damit direkt nach der Installation ein
* Projekt fuer nicht abrechenbare Zeiten angelegt werden kann.
*/
async function ensureInternalCustomer() {
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_customers');
if (rows[0].n > 0) return;
await pool.query(
`INSERT INTO pulse_customers (name, short_name, notes, color) VALUES (?, ?, ?, ?)`,
['perinco GmbH', 'PERINCO', 'Interne Tätigkeiten (nicht abrechenbar)', '#6ba04a']
);
}
/** Kleiner Helfer fuer Transaktionen. */
export async function withTransaction(fn) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const result = await fn(conn);
await conn.commit();
return result;
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
+450
View File
@@ -0,0 +1,450 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import PDFDocument from 'pdfkit';
import ExcelJS from 'exceljs';
/*
* Gemeinsamer Unterbau fuer alle Exporte. Ein Export wird als neutrales
* Dokument beschrieben (Titel, Infoblock, Kennzahlen, Tabellen) und von hier
* aus als PDF, Excel oder CSV geschrieben - so sehen Stundenzettel und Reports
* in allen drei Formaten gleich aufgebaut aus und tragen denselben Kopf und Fuss
* aus den Einstellungen.
*
* doc = {
* title, subtitle, filename,
* info: [[Label, Wert], ...],
* kpis: [[Label, Wert], ...],
* tables: [{ title?, columns: [{ key, label, width?, align?, type? }], rows: [...], totals?: {...} }],
* signature: boolean,
* landscape: boolean,
* }
* type: 'hours' (Minuten -> 1,50), 'duration' (Minuten -> 1:30), 'money', 'date', 'text', 'percent'
*/
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_LOGO = path.join(__dirname, '..', '..', 'assets', 'logo.png');
export function hoursDe(minutes) {
return (minutes / 60).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export function durationDe(minutes) {
const m = Math.round(minutes);
return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`;
}
export function moneyDe(value) {
return `${Number(value || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
}
export function dateDe(value) {
const [y, m, d] = String(value).slice(0, 10).split('-');
return `${d}.${m}.${y}`;
}
function formatCell(value, type) {
if (value == null || value === '') return '';
switch (type) {
case 'hours':
return hoursDe(value);
case 'duration':
return durationDe(value);
case 'money':
return moneyDe(value);
case 'date':
return dateDe(value);
case 'percent':
return `${value} %`;
default:
return String(value);
}
}
function logoBuffer(exportSettings) {
const logo = exportSettings.logo;
if (typeof logo === 'string' && logo.startsWith('data:image/')) {
const match = logo.match(/^data:image\/(png|jpe?g);base64,(.+)$/);
if (match) return Buffer.from(match[2], 'base64');
}
return fs.readFileSync(DEFAULT_LOGO);
}
/* ------------------------------------------------------------------ CSV -- */
export function renderCsv(doc) {
const escape = (v) => {
const s = String(v ?? '');
return /[;"\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
};
const lines = [];
doc.tables.forEach((table, index) => {
if (index > 0) lines.push('');
if (doc.tables.length > 1 && table.title) lines.push(escape(table.title));
lines.push(table.columns.map((c) => escape(c.label)).join(';'));
for (const row of table.rows) {
lines.push(table.columns.map((c) => escape(formatCell(row[c.key], c.type))).join(';'));
}
if (table.totals) lines.push(table.columns.map((c) => escape(formatCell(table.totals[c.key], c.type))).join(';'));
});
// BOM, damit Excel die Umlaute als UTF-8 liest.
return Buffer.from('' + lines.join('\r\n'), 'utf8');
}
/* ---------------------------------------------------------------- Excel -- */
export async function renderXlsx(doc, settings) {
const ex = settings.export;
const accent = (ex.accent || '#6BA04A').replace('#', '').toUpperCase();
const workbook = new ExcelJS.Workbook();
workbook.creator = settings.company_name || 'perinco';
workbook.created = new Date();
const sheet = workbook.addWorksheet(doc.sheetName || 'Stundennachweis', {
pageSetup: {
paperSize: 9,
orientation: doc.landscape ? 'landscape' : 'portrait',
fitToPage: true,
fitToWidth: 1,
fitToHeight: 0,
margins: { left: 0.5, right: 0.5, top: 0.6, bottom: 0.8, header: 0.3, footer: 0.3 },
},
headerFooter: {
oddFooter: `&L&8${xlEsc(ex.footer_left)}&C&8${xlEsc(ex.footer_center)}&R&8${xlEsc(ex.footer_right)} · Seite &P von &N`,
},
views: [{ showGridLines: false }],
});
const widest = doc.tables.reduce((m, t) => Math.max(m, t.columns.length), 2);
// Spaltenbreiten aus der ersten (Haupt-)Tabelle.
sheet.columns = doc.tables[0].columns.map((c) => ({ width: c.xlsWidth || Math.max(10, (c.width || 80) / 5.5) }));
const logo = workbook.addImage({ buffer: logoBuffer(ex), extension: /^data:image\/jpe?g/.test(ex.logo || '') ? 'jpeg' : 'png' });
sheet.addImage(logo, { tl: { col: 0, row: 0 }, ext: { width: 170, height: 77 } });
sheet.getRow(1).height = 22;
sheet.getRow(2).height = 22;
sheet.getRow(3).height = 22;
const headerRight = sheet.getCell(1, widest);
headerRight.value = ex.header_right || '';
headerRight.alignment = { horizontal: 'right' };
headerRight.font = { size: 9, color: { argb: 'FF6E6E6E' } };
let row = 5;
const title = sheet.getCell(row, 1);
title.value = doc.title;
title.font = { size: 18, bold: true, color: { argb: `FF${accent}` } };
row++;
if (doc.subtitle) {
sheet.getCell(row, 1).value = doc.subtitle;
sheet.getCell(row, 1).font = { size: 11, color: { argb: 'FF6E6E6E' } };
row++;
}
row++;
for (const [label, value] of doc.info || []) {
sheet.getCell(row, 1).value = label;
sheet.getCell(row, 1).font = { bold: true, size: 10, color: { argb: 'FF6E6E6E' } };
sheet.getCell(row, 2).value = value;
sheet.getCell(row, 2).font = { size: 10 };
row++;
}
if (doc.kpis?.length) {
row++;
doc.kpis.forEach(([label, value], i) => {
const col = 1 + i * 2;
if (col > Math.max(widest, 8)) return;
sheet.getCell(row, col).value = label;
sheet.getCell(row, col).font = { size: 9, color: { argb: 'FF6E6E6E' } };
sheet.getCell(row + 1, col).value = value;
sheet.getCell(row + 1, col).font = { size: 14, bold: true };
});
row += 2;
}
for (const table of doc.tables) {
row++;
if (table.title) {
sheet.getCell(row, 1).value = table.title;
sheet.getCell(row, 1).font = { bold: true, size: 12, color: { argb: `FF${accent}` } };
row++;
}
const headRow = sheet.getRow(row);
table.columns.forEach((c, i) => {
const cell = headRow.getCell(i + 1);
cell.value = c.label;
cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 10 };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: `FF${accent}` } };
cell.alignment = { horizontal: c.align === 'right' ? 'right' : 'left', vertical: 'middle' };
});
headRow.height = 20;
row++;
const first = row;
table.rows.forEach((data, index) => {
const r = sheet.getRow(row);
table.columns.forEach((c, i) => writeXlsCell(r.getCell(i + 1), data[c.key], c, index % 2 === 1));
row++;
});
if (table.totals) {
const r = sheet.getRow(row);
table.columns.forEach((c, i) => {
const cell = r.getCell(i + 1);
if ((c.type === 'hours' || c.type === 'money') && table.rows.length && table.totals[c.key] != null) {
const letter = sheet.getColumn(i + 1).letter;
const value = c.type === 'hours' ? table.totals[c.key] / 60 : table.totals[c.key];
cell.value = { formula: `SUM(${letter}${first}:${letter}${row - 1})`, result: value };
cell.numFmt = c.type === 'money' ? '#,##0.00 "€"' : '#,##0.00';
} else {
cell.value = formatCell(table.totals[c.key], c.type);
}
cell.font = { bold: true };
cell.alignment = { horizontal: c.align === 'right' ? 'right' : 'left' };
cell.border = { top: { style: 'medium', color: { argb: `FF${accent}` } } };
});
row++;
}
}
if (doc.signature && ex.show_signature) {
row += 3;
const half = Math.max(2, Math.floor(widest / 2));
for (const [col, label] of [
[1, ex.signature_left],
[half + 1, ex.signature_right],
]) {
const cell = sheet.getCell(row, col);
cell.border = { top: { style: 'thin', color: { argb: 'FF9A9A9A' } } };
sheet.getCell(row, col + 1).border = { top: { style: 'thin', color: { argb: 'FF9A9A9A' } } };
cell.value = label;
cell.font = { size: 9, color: { argb: 'FF6E6E6E' } };
}
}
return Buffer.from(await workbook.xlsx.writeBuffer());
}
function xlEsc(value) {
return String(value || '').replaceAll('&', '&&');
}
function writeXlsCell(cell, value, column, zebra) {
switch (column.type) {
case 'hours':
cell.value = value == null ? null : Math.round((value / 60) * 100) / 100;
cell.numFmt = '#,##0.00';
break;
case 'duration':
cell.value = value == null ? null : durationDe(value);
break;
case 'money':
cell.value = value == null ? null : Number(value);
cell.numFmt = '#,##0.00 "€"';
break;
case 'date':
cell.value = value ? new Date(`${String(value).slice(0, 10)}T00:00:00Z`) : null;
cell.numFmt = 'dd.mm.yyyy';
break;
case 'percent':
cell.value = value == null ? null : value / 100;
cell.numFmt = '0%';
break;
default:
cell.value = value ?? '';
}
cell.font = { size: 10 };
cell.alignment = { horizontal: column.align === 'right' ? 'right' : 'left', vertical: 'top', wrapText: column.type === 'text' || !column.type };
if (zebra) cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF4F7F1' } };
cell.border = { bottom: { style: 'hair', color: { argb: 'FFDCDCDC' } } };
}
/* ------------------------------------------------------------------ PDF -- */
export function renderPdf(doc, settings) {
const ex = settings.export;
const accent = ex.accent || '#6BA04A';
const pdf = new PDFDocument({
size: 'A4',
layout: doc.landscape ? 'landscape' : 'portrait',
margins: { top: 40, bottom: 70, left: 42, right: 42 },
bufferPages: true,
info: { Title: `${doc.title} ${doc.subtitle || ''}`.trim(), Author: settings.company_name || 'perinco' },
});
const chunks = [];
pdf.on('data', (c) => chunks.push(c));
const done = new Promise((resolve) => pdf.on('end', () => resolve(Buffer.concat(chunks))));
const left = pdf.page.margins.left;
const width = pdf.page.width - left - pdf.page.margins.right;
const logo = logoBuffer(ex);
const drawHeader = () => {
pdf.image(logo, left, 30, { fit: [120, 46] });
pdf.fontSize(8).fillColor('#6E6E6E').font('Helvetica');
if (ex.subtitle) pdf.text(ex.subtitle, left, 34, { width, align: 'right' });
if (ex.header_right) pdf.text(ex.header_right, left, 46, { width, align: 'right' });
pdf.moveTo(left, 84).lineTo(left + width, 84).lineWidth(1.5).strokeColor(accent).stroke();
pdf.x = left;
pdf.y = 100;
};
drawHeader();
pdf.on('pageAdded', drawHeader);
// Titel
pdf.font('Helvetica-Bold').fontSize(20).fillColor('#1F1F1F').text(doc.title, left, pdf.y);
if (doc.subtitle) pdf.font('Helvetica').fontSize(11).fillColor(accent).text(doc.subtitle);
pdf.moveDown(0.8);
// Infoblock zweispaltig
if (doc.info?.length) {
const colW = width / 2;
const startY = pdf.y;
let maxY = startY;
doc.info.forEach(([label, value], i) => {
const x = left + (i % 2) * colW;
const y = startY + Math.floor(i / 2) * 30;
pdf.font('Helvetica').fontSize(7.5).fillColor('#9A9A9A').text(label.toUpperCase(), x, y, { width: colW - 10, characterSpacing: 0.6 });
pdf.font('Helvetica-Bold').fontSize(10).fillColor('#1F1F1F').text(String(value ?? '–'), x, y + 10, { width: colW - 10, height: 14, ellipsis: true });
maxY = Math.max(maxY, y + 26);
});
pdf.y = maxY + 8;
}
// Kennzahlen als Kacheln
if (doc.kpis?.length) {
const gap = 8;
const boxW = (width - gap * (doc.kpis.length - 1)) / doc.kpis.length;
const y = pdf.y;
doc.kpis.forEach(([label, value], i) => {
const x = left + i * (boxW + gap);
pdf.roundedRect(x, y, boxW, 46, 6).fillColor('#F4F7F1').fill();
pdf.rect(x, y, 3, 46).fillColor(accent).fill();
pdf.font('Helvetica').fontSize(7.5).fillColor('#6E6E6E').text(label, x + 10, y + 8, { width: boxW - 16 });
pdf.font('Helvetica-Bold').fontSize(14).fillColor('#1F1F1F').text(String(value), x + 10, y + 21, { width: boxW - 16 });
});
pdf.y = y + 60;
}
for (const table of doc.tables) drawTable(pdf, table, { left, width, accent });
if (doc.signature && ex.show_signature) {
if (pdf.y + 80 > pdf.page.height - pdf.page.margins.bottom) pdf.addPage();
const y = pdf.y + 45;
const w = (width - 40) / 2;
for (const [x, label] of [
[left, ex.signature_left],
[left + w + 40, ex.signature_right],
]) {
pdf.moveTo(x, y).lineTo(x + w, y).lineWidth(0.6).strokeColor('#9A9A9A').stroke();
pdf.font('Helvetica').fontSize(8).fillColor('#6E6E6E').text(label || '', x, y + 4, { width: w });
}
}
// Fuss auf jeder Seite, inkl. Seitenzahl.
const range = pdf.bufferedPageRange();
for (let i = 0; i < range.count; i++) {
pdf.switchToPage(range.start + i);
const bottom = pdf.page.height - 48;
const margin = pdf.page.margins.bottom;
pdf.page.margins.bottom = 0; // sonst bricht text() unten auf eine neue Seite um
pdf.moveTo(left, bottom - 8).lineTo(left + width, bottom - 8).lineWidth(0.5).strokeColor('#DCDCDC').stroke();
pdf.font('Helvetica').fontSize(7.5).fillColor('#6E6E6E');
const third = width / 3;
pdf.text(ex.footer_left || '', left, bottom, { width: third, align: 'left', lineGap: 1 });
pdf.text(ex.footer_center || '', left + third, bottom, { width: third, align: 'center', lineGap: 1 });
pdf.text(`${ex.footer_right || ''}${ex.footer_right ? '\n' : ''}Seite ${i + 1} von ${range.count}`, left + 2 * third, bottom, {
width: third,
align: 'right',
lineGap: 1,
});
pdf.page.margins.bottom = margin;
}
pdf.end();
return done;
}
function drawTable(pdf, table, { left, width, accent }) {
const fixed = table.columns.reduce((s, c) => s + (c.width || 0), 0);
const flex = table.columns.filter((c) => !c.width).length;
const widths = table.columns.map((c) => c.width || Math.max(40, (width - fixed) / Math.max(1, flex)));
const scale = width / widths.reduce((a, b) => a + b, 0);
const cols = widths.map((w) => w * scale);
const bottomLimit = () => pdf.page.height - pdf.page.margins.bottom;
const header = () => {
const y = pdf.y;
pdf.rect(left, y, width, 18).fillColor(accent).fill();
let x = left;
table.columns.forEach((c, i) => {
pdf.font('Helvetica-Bold').fontSize(8).fillColor('#FFFFFF').text(c.label, x + 5, y + 5, {
width: cols[i] - 10,
align: c.align || 'left',
lineBreak: false,
ellipsis: true,
});
x += cols[i];
});
pdf.y = y + 18;
};
if (pdf.y + 60 > bottomLimit()) pdf.addPage();
if (table.title) {
pdf.moveDown(0.6);
pdf.font('Helvetica-Bold').fontSize(11).fillColor('#1F1F1F').text(table.title, left, pdf.y);
pdf.moveDown(0.3);
}
header();
const drawRow = (data, { zebra = false, bold = false } = {}) => {
const texts = table.columns.map((c) => formatCell(data[c.key], c.type));
pdf.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8.5);
const height = Math.max(16, ...texts.map((t, i) => pdf.heightOfString(t || ' ', { width: cols[i] - 10 }) + 8));
if (pdf.y + height > bottomLimit()) {
pdf.addPage();
header();
}
const y = pdf.y;
if (zebra) pdf.rect(left, y, width, height).fillColor('#F4F7F1').fill();
if (bold) {
pdf.moveTo(left, y).lineTo(left + width, y).lineWidth(1.2).strokeColor(accent).stroke();
}
let x = left;
texts.forEach((text, i) => {
pdf.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8.5).fillColor('#1F1F1F').text(text, x + 5, y + 4, {
width: cols[i] - 10,
align: table.columns[i].align || 'left',
});
x += cols[i];
});
if (!bold) pdf.moveTo(left, y + height).lineTo(left + width, y + height).lineWidth(0.3).strokeColor('#E4E4E4').stroke();
pdf.y = y + height;
};
if (table.rows.length === 0) {
pdf.font('Helvetica-Oblique').fontSize(9).fillColor('#9A9A9A').text('Keine Buchungen im Zeitraum.', left + 5, pdf.y + 6);
pdf.y += 10;
}
table.rows.forEach((row, index) => drawRow(row, { zebra: index % 2 === 1 }));
if (table.totals) drawRow(table.totals, { bold: true });
pdf.x = left;
pdf.moveDown(0.8);
}
export async function sendDocument(res, doc, format, settings) {
const safe = doc.filename.replace(/[^\w.\-äöüÄÖÜß ]+/g, '_');
let buffer;
let type;
if (format === 'pdf') {
buffer = await renderPdf(doc, settings);
type = 'application/pdf';
} else if (format === 'xlsx') {
buffer = await renderXlsx(doc, settings);
type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
} else {
buffer = renderCsv(doc);
type = 'text/csv; charset=utf-8';
}
res.setHeader('Content-Type', type);
res.setHeader(
'Content-Disposition',
`attachment; filename="${safe.replace(/[^\x20-\x7e]/g, '_')}.${format}"; filename*=UTF-8''${encodeURIComponent(safe)}.${format}`
);
res.send(buffer);
}
+45
View File
@@ -0,0 +1,45 @@
import { pool } from '../db.js';
import { hasRole } from '../middleware/auth.js';
/**
* Wessen Zeiten darf der Benutzer sehen?
* management/admin: alle (null)
* lead: sich selbst und alle, die direkt oder indirekt unter ihm haengen
* user: nur sich selbst
*/
export async function visibleUserIds(user) {
if (hasRole(user, 'management')) return null;
if (user.role !== 'lead') return [user.id];
const [rows] = await pool.query('SELECT id, manager_id FROM pulse_users');
const children = new Map();
for (const row of rows) {
if (row.manager_id == null) continue;
if (!children.has(row.manager_id)) children.set(row.manager_id, []);
children.get(row.manager_id).push(row.id);
}
const seen = new Set([user.id]);
const queue = [user.id];
while (queue.length) {
const current = queue.shift();
for (const child of children.get(current) || []) {
if (seen.has(child)) continue; // Schutz gegen Zyklen
seen.add(child);
queue.push(child);
}
}
return [...seen];
}
/** Alle Unterstellten (rekursiv) eines Vorgesetzten, ohne ihn selbst. */
export async function teamOf(leadId) {
const ids = await visibleUserIds({ id: leadId, role: 'lead' });
return ids.filter((id) => id !== leadId);
}
/** SQL-Fragment fuer "user_id IN (...)" - bei null ohne Einschraenkung. */
export function userFilter(ids, column = 'e.user_id') {
if (ids === null) return { sql: '1=1', params: [] };
if (ids.length === 0) return { sql: '1=0', params: [] };
return { sql: `${column} IN (${ids.map(() => '?').join(',')})`, params: ids };
}
+116
View File
@@ -0,0 +1,116 @@
/*
* Datumsrechnung ohne Zeitzonenfallen: alles laeuft ueber 'YYYY-MM-DD' und
* UTC-Mitternacht, damit ein Tag nie durch die Sommerzeit verrutscht.
*/
export function parseDate(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const date = new Date(`${value}T00:00:00Z`);
return Number.isNaN(date.getTime()) || iso(date) !== value ? null : date;
}
export function iso(date) {
return date.toISOString().slice(0, 10);
}
export function today() {
const now = new Date();
// Lokales Datum des Servers (Europe/Berlin), nicht UTC.
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 10);
}
export function addDays(value, days) {
const date = parseDate(value);
date.setUTCDate(date.getUTCDate() + days);
return iso(date);
}
export function daysBetween(from, to) {
return Math.round((parseDate(to) - parseDate(from)) / 86400000);
}
/** Arbeitstage Mo-Fr im geschlossenen Intervall. */
export function workdays(from, to) {
let count = 0;
const end = parseDate(to);
for (const d = parseDate(from); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
const day = d.getUTCDay();
if (day !== 0 && day !== 6) count++;
}
return count;
}
export function monthRange(month) {
if (!/^\d{4}-\d{2}$/.test(month || '')) return null;
const [y, m] = month.split('-').map(Number);
if (m < 1 || m > 12) return null;
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, '0')}` };
}
export function isoWeek(value) {
const date = parseDate(value);
const day = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return { year: date.getUTCFullYear(), week: Math.ceil(((date - yearStart) / 86400000 + 1) / 7) };
}
export function mondayOf(value) {
const date = parseDate(value);
const day = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() - day + 1);
return iso(date);
}
export const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
export function formatDateDe(value) {
const [y, m, d] = String(value).slice(0, 10).split('-');
return `${d}.${m}.${y}`;
}
/** Zeitraum, in dem `value` liegt: day | week | month | quarter | year. */
export function periodRange(granularity, value) {
const [y, m] = value.split('-').map(Number);
switch (granularity) {
case 'day':
return { from: value, to: value };
case 'week': {
const from = mondayOf(value);
return { from, to: addDays(from, 6) };
}
case 'month':
return monthRange(value.slice(0, 7));
case 'quarter': {
const q = Math.floor((m - 1) / 3);
const startMonth = String(q * 3 + 1).padStart(2, '0');
const end = monthRange(`${y}-${String(q * 3 + 3).padStart(2, '0')}`);
return { from: `${y}-${startMonth}-01`, to: end.to };
}
case 'year':
return { from: `${y}-01-01`, to: `${y}-12-31` };
default:
return null;
}
}
/** Beginn des vorherigen Zeitraums (fuer Reihen und Vergleich). */
export function previousStart(granularity, from) {
const d = parseDate(from);
if (granularity === 'day') d.setUTCDate(d.getUTCDate() - 1);
if (granularity === 'week') d.setUTCDate(d.getUTCDate() - 7);
if (granularity === 'month') d.setUTCMonth(d.getUTCMonth() - 1);
if (granularity === 'quarter') d.setUTCMonth(d.getUTCMonth() - 3);
if (granularity === 'year') d.setUTCFullYear(d.getUTCFullYear() - 1);
return iso(d);
}
export function periodLabel(granularity, from) {
const [y, m, d] = from.split('-');
if (granularity === 'day') return `${d}.${m}.`;
if (granularity === 'week') return `KW ${isoWeek(from).week}`;
if (granularity === 'month') return `${MONTHS_DE[Number(m) - 1].slice(0, 3)} ${y.slice(2)}`;
if (granularity === 'quarter') return `Q${Math.floor((Number(m) - 1) / 3) + 1} ${y.slice(2)}`;
return y;
}
+95
View File
@@ -0,0 +1,95 @@
import { pool } from '../db.js';
import { displayName, hourlyRate } from './projects.js';
import { userFilter } from './access.js';
/*
* Buchungen fuer Auswertungen laden - mit allem, was Dashboard, Reports,
* Kundenansicht und Exporte brauchen, und mit dem Betrag je Buchung:
*
* amount abrechenbarer Betrag (Stunden x Stundensatz des Topfs);
* nur fuer Projekte vom Typ "billable"
* fixed_amount Festpreisprojekte: Stunden x virtueller Stundensatz
* (Topfbudget / alle auf dem Topf gebuchten Stunden). Nur
* intern, in Reports per Schalter zuschaltbar.
*/
export async function loadEntries(settings, { from, to, userIds = null, customerId, projectId, taskId } = {}) {
// Administratoren sind keine Mitarbeiter - ihre Zeiten zaehlen nirgends mit.
const where = ["u.role <> 'admin'"];
const params = [];
if (from) {
where.push('e.entry_date >= ?');
params.push(from);
}
if (to) {
where.push('e.entry_date <= ?');
params.push(to);
}
const users = userFilter(userIds);
where.push(users.sql);
params.push(...users.params);
if (customerId) {
where.push('p.customer_id = ?');
params.push(Number(customerId));
}
if (projectId) {
where.push('e.project_id = ?');
params.push(Number(projectId));
}
if (taskId) {
where.push('e.task_id = ?');
params.push(Number(taskId));
}
const [rows] = await pool.query(
`SELECT e.id, e.user_id, e.project_id, e.task_id, e.pot_id, e.entry_date, e.minutes, e.note, e.billable,
e.created_at, e.updated_at,
u.name AS user_name, u.color AS user_color, u.is_external, u.company AS user_company, u.weekly_hours,
u.manager_id,
p.name AS project_name, p.code AS project_code, p.type AS project_type, p.billing_unit,
c.id AS customer_id, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color,
t.name AS task_name,
b.label AS pot_label, b.rate AS pot_rate, b.budget_amount AS pot_amount
FROM pulse_time_entries e
JOIN pulse_users u ON u.id = e.user_id
JOIN pulse_projects p ON p.id = e.project_id
JOIN pulse_customers c ON c.id = p.customer_id
LEFT JOIN pulse_tasks t ON t.id = e.task_id
LEFT JOIN pulse_budget_pots b ON b.id = e.pot_id
WHERE ${where.join(' AND ')}
ORDER BY e.entry_date, e.id`,
params
);
// Virtueller Stundensatz braucht die Stunden des ganzen Topfs, nicht nur des Zeitraums.
const fixedPots = [...new Set(rows.filter((r) => r.project_type === 'fixed' && r.pot_id).map((r) => r.pot_id))];
const potMinutes = new Map();
if (fixedPots.length) {
const [usage] = await pool.query(
`SELECT pot_id, SUM(minutes) AS minutes FROM pulse_time_entries WHERE pot_id IN (${fixedPots.map(() => '?').join(',')}) GROUP BY pot_id`,
fixedPots
);
for (const u of usage) potMinutes.set(u.pot_id, Number(u.minutes));
}
return rows.map((r) => {
const project = { name: r.project_name, code: r.project_code, billing_unit: r.billing_unit };
const hours = r.minutes / 60;
let amount = 0;
let fixedAmount = 0;
if (r.billable && r.project_type === 'billable') {
const rate = hourlyRate({ rate: r.pot_rate }, project, settings);
amount = rate ? hours * rate : 0;
}
if (r.billable && r.project_type === 'fixed' && r.pot_amount && potMinutes.get(r.pot_id)) {
fixedAmount = (Number(r.pot_amount) / (potMinutes.get(r.pot_id) / 60)) * hours;
}
return {
...r,
billable: Boolean(r.billable),
is_external: Boolean(r.is_external),
project_display: displayName(settings, project, { name: r.customer_name, short_name: r.customer_short }),
amount: Math.round(amount * 100) / 100,
fixed_amount: Math.round(fixedAmount * 100) / 100,
};
});
}
+375
View File
@@ -0,0 +1,375 @@
import { pool } from '../db.js';
import { daysBetween, parseDate, today } from './dates.js';
/*
* Fachlogik rund um Projekte und Budgettoepfe - an einer Stelle, weil Buchung,
* Projektansicht, Kunden und Reports dieselben Zahlen zeigen muessen.
*/
const round2 = (n) => Math.round(n * 100) / 100;
export function parseTags(value) {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return [];
}
}
/** Anzeigename nach dem Muster aus den Einstellungen, z. B. "KUKA - PMO". */
export function displayName(settings, project, customer) {
const pattern = settings.project_name_pattern || '{KUNDE} - {PROJEKT}';
const kunde = customer?.short_name && customer.short_name !== customer.name ? prettyShort(customer) : customer?.name || '';
return pattern
.replaceAll('{KUNDE}', kunde)
.replaceAll('{KUNDENNAME}', customer?.name || '')
.replaceAll('{PROJEKT}', project.name)
.replaceAll('{CODE}', project.code || '')
.trim();
}
/** Kuerzel in Grossbuchstaben wirken in Namen laut - "KUKA" bleibt, "PERINCO" wird "perinco". */
function prettyShort(customer) {
if (customer.short_name.toUpperCase() === 'PERINCO') return 'perinco';
return customer.short_name;
}
/** Stundensatz je Stunde, auch wenn das Projekt nach Tagen abrechnet. */
export function hourlyRate(pot, project, settings) {
if (pot?.rate == null) return null;
const rate = Number(pot.rate);
return project.billing_unit === 'day' ? rate / Number(settings.hours_per_day || 8) : rate;
}
/** Buchbares Volumen eines Topfs in Stunden (null = unbegrenzt). */
export function deriveBudgetHours(input, project, settings) {
const amount = input.budget_amount == null || input.budget_amount === '' ? null : Number(input.budget_amount);
const rate = input.rate == null || input.rate === '' ? null : Number(input.rate);
if (project.type === 'billable' && amount != null && rate) {
const perUnit = project.billing_unit === 'day' ? Number(settings.hours_per_day || 8) : 1;
return round2((amount / rate) * perUnit);
}
if (input.budget_hours == null || input.budget_hours === '') return null;
return round2(Number(input.budget_hours));
}
/**
* Kennzahlen eines Topfs. `usedMinutes` sind alle Buchungen auf diesem Topf.
* status: upcoming | active | expired | exhausted
* earned: bereits erbrachter Umsatz, open: noch offener Umsatz
*/
export function potStats(pot, project, usedMinutes, settings) {
const now = today();
const usedHours = usedMinutes / 60;
const budgetHours = pot.budget_hours == null ? null : Number(pot.budget_hours);
const amount = pot.budget_amount == null ? null : Number(pot.budget_amount);
const rateHour = hourlyRate(pot, project, settings);
let status = 'active';
if (now < pot.valid_from) status = 'upcoming';
else if (now > pot.valid_to) status = 'expired';
else if (budgetHours != null && usedHours >= budgetHours - 1e-9) status = 'exhausted';
let earned = 0;
if (project.type === 'billable') {
earned = rateHour ? usedHours * rateHour : 0;
} else if (project.type === 'fixed' && amount) {
if (budgetHours) {
earned = amount * Math.min(1, usedHours / budgetHours);
} else {
// Ohne Stundenkontingent: Festpreis anteilig ueber die Laufzeit.
const total = Math.max(1, daysBetween(pot.valid_from, pot.valid_to) + 1);
const elapsed = now < pot.valid_from ? 0 : Math.min(total, daysBetween(pot.valid_from, now) + 1);
earned = amount * (elapsed / total);
}
if (status === 'expired') earned = amount;
}
const budgetValue = project.type === 'billable' ? (amount ?? (budgetHours && rateHour ? budgetHours * rateHour : null)) : amount;
const open = budgetValue == null || status === 'expired' ? 0 : Math.max(0, budgetValue - earned);
const daysLeft = daysBetween(now, pot.valid_to);
return {
used_minutes: usedMinutes,
used_hours: round2(usedHours),
remaining_hours: budgetHours == null ? null : round2(Math.max(0, budgetHours - usedHours)),
percent_used: budgetHours ? Math.round((usedHours / budgetHours) * 1000) / 10 : null,
hourly_rate: rateHour == null ? null : round2(rateHour),
// Festpreis: Budget geteilt durch gebuchte Stunden - nur intern.
virtual_rate: project.type === 'fixed' && amount && usedHours > 0 ? round2(amount / usedHours) : null,
budget_value: budgetValue == null ? null : round2(budgetValue),
earned: round2(earned),
open: round2(open),
days_left: daysLeft,
status,
};
}
/** Ist der Topf zu leer oder laeuft er bald ab? */
export function potWarning(pot, stats, settings) {
if (stats.status === 'expired') return `Budgettopf „${pot.label}“ ist abgelaufen.`;
if (stats.status === 'exhausted') return `Budgettopf „${pot.label}“ ist aufgebraucht.`;
const parts = [];
if (stats.percent_used != null && stats.percent_used >= Number(settings.budget_warn_percent || 80)) {
parts.push(`${Math.round(stats.percent_used)} % verbraucht`);
}
if (stats.status === 'active' && stats.days_left <= Number(settings.budget_warn_days || 14)) {
parts.push(stats.days_left === 0 ? 'läuft heute ab' : `läuft in ${stats.days_left} Tagen ab`);
}
return parts.length ? `Budgettopf „${pot.label}“: ${parts.join(', ')}.` : null;
}
/** Laufende Nummer je Kunde und Jahr, eingesetzt in das Codemuster. */
export async function generateCode(conn, settings, customer, startDate) {
const year = String(startDate).slice(0, 4);
const pattern = settings.project_code_pattern || '{KUERZEL}-{JAHR}-{NR}';
const [rows] = await conn.query(
'SELECT COALESCE(MAX(seq), 0) AS n FROM pulse_projects WHERE customer_id = ? AND YEAR(start_date) = ?',
[customer.id, Number(year)]
);
let seq = Number(rows[0].n) + 1;
// Gegen Kollisionen, falls das Muster geaendert wurde.
for (let attempt = 0; attempt < 500; attempt++, seq++) {
const code = pattern
.replaceAll('{KUERZEL}', customer.short_name.toUpperCase())
.replaceAll('{JAHR}', year)
.replaceAll('{JJ}', year.slice(2))
.replaceAll('{NR}', String(seq).padStart(3, '0'))
.slice(0, 40);
const [exists] = await conn.query('SELECT id FROM pulse_projects WHERE code = ? LIMIT 1', [code]);
if (exists.length === 0) return { code, seq };
}
throw new Error('Projektcode konnte nicht erzeugt werden.');
}
/**
* Was verbraucht Budget? Bei Billable-Projekten ist das Volumen aus Betrag und
* Satz abgeleitet - also verkaufte Stunden. Nicht abrechenbare Task-Stunden
* (z. B. Reisezeit) verbrauchen es nicht. Bei Festpreis und intern zaehlt alles.
*/
export function countsAgainstBudget(project) {
return project.type === 'billable' ? 'billable' : 'all';
}
/** Abrechenbar ist eine Buchung, wenn Projekt UND Task es sind. */
export function isBillable(project, task) {
if (project.type === 'non_billable') return false;
return task ? Boolean(task.billable) : true;
}
/**
* Laedt Projekte samt Kunde, Toepfen (mit Kennzahlen), Tasks und Mitgliedern.
* `where` schraenkt pulse_projects (Alias p) ein.
*/
export async function loadProjects(settings, { where = '1=1', params = [] } = {}) {
const [projects] = await pool.query(
`SELECT p.*, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color,
u.name AS owner_name
FROM pulse_projects p
JOIN pulse_customers c ON c.id = p.customer_id
LEFT JOIN pulse_users u ON u.id = p.owner_id
WHERE ${where}
ORDER BY c.name, p.name`,
params
);
if (projects.length === 0) return [];
const ids = projects.map((p) => p.id);
const inList = ids.map(() => '?').join(',');
const [[pots], [tasks], [members], [potUsage], [projectUsage], [taskUsage]] = await Promise.all([
pool.query(`SELECT * FROM pulse_budget_pots WHERE project_id IN (${inList}) ORDER BY valid_from, id`, ids),
pool.query(`SELECT * FROM pulse_tasks WHERE project_id IN (${inList}) ORDER BY name`, ids),
pool.query(
`SELECT m.project_id, u.id, u.name, u.color, u.is_external
FROM pulse_project_members m JOIN pulse_users u ON u.id = m.user_id
WHERE m.project_id IN (${inList}) AND u.role <> 'admin' ORDER BY u.name`,
ids
),
pool.query(
`SELECT pot_id, SUM(minutes) AS minutes, SUM(CASE WHEN billable = 1 THEN minutes ELSE 0 END) AS billable_minutes
FROM pulse_time_entries
WHERE project_id IN (${inList}) AND pot_id IS NOT NULL GROUP BY pot_id`,
ids
),
pool.query(
`SELECT project_id, SUM(minutes) AS minutes, SUM(CASE WHEN billable = 1 THEN minutes ELSE 0 END) AS billable_minutes,
MAX(entry_date) AS last_entry
FROM pulse_time_entries WHERE project_id IN (${inList}) GROUP BY project_id`,
ids
),
pool.query(
`SELECT task_id, SUM(minutes) AS minutes FROM pulse_time_entries
WHERE project_id IN (${inList}) AND task_id IS NOT NULL GROUP BY task_id`,
ids
),
]);
const potMinutes = new Map(potUsage.map((r) => [r.pot_id, r]));
const projectMinutes = new Map(projectUsage.map((r) => [r.project_id, r]));
const taskMinutes = new Map(taskUsage.map((r) => [r.task_id, Number(r.minutes)]));
return projects.map((p) => {
const project = { ...p, tags: parseTags(p.tags), is_active: Boolean(p.is_active) };
const customer = { id: p.customer_id, name: p.customer_name, short_name: p.customer_short };
const projectPots = pots
.filter((pot) => pot.project_id === p.id)
.map((pot) => {
const usage = potMinutes.get(pot.id);
const booked = Number(usage?.minutes || 0);
const counted = countsAgainstBudget(project) === 'billable' ? Number(usage?.billable_minutes || 0) : booked;
const stats = potStats(pot, project, counted, settings);
return { ...pot, ...stats, booked_minutes: booked, warning: potWarning(pot, stats, settings) };
});
const usage = projectMinutes.get(p.id);
const totalBudgetHours = projectPots.every((pot) => pot.budget_hours != null) && projectPots.length
? round2(projectPots.reduce((s, pot) => s + Number(pot.budget_hours), 0))
: null;
const usedMinutes = Number(usage?.minutes || 0);
return {
...project,
display_name: displayName(settings, project, customer),
customer: { id: p.customer_id, name: p.customer_name, short_name: p.customer_short, color: p.customer_color },
pots: projectPots,
tasks: tasks
.filter((t) => t.project_id === p.id)
.map((t) => ({ ...t, billable: Boolean(t.billable), is_active: Boolean(t.is_active), used_minutes: taskMinutes.get(t.id) || 0 })),
members: members
.filter((m) => m.project_id === p.id)
.map((m) => ({ id: m.id, name: m.name, color: m.color, is_external: Boolean(m.is_external) })),
used_minutes: usedMinutes,
billable_minutes: Number(usage?.billable_minutes || 0),
// Was gegen das Budget zaehlt (bei Billable nur abrechenbare Stunden).
budget_used_minutes: projectPots.reduce((s, pot) => s + pot.used_minutes, 0),
last_entry: usage?.last_entry || null,
budget_hours: totalBudgetHours,
remaining_hours:
totalBudgetHours == null ? null : round2(Math.max(0, totalBudgetHours - projectPots.reduce((s, pot) => s + pot.used_minutes, 0) / 60)),
budget_value: round2(projectPots.reduce((s, pot) => s + (pot.budget_value || 0), 0)),
earned: round2(projectPots.reduce((s, pot) => s + pot.earned, 0)),
open: round2(projectPots.reduce((s, pot) => s + pot.open, 0)),
};
});
}
/** Betraege weg fuer die Rolle "user" - Mitarbeiter sehen Stunden, keine Preise. */
export function stripAmounts(project) {
const clean = { ...project, budget_value: null, earned: null, open: null };
clean.pots = project.pots.map((pot) => ({
...pot,
budget_amount: null,
rate: null,
hourly_rate: null,
virtual_rate: null,
budget_value: null,
earned: null,
open: null,
}));
return clean;
}
export class BookingError extends Error {
constructor(message) {
super(message);
this.status = 422;
}
}
/**
* Findet den Topf fuer eine Buchung.
*
* Regeln (aus dem Anforderungscall):
* - gebucht wird nur auf einen Topf, dessen Laufzeit das Leistungsdatum enthaelt
* - ist das Volumen erreicht oder das Ablaufdatum ueberschritten: nicht buchbar
* - ueberlappen mehrere Toepfe, zuerst der, der am fruehesten endet
* - Projekte ohne Topf (z. B. intern, unbegrenzt) sind frei buchbar
*/
export async function resolvePot(conn, project, date, minutes, excludeEntryId = null, billable = true) {
const onlyBillable = countsAgainstBudget(project) === 'billable';
// Nicht abrechenbare Stunden in einem Billable-Projekt verbrauchen kein Volumen.
const needed = onlyBillable && !billable ? 0 : minutes;
if (date < project.start_date) {
throw new BookingError(`Das Projekt beginnt erst am ${de(project.start_date)}.`);
}
if (project.end_date && date > project.end_date) {
throw new BookingError(`Das Projekt ist am ${de(project.end_date)} beendet.`);
}
const [pots] = await conn.query(
'SELECT * FROM pulse_budget_pots WHERE project_id = ? ORDER BY valid_to, id FOR UPDATE',
[project.id]
);
if (pots.length === 0) return null;
const covering = pots.filter((pot) => pot.valid_from <= date && pot.valid_to >= date);
if (covering.length === 0) {
throw new BookingError(
`Kein Budgettopf deckt den ${de(date)} ab. Buchbar ist nur innerhalb der Topf-Laufzeiten ` +
`(${pots.map((p) => `${p.label}: ${de(p.valid_from)}–${de(p.valid_to)}`).join('; ')}).`
);
}
let lastShortage = null;
for (const pot of covering) {
if (pot.valid_to < today()) {
lastShortage = `Budgettopf „${pot.label}“ ist seit ${de(pot.valid_to)} abgelaufen.`;
continue;
}
if (pot.budget_hours == null) return pot;
const [rows] = await conn.query(
`SELECT COALESCE(SUM(minutes), 0) AS used FROM pulse_time_entries
WHERE pot_id = ? AND id <> ? ${onlyBillable ? 'AND billable = 1' : ''}`,
[pot.id, excludeEntryId || 0]
);
const free = Number(pot.budget_hours) * 60 - Number(rows[0].used);
// Ein aufgebrauchter Topf ist fuer alles gesperrt, auch fuer Reisezeit.
if (free > 1e-9 && needed <= free + 1e-9) return pot;
lastShortage =
free <= 0
? `Budgettopf „${pot.label}“ ist aufgebraucht.`
: `Budgettopf „${pot.label}“ hat nur noch ${fmtMinutes(free)} h frei.`;
}
throw new BookingError(`${lastShortage} Buchung nicht möglich – bitte Projektverantwortlichen informieren.`);
}
/**
* Nach Aenderung an Toepfen: bestehende Buchungen neu zuordnen. Historische
* Buchungen werden nicht abgewiesen - sie landen im passenden Topf, auch wenn
* dieser dadurch ueberbucht ist (das zeigt die Projektansicht dann rot).
*/
export async function reassignEntries(conn, projectId) {
const [pots] = await conn.query('SELECT * FROM pulse_budget_pots WHERE project_id = ? ORDER BY valid_to, id', [projectId]);
const [[project]] = await conn.query('SELECT type FROM pulse_projects WHERE id = ?', [projectId]);
const onlyBillable = project && countsAgainstBudget(project) === 'billable';
const [entries] = await conn.query(
'SELECT id, entry_date, minutes, pot_id, billable FROM pulse_time_entries WHERE project_id = ? ORDER BY entry_date, id',
[projectId]
);
const used = new Map(pots.map((p) => [p.id, 0]));
for (const entry of entries) {
const needed = onlyBillable && !entry.billable ? 0 : entry.minutes;
const covering = pots.filter((p) => p.valid_from <= entry.entry_date && p.valid_to >= entry.entry_date);
const target =
covering.find((p) => p.budget_hours == null || used.get(p.id) + needed <= Number(p.budget_hours) * 60) ||
covering[0] ||
null;
if (target) used.set(target.id, used.get(target.id) + needed);
const targetId = target ? target.id : null;
if (targetId !== entry.pot_id) {
await conn.query('UPDATE pulse_time_entries SET pot_id = ? WHERE id = ?', [targetId, entry.id]);
}
}
}
function de(value) {
const [y, m, d] = String(value).slice(0, 10).split('-');
return `${d}.${m}.${y}`;
}
export function fmtMinutes(minutes) {
const total = Math.max(0, Math.round(minutes));
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
}
export { parseDate };
+101
View File
@@ -0,0 +1,101 @@
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',
},
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);
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) };
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 '';
}
}
+274
View File
@@ -0,0 +1,274 @@
import crypto from 'node:crypto';
import { pool } from '../db.js';
/*
* Microsoft Entra ID (M365).
*
* Anmeldung: OpenID Connect, Authorization Code Flow als vertrauliche App
* (Client Secret). Das id_token kommt direkt vom Token-Endpunkt ueber TLS -
* nach OIDC Core 3.1.3.7 reicht dann die Pruefung von aud/tid/exp.
*
* Verzeichnis: Client Credentials gegen Microsoft Graph (Anwendungsberechtigung
* User.Read.All). Angezeigt werden nur echte Mitarbeiterkonten - siehe
* isEligible(). Uebernommen wird von Hand in der SSO-Verwaltung; anmelden kann
* sich nur, wer uebernommen wurde. Rollen vergibt ausschliesslich Pulse, AD-
* Gruppen spielen keine Rolle.
*/
const LOGIN = 'https://login.microsoftonline.com';
/** Erlaubte Maildomains fuer Mitarbeiterkonten. */
export const ALLOWED_DOMAINS = ['perinco.de', 'icon-experts.de'];
/*
* Genau zwei Namensteile: vorname.nachname@domain. Bindestriche innerhalb eines
* Teils sind erlaubt (anna-lena.mueller), weitere Punkte nicht - damit fallen
* Admin-Konten wie adm.sascha.wolf@ und Funktionsadressen wie info@ heraus.
*/
const MAIL_PATTERN = new RegExp(
`^[a-z]+(?:-[a-z]+)*\\.[a-z]+(?:-[a-z]+)*@(?:${ALLOWED_DOMAINS.map((d) => d.replace('.', '\\.')).join('|')})$`
);
export function ssoConfigured(sso) {
return Boolean(sso.tenant_id && sso.client_id && sso.client_secret && sso.redirect_uri);
}
/** Primaere Mailadresse eines AD-Benutzers (mail, sonst UPN), klein geschrieben. */
export function adEmail(ad) {
return String(ad.mail || ad.userPrincipalName || '').trim().toLowerCase();
}
/** Technische Praefixe, die nie eine Person sind (adm.sascha.wolf, sa.hrworks, srv.pa). */
const TECHNICAL_PREFIXES = ['adm', 'admin', 'sa', 'srv', 'svc', 'service', 'test'];
/** "Mülln" -> "muelln", "René" -> "rene", "Grote-Aksu" -> "groteaksu". */
export function normalizeName(value) {
return String(value || '')
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z]/g, '');
}
/**
* Ist das ein echtes Mitarbeiterkonto?
* - Mail im Format vorname.nachname@perinco.de bzw. @icon-experts.de
* - kein technisches Praefix (sa., srv., adm. ...)
* - Nachname im AD passt zum Teil hinter dem Punkt - Dienstkonten wie
* sa.hrworks ("Service Account") oder srv.pa ("SRV Flow") fallen so heraus.
* Der Vorname wird bewusst nicht verglichen: Tippfehler im AD kommen vor.
* - kein Gast
* - Konto aktiv (freigegebene Postfaecher sind in Entra deaktiviert)
* - mindestens eine Lizenz (freigegebene Postfaecher und Dienstkonten haben keine)
*/
export function isEligible(ad) {
if (ad.userType === 'Guest') return false;
if (ad.accountEnabled === false) return false;
if (Array.isArray(ad.assignedLicenses) && ad.assignedLicenses.length === 0) return false;
const email = adEmail(ad);
if (!MAIL_PATTERN.test(email)) return false;
const [first, last] = email.split('@')[0].split('.');
if (TECHNICAL_PREFIXES.includes(first)) return false;
const surname = normalizeName(ad.surname);
return surname.length > 0 && normalizeName(last) === surname;
}
export function authorizeUrl(sso, state) {
const params = new URLSearchParams({
client_id: sso.client_id,
response_type: 'code',
redirect_uri: sso.redirect_uri,
response_mode: 'query',
scope: 'openid profile email',
state,
prompt: 'select_account',
});
return `${LOGIN}/${encodeURIComponent(sso.tenant_id)}/oauth2/v2.0/authorize?${params}`;
}
async function tokenRequest(sso, body) {
const response = await fetch(`${LOGIN}/${encodeURIComponent(sso.tenant_id)}/oauth2/v2.0/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ client_id: sso.client_id, client_secret: sso.client_secret, ...body }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(String(payload.error_description || payload.error || `HTTP ${response.status}`).split('\n')[0]);
}
return payload;
}
export async function exchangeCode(sso, code) {
const tokens = await tokenRequest(sso, {
grant_type: 'authorization_code',
code,
redirect_uri: sso.redirect_uri,
scope: 'openid profile email',
});
const [, body] = String(tokens.id_token || '').split('.');
if (!body) throw new Error('Kein id_token erhalten.');
const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
if (claims.aud !== sso.client_id) throw new Error('id_token gehört zu einer anderen Anwendung.');
if (claims.exp * 1000 < Date.now()) throw new Error('id_token ist abgelaufen.');
if (/^[0-9a-f-]{36}$/i.test(sso.tenant_id) && claims.tid !== sso.tenant_id) throw new Error('Falscher Mandant.');
return {
oid: claims.oid,
email: String(claims.email || claims.preferred_username || '').toLowerCase(),
name: claims.name || claims.preferred_username,
};
}
async function graphToken(sso) {
const tokens = await tokenRequest(sso, {
grant_type: 'client_credentials',
scope: 'https://graph.microsoft.com/.default',
});
return tokens.access_token;
}
const SELECT = 'id,displayName,givenName,surname,mail,userPrincipalName,jobTitle,department,accountEnabled,userType,assignedLicenses';
async function graphGetAll(token, url, maxPages = 100) {
const items = [];
let next = url;
let pages = 0;
while (next && pages++ < maxPages) {
const response = await fetch(next, { headers: { Authorization: `Bearer ${token}` } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error?.message || `Graph HTTP ${response.status}`);
items.push(...(payload.value || []));
next = payload['@odata.nextLink'];
}
return items;
}
export async function testConnection(sso) {
const token = await graphToken(sso);
const users = await graphGetAll(token, `https://graph.microsoft.com/v1.0/users?$select=${SELECT}&$top=50`, 1);
const eligible = users.filter(isEligible);
return { sample: eligible.slice(0, 5).map((u) => u.displayName), checked: users.length, eligible: eligible.length };
}
/*
* Verzeichnis-Zwischenspeicher. Die SSO-Verwaltung filtert und sucht im
* Browser; Graph wird hoechstens alle 5 Minuten (oder auf Knopfdruck) gefragt.
* Der Schluessel haengt an Tenant und Client, damit ein Wechsel der
* Zugangsdaten nie alte Daten zeigt.
*/
const CACHE_MS = 5 * 60 * 1000;
let cache = { key: null, at: 0, users: null, raw: null };
function mapUser(ad) {
return {
id: ad.id,
name: ad.displayName || adEmail(ad),
given_name: ad.givenName || null,
surname: ad.surname || null,
email: adEmail(ad),
job_title: ad.jobTitle || null,
department: ad.department || null,
};
}
/** Alle AD-Konten (roh) - fuer den Abgleich, der auch Deaktivierungen sehen muss. */
async function fetchAll(sso, refresh) {
const key = `${sso.tenant_id}|${sso.client_id}`;
if (!refresh && cache.key === key && cache.raw && Date.now() - cache.at < CACHE_MS) return cache.raw;
const token = await graphToken(sso);
const raw = await graphGetAll(token, `https://graph.microsoft.com/v1.0/users?$select=${SELECT}&$top=999`);
cache = { key, at: Date.now(), raw, users: raw.filter(isEligible).map(mapUser).sort((a, b) => a.name.localeCompare(b.name, 'de')) };
return raw;
}
/** Gefilterte Mitarbeiterkonten aus dem AD. */
export async function listDirectory(sso, { refresh = false } = {}) {
await fetchAll(sso, refresh);
return { users: cache.users, fetched_at: new Date(cache.at).toISOString() };
}
/**
* Uebernimmt AD-Konten nach Pulse. Existiert schon ein Benutzer mit derselben
* Objekt-ID oder Mailadresse (z. B. ein vorher lokal angelegtes Konto), wird er
* verknuepft statt doppelt angelegt. Die Rolle wird in beiden Faellen gesetzt.
*/
export async function importUsers(sso, adIds, { role, managerId }) {
const { users } = await listDirectory(sso);
const byId = new Map(users.map((u) => [u.id, u]));
const result = { created: [], linked: [], skipped: [] };
for (const adId of adIds) {
const ad = byId.get(adId);
if (!ad) {
result.skipped.push(adId);
continue;
}
const [rows] = await pool.query('SELECT id FROM pulse_users WHERE external_id = ? OR email = ? LIMIT 1', [ad.id, ad.email]);
if (rows[0]) {
await pool.query(
`UPDATE pulse_users
SET external_id = ?, source = 'sso', name = ?, email = ?, job_title = COALESCE(?, job_title),
role = ?, manager_id = COALESCE(?, manager_id), is_active = 1, is_external = 0
WHERE id = ?`,
[ad.id, ad.name, ad.email, ad.job_title, role, managerId ?? null, rows[0].id]
);
result.linked.push(rows[0].id);
} else {
const [insert] = await pool.query(
`INSERT INTO pulse_users (username, email, name, role, manager_id, source, external_id, job_title)
VALUES (?, ?, ?, ?, ?, 'sso', ?, ?)`,
[await freeUsername(ad.email), ad.email, ad.name, role, managerId ?? null, ad.id, ad.job_title]
);
result.created.push(insert.insertId);
}
}
return result;
}
/**
* Abgleich der uebernommenen Benutzer: Name, Mail, Jobtitel aus dem AD.
* Wer im AD geloescht, deaktiviert oder nicht mehr zulaessig ist, wird in Pulse
* deaktiviert. Neue Konten legt der Abgleich nie an - das geschieht nur in der
* SSO-Verwaltung.
*/
export async function syncUsers(sso) {
const raw = await fetchAll(sso, true);
const byId = new Map(raw.map((u) => [u.id, u]));
const [linked] = await pool.query("SELECT id, external_id, is_active FROM pulse_users WHERE source = 'sso' AND external_id IS NOT NULL");
let updated = 0;
let deactivated = 0;
for (const user of linked) {
const ad = byId.get(user.external_id);
if (!ad || !isEligible(ad)) {
if (user.is_active) {
await pool.query('UPDATE pulse_users SET is_active = 0 WHERE id = ?', [user.id]);
deactivated++;
}
continue;
}
await pool.query('UPDATE pulse_users SET name = ?, email = ?, job_title = COALESCE(?, job_title) WHERE id = ?', [
ad.displayName || adEmail(ad),
adEmail(ad),
ad.jobTitle || null,
user.id,
]);
updated++;
}
return { total: raw.length, eligible: cache.users.length, linked: linked.length, updated, deactivated };
}
export async function freeUsername(email) {
const base = email.split('@')[0].replace(/[^a-z0-9._-]/gi, '').toLowerCase().slice(0, 60) || 'user';
let candidate = base;
for (let i = 2; i < 1000; i++) {
const [rows] = await pool.query('SELECT id FROM pulse_users WHERE username = ? LIMIT 1', [candidate]);
if (rows.length === 0) return candidate;
candidate = `${base}${i}`;
}
return `${base}-${crypto.randomBytes(3).toString('hex')}`;
}
+93
View File
@@ -0,0 +1,93 @@
import jwt from 'jsonwebtoken';
import { pool } from '../db.js';
export const ROLES = ['user', 'lead', 'management', 'admin'];
const RANK = { user: 0, lead: 1, management: 2, admin: 3 };
function secret() {
const value = process.env.JWT_SECRET;
if (!value || value.length < 16) throw new Error('JWT_SECRET fehlt oder ist zu kurz (min. 16 Zeichen).');
return value;
}
export function signToken(user) {
return jwt.sign({ sub: user.id, role: user.role }, secret(), {
expiresIn: process.env.JWT_EXPIRES_IN || '12h',
});
}
export function signShort(payload, expiresIn = '10m') {
return jwt.sign(payload, secret(), { expiresIn });
}
export function verifyShort(token) {
return jwt.verify(token, secret());
}
export const USER_COLUMNS = `id, username, email, name, role, manager_id, job_title, is_external, company,
source, external_id, weekly_hours, color, theme, theme_mode, is_active, last_login_at, created_at, updated_at`;
/**
* Prueft das Bearer-Token und laedt den Benutzer bei jeder Anfrage frisch aus
* der Datenbank - ein deaktiviertes Konto ist sofort draussen und eine
* geaenderte Rolle gilt sofort.
*/
export async function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'Nicht angemeldet.' });
let payload;
try {
payload = jwt.verify(token, secret());
} catch {
return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte neu anmelden.' });
}
try {
const [rows] = await pool.query(`SELECT ${USER_COLUMNS} FROM pulse_users WHERE id = ? LIMIT 1`, [payload.sub]);
const user = rows[0];
if (!user) return res.status(401).json({ error: 'Benutzer existiert nicht mehr.' });
if (!user.is_active) return res.status(401).json({ error: 'Benutzerkonto ist deaktiviert.' });
req.user = user;
next();
} catch (err) {
next(err);
}
}
export function hasRole(user, minimum) {
return RANK[user?.role] >= RANK[minimum];
}
/** Mindestrolle: lead < management < admin. */
export function requireRole(minimum) {
return (req, res, next) => {
if (!hasRole(req.user, minimum)) {
return res.status(403).json({ error: 'Für diese Aktion fehlen die Berechtigungen.' });
}
next();
};
}
export function publicUser(row) {
return {
id: row.id,
username: row.username,
email: row.email,
name: row.name,
role: row.role,
manager_id: row.manager_id ?? null,
job_title: row.job_title ?? null,
is_external: Boolean(row.is_external),
company: row.company ?? null,
source: row.source,
weekly_hours: row.weekly_hours == null ? null : Number(row.weekly_hours),
color: row.color ?? null,
theme: row.theme ?? null,
theme_mode: row.theme_mode ?? null,
is_active: Boolean(row.is_active),
last_login_at: row.last_login_at ?? null,
created_at: row.created_at,
};
}
+170
View File
@@ -0,0 +1,170 @@
import express from 'express';
import bcrypt from 'bcryptjs';
import crypto from 'node:crypto';
import { pool } from '../db.js';
import { publicUser, requireAuth, signShort, signToken, verifyShort } from '../middleware/auth.js';
import { getSettings } from '../lib/settings.js';
import { authorizeUrl, exchangeCode, ssoConfigured } from '../lib/sso.js';
const router = express.Router();
/*
* Bremse gegen Passwortraten: 10 Fehlversuche je IP und Kennung in 15 Minuten.
* Im Speicher reicht - es gibt genau eine Backend-Instanz.
*/
const attempts = new Map();
const WINDOW = 15 * 60000;
function isLimited(key) {
const now = Date.now();
const recent = (attempts.get(key) || []).filter((t) => now - t < WINDOW);
if (recent.length) attempts.set(key, recent);
else attempts.delete(key);
return recent.length >= 10;
}
function recordFailure(key) {
attempts.set(key, [...(attempts.get(key) || []), Date.now()]);
}
// Oeffentlich: was zeigt der Anmeldeschirm?
router.get('/config', async (req, res, next) => {
try {
const { sso } = await getSettings();
const ssoOn = Boolean(sso.enabled && ssoConfigured(sso));
res.json({ sso_enabled: ssoOn, sso_label: sso.button_label, local_login: ssoOn ? sso.local_login !== false : true });
} catch (err) {
next(err);
}
});
router.post('/login', async (req, res, next) => {
const identifier = String(req.body?.identifier || '').trim();
const password = String(req.body?.password || '');
if (!identifier || !password) {
return res.status(400).json({ error: 'Benutzername und Passwort sind erforderlich.' });
}
const key = `${req.ip}|${identifier.toLowerCase()}`;
if (isLimited(key)) {
return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.' });
}
try {
const [rows] = await pool.query('SELECT * FROM pulse_users WHERE username = ? OR email = ? LIMIT 1', [
identifier,
identifier.toLowerCase(),
]);
const user = rows[0];
// Identische Meldung fuer "gibt es nicht" und "falsches Passwort".
const ok = user?.password_hash ? await bcrypt.compare(password, user.password_hash) : false;
if (!ok) {
recordFailure(key);
return res.status(401).json({ error: 'Benutzername oder Passwort ist falsch.' });
}
if (!user.is_active) return res.status(403).json({ error: 'Dieses Benutzerkonto ist deaktiviert.' });
await pool.query('UPDATE pulse_users SET last_login_at = NOW() WHERE id = ?', [user.id]);
res.json({ token: signToken(user), user: publicUser(user) });
} catch (err) {
next(err);
}
});
router.get('/me', requireAuth, (req, res) => res.json({ user: publicUser(req.user) }));
// Eigene Darstellung: Design, Modus, Farbe.
router.patch('/me', requireAuth, async (req, res, next) => {
const updates = {};
if (req.body?.theme !== undefined) {
const theme = req.body.theme;
if (theme !== null && !['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura'].includes(theme)) {
return res.status(400).json({ error: 'Unbekanntes Design.' });
}
updates.theme = theme === 'perinco' ? null : theme;
}
if (req.body?.theme_mode !== undefined) {
const mode = req.body.theme_mode;
if (mode !== null && mode !== 'light' && mode !== 'dark') return res.status(400).json({ error: 'Unbekannter Modus.' });
updates.theme_mode = mode;
}
if (req.body?.color !== undefined) {
const color = req.body.color;
if (color !== null && !/^#[0-9a-f]{6}$/i.test(color)) return res.status(400).json({ error: 'Ungültige Farbe.' });
updates.color = color;
}
const keys = Object.keys(updates);
if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
try {
await pool.query(`UPDATE pulse_users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
...keys.map((k) => updates[k]),
req.user.id,
]);
res.json({ user: publicUser({ ...req.user, ...updates }) });
} catch (err) {
next(err);
}
});
router.post('/change-password', requireAuth, async (req, res, next) => {
const current = String(req.body?.current_password || '');
const wanted = String(req.body?.new_password || '');
if (wanted.length < 8) return res.status(400).json({ error: 'Das neue Passwort muss mindestens 8 Zeichen haben.' });
try {
const [rows] = await pool.query('SELECT password_hash FROM pulse_users WHERE id = ?', [req.user.id]);
const hash = rows[0]?.password_hash;
if (!hash) return res.status(400).json({ error: 'Dieses Konto meldet sich über SSO an und hat kein Passwort.' });
if (!(await bcrypt.compare(current, hash))) return res.status(400).json({ error: 'Das aktuelle Passwort ist falsch.' });
await pool.query('UPDATE pulse_users SET password_hash = ? WHERE id = ?', [await bcrypt.hash(wanted, 12), req.user.id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
/* ---------------------------------------------------------------- SSO -- */
router.get('/sso/start', async (req, res, next) => {
try {
const { sso } = await getSettings();
if (!sso.enabled || !ssoConfigured(sso)) {
return res.redirect('/?sso_error=' + encodeURIComponent('SSO ist nicht eingerichtet.'));
}
const state = signShort({ n: crypto.randomBytes(12).toString('hex'), t: 'sso' });
res.redirect(authorizeUrl(sso, state));
} catch (err) {
next(err);
}
});
router.get('/sso/callback', async (req, res) => {
const back = (message) => res.redirect('/?sso_error=' + encodeURIComponent(message));
try {
if (req.query.error) return back(String(req.query.error_description || req.query.error).split('\n')[0]);
try {
const state = verifyShort(String(req.query.state || ''));
if (state.t !== 'sso') throw new Error('state');
} catch {
return back('Anmeldung abgelaufen. Bitte erneut versuchen.');
}
const { sso } = await getSettings();
if (!sso.enabled || !ssoConfigured(sso)) return back('SSO ist nicht eingerichtet.');
const identity = await exchangeCode(sso, String(req.query.code || ''));
// Nur wer in der SSO-Verwaltung uebernommen wurde, kommt herein - verknuepft
// wird ueber die Objekt-ID, nicht ueber die Mailadresse.
const [rows] = await pool.query("SELECT * FROM pulse_users WHERE external_id = ? AND source = 'sso' LIMIT 1", [identity.oid]);
const user = rows[0];
if (!user) {
return back(`Für ${identity.email || 'dieses Konto'} ist Pulse noch nicht freigeschaltet. Bitte an einen Manager oder Administrator wenden.`);
}
if (!user.is_active) return back('Dieses Benutzerkonto ist deaktiviert.');
await pool.query('UPDATE pulse_users SET last_login_at = NOW() WHERE id = ?', [user.id]);
// Token im Fragment: landet weder im Server-Log noch im Referer.
res.redirect(`/#sso_token=${encodeURIComponent(signToken(user))}`);
} catch (err) {
console.error('[Pulse] SSO-Anmeldung fehlgeschlagen:', err.message);
back('Anmeldung über Microsoft fehlgeschlagen: ' + err.message);
}
});
export default router;
+161
View File
@@ -0,0 +1,161 @@
import express from 'express';
import { pool } from '../db.js';
import { getSettings } from '../lib/settings.js';
import { loadProjects } from '../lib/projects.js';
import { loadEntries } from '../lib/entries.js';
import { addDays, today } from '../lib/dates.js';
const router = express.Router();
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
function normalize(body, creating) {
const errors = [];
const out = {};
if (creating || body.name !== undefined) {
out.name = str(body.name, 190);
if (!out.name) errors.push('Kundenname fehlt.');
}
if (creating || body.short_name !== undefined) {
out.short_name = str(body.short_name, 16)?.toUpperCase().replace(/[^A-Z0-9ÄÖÜ_-]/g, '') || null;
if (!out.short_name) errors.push('Kürzel fehlt (z. B. KUKA) - es steht im Projektcode.');
}
for (const [key, max] of [
['contact_person', 160],
['email', 190],
['phone', 60],
['street', 190],
['zip', 20],
['city', 120],
['country', 80],
['notes', 5000],
]) {
if (body[key] !== undefined) out[key] = str(body[key], max);
}
if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null;
if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0;
return { out, errors };
}
router.get('/', async (req, res, next) => {
try {
const [rows] = await pool.query(
`SELECT c.*, (SELECT COUNT(*) FROM pulse_projects p WHERE p.customer_id = c.id) AS project_count
FROM pulse_customers c ORDER BY c.name`
);
res.json(rows.map((r) => ({ ...r, is_active: Boolean(r.is_active), project_count: Number(r.project_count) })));
} catch (err) {
next(err);
}
});
/**
* Uebersicht: welche Kunden haben welche Projekte mit welchen Mitarbeitern,
* wie viel Umsatz ist erbracht und wie viel noch offen - plus die letzten 12
* Monate Umsatz fuer die Verlaufslinie.
*/
router.get('/overview', async (req, res, next) => {
try {
const settings = await getSettings();
const [customers] = await pool.query('SELECT * FROM pulse_customers ORDER BY name');
const projects = await loadProjects(settings);
const from = `${addDays(today(), -365).slice(0, 7)}-01`;
const entries = await loadEntries(settings, { from });
const months = [];
const cursor = new Date(`${from}T00:00:00Z`);
for (let i = 0; i < 13; i++) {
months.push(cursor.toISOString().slice(0, 7));
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
const result = customers.map((customer) => {
const own = projects.filter((p) => p.customer_id === customer.id);
const memberMap = new Map();
for (const project of own) for (const m of project.members) memberMap.set(m.id, m);
const monthly = months.map((month) =>
entries
.filter((e) => e.customer_id === customer.id && e.entry_date.startsWith(month))
.reduce((sum, e) => sum + e.amount + e.fixed_amount, 0)
);
return {
...customer,
is_active: Boolean(customer.is_active),
projects: own.map((p) => ({
id: p.id,
code: p.code,
name: p.name,
display_name: p.display_name,
type: p.type,
is_active: p.is_active,
used_minutes: p.used_minutes,
budget_hours: p.budget_hours,
earned: p.earned,
open: p.open,
budget_value: p.budget_value,
members: p.members,
end_date: p.end_date,
})),
members: [...memberMap.values()],
used_minutes: own.reduce((s, p) => s + p.used_minutes, 0),
earned: Math.round(own.reduce((s, p) => s + p.earned, 0) * 100) / 100,
open: Math.round(own.reduce((s, p) => s + p.open, 0) * 100) / 100,
monthly: monthly.map((v) => Math.round(v * 100) / 100),
};
});
res.json({ months, customers: result });
} catch (err) {
next(err);
}
});
router.post('/', async (req, res, next) => {
const { out, errors } = normalize(req.body || {}, true);
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
try {
const keys = Object.keys(out);
const [result] = await pool.query(
`INSERT INTO pulse_customers (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
keys.map((k) => out[k])
);
const [[row]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [result.insertId]);
res.status(201).json(row);
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Dieses Kürzel ist schon vergeben.' });
next(err);
}
});
router.put('/:id', async (req, res, next) => {
const { out, errors } = normalize(req.body || {}, false);
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
try {
const keys = Object.keys(out);
if (keys.length) {
const [result] = await pool.query(`UPDATE pulse_customers SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
...keys.map((k) => out[k]),
Number(req.params.id),
]);
if (!result.affectedRows) return res.status(404).json({ error: 'Kunde nicht gefunden.' });
}
const [[row]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [Number(req.params.id)]);
res.json(row);
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Dieses Kürzel ist schon vergeben.' });
next(err);
}
});
router.delete('/:id', async (req, res, next) => {
try {
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_projects WHERE customer_id = ?', [Number(req.params.id)]);
if (Number(n) > 0) {
return res.status(409).json({ error: `Dem Kunden sind ${n} Projekte zugeordnet. Bitte deaktivieren statt löschen.` });
}
await pool.query('DELETE FROM pulse_customers WHERE id = ?', [Number(req.params.id)]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
export default router;
+158
View File
@@ -0,0 +1,158 @@
import express from 'express';
import { pool } from '../db.js';
import { visibleUserIds, userFilter } from '../lib/access.js';
import { getSettings } from '../lib/settings.js';
import { loadEntries } from '../lib/entries.js';
import { addDays, periodLabel, periodRange, previousStart, today, workdays } from '../lib/dates.js';
const router = express.Router();
const SERIES_LENGTH = { day: 14, week: 12, month: 12, quarter: 8, year: 5 };
export function capacityMinutes(user, settings, from, to) {
const weekly = user.weekly_hours == null ? Number(settings.weekly_hours) : Number(user.weekly_hours);
return Math.round((weekly / 5) * workdays(from, to) * 60);
}
function sum(entries) {
let minutes = 0;
let billable = 0;
let amount = 0;
for (const e of entries) {
minutes += e.minutes;
if (e.billable) billable += e.minutes;
amount += e.amount;
}
return { minutes, billable_minutes: billable, non_billable_minutes: minutes - billable, amount: Math.round(amount * 100) / 100 };
}
/**
* Dashboard fuer Fuehrungsrollen. Vorgesetzte sehen ihr Team (rekursiv),
* Management und Administratoren alle.
*
* ?granularity day|week|month|quarter|year - Diagramm und Kennzahlen
* ?rank days|week|month|quarter|year - Zeitraum der Mitarbeiterliste
*/
router.get('/', async (req, res, next) => {
try {
const granularity = SERIES_LENGTH[req.query.granularity] ? req.query.granularity : 'week';
const rank = ['days', 'week', 'month', 'quarter', 'year'].includes(req.query.rank) ? req.query.rank : 'week';
const settings = await getSettings();
const userIds = await visibleUserIds(req.user);
const now = today();
// Diagramm: die letzten N Zeitraeume bis einschliesslich des aktuellen.
const buckets = [];
let start = periodRange(granularity, now).from;
for (let i = 0; i < SERIES_LENGTH[granularity]; i++) {
buckets.unshift({ ...periodRange(granularity, start), label: periodLabel(granularity, start) });
start = previousStart(granularity, start);
}
const rankRange = rank === 'days' ? { from: addDays(now, -6), to: now } : periodRange(rank, now);
const earliest = [buckets[0].from, rankRange.from].sort()[0];
const entries = await loadEntries(settings, { from: earliest, to: buckets[buckets.length - 1].to, userIds });
const series = buckets.map((b) => ({
from: b.from,
to: b.to,
label: b.label,
...sum(entries.filter((e) => e.entry_date >= b.from && e.entry_date <= b.to)),
}));
const filter = userFilter(userIds, 'id');
const [users] = await pool.query(
`SELECT id, name, color, role, weekly_hours, is_external, company, job_title FROM pulse_users
WHERE is_active = 1 AND role <> 'admin' AND ${filter.sql} ORDER BY name`,
filter.params
);
const current = buckets[buckets.length - 1];
const previous = buckets[buckets.length - 2];
// Kapazitaet nur bis heute - sonst sieht die Auslastung am Montag schlecht aus.
const capTo = current.to > now ? now : current.to;
const capacity = users.reduce((s, u) => s + capacityMinutes(u, settings, current.from, capTo), 0);
const kpis = {
current: { ...series[series.length - 1], capacity_minutes: capacity },
previous: series[series.length - 2] ?? null,
period_label: current.label,
previous_label: previous?.label ?? null,
};
const rankTo = rankRange.to > now ? now : rankRange.to;
const ranking = users.map((u) => {
const own = entries.filter((e) => e.user_id === u.id && e.entry_date >= rankRange.from && e.entry_date <= rankRange.to);
const totals = sum(own);
return {
user_id: u.id,
name: u.name,
color: u.color,
job_title: u.job_title,
is_external: Boolean(u.is_external),
...totals,
entries: own.length,
capacity_minutes: capacityMinutes(u, settings, rankRange.from, rankTo),
};
});
// Projekte im aktuellen Zeitraum - fuer die Verteilung.
const byProject = new Map();
for (const e of entries.filter((e) => e.entry_date >= current.from && e.entry_date <= current.to)) {
const item = byProject.get(e.project_id) || {
project_id: e.project_id,
name: e.project_display,
customer_color: e.customer_color,
minutes: 0,
billable_minutes: 0,
};
item.minutes += e.minutes;
if (e.billable) item.billable_minutes += e.minutes;
byProject.set(e.project_id, item);
}
const recentFilter = userFilter(userIds, 'e.user_id');
const [recentIds] = await pool.query(
`SELECT e.id, e.entry_date FROM pulse_time_entries e WHERE ${recentFilter.sql} ORDER BY e.created_at DESC, e.id DESC LIMIT 12`,
recentFilter.params
);
let recent = [];
if (recentIds.length) {
const dates = recentIds.map((r) => r.entry_date).sort();
const pool_ = await loadEntries(settings, { from: dates[0], to: dates[dates.length - 1], userIds });
const byId = new Map(pool_.map((e) => [e.id, e]));
recent = recentIds
.map((r) => byId.get(r.id))
.filter(Boolean)
.map((e) => ({
id: e.id,
user_id: e.user_id,
user_name: e.user_name,
user_color: e.user_color,
project_display: e.project_display,
customer_color: e.customer_color,
task_name: e.task_name,
note: e.note,
entry_date: e.entry_date,
minutes: e.minutes,
billable: e.billable,
created_at: e.created_at,
}));
}
res.json({
granularity,
rank,
rank_range: rankRange,
scope: userIds === null ? 'all' : 'team',
team_size: users.length,
series,
kpis,
ranking,
projects: [...byProject.values()].sort((a, b) => b.minutes - a.minutes).slice(0, 8),
recent,
});
} catch (err) {
next(err);
}
});
export default router;
+88
View File
@@ -0,0 +1,88 @@
import express from 'express';
import { pool } from '../db.js';
import { getSettings } from '../lib/settings.js';
import { ALLOWED_DOMAINS, importUsers, listDirectory, ssoConfigured, syncUsers } from '../lib/sso.js';
/*
* SSO-Verwaltung: AD-Konten ansehen und nach Pulse uebernehmen.
* Zugriff ab Rolle Management (in server.js). Die Zugangsdaten selbst bleiben
* Administratoren vorbehalten (routes/settings.js).
*/
const router = express.Router();
const IMPORT_ROLES = ['user', 'lead', 'management'];
router.get('/ad-users', async (req, res) => {
const { sso } = await getSettings();
if (!ssoConfigured(sso)) {
return res.json({ configured: false, domains: ALLOWED_DOMAINS, users: [], fetched_at: null });
}
try {
const { users, fetched_at } = await listDirectory(sso, { refresh: req.query.refresh === '1' });
const [pulseUsers] = await pool.query('SELECT id, external_id, email, role, is_active, source FROM pulse_users');
const byExternal = new Map(pulseUsers.filter((u) => u.external_id).map((u) => [u.external_id, u]));
const byEmail = new Map(pulseUsers.filter((u) => u.email).map((u) => [u.email.toLowerCase(), u]));
res.json({
configured: true,
domains: ALLOWED_DOMAINS,
fetched_at,
users: users.map((ad) => {
const linked = byExternal.get(ad.id);
const sameMail = !linked ? byEmail.get(ad.email) : null;
return {
...ad,
pulse_user_id: linked?.id ?? null,
pulse_role: linked?.role ?? null,
pulse_active: linked ? Boolean(linked.is_active) : null,
// Lokales Konto mit derselben Mail - wird beim Uebernehmen verknuepft.
local_match_id: sameMail?.id ?? null,
};
}),
});
} catch (err) {
res.status(502).json({ error: `Microsoft Graph nicht erreichbar: ${err.message}` });
}
});
router.post('/import', async (req, res, next) => {
try {
const { sso } = await getSettings();
if (!ssoConfigured(sso)) return res.status(400).json({ error: 'SSO ist noch nicht eingerichtet.' });
const ids = Array.isArray(req.body?.ad_ids) ? [...new Set(req.body.ad_ids.map(String))].slice(0, 500) : [];
const role = req.body?.role;
if (!ids.length) return res.status(400).json({ error: 'Keine Benutzer ausgewählt.' });
if (!IMPORT_ROLES.includes(role)) return res.status(400).json({ error: 'Ungültige Rolle.' });
let managerId = req.body?.manager_id ? Number(req.body.manager_id) : null;
if (managerId) {
const [[manager]] = await pool.query('SELECT id, role, is_active FROM pulse_users WHERE id = ?', [managerId]);
if (!manager || !manager.is_active || !['lead', 'management'].includes(manager.role)) {
return res.status(400).json({ error: 'Der Vorgesetzte muss ein aktiver Vorgesetzter oder Manager sein.' });
}
}
// Ein Administrator in der Auswahl darf nicht herabgestuft werden.
const [admins] = await pool.query(
`SELECT external_id FROM pulse_users WHERE role = 'admin' AND external_id IN (${ids.map(() => '?').join(',')})`,
ids
);
if (admins.length) return res.status(400).json({ error: 'Administratoren können hier nicht geändert werden.' });
const result = await importUsers(sso, ids, { role, managerId });
res.json(result);
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Benutzername oder E-Mail ist schon vergeben.' });
if (/Graph|AADSTS|HTTP/.test(err.message)) return res.status(502).json({ error: `Microsoft Graph: ${err.message}` });
next(err);
}
});
router.post('/sync', async (req, res) => {
try {
const { sso } = await getSettings();
if (!ssoConfigured(sso)) return res.status(400).json({ error: 'SSO ist noch nicht eingerichtet.' });
res.json(await syncUsers(sso));
} catch (err) {
res.status(502).json({ error: `Abgleich fehlgeschlagen: ${err.message}` });
}
});
export default router;
+174
View File
@@ -0,0 +1,174 @@
import express from 'express';
import { pool, withTransaction } from '../db.js';
import { hasRole } from '../middleware/auth.js';
import { visibleUserIds } from '../lib/access.js';
import { getSettings } from '../lib/settings.js';
import { loadEntries } from '../lib/entries.js';
import { parseDate } from '../lib/dates.js';
import { BookingError, isBillable, loadProjects, resolvePot } from '../lib/projects.js';
const router = express.Router();
function publicEntry(entry, showAmounts) {
return {
id: entry.id,
user_id: entry.user_id,
user_name: entry.user_name,
project_id: entry.project_id,
project_name: entry.project_name,
project_code: entry.project_code,
project_display: entry.project_display,
project_type: entry.project_type,
customer_id: entry.customer_id,
customer_name: entry.customer_name,
customer_color: entry.customer_color,
task_id: entry.task_id,
task_name: entry.task_name,
pot_id: entry.pot_id,
pot_label: entry.pot_label,
entry_date: entry.entry_date,
minutes: entry.minutes,
note: entry.note,
billable: entry.billable,
amount: showAmounts ? entry.amount : null,
created_at: entry.created_at,
updated_at: entry.updated_at,
};
}
router.get('/', async (req, res, next) => {
try {
const { from, to } = req.query;
if (!parseDate(from) || !parseDate(to)) return res.status(400).json({ error: 'Zeitraum von/bis fehlt.' });
let userIds = [req.user.id];
if (req.query.user_id && Number(req.query.user_id) !== req.user.id) {
const visible = await visibleUserIds(req.user);
const wanted = Number(req.query.user_id);
if (visible !== null && !visible.includes(wanted)) return res.status(403).json({ error: 'Keine Einsicht in diese Zeiten.' });
userIds = [wanted];
}
const settings = await getSettings();
const entries = await loadEntries(settings, { from, to, userIds });
res.json(entries.map((e) => publicEntry(e, hasRole(req.user, 'lead'))));
} catch (err) {
next(err);
}
});
async function validate(conn, user, body, existing) {
const projectId = Number(body.project_id ?? existing?.project_id);
const date = body.entry_date ?? existing?.entry_date;
const minutes = Number(body.minutes ?? existing?.minutes);
const taskId = body.task_id !== undefined ? (body.task_id ? Number(body.task_id) : null) : existing?.task_id ?? null;
const note = body.note !== undefined ? String(body.note || '').trim().slice(0, 1000) || null : existing?.note ?? null;
if (user.role === 'admin' && !existing) throw new BookingError('Administratoren buchen keine Zeiten.');
if (!parseDate(date)) throw new BookingError('Datum fehlt.');
if (!Number.isInteger(minutes) || minutes < 1) throw new BookingError('Bitte eine Zeit größer 0:00 eingeben.');
if (minutes > 24 * 60) throw new BookingError('Mehr als 24 Stunden an einem Tag gehen nicht.');
const [projects] = await conn.query('SELECT * FROM pulse_projects WHERE id = ? LIMIT 1', [projectId]);
const project = projects[0];
if (!project) throw new BookingError('Projekt nicht gefunden.');
if (!project.is_active) throw new BookingError('Das Projekt ist archiviert und nicht mehr buchbar.');
// Gebucht wird nur auf zugewiesene Projekte - fuer jede Rolle.
const [member] = await conn.query('SELECT 1 FROM pulse_project_members WHERE project_id = ? AND user_id = ?', [
project.id,
existing?.user_id ?? user.id,
]);
if (!member.length) throw new BookingError('Dieses Projekt ist dir nicht zugewiesen.');
let task = null;
if (taskId) {
const [tasks] = await conn.query('SELECT * FROM pulse_tasks WHERE id = ? AND project_id = ?', [taskId, project.id]);
task = tasks[0];
if (!task) throw new BookingError('Der Task gehört nicht zu diesem Projekt.');
if (!task.is_active && task.id !== existing?.task_id) throw new BookingError('Der Task ist nicht mehr aktiv.');
}
const [[day]] = await conn.query(
'SELECT COALESCE(SUM(minutes), 0) AS total FROM pulse_time_entries WHERE user_id = ? AND entry_date = ? AND id <> ?',
[existing?.user_id ?? user.id, date, existing?.id || 0]
);
if (Number(day.total) + minutes > 24 * 60) throw new BookingError('An diesem Tag wären es mehr als 24 Stunden.');
const billable = isBillable(project, task);
const pot = await resolvePot(conn, project, date, minutes, existing?.id, billable);
return { project, task, pot, date, minutes, note, billable: billable ? 1 : 0 };
}
async function respond(req, res, id, status) {
const settings = await getSettings();
const [[row]] = await pool.query('SELECT entry_date, user_id, project_id, pot_id FROM pulse_time_entries WHERE id = ?', [id]);
const entries = await loadEntries(settings, { from: row.entry_date, to: row.entry_date, userIds: [row.user_id] });
const entry = entries.find((e) => e.id === id);
// Hinweis zum Topf gleich mitliefern, damit der Dialog warnen kann.
let warning = null;
if (row.pot_id) {
const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [row.project_id] });
warning = project?.pots.find((p) => p.id === row.pot_id)?.warning ?? null;
}
res.status(status).json({ entry: publicEntry(entry, hasRole(req.user, 'lead')), warning });
}
router.post('/', async (req, res, next) => {
try {
const id = await withTransaction(async (conn) => {
const v = await validate(conn, req.user, req.body || {}, null);
const [result] = await conn.query(
`INSERT INTO pulse_time_entries (user_id, project_id, task_id, pot_id, entry_date, minutes, note, billable)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[req.user.id, v.project.id, v.task?.id ?? null, v.pot?.id ?? null, v.date, v.minutes, v.note, v.billable]
);
return result.insertId;
});
await respond(req, res, id, 201);
} catch (err) {
if (err instanceof BookingError) return res.status(422).json({ error: err.message });
next(err);
}
});
async function loadOwned(req) {
const [rows] = await pool.query('SELECT * FROM pulse_time_entries WHERE id = ? LIMIT 1', [Number(req.params.id)]);
const entry = rows[0];
if (!entry) return { error: [404, 'Buchung nicht gefunden.'] };
// Eigene Buchungen; Administratoren duerfen korrigieren.
if (entry.user_id !== req.user.id && req.user.role !== 'admin') {
return { error: [403, 'Nur eigene Buchungen können geändert werden.'] };
}
return { entry };
}
router.put('/:id', async (req, res, next) => {
try {
const { entry, error } = await loadOwned(req);
if (error) return res.status(error[0]).json({ error: error[1] });
await withTransaction(async (conn) => {
const v = await validate(conn, req.user, req.body || {}, entry);
await conn.query(
`UPDATE pulse_time_entries SET project_id = ?, task_id = ?, pot_id = ?, entry_date = ?, minutes = ?, note = ?, billable = ?
WHERE id = ?`,
[v.project.id, v.task?.id ?? null, v.pot?.id ?? null, v.date, v.minutes, v.note, v.billable, entry.id]
);
});
await respond(req, res, entry.id, 200);
} catch (err) {
if (err instanceof BookingError) return res.status(422).json({ error: err.message });
next(err);
}
});
router.delete('/:id', async (req, res, next) => {
try {
const { entry, error } = await loadOwned(req);
if (error) return res.status(error[0]).json({ error: error[1] });
await pool.query('DELETE FROM pulse_time_entries WHERE id = ?', [entry.id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
export default router;
+259
View File
@@ -0,0 +1,259 @@
import express from 'express';
import { pool } from '../db.js';
import { hasRole } from '../middleware/auth.js';
import { visibleUserIds } from '../lib/access.js';
import { getSettings } from '../lib/settings.js';
import { loadEntries } from '../lib/entries.js';
import { MONTHS_DE, formatDateDe, monthRange, parseDate } from '../lib/dates.js';
import { dateDe, durationDe, hoursDe, moneyDe, sendDocument } from '../export/render.js';
import { buildTimeReport } from './reports.js';
const router = express.Router();
const FORMATS = ['pdf', 'xlsx', 'csv'];
const WEEKDAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
/**
* Stundennachweis eines Monats - die Vorlage fuer Kunden und Rechnungsstellung.
*
* ?month=2026-09 &format=pdf|xlsx|csv
* ?user_id=<id>|all eigener Nachweis (Standard), eines Teammitglieds oder aller sichtbaren
* ?customer_id / ?project_id auf einen Kunden bzw. ein Projekt einschraenken
* ?amounts=1 Satz und Betrag mit ausgeben (ab Rolle Vorgesetzter)
*/
router.get('/timesheet', async (req, res, next) => {
try {
const format = FORMATS.includes(req.query.format) ? req.query.format : 'pdf';
const range = monthRange(String(req.query.month || ''));
if (!range) return res.status(400).json({ error: 'Monat fehlt (JJJJ-MM).' });
let userIds = [req.user.id];
const wanted = req.query.user_id;
if (wanted && String(wanted) !== String(req.user.id)) {
const visible = await visibleUserIds(req.user);
if (wanted === 'all') {
if (!hasRole(req.user, 'lead')) return res.status(403).json({ error: 'Keine Einsicht in fremde Zeiten.' });
userIds = visible;
} else {
if (visible !== null && !visible.includes(Number(wanted))) {
return res.status(403).json({ error: 'Keine Einsicht in diese Zeiten.' });
}
userIds = [Number(wanted)];
}
}
const showAmounts = req.query.amounts === '1' && hasRole(req.user, 'lead');
const settings = await getSettings();
const entries = await loadEntries(settings, {
from: range.from,
to: range.to,
userIds,
customerId: req.query.customer_id,
projectId: req.query.project_id,
});
const [y, m] = req.query.month.split('-');
const monthName = `${MONTHS_DE[Number(m) - 1]} ${y}`;
const multiUser = userIds === null || userIds.length > 1;
let personName = 'Alle Mitarbeiter';
let company = '';
if (!multiUser) {
const [[person]] = await pool.query('SELECT name, company, is_external FROM pulse_users WHERE id = ?', [userIds[0]]);
personName = person?.name || '';
company = person?.is_external ? person.company || '' : settings.company_name;
}
let customerName = 'Alle Kunden';
let projectName = [...new Set(entries.map((e) => e.project_display))].join(', ') || '–';
if (req.query.customer_id) {
const [[c]] = await pool.query('SELECT name FROM pulse_customers WHERE id = ?', [Number(req.query.customer_id)]);
customerName = c?.name || '–';
} else {
const names = [...new Set(entries.map((e) => e.customer_name))];
if (names.length === 1) customerName = names[0];
}
if (projectName.length > 120) projectName = `${new Set(entries.map((e) => e.project_id)).size} Projekte`;
const total = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
const days = new Set(entries.map((e) => e.entry_date)).size;
const amount = entries.reduce((s, e) => s + e.amount, 0);
const columns = [
{ key: 'entry_date', label: 'Datum', type: 'date', width: 58, xlsWidth: 12 },
{ key: 'weekday', label: 'Tag', width: 26, xlsWidth: 6 },
...(multiUser ? [{ key: 'user_name', label: 'Mitarbeiter', width: 90, xlsWidth: 22 }] : []),
{ key: 'project_display', label: 'Projekt', width: 110, xlsWidth: 28 },
{ key: 'task_name', label: 'Task', width: 80, xlsWidth: 20 },
{ key: 'note', label: 'Tätigkeit', type: 'text', xlsWidth: 44 },
{ key: 'duration', label: 'Dauer', align: 'right', width: 38, xlsWidth: 9 },
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 46, xlsWidth: 10 },
...(showAmounts
? [
{ key: 'rate', label: 'Satz/h', type: 'money', align: 'right', width: 56, xlsWidth: 12 },
{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 66, xlsWidth: 14 },
]
: []),
];
const rows = entries.map((e) => ({
...e,
weekday: WEEKDAYS[new Date(`${e.entry_date}T00:00:00Z`).getUTCDay()],
duration: durationDe(e.minutes),
rate: e.amount && e.minutes ? Math.round((e.amount / (e.minutes / 60)) * 100) / 100 : null,
}));
// Zusammenfassung je Projekt und Task.
const summary = new Map();
for (const e of entries) {
const key = `${e.project_id}|${e.task_name || ''}`;
const item = summary.get(key) || { project_display: e.project_display, task_name: e.task_name || '–', minutes: 0, amount: 0 };
item.minutes += e.minutes;
item.amount += e.amount;
summary.set(key, item);
}
const doc = {
title: settings.export.title || 'Stundennachweis',
subtitle: monthName,
filename: `Stundennachweis ${y}-${m} ${multiUser ? customerName : personName}`,
landscape: showAmounts || multiUser,
info: [
[multiUser ? 'Mitarbeiter' : 'Berater', personName + (company && !multiUser ? ` (${company})` : '')],
['Kunde', customerName],
['Zeitraum', `${formatDateDe(range.from)} – ${formatDateDe(range.to)}`],
['Projekt', projectName],
],
kpis: [
['Gesamtstunden', hoursDe(total)],
['Dauer (h:mm)', durationDe(total)],
['Tage mit Leistung', String(days)],
...(showAmounts ? [['Betrag', moneyDe(amount)]] : [['davon abrechenbar', hoursDe(billable)]]),
],
tables: [
{
columns,
rows,
totals: { entry_date: null, weekday: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) },
},
{
title: 'Zusammenfassung nach Projekt und Task',
columns: [
{ key: 'project_display', label: 'Projekt', xlsWidth: 28 },
{ key: 'task_name', label: 'Task', width: 150 },
{ key: 'duration', label: 'Dauer', align: 'right', width: 60 },
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 60 },
...(showAmounts ? [{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 80 }] : []),
],
rows: [...summary.values()].map((s) => ({ ...s, duration: durationDe(s.minutes) })),
totals: { project_display: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) },
},
],
signature: true,
};
// CSV: nur die Buchungen - die Zusammenfassung stoert beim Weiterverarbeiten.
if (format === 'csv') doc.tables = [doc.tables[0]];
await sendDocument(res, doc, format, settings);
} catch (err) {
next(err);
}
});
const GROUPS = {
users: { title: 'Mitarbeiter', label: 'Mitarbeiter' },
projects: { title: 'Projekte', label: 'Projekt' },
clients: { title: 'Kunden', label: 'Kunde' },
tasks: { title: 'Tasks', label: 'Task' },
};
/** Export des Zeitreports in der gewaehlten Aufteilung oder als Detailliste. */
router.get('/report', async (req, res, next) => {
try {
if (!hasRole(req.user, 'lead')) return res.status(403).json({ error: 'Keine Berechtigung.' });
const format = FORMATS.includes(req.query.format) ? req.query.format : 'pdf';
if (!parseDate(req.query.from) || !parseDate(req.query.to)) return res.status(400).json({ error: 'Zeitraum fehlt.' });
const settings = await getSettings();
const report = await buildTimeReport(req.user, req.query);
const s = report.summary;
const groupKey = GROUPS[req.query.group] ? req.query.group : req.query.group === 'detail' ? 'detail' : 'users';
let customerName = null;
if (req.query.customer_id) {
const [[c]] = await pool.query('SELECT name FROM pulse_customers WHERE id = ?', [Number(req.query.customer_id)]);
customerName = c?.name;
}
let table;
if (groupKey === 'detail') {
table = {
columns: [
{ key: 'entry_date', label: 'Datum', type: 'date', width: 58, xlsWidth: 12 },
{ key: 'user_name', label: 'Mitarbeiter', width: 90, xlsWidth: 22 },
{ key: 'project_display', label: 'Projekt', width: 120, xlsWidth: 28 },
{ key: 'task_name', label: 'Task', width: 80, xlsWidth: 20 },
{ key: 'note', label: 'Tätigkeit', type: 'text', xlsWidth: 40 },
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 48, xlsWidth: 10 },
{ key: 'billable_label', label: 'Abr.', width: 30, xlsWidth: 6 },
{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 66, xlsWidth: 14 },
],
rows: report.entries.map((e) => ({
...e,
billable_label: e.billable ? 'ja' : 'nein',
amount: e.amount + (report.include_fixed ? e.fixed_amount : 0),
})),
totals: { user_name: 'Summe', minutes: s.minutes, amount: s.amount },
};
} else {
const rows = report[groupKey].map((row) => ({
...row,
billable_percent: row.minutes ? Math.round((row.billable_minutes / row.minutes) * 100) : 0,
}));
table = {
columns: [
{ key: 'name', label: GROUPS[groupKey].label, xlsWidth: 36 },
...(groupKey === 'projects' ? [{ key: 'customer_name', label: 'Kunde', width: 110, xlsWidth: 24 }] : []),
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 60, xlsWidth: 12 },
...(groupKey === 'users' ? [{ key: 'utilization', label: 'Auslastung', type: 'percent', align: 'right', width: 60, xlsWidth: 12 }] : []),
{ key: 'billable_minutes', label: 'Abrechenbar', type: 'hours', align: 'right', width: 66, xlsWidth: 13 },
{ key: 'billable_percent', label: 'Anteil', type: 'percent', align: 'right', width: 46, xlsWidth: 9 },
{ key: 'non_billable_minutes', label: 'Nicht abr.', type: 'hours', align: 'right', width: 60, xlsWidth: 12 },
{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 80, xlsWidth: 15 },
],
rows,
totals: {
name: 'Summe',
minutes: s.minutes,
billable_minutes: s.billable_minutes,
non_billable_minutes: s.non_billable_minutes,
amount: s.amount,
},
};
}
const title = groupKey === 'detail' ? 'Zeitreport – Detail' : `Zeitreport – ${GROUPS[groupKey].title}`;
await sendDocument(
res,
{
title,
subtitle: `${dateDe(report.range.from)} – ${dateDe(report.range.to)}`,
filename: `Zeitreport ${report.range.from} bis ${report.range.to}${customerName ? ` ${customerName}` : ''}`,
landscape: true,
sheetName: 'Zeitreport',
info: [
['Kunde', customerName || 'Alle Kunden'],
['Festpreisprojekte', report.include_fixed ? 'im Betrag enthalten' : 'nicht im Betrag enthalten'],
],
kpis: [
['Gesamtstunden', hoursDe(s.minutes)],
['Abrechenbar', `${hoursDe(s.billable_minutes)} (${s.minutes ? Math.round((s.billable_minutes / s.minutes) * 100) : 0} %)`],
['Nicht abrechenbar', hoursDe(s.non_billable_minutes)],
['Abrechenbarer Betrag', moneyDe(s.amount)],
],
tables: [table],
},
format,
settings
);
} catch (err) {
next(err);
}
});
export default router;
+385
View File
@@ -0,0 +1,385 @@
import express from 'express';
import { pool, withTransaction } from '../db.js';
import { hasRole, requireRole } from '../middleware/auth.js';
import { getSettings } from '../lib/settings.js';
import { parseDate } from '../lib/dates.js';
import {
deriveBudgetHours,
generateCode,
loadProjects,
reassignEntries,
stripAmounts,
} from '../lib/projects.js';
const router = express.Router();
const TYPES = ['billable', 'non_billable', 'fixed'];
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
const num = (v) => (v === '' || v == null ? null : Number(v));
function validatePot(input, project, settings) {
const errors = [];
const pot = {
label: str(input.label, 160),
valid_from: input.valid_from,
valid_to: input.valid_to,
budget_amount: num(input.budget_amount),
rate: num(input.rate),
notes: str(input.notes, 500),
};
if (!pot.label) errors.push('Budgettopf: Bezeichnung fehlt.');
if (!parseDate(pot.valid_from) || !parseDate(pot.valid_to)) errors.push(`Budgettopf „${pot.label || ''}“: Laufzeit von/bis fehlt.`);
else if (pot.valid_from > pot.valid_to) errors.push(`Budgettopf „${pot.label}“: Laufzeit endet vor dem Beginn.`);
for (const key of ['budget_amount', 'rate']) {
if (pot[key] != null && !(Number.isFinite(pot[key]) && pot[key] >= 0)) errors.push(`Budgettopf „${pot.label}“: ungültiger Betrag.`);
}
if (project.type === 'billable') {
if (!pot.rate) errors.push(`Budgettopf „${pot.label}“: Stundensatz/Tagessatz fehlt.`);
if (pot.budget_amount == null && num(input.budget_hours) == null) {
errors.push(`Budgettopf „${pot.label}“: Budget in Euro oder Stunden angeben.`);
}
}
if (project.type === 'fixed' && !pot.budget_amount) errors.push(`Budgettopf „${pot.label}“: Festpreis fehlt.`);
if (project.type === 'non_billable') pot.rate = null;
pot.budget_hours = deriveBudgetHours({ ...input, budget_amount: pot.budget_amount, rate: pot.rate }, project, settings);
if (pot.budget_hours != null && !(pot.budget_hours > 0)) errors.push(`Budgettopf „${pot.label}“: Volumen muss größer 0 sein.`);
return { pot, errors };
}
function validateProject(body, { creating }) {
const errors = [];
const out = {};
if (creating || body.customer_id !== undefined) {
out.customer_id = Number(body.customer_id) || null;
if (!out.customer_id) errors.push('Kunde fehlt.');
}
if (creating || body.name !== undefined) {
out.name = str(body.name, 190);
if (!out.name) errors.push('Projektname fehlt.');
}
if (creating || body.type !== undefined) {
out.type = body.type || 'billable';
if (!TYPES.includes(out.type)) errors.push('Unbekannter Projekttyp.');
}
if (creating || body.billing_unit !== undefined) out.billing_unit = body.billing_unit === 'day' ? 'day' : 'hour';
if (creating || body.start_date !== undefined) {
out.start_date = body.start_date;
if (!parseDate(out.start_date)) errors.push('Startdatum fehlt.');
}
if (body.end_date !== undefined) {
out.end_date = body.end_date || null;
if (out.end_date && !parseDate(out.end_date)) errors.push('Enddatum ist ungültig.');
if (out.end_date && out.start_date && out.end_date < out.start_date) errors.push('Enddatum liegt vor dem Startdatum.');
}
if (body.tags !== undefined) {
const tags = Array.isArray(body.tags) ? body.tags : String(body.tags || '').split(',');
out.tags = JSON.stringify([...new Set(tags.map((t) => String(t).trim()).filter(Boolean))].slice(0, 20));
}
if (body.notes !== undefined) out.notes = str(body.notes, 5000);
if (body.owner_id !== undefined) out.owner_id = Number(body.owner_id) || null;
if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0;
return { out, errors };
}
async function findProject(id) {
const [rows] = await pool.query('SELECT * FROM pulse_projects WHERE id = ? LIMIT 1', [id]);
return rows[0] || null;
}
async function respondProject(req, res, id, status = 200) {
const settings = await getSettings();
const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [id] });
res.status(status).json(hasRole(req.user, 'lead') ? project : stripAmounts(project));
}
async function setMembers(conn, projectId, userIds) {
let ids = [...new Set((userIds || []).map(Number).filter(Boolean))];
// Administratoren buchen keine Zeiten und werden deshalb nie zugewiesen.
if (ids.length) {
const [admins] = await conn.query(
`SELECT id FROM pulse_users WHERE role = 'admin' AND id IN (${ids.map(() => '?').join(',')})`,
ids
);
const blocked = new Set(admins.map((a) => a.id));
ids = ids.filter((id) => !blocked.has(id));
}
await conn.query('DELETE FROM pulse_project_members WHERE project_id = ?', [projectId]);
if (ids.length) {
await conn.query('INSERT INTO pulse_project_members (project_id, user_id) VALUES ?', [ids.map((id) => [projectId, id])]);
}
}
/** Abrechenbar-Merker aller Buchungen nach Aenderung von Projekttyp oder Task. */
async function refreshBillable(conn, projectId) {
await conn.query(
`UPDATE pulse_time_entries e
JOIN pulse_projects p ON p.id = e.project_id
LEFT JOIN pulse_tasks t ON t.id = e.task_id
SET e.billable = CASE WHEN p.type = 'non_billable' THEN 0 WHEN t.id IS NULL THEN 1 ELSE t.billable END
WHERE e.project_id = ?`,
[projectId]
);
}
// Liste. Mitarbeiter sehen nur zugewiesene Projekte, ohne Betraege.
// ?scope=mine liefert auch fuer Fuehrungsrollen nur die eigenen (Buchungsdialog).
router.get('/', async (req, res, next) => {
try {
const settings = await getSettings();
const mineOnly = req.user.role === 'user' || req.query.scope === 'mine';
const projects = await loadProjects(
settings,
mineOnly
? { where: 'p.id IN (SELECT project_id FROM pulse_project_members WHERE user_id = ?)', params: [req.user.id] }
: {}
);
res.json(hasRole(req.user, 'lead') ? projects : projects.map(stripAmounts));
} catch (err) {
next(err);
}
});
router.get('/:id', async (req, res, next) => {
try {
const id = Number(req.params.id);
if (req.user.role === 'user') {
const [rows] = await pool.query('SELECT 1 FROM pulse_project_members WHERE project_id = ? AND user_id = ?', [id, req.user.id]);
if (!rows.length) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
}
if (!(await findProject(id))) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
await respondProject(req, res, id);
} catch (err) {
next(err);
}
});
router.post('/', requireRole('lead'), async (req, res, next) => {
try {
const settings = await getSettings();
const body = req.body || {};
const { out, errors } = validateProject(body, { creating: true });
const pots = [];
const potInputs = Array.isArray(body.pots) ? body.pots : [];
if (potInputs.length === 0) errors.push('Mindestens ein Budgettopf ist erforderlich.');
for (const input of potInputs) {
const result = validatePot(input, out, settings);
errors.push(...result.errors);
pots.push(result.pot);
}
const [customers] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [out.customer_id || 0]);
if (out.customer_id && !customers[0]) errors.push('Kunde nicht gefunden.');
if (errors.length) return res.status(400).json({ error: [...new Set(errors)].join(' ') });
const id = await withTransaction(async (conn) => {
const { code, seq } = await generateCode(conn, settings, customers[0], out.start_date);
const fields = { ...out, code, seq, created_by: req.user.id, owner_id: out.owner_id ?? (req.user.role === 'admin' ? null : req.user.id) };
const keys = Object.keys(fields);
const [result] = await conn.query(
`INSERT INTO pulse_projects (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
keys.map((k) => fields[k])
);
const projectId = result.insertId;
for (const pot of pots) {
await conn.query(
`INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[projectId, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes]
);
}
const tasks = (Array.isArray(body.tasks) ? body.tasks : []).filter((t) => str(t?.name, 160));
for (const task of tasks) {
await conn.query('INSERT INTO pulse_tasks (project_id, name, billable) VALUES (?, ?, ?)', [
projectId,
str(task.name, 160),
out.type === 'non_billable' ? 0 : task.billable === false ? 0 : 1,
]);
}
await setMembers(conn, projectId, body.member_ids);
return projectId;
});
await respondProject(req, res, id, 201);
} catch (err) {
next(err);
}
});
router.put('/:id', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const body = req.body || {};
const { out, errors } = validateProject(
{ ...body, start_date: body.start_date ?? project.start_date },
{ creating: false }
);
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
await withTransaction(async (conn) => {
const keys = Object.keys(out);
if (keys.length) {
await conn.query(`UPDATE pulse_projects SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
...keys.map((k) => out[k]),
project.id,
]);
}
if (Array.isArray(body.member_ids)) await setMembers(conn, project.id, body.member_ids);
if (out.type && out.type !== project.type) {
if (out.type === 'non_billable') await conn.query('UPDATE pulse_budget_pots SET rate = NULL WHERE project_id = ?', [project.id]);
await refreshBillable(conn, project.id);
await reassignEntries(conn, project.id);
}
});
await respondProject(req, res, project.id);
} catch (err) {
next(err);
}
});
router.delete('/:id', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE project_id = ?', [project.id]);
if (Number(n) > 0) {
return res.status(409).json({ error: `Auf dem Projekt sind ${n} Buchungen. Bitte archivieren statt löschen.` });
}
await pool.query('DELETE FROM pulse_projects WHERE id = ?', [project.id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
router.put('/:id/members', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
await withTransaction((conn) => setMembers(conn, project.id, req.body?.user_ids));
await respondProject(req, res, project.id);
} catch (err) {
next(err);
}
});
/* ------------------------------------------------------ Budgettoepfe -- */
router.post('/:id/pots', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const { pot, errors } = validatePot(req.body || {}, project, await getSettings());
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
await withTransaction(async (conn) => {
await conn.query(
`INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[project.id, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes]
);
await reassignEntries(conn, project.id);
});
await respondProject(req, res, project.id, 201);
} catch (err) {
next(err);
}
});
router.put('/:id/pots/:potId', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const [existing] = await pool.query('SELECT * FROM pulse_budget_pots WHERE id = ? AND project_id = ?', [
Number(req.params.potId),
project.id,
]);
if (!existing[0]) return res.status(404).json({ error: 'Budgettopf nicht gefunden.' });
const { pot, errors } = validatePot({ ...existing[0], ...req.body }, project, await getSettings());
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
await withTransaction(async (conn) => {
await conn.query(
`UPDATE pulse_budget_pots SET label = ?, valid_from = ?, valid_to = ?, budget_amount = ?, rate = ?, budget_hours = ?, notes = ?
WHERE id = ?`,
[pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes, existing[0].id]
);
await reassignEntries(conn, project.id);
});
await respondProject(req, res, project.id);
} catch (err) {
next(err);
}
});
router.delete('/:id/pots/:potId', requireRole('lead'), async (req, res, next) => {
try {
const projectId = Number(req.params.id);
const potId = Number(req.params.potId);
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE pot_id = ?', [potId]);
if (Number(n) > 0) {
return res.status(409).json({ error: `Auf dem Topf sind ${n} Buchungen - er kann nicht gelöscht werden.` });
}
const [[{ pots }]] = await pool.query('SELECT COUNT(*) AS pots FROM pulse_budget_pots WHERE project_id = ?', [projectId]);
if (Number(pots) <= 1) return res.status(400).json({ error: 'Ein Projekt braucht mindestens einen Budgettopf.' });
await pool.query('DELETE FROM pulse_budget_pots WHERE id = ? AND project_id = ?', [potId, projectId]);
await respondProject(req, res, projectId);
} catch (err) {
next(err);
}
});
/* ------------------------------------------------------------- Tasks -- */
router.post('/:id/tasks', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const name = str(req.body?.name, 160);
if (!name) return res.status(400).json({ error: 'Taskname fehlt.' });
await pool.query('INSERT INTO pulse_tasks (project_id, name, billable) VALUES (?, ?, ?)', [
project.id,
name,
project.type === 'non_billable' ? 0 : req.body?.billable === false ? 0 : 1,
]);
await respondProject(req, res, project.id, 201);
} catch (err) {
next(err);
}
});
router.put('/:id/tasks/:taskId', requireRole('lead'), async (req, res, next) => {
try {
const projectId = Number(req.params.id);
const [rows] = await pool.query('SELECT * FROM pulse_tasks WHERE id = ? AND project_id = ?', [Number(req.params.taskId), projectId]);
if (!rows[0]) return res.status(404).json({ error: 'Task nicht gefunden.' });
const name = req.body?.name !== undefined ? str(req.body.name, 160) : rows[0].name;
if (!name) return res.status(400).json({ error: 'Taskname fehlt.' });
const billable = req.body?.billable !== undefined ? (req.body.billable ? 1 : 0) : rows[0].billable;
const active = req.body?.is_active !== undefined ? (req.body.is_active ? 1 : 0) : rows[0].is_active;
await withTransaction(async (conn) => {
await conn.query('UPDATE pulse_tasks SET name = ?, billable = ?, is_active = ? WHERE id = ?', [name, billable, active, rows[0].id]);
if (billable !== rows[0].billable) {
await refreshBillable(conn, projectId);
await reassignEntries(conn, projectId);
}
});
await respondProject(req, res, projectId);
} catch (err) {
next(err);
}
});
router.delete('/:id/tasks/:taskId', requireRole('lead'), async (req, res, next) => {
try {
const projectId = Number(req.params.id);
const taskId = Number(req.params.taskId);
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE task_id = ?', [taskId]);
if (Number(n) > 0) {
await pool.query('UPDATE pulse_tasks SET is_active = 0 WHERE id = ? AND project_id = ?', [taskId, projectId]);
} else {
await pool.query('DELETE FROM pulse_tasks WHERE id = ? AND project_id = ?', [taskId, projectId]);
}
await respondProject(req, res, projectId);
} catch (err) {
next(err);
}
});
export default router;
+234
View File
@@ -0,0 +1,234 @@
import express from 'express';
import { pool } from '../db.js';
import { teamOf, userFilter, visibleUserIds } from '../lib/access.js';
import { getSettings } from '../lib/settings.js';
import { loadEntries } from '../lib/entries.js';
import { loadProjects } from '../lib/projects.js';
import { addDays, mondayOf, parseDate, today, isoWeek } from '../lib/dates.js';
import { capacityMinutes } from './dashboard.js';
const router = express.Router();
const r2 = (n) => Math.round(n * 100) / 100;
/** Welche Benutzer fliessen ein: Sicht des Anfragenden, optional auf ein Team verengt. */
export async function reportUserIds(viewer, teamLeadId) {
const visible = await visibleUserIds(viewer);
if (!teamLeadId) return visible;
const team = [Number(teamLeadId), ...(await teamOf(Number(teamLeadId)))];
return visible === null ? team : team.filter((id) => visible.includes(id));
}
function group(entries, keyFn, labelFn, includeFixed) {
const map = new Map();
for (const e of entries) {
const key = keyFn(e);
const item = map.get(key) || { key, ...labelFn(e), minutes: 0, billable_minutes: 0, amount: 0 };
item.minutes += e.minutes;
if (e.billable) item.billable_minutes += e.minutes;
item.amount += e.amount + (includeFixed ? e.fixed_amount : 0);
map.set(key, item);
}
return [...map.values()]
.map((i) => ({ ...i, amount: r2(i.amount), non_billable_minutes: i.minutes - i.billable_minutes }))
.sort((a, b) => b.minutes - a.minutes);
}
/** Zeitreport wie in Harvest: Summen plus Aufteilung nach Kunde, Projekt, Task, Mitarbeiter. */
export async function buildTimeReport(viewer, query) {
const settings = await getSettings();
const from = query.from;
const to = query.to;
const includeFixed = query.include_fixed === '1' || query.include_fixed === 'true';
const userIds = await reportUserIds(viewer, query.team);
const entries = await loadEntries(settings, {
from,
to,
userIds,
customerId: query.customer_id,
projectId: query.project_id,
});
const filter = userFilter(userIds, 'id');
const [users] = await pool.query(
`SELECT id, name, color, weekly_hours, is_external, company, is_active FROM pulse_users WHERE role <> 'admin' AND ${filter.sql} ORDER BY name`,
filter.params
);
const capTo = to > today() ? today() : to;
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
const amount = entries.reduce((s, e) => s + e.amount, 0);
const fixedAmount = entries.reduce((s, e) => s + e.fixed_amount, 0);
const userRows = group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color, is_external: e.is_external }), includeFixed);
// Mitarbeiter ohne Buchung trotzdem zeigen (nur aktive, nicht bei Kunden-/Projektfilter).
if (!query.customer_id && !query.project_id) {
for (const u of users) {
if (u.is_active && !userRows.some((row) => row.user_id === u.id)) {
userRows.push({ key: u.id, user_id: u.id, name: u.name, color: u.color, is_external: Boolean(u.is_external), minutes: 0, billable_minutes: 0, non_billable_minutes: 0, amount: 0 });
}
}
}
const byId = new Map(users.map((u) => [u.id, u]));
for (const row of userRows) {
const u = byId.get(row.user_id);
row.capacity_minutes = u && from <= capTo ? capacityMinutes(u, settings, from, capTo) : 0;
row.utilization = row.capacity_minutes ? Math.round((row.minutes / row.capacity_minutes) * 100) : null;
}
return {
range: { from, to },
include_fixed: includeFixed,
summary: {
minutes,
billable_minutes: billable,
non_billable_minutes: minutes - billable,
amount: r2(amount + (includeFixed ? fixedAmount : 0)),
amount_without_fixed: r2(amount),
fixed_amount: r2(fixedAmount),
entries: entries.length,
},
clients: group(entries, (e) => e.customer_id, (e) => ({ customer_id: e.customer_id, name: e.customer_name, color: e.customer_color }), includeFixed),
projects: group(
entries,
(e) => e.project_id,
(e) => ({ project_id: e.project_id, name: e.project_display, code: e.project_code, type: e.project_type, customer_name: e.customer_name, color: e.customer_color }),
includeFixed
),
tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed),
users: userRows.sort((a, b) => a.name.localeCompare(b.name, 'de')),
entries,
};
}
router.get('/time', async (req, res, next) => {
try {
if (!parseDate(req.query.from) || !parseDate(req.query.to)) return res.status(400).json({ error: 'Zeitraum fehlt.' });
const report = await buildTimeReport(req.user, req.query);
delete report.entries;
res.json(report);
} catch (err) {
next(err);
}
});
/** Projektreport (Harvest-Projektseite): Verlauf, Budget, Tasks, Team, Toepfe. */
router.get('/project/:id', async (req, res, next) => {
try {
const settings = await getSettings();
const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [Number(req.params.id)] });
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const userIds = await visibleUserIds(req.user);
const entries = await loadEntries(settings, { projectId: project.id, userIds, from: req.query.from, to: req.query.to });
res.json(buildProjectDetail(project, entries));
} catch (err) {
next(err);
}
});
function buildProjectDetail(project, entries) {
const includeFixed = project.type === 'fixed';
// Wochenreihe vom Projektstart bis max(Ende, heute) - hoechstens 104 Wochen.
const end = [project.end_date || today(), today(), ...project.pots.map((p) => p.valid_to)].sort().pop();
const lastMonday = mondayOf(end);
let start = [mondayOf(project.start_date), addDays(lastMonday, -103 * 7)].sort().pop();
const weeks = [];
while (start <= lastMonday) {
weeks.push(start);
start = addDays(start, 7);
}
let cumulative = entries.filter((e) => e.entry_date < weeks[0]).reduce((s, e) => s + e.minutes, 0);
const weekly = weeks.map((monday) => {
const sunday = addDays(monday, 6);
const own = entries.filter((e) => e.entry_date >= monday && e.entry_date <= sunday);
const minutes = own.reduce((s, e) => s + e.minutes, 0);
const billable = own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
cumulative += minutes;
return {
week_start: monday,
label: `KW ${isoWeek(monday).week}`,
minutes,
billable_minutes: billable,
cumulative_minutes: monday <= today() ? cumulative : null,
};
});
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
return {
project,
totals: {
minutes,
billable_minutes: billable,
non_billable_minutes: minutes - billable,
amount: r2(entries.reduce((s, e) => s + e.amount + e.fixed_amount, 0)),
},
weekly,
tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed),
team: group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), includeFixed),
pots: project.pots.map((pot) => {
const own = entries.filter((e) => e.pot_id === pot.id);
return {
...pot,
tasks: group(own, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed),
team: group(own, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), includeFixed),
};
}),
};
}
/** Kundenreport: alle Projekte eines Kunden mit Budget, Verlauf und Team. */
router.get('/customer/:id', async (req, res, next) => {
try {
const settings = await getSettings();
const [[customer]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [Number(req.params.id)]);
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden.' });
const projects = await loadProjects(settings, { where: 'p.customer_id = ?', params: [customer.id] });
const userIds = await visibleUserIds(req.user);
const entries = await loadEntries(settings, { customerId: customer.id, userIds, from: req.query.from, to: req.query.to });
// Monatsreihe ueber die ganze Kundenbeziehung, hoechstens 24 Monate.
const months = [...new Set(entries.map((e) => e.entry_date.slice(0, 7)))].sort();
const first = months[0] || today().slice(0, 7);
const series = [];
const cursor = new Date(`${first}-01T00:00:00Z`);
const last = today().slice(0, 7);
while (cursor.toISOString().slice(0, 7) <= last && series.length < 36) {
const month = cursor.toISOString().slice(0, 7);
const own = entries.filter((e) => e.entry_date.startsWith(month));
series.push({
month,
minutes: own.reduce((s, e) => s + e.minutes, 0),
billable_minutes: own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0),
amount: r2(own.reduce((s, e) => s + e.amount + e.fixed_amount, 0)),
});
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
res.json({
customer,
totals: {
minutes,
billable_minutes: billable,
non_billable_minutes: minutes - billable,
amount: r2(entries.reduce((s, e) => s + e.amount + e.fixed_amount, 0)),
earned: r2(projects.reduce((s, p) => s + p.earned, 0)),
open: r2(projects.reduce((s, p) => s + p.open, 0)),
budget_value: r2(projects.reduce((s, p) => s + p.budget_value, 0)),
},
series: series.slice(-24),
projects: projects.map((p) => {
const own = entries.filter((e) => e.project_id === p.id);
return { ...buildProjectDetail(p, own), weekly: undefined };
}),
team: group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), true),
tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), true),
});
} catch (err) {
next(err);
}
});
export default router;
+182
View File
@@ -0,0 +1,182 @@
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 { 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();
res.json({ ...s, sso: { ...s.sso, client_secret: '', client_secret_set: Boolean(s.sso.client_secret), configured: ssoConfigured(s.sso) } });
} 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.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);
}
});
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;
+229
View File
@@ -0,0 +1,229 @@
import express from 'express';
import bcrypt from 'bcryptjs';
import { pool } from '../db.js';
import { ROLES, USER_COLUMNS, publicUser, requireRole } from '../middleware/auth.js';
import { visibleUserIds } from '../lib/access.js';
const router = express.Router();
/*
* Wer darf wen verwalten?
* admin alle
* management alle ausser Administratoren, vergibt hoechstens "management"
* lead nur Mitarbeiter (Rolle user) im eigenen Team, vergibt nur "user"
*/
function assignableRoles(actor) {
if (actor.role === 'admin') return ROLES;
if (actor.role === 'management') return ['user', 'lead', 'management'];
return ['user'];
}
async function canManage(actor, target) {
if (actor.role === 'admin') return true;
if (actor.role === 'management') return target.role !== 'admin';
if (actor.role === 'lead') {
if (target.role !== 'user') return false;
const team = await visibleUserIds(actor);
return team.includes(target.id);
}
return false;
}
async function loadUser(id) {
const [rows] = await pool.query(`SELECT ${USER_COLUMNS} FROM pulse_users WHERE id = ? LIMIT 1`, [id]);
return rows[0] || null;
}
/** Verhindert, dass jemand (indirekt) sein eigener Vorgesetzter wird. */
async function createsCycle(userId, managerId) {
if (!managerId || !userId) return false;
const [rows] = await pool.query('SELECT id, manager_id FROM pulse_users');
const parent = new Map(rows.map((r) => [r.id, r.manager_id]));
let current = managerId;
for (let guard = 0; current && guard < 1000; guard++) {
if (current === userId) return true;
current = parent.get(current);
}
return false;
}
async function activeAdmins(excludeId) {
const [rows] = await pool.query(
"SELECT COUNT(*) AS n FROM pulse_users WHERE role = 'admin' AND is_active = 1 AND id <> ?",
[excludeId]
);
return Number(rows[0].n);
}
function normalize(body, { creating }) {
const errors = [];
const out = {};
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
if (creating || body.name !== undefined) {
out.name = str(body.name, 160);
if (!out.name) errors.push('Name fehlt.');
}
if (creating || body.username !== undefined) {
out.username = str(body.username, 80);
if (!out.username || !/^[a-zA-Z0-9._@-]{2,80}$/.test(out.username)) errors.push('Benutzername: 2-80 Zeichen, keine Leerzeichen.');
}
if (body.email !== undefined) {
out.email = str(body.email, 190)?.toLowerCase() ?? null;
if (out.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(out.email)) errors.push('E-Mail-Adresse ist ungültig.');
}
if (body.role !== undefined || creating) {
out.role = body.role || 'user';
if (!ROLES.includes(out.role)) errors.push('Unbekannte Rolle.');
}
if (body.manager_id !== undefined) out.manager_id = body.manager_id ? Number(body.manager_id) : null;
if (body.job_title !== undefined) out.job_title = str(body.job_title, 120);
if (body.is_external !== undefined) out.is_external = body.is_external ? 1 : 0;
if (body.company !== undefined) out.company = str(body.company, 160);
if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null;
if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0;
if (body.weekly_hours !== undefined) {
out.weekly_hours = body.weekly_hours === '' || body.weekly_hours == null ? null : Number(body.weekly_hours);
if (out.weekly_hours != null && !(out.weekly_hours >= 0 && out.weekly_hours <= 80)) errors.push('Wochenstunden: 0-80.');
}
return { out, errors };
}
// Verzeichnis fuer alle: Namen, Farben und Hierarchie (ohne Kontaktdaten).
router.get('/directory', async (req, res, next) => {
try {
const [rows] = await pool.query(
'SELECT id, name, color, role, manager_id, is_external, company, job_title, is_active FROM pulse_users ORDER BY name'
);
res.json(rows.map((r) => ({ ...r, is_external: Boolean(r.is_external), is_active: Boolean(r.is_active) })));
} catch (err) {
next(err);
}
});
router.get('/', requireRole('lead'), async (req, res, next) => {
try {
const [rows] = await pool.query(
`SELECT ${USER_COLUMNS.split(',').map((c) => `u.${c.trim()}`).join(', ')},
(SELECT COUNT(*) FROM pulse_time_entries e WHERE e.user_id = u.id) AS entry_count,
(SELECT COUNT(*) FROM pulse_project_members m WHERE m.user_id = u.id) AS project_count
FROM pulse_users u ORDER BY u.name`
);
const team = await visibleUserIds(req.user);
const result = [];
for (const row of rows) {
result.push({
...publicUser(row),
entry_count: Number(row.entry_count),
project_count: Number(row.project_count),
can_manage: await canManage(req.user, row),
in_team: team === null || team.includes(row.id),
});
}
res.json({ users: result, assignable_roles: assignableRoles(req.user) });
} catch (err) {
next(err);
}
});
router.post('/', requireRole('lead'), async (req, res, next) => {
const { out, errors } = normalize(req.body || {}, { creating: true });
const password = String(req.body?.password || '');
if (password.length < 8) errors.push('Passwort: mindestens 8 Zeichen.');
if (!assignableRoles(req.user).includes(out.role)) errors.push('Diese Rolle darfst du nicht vergeben.');
if (req.user.role === 'lead') {
const team = await visibleUserIds(req.user);
if (!out.manager_id || !team.includes(out.manager_id)) out.manager_id = req.user.id;
}
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
try {
const hash = await bcrypt.hash(password, 12);
const fields = { ...out, password_hash: hash, source: 'local' };
const keys = Object.keys(fields);
const [result] = await pool.query(
`INSERT INTO pulse_users (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
keys.map((k) => fields[k])
);
res.status(201).json(publicUser(await loadUser(result.insertId)));
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Benutzername oder E-Mail ist schon vergeben.' });
next(err);
}
});
router.put('/:id', requireRole('lead'), async (req, res, next) => {
try {
const target = await loadUser(Number(req.params.id));
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden.' });
if (!(await canManage(req.user, target))) return res.status(403).json({ error: 'Diesen Benutzer darfst du nicht bearbeiten.' });
const { out, errors } = normalize(req.body || {}, { creating: false });
if (out.role && out.role !== target.role && !assignableRoles(req.user).includes(out.role)) {
errors.push('Diese Rolle darfst du nicht vergeben.');
}
// SSO-Benutzer: Stammdaten kommen aus dem AD.
if (target.source === 'sso') {
delete out.username;
delete out.email;
}
if (out.manager_id !== undefined && (await createsCycle(target.id, out.manager_id))) {
errors.push('Die Hierarchie darf keinen Kreis bilden.');
}
if (req.user.role === 'lead' && out.manager_id !== undefined) {
const team = await visibleUserIds(req.user);
if (!out.manager_id || !team.includes(out.manager_id)) errors.push('Vorgesetzter muss aus deinem Team kommen.');
}
if (target.id === req.user.id && (out.is_active === 0 || (out.role && out.role !== target.role))) {
errors.push('Die eigene Rolle und Aktivierung können nicht geändert werden.');
}
if (target.role === 'admin' && ((out.role && out.role !== 'admin') || out.is_active === 0) && (await activeAdmins(target.id)) === 0) {
errors.push('Der letzte aktive Administrator kann nicht herabgestuft oder deaktiviert werden.');
}
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
if (req.body?.password) {
if (String(req.body.password).length < 8) return res.status(400).json({ error: 'Passwort: mindestens 8 Zeichen.' });
out.password_hash = await bcrypt.hash(String(req.body.password), 12);
}
const keys = Object.keys(out);
if (keys.length) {
await pool.query(`UPDATE pulse_users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
...keys.map((k) => out[k]),
target.id,
]);
}
res.json(publicUser(await loadUser(target.id)));
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Benutzername oder E-Mail ist schon vergeben.' });
next(err);
}
});
router.delete('/:id', requireRole('lead'), async (req, res, next) => {
try {
const target = await loadUser(Number(req.params.id));
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden.' });
if (target.id === req.user.id) return res.status(400).json({ error: 'Das eigene Konto kann nicht gelöscht werden.' });
if (!(await canManage(req.user, target))) return res.status(403).json({ error: 'Diesen Benutzer darfst du nicht löschen.' });
if (target.source !== 'local') {
return res.status(400).json({ error: 'SSO-Benutzer werden über das AD verwaltet - hier nur deaktivieren.' });
}
if (target.role === 'admin' && (await activeAdmins(target.id)) === 0) {
return res.status(400).json({ error: 'Der letzte Administrator kann nicht gelöscht werden.' });
}
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE user_id = ?', [target.id]);
if (Number(n) > 0) {
return res.status(409).json({
error: `Der Benutzer hat ${n} Buchungen. Damit Reports stimmen, bitte deaktivieren statt löschen.`,
});
}
await pool.query('UPDATE pulse_users SET manager_id = ? WHERE manager_id = ?', [target.manager_id, target.id]);
await pool.query('DELETE FROM pulse_users WHERE id = ?', [target.id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
export default router;
+147
View File
@@ -0,0 +1,147 @@
-- perinco Pulse - Datenbankschema (MariaDB)
-- Wird bei jedem Start ausgefuehrt; alle Statements sind idempotent.
CREATE TABLE IF NOT EXISTS pulse_users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(80) NOT NULL,
email VARCHAR(190) NULL,
name VARCHAR(160) NOT NULL,
password_hash VARCHAR(100) NULL,
role ENUM('user','lead','management','admin') NOT NULL DEFAULT 'user',
manager_id INT UNSIGNED NULL,
job_title VARCHAR(120) NULL,
is_external TINYINT(1) NOT NULL DEFAULT 0,
company VARCHAR(160) NULL,
source ENUM('local','sso') NOT NULL DEFAULT 'local',
external_id VARCHAR(64) NULL,
weekly_hours DECIMAL(5,2) NULL,
color VARCHAR(7) NULL,
theme VARCHAR(20) NULL,
theme_mode VARCHAR(10) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_login_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_pulse_users_username (username),
UNIQUE KEY uq_pulse_users_email (email),
UNIQUE KEY uq_pulse_users_external (external_id),
KEY idx_pulse_users_manager (manager_id),
CONSTRAINT fk_pulse_users_manager FOREIGN KEY (manager_id) REFERENCES pulse_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS pulse_customers (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(190) NOT NULL,
short_name VARCHAR(16) NOT NULL,
contact_person VARCHAR(160) NULL,
email VARCHAR(190) NULL,
phone VARCHAR(60) NULL,
street VARCHAR(190) NULL,
zip VARCHAR(20) NULL,
city VARCHAR(120) NULL,
country VARCHAR(80) NULL,
notes TEXT NULL,
color VARCHAR(7) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_pulse_customers_short (short_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS pulse_projects (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
customer_id INT UNSIGNED NOT NULL,
code VARCHAR(40) NOT NULL,
seq INT UNSIGNED NOT NULL DEFAULT 0,
name VARCHAR(190) NOT NULL,
type ENUM('billable','non_billable','fixed') NOT NULL DEFAULT 'billable',
billing_unit ENUM('hour','day') NOT NULL DEFAULT 'hour',
start_date DATE NOT NULL,
end_date DATE NULL,
tags TEXT NULL,
notes TEXT NULL,
owner_id INT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_pulse_projects_code (code),
KEY idx_pulse_projects_customer (customer_id),
CONSTRAINT fk_pulse_projects_customer FOREIGN KEY (customer_id) REFERENCES pulse_customers (id) ON DELETE RESTRICT,
CONSTRAINT fk_pulse_projects_owner FOREIGN KEY (owner_id) REFERENCES pulse_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Budgettopf. rate ist immer in der Abrechnungseinheit des Projekts (Stunde
-- oder Tag). budget_hours ist das buchbare Volumen: bei Betrag + Satz
-- abgeleitet, sonst direkt eingegeben. NULL = unbegrenzt.
CREATE TABLE IF NOT EXISTS pulse_budget_pots (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
project_id INT UNSIGNED NOT NULL,
label VARCHAR(160) NOT NULL,
valid_from DATE NOT NULL,
valid_to DATE NOT NULL,
budget_amount DECIMAL(12,2) NULL,
rate DECIMAL(10,2) NULL,
budget_hours DECIMAL(10,2) NULL,
notes VARCHAR(500) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_pulse_pots_project (project_id, valid_from, valid_to),
CONSTRAINT fk_pulse_pots_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS pulse_tasks (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
project_id INT UNSIGNED NOT NULL,
name VARCHAR(160) NOT NULL,
billable TINYINT(1) NOT NULL DEFAULT 1,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_pulse_tasks_project (project_id),
CONSTRAINT fk_pulse_tasks_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS pulse_project_members (
project_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (project_id, user_id),
KEY idx_pulse_members_user (user_id),
CONSTRAINT fk_pulse_members_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE CASCADE,
CONSTRAINT fk_pulse_members_user FOREIGN KEY (user_id) REFERENCES pulse_users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS pulse_time_entries (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
project_id INT UNSIGNED NOT NULL,
task_id INT UNSIGNED NULL,
pot_id INT UNSIGNED NULL,
entry_date DATE NOT NULL,
minutes INT UNSIGNED NOT NULL,
note VARCHAR(1000) NULL,
billable TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_pulse_entries_user_date (user_id, entry_date),
KEY idx_pulse_entries_project_date (project_id, entry_date),
KEY idx_pulse_entries_pot (pot_id),
KEY idx_pulse_entries_date (entry_date),
CONSTRAINT fk_pulse_entries_user FOREIGN KEY (user_id) REFERENCES pulse_users (id) ON DELETE RESTRICT,
CONSTRAINT fk_pulse_entries_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE RESTRICT,
CONSTRAINT fk_pulse_entries_task FOREIGN KEY (task_id) REFERENCES pulse_tasks (id) ON DELETE SET NULL,
CONSTRAINT fk_pulse_entries_pot FOREIGN KEY (pot_id) REFERENCES pulse_budget_pots (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS pulse_settings (
k VARCHAR(80) NOT NULL,
v MEDIUMTEXT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (k)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+72
View File
@@ -0,0 +1,72 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import { initDatabase, pool } from './db.js';
import { requireAuth, requireRole } from './middleware/auth.js';
import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
import customerRoutes from './routes/customers.js';
import projectRoutes from './routes/projects.js';
import entryRoutes from './routes/entries.js';
import dashboardRoutes from './routes/dashboard.js';
import reportRoutes from './routes/reports.js';
import exportRoutes from './routes/exports.js';
import settingsRoutes from './routes/settings.js';
import directoryRoutes from './routes/directory.js';
const app = express();
const PORT = Number(process.env.PORT || 5010);
const HOST = process.env.HOST || '127.0.0.1';
// Apache steht davor - fuer req.ip (Login-Bremse) die echte Client-IP nehmen.
app.set('trust proxy', 'loopback');
app.disable('x-powered-by');
// Logos kommen als Data-URL.
app.use(express.json({ limit: '4mb' }));
app.use(
cors({
origin: (process.env.CORS_ORIGINS || 'http://localhost:3110,http://127.0.0.1:3110').split(','),
})
);
app.get('/api/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({ ok: true, db: 'up' });
} catch {
res.status(503).json({ ok: false, db: 'down' });
}
});
app.use('/api/auth', authRoutes);
app.use('/api/users', requireAuth, userRoutes);
app.use('/api/customers', requireAuth, requireRole('lead'), customerRoutes);
app.use('/api/projects', requireAuth, projectRoutes);
app.use('/api/entries', requireAuth, entryRoutes);
app.use('/api/dashboard', requireAuth, requireRole('lead'), dashboardRoutes);
app.use('/api/reports', requireAuth, requireRole('lead'), reportRoutes);
app.use('/api/exports', requireAuth, exportRoutes);
app.use('/api/settings', requireAuth, settingsRoutes);
app.use('/api/directory', requireAuth, requireRole('management'), directoryRoutes);
app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' }));
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
if (err?.type === 'entity.parse.failed') return res.status(400).json({ error: 'Ungültige Anfrage.' });
if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'Die Anfrage ist zu groß.' });
if (err?.code === 'ER_ROW_IS_REFERENCED_2') {
return res.status(409).json({ error: 'Der Datensatz wird noch verwendet und kann nicht gelöscht werden.' });
}
console.error('[Pulse] Unbehandelter Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.' });
});
try {
await initDatabase();
app.listen(PORT, HOST, () => console.log(`[Pulse] Backend läuft auf http://${HOST}:${PORT}`));
} catch (err) {
console.error('[Pulse] Start fehlgeschlagen:', err);
process.exit(1);
}
+49
View File
@@ -0,0 +1,49 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isEligible } from '../src/lib/sso.js';
const user = (mail, surname, extra = {}) => ({
mail,
surname,
userType: 'Member',
accountEnabled: true,
assignedLicenses: [{ skuId: 'x' }],
...extra,
});
test('Mitarbeiterkonten beider Domains werden angezeigt', () => {
assert.equal(isEligible(user('sascha.wolf@perinco.de', 'Wolf')), true);
assert.equal(isEligible(user('Sascha.Wolf@PERINCO.de', 'Wolf')), true);
assert.equal(isEligible(user('kai.schwickert@icon-experts.de', 'Schwickert')), true);
assert.equal(isEligible(user('timo.grote-aksu@perinco.de', 'Grote-Aksu')), true);
assert.equal(isEligible(user('niklas.muelln@perinco.de', 'Mülln')), true);
assert.equal(isEligible(user('rene.kleinau@perinco.de', 'Kleinau', { givenName: 'René' })), true);
// Tippfehler im Vornamen (AD: "Chrisitan") schadet nicht.
assert.equal(isEligible(user('christian.brix@perinco.de', 'Brix', { givenName: 'Chrisitan' })), true);
assert.equal(isEligible(user('merle.schwegler@perinco.de', 'Schwegler', { givenName: 'Merle Sophie' })), true);
});
test('Admin-, Dienst- und Funktionskonten werden ignoriert', () => {
assert.equal(isEligible(user('adm.sascha.wolf@perinco.de', 'Wolf')), false);
assert.equal(isEligible(user('sa.hrworks@perinco.de', 'Account', { givenName: 'Service' })), false);
assert.equal(isEligible(user('srv.pa@perinco.de', 'Flow', { givenName: 'SRV' })), false);
assert.equal(isEligible(user('srv.flow@perinco.de', 'Flow')), false);
assert.equal(isEligible(user('info@perinco.de', 'Info')), false);
assert.equal(isEligible(user('max.muster@perinco.de', undefined)), false);
});
test('Fremde Domains und falsche Formate werden ignoriert', () => {
for (const mail of ['sascha.wolf@perinco.com', 'sascha.wolf@sub.perinco.de', 'sascha.wolf2@perinco.de', 'sascha..wolf@perinco.de', '']) {
assert.equal(isEligible(user(mail, 'Wolf')), false, mail);
}
});
test('Freigegebene Postfaecher, Gaeste und Konten ohne Lizenz werden ignoriert', () => {
assert.equal(isEligible(user('philip.daum@perinco.de', 'Daum', { accountEnabled: false })), false);
assert.equal(isEligible(user('lisa.gast@perinco.de', 'Gast', { userType: 'Guest' })), false);
assert.equal(isEligible(user('sven.krueger@perinco.de', 'Krüger', { assignedLicenses: [] })), false);
});
test('Ohne mail zaehlt der UPN', () => {
assert.equal(isEligible({ userPrincipalName: 'jonas.korn@perinco.de', surname: 'Korn', accountEnabled: true, assignedLicenses: [{}] }), true);
});