Files
projektuebersicht/server.js
T
erikandClaude Opus 5.5 4c2e04e78b Kleine Verbesserungen und Teams, Outlook Integration
- Outlook: Kalender-Abo je Konto (eigene Aufgaben, Meilensteine,
  Abwesenheiten als .ics), einzurichten unter Einstellungen
- Teams: Nachricht in einen Kanal bei neuen Aufgaben, mit Link direkt
  auf die Aufgabe; einzurichten unter Admin → Teams
- Sitzungen nach Bedarf laden: beim Start nur die aktuelle und drei
  davor, im Verlauf „Weitere laden“ in Fünferschritten
- Termin aus Mail: Wahl Aufgabe/Meilenstein, Notiz auch bei
  Meilensteinen, nur aktiv mit eingerichteter KI
- Kalender zeigt die KW, auch statt der Browser-Datumsfelder
- Pfeile bei den Abwesenheiten, kleinere Server-Korrekturen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:23:02 +01:00

9344 lines
479 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Express-Server der Projektübersicht: liefert die statischen Frontend-Dateien aus
* public/ (App.html unter "/") und stellt den Frontend-Modulen unter public/js/
* eine JSON-API zum Laden und Speichern der Sitzungsdaten bereit. Die Daten liegen
* in einer SQLite-Datenbank (node:sqlite, Node >= 22.13) unter sessions/projektuebersicht.db.
*
* Das Schema ist voll relational normalisiert: statt eines grossen JSON-Dokuments
* pro Woche gibt es eigene Tabellen für Wochen-Metadaten, Wochen-Personen, Rollen,
* Anwesenheit, Projekte, LPH-Werte, Projekt-Zuordnungen, Projektpläne, TODOs,
* Meilensteine, Abhängigkeiten sowie die globalen Personen inkl. Rollen und
* Abwesenheiten. Die HTTP-API bleibt davon unberührt: Das Frontend sendet und
* erhält weiterhin komplette Wochen- und Personen-Objekte (JSON); der Server
* verteilt sie beim Speichern auf die Tabellen und setzt sie beim Laden wieder
* Zusammen (Serializer/Deserializer).
*
* Eine bestehende Datenbank aus dem alten Ein-Dokument-pro-Woche-Format wird
* automatisch auf das relationale Schema umgestellt.
*
* Standardport ist 3003, überschreibbar über die Umgebungsvariable PORT.
* Endpunkte: GET /, GET /health, GET /load-all, POST /save-week,
* POST /delete-week,
* GET /backup (SQLite-Snapshot als .db-Download; zusätzlich automatische Backups
* nach sessions/backups/ beim Serverstart), POST /import (Backup-Wiederherstellung,
* roher Binär-Body, schema-validiert).
* Ungültige Abteilungen fallen auf ELT zurück.
*/
const express = require('express');
const fs = require('fs');
const path = require('path');
const os = require('os');
const zlib = require('zlib');
const crypto = require('crypto');
const { DatabaseSync } = require('node:sqlite');
// pdfjs-dist/mammoth: reine Textauszug-Bibliotheken für den Protokoll-Import
// (POST /project-protokolle/import) — erste Abhängigkeiten außer
// express/cors, siehe CLAUDE.md "### Protokolle". pdfjs-dist statt des
// zunächst gewählten pdf-parse: dessen mitgelieferte, seit 2017
// eingefrorene pdf.js-Kopie liefert in diesem Prozess reproduzierbar
// "bad XRef entry", sobald Node's eigenes http-Modul geladen ist (bereits
// durch das bloße `require('http')` allein auslösbar, unabhängig vom
// tatsächlichen PDF-Inhalt) — in einem Express-Server also immer, da
// express selbst transitiv http lädt. Ein eigenständiges pdfjs-dist ist
// nicht betroffen.
//
// @napi-rs/canvas: zunächst bewusst draußen gehalten (siehe Git-Historie
// der .npmrc) — das Seiten-Rendering zu Bildern war hier nie gebraucht,
// solange nur TEXT aus einem PDF gezogen wurde. Für den Plan Checker
// (siehe "Plan Checker" weiter unten) ändert sich das: Maßketten und
// Plankopf sind optische Merkmale einer Zeichnung, kein Fließtext, also
// muss die Planseite als BILD an die KI gehen. Jetzt eine echte, direkte
// Abhängigkeit (nicht mehr nur pdfjs-dists optionale) — die .npmrc mit
// `omit=optional` ist deshalb entfallen, sie hätte sonst auch das für
// @napi-rs/canvas selbst nötige, plattformspezifische Binärpaket (z. B.
// @napi-rs/canvas-darwin-arm64) ausgeschlossen.
const mammoth = require('mammoth');
// undici: NICHT für fetch() selbst (das liefert Node schon global) — nur für
// die Agent-Klasse, um der KI-Erkennung gegen einen eigenen Server (siehe
// fetchMitTimeout() weiter unten) ein höheres Verbindungslimit als Node/
// undicis eigenen Vorgabewert mitzugeben, siehe dort.
const { Agent: UndiciAgent } = require('undici');
// PDF-Arbeit im Kindprozess (pdf-arbeit.js): ein Auftrag je Prozess, höchstens
// einer gleichzeitig (Warteschlange), mit Zeitlimit. Stirbt das Kind am
// Speicher, scheitert nur dieser eine Import mit verständlicher Meldung.
const { fork } = require('child_process');
const { PLAN_CHECK_KACHEL_SPALTEN, PLAN_CHECK_KACHEL_ZEILEN } = require('./pdf-arbeit.js');
const PDF_KIND_TIMEOUT_MS = 3 * 60 * 1000;
let pdfWarteschlange = Promise.resolve();
function pdfImKindprozess(art, buf){
const lauf = pdfWarteschlange.then(() => new Promise((resolve, reject) => {
const kind = fork(path.join(__dirname, 'pdf-arbeit.js'), [], {
execArgv: ['--max-old-space-size=' + (Number(process.env.PDF_KIND_HEAP_MB) || 192)],
serialization: 'advanced', stdio: ['ignore', 'inherit', 'inherit', 'ipc']
});
let fertig = false;
const ende = (fn, wert) => { if(fertig) return; fertig = true; clearTimeout(uhr); fn(wert); };
const uhr = setTimeout(() => { kind.kill('SIGKILL'); ende(reject, new Error('Das PDF konnte nicht in angemessener Zeit gelesen werden.')); }, PDF_KIND_TIMEOUT_MS);
kind.on('message', m => m && m.ok ? ende(resolve, m.ergebnis) : ende(reject, new Error((m && m.fehler) || 'PDF konnte nicht gelesen werden.')));
kind.on('exit', code => ende(reject, new Error(code === 99 || code === null
? 'Das PDF ist zu groß für den Server (Arbeitsspeicher).'
: 'PDF konnte nicht gelesen werden (Code ' + code + ').')));
kind.on('error', err => ende(reject, err));
kind.send({ art, daten: buf });
}));
pdfWarteschlange = lauf.catch(() => {});
return lauf;
}
function extrahierePdfText(buf){ return pdfImKindprozess('text', buf); }
async function renderPdfAlsBildsatz(buf){
const r = await pdfImKindprozess('bild', buf);
// IPC ("advanced") liefert Uint8Array statt Buffer zurück.
return { uebersicht: Buffer.from(r.uebersicht), kacheln: r.kacheln.map(k => Buffer.from(k)) };
}
const app = express();
// Wichtige Konstanten: Serverport (PORT-Env) und die gültigen Abteilungsordner
const PORT = process.env.PORT || 3003;
const VALID_DEPARTMENTS = new Set(['ELT','HLS','ARCH']);
// SANDBOX_WEEK_LABEL: die eine, dauerhaft reservierte "Sitzung", in der alle
// Planung-Terminpläne liegen (CLAUDE.md, "Planung — projektunabhängige
// Terminplan-Sandbox"). Kein echtes Datum, keine echte Kalenderwoche — der
// Name selbst ist der Marker, der dieses `weeks`-Label von jeder echten
// Sitzung unterscheidet. MUSS mit der gleichnamigen Konstante in
// public/js/globals.js übereinstimmen (kein gemeinsames Modulsystem zwischen
// Server und Client in diesem Projekt, siehe "Frontend architecture").
const SANDBOX_WEEK_LABEL = '__sandbox__';
// Projekt-Zuordnungs-Rollen aus ROLES in public/js/globals.js (bewusst dupliziert,
// kein Bundler vorhanden — NICHT zu verwechseln mit users.role, s. VALID_ROLES).
const VALID_DISCIPLINE_ROLES = new Set(['Planung','Projektleitung','Bauüberwachung']);
// Systemrollen eines Kontos (users.role) stehen seit „Rollen & Rechte“ in der
// Tabelle roles (siehe RECHTE weiter unten) — geprüft über rolleExistiert().
// Nicht zu verwechseln mit den Fachrollen (user_roles), die nur die
// Projektzuordnung betreffen und keine Berechtigung verleihen.
// GELOESCHTE_PROJEKTE_TAGE: so lange bleibt ein aus der Projektliste
// gelöschtes Projekt im Papierkorb (deleted_projects) und ist für
// Administratoren wiederherstellbar; danach entfernt es
// purgeGeloeschteProjekte() endgültig.
const GELOESCHTE_PROJEKTE_TAGE = 30;
// VALID_AUFTRAGSSTATUS: der Weg von der Anfrage zum abgeschlossenen Auftrag
// (projects.auftragsstatus). Die Reihenfolge ist die fachliche Abfolge und
// steht genauso in AUFTRAGSSTATUS in public/js/auftrag.js — bewusst
// dupliziert, es gibt keinen Bundler. Serverseitig geprüft wird das nicht
// wegen der Oberfläche, sondern weil /save-week der einzige Schreibweg ist:
// ein Tippfehler im Wert wäre sonst dauerhaft in der Datenbank und jede
// Auswertung ("welche Projekte sind noch Angebot?") liefe daran vorbei.
const VALID_AUFTRAGSSTATUS = new Set(['anfrage','angebot','beauftragt','abgeschlossen']);
// DEFAULT_AUFTRAGSSTATUS: Vorgabe für Bestandsdaten und für unbelegte Felder.
// 'beauftragt' und nicht 'anfrage', weil der gesamte Altbestand beauftragte
// Arbeit ist — jeder andere Anfangswert hätte alle laufenden Projekte
// rückwirkend zu Akquise erklärt.
const DEFAULT_AUFTRAGSSTATUS = 'beauftragt';
// AUFTRAGSSTATUS_LABELS: lesbare Bezeichnungen für den CSV-Export. Der
// Schlüssel selbst ('anfrage') steht in der Datenbank, in einer Tabelle für
// Menschen hat er nichts zu suchen.
const AUFTRAGSSTATUS_LABELS = {
anfrage: 'Anfrage', angebot: 'Angebot',
beauftragt: 'Beauftragt', abgeschlossen: 'Abgeschlossen'
};
// normalizeAuftragsstatus: bringt einen eingehenden Wert auf einen gültigen
// Schlüssel. Wird beim SCHREIBEN gebraucht (serializeWeek) — dort ist der Wert
// bereits von der Route geprüft, leer oder fehlend darf er aber sein (alte
// Clients, Demo-Daten) und wird dann zur Vorgabe.
function normalizeAuftragsstatus(value){
const v = String(value || '').trim();
return VALID_AUFTRAGSSTATUS.has(v) ? v : DEFAULT_AUFTRAGSSTATUS;
}
// findInvalidAuftragsstatus: sucht in einer eingehenden Woche das erste Projekt
// mit unbekanntem Auftragsstatus. Getrennt von normalizeAuftragsstatus(), weil
// der Schreibweg BEIM SPEICHERN nicht stillschweigend zurechtbiegen darf: Ein
// unbekannter Wert ist ein Fehler im Aufruf und wird mit 400 abgewiesen,
// sonst käme aus der Oberfläche "Angebot" heraus und in der Datenbank stünde
// "beauftragt". Ein fehlendes/leeres Feld ist dagegen erlaubt (alte Clients).
// Rückgabe: { nr, name, wert } oder null, wenn alles gültig ist.
function findInvalidAuftragsstatus(week){
const projects = Array.isArray(week && week.projects) ? week.projects : [];
for(const p of projects){
const raw = p && p.auftragsstatus;
if(raw === undefined || raw === null || raw === '') continue;
const v = String(raw).trim();
if(!VALID_AUFTRAGSSTATUS.has(v)){
return { nr: String((p && p.nr) || ''), name: String((p && p.name) || ''), wert: v };
}
}
return null;
}
// Globale Middleware: CORS, JSON-Body-Limit (2 MB) und statisches Serving von public/ ohne Cache für Skripte/Styles
app.use(require('cors')());
// Der JSON-Parser darf NICHT über die Routen mit rohem Binär-Body laufen:
// Der Browser schickt beim Datei-Upload den Content-Type der Datei mit, und
// bei einer .json-Datei würde express.json() den Datenstrom verbrauchen, bevor
// express.raw() ihn bekommt ("Keine Datei empfangen"). 8 MB statt 2 MB, weil
// eine Sitzung mit vielen Projekten und langen Kommentaren sonst irgendwann
// still am 413 scheitert.
const RAW_BODY_ROUTES = new Set(['/import', '/project-files', '/project-protokolle/import', '/plan-checker-pruefen', '/plan-todos-aus-protokoll/einlesen']);
const jsonBodyParser = express.json({ limit: '8mb' });
app.use((req, res, next) => {
if(req.method === 'POST' && RAW_BODY_ROUTES.has(req.path)) return next();
jsonBodyParser(req, res, next);
});
// attachUser liest bei jedem Request das Session-Cookie und setzt req.user
// (siehe Definition weiter unten im Abschnitt "Auth: Sessions, Middleware,
// Endpunkte" — als function-Deklaration ist sie im gesamten Modul gehoistet).
// Muss vor express.static laufen, damit sie garantiert für jeden Request greift.
app.use(attachUser);
// Kompression: Texte (JSON, JS, CSS, HTML, SVG) werden gzip-komprimiert
// ausgeliefert, wenn der Browser es anbietet — /load-all schrumpft so von
// rund 150 KB auf einen Bruchteil. Statische Dateien werden je Datei und
// Änderungszeit einmal komprimiert und im Speicher gehalten.
app.use(gzipJson);
const gzipPublic = gzipStatisch(path.join(__dirname, 'public'));
app.use(gzipPublic);
// Zwischenspeichern: Jede JS-/CSS-Datei wird in App.html mit ?v=N
// eingebunden und die Nummer bei jeder Änderung erhöht — eine solche
// Adresse ändert ihren Inhalt nie und darf deshalb ein Jahr im Browser
// liegen. Alles andere (App.html selbst, Dateien ohne ?v) wird bei jedem
// Aufruf nachgefragt (no-cache + ETag, im Normalfall nur ein kurzes 304).
// Vorher: no-store, also bei JEDEM Laden rund 1,4 MB neu.
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res, path) => {
if(path.endsWith('.js') || path.endsWith('.css') || path.endsWith('.html') || path.endsWith('.svg')){
const versioniert = res.req && /[?&]v=/.test(res.req.originalUrl || '');
res.set('Cache-Control', versioniert ? 'public, max-age=31536000, immutable' : 'no-cache');
}
}
}));
// gzipJson: komprimiert res.json()-Antworten ab 1 KB, sofern der Browser
// gzip annimmt. Middleware; ersetzt res.json nur für diese Anfrage.
function gzipJson(req, res, next){
if(!/\bgzip\b/.test(req.headers['accept-encoding'] || '')) return next();
const original = res.json.bind(res);
res.json = function(body){
const text = JSON.stringify(body);
if(text === undefined || text.length < 1024) return original(body);
const gz = zlib.gzipSync(text, { level: 6 });
if(!res.get('Content-Type')) res.set('Content-Type', 'application/json; charset=utf-8');
res.set('Content-Encoding', 'gzip');
res.set('Vary', 'Accept-Encoding');
return res.send(gz);
};
next();
}
// gzipStatisch: liefert JS/CSS/HTML/SVG aus wurzel komprimiert aus (GET/HEAD,
// nur wenn der Browser gzip annimmt). Sonst — und für alles andere — weiter
// an express.static. Cache: Pfad -> { mtime, gz, etag }.
function gzipStatisch(wurzel){
const TYPEN = { '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml' };
const cache = new Map();
const mw = (req, res, next) => {
if(req.method !== 'GET' && req.method !== 'HEAD') return next();
let rel;
try { rel = decodeURIComponent(req.path); } catch(e){ return next(); }
if(rel.endsWith('/')) rel += 'index.html';
const datei = path.join(wurzel, rel);
if(!datei.startsWith(wurzel + path.sep)) return next();
sende(req, res, next, datei);
};
// sende: eine Datei komprimiert ausliefern, sonst next() (auch nutzbar für
// die Seitenrouten, die App.html unter anderen Pfaden liefern).
const sende = (req, res, next, datei) => {
if(!/\bgzip\b/.test(req.headers['accept-encoding'] || '')) return next();
const typ = TYPEN[path.extname(datei).toLowerCase()];
if(!typ) return next();
let st;
try { st = fs.statSync(datei); } catch(e){ return next(); }
if(!st.isFile()) return next();
let e = cache.get(datei);
if(!e || e.mtime !== st.mtimeMs){
const gz = zlib.gzipSync(fs.readFileSync(datei), { level: 6 });
e = { mtime: st.mtimeMs, gz, etag: 'W/"gz-' + st.size.toString(16) + '-' + Math.floor(st.mtimeMs).toString(16) + '"' };
cache.set(datei, e);
}
const versioniert = /[?&]v=/.test(req.originalUrl || '');
res.set('Cache-Control', versioniert ? 'public, max-age=31536000, immutable' : 'no-cache');
res.set('Vary', 'Accept-Encoding');
res.set('ETag', e.etag);
if(req.headers['if-none-match'] === e.etag) return res.status(304).end();
res.set('Content-Type', typ);
res.set('Content-Encoding', 'gzip');
res.set('Content-Length', String(e.gz.length));
if(req.method === 'HEAD') return res.end();
res.end(e.gz);
};
mw.sende = sende;
return mw;
}
// appSenden: App.html für die Seitenrouten — komprimiert, sonst wie bisher.
function appSenden(req, res){
const datei = path.join(__dirname, 'public', 'App.html');
gzipPublic.sende(req, res, () => res.sendFile(datei), datei);
}
// Datenverzeichnis sessions/ bei Bedarf anlegen (bind-mounted in Docker, enthält die DB)
const SESSIONS_DIR = path.join(__dirname, 'sessions');
if(!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
// SQLite-Datenbank öffnen (wird angelegt, falls nicht vorhanden). Der Pfad ist
// über die Umgebungsvariable DB_PATH übersteuerbar (für Tests/Backups), sonst
// standardmäßig sessions/projektuebersicht.db.
const DB_PATH = process.env.DB_PATH ? path.resolve(process.env.DB_PATH) : path.join(SESSIONS_DIR, 'projektuebersicht.db');
const db = new DatabaseSync(DB_PATH);
db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA foreign_keys = ON;');
db.exec('PRAGMA busy_timeout = 5000;'); // 5 s warten statt SQLITE_BUSY-Fehlern bei parallelen Schreibern
// synchronous=NORMAL ist im WAL-Modus die übliche, sichere Einstellung: Die
// Datenbank bleibt bei einem Absturz konsistent, nur ein Stromausfall kann die
// allerletzten Schreibvorgänge kosten. Mit der Vorgabe FULL wartete JEDES
// Schreiben auf die SD-Karte des Pi — spürbar bei jedem Seitenaufruf.
db.exec('PRAGMA synchronous = NORMAL;');
// ---------- Schema (voll relational normalisiert) ----------
// Der serialisierte Datenbestand einer Woche besteht aus folgenden Entitäten:
// - weeks: Metadaten (id, label, date, locked)
// - week_people / week_people_roles: personen-Kopien pro Woche
// - attendance: Anwesenheit pro Woche/Person
// - projects: Projekt-Metadaten pro Woche
// - project_lph: LPH-Fortschrittswerte pro Projekt
// - project_assign: Zuordnungen (Haupt/Optional) pro Projekt/Person/Rolle
// - project_plan: Plan-Kopf (start, end, progress) pro Projekt
// - plan_people: im Plan beteiligte Personen
// - plan_todos / plan_todo_assignees / plan_todo_deps: TODO-Listen
// - plan_milestones / plan_milestone_deps: Meilensteine
// - globale Personen pro Abteilung kommen seit der Ablösung von
// people/person_roles/person_absences aus users/user_roles/user_absences
// (siehe loadGlobalPeople, migrateRetirePeopleTablesIntoUsers)
db.exec(`CREATE TABLE IF NOT EXISTS weeks (
label TEXT NOT NULL,
week_id TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL DEFAULT '',
locked INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT '',
PRIMARY KEY (label)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS week_people (
week_label TEXT NOT NULL,
person_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
full_name TEXT NOT NULL DEFAULT '',
array_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, person_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS week_people_roles (
week_label TEXT NOT NULL,
person_id TEXT NOT NULL,
role TEXT NOT NULL,
role_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, person_id, role)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS attendance (
week_label TEXT NOT NULL,
person_id TEXT NOT NULL,
present INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, person_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS projects (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
nr TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
status_author TEXT NOT NULL DEFAULT '',
status_at TEXT NOT NULL DEFAULT '',
"group" TEXT NOT NULL DEFAULT '',
array_order INTEGER NOT NULL DEFAULT 0,
order_value INTEGER NOT NULL DEFAULT 0,
-- besprochen: im Teammeeting abgehakt. Gehört zur SITZUNG, nicht zum
-- Projekt: Jede Woche wird jedes Projekt neu durchgegangen, und der
-- Haken beantwortet die Frage "hatten wir das schon?" nur für diese eine
-- Sitzung. Deshalb steht er hier und nicht an einer wochenunabhängigen
-- Stelle; beim Duplizieren einer Sitzung wird er zurückgesetzt.
besprochen INTEGER NOT NULL DEFAULT 0,
-- auftragsstatus: Wo das Projekt zwischen Anfrage und Abschluss steht
-- ('anfrage'|'angebot'|'beauftragt'|'abgeschlossen', siehe
-- VALID_AUFTRAGSSTATUS). Im Unterschied zu besprochen gehört der Wert zum
-- PROJEKT und nicht zur Sitzung: Ein Angebot bleibt ein Angebot, bis es
-- beauftragt wird, und wandert beim Duplizieren der Sitzung deshalb mit.
-- Die Gruppe "In Anbahnung" deutete das bisher nur an; sie bleibt die
-- Sortierung auf dem Board, sagt aber nichts darüber, ob schon ein Angebot
-- draußen ist.
auftragsstatus TEXT NOT NULL DEFAULT 'beauftragt',
-- updated_at: Zeitstempel der letzten Speicherung DIESES Projekts. Bis zur
-- Planung-Sandbox (CLAUDE.md, "Planung — projektunabhängige Terminplan-
-- Sandbox") gab es nur eine Revision je WOCHE (weeks.updated_at) — genug,
-- solange jede Sitzung nur wenige Projekte auf einmal hat. Die Sandbox legt
-- aber mehrere, voneinander unabhängige Terminpläne in EINER reservierten
-- Woche ab; ohne eine Zeitangabe je Projekt ließe sich "zuletzt bearbeitet"
-- in der Planungsliste nicht je Plan unterscheiden. Für echte Projekte
-- harmlos mitgeführt, aber ungenutzt.
updated_at TEXT NOT NULL DEFAULT '',
PRIMARY KEY (week_label, project_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_tags (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
tag TEXT NOT NULL,
tag_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, project_id, tag)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_lph (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
lph TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
PRIMARY KEY (week_label, project_id, lph)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_assign (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
person_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT '',
state INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, project_id, person_id, role)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_aufwand (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
lph TEXT NOT NULL,
role TEXT NOT NULL DEFAULT '',
value REAL NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, project_id, lph, role)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_aktuelle_lp (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
lph TEXT NOT NULL,
PRIMARY KEY (week_label, project_id, lph)
)`);
// project_beauftragte_lph: welche Leistungsphasen für dieses Projekt
// BEAUFTRAGT sind — nicht zu verwechseln mit project_aktuelle_lp (die eine
// gerade amtierende Phase) oder project_lph (Fortschritt in Prozent).
// Grundlage für die Honorar-Terminplanung (siehe js/plan/plan-honorar.js):
// das vereinbarte Honorar wird auf genau diese Phasen verteilt.
db.exec(`CREATE TABLE IF NOT EXISTS project_beauftragte_lph (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
lph TEXT NOT NULL,
PRIMARY KEY (week_label, project_id, lph)
)`);
// project_gewerke: welche Fachbereiche des Büros das Projekt bearbeiten
// ('ELT'|'HLS'|'ARCH', dieselben Werte wie users.department). Ein Projekt
// kann zu mehreren gehören — ein Schulanbau etwa zu ELT und HLS.
//
// Wozu: Die Auswahl der Projektbeteiligten wird darauf eingeschränkt. Ein
// reines Elektro-Projekt soll nicht die ganze Belegschaft zur Auswahl
// stellen; wer trotzdem jemanden aus einem anderen Gewerk braucht, holt ihn
// ausdrücklich dazu (siehe den Wizard in js/projekt-wizard.js).
//
// Eigene Tabelle statt einer Spalte mit Trennzeichen — gleiche Bauart wie
// project_beauftragte_lph, und damit dieselben Wege beim Serialisieren.
db.exec(`CREATE TABLE IF NOT EXISTS project_gewerke (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
gewerk TEXT NOT NULL,
PRIMARY KEY (week_label, project_id, gewerk)
)`);
// project_kommentare: Sitzungskommentar und Besprochen-Haken JE GEWERK.
// ELT, HLS und ARCH besprechen ihre Projekte in getrennten Sitzungen (Eriks
// Wunsch: "Die Kommentare und Anwesenheiten von ELT brauchen nicht in HLS
// oder Arch zu sein" / "auch wenn das Projekt in ELT besprochen ist, heißt
// das nicht, dass HLS das Projekt auch besprochen hat"). Löst die Spalten
// projects.status/status_author/status_at/besprochen ab, die nur noch für
// den Übergang (migrateKommentareJeGewerk()) gelesen und sonst leer
// geschrieben werden. Client-Form: p.kommentare = { ELT: {status,
// statusAuthor, statusAt, besprochen}, … }.
db.exec(`CREATE TABLE IF NOT EXISTS project_kommentare (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
gewerk TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '',
status_author TEXT NOT NULL DEFAULT '',
status_at TEXT NOT NULL DEFAULT '',
besprochen INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, project_id, gewerk)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_plan (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
start TEXT NOT NULL DEFAULT '',
end TEXT NOT NULL DEFAULT '',
progress INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, project_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_people (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
person_id TEXT NOT NULL,
array_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, project_id, person_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_todos (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
todo_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
start TEXT NOT NULL DEFAULT '',
end TEXT NOT NULL DEFAULT '',
done INTEGER NOT NULL DEFAULT 0,
progress INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
show_in_gantt INTEGER NOT NULL DEFAULT 1,
leistungsphase TEXT NOT NULL DEFAULT '',
aufwand_stunden REAL NOT NULL DEFAULT 0,
notiz TEXT NOT NULL DEFAULT '',
PRIMARY KEY (week_label, project_id, todo_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
todo_id TEXT NOT NULL,
person_id TEXT NOT NULL,
PRIMARY KEY (week_label, project_id, todo_id, person_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_deps (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
todo_id TEXT NOT NULL,
depends_on_id TEXT NOT NULL,
PRIMARY KEY (week_label, project_id, todo_id, depends_on_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestones (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
milestone_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL DEFAULT '',
position TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
notiz TEXT NOT NULL DEFAULT '',
PRIMARY KEY (week_label, project_id, milestone_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps (
week_label TEXT NOT NULL,
project_id TEXT NOT NULL,
milestone_id TEXT NOT NULL,
depends_on_id TEXT NOT NULL,
PRIMARY KEY (week_label, project_id, milestone_id, depends_on_id)
)`);
// people / person_roles / person_absences (globale Personen pro Abteilung) sind
// abgelöst worden (siehe migrateRetirePeopleTablesIntoUsers weiter unten) —
// werden deshalb hier bewusst NICHT mehr unconditionally angelegt. Für Alt-DBs
// im früheren Ein-Dokument-Format legt migrateLegacySchema() sie transient an,
// migrateRetirePeopleTablesIntoUsers() überführt/droppt sie danach beim selben
// Start.
// ---------- User-Management, Sessions, Datei-Freigabe (Schema, additiv) ----------
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
person_id TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_users_person_id ON users(person_id)`);
db.exec(`CREATE TABLE IF NOT EXISTS user_sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT '',
last_seen_at TEXT NOT NULL DEFAULT '',
expires_at TEXT NOT NULL DEFAULT '',
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// user_roles/user_absences: Rollen (Planung/Projektleitung/Bauüberwachung) und
// Abwesenheiten je Benutzer — lösen die alten people/person_roles/
// person_absences-Tabellen vollständig ab (loadGlobalPeople liest nur noch
// hieraus, migrateRetirePeopleTablesIntoUsers überführt/droppt die Altdaten).
db.exec(`CREATE TABLE IF NOT EXISTS user_roles (
user_id INTEGER NOT NULL, role TEXT NOT NULL, role_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, role),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// created_by: wer den Eintrag angelegt hat. Grundlage der Löschberechtigung —
// eintragen darf jeder für jeden (die Projektleitung pflegt Urlaube ihres
// Teams mit), aber entfernen nur die betroffene Person selbst, wer ihn
// eingetragen hat, oder ein Administrator. 0 = Altbestand ohne Angabe.
db.exec(`CREATE TABLE IF NOT EXISTS user_absences (
user_id INTEGER NOT NULL, id TEXT NOT NULL,
start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
created_by INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// Persönliche KI-Zugangsdaten für den Protokoll-Import (POST
// /project-protokolle/import), siehe readUserAiKey()/writeUserAiKey() und
// CLAUDE.md "### Protokolle": jedes Konto pflegt seinen eigenen Schlüssel +
// optionale abweichende API-URL (z. B. ein Firmen-Proxy statt der
// offiziellen Cloud-API) unter Einstellungen → Mein Konto — bewusst eine
// eigene, kleine User-Kind-Tabelle statt Zeilen in app_settings, das
// strukturell ein firmenweiter Singleton-Speicher ist.
db.exec(`CREATE TABLE IF NOT EXISTS user_ai_keys (
user_id INTEGER NOT NULL, provider TEXT NOT NULL,
api_key TEXT NOT NULL DEFAULT '', api_url TEXT NOT NULL DEFAULT '',
PRIMARY KEY (user_id, provider),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
// benachrichtigungen: Hinweise an ein Konto, bisher nur "neue Aufgabe
// zugewiesen" (art 'aufgabe'). Entstehen in POST /save-week, wenn eine
// Aufgabe einen Zuständigen bekommt, den sie vorher nicht hatte
// (neueZuweisungenMelden()). Gelesen = in der Glocke angeklickt oder "alle
// gelesen". Keine Kopie in IMPORT_TABLES: wie die Konten selbst kontobezogen,
// ein DB-Import lässt sie unberührt. Alte Einträge räumt
// purgeBenachrichtigungen() auf.
db.exec(`CREATE TABLE IF NOT EXISTS benachrichtigungen (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
art TEXT NOT NULL DEFAULT 'aufgabe',
titel TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL DEFAULT '',
project_nr TEXT NOT NULL DEFAULT '',
project_name TEXT NOT NULL DEFAULT '',
week_label TEXT NOT NULL DEFAULT '',
todo_id TEXT NOT NULL DEFAULT '',
faellig TEXT NOT NULL DEFAULT '',
von_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
gelesen INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_benachrichtigungen_user ON benachrichtigungen(user_id, gelesen, created_at)');
db.exec(`CREATE TABLE IF NOT EXISTS project_files (
id TEXT PRIMARY KEY,
project_nr TEXT NOT NULL,
project_name TEXT NOT NULL DEFAULT '',
original_filename TEXT NOT NULL,
stored_name TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes INTEGER NOT NULL DEFAULT 0,
uploaded_by_user_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_project_files_scope ON project_files(project_nr)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_file_reviewers (
file_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
decision TEXT NOT NULL DEFAULT 'pending',
decided_at TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
PRIMARY KEY (file_id, user_id)
)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_pfr_user_pending ON project_file_reviewers(user_id, decision)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_file_comments (
id TEXT PRIMARY KEY,
file_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT ''
)`);
// Protokolle (Besprechungsprotokolle je Projekt) — wie project_files über
// project_nr mit dem Projekt verknüpft, NICHT über week_label/project_id:
// project_id wird bei jedem automatischen Sitzungswechsel
// (ensureCurrentWeekRollover()) neu vergeben, ein Protokoll muss aber über
// Sitzungen hinweg bestehen bleiben. week_label ist hier reine Information
// (welche Sitzung lief beim Anlegen), keine Scoping-Spalte.
db.exec(`CREATE TABLE IF NOT EXISTS project_protokolle (
id TEXT PRIMARY KEY,
project_nr TEXT NOT NULL,
project_name TEXT NOT NULL DEFAULT '',
nummer INTEGER NOT NULL,
datum TEXT NOT NULL DEFAULT '',
betreff TEXT NOT NULL DEFAULT '',
ort TEXT NOT NULL DEFAULT '',
verfasser_user_id INTEGER NOT NULL,
week_label TEXT NOT NULL DEFAULT '',
quelle_dateiname TEXT NOT NULL DEFAULT '',
quelle_stored_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_protokolle_project ON project_protokolle(project_nr)`);
// protokoll_teilnehmer: Freitext-Teilnehmerliste je Protokoll (Name + Firma),
// unabhängig von Benutzerkonten — Baubesprechungen haben typischerweise
// externe Teilnehmer (Bauherr, ausführende Firmen) ohne Login hier.
db.exec(`CREATE TABLE IF NOT EXISTS protokoll_teilnehmer (
id TEXT PRIMARY KEY,
protokoll_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
firma TEXT NOT NULL DEFAULT '',
anwesend INTEGER NOT NULL DEFAULT 1,
array_order INTEGER NOT NULL DEFAULT 0
)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_protokoll_teilnehmer_protokoll ON protokoll_teilnehmer(protokoll_id)`);
// protokoll_punkte: die eigentlichen Besprechungspunkte. stamm_id verbindet
// alle fortgeschriebenen Kopien "desselben" Punkts über mehrere Protokolle
// hinweg (siehe createProtokoll()) — nur daran hängt, ob ein Punkt als neu
// gilt (neu=1), NICHT an nummer/gruppe: beides ist freier, jederzeit vom
// Nutzer änderbarer Anzeige-Text (nummer z.B. "1", "1.1", "2.3"; gruppe z.B.
// "ELT"/"HLS" — keine feste Liste, entsteht einfach durchs Eintragen).
db.exec(`CREATE TABLE IF NOT EXISTS protokoll_punkte (
id TEXT PRIMARY KEY,
protokoll_id TEXT NOT NULL,
stamm_id TEXT NOT NULL,
nummer TEXT NOT NULL DEFAULT '',
gruppe TEXT NOT NULL DEFAULT '',
neu INTEGER NOT NULL DEFAULT 0,
thema TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL DEFAULT '',
zustaendig TEXT NOT NULL DEFAULT '',
termin TEXT NOT NULL DEFAULT '',
erledigt INTEGER NOT NULL DEFAULT 0,
erstellt_am TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'offen',
array_order INTEGER NOT NULL DEFAULT 0
)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_protokoll_punkte_protokoll ON protokoll_punkte(protokoll_id)`);
// lph_templates: firmenweite Aufgaben-Vorlagen je Leistungsphase und Gewerk
// (Spalte gewerk, nachgerüstet von migrateAddLphTemplateGewerk(); '' = gilt
// für alle Gewerke, nur Altbestand). Anders als
// alle Tabellen darüber hängt diese an KEINER Sitzung und an keinem Projekt —
// sie ist globale Konfiguration ("in LPH 5 machen wir immer diese Schritte"),
// aus der auf der Projektplan-Seite per Klick TODOs erzeugt werden (siehe
// js/plan/plan-templates.js). offset_days = Start relativ zum beim Einfügen
// gewählten Datum (0 = am Starttag), duration_days = Dauer in Kalendertagen;
// 0 = ohne Termin (die Aufgabe wird ohne Start/Ende eingefügt). Die erzeugten TODOs sind danach ganz normale plan_todos und
// haben keinerlei Verbindung mehr zur Vorlage — spätere Änderungen an der
// Vorlage wirken sich also NICHT auf bereits eingefügte Aufgaben aus (bewusst:
// ein laufender Projektplan soll sich nicht rückwirkend ändern).
db.exec(`CREATE TABLE IF NOT EXISTS lph_templates (
id TEXT PRIMARY KEY,
lph TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
offset_days INTEGER NOT NULL DEFAULT 0,
duration_days INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_lph_templates_lph ON lph_templates(lph)');
// ---------- Rollen und Rechte ----------
// Eriks Wunsch: Rollen und ihre Rechte werden in der Oberfläche gepflegt
// (Admin → Rollen & Rechte, js/admin/rollen.js), neue Rollen lassen sich
// anlegen. RECHTE ist der feste Katalog — die Schlüssel stehen im Code, nur
// ihre Zuordnung zu Rollen ist frei. Fachrollen (user_roles: Planung,
// Projektleitung, Bauüberwachung) haben damit NICHTS zu tun.
//
// roles: key (= users.role), name, sort_order, fest (1 = Administrator: hat
// immer alle Rechte, ist weder änderbar noch löschbar — sonst könnte man sich
// aussperren). role_rechte: eine Zeile je Haken. Beide NICHT in
// IMPORT_TABLES: ein Backup-Import lässt Rechte wie Konten unberührt.
//
// Eine Rolle OHNE das Recht „app“ ist eine reine Wandtafel-Rolle: sie
// erreicht nur die lesenden Routen der Wandtafel (requireAuthOderKiosk) und
// zählt nirgends als Teammitglied — das frühere fest verdrahtete 'kiosk'.
const RECHTE = [
{ gruppe: 'Grundzugang', rechte: [
['app', 'Anwendung nutzen', 'Projekte, Wochenbericht, Auslastung und Meine Aufgaben sehen'],
['wand', 'Wandtafel anzeigen', ''] ]},
{ gruppe: 'Projekte', rechte: [
['proj.anlegen', 'Projekte anlegen und löschen', ''],
['proj.stamm', 'Stammdaten und Beteiligte bearbeiten', 'Projekteinstellungen'],
['proj.plan', 'Terminplan bearbeiten', 'Aufgaben, Meilensteine, Phasen, Vorlagen'],
['proj.dateien', 'Dateien hochladen, prüfen und kommentieren', ''],
['proj.dateienFremd', 'Fremde Dateien löschen', ''],
['proj.protokolle', 'Protokolle bearbeiten', 'anlegen, importieren, löschen'] ]},
{ gruppe: 'Wochenbericht', rechte: [
['wb.kommentar', 'Kommentar und Fortschritt bearbeiten', ''],
['wb.besprochen', '„Besprochen“ abhaken', ''],
['wb.anwesenheit', 'Anwesenheit erfassen', ''] ]},
{ gruppe: 'Team', rechte: [
['team.abw', 'Abwesenheiten für mich', 'eigene eintragen, ändern, löschen'],
['team.abwProjekte', 'Abwesenheiten für Personen meiner Projekte', 'Beteiligte der eigenen Projekte (aktuelle Sitzung)'],
['team.abwGewerk', 'Abwesenheiten für mein Gewerk', 'alle Konten mit demselben Gewerk'],
['team.abwAlle', 'Abwesenheiten für alle', ''] ]},
{ gruppe: 'Werkzeuge', rechte: [
['wz.planung', 'Terminplan', 'Terminpläne ohne Projekt (Seitenleiste „Terminplan“)'],
['wz.checker', 'Plan Checker', ''] ]},
{ gruppe: 'Sitzungen', rechte: [
['s.entsperren', 'Sitzung entsperren', ''],
['s.loeschen', 'Sitzung löschen', ''],
['s.gesperrt', 'Gesperrte Sitzung bearbeiten', ''] ]},
{ gruppe: 'Admin-Bereich', rechte: [
['a.benutzer', 'Benutzerverwaltung', ''], ['a.honorar', 'Honorar', ''],
['a.auslastung', 'Auslastung', ''], ['a.termine', 'Termine & Sitzungen', ''],
['a.feiertage', 'Feiertage', ''], ['a.vorlagen', 'Aufgaben-Vorlagen', ''],
['a.checker', 'Plan-Checker-Merkmale', ''], ['a.betrieb', 'Betrieb & Sicherheit', ''],
['a.papierkorb', 'Papierkorb', ''], ['a.backup', 'Datenbank-Backup', ''],
['a.import', 'Datenbank-Import', ''], ['a.rollen', 'Rollen & Rechte', ''],
['a.teams', 'Teams-Anbindung', ''] ]},
];
const RECHT_LABEL = new Map(RECHTE.flatMap(g => g.rechte.map(r => [r[0], r[1]])));
const RECHT_KEYS = new Set(RECHT_LABEL.keys());
db.exec(`CREATE TABLE IF NOT EXISTS roles (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
fest INTEGER NOT NULL DEFAULT 0
)`);
db.exec(`CREATE TABLE IF NOT EXISTS role_rechte (
role_key TEXT NOT NULL,
recht TEXT NOT NULL,
PRIMARY KEY (role_key, recht)
)`);
// Startbelegung = Stand vor dem Umbau: Projektleiter und Mitglied alles aus
// Grundzugang bis Werkzeuge (Mitglied ohne „Projekte anlegen und löschen“),
// Wandtafel nur die Wandtafel. Läuft nur, solange roles leer ist.
(function rollenStartbelegung(){
if(db.prepare('SELECT COUNT(*) AS n FROM roles').get().n > 0) return;
const basis = RECHTE.slice(0, 5).flatMap(g => g.rechte.map(r => r[0]));
const start = [
['admin', 'Administrator', 1, []],
['projektleiter', 'Projektleiter', 0, basis],
['member', 'Mitglied', 0, basis.filter(k => k !== 'proj.anlegen')],
['kiosk', 'Wandtafel', 0, ['wand']],
];
const insR = db.prepare('INSERT INTO roles (key, name, sort_order, fest) VALUES (?, ?, ?, ?)');
const insH = db.prepare('INSERT OR IGNORE INTO role_rechte (role_key, recht) VALUES (?, ?)');
db.exec('BEGIN');
try {
start.forEach(([k, n, fest, rechte], i) => { insR.run(k, n, i, fest); rechte.forEach(r => insH.run(k, r)); });
db.exec('COMMIT');
console.log('Rollen & Rechte: Startbelegung angelegt.');
} catch(e){ db.exec('ROLLBACK'); throw e; }
})();
// rollenCache: key → { key, name, sortOrder, fest, rechte:Set } — jede
// Anfrage prüft gegen diesen Speicher, neu geladen nach jeder Änderung.
let rollenCache = new Map();
function ladeRollen(){
const m = new Map();
db.prepare('SELECT * FROM roles ORDER BY sort_order, name').all().forEach(r => {
m.set(r.key, { key: r.key, name: r.name, sortOrder: r.sort_order, fest: !!r.fest, rechte: new Set() });
});
db.prepare('SELECT * FROM role_rechte').all().forEach(r => {
const x = m.get(r.role_key);
if(x && RECHT_KEYS.has(r.recht)) x.rechte.add(r.recht);
});
rollenCache = m;
}
ladeRollen();
// Umstellung der Abwesenheits-Rechte (Eriks Wunsch): „Fremde Abwesenheiten
// löschen“ (team.abwFremd) entfällt. Startbelegung wie im Entwurf: Mitglied
// nur „für mich“, Projektleiter „für mein Gewerk“ und „für Personen meiner
// Projekte“. Selbst angelegte Rollen mit team.abwFremd bekommen „für alle“
// (Mitglied und Projektleiter hatten es aus der Startbelegung, nicht bewusst).
(function abwesenheitsRechteUmstellen(){
db.exec('CREATE TABLE IF NOT EXISTS umstellungen (name TEXT PRIMARY KEY, am TEXT NOT NULL)');
if(db.prepare("SELECT 1 FROM umstellungen WHERE name = 'abwesenheit-rechte'").get()) return;
const ins = db.prepare('INSERT OR IGNORE INTO role_rechte (role_key, recht) VALUES (?, ?)');
db.exec('BEGIN');
try {
db.prepare("SELECT role_key FROM role_rechte WHERE recht = 'team.abwFremd' AND role_key NOT IN ('member', 'projektleiter')").all()
.forEach(r => { ins.run(r.role_key, 'team.abwAlle'); ins.run(r.role_key, 'team.abw'); });
if(db.prepare("SELECT 1 FROM roles WHERE key = 'projektleiter'").get()){
['team.abw', 'team.abwGewerk', 'team.abwProjekte'].forEach(r => ins.run('projektleiter', r));
}
db.exec("DELETE FROM role_rechte WHERE recht = 'team.abwFremd'");
db.prepare("INSERT INTO umstellungen (name, am) VALUES ('abwesenheit-rechte', ?)").run(new Date().toISOString());
db.exec('COMMIT');
} catch(e){ db.exec('ROLLBACK'); throw e; }
ladeRollen();
})();
// abwesenheitZiele: für wen ein Konto Abwesenheiten eintragen, ändern und
// löschen darf (und bei wem es die Art sieht). Rückgabe: { alle:Boolean,
// ids:Set<user_id> }. Das eigene Konto gehört dazu, sobald irgendein
// Abwesenheits-Recht gesetzt ist. „Meine Projekte“ = Projekte der jüngsten
// echten Sitzung, in denen beide als Beteiligte (state > 0) stehen.
function abwesenheitZiele(user){
const ids = new Set();
if(!user) return { alle: false, ids };
if(hatRecht(user, 'team.abwAlle')) return { alle: true, ids };
const gewerk = hatRecht(user, 'team.abwGewerk');
const projekte = hatRecht(user, 'team.abwProjekte');
if(hatRecht(user, 'team.abw') || gewerk || projekte) ids.add(user.id);
if(gewerk && user.department){
db.prepare('SELECT id FROM users WHERE department = ? AND active = 1').all(user.department).forEach(r => ids.add(r.id));
}
if(projekte && user.personId){
const label = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL)
.map(r => r.label).sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b))).pop();
if(label){
db.prepare(`SELECT DISTINCT u.id FROM project_assign a
JOIN project_assign b ON b.week_label = a.week_label AND b.project_id = a.project_id AND b.state > 0
JOIN users u ON u.person_id = b.person_id
WHERE a.week_label = ? AND a.person_id = ? AND a.state > 0`).all(label, user.personId).forEach(r => ids.add(r.id));
}
}
return { alle: false, ids };
}
function darfAbwesenheit(user, zielId){
const z = abwesenheitZiele(user);
return z.alle || z.ids.has(Number(zielId));
}
function rolleExistiert(key){ return rollenCache.has(String(key)); }
// rechteDerRolle: Liste der Rechte einer Rolle (fest = alle).
function rechteDerRolle(key){
const r = rollenCache.get(String(key));
if(!r) return [];
return r.fest ? [...RECHT_KEYS] : [...r.rechte];
}
// hatRecht: darf dieses Konto das? Parameter: user (req.user), recht.
function hatRecht(user, recht){
if(!user) return false;
const r = rollenCache.get(user.role);
return !!r && (r.fest || r.rechte.has(recht));
}
// istNurWandtafel: Rolle ohne Grundzugang „Anwendung nutzen“.
function istNurWandtafel(roleKey){
const r = rollenCache.get(String(roleKey));
return !r || (!r.fest && !r.rechte.has('app'));
}
// sqlOhneWandtafel: SQL-Bedingung, die Konten reiner Wandtafel-Rollen
// ausschließt (ersetzt das frühere role != 'kiosk'). Rollenschlüssel sind
// beim Anlegen auf [a-z0-9_-] beschränkt, daher sicher einzusetzen.
function sqlOhneWandtafel(spalte){
const keys = [...rollenCache.keys()].filter(k => istNurWandtafel(k)).filter(k => /^[a-z0-9_-]+$/.test(k));
const s = spalte || 'role';
return keys.length ? `${s} NOT IN (${keys.map(k => `'${k}'`).join(',')})` : '1=1';
}
// rechtFehlt: einheitliche 403-Antwort, nennt das fehlende Recht.
function rechtFehlt(res, recht){
return res.status(403).json({ ok: false, recht, error: 'Keine Berechtigung: „' + (RECHT_LABEL.get(recht) || recht) + '“ fehlt.' });
}
// requireRecht: Middleware — angemeldet UND Recht vorhanden.
function requireRecht(recht){
return function(req, res, next){
if(!req.user) return res.status(401).json({ ok: false, error: 'Nicht angemeldet.' });
if(!hatRecht(req.user, recht)) return rechtFehlt(res, recht);
next();
};
}
// plan_checker_merkmale: firmenweite Grundliste der Merkmale, gegen die der
// Plan Checker (Seitenleisten-Bereich "Studenten") einen hochgeladenen Plan
// prüft (z. B. "Maßketten vorhanden"). "prompt" ist die Prüfanweisung, die
// an die KI geht — "titel" ist zugleich die Anzeige-Bezeichnung UND der
// Schlüssel, unter dem die KI ihr Ergebnis zurückgibt. Anders als
// lph_templates gibt es hier kein Gruppierungsfeld: die Liste gilt
// unterschiedslos für jede Prüfung (plus ein optionales, NICHT
// gespeichertes Zusatz-Merkmal nur für die jeweils eine Prüfung, siehe
// POST /plan-checker-pruefen).
db.exec(`CREATE TABLE IF NOT EXISTS plan_checker_merkmale (
id TEXT PRIMARY KEY,
titel TEXT NOT NULL,
prompt TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
)`);
// lph_honorar_anteile: firmenweite Honorar-Anteile je Leistungsphase in
// Prozent (HOAI-Sätze). Bewusst NICHT als Konstante im Code, sondern
// pflegbar — die Sätze hängen vom Leistungsbild ab (Technische Ausrüstung
// nach Anlage 15 unterscheidet sich von der Objektplanung nach Anlage 10)
// und ändern sich mit HOAI-Novellen. Startet leer und wird in der
// Honorar-Maske auf der Projektplan-Seite von Administratoren befüllt.
db.exec(`CREATE TABLE IF NOT EXISTS lph_honorar_anteile (
lph TEXT PRIMARY KEY,
percent REAL NOT NULL DEFAULT 0
)`);
// app_settings: firmenweite Einzelwerte als Schlüssel/Wert-Paare. Werte
// stehen als TEXT und werden beim Lesen gewandelt; welche Schlüssel es gibt,
// welchen Typ und welche Vorgabe sie haben, steht in APP_SETTING_DEFS weiter
// unten — dort liegt auch die Prüfung. Eine Zeile existiert erst, wenn der
// Wert einmal gesetzt wurde; sonst gilt die Vorgabe. Damit brauchen neue
// Einstellungen weder eine eigene Tabelle noch eine Migration.
db.exec(`CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
)`);
// holidays: firmenweite Feiertage und sonstige arbeitsfreie Tage. Bewusst
// eine eigene Tabelle statt eines JSON-Werts in app_settings — es ist eine
// Liste, die wächst, und sie soll sich einzeln pflegen lassen. Sie wirkt
// überall dort, wo in WERKTAGEN gerechnet wird: workdaysBetween()/
// addWorkdays() in public/js/utils.js, also in der Terminplan-Berechnung aus
// dem Honorar und in der Ressourcen-Auslastung des Projektbereichs. Die
// Terminplanung über Abhängigkeiten (Auto-Schedule) rechnet weiterhin in
// Kalendertagen — das war schon vorher so.
db.exec(`CREATE TABLE IF NOT EXISTS holidays (
date TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT ''
)`);
// ---------- Migration: altes Ein-Dokument-Format -> relationales Schema ----------
// hasLegacyWeeksTable: erkennt das alte Schema an der Daten-Spalte in `weeks`.
function hasLegacyWeeksTable(){
const cols = db.prepare("SELECT name FROM pragma_table_info('weeks') WHERE name = 'data'").all();
return cols.length > 0;
}
// ---------- Serverseitige Bereinigung von Formatierungs-HTML ----------
//
// Gegenstück zu sanitizeStatusHtml() (public/js/utils.js) für den Server.
// Dieselbe Positivliste, dieselben Regeln — aber ohne DOM, deshalb ein
// eigener, kleiner Tokenizer statt eines Parsers.
//
// Warum überhaupt serverseitig, wo der Client doch schon bereinigt: Weil die
// Bereinigung im Client eine ANZEIGE-Maßnahme ist, keine Eingangskontrolle.
// Wer eine gültige Anmeldung hat, kann die Routen direkt ansprechen und
// beliebiges HTML in einen Kommentar oder Protokollpunkt schreiben. Bis
// hierher wurde das unverändert gespeichert und erst beim Anzeigen entschärft
// — jede Stelle, die das Feld künftig einmal ohne Bereinigung anfasst, wäre
// damit sofort ein Einfallstor. Die Prüfung gehört an die Schreibstelle.
//
// Bewusst keine Abhängigkeit: Das Projekt kommt mit express/cors plus den
// drei Protokoll-Import-Bibliotheken aus; ein Sanitizer-Paket dafür
// aufzunehmen stünde in keinem Verhältnis zu diesen gut hundert Zeilen.
const SICHERE_TAGS = new Set(['b','strong','i','em','u','p','br','div','span','font']);
// Tags, deren INHALT mitverschwindet, statt ausgepackt zu werden: Bei
// <script> wäre der ausgepackte Rest sonst der sichtbare Quelltext.
const TAGS_MIT_INHALT_VERWERFEN = new Set(['script','style','noscript','template','iframe','object','embed']);
// Leere Elemente haben kein schließendes Gegenstück.
const LEERE_TAGS = new Set(['br']);
// htmlEscape: maskiert Text für die Ausgabe in HTML (wie esc() im Client).
function htmlEscape(s){
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// sicheresStyleAttribut: lässt aus einem style-Attribut nur color und
// font-weight durch — identisch zur Client-Regel. Rückgabe: bereinigter
// Wert oder '' (dann wird das Attribut ganz weggelassen).
function sicheresStyleAttribut(wert){
return String(wert || '').split(';').map(s => s.trim()).filter(s => {
const prop = s.split(':')[0].trim().toLowerCase();
if(prop !== 'color' && prop !== 'font-weight') return false;
// url(), expression() und Konsorten haben in einer Farbangabe nichts zu
// suchen; ein Wert mit Klammern oder Semikolon fliegt vorsichtshalber raus.
const val = s.slice(s.indexOf(':') + 1);
return !/[()\\]/.test(val);
}).join('; ');
}
// tagLesen: liest ab Position i (wo '<' steht) ein vollständiges Tag.
// Beachtet Anführungszeichen in Attributwerten, damit ein '>' DARIN das Tag
// nicht vorzeitig beendet. Rückgabe: { name, schliessend, attribute, ende }
// oder null, wenn an dieser Stelle kein gültiges Tag steht (dann ist das '<'
// schlicht Text).
function tagLesen(html, i){
if(html[i] !== '<') return null;
// Kommentare und Deklarationen überspringen und verwerfen
if(html.startsWith('<!--', i)){
const ende = html.indexOf('-->', i + 4);
return { kommentar: true, ende: ende === -1 ? html.length : ende + 3 };
}
if(html[i + 1] === '!' || html[i + 1] === '?'){
const ende = html.indexOf('>', i);
return { kommentar: true, ende: ende === -1 ? html.length : ende + 1 };
}
let p = i + 1;
const schliessend = html[p] === '/';
if(schliessend) p++;
const nameStart = p;
while(p < html.length && /[a-zA-Z0-9]/.test(html[p])) p++;
if(p === nameStart) return null; // '<' ohne Tagnamen -> Text
const name = html.slice(nameStart, p).toLowerCase();
// Attributbereich bis zum '>' lesen, Anführungszeichen beachten
let attr = '';
let inQuote = null;
while(p < html.length){
const c = html[p];
if(inQuote){
if(c === inQuote) inQuote = null;
attr += c;
} else if(c === '"' || c === "'"){
inQuote = c; attr += c;
} else if(c === '>'){
p++; break;
} else {
attr += c;
}
p++;
}
return { name, schliessend, attribute: attr, ende: p };
}
// attributeLesen: zieht die Attribute eines Tags als Map heraus.
function attributeLesen(roh){
const map = {};
const re = /([a-zA-Z_:][-\w:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
let m;
while((m = re.exec(roh))){
map[m[1].toLowerCase()] = m[3] !== undefined ? m[3] : (m[4] !== undefined ? m[4] : m[5]);
}
return map;
}
// sanitizeFormatHtml: die eigentliche Bereinigung. Erlaubte Tags bleiben
// (mit höchstens einem gefilterten style-Attribut), verworfene Tags nehmen
// ihren Inhalt mit, alle übrigen werden ausgepackt (Inhalt bleibt, Tag fällt
// weg). Offen gebliebene Tags werden am Ende geschlossen, sodass das
// Ergebnis immer wohlgeformt ist. Parameter: html (String).
// Rückgabe: bereinigter HTML-String.
function sanitizeFormatHtml(html){
if(html === null || html === undefined) return '';
const quelle = String(html);
let aus = '';
const offen = []; // Stapel offener, erlaubter Tags
let verwerfenBis = null; // Name des Tags, dessen Inhalt verworfen wird
let verwerfenTiefe = 0;
let i = 0;
while(i < quelle.length){
const c = quelle[i];
if(c !== '<'){
const naechstes = quelle.indexOf('<', i);
const text = quelle.slice(i, naechstes === -1 ? quelle.length : naechstes);
if(!verwerfenBis) aus += htmlEscape(text);
i += text.length;
continue;
}
const tag = tagLesen(quelle, i);
if(!tag){ // '<' ist hier blosser Text
if(!verwerfenBis) aus += '&lt;';
i++;
continue;
}
i = tag.ende;
if(tag.kommentar) continue;
if(verwerfenBis){
// Innerhalb eines verworfenen Blocks zählt nur, wann er wieder endet.
if(tag.name === verwerfenBis){
if(tag.schliessend){ verwerfenTiefe--; if(verwerfenTiefe <= 0) verwerfenBis = null; }
else verwerfenTiefe++;
}
continue;
}
if(TAGS_MIT_INHALT_VERWERFEN.has(tag.name)){
if(!tag.schliessend){ verwerfenBis = tag.name; verwerfenTiefe = 1; }
continue;
}
if(!SICHERE_TAGS.has(tag.name)) continue; // auspacken: Tag weg, Inhalt bleibt
if(tag.schliessend){
// Nur schliessen, wenn das Tag auch offen ist — sonst ignorieren.
const pos = offen.lastIndexOf(tag.name);
if(pos !== -1){
// Zwischendrin offen gebliebene Tags mitschliessen (korrekte Schachtelung).
for(let k = offen.length - 1; k >= pos; k--) aus += '</' + offen[k] + '>';
offen.length = pos;
}
continue;
}
const attrs = attributeLesen(tag.attribute);
const style = sicheresStyleAttribut(attrs.style);
aus += '<' + tag.name + (style ? ' style="' + htmlEscape(style) + '"' : '') + '>';
if(!LEERE_TAGS.has(tag.name)) offen.push(tag.name);
}
for(let k = offen.length - 1; k >= 0; k--) aus += '</' + offen[k] + '>';
return aus;
}
// serializeWeek: zerlegt ein Wochen-Objekt (JSON vom Frontend) in die relationalen
// Tabellen. Wird in einer Transaktion vom Aufrufer verwendet (deleteWeekData +
// serializeWeek). Ersetzt alle Zeilen der Woche.
function serializeWeek(week){
const weekLabel = String(week.label || '');
const nowIso = new Date().toISOString(); // Revision: wird bei jedem Speichern aktualisiert
const upsertWeek = db.prepare(`INSERT INTO weeks (label, week_id, date, locked, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(label) DO UPDATE SET week_id = excluded.week_id, date = excluded.date, locked = excluded.locked, updated_at = excluded.updated_at`);
upsertWeek.run(weekLabel, String(week.id || ''), String(week.date || ''), week.locked ? 1 : 0, nowIso);
const insWeekPerson = db.prepare(`INSERT INTO week_people (week_label, person_id, name, full_name, array_order)
VALUES (?, ?, ?, ?, ?)`);
const insWeekPersonRole = db.prepare(`INSERT OR IGNORE INTO week_people_roles (week_label, person_id, role, role_order)
VALUES (?, ?, ?, ?)`);
const insAttendance = db.prepare(`INSERT OR REPLACE INTO attendance (week_label, person_id, present)
VALUES (?, ?, ?)`);
// Sicherheitsnetz gegen doppelte Personen-Kennungen im Teilnehmerkreis:
// ein zweiter Eintrag mit derselben id würde den Primärschlüssel verletzen
// und die gesamte Transaktion (= das Speichern der Sitzung) abbrechen.
const seenPersonIds = new Set();
(Array.isArray(week.people) ? week.people : []).filter(p => {
const pid = String((p && p.id) || '');
if(!pid || seenPersonIds.has(pid)) return false;
seenPersonIds.add(pid);
return true;
}).forEach((p, idx) => {
insWeekPerson.run(weekLabel, String(p.id || ''), String(p.name || ''), String(p.fullName || ''), idx);
(Array.isArray(p.role) ? p.role : []).forEach((r, ridx) => insWeekPersonRole.run(weekLabel, String(p.id || ''), String(r || ''), ridx));
});
const att = week.attendance || {};
Object.keys(att).forEach(pid => {
insAttendance.run(weekLabel, pid, att[pid] ? 1 : 0);
});
(Array.isArray(week.projects) ? week.projects : []).forEach((p, idx) => serializeProject(weekLabel, p, idx, nowIso));
// Rückgabe der Revision für den Aufrufer (saveWeekRelational -> Route)
return nowIso;
}
// projektStatements: die vorbereiteten INSERTs für ein Projekt, einmal angelegt.
let projektStatementsCache = null;
function projektStatements(){
if(projektStatementsCache) return projektStatementsCache;
const insProject = db.prepare(`INSERT INTO projects (week_label, project_id, nr, name, status, status_author, status_at, "group", array_order, order_value, project_uid, honorar, besprochen, auftragsstatus, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const insBeauftragteLp = db.prepare(`INSERT OR IGNORE INTO project_beauftragte_lph (week_label, project_id, lph)
VALUES (?, ?, ?)`);
const insGewerk = db.prepare(`INSERT OR IGNORE INTO project_gewerke (week_label, project_id, gewerk)
VALUES (?, ?, ?)`);
const insKommentar = db.prepare(`INSERT OR REPLACE INTO project_kommentare (week_label, project_id, gewerk, status, status_author, status_at, besprochen)
VALUES (?, ?, ?, ?, ?, ?, ?)`);
const insTag = db.prepare(`INSERT OR IGNORE INTO project_tags (week_label, project_id, tag, tag_order)
VALUES (?, ?, ?, ?)`);
const insLph = db.prepare(`INSERT OR REPLACE INTO project_lph (week_label, project_id, lph, value)
VALUES (?, ?, ?, ?)`);
const insAssign = db.prepare(`INSERT OR REPLACE INTO project_assign (week_label, project_id, person_id, role, state)
VALUES (?, ?, ?, ?, ?)`);
const insAufwand = db.prepare(`INSERT OR REPLACE INTO project_aufwand (week_label, project_id, lph, role, value)
VALUES (?, ?, ?, ?, ?)`);
const insAktuelleLp = db.prepare(`INSERT OR IGNORE INTO project_aktuelle_lp (week_label, project_id, lph)
VALUES (?, ?, ?)`);
const insPlan = db.prepare(`INSERT OR REPLACE INTO project_plan (week_label, project_id, start, end, progress)
VALUES (?, ?, ?, ?, ?)`);
const insPlanPerson = db.prepare(`INSERT OR IGNORE INTO plan_people (week_label, project_id, person_id, array_order)
VALUES (?, ?, ?, ?)`);
const insTodo = db.prepare(`INSERT INTO plan_todos (week_label, project_id, todo_id, title, start, end, done, progress, sort_order, show_in_gantt, leistungsphase, aufwand_stunden, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const insTodoAssignee = db.prepare(`INSERT OR IGNORE INTO plan_todo_assignees (week_label, project_id, todo_id, person_id)
VALUES (?, ?, ?, ?)`);
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id)
VALUES (?, ?, ?, ?)`);
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id)
VALUES (?, ?, ?, ?)`);
projektStatementsCache = { insProject, insBeauftragteLp, insGewerk, insKommentar, insTag, insLph, insAssign, insAufwand, insAktuelleLp, insPlan, insPlanPerson, insTodo, insTodoAssignee, insTodoDep, insMilestone, insMilestoneDep };
return projektStatementsCache;
}
// serializeProject: schreibt EIN Projekt einer Sitzung in alle Projekttabellen
// (die Zeilen müssen vorher fehlen — deleteWeekData() bzw. deleteProjectData()).
// Gemeinsam genutzt von serializeWeek() und POST /save-projects.
// Parameter: weekLabel, p (Projekt), idx (Position, array_order), nowIso
// (Revision des Projekts, projects.updated_at).
function serializeProject(weekLabel, p, idx, nowIso){
const { insProject, insBeauftragteLp, insGewerk, insKommentar, insTag, insLph, insAssign, insAufwand, insAktuelleLp, insPlan, insPlanPerson, insTodo, insTodoAssignee, insTodoDep, insMilestone, insMilestoneDep } = projektStatements();
const projId = String(p.id || '');
// status trägt Formatierungs-HTML (Fett/Kursiv/Farbe aus dem
// Kommentar-Editor) und wird hier — an der Schreibstelle — auf die
// erlaubte Positivliste reduziert, nicht erst beim Anzeigen. Siehe
// sanitizeFormatHtml() oben.
// status/status_author/status_at/besprochen bleiben leer: Kommentar und
// Haken stehen je Gewerk in project_kommentare (siehe dort).
insProject.run(weekLabel, projId, String(p.nr || ''), String(p.name || ''),
'', '', '',
String(p.group || ''), idx, typeof p.order === 'number' ? p.order : idx,
String(p.projectUid || ''), Number(p.honorar) || 0, 0,
normalizeAuftragsstatus(p.auftragsstatus), nowIso);
(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []).forEach(lph => {
insBeauftragteLp.run(weekLabel, projId, String(lph || ''));
});
// Nur gültige Gewerke schreiben: Ein unbekannter Wert wäre stiller
// Datenmüll, der die Beteiligten-Auswahl später leer laufen ließe.
(Array.isArray(p.gewerke) ? p.gewerke : []).forEach(g => {
const wert = String(g || '').trim().toUpperCase();
if(VALID_DEPARTMENTS.has(wert)) insGewerk.run(weekLabel, projId, wert);
});
(Array.isArray(p.tags) ? p.tags : []).forEach((tag, tidx) => {
const clean = String(tag || '').trim();
if(clean) insTag.run(weekLabel, projId, clean, tidx);
});
const kommentare = (p.kommentare && typeof p.kommentare === 'object') ? p.kommentare : {};
Object.keys(kommentare).forEach(g => {
const wert = String(g || '').trim().toUpperCase();
const k = kommentare[g] || {};
const text = sanitizeFormatHtml(k.status);
// Leere Einträge nicht speichern — kein Kommentar und kein Haken ist
// derselbe Zustand wie keine Zeile.
if(!VALID_DEPARTMENTS.has(wert) || (!text && !k.besprochen)) return;
insKommentar.run(weekLabel, projId, wert, text,
text ? String(k.statusAuthor || '') : '', text ? String(k.statusAt || '') : '', k.besprochen ? 1 : 0);
});
const lph = p.lph || {};
Object.keys(lph).forEach(l => insLph.run(weekLabel, projId, String(l || ''), String(lph[l] === undefined ? '' : lph[l])));
const assign = p.assign || {};
Object.keys(assign).forEach(key => {
// Key-Format: "personId|Rolle"
const sep = key.indexOf('|');
const pid = sep >= 0 ? key.slice(0, sep) : key;
const role = sep >= 0 ? key.slice(sep + 1) : '';
insAssign.run(weekLabel, projId, pid, role, Number(assign[key]) || 0);
});
const aufwand = p.aufwandProLP || {};
Object.keys(aufwand).forEach(lph => {
const roles = aufwand[lph] || {};
Object.keys(roles).forEach(role => {
const v = Number(roles[role]);
if(!isNaN(v)) insAufwand.run(weekLabel, projId, String(lph || ''), String(role || ''), v);
});
});
(Array.isArray(p.aktuelleLP) ? p.aktuelleLP : []).forEach(lph => {
insAktuelleLp.run(weekLabel, projId, String(lph || ''));
});
const plan = p.plan || null;
if(plan){
insPlan.run(weekLabel, projId, String(plan.start || ''), String(plan.end || ''), Number(plan.progress) || 0);
(Array.isArray(plan.people) ? plan.people : []).forEach((pid, pidx) => insPlanPerson.run(weekLabel, projId, String(pid || ''), pidx));
(Array.isArray(plan.todos) ? plan.todos : []).forEach((t, tidx) => {
insTodo.run(weekLabel, projId, String(t.id || ''), String(t.title || ''),
String(t.start || ''), String(t.end || ''), t.done ? 1 : 0, Number(t.progress) || 0, tidx,
t.showInGantt === false ? 0 : 1, String(t.lph || ''),
Math.max(0, Number(t.aufwand) || 0), String(t.notiz || '').slice(0, 20000));
(Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => insTodoAssignee.run(weekLabel, projId, String(t.id || ''), String(pid || '')));
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || '')));
});
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => {
insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx, String(m.notiz || '').slice(0, 20000));
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || '')));
});
}
}
// deleteProjectData: entfernt alle Zeilen EINES Projekts einer Sitzung.
function deleteProjectData(weekLabel, projectId){
['project_tags','project_lph','project_assign','project_aufwand','project_aktuelle_lp',
'project_beauftragte_lph','project_gewerke','project_kommentare','project_plan','plan_people',
'plan_todos','plan_todo_assignees','plan_todo_deps','plan_milestones','plan_milestone_deps','projects']
.forEach(t => db.prepare(`DELETE FROM ${t} WHERE week_label = ? AND project_id = ?`).run(weekLabel, projectId));
}
// deleteWeekData: entfernt alle relationalen Zeilen einer Woche (Label)
function deleteWeekData(weekLabel){
const tables = ['week_people_roles','week_people','attendance','project_tags','project_lph','project_assign',
'project_aufwand','project_aktuelle_lp','project_beauftragte_lph','project_gewerke','project_kommentare',
'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps',
'plan_milestones','plan_milestone_deps','projects'];
tables.forEach(t => {
db.prepare(`DELETE FROM ${t} WHERE week_label = ?`).run(weekLabel);
});
// Die Wochen-Tabelle selbst nutzt `label` statt `week_label`
db.prepare('DELETE FROM weeks WHERE label = ?').run(weekLabel);
}
// saveWeekRelational: speichert eine Woche vollständig relational (Transaktion).
// Rückgabe: die neu gesetzte Revision `updated_at` (ISO-Zeitstempel), damit der
// Client seine lokale Basis aktualisieren kann (sonst würde er beim nächsten
// Speichern einen falschen Konflikt mit sich selbst melden).
function saveWeekRelational(week){
const weekLabel = String(week.label || '');
const tx = db.exec('BEGIN');
try {
deleteWeekData(weekLabel);
const updatedAt = serializeWeek(week);
db.exec('COMMIT');
return updatedAt;
} catch(err){
db.exec('ROLLBACK');
throw err;
}
}
// loadGlobalPeople: setzt die (jetzt firmenweite, nicht mehr abteilungs-
// gescopte) Personenliste aus den Tabellen users/user_roles/user_absences
// zusammen. Rückgabeform unverändert: Array von
// {id,name,fullName,email,role:[],absences:[]}.
// fuerUser (optional): Art und Notiz einer Abwesenheit sieht nur die Person
// selbst und der Administrator (feste Rolle 'admin'); für alle anderen kommen
// fremde Abwesenheiten als „abwesend“ ohne Notiz an — unabhängig von weiteren
// Rechten (Eriks Vorgabe: „jeder User sieht nur bei sich die genauen Gründe“,
// „der Admin sieht alles“). darfAbw sagt
// dem Browser, für wen er eintragen darf. Ohne fuerUser (Sitzungswechsel
// u. ä., nie an einen Browser) ungefiltert.
function loadGlobalPeople(fuerUser){
// person_id != '' : Ein Konto ohne Personen-Kennung (z. B. das Bootstrap-
// Konto "admin") ist keine planbare Person. Ohne diesen Filter landen
// mehrere solcher Konten mit derselben leeren Kennung im Teilnehmerkreis
// einer Sitzung und verletzen dort den Primärschlüssel
// (week_label, person_id) — das Speichern schlüge dauerhaft mit 500 fehl.
const users = db.prepare(`SELECT * FROM users WHERE active = 1 AND person_id != '' AND ${sqlOhneWandtafel()} ORDER BY display_name, username`).all();
const roles = db.prepare(`SELECT * FROM user_roles ORDER BY role_order`).all();
const absences = db.prepare(`SELECT * FROM user_absences`).all();
const rolesByUser = {}; roles.forEach(r => (rolesByUser[r.user_id] = rolesByUser[r.user_id] || []).push(r.role));
const ziele = fuerUser ? abwesenheitZiele(fuerUser) : null;
const sichtbar = id => !fuerUser || fuerUser.role === 'admin' || id === fuerUser.id;
const absByUser = {}; absences.forEach(a => (absByUser[a.user_id] = absByUser[a.user_id] || []).push(sichtbar(a.user_id)
? { id:a.id, start:a.start, end:a.end, type:a.type, note:a.note }
: { id:a.id, start:a.start, end:a.end, type:'abwesend', note:'', verdeckt:true }));
return users.map(u => ({
id: u.person_id,
userId: u.id,
darfAbw: !!ziele && (ziele.alle || ziele.ids.has(u.id)),
name: u.initials || u.username,
fullName: u.display_name,
email: u.email,
role: rolesByUser[u.id] || [],
absences: absByUser[u.id] || [],
// Vertragliche Wochenstunden; 0 heißt "nicht gepflegt" und wird im
// Frontend wie eine volle Stelle behandelt (js/load/load.js).
capacityHours: Number(u.capacity_hours) || 0,
// Gewerk der Person (users.department, ein VALID_DEPARTMENTS-Wert).
// Nötig für die Auswahl der Projektbeteiligten: Ein Projekt trägt seine
// Gewerke (project_gewerke), und angeboten werden zunächst nur die
// Mitarbeiter daraus. Leer = keinem Gewerk zugeordnet; solche Personen
// erscheinen nur über die ausdrückliche Auswahl "aus anderen Gewerken".
gewerk: u.department || '',
}));
}
// loadWeeks: setzt alle Wochen (jetzt eine einzige globale Reihe, nicht mehr
// pro Abteilung) aus den relationalen Tabellen wieder zu kompletten
// Wochen-Objekten zusammen. Rückgabe: Array von Wochen-Objekten, sortiert
// nach Label.
function loadWeeks(nurLabel){
// nurLabel (optional): nur diese eine Sitzung laden — für GET /week und die
// Live-Ereignisse, die sonst jedes Mal alle Sitzungen zusammensetzen müssten.
// Auch eine Liste von Labels (für /load-all und /weeks-aelter, die nur
// einige Sitzungen liefern).
const labels = nurLabel === undefined ? null : (Array.isArray(nurLabel) ? nurLabel : [nurLabel]).map(String);
if(labels && labels.length === 0) return [];
const alle = (tabelle, reihenfolge) => {
const spalte = tabelle === 'weeks' ? 'label' : 'week_label';
const wo = labels ? ` WHERE ${spalte} IN (${labels.map(() => '?').join(',')})` : '';
const st = db.prepare(`SELECT * FROM ${tabelle}${wo}${reihenfolge ? ' ' + reihenfolge : ''}`);
return labels ? st.all(...labels) : st.all();
};
const weeks = alle('weeks', 'ORDER BY label');
const weekPeople = alle('week_people', 'ORDER BY array_order');
const weekRoles = alle('week_people_roles', 'ORDER BY role_order');
const attendance = alle('attendance');
const projects = alle('projects', 'ORDER BY array_order');
const tags = alle('project_tags', 'ORDER BY tag_order');
const lph = alle('project_lph');
const assign = alle('project_assign');
const aufwand = alle('project_aufwand');
const aktuelleLp = alle('project_aktuelle_lp');
const beauftragteLp = alle('project_beauftragte_lph');
const gewerke = alle('project_gewerke');
const kommentare = alle('project_kommentare');
const plans = alle('project_plan');
const planPeople = alle('plan_people', 'ORDER BY array_order');
const todos = alle('plan_todos', 'ORDER BY sort_order');
const todoAssignees = alle('plan_todo_assignees');
const todoDeps = alle('plan_todo_deps');
const milestones = alle('plan_milestones', 'ORDER BY sort_order');
const milestoneDeps = alle('plan_milestone_deps');
// Index-Karten für schnellen Zugriff
const weekKey = (label) => label;
const projKey = (label, pid) => label + '|' + pid;
const todoKey = (label, pid, tid) => label + '|' + pid + '|' + tid;
const msKey = (label, pid, mid) => label + '|' + pid + '|' + mid;
const weekPeopleMap = {};
const weekRolesMap = {};
const attendanceMap = {};
// Anhängen statt per Index setzen: Die Abfrage ist bereits nach array_order
// sortiert. Eine Index-Zuweisung würde bei zwei gleichen Ordnungszahlen in
// derselben Woche (möglich nach der Abteilungs-Zusammenführung) die erste
// Person still überschreiben und Lücken im Array hinterlassen.
weekPeople.forEach(r => {
const k = weekKey(r.week_label);
(weekPeopleMap[k] = weekPeopleMap[k] || []).push({ id: r.person_id, name: r.name, fullName: r.full_name });
});
weekRoles.forEach(r => {
const k = weekKey(r.week_label);
const arr = (weekRolesMap[k] = weekRolesMap[k] || {});
(arr[r.person_id] = arr[r.person_id] || []).push(r.role);
});
attendance.forEach(r => {
attendanceMap[weekKey(r.week_label) + '|' + r.person_id] = !!r.present;
});
const projectsMap = {};
projects.forEach(r => {
projectsMap[projKey(r.week_label, r.project_id)] = {
id: r.project_id, nr: r.nr, name: r.name,
// Kommentar und Besprochen-Haken je Gewerk (project_kommentare).
kommentare: {},
group: r.group,
tags: [], lph: {}, assign: {}, order: r.order_value, plan: null,
projectUid: r.project_uid,
honorar: r.honorar,
// Rückfall auf die Vorgabe, weil Zeilen aus der Zeit vor der Spalte
// (und aus einem älteren Backup, siehe IMPORT_OPTIONAL_COLS) einen
// leeren Wert tragen können — die Oberfläche soll keinen Leerzustand
// anzeigen müssen.
auftragsstatus: normalizeAuftragsstatus(r.auftragsstatus),
beauftragteLP: [],
gewerke: [],
updatedAt: r.updated_at || '',
_arrayOrder: r.array_order,
};
});
tags.forEach(r => { const p = projectsMap[projKey(r.week_label, r.project_id)]; if(p) p.tags.push(r.tag); });
lph.forEach(r => { const p = projectsMap[projKey(r.week_label, r.project_id)]; if(p) p.lph[r.lph] = r.value; });
assign.forEach(r => {
const p = projectsMap[projKey(r.week_label, r.project_id)];
if(p) p.assign[r.person_id + (r.role ? '|' + r.role : '')] = r.state;
});
aufwand.forEach(r => {
const p = projectsMap[projKey(r.week_label, r.project_id)];
if(p){ p.aufwandProLP = p.aufwandProLP || {}; (p.aufwandProLP[r.lph] = p.aufwandProLP[r.lph] || {})[r.role] = r.value; }
});
aktuelleLp.forEach(r => {
const p = projectsMap[projKey(r.week_label, r.project_id)];
if(p){ p.aktuelleLP = p.aktuelleLP || []; if(!p.aktuelleLP.includes(r.lph)) p.aktuelleLP.push(r.lph); }
});
beauftragteLp.forEach(r => {
const p = projectsMap[projKey(r.week_label, r.project_id)];
if(p && !p.beauftragteLP.includes(r.lph)) p.beauftragteLP.push(r.lph);
});
gewerke.forEach(r => {
const p = projectsMap[projKey(r.week_label, r.project_id)];
if(p && !p.gewerke.includes(r.gewerk)) p.gewerke.push(r.gewerk);
});
kommentare.forEach(r => {
const p = projectsMap[projKey(r.week_label, r.project_id)];
if(p) p.kommentare[r.gewerk] = { status: r.status || '', statusAuthor: r.status_author || '', statusAt: r.status_at || '', besprochen: !!r.besprochen };
});
const planMap = {};
plans.forEach(r => { planMap[projKey(r.week_label, r.project_id)] = { start: r.start, end: r.end, progress: r.progress, people: [], todos: [], milestones: [] }; });
planPeople.forEach(r => {
const plan = planMap[projKey(r.week_label, r.project_id)];
if(plan) plan.people.push(r.person_id);
});
const todoMap = {};
todos.forEach(r => {
const k = todoKey(r.week_label, r.project_id, r.todo_id);
todoMap[k] = { id: r.todo_id, title: r.title, start: r.start, end: r.end, done: !!r.done, dependsOn: [], assignees: [], progress: r.progress, showInGantt: !!r.show_in_gantt, lph: r.leistungsphase || '', aufwand: Number(r.aufwand_stunden) || 0, notiz: r.notiz || '' };
});
todoAssignees.forEach(r => {
const t = todoMap[todoKey(r.week_label, r.project_id, r.todo_id)];
if(t) t.assignees.push(r.person_id);
});
todoDeps.forEach(r => {
const t = todoMap[todoKey(r.week_label, r.project_id, r.todo_id)];
if(t) t.dependsOn.push(r.depends_on_id);
});
const msMap = {};
milestones.forEach(r => {
const k = msKey(r.week_label, r.project_id, r.milestone_id);
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [], notiz: r.notiz || '' };
});
milestoneDeps.forEach(r => {
const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)];
if(m) m.dependsOn.push(r.depends_on_id);
});
// TODO- und Meilenstein-Objekte in die Projekt-Pläne einsortieren
Object.keys(todoMap).forEach(k => {
const parts = k.split('|');
const plan = planMap[parts[0] + '|' + parts[1]];
if(plan) plan.todos.push(todoMap[k]);
});
Object.keys(msMap).forEach(k => {
const parts = k.split('|');
const plan = planMap[parts[0] + '|' + parts[1]];
if(plan) plan.milestones.push(msMap[k]);
});
// Wochen-Objekte zusammensetzen
return weeks.map(w => {
const wkey = weekKey(w.label);
const people = (weekPeopleMap[wkey] || []).filter(Boolean).map(p => {
const roles = (weekRolesMap[wkey] || {})[p.id] || [];
const full = Object.assign({}, p);
if(roles.length) full.role = roles;
return full;
});
const att = {};
(weekPeopleMap[wkey] || []).filter(Boolean).forEach(p => {
const present = attendanceMap[wkey + '|' + p.id];
if(present !== undefined) att[p.id] = present;
});
const weekProjects = Object.keys(projectsMap)
.filter(k => k.startsWith(w.label + '|'))
.map(k => {
const p = projectsMap[k];
const plan = planMap[k];
if(plan) p.plan = plan;
return p;
})
.sort((a,b) => a._arrayOrder - b._arrayOrder)
.map(({ _arrayOrder, ...rest }) => rest);
return {
id: w.week_id, label: w.label, date: w.date,
people, projects: weekProjects, attendance: att, locked: !!w.locked,
updatedAt: w.updated_at || '',
};
});
}
// ---------- Datenbank-Backup ----------
//
// Zwei Wege: (1) Beim Serverstart wird automatisch eine Kopie der SQLite-DB
// als zeitgestempelte Datei in sessions/backups/ abgelegt (nur die letzten
// aufbewahrten Backups werden behalten). (2) GET /backup liefert einen konsistenten
// Snapshot der DB als herunterladbare .db-Datei (z. B. über den Button
// "DB Backup" im Menü der Hauptseite). Beide nutzen `VACUUM INTO`, das auch
// bei aktivem WAL-Mode eine konsistente Einzeldatei erzeugt (WAL-Inhalte
// werden einbezogen).
// BACKUP_DIR: Zielordner der automatischen Backups (sessions/backups, Docker-
// bind-mounted → bleibt persistent).
const BACKUP_DIR = path.join(SESSIONS_DIR, 'backups');
// Anzahl der automatisch behaltenen Backups (die ältesten werden verworfen)
// Wie viele automatische Backups aufbewahrt werden, ist seit dem Ausbau der
// Einstellungen firmenweit pflegbar (Schlüssel 'maxBackups'); dieser Wert ist
// nur noch die Vorgabe, falls nichts gesetzt ist.
const DEFAULT_MAX_BACKUPS = 10;
// createDbSnapshot: erzeugt einen konsistenten Snapshot der SQLite-DB in die
// angegebene Zieldatei (VACUUM INTO; funktioniert auch bei WAL-Mode).
function createDbSnapshot(targetPath){
const quoted = String(targetPath).replace(/'/g, "''");
db.exec(`VACUUM INTO '${quoted}'`);
}
// runAutoBackup: legt ein zeitgestempeltes Backup in BACKUP_DIR ab und
// verwirft ältere, sobald die eingestellte Anzahl überschritten ist. Fehler werden nur
// geloggt (Backup darf den Serverstart nicht verhindern).
function runAutoBackup(){
try {
if(!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
// Zeitstempel mit Millisekunden, damit auch zwei Backups innerhalb einer
// Sekunde (z. B. direkt nach dem Import) eindeutige Dateinamen erhalten.
const base = 'projektuebersicht_' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23);
let file = path.join(BACKUP_DIR, base + '.db');
let suffix = 1;
while(fs.existsSync(file)) file = path.join(BACKUP_DIR, base + '-' + (suffix++) + '.db');
createDbSnapshot(file);
console.log('Datenbank-Backup erstellt:', file);
// Nur die letzten (einstellbar, Schlüssel 'maxBackups') behalten
const backups = fs.readdirSync(BACKUP_DIR)
.filter(f => /^projektuebersicht_.+\.db$/.test(f))
.map(f => ({ name: f, mtime: fs.statSync(path.join(BACKUP_DIR, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
backups.slice(readAppSetting('maxBackups')).forEach(b => {
fs.unlinkSync(path.join(BACKUP_DIR, b.name));
});
} catch(err){
console.error('Automatisches Backup fehlgeschlagen:', err.message);
}
}
// ---------- Datenbank-Import (Wiederherstellung aus Backup) ----------
//
// POST /import nimmt eine mit "DB Backup" erzeugte .db-Datei als rohen
// Binär-Body entgegen, validiert das Schema und ersetzt den gesamten
// Datenbestand der laufenden Datenbank durch den Inhalt der Backup-Datei.
// Das Aufspielen erfolgt per ATTACH + DELETE/INSERT-SELECT in einer
// Transaktion: Die Live-Verbindung bleibt stabil, bei Fehlern wird
// zurückgerollt. Vor dem Import wird der Zustand vor dem Import als frisches
// Backup gesichert (runAutoBackup), damit er rückgängig gemacht werden kann.
// IMPORT_TABLES: die Sitzungs-/Projektdaten-Tabellen des relationalen Schemas;
// Grundlage für Validierung, Löschvorgang und Datentransfer. Die Reihenfolge
// ist ohne Bedeutung, da das Schema keine Foreign-Key-Constraints deklariert.
// BEWUSST NICHT enthalten sind die firmenweiten Konfigurations- und
// Kontotabellen (users, user_sessions, user_roles, user_absences,
// project_files, project_file_reviewers, project_file_comments sowie
// lph_templates): eine Wiederherstellung soll Sitzungsdaten zurückrollen,
// aber niemals Konten, Freigabestände oder Aufgaben-Vorlagen überschreiben.
const IMPORT_TABLES = ['weeks','week_people','week_people_roles','attendance','projects',
'project_tags','project_lph','project_assign','project_aufwand','project_aktuelle_lp',
'project_beauftragte_lph',
'project_gewerke',
'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps',
'plan_milestones','plan_milestone_deps',
'project_protokolle','protokoll_teilnehmer','protokoll_punkte',
'project_kommentare'];
// IMPORT_OPTIONAL_TABLES: Tabellen, die ältere Backups noch nicht kennen.
// Fehlen sie, bleiben sie nach dem Import leer; migrateKommentareJeGewerk()
// verteilt die alten Kommentare danach aus projects.status.
const IMPORT_OPTIONAL_TABLES = ['project_kommentare'];
// dbTableColumns: liefert die Spaltennamen einer Tabelle (funktioniert gegen
// jede DatabaseSync-Instanz, auch read-only Backups) via pragma_table_info.
function dbTableColumns(conn, table){
return conn.prepare('SELECT name FROM pragma_table_info(?)').all(table).map(r => r.name);
}
// IMPORT_OPTIONAL_COLS: Spalten, die in älteren Backups fehlen dürfen (sie
// werden beim Import mit dem Default-Wert der Live-Tabelle neu belegt).
// Die Liste muss mitwachsen, wenn Spalten ergänzt werden — sonst ist
// ausgerechnet das automatische Backup, das unmittelbar VOR einer
// Wiederherstellung geschrieben wird, nach einem Versionssprung nicht mehr
// einspielbar.
const IMPORT_OPTIONAL_COLS = ['updated_at', 'project_uid', 'honorar', 'status_author', 'status_at',
'auftragsstatus', 'besprochen', 'leistungsphase', 'aufwand_stunden', 'notiz'];
// restoreDbFromBackup: validiert die Backup-Datei (muss das bekannte Schema
// mit identischen Spalten wie die Live-DB besitzen) und ersetzt den
// Datenbestand der Live-DB durch den Snapshot. Parameter: backupPath (Pfad).
// Wirft bei ungültigem Schema oder Transferfehler (Transaktion wird gerollt).
// pruefeBackupDatei: öffnet die Datei und prüft das Schema (Schritt 1 von
// restoreDbFromBackup()) — eigenständig, damit /import eine ungültige Datei
// mit 400 abweisen kann, BEVOR runAutoBackup() läuft. Vorher erzeugte jeder
// Fehlversuch ein Auto-Backup und verdrängte so echte ältere.
// Wirft mit verständlicher Meldung. Parameter: backupPath.
function pruefeBackupDatei(backupPath){
let conn;
try { conn = new DatabaseSync(backupPath, { readOnly: true }); conn.prepare('SELECT 1 FROM sqlite_master LIMIT 1').get(); }
catch(err){ if(conn) conn.close(); throw new Error('Die Datei ist keine gültige Datenbank-Sicherung.'); }
try {
for(const t of IMPORT_TABLES){
const liveCols = dbTableColumns(db, t);
const backCols = dbTableColumns(conn, t);
if(backCols.length === 0){
if(IMPORT_OPTIONAL_TABLES.includes(t)) continue;
throw new Error('Ungültiges Backup (Tabelle fehlt): ' + t);
}
const missing = liveCols.filter(c => !backCols.includes(c) && !IMPORT_OPTIONAL_COLS.includes(c));
if(missing.length > 0) throw new Error('Backup-Schema passt nicht zur Live-DB (Tabelle ' + t + ', fehlt: ' + missing.join(', ') + ')');
}
} finally {
conn.close();
}
}
function restoreDbFromBackup(backupPath){
const backupConn = new DatabaseSync(backupPath, { readOnly: true });
try {
// 1) Schema-Validierung: Tabelle muss existieren und jede Spalte der
// Live-DB (außer den als optional markierten) enthalten. Zusätzliche
// Spalten im Backup (z. B. aus einer neueren Version) werden ignoriert.
for(const t of IMPORT_TABLES){
const liveCols = dbTableColumns(db, t);
const backCols = dbTableColumns(backupConn, t);
if(liveCols.length === 0) throw new Error('Live-Schema defekt (Tabelle fehlt): ' + t);
if(backCols.length === 0){
if(IMPORT_OPTIONAL_TABLES.includes(t)) continue;
throw new Error('Ungültiges Backup (Tabelle fehlt): ' + t);
}
const missing = liveCols.filter(c => !backCols.includes(c) && !IMPORT_OPTIONAL_COLS.includes(c));
if(missing.length > 0) throw new Error('Backup-Schema passt nicht zur Live-DB (Tabelle ' + t + ', fehlt: ' + missing.join(', ') + ')');
}
// 2) Datentransfer: Backup anbinden, Bestand löschen, Snapshot übernehmen.
// ATTACH und BEGIN stehen INNERHALB des try: Bricht BEGIN ab (z. B. weil
// noch eine Transaktion offen ist), muss das finally trotzdem abhängen —
// sonst bliebe import_backup dauerhaft gebunden und jeder weitere Import
// scheiterte bis zum Serverneustart.
try {
db.exec(`ATTACH DATABASE '${String(backupPath).replace(/'/g, "''")}' AS import_backup`);
db.exec('BEGIN');
IMPORT_TABLES.forEach(t => db.exec('DELETE FROM ' + t));
for(const t of IMPORT_TABLES){
const liveCols = dbTableColumns(db, t);
const backCols = dbTableColumns(backupConn, t);
// Nur Spalten übertragen, die BEIDE Schemas enthalten (alte Backups
// ohne updated_at bleiben so importierbar).
const common = liveCols.filter(c => backCols.includes(c));
if(common.length === 0) continue; // optionale Tabelle fehlt im Backup
// Anführungszeichen um jede Spalte: `projects` enthält die
// SQL-Schlüsselwort-Spalte "group", die sonst einen Syntaxfehler wirft.
const quoted = common.map(c => '"' + c + '"').join(', ');
const marks = common.map(() => '?').join(', ');
const rows = backupConn.prepare('SELECT ' + quoted + ' FROM ' + t).all();
const ins = db.prepare('INSERT INTO ' + t + ' (' + quoted + ') VALUES (' + marks + ')');
rows.forEach(r => ins.run(...common.map(c => r[c])));
}
db.exec('COMMIT');
} catch(err){
// Beide Aufräumschritte dürfen den ursprünglichen Fehler nicht
// verdecken: Scheiterte schon ATTACH, gibt es weder Transaktion noch
// angehängte Datenbank, und ROLLBACK/DETACH würden ihrerseits werfen.
try { db.exec('ROLLBACK'); } catch(e){ /* keine offene Transaktion */ }
throw err;
} finally {
try { db.exec('DETACH DATABASE import_backup'); } catch(e){ /* nie angehängt */ }
}
} finally {
backupConn.close();
}
}
// ---------- Migration: altes Ein-Dokument-Format -> relationales Schema ----------
// migrateLegacySchema: überführt eine bestehende Datenbank aus dem alten Format
// (eine `weeks`-Tabelle mit JSON-`data`-Spalte sowie `people` mit JSON-`data`)
// in das relationale Schema. Wird einmalig beim Start ausgeführt.
function migrateLegacySchema(){
if(!hasLegacyWeeksTable()) return;
console.log('Migriere altes Wochen-Dokument-Format in relationales Schema …');
// Alte Wochen-Dokumente lesen
const legacyWeeks = db.prepare('SELECT department, label, data FROM weeks').all();
// Alte globale Personen-Dokumente lesen (eine JSON-Array-Zeile pro Abteilung)
const legacyPeopleRows = [];
const hasLegacyPeople = db.prepare("SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table' AND name='people' AND sql LIKE '%data%'").get().c > 0;
if(hasLegacyPeople){
legacyPeopleRows.push(...db.prepare('SELECT department, data FROM people').all());
}
const tx = db.exec('BEGIN');
try {
// Alte Tabellen entfernen (Reihenfolge: erst Kind-Tabellen, dann Stamm)
db.exec(`DROP TABLE IF EXISTS week_people_roles; DROP TABLE IF EXISTS week_people;
DROP TABLE IF EXISTS attendance; DROP TABLE IF EXISTS project_lph;
DROP TABLE IF EXISTS project_assign; DROP TABLE IF EXISTS project_aufwand;
DROP TABLE IF EXISTS project_aktuelle_lp; DROP TABLE IF EXISTS project_plan;
DROP TABLE IF EXISTS plan_people; DROP TABLE IF EXISTS plan_todos;
DROP TABLE IF EXISTS plan_todo_assignees; DROP TABLE IF EXISTS plan_todo_deps;
DROP TABLE IF EXISTS plan_milestones; DROP TABLE IF EXISTS plan_milestone_deps;
DROP TABLE IF EXISTS projects; DROP TABLE IF EXISTS weeks;
DROP TABLE IF EXISTS people; DROP TABLE IF EXISTS person_roles; DROP TABLE IF EXISTS person_absences;`);
// Neue Tabellen anlegen (Schema-DDL oben ausführen)
db.exec(`CREATE TABLE IF NOT EXISTS weeks (
department TEXT NOT NULL, label TEXT NOT NULL, week_id TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL DEFAULT '', locked INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT '',
PRIMARY KEY (department, label)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS week_people (
department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '', full_name TEXT NOT NULL DEFAULT '',
array_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS week_people_roles (
department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL,
role TEXT NOT NULL, role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id, role)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS attendance (
department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL,
present INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS projects (
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
nr TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT '',
"group" TEXT NOT NULL DEFAULT '', array_order INTEGER NOT NULL DEFAULT 0,
order_value INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (department, week_label, project_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_lph (
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
lph TEXT NOT NULL, value TEXT NOT NULL DEFAULT '',
PRIMARY KEY (department, week_label, project_id, lph)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_assign (
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
person_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT '', state INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (department, week_label, project_id, person_id, role)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_aufwand (
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
lph TEXT NOT NULL, role TEXT NOT NULL DEFAULT '', value REAL NOT NULL DEFAULT 0,
PRIMARY KEY (department, week_label, project_id, lph, role)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_aktuelle_lp (
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
lph TEXT NOT NULL, PRIMARY KEY (department, week_label, project_id, lph)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_plan (
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '', progress INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (department, week_label, project_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_people (
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
person_id TEXT NOT NULL, array_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (department, week_label, project_id, person_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_todos (
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
todo_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', start TEXT NOT NULL DEFAULT '',
end TEXT NOT NULL DEFAULT '', done INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id, todo_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees (
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
todo_id TEXT NOT NULL, person_id TEXT NOT NULL,
PRIMARY KEY (department, week_label, project_id, todo_id, person_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_deps (
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
todo_id TEXT NOT NULL, depends_on_id TEXT NOT NULL,
PRIMARY KEY (department, week_label, project_id, todo_id, depends_on_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestones (
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
milestone_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '',
position TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (department, week_label, project_id, milestone_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps (
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
milestone_id TEXT NOT NULL, depends_on_id TEXT NOT NULL,
PRIMARY KEY (department, week_label, project_id, milestone_id, depends_on_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS people (
department TEXT NOT NULL, person_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '',
full_name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, person_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS person_roles (
department TEXT NOT NULL, person_id TEXT NOT NULL, role TEXT NOT NULL,
role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, person_id, role)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS person_absences (
department TEXT NOT NULL, person_id TEXT NOT NULL, absence TEXT NOT NULL,
PRIMARY KEY (department, person_id, absence)
)`);
// Alte Dokumente einlesen und relational speichern. Die Tabellen wurden
// gerade neu angelegt und sind leer; `serializeWeek` genügt (kein eigener
// Transaktions-Wrapper, da die äußere BEGIN/COMMIT der Migration aktiv ist).
legacyWeeks.forEach(row => {
let week;
try { week = JSON.parse(row.data); } catch(e){ console.error('Kaputte Woche bei Migration:', row.label, e.message); return; }
if(!week || !week.label) return;
serializeWeek(week);
});
// Globale Personen aus dem alten Dokument-Format migrieren. serializeGlobalPeople
// startet selbst eine Transaktion, daher werden die Zeilen hier direkt in die
// neuen Tabellen eingefügt (serializeGlobalPeople ist in Transaktionen nicht
// aufrufbar, weil BEGIN innerhalb BEGIN nicht erlaubt ist).
legacyPeopleRows.forEach(row => {
let people;
try { people = JSON.parse(row.data); } catch(e){ console.error('Kaputte Personen-Liste bei Migration:', row.department, e.message); return; }
if(!Array.isArray(people)) return;
const insPerson = db.prepare(`INSERT INTO people (department, person_id, name, full_name, email, sort_order)
VALUES (?, ?, ?, ?, ?, ?)`);
const insRole = db.prepare(`INSERT OR IGNORE INTO person_roles (department, person_id, role, role_order) VALUES (?, ?, ?, ?)`);
const insAbsence = db.prepare(`INSERT OR IGNORE INTO person_absences (department, person_id, absence) VALUES (?, ?, ?)`);
people.forEach((p, idx) => {
insPerson.run(row.department, String(p.id || ''), String(p.name || ''), String(p.fullName || ''), String(p.email || ''), idx);
(Array.isArray(p.role) ? p.role : []).forEach((r, ridx) => insRole.run(row.department, String(p.id || ''), String(r || ''), ridx));
(Array.isArray(p.absences) ? p.absences : []).forEach(a => insAbsence.run(row.department, String(p.id || ''), String(a || '')));
});
});
db.exec('COMMIT');
console.log('Migration abgeschlossen (' + legacyWeeks.length + ' Wochen).');
} catch(err){
db.exec('ROLLBACK');
console.error('Migration fehlgeschlagen:', err.message);
throw err;
}
}
// migrateWeeksUpdatedAt: ergänzt die `updated_at`-Spalte in bestehenden
// relationalen Datenbanken (idempotent; neue DBs haben die Spalte bereits).
function migrateWeeksUpdatedAt(){
const cols = db.prepare("SELECT name FROM pragma_table_info('weeks')").all().map(r => r.name);
if(!cols.includes('updated_at')){
db.exec("ALTER TABLE weeks ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''");
console.log('Wochen-Tabelle um updated_at ergänzt.');
}
}
// migrateRemoveDepartmentPartition: löst die bisherige Abteilungs-
// Partitionierung (ELT/HLS/ARCH als getrennte Wochen-/Projekt-Reihen) auf.
// Idempotent (Guard: `weeks` hat noch eine `department`-Spalte). Zweistufig:
// (1) Wochen mit gleichem Label aus verschiedenen Ex-Abteilungen werden zu
// einer globalen Woche zusammengeführt (week_people/-roles/attendance
// ebenfalls vereinigt).
// (2) Innerhalb jeder Woche werden Projekte mit gleicher, nicht-leerer `nr`
// aus verschiedenen Ex-Abteilungen zu EINEM Projekt verschmolzen
// (bestätigt vom Nutzer: es handelt sich um dieselben realen Bauprojekte,
// die bisher getrennt je Gewerk geführt wurden) — inkl. Team, Leistungs-
// phasen, Gantt/TODOs. Jede beteiligte Ex-Abteilung wird zum Tag
// (project_tags). Konflikte (abweichende Namen/Werte) werden mit
// deterministischer Präzedenz ELT→HLS→ARCH aufgelöst und vollständig
// protokolliert, niemals still verworfen.
function migrateRemoveDepartmentPartition(){
const weeksCols = db.prepare("SELECT name FROM pragma_table_info('weeks')").all().map(r => r.name);
if(!weeksCols.includes('department')) return; // bereits migriert oder frische DB
console.log('Migriere: Abteilungs-Partitionierung wird entfernt (Wochen und gleichnummerige Projekte werden zusammengeführt) …');
// Zusätzlicher, eigens benannter Sicherheits-Schnappschuss VOR jeder
// Änderung (zusätzlich zum regulären Auto-Backup, das erst NACH dem
// Serverstart läuft) — falls je eine manuelle Wiederherstellung nötig wird.
try {
if(!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
const snapshotPath = path.join(BACKUP_DIR, 'pre-department-migration-' + new Date().toISOString().replace(/[:.]/g, '-') + '.db');
createDbSnapshot(snapshotPath);
console.log('Vor-Migrations-Snapshot gespeichert:', snapshotPath);
} catch(err){
console.error('Vor-Migrations-Snapshot fehlgeschlagen (Migration wird trotzdem fortgesetzt):', err.message);
}
const DEPT_ORDER = ['ELT', 'HLS', 'ARCH'];
const deptRank = (d) => { const i = DEPT_ORDER.indexOf(d); return i === -1 ? DEPT_ORDER.length : i; };
const conflicts = [];
db.exec('BEGIN');
try {
// ---------- Stufe 1: Wochen zusammenführen (nach label) ----------
db.exec(`CREATE TABLE weeks_new (
label TEXT NOT NULL, week_id TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '',
locked INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT '', PRIMARY KEY (label)
)`);
const labels = db.prepare('SELECT DISTINCT label FROM weeks').all().map(r => r.label);
const insWeekNew = db.prepare('INSERT INTO weeks_new (label, week_id, date, locked, updated_at) VALUES (?,?,?,?,?)');
labels.forEach(label => {
const rows = db.prepare('SELECT * FROM weeks WHERE label = ?').all(label)
.sort((a, b) => deptRank(a.department) - deptRank(b.department));
if(rows.length > 1){
conflicts.push(`Woche "${label}": ${rows.length} Abteilungs-Kopien zusammengeführt (${rows.map(r => r.department + '=' + r.date).join(', ')}).`);
}
const canonical = rows[0];
const maxUpdatedAt = rows.map(r => r.updated_at || '').sort().slice(-1)[0] || '';
insWeekNew.run(label, canonical.week_id, canonical.date, canonical.locked, maxUpdatedAt);
});
db.exec('DROP TABLE weeks');
db.exec('ALTER TABLE weeks_new RENAME TO weeks');
// week_people: bei gleicher (week_label,person_id) aus mehreren
// Ex-Abteilungen gewinnt die Präzedenz-Reihenfolge.
db.exec(`CREATE TABLE week_people_new (
week_label TEXT NOT NULL, person_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '',
full_name TEXT NOT NULL DEFAULT '', array_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, person_id)
)`);
{
const wpInsert = db.prepare('INSERT INTO week_people_new (week_label,person_id,name,full_name,array_order) VALUES (?,?,?,?,?)');
const seen = new Set();
db.prepare('SELECT * FROM week_people').all()
.sort((a, b) => deptRank(a.department) - deptRank(b.department) || a.array_order - b.array_order)
.forEach(r => {
const key = r.week_label + '|' + r.person_id;
if(seen.has(key)){
conflicts.push(`week_people ${r.week_label}/${r.person_id}: Duplikat aus ${r.department} verworfen (Erst-Eintrag gewinnt).`);
return;
}
seen.add(key);
wpInsert.run(r.week_label, r.person_id, r.name, r.full_name, r.array_order);
});
}
db.exec('DROP TABLE week_people');
db.exec('ALTER TABLE week_people_new RENAME TO week_people');
// week_people_roles: reine Mengenvereinigung, keine Konflikte möglich
// (gleiche Rolle mehrfach ist harmlos, INSERT OR IGNORE übernimmt das).
db.exec(`CREATE TABLE week_people_roles_new (
week_label TEXT NOT NULL, person_id TEXT NOT NULL, role TEXT NOT NULL,
role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, person_id, role)
)`);
db.exec(`INSERT OR IGNORE INTO week_people_roles_new (week_label,person_id,role,role_order)
SELECT week_label,person_id,role,role_order FROM week_people_roles`);
db.exec('DROP TABLE week_people_roles');
db.exec('ALTER TABLE week_people_roles_new RENAME TO week_people_roles');
// attendance: bei widersprüchlicher Anwesenheit gewinnt die Präzedenz.
db.exec(`CREATE TABLE attendance_new (
week_label TEXT NOT NULL, person_id TEXT NOT NULL, present INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, person_id)
)`);
{
const attInsert = db.prepare('INSERT INTO attendance_new (week_label,person_id,present) VALUES (?,?,?)');
const seen = new Map();
db.prepare('SELECT * FROM attendance').all()
.sort((a, b) => deptRank(a.department) - deptRank(b.department))
.forEach(r => {
const key = r.week_label + '|' + r.person_id;
if(seen.has(key)){
if(seen.get(key) !== r.present){
conflicts.push(`attendance ${r.week_label}/${r.person_id}: widersprüchliche Anwesenheit (${r.department}=${r.present}) verworfen, Erst-Eintrag gewinnt.`);
}
return;
}
seen.set(key, r.present);
attInsert.run(r.week_label, r.person_id, r.present);
});
}
db.exec('DROP TABLE attendance');
db.exec('ALTER TABLE attendance_new RENAME TO attendance');
// ---------- Stufe 2: Projekte je Woche nach `nr` zusammenführen ----------
db.exec(`CREATE TABLE projects_new (
week_label TEXT NOT NULL, project_id TEXT NOT NULL, nr TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT '', "group" TEXT NOT NULL DEFAULT '',
array_order INTEGER NOT NULL DEFAULT 0, order_value INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, project_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS project_tags (
week_label TEXT NOT NULL, project_id TEXT NOT NULL, tag TEXT NOT NULL,
tag_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, project_id, tag)
)`);
// Gruppierung: gleiche (week_label,nr) mit nicht-leerer nr gehören
// zusammen; jede Zeile mit leerer nr bildet ihre eigene Einzel-Gruppe
// (nichts zum Abgleichen vorhanden, bleibt eigenständig).
const groups = new Map();
let soloCounter = 0;
db.prepare('SELECT * FROM projects').all().forEach(r => {
const groupKey = (r.nr && r.nr.trim() !== '')
? r.week_label + '|nr:' + r.nr
: r.week_label + '|solo:' + (soloCounter++);
if(!groups.has(groupKey)) groups.set(groupKey, []);
groups.get(groupKey).push(r);
});
const insProjectNew = db.prepare(`INSERT INTO projects_new (week_label,project_id,nr,name,status,"group",array_order,order_value)
VALUES (?,?,?,?,?,?,?,?)`);
const insTagNew = db.prepare('INSERT OR IGNORE INTO project_tags (week_label,project_id,tag,tag_order) VALUES (?,?,?,?)');
// projectIdMap: alter Schlüssel "department|week_label|project_id" -> neue
// (überlebende) project_id innerhalb derselben week_label.
const projectIdMap = new Map();
groups.forEach(rawRows => {
const rows = rawRows.slice().sort((a, b) => deptRank(a.department) - deptRank(b.department));
const survivor = rows[0];
const weekLabel = survivor.week_label;
const newProjectId = survivor.project_id;
rows.forEach(r => projectIdMap.set(r.department + '|' + r.week_label + '|' + r.project_id, newProjectId));
if(rows.length > 1){
if(new Set(rows.map(r => r.name)).size > 1){
conflicts.push(`Projekt ${survivor.nr} (${weekLabel}): unterschiedliche Namen zusammengeführt (${rows.map(r => r.department + '="' + r.name + '"').join(', ')}), "${survivor.name}" übernommen.`);
}
if(new Set(rows.map(r => r.group)).size > 1){
conflicts.push(`Projekt ${survivor.nr} (${weekLabel}): unterschiedliche Gruppen (${rows.map(r => r.department + '="' + r.group + '"').join(', ')}), "${survivor.group}" übernommen.`);
}
}
// Status (freier Text): alle nicht-leeren Texte mit Abteilungs-Präfix
// zusammenführen statt einen zu verwerfen — kein Informationsverlust.
// Identischer Text aus mehreren Abteilungen (z. B. eine gemeinsame
// Besprechungsnotiz) wird dedupliziert statt wortgleich doppelt gezeigt.
const statusByText = new Map();
rows.forEach(r => {
const t = (r.status || '').trim();
if(!t) return;
if(!statusByText.has(t)) statusByText.set(t, []);
statusByText.get(t).push(r.department);
});
const mergedStatus = Array.from(statusByText.entries())
.map(([text, depts]) => `[${depts.join('+')}] ${text}`)
.join(' | ');
insProjectNew.run(weekLabel, newProjectId, survivor.nr, survivor.name, mergedStatus, survivor.group, survivor.array_order, survivor.order_value);
Array.from(new Set(rows.map(r => r.department))).forEach((d, idx) => insTagNew.run(weekLabel, newProjectId, d, idx));
});
db.exec('DROP TABLE projects');
db.exec('ALTER TABLE projects_new RENAME TO projects');
// mapProjectId: löst für eine alte (department,week_label,project_id) die
// neue (verschmolzene) project_id auf. Fällt auf die alte ID zurück, falls
// aus irgendeinem Grund kein Mapping existiert (sollte nie vorkommen, da
// JEDE alte projects-Zeile oben ein Mapping bekommen hat).
function mapProjectId(department, weekLabel, projectId){
return projectIdMap.get(department + '|' + weekLabel + '|' + projectId) || projectId;
}
// project_lph: Vereinigung je (week_label,neue_project_id,lph); bei
// widersprüchlichen Werten gewinnt das Maximum (weiter fortgeschrittene
// Abteilung bestimmt den gemeinsamen Fortschritt dieser Leistungsphase).
db.exec(`CREATE TABLE project_lph_new (
week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '', PRIMARY KEY (week_label, project_id, lph)
)`);
{
const merged = new Map();
db.prepare('SELECT * FROM project_lph').all()
.sort((a, b) => deptRank(a.department) - deptRank(b.department))
.forEach(r => {
const newPid = mapProjectId(r.department, r.week_label, r.project_id);
const key = r.week_label + '|' + newPid + '|' + r.lph;
const existing = merged.get(key);
if(existing){
if(existing.value !== r.value){
const a = Number(existing.value), b = Number(r.value);
const winner = (!isNaN(a) && !isNaN(b)) ? (b > a ? r.value : existing.value) : existing.value;
conflicts.push(`project_lph ${r.week_label}/${newPid}/LP${r.lph}: unterschiedliche Werte (${existing.value} vs ${r.value}) — Maximum "${winner}" übernommen.`);
existing.value = winner;
}
return;
}
merged.set(key, { weekLabel: r.week_label, projectId: newPid, lph: r.lph, value: r.value });
});
const ins = db.prepare('INSERT INTO project_lph_new (week_label,project_id,lph,value) VALUES (?,?,?,?)');
merged.forEach(v => ins.run(v.weekLabel, v.projectId, v.lph, v.value));
}
db.exec('DROP TABLE project_lph');
db.exec('ALTER TABLE project_lph_new RENAME TO project_lph');
// project_assign: Vereinigung je (week_label,neue_project_id,person_id,role);
// bei widersprüchlichem Status (0/1/2) gewinnt das Maximum.
db.exec(`CREATE TABLE project_assign_new (
week_label TEXT NOT NULL, project_id TEXT NOT NULL, person_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT '', state INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, project_id, person_id, role)
)`);
{
const merged = new Map();
db.prepare('SELECT * FROM project_assign').all()
.sort((a, b) => deptRank(a.department) - deptRank(b.department))
.forEach(r => {
const newPid = mapProjectId(r.department, r.week_label, r.project_id);
const key = r.week_label + '|' + newPid + '|' + r.person_id + '|' + r.role;
const existing = merged.get(key);
if(existing){
if(existing.state !== r.state){
const winner = Math.max(existing.state, r.state);
conflicts.push(`project_assign ${r.week_label}/${newPid}/${r.person_id}|${r.role}: unterschiedlicher Status (${existing.state} vs ${r.state}) — Maximum ${winner} übernommen.`);
existing.state = winner;
}
return;
}
merged.set(key, { weekLabel: r.week_label, projectId: newPid, personId: r.person_id, role: r.role, state: r.state });
});
const ins = db.prepare('INSERT INTO project_assign_new (week_label,project_id,person_id,role,state) VALUES (?,?,?,?,?)');
merged.forEach(v => ins.run(v.weekLabel, v.projectId, v.personId, v.role, v.state));
}
db.exec('DROP TABLE project_assign');
db.exec('ALTER TABLE project_assign_new RENAME TO project_assign');
// project_aufwand: Vereinigung je (week_label,neue_project_id,lph,role);
// bei Widerspruch gewinnt die Präzedenz (Gewichtungsfaktoren sind eher
// Konfiguration als Fortschritt, kein "Maximum" sinnvoll).
db.exec(`CREATE TABLE project_aufwand_new (
week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL,
role TEXT NOT NULL DEFAULT '', value REAL NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, project_id, lph, role)
)`);
{
const merged = new Map();
db.prepare('SELECT * FROM project_aufwand').all()
.sort((a, b) => deptRank(a.department) - deptRank(b.department))
.forEach(r => {
const newPid = mapProjectId(r.department, r.week_label, r.project_id);
const key = r.week_label + '|' + newPid + '|' + r.lph + '|' + r.role;
const existing = merged.get(key);
if(existing){
if(existing.value !== r.value){
conflicts.push(`project_aufwand ${r.week_label}/${newPid}/LP${r.lph}/${r.role}: unterschiedliche Werte (${existing.value} vs ${r.value}) — erster Wert (${existing.value}) übernommen.`);
}
return;
}
merged.set(key, { weekLabel: r.week_label, projectId: newPid, lph: r.lph, role: r.role, value: r.value });
});
const ins = db.prepare('INSERT INTO project_aufwand_new (week_label,project_id,lph,role,value) VALUES (?,?,?,?,?)');
merged.forEach(v => ins.run(v.weekLabel, v.projectId, v.lph, v.role, v.value));
}
db.exec('DROP TABLE project_aufwand');
db.exec('ALTER TABLE project_aufwand_new RENAME TO project_aufwand');
// project_aktuelle_lp: reine Mengenvereinigung (ein Projekt kann jetzt
// mehrere gleichzeitig aktive Leistungsphasen zeigen — gewollt).
db.exec(`CREATE TABLE project_aktuelle_lp_new (
week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL,
PRIMARY KEY (week_label, project_id, lph)
)`);
{
const ins = db.prepare('INSERT OR IGNORE INTO project_aktuelle_lp_new (week_label,project_id,lph) VALUES (?,?,?)');
db.prepare('SELECT * FROM project_aktuelle_lp').all().forEach(r => {
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.lph);
});
}
db.exec('DROP TABLE project_aktuelle_lp');
db.exec('ALTER TABLE project_aktuelle_lp_new RENAME TO project_aktuelle_lp');
// project_plan (Gantt-Rahmen je Projekt): start = frühester, end =
// spätester Termin (gemeinsame Zeitspanne beider Arbeitsanteile);
// progress-Widerspruch -> Maximum.
db.exec(`CREATE TABLE project_plan_new (
week_label TEXT NOT NULL, project_id TEXT NOT NULL, start TEXT NOT NULL DEFAULT '',
end TEXT NOT NULL DEFAULT '', progress INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, project_id)
)`);
{
const merged = new Map();
db.prepare('SELECT * FROM project_plan').all()
.sort((a, b) => deptRank(a.department) - deptRank(b.department))
.forEach(r => {
const newPid = mapProjectId(r.department, r.week_label, r.project_id);
const key = r.week_label + '|' + newPid;
const existing = merged.get(key);
if(existing){
if(r.start && (!existing.start || r.start < existing.start)) existing.start = r.start;
if(r.end && (!existing.end || r.end > existing.end)) existing.end = r.end;
if(existing.progress !== r.progress){
const winner = Math.max(existing.progress, r.progress);
conflicts.push(`project_plan ${r.week_label}/${newPid}: unterschiedlicher Fortschritt (${existing.progress}% vs ${r.progress}%) — Maximum ${winner}% übernommen.`);
existing.progress = winner;
}
return;
}
merged.set(key, { weekLabel: r.week_label, projectId: newPid, start: r.start, end: r.end, progress: r.progress });
});
const ins = db.prepare('INSERT INTO project_plan_new (week_label,project_id,start,end,progress) VALUES (?,?,?,?,?)');
merged.forEach(v => ins.run(v.weekLabel, v.projectId, v.start, v.end, v.progress));
}
db.exec('DROP TABLE project_plan');
db.exec('ALTER TABLE project_plan_new RENAME TO project_plan');
// plan_people: reine Mengenvereinigung.
db.exec(`CREATE TABLE plan_people_new (
week_label TEXT NOT NULL, project_id TEXT NOT NULL, person_id TEXT NOT NULL,
array_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, project_id, person_id)
)`);
{
const ins = db.prepare('INSERT OR IGNORE INTO plan_people_new (week_label,project_id,person_id,array_order) VALUES (?,?,?,?)');
db.prepare('SELECT * FROM plan_people ORDER BY array_order').all().forEach(r => {
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.person_id, r.array_order);
});
}
db.exec('DROP TABLE plan_people');
db.exec('ALTER TABLE plan_people_new RENAME TO plan_people');
// plan_todos / plan_todo_assignees / plan_todo_deps / plan_milestones /
// plan_milestone_deps: todo_id/milestone_id sind zufällige uid()-Strings,
// daher praktisch kein Kollisionsrisiko — reines Umhängen auf die neue
// project_id (das Gantt/TODO des vereinten Projekts zeigt danach schlicht
// die Aufgaben aller beteiligten Abteilungen zusammen).
db.exec(`CREATE TABLE plan_todos_new (
week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '', start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '',
done INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (week_label, project_id, todo_id)
)`);
{
const ins = db.prepare(`INSERT OR IGNORE INTO plan_todos_new (week_label,project_id,todo_id,title,start,end,done,progress,sort_order) VALUES (?,?,?,?,?,?,?,?,?)`);
db.prepare('SELECT * FROM plan_todos ORDER BY sort_order').all().forEach(r => {
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.todo_id, r.title, r.start, r.end, r.done, r.progress, r.sort_order);
});
}
db.exec('DROP TABLE plan_todos');
db.exec('ALTER TABLE plan_todos_new RENAME TO plan_todos');
db.exec(`CREATE TABLE plan_todo_assignees_new (
week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL, person_id TEXT NOT NULL,
PRIMARY KEY (week_label, project_id, todo_id, person_id)
)`);
{
const ins = db.prepare('INSERT OR IGNORE INTO plan_todo_assignees_new (week_label,project_id,todo_id,person_id) VALUES (?,?,?,?)');
db.prepare('SELECT * FROM plan_todo_assignees').all().forEach(r => {
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.todo_id, r.person_id);
});
}
db.exec('DROP TABLE plan_todo_assignees');
db.exec('ALTER TABLE plan_todo_assignees_new RENAME TO plan_todo_assignees');
db.exec(`CREATE TABLE plan_todo_deps_new (
week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL, depends_on_id TEXT NOT NULL,
PRIMARY KEY (week_label, project_id, todo_id, depends_on_id)
)`);
{
const ins = db.prepare('INSERT OR IGNORE INTO plan_todo_deps_new (week_label,project_id,todo_id,depends_on_id) VALUES (?,?,?,?)');
db.prepare('SELECT * FROM plan_todo_deps').all().forEach(r => {
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.todo_id, r.depends_on_id);
});
}
db.exec('DROP TABLE plan_todo_deps');
db.exec('ALTER TABLE plan_todo_deps_new RENAME TO plan_todo_deps');
db.exec(`CREATE TABLE plan_milestones_new (
week_label TEXT NOT NULL, project_id TEXT NOT NULL, milestone_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '', position TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, project_id, milestone_id)
)`);
{
const ins = db.prepare(`INSERT OR IGNORE INTO plan_milestones_new (week_label,project_id,milestone_id,name,date,position,sort_order) VALUES (?,?,?,?,?,?,?)`);
db.prepare('SELECT * FROM plan_milestones ORDER BY sort_order').all().forEach(r => {
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.milestone_id, r.name, r.date, r.position, r.sort_order);
});
}
db.exec('DROP TABLE plan_milestones');
db.exec('ALTER TABLE plan_milestones_new RENAME TO plan_milestones');
db.exec(`CREATE TABLE plan_milestone_deps_new (
week_label TEXT NOT NULL, project_id TEXT NOT NULL, milestone_id TEXT NOT NULL, depends_on_id TEXT NOT NULL,
PRIMARY KEY (week_label, project_id, milestone_id, depends_on_id)
)`);
{
const ins = db.prepare('INSERT OR IGNORE INTO plan_milestone_deps_new (week_label,project_id,milestone_id,depends_on_id) VALUES (?,?,?,?)');
db.prepare('SELECT * FROM plan_milestone_deps').all().forEach(r => {
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.milestone_id, r.depends_on_id);
});
}
db.exec('DROP TABLE plan_milestone_deps');
db.exec('ALTER TABLE plan_milestone_deps_new RENAME TO plan_milestone_deps');
// project_files: department-Spalte entfernen (jetzt korrekt über
// project_nr allein identifiziert, da gleichnummerige Projekte ab jetzt
// als EIN Projekt gelten). Physische Dateien lagen bisher unter
// sessions/uploads/<department>/<datei>, künftig flach unter
// sessions/uploads/<datei> — best-effort verschoben (Tabelle ist aktuell
// in der Praxis leer, dieser Pfad daher selten).
const pfCols = db.prepare("SELECT name FROM pragma_table_info('project_files')").all().map(r => r.name);
if(pfCols.includes('department')){
db.exec(`CREATE TABLE project_files_new (
id TEXT PRIMARY KEY, project_nr TEXT NOT NULL, project_name TEXT NOT NULL DEFAULT '',
original_filename TEXT NOT NULL, stored_name TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream', size_bytes INTEGER NOT NULL DEFAULT 0,
uploaded_by_user_id INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT ''
)`);
db.exec(`INSERT INTO project_files_new (id,project_nr,project_name,original_filename,stored_name,content_type,size_bytes,uploaded_by_user_id,status,created_at,updated_at)
SELECT id,project_nr,project_name,original_filename,stored_name,content_type,size_bytes,uploaded_by_user_id,status,created_at,updated_at FROM project_files`);
const oldFileRows = db.prepare('SELECT id, department, stored_name FROM project_files').all();
db.exec('DROP TABLE project_files');
db.exec('ALTER TABLE project_files_new RENAME TO project_files');
db.exec('DROP INDEX IF EXISTS idx_project_files_scope');
db.exec('CREATE INDEX idx_project_files_scope ON project_files(project_nr)');
oldFileRows.forEach(r => {
try {
const oldPath = path.join(SESSIONS_DIR, 'uploads', r.department, r.stored_name);
const newPath = path.join(SESSIONS_DIR, 'uploads', r.stored_name);
if(fs.existsSync(oldPath) && !fs.existsSync(newPath)) fs.renameSync(oldPath, newPath);
} catch(err){
conflicts.push(`project_files ${r.id}: physische Datei konnte nicht verschoben werden (${err.message}).`);
}
});
}
db.exec('COMMIT');
console.log(`Migration abgeschlossen. ${conflicts.length} Konflikt(e)/Hinweis(e) protokolliert:`);
conflicts.forEach(c => console.log(' - ' + c));
} catch(err){
db.exec('ROLLBACK');
console.error('Department-Migration fehlgeschlagen, Datenbank unverändert:', err);
throw err;
}
}
// migrateAddProjectUid: vergibt jeder projects-Zeile eine dauerhafte,
// wochenunabhängige project_uid (Basis für echte Projekt-Historie über
// mehrere Sitzungen hinweg, siehe "Projekt-Historie"-Architekturnotiz).
// Anders als project_id (wird bei jedem cloneWeek() neu vergeben, siehe
// js/weeks.js) und nr (informell stabil, aber änderbar/leer möglich) wird
// project_uid einmal bei echter Projekt-Entstehung erzeugt
// (saveNewProj()/demoProj()) und danach nie wieder verändert — cloneWeek()
// kopiert sie unverändert mit. Läuft NACH migrateRemoveDepartmentPartition()
// (setzt eine bereits globale, nicht mehr abteilungsgetrennte projects-
// Tabelle voraus). Idempotent (Guard: Spalte existiert bereits).
// migrateAddProjectBesprochen: rüstet die Spalte `besprochen` nach
// (idempotentes ALTER TABLE, gleiches Muster wie migrateUsersDepartment()).
// Bestandszeilen starten mit 0 — für vergangene Sitzungen ist der Haken
// ohnehin bedeutungslos.
function migrateAddProjectBesprochen(){
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
if(!cols.includes('besprochen')){
db.exec('ALTER TABLE projects ADD COLUMN besprochen INTEGER NOT NULL DEFAULT 0');
console.log('Projekt-Tabelle um besprochen ergänzt.');
}
}
// migrateBackfillProjectGewerkeAusTags: setzt für Projekte OHNE jede
// project_gewerke-Zeile das Gewerk aus dem Schlagwort, das
// migrateRemoveDepartmentPartition() beim Zusammenführen der früheren
// Abteilungs-Sitzungen automatisch als project_tags-Eintrag vergeben hat
// (ELT/HLS/ARCH). Ohne diese Übernahme verschwände jedes Bestandsprojekt aus
// jeder Gewerke-gefilterten Ansicht des Wochenberichts (berichtProjekte()),
// weil project_gewerke — der Assistent "Neues Projekt" schreibt hierhin —
// für alle vor dem Assistenten angelegten Projekte leer ist. Idempotent:
// sobald ein Projekt mindestens eine project_gewerke-Zeile hat (egal woher,
// auch aus dieser Migration selbst), wird es bei künftigen Läufen
// übersprungen — eine spätere manuelle Korrektur in den Projekteinstellungen
// wird also nicht rückgängig gemacht.
function migrateBackfillProjectGewerkeAusTags(){
const kandidaten = db.prepare(`
SELECT DISTINCT pt.week_label, pt.project_id, pt.tag
FROM project_tags pt
WHERE pt.tag IN ('ELT','HLS','ARCH')
AND NOT EXISTS (
SELECT 1 FROM project_gewerke pg
WHERE pg.week_label = pt.week_label AND pg.project_id = pt.project_id
)
`).all();
if(!kandidaten.length) return;
const ins = db.prepare('INSERT OR IGNORE INTO project_gewerke (week_label, project_id, gewerk) VALUES (?,?,?)');
db.exec('BEGIN');
try {
kandidaten.forEach(r => ins.run(r.week_label, r.project_id, r.tag));
db.exec('COMMIT');
console.log('Gewerke aus Schlagwörtern übernommen:', kandidaten.length, 'Zuordnung(en).');
} catch(err){
db.exec('ROLLBACK');
console.error('Gewerke-Backfill aus Schlagwörtern fehlgeschlagen:', err.message);
}
}
// migrateKommentareJeGewerk: verteilt die alten, gewerkübergreifenden
// Kommentare und Haken (projects.status/status_author/status_at/besprochen)
// auf project_kommentare. Läuft bei jedem Start und nach jedem DB-Import;
// idempotent, weil die Altspalten danach geleert werden.
// Zuordnung je Kommentar: das Gewerk des Verfassers (users.department über
// den Anzeigenamen), wenn das Projekt dieses Gewerk hat; sonst das einzige
// Gewerk des Projekts; bei mehreren und unbekanntem Verfasser alle Gewerke
// des Projekts — welches gemeint war, lässt sich dann nicht mehr sagen.
// Projekte ohne Gewerk bleiben unverändert (sie erscheinen ohnehin in keinem
// Gewerke-Wochenbericht).
function migrateKommentareJeGewerk(){
const zeilen = db.prepare(`SELECT week_label, project_id, status, status_author, status_at, besprochen
FROM projects WHERE status != '' OR besprochen = 1`).all();
if(!zeilen.length) return;
const gewerkeVon = db.prepare('SELECT gewerk FROM project_gewerke WHERE week_label = ? AND project_id = ?');
const abteilungVon = db.prepare("SELECT department FROM users WHERE display_name = ? AND department != '' LIMIT 1");
const vorhanden = db.prepare('SELECT 1 FROM project_kommentare WHERE week_label = ? AND project_id = ? AND gewerk = ?');
const ins = db.prepare(`INSERT INTO project_kommentare (week_label, project_id, gewerk, status, status_author, status_at, besprochen)
VALUES (?, ?, ?, ?, ?, ?, ?)`);
const leeren = db.prepare(`UPDATE projects SET status = '', status_author = '', status_at = '', besprochen = 0
WHERE week_label = ? AND project_id = ?`);
let anzahl = 0;
db.exec('BEGIN');
try {
zeilen.forEach(r => {
const gewerke = gewerkeVon.all(r.week_label, r.project_id).map(x => x.gewerk);
if(!gewerke.length) return;
let ziele = gewerke;
if(r.status && gewerke.length > 1){
const a = r.status_author ? abteilungVon.get(r.status_author) : null;
if(a && gewerke.includes(a.department)) ziele = [a.department];
}
ziele.forEach(g => {
if(vorhanden.get(r.week_label, r.project_id, g)) return;
ins.run(r.week_label, r.project_id, g, r.status || '', r.status_author || '', r.status_at || '', r.besprochen ? 1 : 0);
});
leeren.run(r.week_label, r.project_id);
anzahl++;
});
db.exec('COMMIT');
if(anzahl) console.log('Kommentare je Gewerk übernommen:', anzahl, 'Projekt-Fassung(en).');
} catch(err){
db.exec('ROLLBACK');
console.error('Übernahme der Kommentare je Gewerk fehlgeschlagen:', err.message);
}
}
// migrateDropProjectGroesse: entfernt die frühere Spalte `groesse`
// (Projektgröße 0–1, Ersatz für ein fehlendes Honorar in der alten
// Auslastungsrechnung). Seit die Auslastung allein aus Aufgaben-Stunden
// entsteht, hat sie keine Verwendung mehr — Eriks Wunsch, sie auch aus der
// Datenbank zu nehmen. Idempotent; ein Backup mit der Spalte bleibt
// importierbar (überzählige Spalten werden beim Import ignoriert).
function migrateDropProjectGroesse(){
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
if(cols.includes('groesse')){
db.exec('ALTER TABLE projects DROP COLUMN groesse');
console.log('Spalte projects.groesse entfernt.');
}
}
// einmaligeUmstellung: führt eine Datenumstellung genau einmal je
// Datenbank aus (Merker in der Tabelle umstellungen). Für Umstellungen, die
// nicht an ein neu angelegtes Feld gekoppelt werden können.
function einmaligeUmstellung(name, fn){
db.exec('CREATE TABLE IF NOT EXISTS umstellungen (name TEXT PRIMARY KEY, am TEXT NOT NULL)');
if(db.prepare('SELECT 1 FROM umstellungen WHERE name = ?').get(name)) return;
fn();
db.prepare('INSERT INTO umstellungen (name, am) VALUES (?, ?)').run(name, new Date().toISOString());
}
// kommentarSpiegelstricheUmbrechen: Eriks Wunsch für die Bestandskommentare
// — jeder Spiegelstrich "- " beginnt eine eigene Zeile. Ältere Kommentare
// (aus der Zeit des Markup-Editors bzw. aus eingefügtem Text) tragen ihre
// Stichpunkte oft als eine Zeile "- A - B - C". Rein textuell, ohne DOM:
// Das HTML wird in Tags und Text zerlegt; Block-Tags (br/div/p) gelten als
// Zeilenanfang, Inline-Tags (b/i/span/font …) nicht. Ein "-" mit
// Leerzeichen (oder Anfang) davor und Leerzeichen/&nbsp; danach bekommt ein
// <br> vorangestellt — außer es steht schon am Zeilenanfang oder nur hinter
// einem Gewerke-Vorsatz wie "[ELT]" ("[ELT] - Text" bleibt eine Zeile).
// Bindestriche in Wörtern ("SV-Abnahme") bleiben unberührt. Repariert
// nebenbei als Text gespeicherte "&nbsp;" (siehe unten).
// Parameter: html (String). Rückgabe: String (unverändert, wenn nichts zu tun).
function kommentarSpiegelstricheUmbrechen(html){
// Eine ältere Fassung des Kommentarfelds hat das geschützte Leerzeichen nach
// dem Strich als Text "&nbsp;" gespeichert (sichtbar als "-&nbsp;").
if(html) html = String(html).replace(/&amp;nbsp;/g, '&nbsp;');
if(!html || html.indexOf('-') === -1) return html;
const teile = String(html).split(/(<[^>]*>)/);
let zeile = ''; // sichtbarer Text der laufenden Zeile bis hierher
const aus = teile.map(t => {
if(t.startsWith('<')){
if(/^<\/?(br|div|p)\b/i.test(t)) zeile = '';
return t;
}
const neu = t.replace(/(^|\s)-(?=\s|&nbsp;|\u00a0)/g, (m, vor, pos, ganz) => {
const sichtbar = (zeile + ganz.slice(0, pos)).replace(/&nbsp;/g, ' ').trim();
if(sichtbar === '' || /^\[[^\]]{1,20}\]$/.test(sichtbar)) return m;
return '<br>-';
}).replace(/\s+<br>-/g, '<br>-');
zeile += t;
return neu;
});
return aus.join('');
}
// kommentarVorsatzEntfernen: nimmt den Gewerke-Vorsatz "[ELT] ", "[ELT+HLS] "
// am Textanfang weg. Ihn hat migrateRemoveDepartmentPartition() beim
// Zusammenführen der Abteilungen vor jeden Kommentar gesetzt; seit es
// Kommentare je Gewerk gibt, steht das Gewerk ohnehin am Eintrag und wird
// in der Anzeige als eigene Beschriftung gezeigt (Eriks Wunsch: "nur als
// Systemtext, komplett weg vom normalen Text"). Ein direkt folgendes <br>
// fällt mit weg, damit keine Leerzeile am Anfang bleibt.
// Parameter: html (String). Rückgabe: String.
function kommentarVorsatzEntfernen(html){
if(!html) return html;
return String(html).replace(/^\s*\[(?:ELT|HLS|ARCH)(?:\s*\+\s*(?:ELT|HLS|ARCH))*\]\s*(?:<br\s*\/?>\s*)?/i, '');
}
function kommentareVorsatzUmstellen(){
const zeilen = db.prepare("SELECT week_label, project_id, gewerk, status FROM project_kommentare WHERE status LIKE '%[%'").all();
const upd = db.prepare('UPDATE project_kommentare SET status = ? WHERE week_label = ? AND project_id = ? AND gewerk = ?');
let n = 0;
db.exec('BEGIN');
try {
zeilen.forEach(r => {
const neu = kommentarVorsatzEntfernen(r.status);
if(neu !== r.status){ upd.run(neu, r.week_label, r.project_id, r.gewerk); n++; }
});
db.exec('COMMIT');
} catch(e){ db.exec('ROLLBACK'); throw e; }
if(n) console.log('Gewerke-Vorsatz aus ' + n + ' Kommentaren entfernt.');
}
function kommentareSpiegelstricheUmstellen(){
const zeilen = db.prepare("SELECT week_label, project_id, gewerk, status FROM project_kommentare WHERE status != ''").all();
const upd = db.prepare('UPDATE project_kommentare SET status = ? WHERE week_label = ? AND project_id = ? AND gewerk = ?');
let n = 0;
db.exec('BEGIN');
try {
zeilen.forEach(r => {
const neu = kommentarSpiegelstricheUmbrechen(r.status);
if(neu !== r.status){ upd.run(neu, r.week_label, r.project_id, r.gewerk); n++; }
});
db.exec('COMMIT');
} catch(e){ db.exec('ROLLBACK'); throw e; }
console.log('Spiegelstriche in ' + n + ' Bestandskommentaren auf eigene Zeilen gesetzt.');
}
// migrateAddPlanTodoLeistungsphase: rüstet die Spalte `leistungsphase` nach
// (idempotent). Kennzeichnet eine Aufgabe als Balken einer Leistungsphase
// ("LPH 5") — die Auslastung verteilt die Reststunden einer Phase auf die
// Wochen ihres Balkens. Gesetzt von "Terminplan aus Honorar" und vom
// Projekt-Assistenten
// und "+ Neu" → "Leistungsphase"; ältere Balken kennzeichnet
// phasenbalkenAusTitel() einmalig.
function migrateAddPlanTodoLeistungsphase(){
const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name);
if(!cols.includes('leistungsphase')){
db.exec("ALTER TABLE plan_todos ADD COLUMN leistungsphase TEXT NOT NULL DEFAULT ''");
console.log('plan_todos um leistungsphase ergänzt.');
}
// Phasenbalken sind seit Eriks Wunsch NUR noch über leistungsphase eine
// Phase, nicht mehr über ihren Titel. Bestehende Balken, die bisher allein
// am Titel erkannt wurden ("LPH 5 (307 h)", "LPH 5 …", "W+M"), werden
// einmalig gekennzeichnet — danach zählt nur das Feld.
einmaligeUmstellung('phasenbalken-aus-titel', phasenbalkenAusTitel);
}
// phasenbalkenAusTitel: kennzeichnet Balken, die bisher allein am Titel als
// Phase erkannt wurden. Läuft einmal je Datenbank und nach einem
// Backup-Import (ältere Backups kennen das Feld noch nicht).
function phasenbalkenAusTitel(){
const n = db.prepare(`UPDATE plan_todos SET leistungsphase = CASE
WHEN title GLOB 'W+M' OR title GLOB 'W+M *' OR title GLOB 'W+M(*' THEN 'W+M'
ELSE substr(title, 1, 5) END
WHERE leistungsphase = '' AND (title GLOB 'LPH [1-9]' OR title GLOB 'LPH [1-9] *' OR title GLOB 'LPH [1-9](*'
OR title GLOB 'W+M' OR title GLOB 'W+M *' OR title GLOB 'W+M(*')`).run().changes;
if(n) console.log(n + ' Phasenbalken anhand ihres Titels als Leistungsphase gekennzeichnet.');
}
// migrateAddPlanTodoAufwand: aufwand_stunden — geschätzter Aufwand einer
// Aufgabe; zählt zur Auslastung der Zuständigen (verteilt auf den Zeitraum
// der Aufgabe). Idempotent.
function migrateAddPlanTodoAufwand(){
const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name);
if(!cols.includes('aufwand_stunden')){
db.exec('ALTER TABLE plan_todos ADD COLUMN aufwand_stunden REAL NOT NULL DEFAULT 0');
console.log('plan_todos um aufwand_stunden ergänzt.');
}
}
// migrateAddPlanTodoNotiz: notiz — freier Text zur Aufgabe, z. B. die Mail,
// aus der „Termin aus Mail“ sie erzeugt hat (Absender, Datum, Text). Idempotent.
function migrateAddPlanTodoNotiz(){
const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name);
if(!cols.includes('notiz')){
db.exec("ALTER TABLE plan_todos ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
console.log('plan_todos um notiz ergänzt.');
}
const msCols = db.prepare("SELECT name FROM pragma_table_info('plan_milestones')").all().map(r => r.name);
if(!msCols.includes('notiz')){
db.exec("ALTER TABLE plan_milestones ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
console.log('plan_milestones um notiz ergänzt.');
}
}
// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach
// (idempotent, gleiches ALTER-TABLE-Muster wie migrateAddProjectBesprochen()).
// DEFAULT 1, damit jede bestehende Aufgabe ihr bisheriges Verhalten behält
// (sichtbar im GANTT, sobald Start UND Ende gesetzt sind) — die neue
// Checkbox im Aufgaben-Dialog ("Im Terminplan anzeigen", plan-modals.js)
// blendet gezielt EINZELNE Aufgaben aus, ändert an diesem Normalfall nichts.
// Erik auf eigenen Wunsch, nachdem "TODOs aus Protokoll" (plan-protokoll-
// todos.js) gezeigt hatte, dass ein einzelner Import leicht 15-25 Aufgaben
// auf einmal anlegt — alle MIT Datum (siehe dortiger Kopfkommentar), also
// bisher alle zwingend auch als GANTT-Balken.
// migrateAddLphTemplateGewerk: Aufgaben-Vorlagen je Gewerk (Eriks Wunsch).
// Bestandsvorlagen behalten gewerk = '' und gelten dann für alle Gewerke.
function migrateAddLphTemplateGewerk(){
const cols = db.prepare("SELECT name FROM pragma_table_info('lph_templates')").all().map(r => r.name);
if(!cols.includes('gewerk')){
db.exec("ALTER TABLE lph_templates ADD COLUMN gewerk TEXT NOT NULL DEFAULT ''");
console.log('lph_templates um gewerk ergänzt.');
}
// anteil: Anteil des Schritts in Prozent (z. B. nach Siemon-Tabelle).
// Beim Einfügen bekommt jeder Schritt diesen Anteil an der Dauer der Phase
// (js/plan/plan-templates.js) — Eriks Wunsch. 0 = kein Anteil hinterlegt.
if(!cols.includes('anteil')){
db.exec('ALTER TABLE lph_templates ADD COLUMN anteil REAL NOT NULL DEFAULT 0');
console.log('lph_templates um anteil ergänzt.');
}
// enthalten_in: Buchstaben der Schritte, in denen dieser enthalten ist
// (Siemon-Tabelle „in a) enth.“, z. B. 'a' oder 'a,b') — er läuft beim
// Einfügen zeitgleich mit ihnen statt mit eigenem Anteil.
if(!cols.includes('enthalten_in')){
db.exec("ALTER TABLE lph_templates ADD COLUMN enthalten_in TEXT NOT NULL DEFAULT ''");
console.log('lph_templates um enthalten_in ergänzt.');
}
}
function migrateAddPlanTodoShowInGantt(){
const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name);
if(!cols.includes('show_in_gantt')){
db.exec('ALTER TABLE plan_todos ADD COLUMN show_in_gantt INTEGER NOT NULL DEFAULT 1');
console.log('plan_todos um show_in_gantt ergänzt.');
}
}
// migrateAddProjectUpdatedAt: rüstet die Spalte `updated_at` nach (idempotent,
// gleiches ALTER-TABLE-Muster wie migrateAddProjectBesprochen()). Gebraucht von
// der Planung-Sandbox, siehe Spaltenkommentar am CREATE TABLE oben.
function migrateAddProjectUpdatedAt(){
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
if(!cols.includes('updated_at')){
db.exec("ALTER TABLE projects ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''");
console.log('Projekt-Tabelle um updated_at ergänzt.');
}
}
// migrateAddUserAiKeyModel: rüstet die Spalte `model` nach (idempotent,
// gleiches Muster wie migrateAddProjectBesprochen()). Leer = die in
// erkennePunkteMitChatGPT()/erkennePunkteMitClaude()/pruefePlanMitChatGPT()/
// pruefePlanMitClaude() hartcodierte Vorgabe gilt weiterhin — nötig, seit
// Erik mit Mammouth.ai einen eigenständigen Anbieter mit eigenem
// Modell-Katalog anbinden wollte: "gpt-4o-mini"/"gpt-4o" sind feste
// OpenAI-Modellnamen, die bei einem fremden Anbieter schlicht nicht
// existieren (HTTP 400 "Invalid model name ... Call /v1/models to view
// available models for your key"). Bisher ließ sich bei "Eigener Server"
// nur die URL überschreiben, nie der Modellname — dieselbe Lücke, die schon
// bei der API-URL bestand, bevor sie ergänzt wurde (siehe
// istGueltigeAiUrl()/normalisiereAiUrl() oben).
function migrateAddUserAiKeyModel(){
const cols = db.prepare("SELECT name FROM pragma_table_info('user_ai_keys')").all().map(r => r.name);
if(!cols.includes('model')){
db.exec("ALTER TABLE user_ai_keys ADD COLUMN model TEXT NOT NULL DEFAULT ''");
console.log('user_ai_keys-Tabelle um model ergänzt.');
}
}
// migrateAddProtokollQuelle: rüstet die beiden Spalten nach, die ein
// importiertes Protokoll von einem manuell angelegten unterscheiden
// (idempotent, gleiches Muster wie migrateAddProjectBesprochen()). Beide
// bleiben für ein manuell angelegtes Protokoll leer — siehe
// legeProtokollAn() und CLAUDE.md "### Protokolle".
function migrateAddProtokollQuelle(){
const cols = db.prepare("SELECT name FROM pragma_table_info('project_protokolle')").all().map(r => r.name);
if(!cols.includes('quelle_dateiname')){
db.exec("ALTER TABLE project_protokolle ADD COLUMN quelle_dateiname TEXT NOT NULL DEFAULT ''");
db.exec("ALTER TABLE project_protokolle ADD COLUMN quelle_stored_name TEXT NOT NULL DEFAULT ''");
console.log('Protokoll-Tabelle um Quelle-Spalten ergänzt.');
}
}
// migrateAddProtokollPunktErstelltAm: rüstet das Datum nach, an dem ein
// Punkt zum ERSTEN Mal erfasst wurde (Erik's Wunsch) — bestehende Zeilen
// bleiben leer, da ihr echtes Entstehungsdatum nicht mehr rekonstruierbar
// ist (idempotent, gleiches Muster wie migrateAddProtokollQuelle()).
function migrateAddProtokollPunktErstelltAm(){
const cols = db.prepare("SELECT name FROM pragma_table_info('protokoll_punkte')").all().map(r => r.name);
if(!cols.includes('erstellt_am')){
db.exec("ALTER TABLE protokoll_punkte ADD COLUMN erstellt_am TEXT NOT NULL DEFAULT ''");
console.log('Protokoll-Punkte-Tabelle um erstellt_am ergänzt.');
}
}
// migrateAddProtokollPunktStatus: löst das bisherige boolesche `erledigt`
// in einen Status mit drei Werten auf (offen/erledigt/info, Eriks Wunsch
// nach einem dritten Zustand für rein informative Punkte ohne eigene
// Handlung). `erledigt` bleibt als Spalte bestehen und wird weiterhin
// synchron mitgeschrieben (reine Rückwärtskompatibilität, z. B. für
// IMPORT_TABLES-Restores älterer Backups) — maßgeblich für die Anwendung
// ist ab hier ausschließlich `status`. Bestehende Zeilen werden anhand
// ihres bisherigen `erledigt`-Werts befüllt, der ADD-COLUMN-Standardwert
// 'offen' allein würde erledigte Altpunkte fälschlich wieder öffnen.
function migrateAddProtokollPunktStatus(){
const cols = db.prepare("SELECT name FROM pragma_table_info('protokoll_punkte')").all().map(r => r.name);
if(!cols.includes('status')){
db.exec("ALTER TABLE protokoll_punkte ADD COLUMN status TEXT NOT NULL DEFAULT 'offen'");
db.exec("UPDATE protokoll_punkte SET status = 'erledigt' WHERE erledigt = 1");
console.log('Protokoll-Punkte-Tabelle um status ergänzt.');
}
}
// migrateAddProtokollPunktEntfaelltNaechstes: rüstet das Merkmal nach, das
// einem erledigten Punkt EINE Gnadenrunde gibt (Eriks Wunsch): 'offen' und
// 'info' laufen wie bisher unbegrenzt weiter, aber 'erledigt' soll nicht
// sofort verschwinden, sondern genau einmal noch — als erledigt markiert,
// zusätzlich mit einem Hinweis "entfällt im nächsten Protokoll" — im
// nächsten Protokoll auftauchen und erst im übernächsten entfallen. Dieses
// Flag steht NICHT auf dem Punkt, der gerade erledigt markiert wurde,
// sondern wird erst von legeProtokollAn() auf die fortgeschriebene KOPIE
// gesetzt, wenn ihre Quelle status='erledigt' hatte — siehe dort. Idempotent,
// gleiches Muster wie migrateAddProtokollPunktStatus().
function migrateAddProtokollPunktEntfaelltNaechstes(){
const cols = db.prepare("SELECT name FROM pragma_table_info('protokoll_punkte')").all().map(r => r.name);
if(!cols.includes('entfaellt_naechstes')){
db.exec("ALTER TABLE protokoll_punkte ADD COLUMN entfaellt_naechstes INTEGER NOT NULL DEFAULT 0");
console.log('Protokoll-Punkte-Tabelle um entfaellt_naechstes ergänzt.');
}
}
// ensureSandboxWeek: legt die reservierte `weeks`-Zeile für die Planung-
// Sandbox einmalig an (INSERT OR IGNORE — beim zweiten und jedem weiteren
// Start ist sie schon da). Kein echtes Datum, nie gesperrt. Alle Terminpläne
// der Planung hängen an diesem einen `week_label`, siehe SANDBOX_WEEK_LABEL.
function ensureSandboxWeek(){
db.prepare(`INSERT OR IGNORE INTO weeks (label, week_id, date, locked, updated_at)
VALUES (?, 'sandbox', '', 0, '')`).run(SANDBOX_WEEK_LABEL);
}
// getIsoWeekInfo: ISO-Kalenderwoche UND -Wochenjahr eines Datums (das
// Wochenjahr kann an Jahresgrenzen vom Kalenderjahr abweichen — Silvester
// kann in KW 1 des Folgejahres liegen). Portiert von getWeekNumber()
// (public/js/utils.js) — reines, browserunabhängiges JS, hier um das
// Wochenjahr erweitert, das für den Sitzungswechsel unten gebraucht wird.
// Parameter: date (Date). Rückgabe: {week, year}.
function getIsoWeekInfo(date){
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const year = d.getUTCFullYear();
const yearStart = new Date(Date.UTC(year, 0, 1));
const week = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
return { week, year };
}
// formatWeekLabel: "KW <n zweistellig> / <JJJJ>" — das Format, das sowohl
// validateWeekLabel()s Regex (früher public/js/weeks.js) als auch
// weekSortKey()/weekSortKeyServer() erwarten.
function formatWeekLabel(week, year){
return 'KW ' + String(week).padStart(2, '0') + ' / ' + year;
}
// ensureCurrentWeekRollover: legt automatisch eine neue Sitzung an, sobald
// der Kalender in eine neue ISO-Woche gewechselt hat — unabhängig davon, ob
// irgendjemand die Anwendung gerade nutzt (siehe CLAUDE.md, Abschnitt zum
// automatischen Sitzungswechsel). Ersetzt die früheren, manuellen Knöpfe
// "Neue Sitzung"/"Sitzung duplizieren" (public/js/weeks.js, entfernt).
// Repliziert exakt das Rücksetzverhalten des früheren cloneWeek(): neue
// week-/Projekt-ids, Kommentar/Verfasser/Zeitstempel/besprochen geleert,
// alles andere (Beteiligte, Zuordnungen, Terminpläne, Honorar, beauftragte
// Leistungsphasen, Gewerke, project_uid) bleibt unverändert erhalten. Sperrt
// anschließend — sofern app_settings.autoLockAktiv aktiv ist — alle anderen
// echten Sitzungen, dieselbe Invariante, die applyAutoLock() bisher nur
// clientseitig durchsetzte. Ohne jede bestehende echte Sitzung (frische
// Installation) wird stattdessen eine leere Sitzung angelegt (wie es früher
// newWeek() tat), damit ein frisches System nicht für immer ohne jede
// Sitzung dasteht. Fehler werden geloggt, nie geworfen — darf den
// Serverbetrieb nie stören. Keine Parameter, keine Rückgabe.
function ensureCurrentWeekRollover(){
try {
// Nur die jüngste Sitzung laden — alle Sitzungen stündlich komplett
// zusammenzusetzen kostete auf dem Pi Zero unnötig Speicher.
const juengstesLabel = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL)
.map(r => r.label)
.sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b)))
.pop();
const echteWochen = juengstesLabel === undefined ? [] : loadWeeks(juengstesLabel);
const { week, year } = getIsoWeekInfo(new Date());
const heuteKey = String(year) + '-' + String(week).padStart(2, '0');
const neuesLabel = formatWeekLabel(week, year);
let neueWoche;
if(echteWochen.length === 0){
// Frische Installation: keine Quelle zum Kopieren vorhanden.
neueWoche = {
id: crypto.randomUUID(), label: neuesLabel, date: heuteIsoLokal(),
people: loadGlobalPeople(), projects: [], attendance: {}, locked: false
};
} else {
const sortiert = echteWochen.slice()
.sort((a, b) => weekSortKeyServer(a.label).localeCompare(weekSortKeyServer(b.label)));
const letzte = sortiert[sortiert.length - 1];
const letzteKey = weekSortKeyServer(letzte.label);
if(heuteKey <= letzteKey) return; // schon auf dem Stand der aktuellen Kalenderwoche
neueWoche = JSON.parse(JSON.stringify(letzte));
neueWoche.id = crypto.randomUUID();
neueWoche.label = neuesLabel;
neueWoche.date = heuteIsoLokal();
neueWoche.attendance = {};
neueWoche.locked = false;
delete neueWoche.updatedAt;
(neueWoche.projects || []).forEach(p => {
p.id = crypto.randomUUID();
// Eriks Wunsch: Der Kommentar der Vorwoche wird je Gewerk übernommen
// und in der Besprechung angepasst; nur der Besprochen-Haken gehört
// zur Sitzung und beginnt leer.
const vorher = p.kommentare || {};
p.kommentare = {};
Object.keys(vorher).forEach(g => {
const k = vorher[g] || {};
if(!String(k.status || '').trim()) return;
p.kommentare[g] = { status: k.status, statusAuthor: k.statusAuthor || '',
statusAt: k.statusAt || '', besprochen: false };
});
delete p.statusState;
});
}
saveWeekRelational(neueWoche);
console.log('Automatischer Sitzungswechsel: neue Sitzung "' + neuesLabel + '" angelegt.');
if(typeof ereignisSenden === 'function') ereignisSenden({ typ: 'neu' }, null);
if(Number(readAppSettings().autoLockAktiv)){
const nowIso = new Date().toISOString();
db.prepare(`UPDATE weeks SET locked = 1, updated_at = ?
WHERE label != ? AND label != ?`).run(nowIso, neuesLabel, SANDBOX_WEEK_LABEL);
}
} catch(err){
console.error('Automatischer Sitzungswechsel fehlgeschlagen:', err);
}
}
function migrateAddProjectUid(){
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
if(!cols.includes('project_uid')){
db.exec("ALTER TABLE projects ADD COLUMN project_uid TEXT NOT NULL DEFAULT ''");
} else {
// Die Spalte allein genügt als Abschluss-Kennzeichen NICHT: Das ALTER
// TABLE läuft außerhalb der Transaktion, das Nachfüllen darin. Brach das
// Nachfüllen einmal ab (z. B. SQLITE_BUSY), existierte die Spalte, war
// aber überall leer — und jeder weitere Start übersprang die Migration.
// Die Projekt-Historie über Sitzungen hinweg wäre dauerhaft tot gewesen.
const offen = db.prepare("SELECT COUNT(*) AS c FROM projects WHERE project_uid = ''").get();
if(!offen || offen.c === 0) return; // wirklich fertig
console.log('Migriere: project_uid wird nachgefüllt (' + offen.c + ' Zeile(n) ohne Kennung) …');
}
// Backfill für Bestandsdaten: Zeilen mit gleicher, nicht-leerer nr über
// alle Wochen hinweg gelten als dasselbe reale Projekt und bekommen
// dieselbe uid. Zeilen ohne nr können nicht zusammengeführt werden (kein
// anderer stabiler Schlüssel vorhanden) und bekommen je eine eigene uid.
// Bekannte Grenze dieses einmaligen Rückblicks: eine in der Vergangenheit
// wiederverwendete nr (zwei echte Projekte) wird fälschlich zusammengeführt,
// eine mitten in der Historie geänderte nr wird fälschlich getrennt — ab
// dem Zeitpunkt dieser Migration tritt das Problem nicht mehr auf, da
// project_uid danach nie wieder aus nr neu berechnet wird.
const rows = db.prepare('SELECT week_label, project_id, nr, project_uid FROM projects').all();
const uidByNr = new Map();
// Bereits vergebene Kennungen als Vorgabe übernehmen: Beim Nachfüllen eines
// Teilbestands (siehe Guard oben) dürfen vorhandene Kennungen NICHT neu
// gewürfelt werden — sonst risse die Verbindung desselben Projekts über
// mehrere Sitzungen hinweg ab.
rows.forEach(r => {
const key = (r.nr || '').trim();
if(key && r.project_uid && !uidByNr.has(key)) uidByNr.set(key, r.project_uid);
});
const update = db.prepare('UPDATE projects SET project_uid = ? WHERE week_label = ? AND project_id = ?');
db.exec('BEGIN');
try {
rows.forEach(r => {
if(r.project_uid) return; // schon gesetzt, nicht anfassen
const key = (r.nr || '').trim();
let projectUid;
if(key){
if(!uidByNr.has(key)) uidByNr.set(key, crypto.randomUUID());
projectUid = uidByNr.get(key);
} else {
projectUid = crypto.randomUUID();
}
update.run(projectUid, r.week_label, r.project_id);
});
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
console.error('project_uid-Migration fehlgeschlagen, Datenbank unverändert:', err);
throw err;
}
console.log(`project_uid vergeben: ${rows.length} Zeile(n), ${uidByNr.size} eindeutige Projekt-Historie(n) über nr gruppiert.`);
}
// migrateAddProjectHonorar: ergänzt die Spalte `honorar` in der Tabelle
// `projects` (vereinbartes Netto-Honorar in Euro, Grundlage der
// Honorar-Terminplanung). Idempotent, gleiches Muster wie
// migrateWeeksUpdatedAt(). Bestandsprojekte bekommen 0 und gelten damit als
// "kein Honorar hinterlegt" — die Honorar-Maske verlangt dann eine Eingabe,
// bevor sie rechnet.
function migrateAddProjectHonorar(){
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
if(cols.includes('honorar')) return;
db.exec('ALTER TABLE projects ADD COLUMN honorar REAL NOT NULL DEFAULT 0');
console.log('Projekte-Tabelle um honorar ergänzt.');
}
// migrateAddProjectStatusAuthor: ergänzt die Spalten `status_author` und
// `status_at` in der Tabelle `projects` — Verfasser (Anzeigename zum
// Zeitpunkt des Schreibens) und Zeitstempel des Wochenkommentars. Bewusst
// der Anzeigename und nicht die user_id: der Kommentar ist ein historischer
// Eintrag, er soll auch nach dem Löschen oder Umbenennen eines Kontos noch
// aussagen, wer ihn damals verfasst hat. Idempotent, gleiches Muster wie
// migrateAddProjectHonorar(). Bestandskommentare bekommen leere Werte und
// werden in der Oberfläche als "unbekannt" ausgewiesen — der Verfasser ist
// rückwirkend nicht ermittelbar.
function migrateAddProjectStatusAuthor(){
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
if(cols.includes('status_author')) return;
db.exec("ALTER TABLE projects ADD COLUMN status_author TEXT NOT NULL DEFAULT ''");
db.exec("ALTER TABLE projects ADD COLUMN status_at TEXT NOT NULL DEFAULT ''");
console.log('Projekt-Tabelle um status_author/status_at ergänzt.');
}
// migrateAddProjectAuftragsstatus: rüstet die Spalte `auftragsstatus` nach
// (idempotentes ALTER TABLE, gleiches Muster wie
// migrateAddProjectBesprochen()). Bestandszeilen bekommen über den
// Spalten-Default 'beauftragt' — der Altbestand ist beauftragte Arbeit, und
// jeder andere Anfangswert hätte laufende Projekte rückwirkend zu Akquise
// erklärt (siehe DEFAULT_AUFTRAGSSTATUS).
function migrateAddProjectAuftragsstatus(){
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
if(!cols.includes('auftragsstatus')){
db.exec("ALTER TABLE projects ADD COLUMN auftragsstatus TEXT NOT NULL DEFAULT 'beauftragt'");
console.log('Projekt-Tabelle um auftragsstatus ergänzt.');
}
}
// migrateUsersDepartment: ergänzt die Spalten `department`/`initials` in
// bestehenden users-Tabellen (idempotent; neue DBs haben sie bereits).
function migrateUsersDepartment(){
const cols = db.prepare("SELECT name FROM pragma_table_info('users')").all().map(r => r.name);
// einfuehrung_am: wann das Konto den Rundgang (public/js/einfuehrung.js)
// abgeschlossen hat; leer = steht noch aus. Bewusst auch für bestehende
// Konten leer (Eriks Wunsch: jeder macht den Rundgang einmal).
if(!cols.includes('einfuehrung_am')){
db.exec("ALTER TABLE users ADD COLUMN einfuehrung_am TEXT NOT NULL DEFAULT ''");
console.log('Benutzer-Tabelle um einfuehrung_am ergänzt.');
}
// dashboard_reihenfolge: eigene Reihenfolge der Projekte auf "Meine
// Aufgaben" (JSON-Liste von Projekt-Schlüsseln, meist project_uid); leer =
// wie in der Projektliste. Je Konto, damit sie auf jedem Gerät gilt.
// kalender_token: geheime Adresse des Kalender-Abos (/kalender/<token>.ics),
// leer = abgeschaltet. Siehe "Kalender-Abo für Outlook".
if(!cols.includes('kalender_token')){
db.exec("ALTER TABLE users ADD COLUMN kalender_token TEXT NOT NULL DEFAULT ''");
console.log('Benutzer-Tabelle um kalender_token ergänzt.');
}
if(!cols.includes('dashboard_reihenfolge')){
db.exec("ALTER TABLE users ADD COLUMN dashboard_reihenfolge TEXT NOT NULL DEFAULT ''");
console.log('Benutzer-Tabelle um dashboard_reihenfolge ergänzt.');
}
if(!cols.includes('department')){
db.exec("ALTER TABLE users ADD COLUMN department TEXT NOT NULL DEFAULT ''");
console.log('Benutzer-Tabelle um department ergänzt.');
}
if(!cols.includes('initials')){
db.exec("ALTER TABLE users ADD COLUMN initials TEXT NOT NULL DEFAULT ''");
console.log('Benutzer-Tabelle um initials ergänzt.');
}
// capacity_hours: vertragliche Wochenstunden. Bis dahin kannte die
// Auslastungsrechnung keine Teilzeit — sie maß jede Person an einer vollen
// Stelle, eine halbe Stelle erschien deshalb dauerhaft unterbeschäftigt
// (in js/load/load.js ausdrücklich als offener Punkt vermerkt). 0 bedeutet
// "nicht gepflegt" und wird wie eine volle Stelle behandelt, damit
// Bestandskonten ihr bisheriges Bild behalten.
if(!cols.includes('capacity_hours')){
db.exec('ALTER TABLE users ADD COLUMN capacity_hours REAL NOT NULL DEFAULT 0');
console.log('Benutzer-Tabelle um capacity_hours ergänzt.');
}
}
// migrateUsersVorNachname: Vor- und Nachname als eigene Spalten
// (first_name/last_name). Bis dahin gab es nur den Anzeigenamen; daraus
// ließen sich Benutzername und E-Mail-Adresse nicht verlässlich ableiten (ein
// einzelnes Wort im Feld ergab z. B. nur den Nachnamen). display_name bleibt
// bestehen und wird beim Anlegen/Bearbeiten als "Vorname Nachname" gesetzt —
// alle übrigen Stellen lesen weiter display_name. Bestandskonten bekommen
// Vor-/Nachname einmalig aus display_name (erstes Wort / Rest). Benutzernamen
// bleiben unverändert: Angemeldet wird mit dem Anfang der E-Mail-Adresse
// (z. B. e.dunin), und genau so heißen die Bestandskonten bereits.
function migrateUsersVorNachname(){
const cols = db.prepare("SELECT name FROM pragma_table_info('users')").all().map(r => r.name);
if(cols.includes('first_name') && cols.includes('last_name')) return;
if(!cols.includes('first_name')) db.exec("ALTER TABLE users ADD COLUMN first_name TEXT NOT NULL DEFAULT ''");
if(!cols.includes('last_name')) db.exec("ALTER TABLE users ADD COLUMN last_name TEXT NOT NULL DEFAULT ''");
const rows = db.prepare('SELECT id, display_name FROM users').all();
const setNamen = db.prepare('UPDATE users SET first_name = ?, last_name = ? WHERE id = ?');
rows.forEach(r => {
const woerter = String(r.display_name || '').trim().split(/\s+/).filter(Boolean);
setNamen.run(woerter[0] || '', woerter.slice(1).join(' '), r.id);
});
console.log('Benutzer-Tabelle um Vor-/Nachname ergänzt (' + rows.length + ' Konten aus dem Anzeigenamen befüllt).');
}
// dedupeUserPersonIds: person_id musste bisher nur je Abteilung eindeutig
// sein (alte people-Tabelle); jetzt identifiziert person_id global einen
// Benutzer, daher müssen Bestandskollisionen vor dem UNIQUE INDEX aufgelöst
// werden. Alle bis auf die erste Zeile je Kollision bekommen "_<id>" angehängt.
function dedupeUserPersonIds(){
const dupes = db.prepare(`SELECT person_id, COUNT(*) c FROM users WHERE person_id != '' GROUP BY person_id HAVING c > 1`).all();
dupes.forEach(d => {
const rows = db.prepare('SELECT id FROM users WHERE person_id = ? ORDER BY id').all(d.person_id);
rows.slice(1).forEach(r => {
const newId = d.person_id + '_' + r.id;
db.prepare('UPDATE users SET person_id = ? WHERE id = ?').run(newId, r.id);
console.warn(`Person-ID-Kollision behoben: Benutzer #${r.id} bekommt "${newId}" statt "${d.person_id}" (bitte in der Benutzerverwaltung prüfen).`);
});
});
}
// ensureUserPersonIdUniqueIndex: löst zuerst Bestandskollisionen auf (siehe
// dedupeUserPersonIds) und legt danach den UNIQUE-Index an (idempotent,
// überspringt leere person_id-Werte per partiellem Index).
function ensureUserPersonIdUniqueIndex(){
dedupeUserPersonIds();
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_person_id_unique ON users(person_id) WHERE person_id != ''`);
}
// migrateRetirePeopleTablesIntoUsers: löst das alte, abteilungsbezogene
// Projektbeteiligte-Schema (people/person_roles/person_absences) endgültig ab,
// nachdem es durch die globalen User-Accounts (users/user_roles/user_absences)
// ersetzt wurde. Idempotent: läuft nur, solange die Tabelle `people` noch
// existiert. person_absences wird bewusst NICHT übernommen (enthielt bereits
// vorher keine sinnvollen Daten, siehe serializeGlobalPeople-Altcode), und für
// historische people-Zeilen ohne passenden User wird KEIN Platzhalter-User
// angelegt (würde die Benutzerliste mit nicht-loginfähigen Karteileichen
// verschmutzen) — stattdessen wird einmalig auf der Konsole protokolliert.
function migrateRetirePeopleTablesIntoUsers(){
const stillExists = db.prepare("SELECT COUNT(*) c FROM sqlite_master WHERE type='table' AND name='people'").get().c > 0;
if(!stillExists) return;
console.log('Migriere altes Projektbeteiligte-Schema (people/person_roles/person_absences) …');
const legacyPeople = db.prepare('SELECT * FROM people').all();
const legacyRoles = db.prepare('SELECT * FROM person_roles').all();
let merged = 0; const unmatched = [];
db.exec('BEGIN');
try {
legacyPeople.forEach(p => {
const user = db.prepare('SELECT id FROM users WHERE department = ? AND person_id = ?').get(p.department, p.person_id);
if(!user){ unmatched.push(p.department + '/' + p.person_id + ' (' + p.full_name + ')'); return; }
legacyRoles.filter(r => r.department === p.department && r.person_id === p.person_id)
.forEach(r => db.prepare('INSERT OR IGNORE INTO user_roles (user_id, role, role_order) VALUES (?,?,?)').run(user.id, r.role, r.role_order));
merged++;
});
db.exec('DROP TABLE people; DROP TABLE person_roles; DROP TABLE person_absences;');
db.exec('COMMIT');
console.log(`Migration abgeschlossen: ${merged} Personen-Rollen übernommen. ${unmatched.length} alte Einträge ohne passenden Benutzer (nicht automatisch migrierbar):`);
unmatched.forEach(u => console.log(' - ' + u));
} catch(err){ db.exec('ROLLBACK'); console.error('Migration fehlgeschlagen:', err.message); throw err; }
}
// ---------- Auth: Passwort-Hashing ----------
// hashPassword: erzeugt Salt + scrypt-Hash für ein neues/geändertes Passwort.
function hashPassword(password){
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
return { hash, salt };
}
// verifyPassword: prüft ein eingegebenes Passwort gegen gespeicherten Salt+Hash
// (zeitkonstanter Vergleich gegen Timing-Angriffe).
function verifyPassword(password, salt, expectedHash){
const hash = crypto.scryptSync(password, salt, 64);
const expected = Buffer.from(expectedHash, 'hex');
return hash.length === expected.length && crypto.timingSafeEqual(hash, expected);
}
// bootstrapInitialAdmin: legt beim allerersten Start (users-Tabelle leer) ein
// Admin-Konto mit zufällig generiertem Passwort an (kein Hardcoded-Passwort im
// Code). Läuft danach nie wieder (Guard: COUNT(*) > 0). Das Klartext-Passwort
// wird einmalig auf die Konsole ausgegeben und zusätzlich nach
// sessions/INITIAL_ADMIN_PASSWORD.txt geschrieben (liegt außerhalb von
// public/, wird also nie von express.static ausgeliefert).
function bootstrapInitialAdmin(){
const row = db.prepare('SELECT COUNT(*) AS c FROM users').get();
if(row.c > 0) return;
const password = crypto.randomBytes(15).toString('base64url');
const { hash, salt } = hashPassword(password);
const now = new Date().toISOString();
db.prepare('INSERT INTO users (username, display_name, role, password_hash, password_salt, must_change_password, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)')
.run('admin', 'Administrator', 'admin', hash, salt, now, now);
const msg = 'Erstmaliges Admin-Konto angelegt.\n Benutzername: admin\n Passwort: ' + password + '\n (Bitte nach dem ersten Login sofort ändern.)';
console.log(msg);
try {
fs.writeFileSync(path.join(SESSIONS_DIR, 'INITIAL_ADMIN_PASSWORD.txt'), msg + '\n', 'utf8');
} catch(err){
console.error('Konnte INITIAL_ADMIN_PASSWORD.txt nicht schreiben:', err);
}
}
// Migration beim Serverstart ausführen
migrateLegacySchema();
migrateWeeksUpdatedAt();
migrateRemoveDepartmentPartition();
migrateBackfillProjectGewerkeAusTags();
migrateAddProjectUid();
migrateAddProjectBesprochen();
migrateAddProjectHonorar();
migrateAddProjectStatusAuthor();
migrateAddProjectAuftragsstatus();
migrateDropProjectGroesse();
migrateAddProjectUpdatedAt();
migrateAddPlanTodoShowInGantt();
migrateAddLphTemplateGewerk();
migrateAddPlanTodoLeistungsphase();
migrateAddPlanTodoAufwand();
migrateAddPlanTodoNotiz();
migrateAddUserAiKeyModel();
migrateAddProtokollQuelle();
migrateAddProtokollPunktErstelltAm();
migrateAddProtokollPunktStatus();
migrateAddProtokollPunktEntfaelltNaechstes();
migrateUsersDepartment();
migrateUsersVorNachname();
migrateKommentareJeGewerk();
// ---------- Aufgaben-Vorlagen: Grundleistungen nach HOAI ----------
// Eriks Wunsch: die Aufgaben-Vorlagen je Gewerk mit den Grundleistungen der
// HOAI füllen — ARCH nach Anlage 10.1 (Gebäude und Innenräume), ELT und HLS
// nach Anlage 15.1 (Technische Ausrüstung; die HOAI unterscheidet die
// Anlagengruppen bei den Grundleistungen nicht). Titel sind gekürzt, der
// Buchstabe der HOAI steht am Ende. Ohne Termin (duration_days = 0, siehe
// lph_templates): Die Dauer legt man im Projekt fest. Die Prozentanteile der
// Siemon-Tabelle sind bewusst NICHT hinterlegt — sie lagen nicht vor.
// Läuft einmal (einmaligeUmstellung) und nur für Gewerke ohne eigene Vorlagen.
const HOAI_GRUNDLEISTUNGEN = {
gebaeude: {
'LPH 1': [
'Aufgabenstellung klären (Vorgaben, Bedarfsplanung) (a)',
'Ortsbesichtigung (b)',
'Zum Leistungs- und Untersuchungsbedarf beraten (c)',
'Entscheidungshilfen zur Auswahl weiterer Fachplaner formulieren (d)',
'Ergebnisse zusammenfassen, erläutern und dokumentieren (e)'],
'LPH 2': [
'Grundlagen analysieren, Leistungen mit Fachplanern abstimmen (a)',
'Zielvorstellungen abstimmen, auf Zielkonflikte hinweisen (b)',
'Vorplanung erarbeiten, Varianten untersuchen und bewerten (c)',
'Wesentliche Zusammenhänge, Vorgaben und Bedingungen klären (d)',
'Ergebnisse für Fachplaner bereitstellen, Koordination und Integration (e)',
'Vorverhandlungen über die Genehmigungsfähigkeit (f)',
'Kostenschätzung nach DIN 276, Vergleich mit Budget (g)',
'Terminplan mit den wesentlichen Vorgängen erstellen (h)',
'Ergebnisse zusammenfassen, erläutern und dokumentieren (i)'],
'LPH 3': [
'Entwurfsplanung bis zum vollständigen Entwurf erarbeiten (a)',
'Ergebnisse für Fachplaner bereitstellen, Koordination und Integration (b)',
'Objektbeschreibung (c)',
'Verhandlungen über die Genehmigungsfähigkeit (d)',
'Kostenberechnung nach DIN 276, Vergleich mit Kostenschätzung (e)',
'Terminplan fortschreiben (f)',
'Ergebnisse zusammenfassen, erläutern und dokumentieren (g)'],
'LPH 4': [
'Vorlagen und Nachweise für Genehmigungen erarbeiten und zusammenstellen (a)',
'Vorlagen einreichen (b)',
'Planungsunterlagen, Beschreibungen und Berechnungen ergänzen und anpassen (c)'],
'LPH 5': [
'Ausführungsplanung mit allen Einzelangaben erarbeiten (a)',
'Ausführungs-, Detail- und Konstruktionszeichnungen (b)',
'Ergebnisse für Fachplaner bereitstellen, Koordination und Integration (c)',
'Terminplan fortschreiben (d)',
'Ausführungsplanung während der Objektausführung fortschreiben (e)',
'Montagepläne auf Übereinstimmung mit der Ausführungsplanung prüfen (f)'],
'LPH 6': [
'Vergabeterminplan aufstellen (a)',
'Leistungsbeschreibungen mit Leistungsverzeichnissen aufstellen, Mengen ermitteln (b)',
'Schnittstellen zu den Leistungsbeschreibungen der Fachplaner abstimmen (c)',
'Kosten anhand bepreister Leistungsverzeichnisse ermitteln (d)',
'Kostenkontrolle: bepreiste LV mit Kostenberechnung vergleichen (e)',
'Vergabeunterlagen für alle Leistungsbereiche zusammenstellen (f)'],
'LPH 7': [
'Vergaben der Fachplaner koordinieren (a)',
'Angebote einholen (b)',
'Angebote prüfen und werten, Preisspiegel aufstellen (c)',
'Bietergespräche führen (d)',
'Vergabevorschläge erstellen, Vergabeverfahren dokumentieren (e)',
'Vertragsunterlagen für alle Leistungsbereiche zusammenstellen (f)',
'Kostenkontrolle: Ausschreibungsergebnisse mit bepreisten LV vergleichen (g)',
'Bei der Auftragserteilung mitwirken (h)'],
'LPH 8': [
'Ausführung überwachen (Genehmigung, Verträge, Pläne, Regeln der Technik) (a)',
'Ausführung einfacher Tragwerke auf Standsicherheitsnachweis überwachen (b)',
'An der Objektüberwachung Beteiligte koordinieren (c)',
'Terminplan (Balkendiagramm) aufstellen, fortschreiben, überwachen (d)',
'Bauablauf dokumentieren (Bautagebuch) (e)',
'Gemeinsames Aufmaß mit den ausführenden Unternehmen (f)',
'Rechnungsprüfung einschließlich Prüfen der Aufmaße (g)',
'Rechnungsprüfungen mit Auftragssummen inkl. Nachträgen vergleichen (h)',
'Kostenkontrolle: Abrechnung mit Vertragspreisen vergleichen (i)',
'Kostenfeststellung nach DIN 276 (j)',
'Abnahme organisieren, Mängel feststellen, Abnahmeempfehlung (k)',
'Öffentlich-rechtliche Abnahmen beantragen und teilnehmen (l)',
'Dokumentation, Zeichnungen und Berechnungen systematisch zusammenstellen (m)',
'Objekt übergeben (n)',
'Verjährungsfristen für Mängelansprüche auflisten (o)',
'Beseitigung der Abnahmemängel überwachen (p)'],
'LPH 9': [
'Innerhalb der Verjährungsfristen festgestellte Mängel fachlich bewerten (a)',
'Objektbegehung vor Ablauf der Verjährungsfristen (b)',
'Bei der Freigabe von Sicherheitsleistungen mitwirken (c)'],
},
technik: {
'LPH 1': [
'Aufgabenstellung im Benehmen mit dem Objektplaner klären (a)',
'Planungsrandbedingungen ermitteln, zum Leistungsbedarf und zur Erschließung beraten (b)',
'Ergebnisse zusammenfassen, erläutern und dokumentieren (c)'],
'LPH 2': [
'Grundlagen analysieren, beim Abstimmen der Leistungen mitwirken (a)',
'Planungskonzept erarbeiten: Vordimensionierung, Varianten, Raumbedarf (b)',
'Funktionsschema bzw. Prinzipschaltbild je Anlage aufstellen (c)',
'Fachübergreifende Prozesse, Randbedingungen und Schnittstellen klären (d)',
'Vorverhandlungen mit Behörden und Versorgern (e)',
'Kostenschätzung nach DIN 276 (2. Ebene) und Terminplanung (f)',
'Ergebnisse zusammenfassen, erläutern und dokumentieren (g)'],
'LPH 3': [
'Planungskonzept bis zum vollständigen Entwurf durcharbeiten (a)',
'Alle Systeme und Anlagenteile festlegen (b)',
'Anlagen berechnen und bemessen, Entwurf zeichnen, Schemata fortschreiben (c)',
'Berechnungsergebnisse übergeben, Durchführungen und Lasten für die Tragwerksplanung (d)',
'Verhandlungen mit Behörden über die Genehmigungsfähigkeit (e)',
'Kostenberechnung nach DIN 276 (3. Ebene) und Terminplanung (f)',
'Kostenkontrolle: Kostenberechnung mit Kostenschätzung vergleichen (g)',
'Ergebnisse zusammenfassen, erläutern und dokumentieren (h)'],
'LPH 4': [
'Vorlagen und Nachweise für Genehmigungen erarbeiten, bei Behördenverhandlungen mitwirken (a)',
'Planungsunterlagen, Beschreibungen und Berechnungen vervollständigen und anpassen (b)'],
'LPH 5': [
'Ausführungsplanung bis zur ausführungsreifen Lösung erarbeiten (a)',
'Berechnungen fortschreiben, Anlagen zeichnen, Schemata detaillieren, abstimmen (b)',
'Schlitz- und Durchbruchspläne anfertigen (c)',
'Terminplan fortschreiben (d)',
'Ausführungsplanung auf Stand der Ausschreibung fortschreiben und übergeben (e)',
'Montage- und Werkstattpläne prüfen und anerkennen (f)'],
'LPH 6': [
'Mengen als Grundlage für die Leistungsverzeichnisse ermitteln (a)',
'Vergabeunterlagen mit Leistungsverzeichnissen inkl. Wartung aufstellen (b)',
'Beim Abstimmen der Schnittstellen zu anderen Leistungsbeschreibungen mitwirken (c)',
'Kosten anhand bepreister Leistungsverzeichnisse ermitteln (d)',
'Kostenkontrolle: bepreiste LV mit Kostenberechnung vergleichen (e)',
'Vergabeunterlagen zusammenstellen (f)'],
'LPH 7': [
'Angebote einholen (a)',
'Angebote prüfen und werten, Preisspiegel aufstellen (b)',
'Bietergespräche führen (c)',
'Ausschreibungsergebnisse mit bepreisten LV und Kostenberechnung vergleichen (d)',
'Vergabevorschläge erstellen, bei der Dokumentation mitwirken (e)',
'Vertragsunterlagen zusammenstellen, bei der Auftragserteilung mitwirken (f)'],
'LPH 8': [
'Ausführung überwachen (Genehmigung, Verträge, Montagepläne, Regeln der Technik) (a)',
'Bei der Koordination der Projektbeteiligten mitwirken (b)',
'Terminplan (Balkendiagramm) aufstellen, fortschreiben, überwachen (c)',
'Bauablauf dokumentieren (Bautagebuch) (d)',
'Nachträge prüfen und bewerten (e)',
'Gemeinsames Aufmaß mit den ausführenden Unternehmen (f)',
'Rechnungsprüfung, Leistungsstand bescheinigen (g)',
'Kostenkontrolle: Abrechnung mit Vertragspreisen und Kostenanschlag vergleichen (h)',
'Kostenfeststellung (i)',
'Bei Leistungs- und Funktionsprüfungen mitwirken (j)',
'Fachtechnische Abnahme, Abnahmeprotokoll, Mängel feststellen (k)',
'Behördliche Abnahmen beantragen und teilnehmen (l)',
'Revisionsunterlagen auf Vollständigkeit prüfen (m)',
'Verjährungsfristen der Mängelansprüche auflisten (n)',
'Beseitigung der Abnahmemängel überwachen (o)',
'Dokumentation, Zeichnungen und Berechnungen systematisch zusammenstellen (p)'],
'LPH 9': [
'Innerhalb der Verjährungsfristen festgestellte Mängel fachlich bewerten (a)',
'Objektbegehung vor Ablauf der Verjährungsfristen (b)',
'Bei der Freigabe von Sicherheitsleistungen mitwirken (c)'],
},
};
function hoaiVorlagenEinfuegen(){
const vorhanden = new Set(db.prepare('SELECT DISTINCT gewerk FROM lph_templates').all().map(r => r.gewerk));
const ins = db.prepare(`INSERT INTO lph_templates (id, lph, gewerk, title, offset_days, duration_days, sort_order)
VALUES (?, ?, ?, ?, 0, 0, ?)`);
const ziele = [['ARCH', 'gebaeude'], ['ELT', 'technik'], ['HLS', 'technik']];
db.exec('BEGIN');
try {
let n = 0;
ziele.forEach(([gewerk, art]) => {
if(vorhanden.has(gewerk)) return; // eigene Vorlagen nie überschreiben
Object.entries(HOAI_GRUNDLEISTUNGEN[art]).forEach(([lph, titel]) => {
titel.forEach((t, i) => { ins.run(crypto.randomUUID(), lph, gewerk, t, i); n++; });
});
});
db.exec('COMMIT');
if(n) console.log('Aufgaben-Vorlagen: ' + n + ' HOAI-Grundleistungen angelegt.');
} catch(e){ db.exec('ROLLBACK'); throw e; }
}
einmaligeUmstellung('kommentar-spiegelstriche', kommentareSpiegelstricheUmstellen);
// kommentareInAktuelleSitzungUebernehmen: einmalig zur Einführung der
// Kommentar-Übernahme im Sitzungswechsel. Füllt in der jüngsten echten
// Sitzung jeden LEEREN Kommentar je Gewerk mit dem zuletzt geschriebenen aus
// einer früheren Sitzung (gleiche project_uid). Der Besprochen-Haken bleibt.
function kommentareInAktuelleSitzungUebernehmen(){
const labels = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL)
.map(r => r.label)
.sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b)));
if(labels.length < 2) return;
const aktuell = labels[labels.length - 1];
const rang = new Map(labels.map((l, i) => [l, i]));
const projekte = db.prepare("SELECT project_id, project_uid FROM projects WHERE week_label = ? AND project_uid != ''").all(aktuell);
const frueher = db.prepare(`SELECT k.week_label, k.gewerk, k.status, k.status_author, k.status_at
FROM project_kommentare k JOIN projects p ON p.week_label = k.week_label AND p.project_id = k.project_id
WHERE p.project_uid = ? AND k.week_label != ? AND k.week_label != ? AND trim(k.status) != ''`);
const schreiben = db.prepare(`INSERT INTO project_kommentare (week_label, project_id, gewerk, status, status_author, status_at, besprochen)
VALUES (?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(week_label, project_id, gewerk) DO UPDATE SET status = excluded.status,
status_author = excluded.status_author, status_at = excluded.status_at
WHERE trim(project_kommentare.status) = ''`);
const jetzt = new Date().toISOString();
let n = 0;
db.exec('BEGIN');
try {
projekte.forEach(pr => {
const juengste = new Map();
frueher.all(pr.project_uid, aktuell, SANDBOX_WEEK_LABEL).forEach(r => {
if(!rang.has(r.week_label)) return;
const bisher = juengste.get(r.gewerk);
if(!bisher || rang.get(r.week_label) > rang.get(bisher.week_label)) juengste.set(r.gewerk, r);
});
juengste.forEach((r, g) => {
const erg = schreiben.run(aktuell, pr.project_id, g, r.status, r.status_author || '', r.status_at || '');
if(erg.changes){
n++;
db.prepare('UPDATE projects SET updated_at = ? WHERE week_label = ? AND project_id = ?').run(jetzt, aktuell, pr.project_id);
}
});
});
if(n) db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, aktuell);
db.exec('COMMIT');
if(n) console.log('Kommentar-Übernahme: ' + n + ' Kommentar(e) in "' + aktuell + '" übernommen.');
} catch(e){ db.exec('ROLLBACK'); throw e; }
}
einmaligeUmstellung('kommentare-uebernehmen', kommentareInAktuelleSitzungUebernehmen);
// SIEMON_ANTEILE: Teilleistungsbewertung nach dem Siemon-Gutachten zur
// HOAI 2021 (Anlagenband zum Gutachten 2021-1, Anlage 1.1 Gebäude und 1.7
// Technische Ausrüstung, veröffentlicht u. a. auf architektenhonorar.de):
// ein Wert je Grundleistung in Prozent des Gesamthonorars, Summe je Phase =
// HOAI-Prozentsatz. Löst die zuerst hinterlegten Mittelwerte der Spannen aus
// den Siemon-Tabellen 2013 ab (SIEMON_2013 unten, nur noch zum Erkennen
// unveränderter Werte bei der Umstellung) — Eriks Meldung „die Werte sind
// nicht richtig“.
const SIEMON_ANTEILE = {
gebaeude: {
'LPH 1': { a: 0.75, b: 0.10, c: 0.75, d: 0.25, e: 0.15 },
'LPH 2': { a: 0.50, b: 0.10, c: 3.00, d: 0.85, e: 0.50, f: 0.30, g: 1.00, h: 0.50, i: 0.25 },
'LPH 3': { a: 11.00, b: 1.15, c: 0.40, d: 0.20, e: 1.25, f: 0.35, g: 0.65 },
'LPH 4': { a: 2.50, b: 0.25, c: 0.25 },
'LPH 5': { a: 10.00, b: 9.50, c: 3.00, d: 0.85, e: 0.40, f: 1.25 },
'LPH 6': { a: 0.20, b: 7.60, c: 0.35, d: 1.50, e: 0.25, f: 0.10 },
'LPH 7': { a: 0.25, b: 0.10, c: 2.00, d: 0.25, e: 0.80, f: 0.10, g: 0.25, h: 0.25 },
'LPH 8': { a: 18.00, b: 0.10, c: 1.50, d: 1.60, e: 0.75, f: 1.00, g: 3.75, h: 0.25, i: 0.85, j: 1.00,
k: 1.50, l: 0.10, m: 0.25, n: 0.25, o: 0.10, p: 1.00 },
'LPH 9': { a: 1.00, b: 0.80, c: 0.20 },
},
technik: {
'LPH 1': { a: 1.50, b: 0.30, c: 0.20 },
'LPH 2': { a: 0.80, b: 5.25, c: 1.00, d: 0.80, e: 0.20, f: 0.70, g: 0.25 },
'LPH 3': { a: 5.50, b: 0.25, c: 9.95, d: 0.25, e: 0.15, f: 0.65, g: 0.10, h: 0.15 },
'LPH 4': { a: 1.75, b: 0.25 },
'LPH 5': { a: 4.50, b: 8.50, c: 4.00, d: 0.50, e: 0.50, f: 4.00 },
'LPH 6': { a: 2.90, b: 3.05, c: 0.10, d: 0.75, e: 0.10, f: 0.10 },
'LPH 7': { a: 0.10, b: 3.50, c: 0.50, d: 0.25, e: 0.50, f: 0.15 },
'LPH 8': { a: 17.50, b: 0.30, c: 0.65, d: 0.50, e: 0.10, f: 2.25, g: 6.50, h: 0.85, i: 0.80, j: 0.50,
k: 2.50, l: 0.10, m: 0.75, n: 0.10, o: 1.50, p: 0.10 },
'LPH 9': { a: 0.50, b: 0.40, c: 0.10 },
},
};
// SIEMON_2013: die zuerst eingetragenen Werte (Mittelwerte der Spannen der
// Siemon-Tabellen 2013, Buchstabe = „in x) enthalten“). Nur noch dafür da,
// bei der Umstellung auf 2021 zu erkennen, was niemand verändert hat.
const SIEMON_2013 = {
gebaeude: {
'LPH 1': { a: 0.875, b: 'a', c: 0.875, d: 'c', e: 0.3 },
'LPH 2': { a: 0.375, b: 'a', c: 3.25, d: 1.5, e: 'd', f: 0.3, g: 1.125, h: 0.3, i: 0.3 },
'LPH 3': { a: 11, b: 1, c: 0.5, d: 0.75, e: 1.5, f: 0.375, g: 0.375 },
'LPH 4': { a: 3, b: 'a', c: 'a' },
'LPH 5': { a: 11.5, b: 11.5, c: 'a,b', d: 0.5, e: 0.75, f: 'a,b' },
'LPH 6': { a: 0.125, b: 8.5, c: 'b', d: 1.5, e: 'd', f: 'b' },
'LPH 7': { a: 0.3, b: 0.125, c: 3.125, d: 'c', e: 'c', f: 0.175, g: 0.375, h: 0.125 },
'LPH 8': { a: 21.5, b: 'a', c: 'a', d: 0.75, e: 0.375, f: 'g', g: 5.5, h: 1.25, i: 'h', j: 0.75,
k: 2, l: 'k', m: 0.175, n: 'k', o: 'k', p: 0.875 },
'LPH 9': { a: 0.625, b: 1.375, c: 'b' },
},
technik: {
'LPH 1': { a: 0.875, b: 0.875, c: 0.175 },
'LPH 2': { a: 0.375, b: 6, c: 'b', d: 1.5, e: 'd', f: 0.75, g: 0.175 },
'LPH 3': { a: 5.5, b: 'a', c: 10.5, d: 0.175, e: 'a', f: 1.125, g: 'f', h: 0.175 },
'LPH 4': { a: 2, b: 'a' },
'LPH 5': { a: 5, b: 9.5, c: 3, d: 0.3, e: 0.75, f: 3 },
'LPH 6': { a: 2.625, b: 3, c: 'a,b', d: 1.5, e: 'd', f: 'b' },
'LPH 7': { a: 0.05, b: 3.875, c: 'b', d: 0.75, e: 'b', f: 0.175 },
'LPH 8': { a: 19, b: 0.75, c: 0.375, d: 0.375, e: 0.5, f: 'g', g: 9, h: 1, i: 'h', j: 0.175,
k: 3.25, l: 'k', m: 0.625, n: 'k', o: 0.875, p: 0.175 },
'LPH 9': { a: 0.5, b: 0.625, c: 'b' },
},
};
// siemon2021Umstellen: ersetzt die Werte von 2013 durch die von 2021 — nur
// dort, wo noch genau der 2013 eingetragene Wert steht (selbst Gepflegtes
// bleibt). Einmalig (einmaligeUmstellung('siemon-anteile-2021')).
function siemon2021Umstellen(){
const art = { ARCH: 'gebaeude', ELT: 'technik', HLS: 'technik' };
const upd = db.prepare("UPDATE lph_templates SET anteil = ?, enthalten_in = '' WHERE id = ?");
let n = 0;
db.exec('BEGIN');
try {
db.prepare('SELECT * FROM lph_templates').all().forEach(t => {
const a = art[t.gewerk];
const m = String(t.title).match(/\(([a-p])\)\s*$/);
if(!a || !m) return;
const alt = (SIEMON_2013[a][t.lph] || {})[m[1]];
const neuW = (SIEMON_ANTEILE[a][t.lph] || {})[m[1]];
if(alt === undefined || neuW === undefined) return;
const unveraendert = typeof alt === 'number'
? (Math.abs((Number(t.anteil) || 0) - alt) < 1e-9 && !t.enthalten_in)
: (t.enthalten_in === alt && !(Number(t.anteil) > 0));
if(!unveraendert) return;
upd.run(neuW, t.id); n++;
});
db.exec('COMMIT');
if(n) console.log('Aufgaben-Vorlagen: ' + n + ' Anteile auf Siemon-Gutachten 2021 umgestellt.');
} catch(e){ db.exec('ROLLBACK'); throw e; }
}
// siemonAnteileEintragen: trägt die Werte in die HOAI-Vorlagen ein — je
// Gewerk und Phase über den Buchstaben am Ende des Titels („… (c)“). Nur
// Schritte ohne Anteil und ohne „enthalten in“, also nie etwas, das jemand
// schon selbst gepflegt hat. Einmalig (einmaligeUmstellung).
function siemonAnteileEintragen(){
const art = { ARCH: 'gebaeude', ELT: 'technik', HLS: 'technik' };
const upd = db.prepare('UPDATE lph_templates SET anteil = ?, enthalten_in = ? WHERE id = ?');
let n = 0;
db.exec('BEGIN');
try {
db.prepare("SELECT * FROM lph_templates WHERE anteil = 0 AND enthalten_in = ''").all().forEach(t => {
const tab = SIEMON_ANTEILE[art[t.gewerk]];
const m = String(t.title).match(/\(([a-p])\)\s*$/);
if(!tab || !tab[t.lph] || !m) return;
const wert = tab[t.lph][m[1]];
if(wert === undefined) return;
if(typeof wert === 'number') upd.run(wert, '', t.id); else upd.run(0, wert, t.id);
n++;
});
db.exec('COMMIT');
if(n) console.log('Aufgaben-Vorlagen: Siemon-Anteile für ' + n + ' Schritte eingetragen.');
} catch(e){ db.exec('ROLLBACK'); throw e; }
}
einmaligeUmstellung('hoai-aufgabenvorlagen', hoaiVorlagenEinfuegen);
einmaligeUmstellung('siemon-anteile', siemonAnteileEintragen);
einmaligeUmstellung('siemon-anteile-2021', siemon2021Umstellen);
// Idempotent (ein bereits entfernter Vorsatz ist weg) — läuft deshalb bei
// jedem Start und nach /import, damit ältere Backups ihn nicht zurückbringen.
kommentareVorsatzUmstellen();
ensureSandboxWeek();
ensureUserPersonIdUniqueIndex();
migrateRetirePeopleTablesIntoUsers();
// migrateAbsencesCreatedBy: ergänzt die Spalte created_by in bestehenden
// Datenbanken (idempotent). Altbestand behält 0 und ist damit nur von der
// betroffenen Person selbst oder von Administratoren löschbar.
(function migrateAbsencesCreatedBy(){
try {
const cols = db.prepare("SELECT name FROM pragma_table_info('user_absences')").all().map(r => r.name);
if(!cols.includes('created_by')){
db.exec("ALTER TABLE user_absences ADD COLUMN created_by INTEGER NOT NULL DEFAULT 0");
console.log('Migriert: user_absences.created_by ergänzt.');
}
} catch(err){
console.error('Migration user_absences.created_by fehlgeschlagen:', err.message);
}
})();
bootstrapInitialAdmin();
// Abgelaufene Sitzungen beim Start entfernen. Sie wurden bisher nur ignoriert,
// blieben aber für immer in der Tabelle stehen — und damit auch in jedem
// Backup-Snapshot.
try {
const removed = db.prepare('DELETE FROM user_sessions WHERE expires_at <= ?').run(new Date().toISOString());
if(removed && removed.changes) console.log('Abgelaufene Sitzungen entfernt:', removed.changes);
} catch(err){
console.error('Aufräumen der Sitzungen fehlgeschlagen:', err.message);
}
// ---------- Papierkorb gelöschter Projekte ----------
// deleted_projects: ein Schnappschuss (das vollständige Projekt-Objekt als
// JSON, wie es /load-all liefert — inkl. Terminplan, Beteiligten, LPH) je
// gelöschtem Projekt. Bewusst JSON statt relational: Der Eintrag wird nie
// abgefragt oder ausgewertet, nur 1:1 wieder in eine Sitzung eingesetzt.
// Dateien und Protokolle hängen an project_nr (nicht an der Projekt-ID) und
// werden beim Löschen gar nicht angefasst — nach dem Wiederherstellen sind
// sie deshalb ohne weiteres Zutun wieder da.
db.exec(`CREATE TABLE IF NOT EXISTS deleted_projects (
id TEXT PRIMARY KEY,
week_label TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL DEFAULT '',
project_uid TEXT NOT NULL DEFAULT '',
nr TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL,
deleted_by_user_id INTEGER NOT NULL DEFAULT 0,
deleted_by_name TEXT NOT NULL DEFAULT '',
deleted_at TEXT NOT NULL
)`);
// purgeGeloeschteProjekte: entfernt Papierkorb-Einträge, die älter als
// GELOESCHTE_PROJEKTE_TAGE sind. Läuft beim Start und stündlich mit dem
// Sitzungswechsel (siehe app.listen()).
function purgeGeloeschteProjekte(){
try {
const grenze = new Date(Date.now() - GELOESCHTE_PROJEKTE_TAGE * 24 * 60 * 60 * 1000).toISOString();
const r = db.prepare('DELETE FROM deleted_projects WHERE deleted_at < ?').run(grenze);
if(r && r.changes) console.log('Papierkorb: ' + r.changes + ' Projekt(e) nach ' + GELOESCHTE_PROJEKTE_TAGE + ' Tagen endgültig entfernt.');
} catch(err){
console.error('Aufräumen des Projekt-Papierkorbs fehlgeschlagen:', err.message);
}
}
purgeGeloeschteProjekte();
// ---------- Auth: Sessions, Middleware, Endpunkte ----------
const SESSION_COOKIE = 'puebersicht_session';
// Gültigkeitsdauer einer Anmeldung. Vorgabe 30 Tage, firmenweit über den
// Schlüssel 'sessionTage' pflegbar (siehe sessionMaxAgeMs()).
const DEFAULT_SESSION_TAGE = 30;
// sessionMaxAgeMs: aktuelle Gültigkeitsdauer in Millisekunden. Wirkt auf neu
// erzeugte Sitzungen; bestehende behalten ihr gespeichertes expires_at.
function sessionMaxAgeMs(){
return readAppSetting('sessionTage') * 24 * 60 * 60 * 1000;
}
// parseCookies: minimaler handgeschriebener Cookie-Parser (kein cookie-parser-
// Paket — Projekt-Konvention: nur express+cors als npm-Abhängigkeiten).
function parseCookies(req){
const header = req.headers.cookie || '';
const out = {};
header.split(';').forEach(part => {
const idx = part.indexOf('=');
if(idx === -1) return;
const k = part.slice(0, idx).trim();
const v = part.slice(idx + 1).trim();
if(k) out[k] = decodeURIComponent(v);
});
return out;
}
// setSessionCookie / clearSessionCookie: setzen bzw. löschen das Session-Cookie
// (HttpOnly, SameSite=Lax, 30 Tage Laufzeit, Secure nur bei HTTPS-Requests).
function setSessionCookie(res, token, req){
const secure = req && req.secure ? '; Secure' : '';
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(sessionMaxAgeMs()/1000)}${secure}`);
}
function clearSessionCookie(res){
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
}
// attachUser: liest das Session-Cookie, lädt (falls gültig) Session+User und
// setzt req.user entsprechend (oder null). Läuft für JEDEN Request (auch vor
// dem statischen Datei-Ausliefern), damit spätere Routen sich stets auf
// req.user verlassen können. Aktualisiert nebenbei last_seen_at der Session.
function attachUser(req, res, next){
req.user = null;
const cookies = parseCookies(req);
const token = cookies[SESSION_COOKIE];
if(!token) return next();
const row = db.prepare(`
SELECT s.expires_at AS expires_at, u.id AS id, u.username AS username,
u.display_name AS display_name, u.email AS email, u.role AS role,
u.person_id AS person_id, u.active AS active,
u.must_change_password AS must_change_password, u.department AS department,
u.einfuehrung_am AS einfuehrung_am, u.dashboard_reihenfolge AS dashboard_reihenfolge
FROM user_sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ?
`).get(token);
if(!row || !row.active || row.expires_at <= new Date().toISOString()) return next();
req.user = {
id: row.id,
username: row.username,
displayName: row.display_name,
email: row.email,
role: row.role,
personId: row.person_id,
mustChangePassword: !!row.must_change_password,
// department: das eigene Gewerk des Kontos — der Wochenbericht schlägt es
// als Vorauswahl vor (js/wochenbericht/wochenbericht.js), damit man nicht
// jedes Mal manuell das eigene Gewerk wählen muss.
department: row.department || '',
// einfuehrungFaellig: Rundgang noch nicht abgeschlossen (js/einfuehrung.js)
einfuehrungFaellig: !row.einfuehrung_am,
// dashboardReihenfolge: eigene Projektreihenfolge auf "Meine Aufgaben"
dashboardReihenfolge: (() => { try { const a = JSON.parse(row.dashboard_reihenfolge || '[]'); return Array.isArray(a) ? a : []; } catch(e){ return []; } })()
};
// last_seen_at höchstens einmal je Minute und Sitzung schreiben. Vorher
// schrieb JEDE Anfrage — auch jede der rund 60 JS/CSS-Dateien eines
// Seitenaufrufs — in die Datenbank; auf der SD-Karte des Pi der größte
// Teil der Ladezeit.
const jetzt = Date.now();
if(jetzt - (sitzungZuletztGeschrieben.get(token) || 0) > 60 * 1000){
sitzungZuletztGeschrieben.set(token, jetzt);
try {
db.prepare('UPDATE user_sessions SET last_seen_at = ? WHERE token = ?').run(new Date(jetzt).toISOString(), token);
} catch(err){
console.error('Konnte last_seen_at nicht aktualisieren:', err);
}
}
next();
}
const sitzungZuletztGeschrieben = new Map();
// (Registrierung via app.use(attachUser) erfolgt bewusst weiter oben, direkt
// nach express.json() und vor express.static — siehe dort.)
// requireAuth / requireRole: Middleware-Fabriken für spätere Phasen, die damit
// bestehende Routen absichern. In dieser Phase werden sie auf KEINE bestehende
// Route angewendet.
function requireAuth(req, res, next){
if(!req.user) return res.status(401).json({ ok:false, error:'Nicht angemeldet.' });
// Rollen ohne „Anwendung nutzen“ (früher fest: 'kiosk') sind überall, wo
// requireAuth genügte, bewusst AUSGESCHLOSSEN — sie dürfen nichts schreiben
// und nichts lesen, was die Wandtafel nicht selbst braucht. Die wenigen
// Routen, die sie braucht, nutzen requireAuthOderKiosk statt requireAuth.
if(istNurWandtafel(req.user.role)) return res.status(403).json({ ok:false, error:'Dieses Konto hat nur Lesezugriff für die Wandtafel.' });
next();
}
// requireAuthOderKiosk: wie requireAuth, lässt zusätzlich die Kiosk-Rolle zu —
// ausschließlich für die wenigen Routen, die ein Kiosk-Konto selbst braucht:
// die lesenden Routen, die die Wandtafel beim Laden aufruft (GET /load-all,
// /app-settings, /holidays, /honorar-config), /logout (ein Kiosk-Konto darf
// sich abmelden können, auch wenn die Oberfläche dort aktuell keinen Knopf
// dafür zeigt) sowie /change-password und /change-email (auf das EIGENE
// Konto beschränkt — jedes neu angelegte Konto, auch ein Kiosk-Konto, trägt
// must_change_password und MUSS sein Passwort beim ersten Login ändern
// können, sonst bliebe der erzwungene Dialog für immer unerfüllbar).
function requireAuthOderKiosk(req, res, next){
if(!req.user) return res.status(401).json({ ok:false, error:'Nicht angemeldet.' });
next();
}
function requireRole(role){
return function(req, res, next){
if(!req.user) return res.status(401).json({ ok:false, error:'Nicht angemeldet.' });
if(req.user.role !== role) return res.status(403).json({ ok:false, error:'Keine Berechtigung.' });
next();
};
}
// Route: Login — prüft Benutzername/Passwort, legt bei Erfolg eine neue Session
// an und setzt das Session-Cookie. Liefert bei falschem Benutzernamen, falschem
// Passwort oder inaktivem Konto absichtlich dieselbe Fehlermeldung (kein
// Username-Enumeration-Unterschied).
// loginAttempts: Fehlversuche je Absender-Adresse (nur im Arbeitsspeicher,
// kein Zustand in der Datenbank). Nach den eingestellten Fehlversuchen ist
// die Anmeldung für die eingestellte Dauer gesperrt — ohne diese Bremse kann ein
// Passwort beliebig oft durchprobiert werden.
const loginAttempts = new Map();
// Vorgaben der Anmeldebremse; firmenweit über 'loginMaxVersuche' und
// 'loginSperreMinuten' pflegbar (siehe loginMaxAttempts()/loginBlockMs()).
const DEFAULT_LOGIN_MAX_ATTEMPTS = 10;
const DEFAULT_LOGIN_BLOCK_MINUTEN = 10;
function loginMaxAttempts(){ return readAppSetting('loginMaxVersuche'); }
function loginBlockMs(){ return readAppSetting('loginSperreMinuten') * 60 * 1000; }
// Schlüssel aus Benutzername UND Absender-Adresse: Nur nach der Adresse zu
// sperren würde im Büro alle hinter derselben Leitung aussperren, sobald sich
// eine Person zehnmal vertippt. So ist immer nur das betroffene Konto von
// dieser Stelle aus gebremst.
// Schlüssel aus der NORMALISIERTEN Kennung (wie sie /login auch sucht: ohne
// "@…", ohne Leerzeichen, klein) — sonst bekam jede Schreibweise
// ("x@a.de", " x") einen eigenen Zähler und die Sperre ließ sich beliebig
// umgehen. Die Adresse kommt nur noch aus der Verbindung selbst: Der
// Header X-Forwarded-For ist frei wählbar, und vor diesem Server steht kein
// Proxy, der ihn setzen würde.
function loginKennung(username){
const eingabe = String(username || '').trim();
return (eingabe.includes('@') ? eingabe.slice(0, eingabe.indexOf('@')) : eingabe).trim().toLowerCase();
}
function loginClientKey(req, username){
const ip = String(req.socket.remoteAddress || 'unbekannt');
return loginKennung(username) + '|' + ip;
}
function loginIsBlocked(key){
const entry = loginAttempts.get(key);
if(!entry) return false;
if(Date.now() - entry.first > loginBlockMs()){ loginAttempts.delete(key); return false; }
return entry.count >= loginMaxAttempts();
}
function loginNoteFailure(key){
const entry = loginAttempts.get(key);
if(!entry || Date.now() - entry.first > loginBlockMs()) loginAttempts.set(key, { count: 1, first: Date.now() });
else entry.count++;
}
app.post('/login', (req, res) => {
const { username, password } = req.body || {};
if(!username || !password) return res.status(400).json({ ok:false, error:'Benutzername und Passwort erforderlich.' });
// Nur Zeichenketten — eine Zahl ließ scrypt mit 500 abbrechen.
if(typeof username !== 'string' || typeof password !== 'string'){
return res.status(400).json({ ok:false, error:'Benutzername und Passwort erforderlich.' });
}
const clientKey = loginClientKey(req, username);
if(loginIsBlocked(clientKey)){
return res.status(429).json({ ok:false, error:'Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.' });
}
// Benutzername = Anfang der E-Mail-Adresse (z. B. e.dunin). Wer die
// ganze Adresse eintippt, wird ebenso erkannt (alles ab "@" fällt weg),
// und Groß-/Kleinschreibung spielt keine Rolle ("E.Dunin").
const kennung = loginKennung(username);
const row = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(kennung);
// Auch bei unbekanntem Benutzernamen einmal hashen: Sonst verrät die
// Antwortzeit (scrypt läuft bewusst langsam), welche Konten es gibt.
const salt = row ? row.password_salt : 'unbekannt';
const expected = row ? row.password_hash : crypto.scryptSync('x', 'unbekannt', 64).toString('hex');
const passwordOk = verifyPassword(password, salt, expected);
if(!row || !row.active || !passwordOk){
loginNoteFailure(clientKey);
return res.status(401).json({ ok:false, error:'Benutzername oder Passwort falsch.' });
}
loginAttempts.delete(clientKey);
const token = crypto.randomBytes(32).toString('hex');
const now = new Date().toISOString();
const expiresAt = new Date(Date.now() + sessionMaxAgeMs()).toISOString();
db.prepare('INSERT INTO user_sessions (token, user_id, created_at, last_seen_at, expires_at) VALUES (?,?,?,?,?)')
.run(token, row.id, now, now, expiresAt);
setSessionCookie(res, token, req);
res.json({
ok: true,
user: {
id: row.id,
username: row.username,
displayName: row.display_name,
role: row.role,
personId: row.person_id,
mustChangePassword: !!row.must_change_password
}
});
});
// Route: Logout — löscht die Session des aktuellen Cookies und leert das Cookie.
app.post('/logout', requireAuthOderKiosk, (req, res) => {
const cookies = parseCookies(req);
const token = cookies[SESSION_COOKIE];
if(token) db.prepare('DELETE FROM user_sessions WHERE token = ?').run(token);
clearSessionCookie(res);
res.json({ ok:true });
});
// Route: liefert den aktuell angemeldeten Benutzer (oder null) — Basis für das
// clientseitige Login-Overlay.
app.get('/whoami', (req, res) => {
// rechte/rolleName: für die Oberfläche (Knöpfe ein-/ausblenden). Die
// eigentliche Grenze sind requireRecht()/hatRecht() an den Routen.
const user = req.user ? Object.assign({}, req.user, {
rechte: rechteDerRolle(req.user.role),
rolleName: (rollenCache.get(req.user.role) || {}).name || req.user.role
}) : null;
res.json({ ok:true, user });
});
// POST /einfuehrung {erledigt: true}: Rundgang abgeschlossen bzw.
// übersprungen — nur für das eigene Konto. Neu starten lässt er sich jederzeit
// unter Einstellungen, ohne diesen Stand zu ändern.
app.post('/einfuehrung', requireAuth, (req, res) => {
try {
const wert = (req.body || {}).erledigt === false ? '' : new Date().toISOString();
db.prepare('UPDATE users SET einfuehrung_am = ? WHERE id = ?').run(wert, req.user.id);
res.json({ ok: true });
} catch(err){ console.error('Einführung speichern fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
});
// POST /dashboard-reihenfolge {reihenfolge:[schlüssel]} — eigene Reihenfolge
// der Projekte auf "Meine Aufgaben"; leere Liste = wie in der Projektliste.
// Nur das eigene Konto.
app.post('/dashboard-reihenfolge', requireAuth, (req, res) => {
try {
const liste = (req.body || {}).reihenfolge;
if(!Array.isArray(liste)) return res.status(400).json({ ok: false, error: 'reihenfolge muss eine Liste sein.' });
const sauber = [...new Set(liste.map(x => String(x || '').slice(0, 100)).filter(Boolean))].slice(0, 500);
db.prepare('UPDATE users SET dashboard_reihenfolge = ? WHERE id = ?').run(sauber.length ? JSON.stringify(sauber) : '', req.user.id);
res.json({ ok: true, reihenfolge: sauber });
} catch(err){ console.error('Dashboard-Reihenfolge speichern fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
});
// ---------- Rollen & Rechte: Routen ----------
// GET liefert Katalog und Rollen (mit Anzahl Konten) — jedes angemeldete
// Konto mit Grundzugang, weil die Benutzerverwaltung die Liste für ihre
// Rollenauswahl braucht. Ändern nur mit Recht „Rollen & Rechte“.
function rollenAntwort(){
const zaehl = new Map(db.prepare('SELECT role, COUNT(*) AS n FROM users WHERE active = 1 GROUP BY role').all().map(r => [r.role, r.n]));
return {
ok: true,
katalog: RECHTE.map(g => ({ gruppe: g.gruppe, rechte: g.rechte.map(([key, name, hinweis]) => ({ key, name, hinweis })) })),
rollen: [...rollenCache.values()].map(r => ({ key: r.key, name: r.name, fest: r.fest, konten: zaehl.get(r.key) || 0, rechte: rechteDerRolle(r.key) }))
};
}
app.get('/rollen', requireAuth, (req, res) => {
try { res.json(rollenAntwort()); }
catch(err){ console.error('Rollen laden fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
});
// POST /rollen {name, vonRolle?}: neue Rolle, optional mit den Rechten einer
// bestehenden. Der Schlüssel wird aus dem Namen gebildet (a-z, 0-9, -).
app.post('/rollen', requireRecht('a.rollen'), (req, res) => {
try {
const name = String((req.body || {}).name || '').trim();
if(!name) return res.status(400).json({ ok: false, error: 'Name fehlt.' });
if([...rollenCache.values()].some(r => r.name.toLowerCase() === name.toLowerCase())){
return res.status(409).json({ ok: false, error: 'Eine Rolle mit diesem Namen gibt es schon.' });
}
const basis = name.toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30) || 'rolle';
let key = basis, i = 2;
while(rollenCache.has(key)) key = basis + '-' + (i++);
const von = (req.body || {}).vonRolle ? String(req.body.vonRolle) : '';
if(von && !rolleExistiert(von)) return res.status(400).json({ ok: false, error: 'Unbekannte Vorlage-Rolle.' });
const sort = (db.prepare('SELECT MAX(sort_order) AS m FROM roles').get().m || 0) + 1;
db.exec('BEGIN');
try {
db.prepare('INSERT INTO roles (key, name, sort_order, fest) VALUES (?, ?, ?, 0)').run(key, name, sort);
const ins = db.prepare('INSERT OR IGNORE INTO role_rechte (role_key, recht) VALUES (?, ?)');
(von ? rechteDerRolle(von) : []).forEach(r => ins.run(key, r));
db.exec('COMMIT');
} catch(e){ db.exec('ROLLBACK'); throw e; }
ladeRollen();
res.json(Object.assign(rollenAntwort(), { neu: key }));
} catch(err){ console.error('Rolle anlegen fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
});
// PATCH /rollen/:key {name}: umbenennen. Administrator bleibt, wie er ist.
app.patch('/rollen/:key', requireRecht('a.rollen'), (req, res) => {
try {
const r = rollenCache.get(String(req.params.key));
if(!r) return res.status(404).json({ ok: false, error: 'Rolle nicht gefunden.' });
if(r.fest) return res.status(400).json({ ok: false, error: 'Die Rolle Administrator ist fest.' });
const name = String((req.body || {}).name || '').trim();
if(!name) return res.status(400).json({ ok: false, error: 'Name fehlt.' });
if([...rollenCache.values()].some(x => x.key !== r.key && x.name.toLowerCase() === name.toLowerCase())){
return res.status(409).json({ ok: false, error: 'Eine Rolle mit diesem Namen gibt es schon.' });
}
db.prepare('UPDATE roles SET name = ? WHERE key = ?').run(name, r.key);
ladeRollen();
res.json(rollenAntwort());
} catch(err){ console.error('Rolle umbenennen fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
});
// PUT /rollen/:key/rechte {rechte:[…], an:true|false}: setzt oder entzieht
// ein oder mehrere Rechte (ein Schalter oder „alle/keine“ einer Gruppe).
app.put('/rollen/:key/rechte', requireRecht('a.rollen'), (req, res) => {
try {
const r = rollenCache.get(String(req.params.key));
if(!r) return res.status(404).json({ ok: false, error: 'Rolle nicht gefunden.' });
if(r.fest) return res.status(400).json({ ok: false, error: 'Die Rolle Administrator hat immer alle Rechte.' });
const liste = Array.isArray((req.body || {}).rechte) ? req.body.rechte.map(String) : [];
if(!liste.length || liste.some(k => !RECHT_KEYS.has(k))) return res.status(400).json({ ok: false, error: 'Unbekanntes Recht.' });
const an = !!req.body.an;
// Sich selbst „Rollen & Rechte“ zu entziehen, sperrte einen sofort aus.
if(!an && r.key === req.user.role && liste.includes('a.rollen')){
return res.status(400).json({ ok: false, error: 'Du kannst deiner eigenen Rolle „Rollen & Rechte“ nicht entziehen.' });
}
const stmt = an
? db.prepare('INSERT OR IGNORE INTO role_rechte (role_key, recht) VALUES (?, ?)')
: db.prepare('DELETE FROM role_rechte WHERE role_key = ? AND recht = ?');
db.exec('BEGIN');
try { liste.forEach(k => stmt.run(r.key, k)); db.exec('COMMIT'); }
catch(e){ db.exec('ROLLBACK'); throw e; }
ladeRollen();
res.json(rollenAntwort());
} catch(err){ console.error('Rechte setzen fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
});
// DELETE /rollen/:key: nur ohne zugeordnete Konten (auch deaktivierte).
app.delete('/rollen/:key', requireRecht('a.rollen'), (req, res) => {
try {
const r = rollenCache.get(String(req.params.key));
if(!r) return res.json(rollenAntwort());
if(r.fest) return res.status(400).json({ ok: false, error: 'Die Rolle Administrator kann nicht gelöscht werden.' });
const konten = db.prepare('SELECT display_name FROM users WHERE role = ? ORDER BY display_name').all(r.key).map(x => x.display_name);
if(konten.length){
return res.status(409).json({ ok: false, error: 'Der Rolle sind noch Konten zugeordnet: ' + konten.join(', ') + '. Erst eine andere Rolle zuweisen.' });
}
db.exec('BEGIN');
try {
db.prepare('DELETE FROM role_rechte WHERE role_key = ?').run(r.key);
db.prepare('DELETE FROM roles WHERE key = ?').run(r.key);
db.exec('COMMIT');
} catch(e){ db.exec('ROLLBACK'); throw e; }
ladeRollen();
res.json(rollenAntwort());
} catch(err){ console.error('Rolle löschen fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
});
// Route: Passwort ändern — verlangt immer das aktuelle Passwort (auch bei
// erzwungenem must_change_password), neues Passwort muss mind. 8 Zeichen haben.
app.post('/change-password', requireAuthOderKiosk, (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if(!currentPassword || !newPassword || typeof currentPassword !== 'string' || typeof newPassword !== 'string'){
return res.status(400).json({ ok:false, error:'Aktuelles und neues Passwort erforderlich.' });
}
if(newPassword.length < 8) return res.status(400).json({ ok:false, error:'Neues Passwort muss mindestens 8 Zeichen haben.' });
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if(!row || !verifyPassword(currentPassword, row.password_salt, row.password_hash)){
return res.status(401).json({ ok:false, error:'Aktuelles Passwort falsch.' });
}
const { hash, salt } = hashPassword(newPassword);
db.prepare('UPDATE users SET password_hash = ?, password_salt = ?, must_change_password = 0, updated_at = ? WHERE id = ?')
.run(hash, salt, new Date().toISOString(), row.id);
res.json({ ok:true });
});
// Route: eigene E-Mail-Adresse ändern — im Unterschied zu Anzeigename, Rolle
// und Personen-Zuordnung (weiterhin nur PATCH /admin/users/:id) pflegt jedes
// Konto seine E-Mail selbst, deshalb hier statt in der Admin-Benutzerverwaltung.
// Kein Formatzwang (wie auch beim Admin-Pfad) — nur eine leere Eingabe wird
// abgelehnt, das Leeren des Feldes geschieht wie gehabt über die
// Benutzerverwaltung.
app.post('/change-email', requireAuthOderKiosk, (req, res) => {
const { email } = req.body || {};
const trimmed = String(email || '').trim();
if(!trimmed) return res.status(400).json({ ok:false, error:'E-Mail-Adresse erforderlich.' });
db.prepare('UPDATE users SET email = ?, updated_at = ? WHERE id = ?')
.run(trimmed, new Date().toISOString(), req.user.id);
res.json({ ok:true, email: trimmed });
});
// ---------- Admin-Benutzerverwaltung ----------
//
// Alle Routen dieses Abschnitts verlangen das Recht „Benutzerverwaltung“ und
// dienen dem Abschnitt Benutzerverwaltung des Bereichs Admin. Sie legen
// Benutzerkonten an, ändern sie, setzen Passwörter zurück und löschen Konten
// (Soft-Delete standardmäßig, Hard-Delete nur ohne Referenzen in
// project_files/project_file_reviewers/project_file_comments).
// parseCapacityHours: prüft die vertraglichen Wochenstunden eines Kontos.
// Leer/undefiniert ergibt 0 = "nicht gepflegt"; das Frontend rechnet dann mit
// einer vollen Stelle, damit Bestandskonten ihr bisheriges Auslastungsbild
// behalten. Rückgabe: Zahl 0–80 oder null bei unbrauchbarer Eingabe.
function parseCapacityHours(value){
if(value === undefined || value === null || value === '') return 0;
const n = Number(value);
if(!Number.isFinite(n) || n < 0 || n > 80) return null;
return n;
}
// generateRandomPassword: erzeugt ein zufälliges Klartext-Passwort für neu
// angelegte Accounts bzw. einen Passwort-Reset (gleiche Methode wie
// bootstrapInitialAdmin).
function generateRandomPassword(){
return crypto.randomBytes(15).toString('base64url');
}
// serializeUserRow: wandelt eine users-Zeile in die öffentliche Repräsentation
// um (OHNE password_hash/password_salt).
function serializeUserRow(row){
return {
id: row.id,
username: row.username,
displayName: row.display_name,
firstName: row.first_name || '',
lastName: row.last_name || '',
email: row.email,
role: row.role,
personId: row.person_id,
department: row.department,
initials: row.initials,
capacityHours: Number(row.capacity_hours) || 0,
active: !!row.active,
mustChangePassword: !!row.must_change_password,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
// loadUserRolesByUserId: Batch-Query aller user_roles, gruppiert nach user_id
// (analog zum bestehenden Muster in loadGlobalPeople() für person_roles) —
// vermeidet eine Einzelabfrage pro Zeile in GET /admin/users bzw. GET /users.
function loadUserRolesByUserId(){
const rows = db.prepare('SELECT * FROM user_roles ORDER BY role_order').all();
const byUser = {};
rows.forEach(r => { (byUser[r.user_id] = byUser[r.user_id] || []).push(r.role); });
return byUser;
}
// Route: Liste aller Benutzer (ohne Passwortfelder), sortiert nach Benutzername.
app.get('/admin/users', requireRecht('a.benutzer'), (req, res) => {
try {
const rows = db.prepare('SELECT * FROM users ORDER BY username').all();
const rolesByUser = loadUserRolesByUserId();
res.json({ ok: true, users: rows.map(r => Object.assign(serializeUserRow(r), { roles: rolesByUser[r.id] || [] })) });
} catch(err){
console.error('Admin-Benutzerliste fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Abwesenheiten eines einzelnen Benutzers, sortiert nach Start-Datum.
// GET /admin/users liefert bewusst keine Abwesenheiten mit (würde die Liste
// unnötig aufblähen) — das Bearbeiten-Modal in admin-users.js lädt sie beim
// Öffnen separat über diese Route nach.
app.get('/admin/users/:id/absences', requireRecht('a.benutzer'), (req, res) => {
const id = Number(req.params.id);
try {
const rows = db.prepare('SELECT id, start, end, type, note FROM user_absences WHERE user_id = ? ORDER BY start').all(id);
res.json({ ok: true, absences: rows });
} catch(err){
console.error('Abwesenheiten laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Legt einen neuen Benutzer an. Passwort entweder explizit (mind. 8
// Zeichen) oder automatisch generiert (generatePassword: true); in jedem Fall
// muss der neue Account beim ersten Login sein Passwort ändern. `personId`
// wird 1:1 vom Client übernommen (Berechnung/Vorschau via personIdForName()
// passiert clientseitig).
app.post('/admin/users', requireRecht('a.benutzer'), (req, res) => {
const { username, email, role, personId, department, initials, capacityHours, roles, password, generatePassword } = req.body || {};
const firstName = String((req.body || {}).firstName || '').trim();
const lastName = String((req.body || {}).lastName || '').trim();
// Anzeigename = "Vorname Nachname"; ältere Clients schicken nur displayName.
const displayName = (firstName || lastName) ? [firstName, lastName].filter(Boolean).join(' ') : String((req.body || {}).displayName || '').trim();
if(!username || !String(username).trim()) return res.status(400).json({ ok: false, error: 'Benutzername erforderlich.' });
if(!displayName) return res.status(400).json({ ok: false, error: 'Vor- und Nachname erforderlich.' });
if(db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE').get(String(username).trim())){
return res.status(409).json({ ok: false, error: 'Benutzername bereits vergeben.' });
}
const finalRole = role ? String(role) : 'member';
if(!rolleExistiert(finalRole)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' });
// Administrator vergibt nur, wer selbst Administrator ist — sonst könnte
// sich eine Rolle mit „Benutzerverwaltung“ selbst alle Rechte verschaffen.
if(rollenCache.get(finalRole).fest && !(rollenCache.get(req.user.role) || {}).fest){
return res.status(403).json({ ok: false, error: 'Nur ein Administrator kann die Rolle Administrator vergeben.' });
}
if(typeof department !== 'string' || !VALID_DEPARTMENTS.has(department)){
return res.status(400).json({ ok: false, error: 'Ungültige oder fehlende Abteilung.' });
}
const roleList = Array.isArray(roles) ? roles : [];
for(const r of roleList){
if(!VALID_DISCIPLINE_ROLES.has(r)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle: ' + r });
}
// Ein Kiosk-Konto hat keine Fachrolle — es erscheint in keinem
// Projektboard und wird keiner Rolle zugeordnet, die Felder wären also
// ohnehin wirkungslos; ihr Setzen wird deshalb zurückgewiesen statt
// stillschweigend ignoriert.
if(istNurWandtafel(finalRole) && roleList.length > 0){
return res.status(400).json({ ok: false, error: 'Ein reines Wandtafel-Konto kann keine Fachrolle haben.' });
}
// capacityHours: vertragliche Wochenstunden (Teilzeit). 0 bzw. keine
// Angabe heißt "nicht gepflegt" — das Frontend rechnet dann mit einer
// vollen Stelle weiter.
const capacity = parseCapacityHours(capacityHours);
if(capacity === null) return res.status(400).json({ ok: false, error: 'Wochenstunden müssen zwischen 0 und 80 liegen.' });
let plainPassword;
if(generatePassword === true){
plainPassword = generateRandomPassword();
} else if(typeof password === 'string' && password){
if(password.length < 8) return res.status(400).json({ ok: false, error: 'Passwort muss mindestens 8 Zeichen haben.' });
plainPassword = password;
} else {
return res.status(400).json({ ok: false, error: 'Passwort erforderlich (manuell oder generatePassword).' });
}
const { hash, salt } = hashPassword(plainPassword);
const now = new Date().toISOString();
try {
const info = db.prepare(`INSERT INTO users
(username, display_name, first_name, last_name, email, role, person_id, department, initials, capacity_hours, password_hash, password_salt, active, must_change_password, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`)
.run(String(username).trim(), displayName, firstName, lastName, String(email || ''), finalRole, String(personId || ''), department, String(initials || ''), capacity, hash, salt, now, now);
const insRole = db.prepare('INSERT INTO user_roles (user_id, role, role_order) VALUES (?, ?, ?)');
roleList.forEach((r, idx) => insRole.run(info.lastInsertRowid, r, idx));
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid);
const result = { ok: true, user: Object.assign(serializeUserRow(row), { roles: roleList }) };
result.sitzungGeaendert = kontenInAktuelleSitzung([row.id]) > 0;
if(generatePassword === true) result.generatedPassword = plainPassword;
res.json(result);
} catch(err){
if(err && String(err.message || '').includes('person_id')){
return res.status(409).json({ ok: false, error: 'Person-ID bereits vergeben.' });
}
if(err && String(err.message || '').includes('UNIQUE')){
return res.status(409).json({ ok: false, error: 'Benutzername bereits vergeben.' });
}
console.error('Benutzer anlegen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Aktualisiert einen bestehenden Benutzer (nur die übergebenen Felder).
// Schützt einen Admin davor, sich selbst zu deaktivieren oder zu degradieren.
app.patch('/admin/users/:id', requireRecht('a.benutzer'), (req, res) => {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if(!row) return res.status(404).json({ ok: false, error: 'Benutzer nicht gefunden.' });
const { displayName, email, role, active, personId, department, initials, capacityHours, roles } = req.body || {};
if(role !== undefined && !rolleExistiert(role)){
return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' });
}
// Administrator vergeben oder entziehen darf nur ein Administrator.
const ichAdmin = !!(rollenCache.get(req.user.role) || {}).fest;
const zielAdminVorher = !!(rollenCache.get(row.role) || {}).fest;
if(role !== undefined && String(role) !== row.role && !ichAdmin
&& (zielAdminVorher || (rollenCache.get(String(role)) || {}).fest)){
return res.status(403).json({ ok: false, error: 'Nur ein Administrator kann die Rolle Administrator vergeben oder entziehen.' });
}
if(department !== undefined && !VALID_DEPARTMENTS.has(department)){
return res.status(400).json({ ok: false, error: 'Ungültige Abteilung.' });
}
let capacity;
if(capacityHours !== undefined){
capacity = parseCapacityHours(capacityHours);
if(capacity === null) return res.status(400).json({ ok: false, error: 'Wochenstunden müssen zwischen 0 und 80 liegen.' });
}
let roleList;
if(roles !== undefined){
roleList = Array.isArray(roles) ? roles : [];
for(const r of roleList){
if(!VALID_DISCIPLINE_ROLES.has(r)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle: ' + r });
}
}
// Ein Kiosk-Konto hat keine Fachrolle — geprüft gegen die Rolle UND die
// Fachrollen-Liste, wie sie nach diesem PATCH jeweils gelten würden (eines
// von beiden kann unverändert bleiben, wenn es im Body fehlt).
const effectiveRole = role !== undefined ? String(role) : row.role;
if(istNurWandtafel(effectiveRole)){
const effectiveRoles = roles !== undefined
? roleList
: db.prepare('SELECT role FROM user_roles WHERE user_id = ?').all(id).map(r => r.role);
if(effectiveRoles.length > 0){
return res.status(400).json({ ok: false, error: 'Ein reines Wandtafel-Konto kann keine Fachrolle haben.' });
}
}
const isSelf = req.user.id === id;
const wouldDeactivate = active !== undefined && !active;
// Degradierung schließt jeden Wechsel WEG von admin ein, nicht nur zu
// 'member' — sonst gäbe es für den Selbstschutz eine Kiosk-förmige Lücke.
const wouldDemote = role !== undefined && zielAdminVorher && !(rollenCache.get(String(role)) || {}).fest;
if(isSelf && (wouldDeactivate || wouldDemote)){
return res.status(400).json({ ok: false, error: 'Sie können sich nicht selbst deaktivieren oder degradieren.' });
}
// Der letzte aktive Administrator bleibt: sonst kann niemand mehr Rollen
// und Rechte pflegen.
if(zielAdminVorher && row.active && (wouldDeactivate || wouldDemote)){
const festRollen = [...rollenCache.values()].filter(r => r.fest).map(r => r.key);
const n = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE active = 1 AND role IN (${festRollen.map(() => '?').join(',')})`).get(...festRollen).n;
if(n <= 1) return res.status(400).json({ ok: false, error: 'Der letzte aktive Administrator kann nicht deaktiviert oder herabgestuft werden.' });
}
const fields = [];
const values = [];
const { firstName, lastName } = req.body || {};
if(firstName !== undefined || lastName !== undefined){
// Vor-/Nachname setzen auch den Anzeigenamen — er ist ihre Verbindung.
const alt = db.prepare('SELECT first_name, last_name FROM users WHERE id = ?').get(id) || {};
const vn = firstName !== undefined ? String(firstName).trim() : (alt.first_name || '');
const nn = lastName !== undefined ? String(lastName).trim() : (alt.last_name || '');
if(!vn && !nn) return res.status(400).json({ ok: false, error: 'Vor- und Nachname erforderlich.' });
fields.push('first_name = ?', 'last_name = ?', 'display_name = ?');
values.push(vn, nn, [vn, nn].filter(Boolean).join(' '));
} else if(displayName !== undefined){ fields.push('display_name = ?'); values.push(String(displayName)); }
if(email !== undefined){ fields.push('email = ?'); values.push(String(email)); }
if(role !== undefined){ fields.push('role = ?'); values.push(role); }
if(active !== undefined){ fields.push('active = ?'); values.push(active ? 1 : 0); }
if(personId !== undefined){ fields.push('person_id = ?'); values.push(String(personId)); }
if(department !== undefined){ fields.push('department = ?'); values.push(department); }
if(initials !== undefined){ fields.push('initials = ?'); values.push(String(initials)); }
if(capacity !== undefined){ fields.push('capacity_hours = ?'); values.push(capacity); }
const now = new Date().toISOString();
fields.push('updated_at = ?'); values.push(now);
values.push(id);
try {
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
// Neue Person-ID: in der laufenden Sitzung und der Planung-Sandbox
// mitziehen. Sonst legte kontenInAktuelleSitzung() die Person unter der
// neuen ID ein zweites Mal an, und ihre Zuordnungen, Aufgaben und
// Anwesenheit hingen weiter an der alten. Ältere Sitzungen bleiben
// Historie (wie beim endgültigen Löschen).
const alteId = String(row.person_id || ''), neueId = personId !== undefined ? String(personId) : alteId;
if(alteId && neueId && alteId !== neueId){
const echte = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL).map(r => r.label)
.sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b)));
[echte[echte.length - 1], SANDBOX_WEEK_LABEL].filter(Boolean).forEach(w => {
['week_people','week_people_roles','attendance','project_assign','plan_todo_assignees','plan_people'].forEach(t => {
db.prepare(`UPDATE OR IGNORE ${t} SET person_id = ? WHERE week_label = ? AND person_id = ?`).run(neueId, w, alteId);
db.prepare(`DELETE FROM ${t} WHERE week_label = ? AND person_id = ?`).run(w, alteId);
});
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(new Date().toISOString(), w);
});
}
if(roleList !== undefined){
db.prepare('DELETE FROM user_roles WHERE user_id = ?').run(id);
const insRole = db.prepare('INSERT INTO user_roles (user_id, role, role_order) VALUES (?, ?, ?)');
roleList.forEach((r, idx) => insRole.run(id, r, idx));
}
// Deaktivierung erzwingt sofortigen Logout (alle Sessions löschen).
if(wouldDeactivate){
db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(id);
offenePruefungenAufraeumen(id);
}
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
const updatedRoles = roleList !== undefined ? roleList : (loadUserRolesByUserId()[id] || []);
const sitzungGeaendert = kontenInAktuelleSitzung([id]) > 0;
res.json({ ok: true, user: Object.assign(serializeUserRow(updated), { roles: updatedRoles }), sitzungGeaendert });
} catch(err){
if(err && String(err.message || '').includes('person_id')){
return res.status(409).json({ ok: false, error: 'Person-ID bereits vergeben.' });
}
console.error('Benutzer aktualisieren fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Setzt das Passwort eines Benutzers auf ein neues Zufallspasswort
// zurück, erzwingt eine Passwortänderung beim nächsten Login und löscht alle
// bestehenden Sessions (erzwingt Neuanmeldung).
app.post('/admin/users/:id/reset-password', requireRecht('a.benutzer'), (req, res) => {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if(!row) return res.status(404).json({ ok: false, error: 'Benutzer nicht gefunden.' });
const plainPassword = generateRandomPassword();
const { hash, salt } = hashPassword(plainPassword);
const now = new Date().toISOString();
try {
db.prepare('UPDATE users SET password_hash = ?, password_salt = ?, must_change_password = 1, updated_at = ? WHERE id = ?')
.run(hash, salt, now, id);
db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(id);
res.json({ ok: true, generatedPassword: plainPassword });
} catch(err){
console.error('Passwort zurücksetzen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Löscht einen Benutzer. Standardmäßig Soft-Delete (active = 0 +
// Sessions löschen, erzwingt Logout); mit `?hard=1` endgültiges Löschen,
// sofern der Benutzer nirgends referenziert wird (project_files.uploaded_by_user_id,
// project_file_reviewers.user_id, project_file_comments.user_id — diese Tabellen
// sind schon angelegt, aber aktuell noch leer). Ein Admin kann sich selbst
// weder soft- noch hard-löschen.
// loescheBenutzerEndgueltig: entfernt ein Konto samt seiner Verweise, in
// einer Transaktion. Auf Eriks Wunsch verschwindet die Person dabei aus den
// Projekten und damit aus der Auslastung — aber nur in der AKTUELLEN Sitzung
// (jüngste echte Woche) und in den Planung-Terminplänen. Ältere Sitzungen
// sind Historie (Verlauf im Wochenbericht) und bleiben unverändert.
// Welche Kennungen gemeint sind, bestimmt je Woche personenKennungenInWoche():
// users.person_id UND jede Kennung im Teilnehmerkreis dieser Woche mit
// demselben vollen Namen — Altbestand trägt Kurzkennungen (p_ap statt
// p_alexander_plum), an denen die Zuordnungen hängen; dieselbe Brücke
// über den Namen nutzt kapazitaetsFaktor() in public/js/load/load.js.
// Entfernt werden dort: Teilnehmerkreis
// (week_people/_roles), Anwesenheit, Projektzuordnungen (project_assign —
// daraus rechnet die Auslastung), Beteiligte des Terminplans (plan_people)
// und Aufgaben-Zuständigkeiten (plan_todo_assignees). Die betroffenen
// Wochen bekommen eine neue Revision, damit ein Browser mit altem Stand
// beim nächsten Speichern einen Konflikt bekommt und neu lädt, statt die
// Person wieder hineinzuschreiben.
// Dateien: Hochgeladene Dateien und Kommentare bleiben und erscheinen als
// „Gelöschter Benutzer" (LEFT JOIN + COALESCE in den Datei-Abfragen).
// Prüfaufträge an die Person werden entfernt und der Gesamtstatus der
// betroffenen, noch offenen Dateien neu berechnet (dieselbe Regel wie in
// POST /project-files/:id/review). Sitzungen, Rollen, Abwesenheiten und
// KI-Schlüssel fallen per ON DELETE CASCADE mit weg.
// Rückgabe: Zähler dessen, was entfernt wurde.
function personenKennungenInWoche(row, woche){
const ids = new Set();
if(row.person_id) ids.add(String(row.person_id));
const name = String(row.display_name || '').trim();
if(name){
db.prepare('SELECT person_id FROM week_people WHERE week_label = ? AND full_name = ?').all(woche, name)
.forEach(r => ids.add(r.person_id));
}
return [...ids].filter(Boolean);
}
// kontenInAktuelleSitzung: trägt aktive Konten in den Teilnehmerkreis der
// AKTUELLEN Sitzung (jüngste echte Woche) ein bzw. gleicht dort Kürzel,
// Name und Fachrollen mit dem Konto ab. Der Teilnehmerkreis einer Sitzung
// ist eine Kopie, die beim Sitzungswechsel entsteht — ein später angelegtes
// Konto fehlte darin, und der Assistent "Neues Projekt" bot niemanden an
// (aufgefallen auf einer frisch aufgesetzten Datenbank, deren Sitzung vor den
// Konten entstand). Ältere Sitzungen bleiben als Historie unberührt.
// userIds: nur diese Konten; ohne Angabe alle aktiven (Abgleich beim Start).
// Bei einer Änderung bekommt die Woche eine neue Revision, damit ein Browser
// mit altem Stand neu lädt, statt die Person beim Speichern wieder zu tilgen.
function kontenInAktuelleSitzung(userIds){
const echte = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL).map(r => r.label)
.sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b)));
const woche = echte[echte.length - 1];
if(!woche) return 0;
const filter = Array.isArray(userIds) ? new Set(userIds.map(Number)) : null;
// Kiosk-Konten (Wandtafel) gehören nicht in den Teilnehmerkreis: Sie
// haben keine Fachrolle und könnten keine Aufgabe je bearbeiten.
const konten = db.prepare(`SELECT * FROM users WHERE active = 1 AND person_id != '' AND ${sqlOhneWandtafel()}`).all()
.filter(u => !filter || filter.has(u.id));
let geaendert = 0;
db.exec('BEGIN');
try {
// Bereits eingetragene Kiosk-Konten wieder austragen — solange sie keinem
// Projekt der Sitzung zugeordnet sind (dann bleibt die Zuordnung lesbar).
db.prepare(`SELECT person_id FROM users WHERE NOT (${sqlOhneWandtafel()}) AND person_id != ''`).all().forEach(k => {
const zugeordnet = db.prepare('SELECT 1 FROM project_assign WHERE week_label = ? AND person_id = ? AND state > 0 LIMIT 1').get(woche, k.person_id);
if(zugeordnet) return;
const weg = db.prepare('DELETE FROM week_people WHERE week_label = ? AND person_id = ?').run(woche, k.person_id).changes;
if(weg){
db.prepare('DELETE FROM week_people_roles WHERE week_label = ? AND person_id = ?').run(woche, k.person_id);
db.prepare('DELETE FROM attendance WHERE week_label = ? AND person_id = ?').run(woche, k.person_id);
geaendert++;
}
});
konten.forEach(u => {
const rollen = db.prepare('SELECT role FROM user_roles WHERE user_id = ? ORDER BY role_order').all(u.id).map(r => r.role);
const kuerzel = u.initials || u.username;
const da = db.prepare('SELECT * FROM week_people WHERE week_label = ? AND person_id = ?').get(woche, u.person_id);
const alteRollen = db.prepare('SELECT role FROM week_people_roles WHERE week_label = ? AND person_id = ? ORDER BY role_order')
.all(woche, u.person_id).map(r => r.role);
if(da && da.name === kuerzel && da.full_name === u.display_name && alteRollen.join('|') === rollen.join('|')) return;
if(da){
db.prepare('UPDATE week_people SET name = ?, full_name = ? WHERE week_label = ? AND person_id = ?')
.run(kuerzel, u.display_name, woche, u.person_id);
} else {
const ende = db.prepare('SELECT COALESCE(MAX(array_order), -1) + 1 n FROM week_people WHERE week_label = ?').get(woche).n;
db.prepare('INSERT INTO week_people (week_label, person_id, name, full_name, array_order) VALUES (?, ?, ?, ?, ?)')
.run(woche, u.person_id, kuerzel, u.display_name, ende);
}
db.prepare('DELETE FROM week_people_roles WHERE week_label = ? AND person_id = ?').run(woche, u.person_id);
const insRolle = db.prepare('INSERT INTO week_people_roles (week_label, person_id, role, role_order) VALUES (?, ?, ?, ?)');
rollen.forEach((r, i) => insRolle.run(woche, u.person_id, r, i));
geaendert++;
});
if(geaendert) db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(new Date().toISOString(), woche);
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
console.error('Teilnehmerkreis der aktuellen Sitzung konnte nicht abgeglichen werden:', err);
return 0;
}
if(geaendert) console.log(`Teilnehmerkreis "${woche}": ${geaendert} Konto/Konten eingetragen bzw. abgeglichen.`);
return geaendert;
}
// offenePruefungenAufraeumen: nimmt ein deaktiviertes Konto aus den noch
// offenen Dateiprüfungen und berechnet deren Gesamtstatus neu. Ohne das blieb
// eine Datei, deren Prüfer deaktiviert wurde, für immer "ausstehend" — die
// übrigen Prüfer konnten zustimmen, so viel sie wollten. War das Konto der
// EINZIGE Prüfer, bleibt die Prüfung stehen (eine Datei ganz ohne Prüfer
// wäre ebenso festgefahren; nach einer Reaktivierung geht es dort weiter).
// Parameter: userId. Keine Rückgabe.
function offenePruefungenAufraeumen(userId){
const jetzt = new Date().toISOString();
const offen = db.prepare(`SELECT r.file_id FROM project_file_reviewers r JOIN project_files f ON f.id = r.file_id
WHERE r.user_id = ? AND r.decision = 'pending' AND f.status = 'pending'`).all(userId);
offen.forEach(({ file_id }) => {
const andere = db.prepare('SELECT decision FROM project_file_reviewers WHERE file_id = ? AND user_id != ?').all(file_id, userId).map(r => r.decision);
if(!andere.length) return;
db.prepare('DELETE FROM project_file_reviewers WHERE file_id = ? AND user_id = ?').run(file_id, userId);
let status = 'pending';
if(andere.some(d => d === 'rejected')) status = 'rejected';
else if(andere.every(d => d === 'approved')) status = 'approved';
if(status !== 'pending') db.prepare('UPDATE project_files SET status = ?, updated_at = ? WHERE id = ?').run(status, jetzt, file_id);
});
}
function loescheBenutzerEndgueltig(row){
const echte = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL).map(r => r.label)
.sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b)));
const wochen = [echte[echte.length - 1], SANDBOX_WEEK_LABEL].filter(Boolean);
const zaehler = { zuordnungen: 0, aufgaben: 0, pruefauftraege: 0 };
const jetzt = new Date().toISOString();
db.exec('BEGIN');
try {
wochen.forEach(w => {
const ids = personenKennungenInWoche(row, w);
if(!ids.length) return;
ids.forEach(pid => {
zaehler.zuordnungen += db.prepare('DELETE FROM project_assign WHERE week_label = ? AND person_id = ?').run(w, pid).changes;
zaehler.aufgaben += db.prepare('DELETE FROM plan_todo_assignees WHERE week_label = ? AND person_id = ?').run(w, pid).changes;
['plan_people', 'week_people_roles', 'week_people', 'attendance'].forEach(t => {
db.prepare(`DELETE FROM ${t} WHERE week_label = ? AND person_id = ?`).run(w, pid);
});
});
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, w);
});
const dateien = db.prepare('SELECT file_id FROM project_file_reviewers WHERE user_id = ?').all(row.id).map(r => r.file_id);
zaehler.pruefauftraege = db.prepare('DELETE FROM project_file_reviewers WHERE user_id = ?').run(row.id).changes;
dateien.forEach(fileId => {
const datei = db.prepare('SELECT status FROM project_files WHERE id = ?').get(fileId);
if(!datei || datei.status !== 'pending') return;
const entscheidungen = db.prepare('SELECT decision FROM project_file_reviewers WHERE file_id = ?').all(fileId).map(r => r.decision);
let status = 'pending';
if(entscheidungen.some(d => d === 'rejected')) status = 'rejected';
else if(entscheidungen.length > 0 && entscheidungen.every(d => d === 'approved')) status = 'approved';
if(status !== 'pending') db.prepare('UPDATE project_files SET status = ?, updated_at = ? WHERE id = ?').run(status, jetzt, fileId);
});
db.prepare('DELETE FROM users WHERE id = ?').run(row.id);
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
throw err;
}
return zaehler;
}
app.delete('/admin/users/:id', requireRecht('a.benutzer'), (req, res) => {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if(!row) return res.status(404).json({ ok: false, error: 'Benutzer nicht gefunden.' });
if(req.user.id === id) return res.status(400).json({ ok: false, error: 'Sie können Ihren eigenen Account nicht löschen.' });
const hard = req.query.hard === '1';
try {
if(hard){
const ergebnis = loescheBenutzerEndgueltig(row);
console.log('Benutzer endgültig gelöscht:', row.username, ergebnis);
return res.json(Object.assign({ ok: true, hard: true }, ergebnis));
}
const now = new Date().toISOString();
db.prepare('UPDATE users SET active = 0, updated_at = ? WHERE id = ?').run(now, id);
db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(id);
offenePruefungenAufraeumen(id);
res.json({ ok: true, hard: false });
} catch(err){
console.error('Benutzer löschen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Speichert eine komplette Woche (als JSON-Objekt) für eine Abteilung
app.post('/save-week', requireAuth, (req, res) => {
const { week, baseUpdatedAt } = req.body || {};
if(!week || typeof week.label !== 'string' || !week.label) return res.status(400).json({ ok: false, error: 'Missing week or label' });
// Auftragsstatus prüfen, BEVOR irgendetwas geschrieben wird: /save-week ist
// der einzige Schreibweg für Projektdaten, ein unbekannter Wert bliebe also
// dauerhaft stehen (siehe findInvalidAuftragsstatus()).
const badStatus = findInvalidAuftragsstatus(week);
if(badStatus){
return res.status(400).json({
ok: false,
error: 'Unbekannter Auftragsstatus "' + badStatus.wert + '" am Projekt '
+ ((badStatus.nr ? badStatus.nr + ' ' : '') + badStatus.name).trim()
+ '. Erlaubt: ' + Array.from(VALID_AUFTRAGSSTATUS).join(', ') + '.'
});
}
try {
// Revisions-/Konflikt-Check: Mit `baseUpdatedAt` meldet der Client die
// Version, auf der seine lokalen Daten beruhen. Ist die Woche auf dem
// Server zwischenzeitlich von jemand anderem gespeichert worden, wird
// NICHT überschrieben, sondern ein Konflikt gemeldet (HTTP 409).
// Wichtig: Auch eine leere Basis (updated_at='' direkt nach der Migration)
// wird geprüft – nur Clients OHNE baseUpdatedAt-Feld (alte Versionen)
// überspringen die Prüfung. Der Check läuft synchron vor dem Schreiben,
// daher gibt es im Prozess keine Race-Condition zwischen Prüfung und
// Transaktion.
// Sperr-Status serverseitig durchsetzen: Eine gesperrte Sitzung darf NUR
// über /unlock-week (Recht „Sitzung entsperren“) wieder geöffnet werden. Das
// Sperren selbst (false -> true) bleibt jedem erlaubt, weil es die
// Auto-Sperre des Clients (applyAutoLock(), public/js/weeks.js) beim
// normalen Speichern mitschreibt. Ohne diese Prüfung wäre die Sperre reine
// Client-Logik: ein beliebiges Mitglied könnte die Woche mit locked:false
// zurückschreiben und die Admin-Grenze umgehen.
const lockRow = db.prepare('SELECT locked FROM weeks WHERE label = ?').get(String(week.label));
if(lockRow && lockRow.locked && !week.locked && !hatRecht(req.user, 's.gesperrt')){
return res.status(403).json({
ok: false,
error: 'Die Sitzung "' + week.label + '" ist gesperrt. Nur Administratoren können sie wieder öffnen.'
});
}
// Auch der INHALT einer gesperrten Sitzung ist für Nicht-Admins tabu —
// bisher wurde nur das Entsperren abgewiesen, Kommentare und Aufgaben
// einer gesperrten Woche ließen sich über die API weiter ändern. Als 409
// (Konflikt) statt 403: Ein Browser, der die Woche noch offen glaubt (etwa
// weil der Sitzungswechsel sie inzwischen gesperrt hat), lädt sie dann neu
// und zeigt sie gesperrt, statt in einer Fehlerschleife zu hängen.
// Das Mitschreiben der Auto-Sperre ist nicht betroffen: Es trifft nur
// Wochen, die am Server noch offen sind.
if(lockRow && lockRow.locked && !hatRecht(req.user, 's.gesperrt')){
const stand = db.prepare('SELECT updated_at FROM weeks WHERE label = ?').get(String(week.label));
return res.status(409).json({
ok: false, conflict: true,
error: 'Die Sitzung "' + week.label + '" ist gesperrt und kann nicht mehr geändert werden.',
updatedAt: stand ? stand.updated_at : ''
});
}
if(typeof baseUpdatedAt === 'string'){
const row = db.prepare('SELECT updated_at FROM weeks WHERE label = ?').get(String(week.label));
if(row){
if(baseUpdatedAt !== row.updated_at){
return res.status(409).json({
ok: false,
conflict: true,
error: 'Die Sitzung "' + week.label + '" wurde zwischenzeitlich von einer anderen Person geändert.',
updatedAt: row.updated_at
});
}
} else if(baseUpdatedAt !== ''){
// Die Woche existiert am Server nicht mehr (vermutlich gelöscht). Ein
// Save mit nicht-leerer, also älterer Revision darf eine gelöschte
// Sitzung nicht wiederbeleben; nur neue Wochen (leere/fehlende Basis)
// dürfen angelegt werden.
return res.status(409).json({
ok: false,
conflict: true,
error: 'Die Sitzung "' + week.label + '" wurde zwischenzeitlich gelöscht.',
updatedAt: ''
});
}
}
// Angelegte und gelöschte Projekte erkennen: /save-week schreibt immer
// die ganze Woche, beides ist also nur am Vergleich mit dem gespeicherten
// Stand zu sehen (projektlisteAenderungen()). Nur Projektleiter und
// Administratoren dürfen das (403 sonst, bevor irgendetwas geschrieben
// wird); gelöschte Projekte wandern in den Papierkorb
// (deleted_projects). Die Planung-Sandbox ist ausgenommen — ihre
// Terminpläne sind keine Projekte der Projektliste und werden dort von
// jedem angelegt und gelöscht.
// Jede Projekt-id und jede projectUid darf in einer Woche nur einmal
// vorkommen. Eine doppelte id ließ das Speichern mit 500 (UNIQUE) scheitern;
// eine doppelte uid ließ eine Kopie eines bestehenden Projekts als
// "bekannt" durchgehen — so konnte auch ein Mitglied Projekte anlegen,
// und das spätere Löschen der Kopie landete nicht im Papierkorb.
{
const ids = new Set(), uids = new Set();
for(const p of (Array.isArray(week.projects) ? week.projects : []).filter(Boolean)){
const id = String(p.id || ''), uid = String(p.projectUid || '');
if((id && ids.has(id)) || (uid && uids.has(uid))){
return res.status(400).json({ ok: false, error: 'Ein Projekt kommt in der Sitzung doppelt vor (' + String(p.nr || p.name || id) + ').' });
}
if(id) ids.add(id);
if(uid) uids.add(uid);
}
}
const aenderung = week.label === SANDBOX_WEEK_LABEL
? { neu: [], entfernt: [] } : projektlisteAenderungen(week);
const entfernt = aenderung.entfernt;
if((aenderung.neu.length || entfernt.length) && !darfProjektlisteAendern(req.user)){
return res.status(403).json({
ok: false,
error: aenderung.neu.length
? 'Keine Berechtigung: „Projekte anlegen und löschen“ fehlt.'
: 'Keine Berechtigung: „Projekte anlegen und löschen“ fehlt.',
recht: 'proj.anlegen'
});
}
{
const fehlt = fehlendesBearbeitungsrecht(req.user, week.label, week.projects, week);
if(fehlt) return rechtFehlt(res, fehlt);
}
// Zuständige VOR dem Speichern festhalten — danach lassen sich neu
// hinzugekommene erkennen (neueZuweisungenMelden()). Eine Woche, die es
// noch nicht gab, meldet nichts: Sie entsteht als Ganzes, nicht durch
// eine Zuweisung.
const wocheGabEs = !!db.prepare('SELECT 1 FROM weeks WHERE label = ?').get(String(week.label));
const alteZuweisungen = new Set(db.prepare('SELECT todo_id, person_id FROM plan_todo_assignees WHERE week_label = ?')
.all(String(week.label)).map(r => r.todo_id + '|' + r.person_id));
// `updatedAt` zurückgeben, damit der Client seine lokale Revisions-Basis
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
const updatedAt = saveWeekRelational(week);
if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user);
if(wocheGabEs) neueZuweisungenMelden(week, alteZuweisungen, req.user, req);
console.log('Saved week:', week.label);
ereignisSenden({ typ: 'woche', label: week.label, updatedAt }, req);
return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
} catch(err){
console.error('Save week error', err);
return res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Teams: Nachricht bei neuen Aufgaben ----------
//
// Eriks Wunsch: Wird jemandem eine Aufgabe zugewiesen, erscheint in einem
// Teams-Kanal eine Nachricht mit Link direkt auf die Aufgabe im Projekt.
// Eingerichtet in Teams über „Workflows“ → „Beim Empfang einer
// Webhook-Anfrage in einem Kanal posten“; die dort erzeugte Adresse trägt
// ein Admin unter Admin → Teams ein (Recht a.teams). Die Adresse ist ein
// Geheimnis (wer sie kennt, kann in den Kanal schreiben) — sie steht in
// `integrationen`, nicht in app_settings (das jedes Konto lesen darf), und
// geht nie an den Browser zurück. Nicht in IMPORT_TABLES.
//
// Senden braucht Internetzugang nach außen; ein Fehler wird nur geloggt,
// das Speichern der Sitzung scheitert nie daran.
db.exec(`CREATE TABLE IF NOT EXISTS integrationen (
schluessel TEXT PRIMARY KEY,
wert TEXT NOT NULL DEFAULT ''
)`);
function integrationLesen(k){
const r = db.prepare('SELECT wert FROM integrationen WHERE schluessel = ?').get(k);
return r ? r.wert : '';
}
function integrationSchreiben(k, v){
db.prepare('INSERT INTO integrationen (schluessel, wert) VALUES (?, ?) ON CONFLICT(schluessel) DO UPDATE SET wert = excluded.wert')
.run(k, String(v));
}
// teamsKarte: Adaptive Card im Umschlag, den der Teams-Workflow erwartet.
function teamsKarte(titel, zeilen, fakten, link, linkText){
const body = [{ type: 'TextBlock', text: titel, weight: 'Bolder', size: 'Medium', wrap: true }];
zeilen.forEach(z => body.push({ type: 'TextBlock', text: z, wrap: true, spacing: 'Small' }));
if(fakten.length) body.push({ type: 'FactSet', facts: fakten.map(([title, value]) => ({ title, value })) });
const content = {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard', version: '1.4', body,
actions: link ? [{ type: 'Action.OpenUrl', title: linkText || 'Öffnen', url: link }] : []
};
return { type: 'message', attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', contentUrl: null, content }] };
}
// teamsSenden: an die eingetragene Adresse schicken. Rückgabe: Promise —
// wirft bei Fehler (für den Test-Knopf); Aufrufer im Hintergrund fangen ab.
async function teamsSenden(nachricht, url){
const ziel = url || integrationLesen('teams_webhook');
if(!ziel) throw new Error('Keine Teams-Adresse eingetragen.');
const res = await fetchMitTimeout(ziel, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nachricht)
});
if(!res.ok){
const t = await res.text().catch(() => '');
throw new Error('Teams antwortete mit HTTP ' + res.status + (t ? ': ' + t.slice(0, 200) : ''));
}
}
// aufgabenLink: Adresse der Aufgabe im Projekt (der Browser öffnet sie direkt
// im Aufgaben-Panel, Parameter aufgabe=). Host aus der speichernden Anfrage —
// also die Adresse, unter der die App im Büro aufgerufen wird.
function aufgabenLink(req, weekLabel, p, todoId){
if(!req) return '';
const u = new URL('/Projektplan/' + encodeURIComponent(p.name || ''), req.protocol + '://' + req.get('host'));
u.searchParams.set('weekLabel', weekLabel);
u.searchParams.set('projNr', p.nr || '');
u.searchParams.set('aufgabe', String(todoId));
u.hash = 'aufgaben';
return u.toString();
}
function teamsDatum(iso){
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''));
return m ? m[3] + '.' + m[2] + '.' + m[1] : '';
}
// teamsNeueAufgaben: je Aufgabe mit neu Zuständigen EINE Nachricht. neu =
// Map todoId -> { p, t, personen:[ids] }. Läuft im Hintergrund.
function teamsNeueAufgaben(weekLabel, neu, user, req){
if(!neu.size || integrationLesen('teams_neue_aufgaben') !== '1' || !integrationLesen('teams_webhook')) return;
const namen = new Map(db.prepare('SELECT person_id, name, full_name FROM week_people WHERE week_label = ?').all(weekLabel)
.map(r => [r.person_id, r.full_name || r.name || r.person_id]));
const von = (user && (user.displayName || user.username)) || '';
neu.forEach(({ p, t, personen }) => {
const wer = personen.map(id => namen.get(id) || id).join(', ');
const zeitraum = t.start && t.end && t.start !== t.end ? teamsDatum(t.start) + ' – ' + teamsDatum(t.end) : '';
const fakten = [['Projekt', [p.nr, p.name].filter(Boolean).join(' ')], ['Zuständig', wer]];
if(t.end) fakten.push(['Fällig', teamsDatum(t.end)]);
if(zeitraum) fakten.push(['Zeitraum', zeitraum]);
if(Number(t.aufwand) > 0) fakten.push(['Aufwand', String(t.aufwand).replace('.', ',') + ' h']);
if(von) fakten.push(['Zugewiesen von', von]);
const karte = teamsKarte('Neue Aufgabe: ' + (t.title || 'Aufgabe'), [], fakten,
aufgabenLink(req, weekLabel, p, t.id), 'Aufgabe öffnen');
teamsSenden(karte).catch(err => console.warn('Teams-Nachricht nicht gesendet:', err.message));
});
}
app.get('/admin/teams', requireRecht('a.teams'), (req, res) => {
const url = integrationLesen('teams_webhook');
let host = '';
try { host = url ? new URL(url).host : ''; } catch(e){ /* egal */ }
res.json({ ok: true, konfiguriert: !!url, host, neueAufgaben: integrationLesen('teams_neue_aufgaben') === '1' });
});
// PUT {webhook?, neueAufgaben?} — webhook '' entfernt die Adresse.
app.put('/admin/teams', requireRecht('a.teams'), (req, res) => {
try {
const b = req.body || {};
if(typeof b.webhook === 'string'){
const w = b.webhook.trim();
if(w){
let u;
try { u = new URL(w); } catch(e){ return res.status(400).json({ ok: false, error: 'Das ist keine gültige Adresse.' }); }
if(u.protocol !== 'https:') return res.status(400).json({ ok: false, error: 'Die Teams-Adresse muss mit https:// beginnen.' });
}
integrationSchreiben('teams_webhook', w);
}
if(typeof b.neueAufgaben === 'boolean') integrationSchreiben('teams_neue_aufgaben', b.neueAufgaben ? '1' : '0');
res.json({ ok: true });
} catch(err){
console.error('Teams-Einstellung speichern fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// POST /admin/teams/test — Testnachricht; Fehler als {ok:false, error} mit
// HTTP 200 (ein fehlgeschlagener Test ist ein erwartetes Ergebnis).
app.post('/admin/teams/test', requireRecht('a.teams'), async (req, res) => {
try {
const link = req.protocol + '://' + req.get('host') + '/';
await teamsSenden(teamsKarte('Projektübersicht ist mit Teams verbunden',
['Ab jetzt erscheinen hier neue Aufgaben mit einem Link direkt zur Aufgabe im Projekt.'],
[['Eingerichtet von', req.user.displayName || req.user.username]], link, 'Projektübersicht öffnen'));
res.json({ ok: true });
} catch(err){
res.json({ ok: false, error: err.message });
}
});
// neueZuweisungenMelden: legt für jede Person, die einer offenen Aufgabe neu
// als zuständig zugewiesen wurde, eine Benachrichtigung an — sofern zu der
// Person ein aktives Konto gehört (users.person_id; Kiosk-Konten nicht) und
// sie sich nicht selbst zugewiesen hat. Läuft NACH dem erfolgreichen
// Speichern und darf es nie scheitern lassen (Fehler werden nur geloggt).
// Parameter: week (gespeichertes Wochenobjekt), alt (Set "todoId|personId"
// vor dem Speichern), user (req.user). Keine Rückgabe.
function neueZuweisungenMelden(week, alt, user, req){
try {
const konto = db.prepare(`SELECT id FROM users WHERE person_id = ? AND person_id != '' AND active = 1 AND ${sqlOhneWandtafel()}`);
const schonDa = db.prepare('SELECT 1 FROM benachrichtigungen WHERE user_id = ? AND todo_id = ? AND gelesen = 0');
const ins = db.prepare(`INSERT INTO benachrichtigungen
(id, user_id, art, titel, project_id, project_nr, project_name, week_label, todo_id, faellig, von_name, created_at)
VALUES (?, ?, 'aufgabe', ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const jetzt = new Date().toISOString();
const von = (user && (user.displayName || user.username)) || '';
let anzahl = 0;
// Für Teams: je Aufgabe die neu Zuständigen — auch Personen ohne Konto,
// aber nicht, wer sich selbst zuweist; nie aus der Planung-Sandbox.
const fuerTeams = new Map();
const sandbox = week.label === SANDBOX_WEEK_LABEL;
const ichPerson = user && user.personId ? String(user.personId) : '';
(Array.isArray(week.projects) ? week.projects : []).forEach(p => {
const todos = (p && p.plan && Array.isArray(p.plan.todos)) ? p.plan.todos : [];
todos.forEach(t => {
if(!t || t.done) return;
(Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => {
if(alt.has(String(t.id) + '|' + String(pid))) return;
if(!sandbox && !t.lph && String(pid) !== ichPerson){
if(!fuerTeams.has(String(t.id))) fuerTeams.set(String(t.id), { p, t, personen: [] });
fuerTeams.get(String(t.id)).personen.push(String(pid));
}
const k = konto.get(String(pid));
if(!k || (user && k.id === user.id)) return;
if(schonDa.get(k.id, String(t.id))) return;
ins.run(crypto.randomUUID(), k.id, String(t.title || 'Aufgabe'), String(p.id || ''), String(p.nr || ''),
String(p.name || ''), String(week.label || ''), String(t.id || ''), String(t.end || ''), von, jetzt);
anzahl++;
});
});
});
if(anzahl) console.log('Benachrichtigungen angelegt:', anzahl, '(neue Aufgaben-Zuweisungen)');
teamsNeueAufgaben(String(week.label || ''), fuerTeams, user, req);
} catch(err){
console.error('Benachrichtigungen konnten nicht angelegt werden:', err.message);
}
}
// purgeBenachrichtigungen: gelesene nach 60, alle nach 180 Tagen entfernen.
function purgeBenachrichtigungen(){
try {
const tag = 864e5;
const n = db.prepare('DELETE FROM benachrichtigungen WHERE (gelesen = 1 AND created_at < ?) OR created_at < ?')
.run(new Date(Date.now() - 60 * tag).toISOString(), new Date(Date.now() - 180 * tag).toISOString()).changes;
if(n) console.log('Alte Benachrichtigungen entfernt:', n);
} catch(err){ console.error('Benachrichtigungen aufräumen fehlgeschlagen:', err.message); }
}
// GET /benachrichtigungen: die 30 jüngsten des angemeldeten Kontos plus die
// Zahl der ungelesenen (für die Glocke). Kiosk-Konten bekommen 403 über
// requireAuth wie überall.
app.get('/benachrichtigungen', requireAuth, (req, res) => {
try {
const rows = db.prepare(`SELECT * FROM benachrichtigungen WHERE user_id = ?
ORDER BY created_at DESC LIMIT 30`).all(req.user.id);
const ungelesen = db.prepare('SELECT COUNT(*) AS n FROM benachrichtigungen WHERE user_id = ? AND gelesen = 0').get(req.user.id).n;
res.json({ ok: true, ungelesen, eintraege: rows.map(r => ({
id: r.id, art: r.art, titel: r.titel, projectId: r.project_id, projectNr: r.project_nr,
projectName: r.project_name, weekLabel: r.week_label, todoId: r.todo_id, faellig: r.faellig,
von: r.von_name, erstellt: r.created_at, gelesen: !!r.gelesen,
})) });
} catch(err){
console.error('Benachrichtigungen laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// POST /benachrichtigungen/gelesen {ids:[…]} oder {alle:true}: markiert als
// gelesen — immer nur Einträge des eigenen Kontos.
app.post('/benachrichtigungen/gelesen', requireAuth, (req, res) => {
try {
const { ids, alle } = req.body || {};
let n = 0;
if(alle){
n = db.prepare('UPDATE benachrichtigungen SET gelesen = 1 WHERE user_id = ? AND gelesen = 0').run(req.user.id).changes;
} else if(Array.isArray(ids)){
const upd = db.prepare('UPDATE benachrichtigungen SET gelesen = 1 WHERE user_id = ? AND id = ?');
ids.slice(0, 200).forEach(id => { n += upd.run(req.user.id, String(id)).changes; });
} else {
return res.status(400).json({ ok: false, error: 'ids oder alle angeben.' });
}
res.json({ ok: true, markiert: n });
} catch(err){
console.error('Benachrichtigungen markieren fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// POST /benachrichtigungen/loeschen {ids:[…]} oder {alle:true}: entfernt
// Einträge endgültig (Eriks Wunsch: für die User löschbar) — wie beim
// Markieren immer nur Einträge des eigenen Kontos.
app.post('/benachrichtigungen/loeschen', requireAuth, (req, res) => {
try {
const { ids, alle } = req.body || {};
let n = 0;
if(alle){
n = db.prepare('DELETE FROM benachrichtigungen WHERE user_id = ?').run(req.user.id).changes;
} else if(Array.isArray(ids)){
const del = db.prepare('DELETE FROM benachrichtigungen WHERE user_id = ? AND id = ?');
ids.slice(0, 200).forEach(id => { n += del.run(req.user.id, String(id)).changes; });
} else {
return res.status(400).json({ ok: false, error: 'ids oder alle angeben.' });
}
res.json({ ok: true, geloescht: n });
} catch(err){
console.error('Benachrichtigungen löschen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Live-Ereignisse (Server-Sent Events) ----------
//
// Eriks Wunsch: Änderungen sollen sofort bei allen ankommen, nicht erst beim
// nächsten Abfragen (vorher alle 20 Sekunden), und nur der geänderte Teil.
// GET /ereignisse hält je offenem Browser eine Verbindung offen; nach jedem
// Speichern meldet ereignisSenden() allen anderen, was sich geändert hat:
// { typ:'projekte', label, updatedAt, projekte:[…] } — nur die geänderten
// Projekte, so wie sie jetzt in der Datenbank stehen (POST /save-projects)
// { typ:'woche', label, updatedAt, geloescht? } — eine ganze Sitzung hat sich
// geändert (POST /save-week, Entsperren, Papierkorb, Löschen); der
// Browser holt sie über GET /week nach
// { typ:'neu' } — neue Sitzung oder Backup eingespielt: alles neu laden
// Der Absender bekommt sein eigenes Ereignis nicht (Kopfzeile X-Client-Id,
// je Browser-Tab zufällig). Keine Abhängigkeit, kein WebSocket: SSE ist
// reines HTTP und läuft unverändert durch Docker. Ein vorgeschalteter Proxy
// (nginx) darf diese Antwort nicht puffern — X-Accel-Buffering: no.
const ereignisKunden = new Set(); // { res, token, clientId }
app.get('/ereignisse', requireAuthOderKiosk, (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
res.write('retry: 3000\n\n');
const kunde = {
res,
token: parseCookies(req)[SESSION_COOKIE] || '',
clientId: String(req.query.client || '')
};
ereignisKunden.add(kunde);
req.on('close', () => ereignisKunden.delete(kunde));
});
// Herzschlag: hält die Verbindung durch Router/Proxys offen, die stille
// Verbindungen sonst nach einer Weile trennen.
setInterval(() => { ereignisKunden.forEach(k => { try { k.res.write(': ping\n\n'); } catch(e){} }); }, 25000);
// ereignisSenden: verteilt ein Ereignis an alle verbundenen Browser außer dem
// auslösenden. Abgelaufene oder abgemeldete Sitzungen bekommen nichts mehr
// und werden getrennt. Parameter: daten (Objekt), req (auslösende Anfrage
// oder null).
function ereignisSenden(daten, req){
if(ereignisKunden.size === 0) return;
const von = req ? String(req.get('x-client-id') || '') : '';
const text = 'data: ' + JSON.stringify(daten) + '\n\n';
const jetzt = new Date().toISOString();
const gueltig = db.prepare('SELECT 1 FROM user_sessions WHERE token = ? AND expires_at > ?');
ereignisKunden.forEach(k => {
if(von && k.clientId === von) return;
if(!gueltig.get(k.token, jetzt)){ ereignisKunden.delete(k); try { k.res.end(); } catch(e){} return; }
try { k.res.write(text); } catch(e){ ereignisKunden.delete(k); }
});
}
// GET /week?label=: eine einzelne Sitzung (statt /load-all mit allen) — für
// das Nachladen nach einem Ereignis und beim Zusammenführen. Die Sandbox
// kommt in derselben Form wie bei /load-all (nur ihre Projekte).
app.get('/week', requireAuthOderKiosk, (req, res) => {
try {
const label = String(req.query.label || '');
const w = label ? loadWeeks(label)[0] : null;
if(!w) return res.status(404).json({ ok: false, error: 'Sitzung nicht gefunden' });
res.json({ ok: true, week: w });
} catch(err){
res.status(500).json({ ok: false, error: String(err) });
}
});
// POST /save-projects {label, projects:[{project, baseUpdatedAt}]}: speichert
// NUR die übergebenen Projekte einer Sitzung — der Browser schickt, was sich
// gegenüber dem zuletzt bekannten Server-Stand geändert hat, statt der ganzen
// Sitzung. Konflikte werden je PROJEKT geprüft (projects.updated_at): Zwei
// Personen an verschiedenen Projekten kommen sich nicht mehr in die Quere.
// Nur für bestehende Projekte; Anlegen, Löschen, Umsortieren und Änderungen
// an der Sitzung selbst (Teilnehmer, Anwesenheit, Sperre) laufen weiter über
// /save-week. Antwort: { ok, updatedAt (Sitzung), projekte: {id: updatedAt} };
// 409 { conflict, projekte:[ids] }, wenn ein Projekt inzwischen anders steht.
app.post('/save-projects', requireAuth, (req, res) => {
const { label, projects } = req.body || {};
if(typeof label !== 'string' || !label || !Array.isArray(projects) || projects.length === 0){
return res.status(400).json({ ok: false, error: 'label und projects erforderlich' });
}
const liste = projects.filter(e => e && e.project && e.project.id);
const badStatus = findInvalidAuftragsstatus({ projects: liste.map(e => e.project) });
if(badStatus){
return res.status(400).json({ ok: false, error: 'Unbekannter Auftragsstatus "' + badStatus.wert + '".' });
}
try {
const wocheRow = db.prepare('SELECT locked, updated_at FROM weeks WHERE label = ?').get(label);
if(!wocheRow) return res.status(409).json({ ok: false, conflict: true, error: 'Die Sitzung "' + label + '" gibt es nicht mehr.', projekte: [] });
if(wocheRow.locked && !hatRecht(req.user, 's.gesperrt')){
return res.status(409).json({ ok: false, conflict: true, error: 'Die Sitzung "' + label + '" ist gesperrt.', projekte: [] });
}
const stand = db.prepare('SELECT array_order, updated_at, project_uid FROM projects WHERE week_label = ? AND project_id = ?');
const konflikte = [];
const gefunden = liste.map(e => {
const row = stand.get(label, String(e.project.id));
if(!row || String(e.baseUpdatedAt || '') !== String(row.updated_at || '')
|| String(e.project.projectUid || '') !== String(row.project_uid || '')) konflikte.push(String(e.project.id));
return { e, row };
});
if(konflikte.length){
return res.status(409).json({ ok: false, conflict: true, projekte: konflikte,
error: 'Ein Projekt wurde zwischenzeitlich von einer anderen Person geändert.' });
}
{
const fehlt = fehlendesBearbeitungsrecht(req.user, label, liste.map(e => e.project), null);
if(fehlt) return rechtFehlt(res, fehlt);
}
const alteZuweisungen = new Set(db.prepare('SELECT todo_id, person_id FROM plan_todo_assignees WHERE week_label = ?')
.all(label).map(r => r.todo_id + '|' + r.person_id));
const jetzt = new Date().toISOString();
db.exec('BEGIN');
try {
gefunden.forEach(({ e, row }) => {
deleteProjectData(label, String(e.project.id));
serializeProject(label, e.project, row.array_order, jetzt);
});
// Die Sitzung als Ganzes ist damit auch geändert: Ein gleichzeitiges
// /save-week auf altem Stand muss das als Konflikt bemerken.
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, label);
db.exec('COMMIT');
} catch(err){ db.exec('ROLLBACK'); throw err; }
neueZuweisungenMelden({ label, projects: liste.map(e => e.project) }, alteZuweisungen, req.user, req);
const ids = new Set(liste.map(e => String(e.project.id)));
const frisch = (loadWeeks(label)[0] || { projects: [] }).projects.filter(p => ids.has(String(p.id)));
ereignisSenden({ typ: 'projekte', label, updatedAt: jetzt, projekte: frisch }, req);
const projekte = {};
frisch.forEach(p => { projekte[p.id] = p.updatedAt; });
console.log('Saved projects:', label, Array.from(ids).length);
res.json({ ok: true, updatedAt: jetzt, projekte });
} catch(err){
console.error('Save projects error', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// GET /week-revisionen: der Revisionsstand (weeks.updated_at) jeder Sitzung —
// winzig, deshalb fragt der Browser ihn alle 20 Sekunden ab und holt nur bei
// einer Abweichung die geänderte Sitzung nach (Live-Abgleich beim
// gleichzeitigen Arbeiten, js/store.js). Für die Wandtafel ebenfalls lesbar.
// GET /weeks-aelter?vor=<label>&anzahl=5: die nächsten älteren Sitzungen vor
// <label> vollständig ("Weitere laden" im Verlauf), dazu wie viele danach
// noch übrig sind.
app.get('/weeks-aelter', requireAuthOderKiosk, (req, res) => {
try {
const liste = echteSitzungenSortiert();
const vor = String(req.query.vor || '');
const anzahl = Math.min(50, Math.max(1, Math.floor(Number(req.query.anzahl)) || 5));
const idx = liste.findIndex(w => w.label === vor);
const bis = idx < 0 ? liste.length : idx;
const von = Math.max(0, bis - anzahl);
const weeks = loadWeeks(liste.slice(von, bis).map(w => w.label));
res.json({ ok: true, weeks, uebrig: von });
} catch(err){
console.error('Ältere Sitzungen laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
app.get('/week-revisionen', requireAuthOderKiosk, (req, res) => {
try {
const revisionen = {};
db.prepare('SELECT label, updated_at FROM weeks').all().forEach(r => { revisionen[r.label] = r.updated_at || ''; });
res.json({ ok: true, revisionen });
} catch(err){
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Bearbeitungsrechte beim Speichern ----------
// /save-week und /save-projects schreiben ganze Projekte. Welches Recht eine
// Änderung braucht, zeigt erst der Vergleich mit dem gespeicherten Stand:
// Kommentar und Leistungsphasen-Fortschritt → wb.kommentar, „besprochen“ →
// wb.besprochen, Terminplan (plan) → proj.plan, alles andere am Projekt →
// proj.stamm, Anwesenheit der Sitzung → wb.anwesenheit, die Planung-Sandbox
// → wz.planung. Wer alle diese Rechte hat (Startbelegung), überspringt den
// Vergleich ganz — nur eingeschränkte Rollen zahlen dafür.
const BEARBEITUNGS_RECHTE = ['wb.kommentar', 'wb.besprochen', 'wb.anwesenheit', 'proj.stamm', 'proj.plan'];
const PROJEKT_FELDER_OHNE_RECHT = new Set(['id', 'projectUid', 'updatedAt', 'order',
'status', 'statusAuthor', 'statusAt', 'besprochen', 'statusState']);
// normWert: vergleichbare Form — leere Werte (undefined, null, '', false, 0,
// [], {}) gelten als gleich, Zahlen und Text ebenso („5“ = 5), Schlüssel-
// reihenfolge zählt nicht. Sonst meldete ein Feld, das der Browser nur
// anders schreibt, eine Änderung, die niemand gemacht hat.
function normWert(v){
if(v === undefined || v === null || v === '' || v === false || v === 0) return null;
if(Array.isArray(v)){ const a = v.map(normWert); return a.length ? a : null; }
if(typeof v === 'object'){
const o = {};
Object.keys(v).sort().forEach(k => { const x = normWert(v[k]); if(x !== null) o[k] = x; });
return Object.keys(o).length ? o : null;
}
return String(v);
}
const gleichNorm = (a, b) => JSON.stringify(normWert(a)) === JSON.stringify(normWert(b));
// fehlendesBearbeitungsrecht: erstes fehlende Recht für die Änderungen oder
// null. Parameter: user, label, projekte (eingehend), woche (bei /save-week,
// für die Anwesenheit; sonst null).
function fehlendesBearbeitungsrecht(user, label, projekte, woche){
if(String(label) === SANDBOX_WEEK_LABEL) return hatRecht(user, 'wz.planung') ? null : 'wz.planung';
if(BEARBEITUNGS_RECHTE.every(r => hatRecht(user, r))) return null;
const alt = loadWeeks(String(label))[0];
if(!alt) return null; // neue Sitzung: legt nur der Server an
if(woche && !hatRecht(user, 'wb.anwesenheit') && !gleichNorm(woche.attendance, alt.attendance)) return 'wb.anwesenheit';
const altJeId = new Map(), altJeUid = new Map();
(alt.projects || []).forEach(p => { altJeId.set(String(p.id), p); if(p.projectUid) altJeUid.set(String(p.projectUid), p); });
for(const p of (projekte || []).filter(Boolean)){
const a = altJeId.get(String(p.id)) || (p.projectUid && altJeUid.get(String(p.projectUid)));
if(!a) continue; // neu angelegt — prüft darfProjektlisteAendern()
const keys = new Set([...Object.keys(p), ...Object.keys(a)]);
for(const k of keys){
if(PROJEKT_FELDER_OHNE_RECHT.has(k) || gleichNorm(p[k], a[k])) continue;
if(k === 'kommentare'){
const kn = p.kommentare || {}, ka = a.kommentare || {};
for(const g of new Set([...Object.keys(kn), ...Object.keys(ka)])){
const x = kn[g] || {}, y = ka[g] || {};
if(!gleichNorm({ s: x.status, a: x.statusAuthor, t: x.statusAt }, { s: y.status, a: y.statusAuthor, t: y.statusAt })
&& !hatRecht(user, 'wb.kommentar')) return 'wb.kommentar';
if(!gleichNorm(x.besprochen, y.besprochen) && !hatRecht(user, 'wb.besprochen')) return 'wb.besprochen';
}
continue;
}
const recht = k === 'lph' ? 'wb.kommentar' : k === 'plan' ? 'proj.plan' : 'proj.stamm';
if(!hatRecht(user, recht)) return recht;
}
}
return null;
}
// darfProjektlisteAendern: Recht „Projekte anlegen und löschen“ (Rollen &
// Rechte). Dieselbe Regel steht clientseitig in
// darfProjektlisteAendern() (public/js/auth.js), dort nur als UI-Gate.
function darfProjektlisteAendern(user){
return hatRecht(user, 'proj.anlegen');
}
// projektlisteAenderungen: vergleicht die eingehende Woche mit dem
// gespeicherten Stand. Ein Projekt gilt als dasselbe, wenn seine ID ODER
// seine projectUid übereinstimmt. Rückgabe:
// neu — eingehende Projekte ohne Gegenstück (angelegt)
// entfernt — vollständige gespeicherte Projekt-Objekte (aus loadWeeks())
// ohne Gegenstück (gelöscht); der teure loadWeeks()-Aufruf läuft
// nur, wenn die günstige Vorprüfung überhaupt eines findet.
// Neue Sitzungen legt nur der Server selbst an (ensureCurrentWeekRollover(),
// ohne /save-week) — eine über /save-week neu eingehende Woche mit Projekten
// zählt deshalb ebenfalls als Anlegen.
function projektlisteAenderungen(week){
const label = String(week.label);
const gespeichert = db.prepare('SELECT project_id, project_uid FROM projects WHERE week_label = ?').all(label);
const eingehend = (Array.isArray(week.projects) ? week.projects : []).filter(Boolean);
const altIds = new Set(gespeichert.map(r => r.project_id));
const altUids = new Set(gespeichert.map(r => r.project_uid).filter(Boolean));
const neu = eingehend.filter(p => !altIds.has(String(p.id || '')) && !(p.projectUid && altUids.has(String(p.projectUid))));
const ids = new Set(eingehend.map(p => String(p.id || '')).filter(Boolean));
const uids = new Set(eingehend.map(p => String(p.projectUid || '')).filter(Boolean));
const fehlend = gespeichert.filter(r => !ids.has(r.project_id) && !(r.project_uid && uids.has(r.project_uid)));
if(!fehlend.length) return { neu, entfernt: [] };
// Nur diese eine Sitzung laden, nicht alle (loadWeeks(label)).
const w = loadWeeks(label)[0];
const fehlendIds = new Set(fehlend.map(r => r.project_id));
return { neu, entfernt: w ? (w.projects || []).filter(p => fehlendIds.has(String(p.id))) : [] };
}
// legeInPapierkorb: schreibt je gelöschtem Projekt einen Schnappschuss nach
// deleted_projects. Läuft NACH dem erfolgreichen Speichern der Woche — ein
// fehlgeschlagenes Speichern darf keinen Papierkorb-Eintrag für ein Projekt
// hinterlassen, das in Wahrheit noch da ist.
function legeInPapierkorb(projekte, weekLabel, user){
const ins = db.prepare(`INSERT INTO deleted_projects
(id, week_label, project_id, project_uid, nr, name, data, deleted_by_user_id, deleted_by_name, deleted_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`);
const jetzt = new Date().toISOString();
projekte.forEach(p => {
try {
ins.run(crypto.randomUUID(), String(weekLabel), String(p.id || ''), String(p.projectUid || ''),
String(p.nr || ''), String(p.name || ''), JSON.stringify(p),
user ? Number(user.id) || 0 : 0, user ? String(user.displayName || user.username || '') : '', jetzt);
console.log('Projekt in den Papierkorb gelegt:', ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim());
} catch(err){
console.error('Papierkorb-Eintrag fehlgeschlagen:', err.message);
}
});
}
// Route: Papierkorb — listet die gelöschten Projekte der letzten
// GELOESCHTE_PROJEKTE_TAGE Tage (ohne den Schnappschuss selbst).
app.get('/admin/deleted-projects', requireRecht('a.papierkorb'), (req, res) => {
try {
purgeGeloeschteProjekte();
const rows = db.prepare(`SELECT id, week_label, nr, name, deleted_by_name, deleted_at
FROM deleted_projects ORDER BY deleted_at DESC`).all();
const tagMs = 24 * 60 * 60 * 1000;
res.json({
ok: true,
aufbewahrungTage: GELOESCHTE_PROJEKTE_TAGE,
projekte: rows.map(r => ({
id: r.id, weekLabel: r.week_label, nr: r.nr, name: r.name,
deletedBy: r.deleted_by_name, deletedAt: r.deleted_at,
endgueltigAm: new Date(Date.parse(r.deleted_at) + GELOESCHTE_PROJEKTE_TAGE * tagMs).toISOString()
}))
});
} catch(err){
console.error('Papierkorb laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Papierkorb — stellt ein gelöschtes Projekt wieder her. Ziel ist
// immer die aktuelle (jüngste echte) Sitzung, nicht die, aus der es gelöscht
// wurde: Ältere Sitzungen sind gesperrte Historie. Eine Nummern-Kollision mit
// einem inzwischen neu angelegten Projekt wird mit 409 abgewiesen (Dateien
// und Protokolle hängen an der Nummer und gehörten sonst zwei Projekten).
app.post('/admin/deleted-projects/:id/restore', requireRecht('a.papierkorb'), (req, res) => {
try {
const row = db.prepare('SELECT * FROM deleted_projects WHERE id = ?').get(String(req.params.id));
if(!row) return res.status(404).json({ ok: false, error: 'Eintrag nicht gefunden (evtl. schon wiederhergestellt oder abgelaufen).' });
const echte = loadWeeks().filter(w => w.label !== SANDBOX_WEEK_LABEL)
.sort((a, b) => weekSortKeyServer(a.label).localeCompare(weekSortKeyServer(b.label)));
const ziel = echte[echte.length - 1];
if(!ziel) return res.status(409).json({ ok: false, error: 'Es gibt keine Sitzung, in die das Projekt zurückkehren könnte.' });
const projekt = JSON.parse(row.data);
const projekte = Array.isArray(ziel.projects) ? ziel.projects : (ziel.projects = []);
const nr = String(projekt.nr || '').trim();
if(nr && projekte.some(p => String(p.nr || '').trim() === nr)){
return res.status(409).json({ ok: false, error: 'In der Sitzung "' + ziel.label + '" gibt es bereits ein Projekt mit der Nummer ' + nr + '.' });
}
if(projekt.projectUid && projekte.some(p => p.projectUid === projekt.projectUid)){
return res.status(409).json({ ok: false, error: 'Dieses Projekt steht bereits in der Sitzung "' + ziel.label + '".' });
}
// Neue ID, falls die alte in der Zielsitzung schon vergeben ist (nach
// einem Sitzungswechsel praktisch ausgeschlossen, aber billig abzusichern).
if(!projekt.id || projekte.some(p => p.id === projekt.id)) projekt.id = crypto.randomUUID();
// Ans Ende seiner Gruppe — die alte Position kann inzwischen belegt sein.
const maxOrder = projekte.filter(p => p.group === projekt.group)
.reduce((m, p) => Math.max(m, Number(p.order) || 0), -1);
projekt.order = maxOrder + 1;
// Der Sitzungskommentar gehört zur Sitzung, aus der das Projekt stammt.
if(row.week_label !== ziel.label){
projekt.kommentare = {}; delete projekt.statusState;
}
projekte.push(projekt);
const updatedAt = saveWeekRelational(ziel);
db.prepare('DELETE FROM deleted_projects WHERE id = ?').run(row.id);
console.log('Projekt aus dem Papierkorb wiederhergestellt:', ((projekt.nr ? projekt.nr + ' ' : '') + (projekt.name || '')).trim(), '→', ziel.label);
ereignisSenden({ typ: 'woche', label: ziel.label, updatedAt }, req);
res.json({ ok: true, weekLabel: ziel.label, updatedAt });
} catch(err){
console.error('Wiederherstellen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Papierkorb — entfernt einen Eintrag sofort endgültig.
app.delete('/admin/deleted-projects/:id', requireRecht('a.papierkorb'), (req, res) => {
try {
db.prepare('DELETE FROM deleted_projects WHERE id = ?').run(String(req.params.id));
res.json({ ok: true });
} catch(err){
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Löscht eine Woche (fehlende Woche wird toleriert)
app.post('/delete-week', requireRecht('s.loeschen'), (req, res) => {
const { label } = req.body || {};
if(!label) return res.status(400).json({ ok: false, error: 'Missing label' });
if(label === SANDBOX_WEEK_LABEL) return res.status(400).json({ ok: false, error: 'Die Planung-Sandbox ist keine Sitzung und kann hier nicht gelöscht werden.' });
try {
const row = db.prepare('SELECT 1 FROM weeks WHERE label = ?').get(String(label));
const existed = !!row;
// In einer Transaktion: deleteWeekData() räumt 18 Tabellen leer. Bricht
// eines der DELETEs ab, blieben sonst Waisen-Zeilen zurück, die eine
// später gleichnamig angelegte Sitzung wieder einsammeln würde.
db.exec('BEGIN');
try {
deleteWeekData(String(label));
db.exec('COMMIT');
} catch(err){
try { db.exec('ROLLBACK'); } catch(e){ /* keine offene Transaktion */ }
throw err;
}
console.log('Deleted week:', label, existed ? '' : '(nicht vorhanden)');
if(existed) ereignisSenden({ typ: 'woche', label: String(label), geloescht: true }, req);
return res.json({ ok: true, deleted: existed });
} catch(err){
console.error('Delete week error', err);
return res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Entsperrt eine gesperrte Woche (Admin-only — ersetzt die bisher rein
// clientseitige Entsperr-Logik). Setzt `locked = 0` und schreibt eine neue
// Revision (`updated_at`), damit der Client seine lokale Sync-Basis
// aktualisieren kann (gleiche Konvention wie bei /save-week).
app.post('/unlock-week', requireRecht('s.entsperren'), (req, res) => {
const { label } = req.body || {};
if(!label) return res.status(400).json({ ok: false, error: 'Missing label' });
if(label === SANDBOX_WEEK_LABEL) return res.status(400).json({ ok: false, error: 'Die Planung-Sandbox ist keine Sitzung und wird nie gesperrt.' });
try {
const row = db.prepare('SELECT 1 FROM weeks WHERE label = ?').get(String(label));
if(!row) return res.status(404).json({ ok: false, error: 'Sitzung nicht gefunden.' });
const updatedAt = new Date().toISOString();
db.prepare('UPDATE weeks SET locked = 0, updated_at = ? WHERE label = ?').run(updatedAt, String(label));
console.log('Unlocked week:', label);
ereignisSenden({ typ: 'woche', label: String(label), updatedAt }, req);
return res.json({ ok: true, updatedAt });
} catch(err){
console.error('Unlock week error', err);
return res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Lädt alle Wochen (jetzt eine einzige globale Reihe) plus die
// firmenweite Personenliste. Liefert strukturierte JSON-Objekte (aus den
// relationalen Tabellen zusammengesetzt); nach Label sortiert.
// echteSitzungenSortiert: alle echten Sitzungen (ohne Sandbox) als schlanke
// Liste, älteste zuerst — {id, label, date, locked, updatedAt}.
function echteSitzungenSortiert(){
return db.prepare('SELECT week_id, label, date, locked, updated_at FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL)
.map(r => ({ id: r.week_id, label: r.label, date: r.date || '', locked: !!r.locked, updatedAt: r.updated_at || '' }))
.sort((a, b) => weekSortKeyServer(a.label).localeCompare(weekSortKeyServer(b.label)));
}
// GET /load-all?wochen=N: Mit wochen nur die jüngsten N Sitzungen vollständig
// (Eriks Wunsch: aktuelle + drei davor), dazu wochenListe = ALLE Sitzungen
// schlank (Name, Datum, Sperre) für Admin → Sitzungen und das Nachladen.
// Ohne wochen wie früher alle (z. B. ältere Browser-Stände).
app.get('/load-all', requireAuthOderKiosk, (req, res) => {
try {
const anzahl = Math.floor(Number(req.query.wochen));
const wochenListe = echteSitzungenSortiert();
const allWeeks = anzahl > 0
? loadWeeks(wochenListe.slice(-anzahl).map(w => w.label).concat([SANDBOX_WEEK_LABEL]))
: loadWeeks();
// Die reservierte Sandbox-"Sitzung" (Planung, siehe SANDBOX_WEEK_LABEL)
// ist keine echte Sitzung — sie wird separat als sandboxPlans
// ausgeliefert, statt in weeks[] zu landen. So kommt sie clientseitig
// strukturell nie in db.weeks an und kann dort auch nicht versehentlich
// in Board/Wochenbericht/Auslastung/Sitzungsverwaltung auftauchen.
const weeks = allWeeks.filter(w => w.label !== SANDBOX_WEEK_LABEL);
const sandbox = allWeeks.find(w => w.label === SANDBOX_WEEK_LABEL);
const sandboxPlans = sandbox ? sandbox.projects : [];
const sandboxUpdatedAt = sandbox ? sandbox.updatedAt : '';
const people = loadGlobalPeople(req.user);
res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt, wochenListe });
} catch(err){
console.error('Load error', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Dashboard ----------
// heuteIsoLokal / isoPlusTageLokal: Datum als YYYY-MM-TT in der ZEITZONE DES
// SERVERS, nicht in UTC.
//
// new Date().toISOString().slice(0,10) rechnet nach UTC um: In deutscher
// Sommerzeit (UTC+2) liefert es zwischen 00:00 und 02:00 Ortszeit noch den
// Vortag. Termine von heute galten in diesem Fenster als überfällig, und die
// Abwesenheitsliste ließ eine heute beginnende Abwesenheit aus. Voraussetzung
// ist, dass der Server auf die Zeitzone des Büros gestellt ist — im
// Docker-Container also TZ=Europe/Berlin.
// Parameter: (isoPlusTageLokal) n = Tage in die Zukunft, auch negativ.
// Rückgabe: String.
function heuteIsoLokal(){
return isoPlusTageLokal(0);
}
function isoPlusTageLokal(n){
const d = new Date();
d.setDate(d.getDate() + (Number(n) || 0));
const m = String(d.getMonth() + 1).padStart(2, '0');
const t = String(d.getDate()).padStart(2, '0');
return d.getFullYear() + '-' + m + '-' + t;
}
// weekSortKeyServer: serverseitige Portierung von weekSortKey()
// (public/js/utils.js) — wird NUR im Fallback von /my-dashboard gebraucht,
// wenn die applyAutoLock()-Invariante (genau eine entsperrte Woche je
// Abteilung) ausnahmsweise verletzt ist (0 oder >1 entsperrte Wochen).
function weekSortKeyServer(label){
const m = String(label || '').match(/KW\s*(\d+)[^\d]+(\d{4})/i);
if(m){
const kw = m[1].padStart(2, '0');
return `${m[2]}-${kw}`;
}
return String(label || '').toLowerCase();
}
// Route: persönliches Dashboard des angemeldeten Benutzers — liefert die
// offenen TODOs des eigenen person_id in der aktuell entsperrten (firmenweit
// globalen) Sitzung, ausstehende Datei-Freigaben sowie `colleagueAbsences`
// (aktuelle/anstehende Abwesenheiten aller anderen aktiven Nutzer, siehe
// loadGlobalPeople()). Jede Aufgabe bekommt zusätzlich die Tags ihres
// Projekts (`tags: string[]`, ggf. leer) mit — separat nachgeladen aus
// project_tags, gruppiert nach project_id (siehe Kommentar unten), NICHT per
// JOIN (würde bei mehreren Tags Aufgaben-Zeilen verdoppeln).
app.get('/my-dashboard', requireAuth, (req, res) => {
try {
// Normalfall: applyAutoLock() garantiert genau eine entsperrte Woche
// (jetzt firmenweit global, nicht mehr je Abteilung).
// Die Planung-Sandbox ist immer entsperrt und keine echte Sitzung — ohne
// diesen Ausschluss gab es stets zwei entsperrte Wochen, und die richtige
// wurde nur zufällig über die Sortierung im Rückfallzweig gefunden.
const unlocked = db.prepare('SELECT label FROM weeks WHERE locked = 0 AND label != ?').all(SANDBOX_WEEK_LABEL);
let weekLabel = null;
if(unlocked.length === 1){
weekLabel = unlocked[0].label;
} else {
// Fallback (0 oder >1 entsperrte Wochen, sollte normalerweise nicht
// vorkommen): jüngste Woche nach weekSortKeyServer() wählen.
const all = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL);
if(all.length > 0){
weekLabel = all.map(r => r.label)
.sort((a, b) => weekSortKeyServer(b).localeCompare(weekSortKeyServer(a)))[0];
}
}
let tasks = [];
if(weekLabel && req.user.personId){
tasks = db.prepare(`
SELECT t.todo_id AS todoId, t.title AS title, t.start AS start, t.end AS end,
t.progress AS progress, t.project_id AS projectId, p.nr AS projectNr, p.name AS projectName
FROM plan_todos t
JOIN plan_todo_assignees a
ON a.week_label = t.week_label
AND a.project_id = t.project_id AND a.todo_id = t.todo_id
JOIN projects p
ON p.week_label = t.week_label
AND p.project_id = t.project_id
WHERE t.week_label = ? AND t.done = 0 AND a.person_id = ?
`).all(weekLabel, req.user.personId);
// Sortierung nach Fälligkeit aufsteigend, leere Werte ans Ende.
tasks.sort((a, b) => {
if(!a.end && !b.end) return 0;
if(!a.end) return 1;
if(!b.end) return -1;
return a.end < b.end ? -1 : (a.end > b.end ? 1 : 0);
});
// Tags der zugehörigen Projekte nachladen: project_tags hat keine
// todo_id, ein direkter JOIN würde also bei mehreren Tags pro Projekt
// die TODO-Zeile vervielfachen. Separate, nach (week_label, project_id)
// gruppierte Abfrage stattdessen (gleiches Muster wie beim Zusammenbau
// der Projekte in loadWeeks()). project_id ist nur ein internes
// Zuordnungsfeld für diese Nachladung und wird vor der Antwort wieder
// entfernt (kein Teil der bisherigen /my-dashboard-Antwortform).
const projectIds = [...new Set(tasks.map(t => t.projectId))];
if(projectIds.length > 0){
const placeholders = projectIds.map(() => '?').join(',');
const tagRows = db.prepare(`
SELECT project_id AS projectId, tag FROM project_tags
WHERE week_label = ? AND project_id IN (${placeholders})
ORDER BY tag_order
`).all(weekLabel, ...projectIds);
const tagsByProject = {};
tagRows.forEach(r => { (tagsByProject[r.projectId] || (tagsByProject[r.projectId] = [])).push(r.tag); });
tasks.forEach(t => { t.tags = tagsByProject[t.projectId] || []; });
}
tasks.forEach(t => { delete t.projectId; });
}
// ---------- Termine der eigenen Projekte ----------
//
// Nicht dasselbe wie `tasks` oben: Dort stehen die Aufgaben, die der
// Person PERSÖNLICH zugewiesen sind — auch in Projekten, mit denen sie
// sonst nichts zu tun hat. Hier stehen die Termine der PROJEKTE, denen
// sie zugeordnet ist (project_assign), unabhängig davon, wer die einzelne
// Aufgabe erledigt. Eine Projektleitung muss wissen, wann im eigenen
// Projekt etwas fällig wird, auch wenn ein anderer es abarbeitet.
//
// Doppelungen werden unterdrückt: Was schon unter `tasks` steht,
// erscheint hier nicht noch einmal (siehe eigeneTodoIds).
//
// Der Zeitraum endet nach 21 Tagen. Ohne Grenze stünden Termine aus einem
// Jahr in der Liste und die dringenden gingen darin unter; überfällige
// bleiben dagegen ohne Altersgrenze stehen, denn eine Frist verfällt
// nicht dadurch, dass sie lange gerissen ist.
const HORIZONT_TAGE = 21;
let projectDates = [];
if(weekLabel && req.user.personId){
const grenzeIso = isoPlusTageLokal(HORIZONT_TAGE);
const heuteIso = heuteIsoLokal();
// Projekte, denen die Person in irgendeiner Rolle zugeordnet ist.
// state 1 = Hauptverantwortung, 2 = Unterstützung; 0 kommt in der
// Tabelle nicht vor (cycleAssign() löscht den Eintrag stattdessen),
// wird aber sicherheitshalber ausgeschlossen.
const meineProjekte = db.prepare(`
SELECT DISTINCT a.project_id AS projectId
FROM project_assign a
WHERE a.week_label = ? AND a.person_id = ? AND a.state IN (1, 2)
`).all(weekLabel, req.user.personId).map(r => r.projectId);
if(meineProjekte.length > 0){
const platzhalter = meineProjekte.map(() => '?').join(',');
const eigeneTodoIds = new Set(tasks.map(t => t.todoId));
// Aufgaben: offen, mit Fälligkeit, in den eigenen Projekten.
const todos = db.prepare(`
SELECT t.todo_id AS id, t.title AS titel, t.end AS datum, t.progress AS progress,
t.project_id AS projectId, p.nr AS projectNr, p.name AS projectName
FROM plan_todos t
JOIN projects p ON p.week_label = t.week_label AND p.project_id = t.project_id
WHERE t.week_label = ? AND t.done = 0 AND t.end != ''
AND t.project_id IN (${platzhalter})
`).all(weekLabel, ...meineProjekte);
// Meilensteine: sie tragen kein Erledigt-Kennzeichen, deshalb zählt
// allein das Datum.
const milestones = db.prepare(`
SELECT m.milestone_id AS id, m.name AS titel, m.date AS datum,
m.project_id AS projectId, p.nr AS projectNr, p.name AS projectName
FROM plan_milestones m
JOIN projects p ON p.week_label = m.week_label AND p.project_id = m.project_id
WHERE m.week_label = ? AND m.date != ''
AND m.project_id IN (${platzhalter})
`).all(weekLabel, ...meineProjekte);
// Zuständige je Aufgabe nachladen — getrennt, weil ein JOIN über
// plan_todo_assignees die Aufgabenzeile bei mehreren Zuständigen
// vervielfachen würde (gleiches Muster wie bei den Tags oben).
const namen = {};
db.prepare(`
SELECT a.todo_id AS todoId, a.project_id AS projectId, a.person_id AS personId
FROM plan_todo_assignees a
WHERE a.week_label = ? AND a.project_id IN (${platzhalter})
`).all(weekLabel, ...meineProjekte).forEach(r => {
const key = r.projectId + '|' + r.todoId;
(namen[key] || (namen[key] = [])).push(r.personId);
});
// Namen der Zuständigen: aus week_people der Sitzung, NICHT aus
// loadGlobalPeople(). Die globale Liste enthält nur Personen mit
// Benutzerkonto; die Teilnehmerliste einer Sitzung führt dagegen
// jede Person, die dort mitarbeitet. Ohne diesen Weg stand in der
// Terminliste "p_ap" statt "Alexander Plum".
const personenNamen = {};
db.prepare(`
SELECT person_id AS id, name, full_name AS fullName
FROM week_people WHERE week_label = ?
`).all(weekLabel).forEach(w => {
personenNamen[w.id] = w.fullName || w.name || w.id;
});
projectDates = todos.map(t => ({
art: 'aufgabe',
id: t.id,
titel: t.titel,
datum: t.datum,
progress: t.progress,
projectNr: t.projectNr,
projectName: t.projectName,
zustaendig: (namen[t.projectId + '|' + t.id] || [])
.map(pid => personenNamen[pid] || pid),
selbst: eigeneTodoIds.has(t.id),
})).concat(milestones.map(m => ({
art: 'meilenstein',
id: m.id,
titel: m.titel,
datum: m.datum,
projectNr: m.projectNr,
projectName: m.projectName,
zustaendig: [],
selbst: false,
})));
projectDates = projectDates
// Was bereits unter "Meine Aufgaben" steht, hier nicht wiederholen.
.filter(d => !d.selbst)
.filter(d => d.datum <= grenzeIso)
.sort((a, b) => (a.datum < b.datum ? -1 : (a.datum > b.datum ? 1 : 0)));
// Der Server liefert auch gleich die Einordnung mit, damit Browser
// und PDF/Export nicht jeweils eigene Stichtage rechnen.
projectDates.forEach(d => {
d.ueberfaellig = d.datum < heuteIso;
d.heute = d.datum === heuteIso;
});
}
}
const pendingReviews = db.prepare(`
SELECT f.id AS fileId, f.project_nr AS projectNr,
f.project_name AS projectName, f.original_filename AS originalFilename,
COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS uploadedBy,
f.created_at AS createdAt
FROM project_file_reviewers r
JOIN project_files f ON f.id = r.file_id
LEFT JOIN users u ON u.id = f.uploaded_by_user_id
WHERE r.user_id = ? AND r.decision = 'pending' AND f.status = 'pending'
ORDER BY f.created_at DESC
`).all(req.user.id);
// Abwesenheiten der Kollegen: firmenweite Personenliste (loadGlobalPeople(),
// keine Abteilungstrennung mehr), gefiltert auf Abwesenheiten, die heute
// laufen oder innerhalb der nächsten 14 Tage beginnen — der Aufrufer selbst
// wird per person_id ausgeschlossen. Sortiert nach Startdatum; `current`
// markiert eine heute laufende Abwesenheit fürs Hervorheben im Dashboard.
const todayIso = heuteIsoLokal();
const horizonIso = isoPlusTageLokal(14);
const colleagueAbsences = [];
loadGlobalPeople(req.user).forEach(person => {
if(person.id && person.id === req.user.personId) return;
(person.absences || []).forEach(a => {
if(!a.start || !a.end) return;
if(a.end < todayIso || a.start > horizonIso) return;
colleagueAbsences.push({
personId: person.id,
name: person.fullName || person.name,
type: a.type,
note: a.note,
start: a.start,
end: a.end,
current: a.start <= todayIso && todayIso <= a.end,
});
});
});
colleagueAbsences.sort((a, b) => (a.start < b.start ? -1 : (a.start > b.start ? 1 : 0)));
res.json({ ok: true, weekLabel, tasks, projectDates, pendingReviews, colleagueAbsences });
} catch(err){
console.error('Dashboard-Fehler', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Datei-Upload, Freigabe, Kommentare ----------
//
// Dateien werden über project_nr allein mit Projekten verknüpft, NICHT über
// (week_label, project_id) — project_id wird bei jedem Wochen-Klon neu
// generiert (cloneWeek(), public/js/weeks.js), project_nr bleibt über den
// gesamten Projektlebenszyklus stabil und identifiziert seit der Zusammen-
// führung gleichnummeriger Projekte über ehemalige Abteilungsgrenzen hinweg
// eindeutig das eine logische Projekt. Ein Projekt ohne project_nr kann daher
// keine Dateien erhalten.
//
// Sicherheitskritisch: original_filename/project_nr/project_name/filename
// (Client-Eingaben) werden NIEMALS in einen Dateisystempfad eingesetzt. Der
// Pfad setzt sich ausschließlich aus einer serverseitig erzeugten UUID
// (stored_name) zusammen; original_filename dient nur als Anzeige-/
// Download-Header-Wert.
// UPLOADS_DIR: Basisverzeichnis für hochgeladene Projektdateien
// ein Unterordner (lazy angelegt, siehe uploadDirFor()).
const UPLOADS_DIR = path.join(SESSIONS_DIR, 'uploads');
// uploadDirFor: liefert (und legt bei Bedarf an) das Upload-Verzeichnis einer
// Abteilung. Parameter department MUSS bereits gegen VALID_DEPARTMENTS
// geprüft sein (nur dann sind ausschließlich 'ELT'/'HLS'/'ARCH' möglich).
function uploadDirFor(){
if(!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
return UPLOADS_DIR;
}
// extractFileExtension: extrahiert per Whitelist-Regex eine kurze
// alphanumerische Dateiendung aus einem (nicht vertrauenswürdigen)
// Dateinamen. Kein Treffer (z. B. kein Punkt, zu lange/exotische Endung) →
// leerer String, kein Fehler.
function extractFileExtension(filename){
const m = String(filename || '').match(/\.([a-zA-Z0-9]{1,8})$/);
return m ? m[1].toLowerCase() : '';
}
// serializeFileRow: wandelt eine project_files-Zeile (ggf. mit JOIN-Feldern)
// in die öffentliche Repräsentation für GET /project-files um.
function serializeFileRow(row){
return {
id: row.id,
projectNr: row.project_nr,
projectName: row.project_name,
originalFilename: row.original_filename,
contentType: row.content_type,
sizeBytes: row.size_bytes,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
uploadedBy: row.uploadedBy,
// Uploader-ID mitgeben: Der Client kann den "Löschen"-Knopf sonst nicht
// an dieselbe Regel binden, die der Server durchsetzt (Uploader, solange
// die Datei offen ist — oder Admin).
uploadedByUserId: row.uploaded_by_user_id,
};
}
// Route: Liste aller aktiven Benutzer (ohne Passwortfelder, ohne inaktive
// Konten) — für die Prüfer-Auswahl beim Datei-Upload. Bewusst für JEDEN
// eingeloggten Benutzer zugänglich (requireAuth, kein Admin-Recht),
// da jeder Uploader Prüfer auswählen muss; getrennt von GET /admin/users
// (admin-only, zeigt zusätzlich inaktive Konten + mehr Felder).
app.get('/users', requireAuth, (req, res) => {
try {
// role != 'kiosk': das Wandtafel-Konto ist kein Teammitglied — es soll
// weder im Datei-Freigabe-Prüfer-Auswahlfeld noch in der
// abteilungsübergreifenden Besetzung zur Wahl stehen.
const rows = db.prepare(`SELECT id, username, display_name, email, person_id, department, initials FROM users WHERE active = 1 AND ${sqlOhneWandtafel()} ORDER BY display_name, username`).all();
const rolesByUser = loadUserRolesByUserId();
res.json({ ok: true, users: rows.map(r => ({
id: r.id, username: r.username, displayName: r.display_name, email: r.email,
personId: r.person_id, department: r.department, initials: r.initials,
roles: rolesByUser[r.id] || [],
})) });
} catch(err){
console.error('Benutzerliste fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Abwesenheiten ----------
// Rechte (Rollen & Rechte, Gruppe „Team“): team.abw (für mich),
// team.abwProjekte, team.abwGewerk, team.abwAlle — geprüft über
// darfAbwesenheit() für Anlegen, Ändern und Löschen gleichermaßen.
const ABWESENHEIT_ARTEN = new Set(['Urlaub', 'Krank', 'Fortbildung', 'Sonstiges']);
// abwesenheitPruefen: Eingaben prüfen. Rückgabe: Fehlertext oder ''.
function abwesenheitPruefen(start, end, type){
const iso = /^\d{4}-\d{2}-\d{2}$/;
if(!iso.test(String(start || '')) || !iso.test(String(end || ''))) return 'Start- und Enddatum erforderlich.';
if(String(end) < String(start)) return 'Das Ende liegt vor dem Beginn.';
if(!ABWESENHEIT_ARTEN.has(String(type || ''))) return 'Unbekannte Art der Abwesenheit.';
return '';
}
const ABW_KEIN_RECHT = 'Für diese Person darfst du keine Abwesenheiten eintragen.';
// GET /personen: die Personenliste wie in /load-all (mit den Abwesenheiten
// aus Sicht des Aufrufers) — zum Nachladen nach einem Live-Ereignis.
app.get('/personen', requireAuthOderKiosk, (req, res) => {
res.json({ ok: true, people: loadGlobalPeople(req.user) });
});
app.post('/users/:id/absences', requireAuth, (req, res) => {
const id = Number(req.params.id);
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
if(!user) return res.status(404).json({ ok: false, error: 'Benutzer nicht gefunden.' });
if(!darfAbwesenheit(req.user, id)) return res.status(403).json({ ok: false, error: ABW_KEIN_RECHT });
const { start, end, type, note } = req.body || {};
const fehler = abwesenheitPruefen(start, end, type);
if(fehler) return res.status(400).json({ ok: false, error: fehler });
try {
const absenceId = crypto.randomUUID();
db.prepare('INSERT INTO user_absences (user_id, id, start, end, type, note, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(id, absenceId, String(start), String(end), String(type), String(note || ''), req.user.id);
ereignisSenden({ typ: 'personen' }, req);
res.json({ ok: true, absence: { id: absenceId, start: String(start), end: String(end), type: String(type), note: String(note || '') } });
} catch(err){
console.error('Abwesenheit anlegen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
app.patch('/users/:id/absences/:absenceId', requireAuth, (req, res) => {
const id = Number(req.params.id);
if(!darfAbwesenheit(req.user, id)) return res.status(403).json({ ok: false, error: ABW_KEIN_RECHT });
const row = db.prepare('SELECT * FROM user_absences WHERE user_id = ? AND id = ?').get(id, req.params.absenceId);
if(!row) return res.status(404).json({ ok: false, error: 'Abwesenheit nicht gefunden.' });
const b = req.body || {};
const start = b.start !== undefined ? b.start : row.start;
const end = b.end !== undefined ? b.end : row.end;
const type = b.type !== undefined ? b.type : row.type;
const note = b.note !== undefined ? String(b.note) : row.note;
const fehler = abwesenheitPruefen(start, end, type);
if(fehler) return res.status(400).json({ ok: false, error: fehler });
db.prepare('UPDATE user_absences SET start = ?, end = ?, type = ?, note = ? WHERE user_id = ? AND id = ?')
.run(String(start), String(end), String(type), note, id, req.params.absenceId);
ereignisSenden({ typ: 'personen' }, req);
res.json({ ok: true, absence: { id: row.id, start: String(start), end: String(end), type: String(type), note } });
});
// Löschen: idempotent (auch ok, wenn nichts gelöscht wurde).
app.delete('/users/:id/absences/:absenceId', requireAuth, (req, res) => {
try {
const targetUserId = Number(req.params.id);
if(!darfAbwesenheit(req.user, targetUserId)) return res.status(403).json({ ok: false, error: ABW_KEIN_RECHT });
const r = db.prepare('DELETE FROM user_absences WHERE user_id = ? AND id = ?').run(targetUserId, req.params.absenceId);
if(r.changes) ereignisSenden({ typ: 'personen' }, req);
res.json({ ok: true });
} catch(err){
console.error('Abwesenheit löschen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// empfangeInDatei: schreibt den rohen Anfrage-Body als Datenstrom in ziel,
// ohne ihn im Speicher zu sammeln. Über maxBytes: Abbruch, Datei weg, 413.
// Rückgabe: Promise<Anzahl Bytes>.
function empfangeInDatei(req, ziel, maxBytes){
return new Promise((resolve, reject) => {
const aus = fs.createWriteStream(ziel);
let n = 0, abgebrochen = false;
const fehler = (err) => {
if(abgebrochen) return; abgebrochen = true;
req.unpipe(aus); aus.destroy(); fs.unlink(ziel, () => {});
reject(err);
};
req.on('data', chunk => {
n += chunk.length;
if(n > maxBytes){ const e = new Error('Datei zu groß (höchstens ' + Math.round(maxBytes / 1048576) + ' MB).'); e.status = 413; fehler(e); req.resume(); }
});
req.on('aborted', () => fehler(new Error('Upload abgebrochen.')));
aus.on('error', fehler);
aus.on('finish', () => { if(!abgebrochen) resolve(n); });
req.pipe(aus);
});
}
// Route: Datei-Upload für ein Projekt. Roher Binär-Body (wie /import), Metadaten
// als Query-Parameter, da der Body ausschließlich die Datei selbst enthält.
app.post('/project-files', requireAuth, requireRecht('proj.dateien'), async (req, res) => {
try {
const projectNr = String(req.query.projectNr || '');
if(!projectNr) return res.status(400).json({ ok: false, error: 'Projekt hat keine Projektnummer.' });
const projectName = String(req.query.projectName || '');
let filename = '';
try { filename = decodeURIComponent(String(req.query.filename || '')); }
catch(e){ filename = String(req.query.filename || ''); }
const ext = extractFileExtension(filename);
// Reviewer-Parameter: Komma-getrennte IDs -> Zahlen -> nur existierende,
// aktive Benutzer -> Uploader (kein Self-Review) entfernen.
const rawReviewerIds = String(req.query.reviewers || '')
.split(',')
.map(s => Number(s.trim()))
.filter(n => Number.isInteger(n) && n > 0);
// Kiosk-Konten können nie entscheiden (requireAuth weist sie ab) — als
// Prüfer würden sie die Datei für immer offen halten.
const activeUsers = db.prepare(`SELECT id FROM users WHERE active = 1 AND ${sqlOhneWandtafel()}`).all().map(r => r.id);
const activeSet = new Set(activeUsers);
const reviewerIds = Array.from(new Set(rawReviewerIds))
.filter(id => activeSet.has(id) && id !== req.user.id);
if(reviewerIds.length === 0){
return res.status(400).json({ ok: false, error: 'Mindestens ein Prüfer erforderlich.' });
}
const id = crypto.randomUUID();
const storedName = id + (ext ? '.' + ext : '');
const dir = uploadDirFor();
// Direkt auf die Platte statt erst ganz in den Arbeitsspeicher (Pi Zero:
// eine 200-MB-Datei im RAM brachte den Server zum Absturz).
let groesse;
try { groesse = await empfangeInDatei(req, path.join(dir, storedName), 200 * 1024 * 1024); }
catch(err){ return res.status(err.status || 400).json({ ok: false, error: err.message }); }
if(!groesse){
fs.unlink(path.join(dir, storedName), () => {});
return res.status(400).json({ ok: false, error: 'Keine Datei empfangen' });
}
const contentType = req.headers['content-type'] || 'application/octet-stream';
const now = new Date().toISOString();
db.exec('BEGIN');
try {
db.prepare(`INSERT INTO project_files
(id, project_nr, project_name, original_filename, stored_name, content_type, size_bytes, uploaded_by_user_id, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`)
.run(id, projectNr, projectName, filename || storedName, storedName, contentType, groesse, req.user.id, now, now);
const insReviewer = db.prepare(`INSERT INTO project_file_reviewers (file_id, user_id, decision, decided_at, note) VALUES (?, ?, 'pending', '', '')`);
reviewerIds.forEach(uid => insReviewer.run(id, uid));
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
// Bereits geschriebene Datei bei Fehler wieder entfernen (best effort).
fs.unlink(path.join(dir, storedName), () => {});
throw err;
}
console.log('Datei hochgeladen:', projectNr, '-', filename || storedName);
res.json({
ok: true,
file: {
id, projectNr, projectName,
originalFilename: filename || storedName,
contentType, sizeBytes: groesse, status: 'pending', createdAt: now,
uploadedBy: req.user.displayName,
}
});
} catch(err){
console.error('Datei-Upload fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Liste aller Dateien eines Projekts (project_nr), inkl. Uploader-Name,
// Prüferstatus je Prüfer und Kommentaranzahl.
app.get('/project-files', requireAuth, (req, res) => {
try {
const projectNr = String(req.query.projectNr || '');
const rows = db.prepare(`
SELECT f.*, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS uploadedBy
FROM project_files f
LEFT JOIN users u ON u.id = f.uploaded_by_user_id
WHERE f.project_nr = ?
ORDER BY f.created_at DESC
`).all(projectNr);
const reviewerStmt = db.prepare(`
SELECT r.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS displayName, r.decision AS decision
FROM project_file_reviewers r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.file_id = ?
`);
const commentCountStmt = db.prepare('SELECT COUNT(*) AS c FROM project_file_comments WHERE file_id = ?');
const files = rows.map(row => {
const out = serializeFileRow(row);
out.reviewers = reviewerStmt.all(row.id);
out.commentCount = commentCountStmt.get(row.id).c;
return out;
});
res.json({ ok: true, files });
} catch(err){
console.error('Dateiliste fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Datei-Download. Der Dateisystempfad besteht ausschließlich aus der
// (server-seitig erzeugten) stored_name-UUID — original_filename fließt NUR
// in den Content-Disposition-Header ein (zweites res.download()-Argument),
// niemals in den Pfad.
app.get('/project-files/:id/download', requireAuth, (req, res) => {
try {
const row = db.prepare('SELECT * FROM project_files WHERE id = ?').get(req.params.id);
if(!row) return res.status(404).json({ ok: false, error: 'Datei nicht gefunden.' });
const filePath = path.join(UPLOADS_DIR, row.stored_name);
res.download(filePath, row.original_filename, (err) => {
if(err && !res.headersSent){
console.error('Datei-Download fehlgeschlagen:', err);
res.status(404).json({ ok: false, error: 'Datei nicht gefunden.' });
}
});
} catch(err){
console.error('Datei-Download fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Freigabe-Entscheidung eines Prüfers. Nur möglich, solange der
// Gesamtstatus der Datei noch nicht terminal (approved/rejected) ist, und nur
// für Benutzer, die tatsächlich als Prüfer für diese Datei eingetragen sind.
// Neuberechnung des Gesamtstatus: eine Ablehnung blockiert sofort; erst wenn
// ALLE Prüfer zugestimmt haben, gilt die Datei als freigegeben.
app.post('/project-files/:id/review', requireAuth, requireRecht('proj.dateien'), (req, res) => {
try {
const fileRow = db.prepare('SELECT * FROM project_files WHERE id = ?').get(req.params.id);
if(!fileRow) return res.status(404).json({ ok: false, error: 'Datei nicht gefunden.' });
const { decision, note } = req.body || {};
if(decision !== 'approved' && decision !== 'rejected'){
return res.status(400).json({ ok: false, error: 'Ungültige Entscheidung.' });
}
const reviewerRow = db.prepare('SELECT * FROM project_file_reviewers WHERE file_id = ? AND user_id = ?').get(fileRow.id, req.user.id);
if(!reviewerRow) return res.status(403).json({ ok: false, error: 'Sie sind für diese Datei nicht als Prüfer eingetragen.' });
if(fileRow.status === 'approved' || fileRow.status === 'rejected'){
return res.status(409).json({ ok: false, error: 'Über diese Datei wurde bereits abschließend entschieden.' });
}
const now = new Date().toISOString();
db.prepare('UPDATE project_file_reviewers SET decision = ?, decided_at = ?, note = ? WHERE file_id = ? AND user_id = ?')
.run(decision, now, String(note || ''), fileRow.id, req.user.id);
const allDecisions = db.prepare('SELECT decision FROM project_file_reviewers WHERE file_id = ?').all(fileRow.id).map(r => r.decision);
let newStatus = 'pending';
if(allDecisions.some(d => d === 'rejected')) newStatus = 'rejected';
else if(allDecisions.length > 0 && allDecisions.every(d => d === 'approved')) newStatus = 'approved';
db.prepare('UPDATE project_files SET status = ?, updated_at = ? WHERE id = ?').run(newStatus, now, fileRow.id);
console.log('Datei-Freigabe:', fileRow.id, '-', req.user.username, '->', decision, '(Gesamtstatus:', newStatus + ')');
res.json({ ok: true, status: newStatus });
} catch(err){
console.error('Freigabe-Entscheidung fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Kommentare einer Datei lesen (chronologisch aufsteigend).
app.get('/project-files/:id/comments', requireAuth, (req, res) => {
try {
const rows = db.prepare(`
SELECT c.id AS id, c.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS displayName,
c.body AS body, c.created_at AS createdAt
FROM project_file_comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.file_id = ?
ORDER BY c.created_at ASC
`).all(req.params.id);
res.json({ ok: true, comments: rows });
} catch(err){
console.error('Kommentare laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Kommentar zu einer Datei hinzufügen. Jeder eingeloggte Benutzer darf
// kommentieren, nicht nur die eingetragenen Prüfer.
app.post('/project-files/:id/comments', requireAuth, requireRecht('proj.dateien'), (req, res) => {
try {
const fileRow = db.prepare('SELECT id FROM project_files WHERE id = ?').get(req.params.id);
if(!fileRow) return res.status(404).json({ ok: false, error: 'Datei nicht gefunden.' });
const body = String((req.body || {}).body || '').trim();
if(!body) return res.status(400).json({ ok: false, error: 'Kommentartext erforderlich.' });
const id = crypto.randomUUID();
const now = new Date().toISOString();
db.prepare('INSERT INTO project_file_comments (id, file_id, user_id, body, created_at) VALUES (?, ?, ?, ?, ?)')
.run(id, fileRow.id, req.user.id, body, now);
res.json({ ok: true, comment: { id, userId: req.user.id, displayName: req.user.displayName, body, createdAt: now } });
} catch(err){
console.error('Kommentar anlegen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Datei löschen. Erlaubt für den Uploader, solange die Datei noch
// 'pending' ist, oder jederzeit für Admins. Löscht zuerst die DB-Zeile
// (inkl. abhängiger Prüfer-/Kommentar-Zeilen — explizit statt sich auf eine
// FK-CASCADE zu verlassen, da project_file_reviewers/project_file_comments
// keine deklarierten FOREIGN KEY-Constraints auf project_files haben), dann
// best-effort die physische Datei.
app.delete('/project-files/:id', requireAuth, (req, res) => {
try {
const row = db.prepare('SELECT * FROM project_files WHERE id = ?').get(req.params.id);
if(!row) return res.status(404).json({ ok: false, error: 'Datei nicht gefunden.' });
const isOwnerAndPending = (req.user.id === row.uploaded_by_user_id) && row.status === 'pending';
const isAdmin = hatRecht(req.user, 'proj.dateienFremd');
if(!isOwnerAndPending && !isAdmin){
return res.status(403).json({ ok: false, error: 'Keine Berechtigung, diese Datei zu löschen.' });
}
db.exec('BEGIN');
try {
db.prepare('DELETE FROM project_file_comments WHERE file_id = ?').run(row.id);
db.prepare('DELETE FROM project_file_reviewers WHERE file_id = ?').run(row.id);
db.prepare('DELETE FROM project_files WHERE id = ?').run(row.id);
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
throw err;
}
fs.unlink(path.join(UPLOADS_DIR, row.stored_name), (err) => {
if(err) console.error('Physische Datei konnte nicht gelöscht werden:', err);
});
console.log('Datei gelöscht:', row.id, '-', row.original_filename);
res.json({ ok: true });
} catch(err){
console.error('Datei löschen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Protokolle (Besprechungsprotokolle je Projekt) ----------
//
// Verknüpft über project_nr (siehe Tabellendefinition oben), nicht über
// week_label/project_id. Speichermuster: eigene Routen, aber "ganzes Objekt
// ersetzen" wie saveWeekRelational()/deleteWeekData() (Zeilen löschen, neu
// einfügen, in einer Transaktion) statt vieler kleiner Einzel-Feld-Routen —
// Punkte und Teilnehmer werden ohnehin gemeinsam in einer Sitzung bearbeitet.
// loadProtokollDetail: baut ein volles Protokoll (Kopfdaten + Teilnehmer +
// Punkte) für die Antwort von GET/POST/PUT zusammen. Parameter: id.
// Rückgabe: Objekt oder null, wenn nicht gefunden.
function loadProtokollDetail(id){
const row = db.prepare(`SELECT p.*, u.display_name AS verfasser_name
FROM project_protokolle p LEFT JOIN users u ON u.id = p.verfasser_user_id
WHERE p.id = ?`).get(id);
if(!row) return null;
const teilnehmer = db.prepare('SELECT * FROM protokoll_teilnehmer WHERE protokoll_id = ? ORDER BY array_order').all(row.id)
.map(t => ({ id: t.id, name: t.name, firma: t.firma, anwesend: !!t.anwesend }));
// vorherigeWerte: Thema/Text des UNMITTELBAR vorangegangenen Protokolls
// (chronologisch davor, per nummer), je stamm_id — Grundlage für
// themaGeaendert/textGeaendert unten (Eriks Wunsch): ein fortgeschriebener
// Punkt (neu=0), dessen Thema oder Text sich gegenüber der letzten Fassung
// geändert hat, soll das erkennbar machen, aber nur AN DEM GEÄNDERTEN FELD
// (gelb), nicht an der ganzen Zeile wie bei einem echten neuen Punkt
// (.is-neu) — ein neuer Punkt hat ohnehin keine Vorfassung zum Vergleichen.
const prevRow = db.prepare(`SELECT id FROM project_protokolle
WHERE project_nr = ? AND nummer < ? ORDER BY nummer DESC LIMIT 1`).get(row.project_nr, row.nummer);
const vorherigeWerte = new Map();
if(prevRow){
db.prepare('SELECT stamm_id, thema, text FROM protokoll_punkte WHERE protokoll_id = ?').all(prevRow.id)
.forEach(p => vorherigeWerte.set(p.stamm_id, { thema: p.thema, text: p.text }));
}
const punkte = db.prepare('SELECT * FROM protokoll_punkte WHERE protokoll_id = ? ORDER BY array_order').all(row.id)
.map(p => {
const vorher = !p.neu ? vorherigeWerte.get(p.stamm_id) : null;
return { id: p.id, nummer: p.nummer, gruppe: p.gruppe, neu: !!p.neu, thema: p.thema, text: p.text,
zustaendig: p.zustaendig, termin: p.termin, status: p.status || 'offen', erstelltAm: p.erstellt_am || '',
entfaelltNaechstes: !!p.entfaellt_naechstes,
themaGeaendert: !!(vorher && vorher.thema !== p.thema),
textGeaendert: !!(vorher && vorher.text !== p.text) };
});
return {
id: row.id, projectNr: row.project_nr, projectName: row.project_name, nummer: row.nummer,
datum: row.datum, betreff: row.betreff, ort: row.ort, verfasserName: row.verfasser_name || '',
weekLabel: row.week_label, quelleDateiname: row.quelle_dateiname || '',
createdAt: row.created_at, updatedAt: row.updated_at,
teilnehmer, punkte
};
}
// Route: Liste aller Protokolle eines Projekts (Zusammenfassung je Protokoll
// für die Listenansicht — offene/erledigte Punkte, Teilnehmerzahl —, nicht
// die vollen Punkte/Teilnehmer, das liefert erst GET /:id).
app.get('/project-protokolle', requireAuth, (req, res) => {
try {
const projectNr = String(req.query.projectNr || '');
if(!projectNr) return res.status(400).json({ ok: false, error: 'Projekt hat keine Projektnummer.' });
const rows = db.prepare(`SELECT p.*, u.display_name AS verfasser_name
FROM project_protokolle p LEFT JOIN users u ON u.id = p.verfasser_user_id
WHERE p.project_nr = ? ORDER BY p.nummer DESC`).all(projectNr);
const protokolle = rows.map(row => {
const punkte = db.prepare('SELECT status FROM protokoll_punkte WHERE protokoll_id = ?').all(row.id);
const teilnehmerCount = db.prepare('SELECT COUNT(*) AS c FROM protokoll_teilnehmer WHERE protokoll_id = ?').get(row.id).c;
const offenCount = punkte.filter(p => p.status === 'offen').length;
return {
id: row.id, nummer: row.nummer, datum: row.datum, betreff: row.betreff, ort: row.ort,
verfasserName: row.verfasser_name || '', createdAt: row.created_at,
// Info-Punkte sind weder offen noch erledigt — eigene Zahl statt
// sie bei "erledigt" mitzuzählen.
offenCount, erledigtCount: punkte.filter(p => p.status === 'erledigt').length,
infoCount: punkte.filter(p => p.status === 'info').length, teilnehmerCount
};
});
res.json({ ok: true, protokolle });
} catch(err){
console.error('Protokolle laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
app.get('/project-protokolle/:id', requireAuth, (req, res) => {
try {
const protokoll = loadProtokollDetail(req.params.id);
if(!protokoll) return res.status(404).json({ ok: false, error: 'Protokoll nicht gefunden.' });
res.json({ ok: true, protokoll });
} catch(err){
console.error('Protokoll laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: neues Protokoll anlegen. nummer wird serverseitig vergeben
// (MAX(nummer)+1 je project_nr, sonst 1); Teilnehmer kommen vorbelegt vom
// Client (siehe js/plan/plan-protokoll.js); offene Punkte des zuletzt
// angelegten Protokolls desselben Projekts werden KOPIERT (neue id, gleiche
// stamm_id/nummer, neu=0) — das "Fortschreiben". Im allerersten Protokoll
// eines Projekts gibt es nichts zu kopieren, dort sind zwangsläufig alle
// später hinzugefügten Punkte neu.
// legeProtokollAn: die gemeinsame Anlege-Logik für ein neues Protokoll —
// genutzt sowohl von POST /project-protokolle (manuelles Anlegen) als auch
// von POST /project-protokolle/import (Datei-Import, siehe unten). Beide
// Wege sollen ein Protokoll auf exakt dieselbe Art in die Sitzungsfolge
// einreihen: nummer fortlaufend je project_nr, offene Punkte des zuletzt
// angelegten Protokolls desselben Projekts kopiert (das "Fortschreiben",
// siehe CLAUDE.md "### Protokolle"). Der Import hängt danach zusätzlich
// erkannte Punkte an (`zusatzPunkte`) — beim manuellen Anlegen ist dieses
// Array einfach leer.
// Parameter: { projectNr, projectName, weekLabel, datum, betreff, ort,
// verfasserUserId, teilnehmer, zusatzPunkte, quelleDateiname,
// quelleStoredName } — die letzten beiden bleiben leer für ein normal
// angelegtes Protokoll. Läuft in einer eigenen Transaktion. Rückgabe: die
// neue Protokoll-`id`. Wirft bei einem Fehler (Aufrufer fangen und in eine
// HTTP-Antwort übersetzen).
// VALID_PROTOKOLL_PUNKT_STATUS: der Status eines Punkts — 'offen' (läuft
// weiter, wird fortgeschrieben), 'erledigt' (abgeschlossen) oder 'info'
// (rein informativ, keine eigene Handlung nötig — z. B. eine bereits
// zur Kenntnis genommene Mitteilung). Nur 'offen' wird beim Anlegen des
// nächsten Protokolls fortgeschrieben (legeProtokollAn()); sowohl
// 'erledigt' als auch 'info' laufen nicht weiter mit, aus demselben Grund:
// beides ist nichts mehr, worauf jemand noch reagieren muss.
const VALID_PROTOKOLL_PUNKT_STATUS = new Set(['offen', 'erledigt', 'info']);
// PROTOKOLL_PUNKT_MARKER: erkennt einen Zeilenanfang wie "1.1", "TOP 3",
// "Punkt 2.4" oder "1." als Beginn eines neuen Punkts; der Rest der Zeile
// wird dessen Thema. Ein Regex-Satz für mehrere gängige Schreibweisen, kein
// Anspruch auf Vollständigkeit — siehe erkennePunkteAusText().
const PROTOKOLL_PUNKT_MARKER = /^\s*(?:top|punkt)?\s*(\d+(?:\.\d+)*)[.\):]?\s+(.+)$/i;
// PROTOKOLL_LABEL_MUSTER: Label-Muster, die INNERHALB eines erkannten
// Punkttexts nach Zuständig/Termin suchen — nur bei eindeutigem Treffer
// übernommen (siehe erkennePunkteAusText()).
// Nicht-gierig bis zum nächsten Satzende, Zeilenende ODER dem nächsten
// bekannten Label — sonst reißt z. B. "Zuständig: Firma Muster. Termin: …"
// (beides auf einer Zeile, ohne Komma dazwischen) den Termin-Teil mit in den
// Zuständig-Treffer hinein. Ein Punkt gilt nur als Satzende, wenn ihm keine
// Ziffer folgt — sonst würde ein Datum wie "15.10.2026" beim ersten Punkt
// abgeschnitten.
const PROTOKOLL_LABEL_STOP = '(?=[,;]|\\.(?!\\d)|$|\\s*(?:zust(?:ä|ae|a)ndig|verantwortlich|termin|frist)\\s*:)';
const PROTOKOLL_LABEL_MUSTER = [
{ feld: 'zustaendig', regex: new RegExp('(?:zust(?:ä|ae|a)ndig|verantwortlich)\\s*:\\s*([^,;]+?)' + PROTOKOLL_LABEL_STOP, 'i') },
{ feld: 'termin', regex: new RegExp('(?:termin|frist)\\s*:\\s*([^,;]+?)' + PROTOKOLL_LABEL_STOP, 'i') }
];
// erkennePunkteAusText: reines String-Processing ohne I/O (kein Netzwerk,
// keine KI) — teilt einen aus PDF/Word extrahierten Freitext heuristisch in
// Protokoll-Punkte auf. Siehe CLAUDE.md "### Protokolle" für die Begründung
// jeder Regel. Erkennt PROTOKOLL_PUNKT_MARKER-Zeilen als neue Punkte, alles
// dazwischen wird deren Text; findet sich darin ein PROTOKOLL_LABEL_MUSTER,
// wird der Wert herausgelöst statt im Fließtext zu bleiben. `gruppe` wird
// bewusst NIE geraten — jeder erkannte Punkt landet in "Ohne Gruppe", der
// Nutzer ordnet in der ohnehin vorhandenen Detailansicht selbst zu. Findet
// sich im ganzen Text kein einziger Marker (keine erkennbare Nummerierung),
// greift ein Fallback: jeder nicht-leere Absatz (durch Leerzeile getrennt)
// wird ein eigener Punkt — liefert immer ein Grundgerüst statt eines leeren
// Protokolls. `nummer` hier ist rein informationell (die im Quelldokument
// gefundene Nummer) — angezeigt wird sie ohnehin nie aus der Datenbank,
// sondern immer frisch aus Gruppe+Reihenfolge errechnet
// (berechneProtokollNummern(), public/js/plan/plan-protokoll.js).
// Parameter: text (String). Rückgabe: Array von
// { nummer, gruppe, thema, text, zustaendig, termin, erledigt }.
// normalisiereTermin: bringt ein erkanntes Datum auf ISO (JJJJ-MM-TT), das
// Format, das Protokoll-Ansicht, PDF (parseDateISO()) und <input type=date>
// erwarten. Protokolle schreiben "15.10.2026" oder "5.1.26" — das wurde
// bisher unverändert gespeichert und erschien dann überall als "kein
// Termin"; bei "TODOs aus Protokoll" ging die Fälligkeit still verloren.
// Was kein erkennbares Datum ist ("KW 45", "laufend"), bleibt wie es ist.
// Parameter: wert. Rückgabe: String.
function normalisiereTermin(wert){
const s = String(wert || '').trim();
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/);
if(!m) return s;
const jahr = m[3].length === 2 ? '20' + m[3] : m[3];
const monat = Number(m[2]), tag = Number(m[1]);
if(monat < 1 || monat > 12 || tag < 1 || tag > 31) return s;
return jahr + '-' + String(monat).padStart(2, '0') + '-' + String(tag).padStart(2, '0');
}
function erkennePunkteAusText(text){
const zeilen = String(text || '').split(/\r?\n/).map(z => z.trim());
const treffer = [];
let aktuell = null;
zeilen.forEach(zeile => {
if(!zeile) return;
const m = zeile.match(PROTOKOLL_PUNKT_MARKER);
if(m){
if(aktuell) treffer.push(aktuell);
aktuell = { nummer: m[1], thema: m[2].trim(), textZeilen: [], zustaendig: '', termin: '', erledigt: false };
} else if(aktuell){
aktuell.textZeilen.push(zeile);
}
});
if(aktuell) treffer.push(aktuell);
if(treffer.length === 0){
return String(text || '').split(/\r?\n\s*\r?\n/)
.map(a => a.replace(/\s+/g, ' ').trim())
.filter(Boolean)
.map(a => ({
nummer: '', gruppe: '', thema: a.slice(0, 60), text: a.length > 60 ? a : '',
zustaendig: '', termin: '', erledigt: false
}));
}
return treffer.map(t => {
// Labels wie "Zuständig:"/"Termin:" stehen bei kurzen Punkten oft in
// derselben Zeile wie das Thema (ein Word-Absatz je Punkt), nicht in
// einer eigenen Folgezeile — deshalb wird über Thema UND die
// nachfolgenden Zeilen gemeinsam gesucht, nicht nur über Letztere.
let thema = t.thema;
let restText = t.textZeilen.join(' ').replace(/\s+/g, ' ').trim();
PROTOKOLL_LABEL_MUSTER.forEach(({ feld, regex }) => {
let lm = thema.match(regex);
if(lm){
t[feld] = lm[1].trim();
thema = thema.replace(lm[0], '').trim();
return;
}
lm = restText.match(regex);
if(lm){
t[feld] = lm[1].trim();
restText = restText.replace(lm[0], '').trim();
}
});
// Entfernen eines Labels aus der Mitte des Themas kann einen
// verwaisten Satzpunkt zurücklassen ("…abzuschließen. ." statt
// "…abzuschließen.") — am Ende auf einen einzigen Punkt einkürzen.
thema = thema.replace(/[.\s]+$/, m => (m.indexOf('.') !== -1 ? '.' : '')).trim();
const erledigt = /\berledigt\b|\bgeschlossen\b/i.test(restText) || /\berledigt\b|\bgeschlossen\b/i.test(thema);
// status zusätzlich zu erledigt: Der Client (TODOs aus Protokoll) wählt
// offene Punkte über status vor — ohne das Feld war nichts vorausgewählt.
return { nummer: t.nummer, gruppe: '', thema, text: restText,
zustaendig: t.zustaendig, termin: normalisiereTermin(t.termin), erledigt,
status: erledigt ? 'erledigt' : 'offen' };
});
}
// ---------- KI-gestützte Erkennung (ChatGPT/Claude) für den Protokoll-Import ----------
//
// Alternative zu erkennePunkteAusText() oben: statt starrer Regex-Muster
// liest ein Sprachmodell den Text und liefert dieselbe Punkt-Form zurück.
// Bewusst zusätzlich zur Heuristik, nicht statt ihr — siehe CLAUDE.md
// "### Protokolle": ohne hinterlegten Schlüssel oder bei jedem Fehler
// (Netzwerk, falscher Schlüssel, Zeitüberschreitung, unerwartete Antwort)
// fällt der Aufrufer (POST /project-protokolle/import) automatisch auf
// erkennePunkteAusText() zurück — der Import funktioniert immer, auch ohne
// Internetzugang des Servers. Beide Anbieter laufen über das in Node bereits
// global verfügbare fetch() — keine neue npm-Abhängigkeit.
const KI_MAX_TEXT_LAENGE = 40000; // ~10k Tokens; deckelt Kosten/Dauer bei
// einer versehentlich riesigen Datei, reicht für jedes normale Protokoll.
const KI_TIMEOUT_MS_CLOUD = 45000;
// Ein lokal laufender Server (LM Studio, Ollama) rechnet auf der eigenen
// Maschine, oft ohne oder mit begrenzter GPU-Beschleunigung — eine einzelne
// Antwort kann mehrere Minuten dauern, weit jenseits dessen, was bei einer
// Cloud-API schon ein Hänger wäre. Mit dem kurzen, einheitlichen Timeout
// brach die Anfrage bei Erik vorzeitig ab ("This operation was aborted"),
// obwohl LM Studio nachweislich weiterrechnete und fertig geworden wäre
// ("läuft aber durch"). Ein kurzer, einheitlicher Timeout hätte lokale
// Modelle damit praktisch unbenutzbar gemacht. Ursprünglich 300000 (5 min),
// dann 900000 (15 min) — beide brachen bei Erik immer noch ab, vor allem
// seit der Prompt jetzt für jeden erkannten Punkt echten Fließtext erzeugt
// (also deutlich mehr Tokens generiert als die frühere, kaputte Fassung,
// die oft fast leer blieb): bei ~12.000 generierten Tokens auf seiner
// Hardware lief LM Studio selbst nachweislich sauber bis zum Ende durch
// (vom eigenen "Fertig"-Zustand im LM-Studio-Fenster bestätigt), nur die
// eigene AbortController-Schranke hier griff vorher. Auf 30 Minuten
// angehoben, mit entsprechendem Headroom bei LOKAL_DISPATCHER (unten).
// Der Import zeigt währenddessen den Ladehinweis im Dialog
// (#protokollImportLoading, plan-protokoll.js), das lange Warten ist also
// sichtbar, kein scheinbares Hängenbleiben.
const KI_TIMEOUT_MS_LOKAL = 1800000;
// KI_TIMEOUT_MS_CLOUD_FREMD: ein abweichender, aber weiterhin REMOTE
// liegender Endpunkt (z. B. Mammouth.ai, OpenRouter, ein Firmen-Proxy) ist
// weder "die echte, bekanntermaßen schnelle OpenAI/Anthropic-API" (dort
// gilt zurecht der scharfe KI_TIMEOUT_MS_CLOUD) noch "lokale CPU-Inferenz,
// die Minuten dauern darf" (KI_TIMEOUT_MS_LOKAL) — ein eigener,
// mittlerer Wert. Gefunden bei einem echten Fehlschlag: "TODOs aus
// Protokoll" brach bei Erik zuverlässig mit "This operation was aborted"
// ab, obwohl der Protokoll-Import grundsätzlich funktionierte — derselbe
// Konto-Schlüssel zeigte auf https://api.mammouth.ai/v1 (Modell
// "gpt-6-luna"), kein Loopback, also griff bislang der scharfe
// KI_TIMEOUT_MS_CLOUD (45s). Direkt gegen den echten Schlüssel nachgestellt
// (derselbe Aufruf, derselbe Prompt, dasselbe Dokument wie im Fehlerfall):
// ein einzelnes triviales Prompt beantwortete der Anbieter in 1,3s, die
// tatsächliche Punkte-Erkennung eines realen Protokolls (29.594 Zeichen)
// brauchte dagegen 39,2s — schon im Erfolgsfall nur 6s unter dem alten
// Limit, jede normale Schwankung (Tageszeit, Dokumentlänge, Anbieterlast)
// reichte seither zuverlässig zum Abbruch. Ein Drittanbieter-Proxy ist
// grundsätzlich langsamer und weniger vorhersagbar als die Original-API,
// aber immer noch ein verwalteter Cloud-Dienst, keine CPU-Inferenz auf
// fremder Hardware — 2 Minuten geben das Dreifache der gemessenen Dauer
// als Spielraum, ohne in die Größenordnung von KI_TIMEOUT_MS_LOKAL zu gehen.
const KI_TIMEOUT_MS_CLOUD_FREMD = 120000;
// LOKAL_DISPATCHER: Node's globales fetch() läuft intern über undici, dessen
// eigener Standard-Agent Verbindungen nach headersTimeout/bodyTimeout = 5
// Minuten von sich aus kappt — UNABHÄNGIG vom eigenen AbortController unten.
// Solange KI_TIMEOUT_MS_LOKAL ebenfalls bei 5 Minuten lag, fiel das nicht
// auf: beide Zeitgeber liefen praktisch gleichzeitig ab, und man sah immer
// den eigenen, sauberen "This operation was aborted"-Abbruch. Nach dem
// Anheben auf 15 Minuten griff undicis eigenes 5-Minuten-Limit als Erstes —
// bei Erik reproduzierbar als "fetch failed", obwohl LM Studio selbst
// nachweislich sauber durchlief. Ein eigener Agent mit angehobenem Limit
// ist die einzige Möglichkeit, das zu umgehen: fetch() selbst bietet dafür
// keine Option, nur das (nicht standardisierte, aber von Node übernommene)
// `dispatcher`-Feld in den Request-Optionen. Nur für lokale Ziele
// verwendet — eine Cloud-API bleibt beim kurzen KI_TIMEOUT_MS_CLOUD (45s),
// weit unter undicis Vorgabewert, dort besteht das Problem gar nicht.
const LOKAL_DISPATCHER = new UndiciAgent({
headersTimeout: KI_TIMEOUT_MS_LOKAL + 10000,
bodyTimeout: KI_TIMEOUT_MS_LOKAL + 10000
});
// KI_SYSTEM_PROMPT wurde NICHT nach Stil-/Rechtschreibkonvention geglättet,
// sondern bewusst wortgleich zu der Fassung übernommen, die gegen ein
// echtes, mehrstufig nummeriertes Besprechungsprotokoll (PromanX/Phenix-
// Format: "01" Kapitel, "01.0" Unterkapitel, "01.0 01.03" der eigentliche
// Punkt) tatsächlich funktioniert hat — gegen Eriks lokales LM-Studio-Modell
// erwies sich das Ergebnis als erstaunlich instabil gegenüber minimalen
// Formulierungsänderungen: dieselbe Aufgabe lieferte je nach Umformulierung
// zwischen 0 und 11 erkannten Punkten, mal mit vollem Text, mal mit
// durchgehend LEEREM "text" (reproduzierbar bei temperature:0, also keine
// bloße Zufallsstreuung). Ursache war u. a. eine vorangestellte, allgemein
// gehaltene "erfinde nichts, lass im Zweifel leer"-Anweisung, die das
// Modell offenbar dazu brachte, aus Vorsicht auch "text" leer zu lassen,
// obwohl der Inhalt eindeutig im Dokument stand — das genaue Gegenteil des
// gemeldeten Fehlers ("kopiert die Texte nicht mit, erstellt nur die
// Überschriften"). Eine zweite, von Erik angestoßene Runde ergänzte GENAU
// EINE weitere, chirurgisch kurze Anweisung — mehrere eigenständige Punkte
// unter derselben Überschrift einzeln auszugeben, UND mehrere aufeinander-
// folgende, je datierte Textabsätze desselben Punkts (eine im Quelldokument
// selbst geführte Revisionshistorie über mehrere Besprechungen) zu einem
// zusammenhängenden "text" zusammenzuführen, statt sie als eigene, von
// ihrem Thema abgetrennte Punkte auszugeben — und bestand denselben
// Stabilitätstest (reales Dokument, temperature:0): korrekt erkannte
// Punkte mit vollständig zusammengeführtem Text statt verwaister, themenloser
// Fragmente. Jede WEITERE Ergänzung darüber hinaus (z. B. eine explizite
// Teilnehmer-Anleitung) hat in denselben Tests dagegen zuverlässig wieder
// zur Regression geführt — deshalb bleibt "teilnehmer" bewusst ungeleitet,
// das Schema allein genügt dafür. Ein eigener Blindgang dabei: gegen einen
// künstlich verkürzten Testausschnitt OHNE die echte Teilnehmer-/Verteiler-
// Tabelle des Dokuments geriet das Modell beim Versuch, "teilnehmer" trotzdem
// zu befüllen, in eine Wiederholungsschleife (tausendfach dasselbe Namens-
// Fragment, bis das Token-Limit die Antwort mitten im JSON abschnitt) — mit
// der echten Tabelle (wie jeder reale Import sie tatsächlich schickt) trat
// das nicht wieder auf, lief sogar spürbar schneller durch. frequency_penalty/
// presence_penalty unten bleiben trotzdem als zusätzliches Sicherheitsnetz
// gegen genau solche Wiederholungsschleifen bestehen.
const KI_SYSTEM_PROMPT = 'Extrahiere ALLE Besprechungspunkte aus diesem Protokoll-Text, auch wenn es viele sind. '
+ 'Das Dokument ist mehrstufig nummeriert (z.B. "01" Kapitel, "01.0" Unterkapitel, "01.0 01.03" der echte Punkt mit Text). '
+ 'Nur Zeilen mit eigenem Fließtext darunter werden ein Punkt - reine Kapitelüberschriften ohne Text NICHT. '
+ 'Stehen unter einer Überschrift mehrere eigene Punkte, gib jeden einzeln aus. Folgen auf einen Punkt weitere '
+ 'Absätze ohne neue eigene Nummer, gehören sie zu diesem Punkt dazu - auch wenn ein spaeterer Absatz einen '
+ 'anderen Status zeigt, bleibt es EIN Punkt mit dem Status des letzten Absatzes. '
+ '"status" ist offen, erledigt oder info - info wenn der Text den Punkt selbst so kennzeichnet (z.B. das Wort '
+ '"Info" direkt beim Punkt) oder der Punkt rein informativ ohne eigene Handlung ist, erledigt nur bei '
+ 'ausdruecklicher Erledigt-/Geschlossen-Kennzeichnung, sonst offen. '
+ 'Gib fuer jeden Punkt nummer, thema, text, zustaendig, termin, status, gruppe (leer lassen wenn unklar) zurueck. Erfinde nichts.';
// readUserAiKey/writeUserAiKey: Schlüssel+URL liegen PERSÖNLICH je Konto in
// user_ai_keys (siehe CREATE TABLE weiter oben bei den übrigen User-Kind-
// Tabellen), nicht firmenweit in app_settings — jeder Nutzer verwaltet
// seinen eigenen Zugang unter Einstellungen → Mein Konto
// (js/settings/settings.js), genau wie E-Mail und Passwort. Klartext in der
// SQLite-Datei, wie jeder andere Wert dort auch.
function readUserAiKey(userId, provider){
const row = db.prepare('SELECT api_key, api_url, model FROM user_ai_keys WHERE user_id = ? AND provider = ?').get(userId, provider);
return { apiKey: row ? row.api_key : '', apiUrl: row ? row.api_url : '', model: row ? row.model : '' };
}
function writeUserAiKey(userId, provider, patch){
const bestehend = readUserAiKey(userId, provider);
const apiKey = Object.prototype.hasOwnProperty.call(patch, 'apiKey') ? String(patch.apiKey || '') : bestehend.apiKey;
const apiUrl = Object.prototype.hasOwnProperty.call(patch, 'apiUrl') ? String(patch.apiUrl || '') : bestehend.apiUrl;
// model: leer = die im jeweiligen Aufrufer hartcodierte Vorgabe gilt
// (gpt-4o-mini/gpt-4o bzw. claude-haiku/claude-sonnet-5) - nur bei einem
// eigenständigen Anbieter mit abweichendem Modell-Katalog (z. B.
// Mammouth.ai) muss das hier überschrieben werden.
const model = Object.prototype.hasOwnProperty.call(patch, 'model') ? String(patch.model || '') : bestehend.model;
db.prepare(`INSERT INTO user_ai_keys (user_id, provider, api_key, api_url, model) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, provider) DO UPDATE SET api_key = excluded.api_key, api_url = excluded.api_url, model = excluded.model`)
.run(userId, provider, apiKey, apiUrl, model);
}
// KI_DEFAULT_URL: vor istLoopbackHost()/fetchMitTimeout() platziert (stand
// ursprünglich weiter unten bei normalisiereAiUrl()), weil fetchMitTimeout()
// unten die beiden Adressen jetzt selbst braucht, um einen ECHTEN
// Anbieter-Endpunkt von einem abweichenden (lokal ODER ein fremder
// Cloud-Proxy) zu unterscheiden — siehe istStandardCloudHost() und den
// Fund bei KI_TIMEOUT_MS_CLOUD_FREMD weiter unten.
const KI_DEFAULT_URL = {
chatgpt: 'https://api.openai.com/v1/chat/completions',
claude: 'https://api.anthropic.com/v1/messages'
};
// istLoopbackHost: gemeinsame Grundlage für istGueltigeAiUrl() (URL-Prüfung
// beim Speichern) und fetchMitTimeout() (Timeout-Wahl beim Aufruf) — beide
// müssen dieselbe Vorstellung von "läuft auf der eigenen Maschine" teilen.
function istLoopbackHost(hostname){
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
}
// istStandardCloudHost: true nur für die beiden ECHTEN Anbieter-Hosts aus
// KI_DEFAULT_URL (api.openai.com/api.anthropic.com) — die einzigen, bei
// denen "eine Antwort dauert ungewöhnlich lange" zuverlässig "hier ist
// etwas kaputt" bedeutet. Grundlage für fetchMitTimeout()s dritte Stufe
// (KI_TIMEOUT_MS_CLOUD_FREMD) direkt darunter.
function istStandardCloudHost(hostname){
return hostname === new URL(KI_DEFAULT_URL.chatgpt).hostname || hostname === new URL(KI_DEFAULT_URL.claude).hostname;
}
// istGueltigeAiUrl: https wird grundsätzlich verlangt (der Schlüssel reist
// sonst im Klartext über ein Netz, das nicht die eigene Maschine ist) — mit
// EINER Ausnahme: http auf dem Loopback. Ein lokal laufender Server wie
// LM Studio oder Ollama bietet praktisch nie ein TLS-Zertifikat für
// localhost an; ihn auszuschließen hätte den naheliegendsten Anwendungsfall
// einer eigenen API-URL — ein kostenloses, lokales Modell statt eines
// kostenpflichtigen Cloud-Zugangs — unmöglich gemacht. Der Klartext
// verlässt dabei nie die eigene Maschine, das Risiko besteht also nicht.
// Rückgabe: bool.
function istGueltigeAiUrl(url){
let parsed;
try { parsed = new URL(url); } catch(e){ return false; }
if(parsed.protocol === 'https:') return true;
return parsed.protocol === 'http:' && istLoopbackHost(parsed.hostname);
}
async function fetchMitTimeout(url, options){
// Drei Stufen statt zwei: echter Anbieter-Host (knapp, 45s) < abweichender
// Remote-Host wie ein Drittanbieter-Proxy (mittel, 2min, s.
// KI_TIMEOUT_MS_CLOUD_FREMD) < Loopback (großzügig, 30min) — siehe dortigen
// Kommentar für den konkreten Fehlschlag, der die mittlere Stufe nötig machte.
let istLokal = false;
let istStandard = true;
try {
const hostname = new URL(url).hostname;
istLokal = istLoopbackHost(hostname);
istStandard = istStandardCloudHost(hostname);
} catch(e){ /* ungültige URL: Vorgabe bleibt der knappe Standard-Timeout */ }
const timeoutMs = istLokal ? KI_TIMEOUT_MS_LOKAL : (istStandard ? KI_TIMEOUT_MS_CLOUD : KI_TIMEOUT_MS_CLOUD_FREMD);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const fetchOptions = Object.assign({}, options, { signal: controller.signal });
if(istLokal) fetchOptions.dispatcher = LOKAL_DISPATCHER;
return await fetch(url, fetchOptions);
} finally {
clearTimeout(timer);
}
}
// normalisierePunktAusKi: eine KI-Antwortzeile in dieselbe Form wie ein
// erkennePunkteAusText()-Punkt bringen — beide Aufrufer (POST-Route,
// legeProtokollAn) müssen nicht wissen, woher ein Punkt stammt.
function normalisierePunktAusKi(p){
return {
nummer: String((p && p.nummer) || ''),
gruppe: String((p && p.gruppe) || ''),
thema: String((p && p.thema) || ''),
text: String((p && p.text) || ''),
zustaendig: String((p && p.zustaendig) || ''),
termin: normalisiereTermin((p && p.termin) || ''),
status: VALID_PROTOKOLL_PUNKT_STATUS.has(p && p.status) ? p.status : 'offen'
};
}
// normalisiereTeilnehmerAusKi: Gegenstück zu normalisierePunktAusKi() für
// das zweite KI_PUNKT_SCHEMA-Feld. "anwesend" defaultet — anders als
// "erledigt" oben — auf true, nicht auf false: eine erkannte Person gilt als
// anwesend, außer der Prompt weist das Modell ausdrücklich an, sie als
// entschuldigt/abwesend zu kennzeichnen (dieselbe Grundannahme wie beim
// manuellen "+ Teilnehmer" in der Detailansicht, js/plan/plan-protokoll.js).
function normalisiereTeilnehmerAusKi(t){
return {
name: String((t && t.name) || ''),
firma: String((t && t.firma) || ''),
anwesend: !(t && t.anwesend === false)
};
}
const KI_PUNKT_SCHEMA = {
type: 'object',
properties: {
punkte: {
type: 'array',
items: {
type: 'object',
properties: {
nummer: { type: 'string' },
gruppe: { type: 'string' },
thema: { type: 'string' },
text: { type: 'string' },
zustaendig: { type: 'string' },
termin: { type: 'string' },
status: { type: 'string', enum: ['offen', 'erledigt', 'info'] }
},
required: ['nummer', 'gruppe', 'thema', 'text', 'zustaendig', 'termin', 'status'],
additionalProperties: false
}
},
teilnehmer: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
firma: { type: 'string' },
anwesend: { type: 'boolean' }
},
required: ['name', 'firma', 'anwesend'],
additionalProperties: false
}
}
},
required: ['punkte', 'teilnehmer'],
additionalProperties: false
};
// KI_DEFAULT_URL ist jetzt weiter oben definiert, direkt vor
// istLoopbackHost()/fetchMitTimeout() — siehe Kommentar dort.
// normalisiereAiUrl: wer eine eigene API-URL hinterlegt, tippt bei einem
// lokalen Server wie LM Studio/Ollama erfahrungsgemäß nur die Basisadresse
// (z. B. "http://localhost:1234"), nicht den vollen Completions-Pfad —
// beide Tools bewerben sich selbst über genau diese kurze Adresse. Ohne
// diese Ergänzung landet die Anfrage dort auf "/" statt auf
// "/v1/chat/completions" (bzw. "/v1/messages" bei Claude): der lokale
// Server antwortet trotzdem mit HTTP 200, aber mit einem Text, der keine
// gültige Chat-Completion ist — reproduzierbar mit Eriks eigenem Fehler
// ("Received request: POST to /" in LM Studios Log). Nur ein FEHLENDER
// oder leerer Pfad wird ergänzt; ein ausdrücklich angegebener Pfad (z. B.
// ein Firmen-Proxy mit eigener Route) bleibt unangetastet.
function normalisiereAiUrl(apiUrl, defaultUrl){
if(!apiUrl) return defaultUrl;
try {
const parsed = new URL(apiUrl);
const defaultPath = new URL(defaultUrl).pathname; // z. B. '/v1/chat/completions'
const leer = !parsed.pathname || parsed.pathname === '/';
// Viele OpenAI-kompatible Anbieter (Mammouth.ai, OpenRouter und weitere)
// dokumentieren ihre Basis-URL nach der offiziellen OpenAI-SDK-Konvention
// als ".../v1" - ohne den eigentlichen Completions-Pfad dahinter. Das
// Eingabefeld (Einstellungen → Mein Konto) schlägt mit seinem Platzhalter
// ("z. B. http://localhost:1234 für LM Studio") genau diese Art bloßer
// Basis-URL vor, und wer sie nach der für den jeweiligen Anbieter
// dokumentierten Form mit angehängtem "/v1" einträgt, bekam bisher einen
// 404: Die Anfrage landete exakt auf ".../v1" statt auf
// ".../v1/chat/completions" (reproduzierbar bei Mammouth.ai, dessen
// FastAPI-typische {"detail":"Not Found"}-Antwort das auch so ausweist).
// Nur ein Pfad, der GENAU auf "/v1" endet, wird so behandelt wie ein
// leerer - ein abweichender, bewusst eigener Pfad (z. B. ein
// Firmen-Proxy mit eigener Route) bleibt wie bisher unangetastet.
const nurBasisPfad = /\/v1\/?$/.test(parsed.pathname) && defaultPath.startsWith('/v1/');
if(leer || nurBasisPfad){
parsed.pathname = defaultPath;
return parsed.toString();
}
return apiUrl;
} catch(e){
return apiUrl;
}
}
async function erkennePunkteMitChatGPT(text, apiKey, apiUrl, model){
const zielUrl = normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.chatgpt);
let istLokal = false;
try { istLokal = istLoopbackHost(new URL(zielUrl).hostname); } catch(e){ /* ungültige URL: bleibt false */ }
const body = {
// model (Konto-Einstellung, user_ai_keys.model) überschreibt die Vorgabe
// nur, wenn gesetzt — nötig für einen eigenständigen Anbieter mit
// abweichendem Modell-Katalog (z. B. Mammouth.ai), siehe
// migrateAddUserAiKeyModel() oben.
model: model || 'gpt-4o-mini',
messages: [
{ role: 'system', content: KI_SYSTEM_PROMPT },
{ role: 'user', content: text }
],
response_format: { type: 'json_schema', json_schema: { name: 'protokoll_punkte', strict: true, schema: KI_PUNKT_SCHEMA } },
// Ohne eigenes max_tokens bricht mancher lokale Server (anders als die
// echte OpenAI-API, die hier großzügig ist) die Antwort bei einem langen
// Protokoll mit vielen Punkten vorzeitig ab, sobald ein internes
// Standardlimit erreicht ist — die JSON-Antwort bleibt dabei gültig,
// enthält aber nur die ersten paar Punkte statt aller.
max_tokens: 16000,
// temperature:0 macht die Erkennung reproduzierbar — bei Streuung (dem
// Standardwert) lieferte ein und dieselbe Anfrage an Eriks lokales
// Modell je nach Lauf 0 bis 11 erkannte Punkte, was Fehlersuche und
// Nutzervertrauen gleichermaßen unmöglich macht. Für diese Aufgabe
// (Fakten aus einem Text ziehen) ist Kreativität ohnehin unerwünscht.
temperature: 0,
// Sicherheitsnetz gegen Wiederholungsschleifen: bei einem Testlauf ohne
// echte Teilnehmerdaten im Text geriet Eriks lokales Modell beim
// Befüllen von "teilnehmer" in eine Endlosschleife (dasselbe
// Namens-Fragment tausendfach), bis max_tokens die Antwort mitten im
// JSON abschnitt — mit echten Teilnehmerdaten (wie jeder reale Import
// sie liefert) trat das nicht auf, aber diese beiden Parameter machen
// eine erneute Schleife unabhängig vom Dokumentinhalt unwahrscheinlicher.
frequency_penalty: 0.4,
presence_penalty: 0.2
};
// Manche lokale Server laden standardmäßig ein Reasoning-Modell (z. B.
// Qwen3 in LM Studio), das vor jeder Antwort ausführlich "denkt" — für
// diese Aufgabe direkt gegen ein echtes LM-Studio-Setup gemessen: 260 von
// 264 Tokens (98,5 %) fürs Denken, 15s statt 0,4s für eine triviale
// Anfrage; bei einem langen Besprechungsprotokoll mit vielen Punkten
// reicht das leicht über jeden vertretbaren Timeout hinaus, obwohl das
// Modell am Ende korrekt "durchläuft". reasoning_effort:'none' schaltet
// das Denken ab (ebenfalls live verifiziert — mit dem echten System-
// Prompt und Schema unten korrekt und in Sekunden statt Minuten). Nur
// für lokale Ziele gesetzt: die echte OpenAI-API für gpt-4o-mini (kein
// Reasoning-Modell) kennt den Parameter nicht und soll ihn nie sehen.
if(istLokal) body.reasoning_effort = 'none';
const res = await fetchMitTimeout(zielUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
body: JSON.stringify(body)
});
if(!res.ok){
const body = await res.text().catch(() => '');
throw new Error('ChatGPT antwortete mit HTTP ' + res.status + (body ? ': ' + body.slice(0, 200) : ''));
}
const data = await res.json();
const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
if(!content) throw new Error('ChatGPT lieferte keine Antwort.');
let parsed;
try { parsed = JSON.parse(content); } catch(e){ throw new Error('ChatGPT-Antwort war kein gültiges JSON.'); }
if(!Array.isArray(parsed.punkte)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.');
return {
punkte: parsed.punkte.map(normalisierePunktAusKi),
teilnehmer: Array.isArray(parsed.teilnehmer) ? parsed.teilnehmer.map(normalisiereTeilnehmerAusKi) : []
};
}
async function erkennePunkteMitClaude(text, apiKey, apiUrl, model){
const res = await fetchMitTimeout(normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.claude), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({
model: model || 'claude-haiku-4-5-20251001',
// wie beim ChatGPT-Pfad: ein langes Protokoll mit vielen Punkten
// braucht mehr als das ursprüngliche Minimum, sonst bricht die
// Antwort vor dem letzten Punkt ab.
max_tokens: 8192,
// Wie beim ChatGPT-Pfad: reproduzierbares Ergebnis statt Streuung
// zwischen Läufen (siehe Begründung bei KI_SYSTEM_PROMPT oben).
temperature: 0,
system: KI_SYSTEM_PROMPT,
messages: [{ role: 'user', content: text }],
tools: [{
name: 'protokoll_punkte',
description: 'Die aus dem Text erkannten Besprechungspunkte.',
input_schema: KI_PUNKT_SCHEMA
}],
tool_choice: { type: 'tool', name: 'protokoll_punkte' }
})
});
if(!res.ok){
const body = await res.text().catch(() => '');
throw new Error('Claude antwortete mit HTTP ' + res.status + (body ? ': ' + body.slice(0, 200) : ''));
}
const data = await res.json();
const toolUse = Array.isArray(data.content) ? data.content.find(b => b.type === 'tool_use') : null;
if(!toolUse || !toolUse.input || !Array.isArray(toolUse.input.punkte)){
throw new Error('Claude-Antwort hatte nicht die erwartete Form.');
}
return {
punkte: toolUse.input.punkte.map(normalisierePunktAusKi),
teilnehmer: Array.isArray(toolUse.input.teilnehmer) ? toolUse.input.teilnehmer.map(normalisiereTeilnehmerAusKi) : []
};
}
async function erkennePunkteMitKI(text, userId, provider){
const { apiKey, apiUrl, model } = readUserAiKey(userId, provider);
if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.');
const gekuerzterText = text.length > KI_MAX_TEXT_LAENGE ? text.slice(0, KI_MAX_TEXT_LAENGE) : text;
if(provider === 'chatgpt') return erkennePunkteMitChatGPT(gekuerzterText, apiKey, apiUrl, model);
if(provider === 'claude') return erkennePunkteMitClaude(gekuerzterText, apiKey, apiUrl, model);
throw new Error('Unbekannter Anbieter: ' + provider);
}
// ---------- "TODOs aus Protokoll" (public/js/plan/plan-protokoll-todos.js) ----------
//
// Eigenständige, zweite KI-Stufe: nimmt bereits erkannte Besprechungspunkte
// (egal ob frisch aus einem Ad-hoc-PDF-Upload oder sonstwoher) und leitet
// daraus Aufgaben-VORSCHLÄGE ab (Titel, Beschreibung, Fälligkeit, Zuständig-
// Vorschlag) — anders als erkennePunkteMitKI() oben, das nur Punkte aus
// Fließtext EXTRAHIERT, beurteilt dieser Schritt, was davon tatsächlich zu
// tun ist: mehrere Punkte können zu einer Aufgabe zusammengehören, rein
// informative Punkte liefern keine. Komplett unabhängig von
// project_protokolle/protokoll_punkte — die Punkte kommen direkt im
// Request-Body, nichts wird hier gelesen oder geschrieben.
// normalisiereAufgabeAusKi: Analoges Gegenstück zu normalisierePunktAusKi()
// oben, für eine KI-Antwortzeile dieser zweiten Stufe.
function normalisiereAufgabeAusKi(a){
return {
titel: String((a && a.titel) || ''),
beschreibung: String((a && a.beschreibung) || ''),
faelligkeit: String((a && a.faelligkeit) || ''),
zustaendigVorschlag: String((a && a.zustaendigVorschlag) || ''),
quellRefs: Array.isArray(a && a.quellRefs) ? a.quellRefs.filter(r => Number.isInteger(r)) : []
};
}
const KI_AUFGABEN_SCHEMA = {
type: 'object',
properties: {
aufgaben: {
type: 'array',
items: {
type: 'object',
properties: {
titel: { type: 'string' },
beschreibung: { type: 'string' },
faelligkeit: { type: 'string' },
zustaendigVorschlag: { type: 'string' },
quellRefs: { type: 'array', items: { type: 'integer' } }
},
required: ['titel', 'beschreibung', 'faelligkeit', 'zustaendigVorschlag', 'quellRefs'],
additionalProperties: false
}
}
},
required: ['aufgaben'],
additionalProperties: false
};
// KI_AUFGABEN_SYSTEM_PROMPT: eigener, von KI_SYSTEM_PROMPT bewusst getrennter
// Prompt — andere Aufgabe (Aufgaben aus bereits strukturierten Punkten
// ABLEITEN, nicht Punkte aus Fließtext EXTRAHIEREN). Wie bei KI_SYSTEM_PROMPT/
// PLAN_CHECK_SYSTEM_PROMPT gilt: der Wortlaut ist ein Startpunkt, kein
// Festbetrag — vor dem produktiven Einsatz mit einem echten externen
// Protokoll bei temperature:0 testen, Änderungen einzeln bewerten (siehe
// CLAUDE.md "### Protokolle").
const KI_AUFGABEN_SYSTEM_PROMPT = 'Du bekommst Besprechungspunkte eines Projektprotokolls (je Punkt: ref, thema, '
+ 'text, zustaendig, termin). Leite daraus konkrete, umsetzbare Aufgaben ab. Mehrere Punkte, die dieselbe Handlung '
+ 'betreffen, können in einer Aufgabe zusammengefasst werden; rein informative Punkte ohne notwendige Handlung '
+ 'liefern KEINE Aufgabe. Je Aufgabe: ein kurzer Titel (Tätigkeit zuerst), eine kurze Beschreibung, ein '
+ 'Fälligkeitsdatum NUR wenn im Text ein konkretes Datum genannt ist (sonst leer lassen, nichts erfinden), ein '
+ 'Vorschlag für die zuständige Person (wörtlich aus dem Feld "zustaendig" übernommen, nichts erfinden), und die '
+ 'refs der Punkte, aus denen sie entstand.';
// aufgabenTextFuerKi: baut den Nutzer-Content für beide Anbieter aus der
// Punkteliste — jeder Punkt trägt seine Array-Position als "ref", damit die
// Antwort referenzierbar ist, ohne dass ein id-Konzept nötig wäre (nichts
// hier ist persistiert).
function aufgabenTextFuerKi(punkte){
return punkte.map((p, i) => `ref ${i}\nThema: ${p.thema || ''}\nText: ${p.text || ''}\n`
+ `Zuständig: ${p.zustaendig || ''}\nTermin: ${p.termin || ''}`).join('\n\n');
}
async function leiteAufgabenMitChatGPT(punkte, apiKey, apiUrl, model){
const zielUrl = normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.chatgpt);
let istLokal = false;
try { istLokal = istLoopbackHost(new URL(zielUrl).hostname); } catch(e){ /* ungültige URL: bleibt false */ }
const body = {
model: model || 'gpt-4o-mini',
messages: [
{ role: 'system', content: KI_AUFGABEN_SYSTEM_PROMPT },
{ role: 'user', content: aufgabenTextFuerKi(punkte) }
],
response_format: { type: 'json_schema', json_schema: { name: 'protokoll_aufgaben', strict: true, schema: KI_AUFGABEN_SCHEMA } },
// Kleiner als bei der Punkte-Erkennung (erkennePunkteMitChatGPT oben):
// die Ausgabe ist nur eine Handvoll kurzer Aufgaben, kein ganzes Protokoll.
max_tokens: 4096,
temperature: 0
};
if(istLokal) body.reasoning_effort = 'none';
const res = await fetchMitTimeout(zielUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
body: JSON.stringify(body)
});
if(!res.ok){
const errBody = await res.text().catch(() => '');
throw new Error('ChatGPT antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : ''));
}
const data = await res.json();
const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
if(!content) throw new Error('ChatGPT lieferte keine Antwort.');
let parsed;
try { parsed = JSON.parse(content); } catch(e){ throw new Error('ChatGPT-Antwort war kein gültiges JSON.'); }
if(!Array.isArray(parsed.aufgaben)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.');
return parsed.aufgaben.map(normalisiereAufgabeAusKi);
}
async function leiteAufgabenMitClaude(punkte, apiKey, apiUrl, model){
const res = await fetchMitTimeout(normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.claude), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({
model: model || 'claude-haiku-4-5-20251001',
max_tokens: 4096,
temperature: 0,
system: KI_AUFGABEN_SYSTEM_PROMPT,
messages: [{ role: 'user', content: aufgabenTextFuerKi(punkte) }],
tools: [{
name: 'protokoll_aufgaben',
description: 'Die aus den Besprechungspunkten abgeleiteten Aufgaben.',
input_schema: KI_AUFGABEN_SCHEMA
}],
tool_choice: { type: 'tool', name: 'protokoll_aufgaben' }
})
});
if(!res.ok){
const errBody = await res.text().catch(() => '');
throw new Error('Claude antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : ''));
}
const data = await res.json();
const toolUse = Array.isArray(data.content) ? data.content.find(b => b.type === 'tool_use') : null;
if(!toolUse || !toolUse.input || !Array.isArray(toolUse.input.aufgaben)){
throw new Error('Claude-Antwort hatte nicht die erwartete Form.');
}
return toolUse.input.aufgaben.map(normalisiereAufgabeAusKi);
}
async function leiteAufgabenMitKI(punkte, userId, provider){
const { apiKey, apiUrl, model } = readUserAiKey(userId, provider);
if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.');
if(provider === 'chatgpt') return leiteAufgabenMitChatGPT(punkte, apiKey, apiUrl, model);
if(provider === 'claude') return leiteAufgabenMitClaude(punkte, apiKey, apiUrl, model);
throw new Error('Unbekannter Anbieter: ' + provider);
}
// Route 1/2: liest ein hochgeladenes PDF (roh im Body, wie
// /project-protokolle/import) und erkennt daraus Besprechungspunkte — nutzt
// DIESELBE Erkennung wie der echte Protokoll-Import (erkennePunkteMitKI()/
// erkennePunkteAusText()), legt aber NICHTS an: kein project_protokolle-
// Eintrag, keine Datei auf der Festplatte (anders als der Import, der beides
// immer persistiert — diese Funktion ist bewusst ein Ad-hoc-Werkzeug ohne
// Archiv, siehe Kopfkommentar oben). Die erkannten Punkte gehen direkt an
// den Client zurück und werden von der zweiten Route unten (ohne erneuten
// Server-Lookup) weiterverarbeitet.
app.post('/plan-todos-aus-protokoll/einlesen', requireAuth, requireRecht('proj.plan'), express.raw({ type: '*/*', limit: '25mb' }), async (req, res) => {
try {
const buf = req.body;
if(!Buffer.isBuffer(buf) || buf.length === 0){
return res.status(400).json({ ok: false, error: 'Keine Datei empfangen.' });
}
let text;
try { text = await extrahierePdfText(buf); }
catch(err){ return res.status(400).json({ ok: false, error: 'Die Datei konnte nicht als PDF gelesen werden.' }); }
let punkte, erkennungsart = 'textmuster', erkennungshinweis = '';
const anbieter = req.query.anbieter;
if(anbieter === 'chatgpt' || anbieter === 'claude'){
try {
const ergebnis = await erkennePunkteMitKI(text, req.user.id, anbieter);
punkte = ergebnis.punkte;
erkennungsart = anbieter;
} catch(err){
console.warn('KI-Erkennung (TODOs aus Protokoll) fehlgeschlagen, Rückfall auf Textmuster:', err.message);
erkennungshinweis = err.message;
punkte = erkennePunkteAusText(text);
}
} else {
punkte = erkennePunkteAusText(text);
}
res.json({ ok: true, punkte, erkennungsart, erkennungshinweis });
} catch(err){
console.error('TODOs aus Protokoll (Einlesen) fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route 2/2: leitet aus den (vom Client ausgewählten) Punkten Aufgaben-
// Vorschläge ab. Die Punkte kommen direkt im Body — kein Bezug zu
// project_protokolle, siehe Kopfkommentar oben.
app.post('/plan-todos-aus-protokoll/aufgaben-vorschlag', requireAuth, requireRecht('proj.plan'), async (req, res) => {
try {
const { anbieter } = req.body || {};
const punkte = Array.isArray((req.body || {}).punkte)
? req.body.punkte.filter(p => p && typeof p === 'object') : [];
if(punkte.length === 0){
return res.status(400).json({ ok: false, error: 'Keine Punkte ausgewählt.' });
}
let vorschlaege, erkennungsart = 'direkt', erkennungshinweis = '';
if(anbieter === 'chatgpt' || anbieter === 'claude'){
try {
vorschlaege = await leiteAufgabenMitKI(punkte, req.user.id, anbieter);
erkennungsart = anbieter;
} catch(err){
console.warn('KI-Ableitung (TODOs aus Protokoll) fehlgeschlagen, Rückfall auf 1:1-Übernahme:', err.message);
erkennungshinweis = err.message;
vorschlaege = null;
}
}
if(!vorschlaege){
// Direkter Rückfall: je ausgewähltem Punkt ein Vorschlag, derselbe
// Rückfall-Geist wie erkennePunkteAusText() beim Import.
vorschlaege = punkte.map((p, i) => ({
titel: String(p.thema || '').trim() || String(p.text || '').slice(0, 80),
beschreibung: String(p.text || ''),
faelligkeit: String(p.termin || ''),
zustaendigVorschlag: String(p.zustaendig || ''),
quellRefs: [i]
}));
}
vorschlaege = vorschlaege.map(v => Object.assign({}, v, { faelligkeit: normalisiereTermin(v.faelligkeit) }));
res.json({ ok: true, vorschlaege, erkennungsart, erkennungshinweis });
} catch(err){
console.error('TODOs aus Protokoll (Aufgaben-Vorschlag) fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Termin aus Mail (Drag & Drop in Terminplan/Aufgaben) ----------
//
// Eriks Wunsch: Mailtext (markiert oder als ganze .eml-Datei) auf den
// Terminplan ziehen, eine KI macht daraus Aufgaben. Wie "TODOs aus
// Protokoll" wird hier NICHTS gespeichert — die Route liefert nur
// Vorschläge, angelegt wird erst im Browser nach Prüfung im Aufgaben-Panel.
// Mit dem Mailtext als Notiz der Aufgabe (plan_todos.notiz).
// mailWoerterDekodieren: RFC-2047-Wörter in Kopfzeilen ("=?utf-8?B?…?=").
function mailWoerterDekodieren(s){
return String(s || '').replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=(\s+(?==\?))?/g, (m, cs, enc, txt) => {
try {
const buf = enc.toUpperCase() === 'B'
? Buffer.from(txt, 'base64')
: Buffer.from(txt.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
return mailZeichensatz(buf, cs);
} catch(e){ return m; }
});
}
function mailZeichensatz(buf, charset){
const cs = String(charset || 'utf-8').toLowerCase().replace(/^"|"$/g, '');
try { return new TextDecoder(cs).decode(buf); }
catch(e){ return buf.toString(cs.includes('8859') || cs.includes('1252') ? 'latin1' : 'utf8'); }
}
function mailKopfTeilen(roh){
const trenn = roh.search(/\r?\n\r?\n/);
const kopfText = trenn < 0 ? roh : roh.slice(0, trenn);
const rumpf = trenn < 0 ? '' : roh.slice(trenn).replace(/^\r?\n\r?\n/, '');
const kopf = {};
kopfText.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/).forEach(z => {
const i = z.indexOf(':');
if(i > 0){ const k = z.slice(0, i).trim().toLowerCase(); if(!(k in kopf)) kopf[k] = z.slice(i + 1).trim(); }
});
return { kopf, rumpf };
}
function mailKopfParam(wert, name){
const m = String(wert || '').match(new RegExp(name + '\\s*=\\s*("([^"]*)"|[^;\\s]+)', 'i'));
return m ? (m[2] !== undefined ? m[2] : m[1]) : '';
}
// mailTeilLesen: liefert {plain, html} eines (ggf. mehrteiligen) Mailteils.
function mailTeilLesen(roh, tiefe){
const { kopf, rumpf } = mailKopfTeilen(roh);
const typ = String(kopf['content-type'] || 'text/plain').toLowerCase();
if(typ.startsWith('multipart/') && tiefe < 6){
const grenze = mailKopfParam(kopf['content-type'], 'boundary');
if(!grenze) return { plain: rumpf, html: '' };
const teile = rumpf.split('--' + grenze).slice(1).filter(t => !t.startsWith('--'));
const erg = { plain: '', html: '' };
teile.forEach(t => {
const r = mailTeilLesen(t.replace(/^\r?\n/, ''), tiefe + 1);
if(!erg.plain && r.plain) erg.plain = r.plain;
if(!erg.html && r.html) erg.html = r.html;
});
return erg;
}
if(/attachment/i.test(kopf['content-disposition'] || '')) return { plain: '', html: '' };
if(!typ.startsWith('text/')) return { plain: '', html: '' };
const enc = String(kopf['content-transfer-encoding'] || '').toLowerCase();
let buf;
if(enc === 'base64') buf = Buffer.from(rumpf.replace(/\s+/g, ''), 'base64');
else if(enc === 'quoted-printable'){
buf = Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
} else buf = Buffer.from(rumpf, 'latin1');
const text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8');
return typ.startsWith('text/html') ? { plain: '', html: text } : { plain: text, html: '' };
}
function htmlZuText(html){
return String(html || '')
.replace(/<(script|style|head)[\s\S]*?<\/\1>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h\d|blockquote)>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
.replace(/&#(\d+);/g, (m, n) => String.fromCharCode(Number(n))).replace(/&amp;/g, '&')
.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
// leseEml: .eml-Datei (als Text) → {betreff, absender, datum, text}.
function leseEml(roh){
const s = String(roh || '');
const { kopf } = mailKopfTeilen(s);
const teil = mailTeilLesen(s, 0);
let datum = '';
const d = new Date(kopf['date'] || '');
if(!isNaN(d)) datum = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
return {
betreff: mailWoerterDekodieren(kopf['subject'] || ''),
absender: mailWoerterDekodieren(kopf['from'] || ''),
datum,
text: (teil.plain || htmlZuText(teil.html)).trim()
};
}
// mailZitateAbschneiden: entfernt zitierte Vorgänger-Mails — sonst griffe
// die KI Termine aus alten Mails derselben Unterhaltung auf.
function mailZitateAbschneiden(text){
const zeilen = String(text || '').replace(/\r\n/g, '\n').split('\n');
let ende = zeilen.length;
for(let i = 1; i < zeilen.length; i++){
const z = zeilen[i].trim();
const naechste = zeilen.slice(i + 1, i + 5).join('\n');
if(/^-{2,}\s*(Ursprüngliche Nachricht|Original Message)/i.test(z)
|| /^(Am|On) .+(schrieb|wrote).*:$/i.test(z)
|| (/^(Von|From):\s/i.test(z) && /^(Gesendet|Sent|Datum|Date):/im.test(naechste))){
ende = i; break;
}
}
return zeilen.slice(0, ende).filter(z => !/^\s*>/.test(z)).join('\n').replace(/\n{3,}/g, '\n\n').trim();
}
const KI_MAIL_SCHEMA = {
type: 'object',
properties: {
eintraege: {
type: 'array',
items: {
type: 'object',
properties: {
art: { type: 'string', enum: ['aufgabe', 'meilenstein'] },
titel: { type: 'string' },
start: { type: 'string' },
ende: { type: 'string' },
zustaendig: { type: 'array', items: { type: 'string' } },
aufwandStunden: { type: 'number' },
zitat: { type: 'string' }
},
required: ['art', 'titel', 'start', 'ende', 'zustaendig', 'aufwandStunden', 'zitat'],
additionalProperties: false
}
}
},
required: ['eintraege'],
additionalProperties: false
};
// Wie bei den übrigen KI-Prompts: Startpunkt, vor Änderungen mit echten
// Mails bei temperature:0 einzeln prüfen (siehe CLAUDE.md "### Protokolle").
const KI_MAIL_SYSTEM_PROMPT = 'Du bekommst eine E-Mail aus einem Ingenieurbüro und Angaben zum Projekt. Mache daraus '
+ 'Einträge für den Terminplan des Projekts: je Termin, Frist oder zu erledigender Arbeit einen Eintrag. '
+ 'art: "meilenstein" für einen festen Stichtag ohne eigene Arbeit (Abgabe, Termin, Freigabe, Besprechung), '
+ '"aufgabe" für zu erledigende Arbeit. Titel kurz, Tätigkeit zuerst. Datumsangaben als JJJJ-MM-TT; relative Angaben wie "nächsten Freitag" '
+ 'rechnest du vom Datum der Mail aus. Ein einzelner Termin oder eine Frist: start und ende gleich. '
+ 'Ohne Datum in der Mail bleiben start und ende leer. zustaendig: nur Namen aus der Liste der Beteiligten, '
+ 'die in der Mail als verantwortlich genannt sind, sonst leer. aufwandStunden nur wenn genannt, sonst 0. zitat: der Satz aus der Mail, '
+ 'auf dem der Eintrag beruht. Enthält die Mail nichts für den Terminplan, gib eine leere Liste zurück.';
function mailTextFuerKi(m, kontext){
const teile = [
'Heute: ' + (kontext.heute || heuteIsoLokal()),
'Datum der Mail: ' + (m.datum || 'unbekannt'),
'Projekt: ' + (kontext.projekt || ''),
'Beteiligte: ' + ((kontext.personen || []).join(', ') || 'keine'),
'Leistungsphasen im Terminplan: ' + ((kontext.phasen || []).map(p => `${p.lph} ${p.start}–${p.ende}`).join('; ') || 'keine'),
'',
'Von: ' + (m.absender || ''),
'Betreff: ' + (m.betreff || ''),
'',
m.text
];
return teile.join('\n');
}
function normalisiereMailEintrag(e){
const iso = v => { const s = normalisiereTermin(v); return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : ''; };
let start = iso(e && e.start), ende = iso(e && e.ende);
if(start && !ende) ende = start;
if(ende && !start) start = ende;
if(start && ende && ende < start){ const t = start; start = ende; ende = t; }
return {
art: (e && e.art) === 'meilenstein' ? 'meilenstein' : 'aufgabe',
titel: String((e && e.titel) || '').trim().slice(0, 200),
start, ende,
zustaendig: Array.isArray(e && e.zustaendig) ? e.zustaendig.map(String).filter(Boolean) : [],
aufwandStunden: Math.max(0, Number(e && e.aufwandStunden) || 0),
zitat: String((e && e.zitat) || '').trim().slice(0, 500)
};
}
async function mailEintraegeMitKI(m, kontext, userId, provider){
const { apiKey, apiUrl, model } = readUserAiKey(userId, provider);
if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.');
const inhalt = mailTextFuerKi(m, kontext);
let liste;
if(provider === 'chatgpt'){
const zielUrl = normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.chatgpt);
let istLokal = false;
try { istLokal = istLoopbackHost(new URL(zielUrl).hostname); } catch(e){ /* bleibt false */ }
const body = {
model: model || 'gpt-4o-mini',
messages: [{ role: 'system', content: KI_MAIL_SYSTEM_PROMPT }, { role: 'user', content: inhalt }],
response_format: { type: 'json_schema', json_schema: { name: 'terminplan_eintraege', strict: true, schema: KI_MAIL_SCHEMA } },
max_tokens: 4096,
temperature: 0
};
if(istLokal) body.reasoning_effort = 'none';
const res = await fetchMitTimeout(zielUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
body: JSON.stringify(body)
});
if(!res.ok){
const t = await res.text().catch(() => '');
throw new Error('ChatGPT antwortete mit HTTP ' + res.status + (t ? ': ' + t.slice(0, 200) : ''));
}
const data = await res.json();
const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
let parsed;
try { parsed = JSON.parse(content || ''); } catch(e){ throw new Error('ChatGPT-Antwort war kein gültiges JSON.'); }
liste = parsed && parsed.eintraege;
} else if(provider === 'claude'){
const res = await fetchMitTimeout(normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.claude), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({
model: model || 'claude-haiku-4-5-20251001',
max_tokens: 4096,
temperature: 0,
system: KI_MAIL_SYSTEM_PROMPT,
messages: [{ role: 'user', content: inhalt }],
tools: [{ name: 'terminplan_eintraege', description: 'Die Einträge für den Terminplan.', input_schema: KI_MAIL_SCHEMA }],
tool_choice: { type: 'tool', name: 'terminplan_eintraege' }
})
});
if(!res.ok){
const t = await res.text().catch(() => '');
throw new Error('Claude antwortete mit HTTP ' + res.status + (t ? ': ' + t.slice(0, 200) : ''));
}
const data = await res.json();
const toolUse = Array.isArray(data.content) ? data.content.find(b => b.type === 'tool_use') : null;
liste = toolUse && toolUse.input && toolUse.input.eintraege;
} else {
throw new Error('Unbekannter Anbieter: ' + provider);
}
if(!Array.isArray(liste)) throw new Error('Die KI-Antwort hatte nicht die erwartete Form.');
return liste.map(normalisiereMailEintrag).filter(e => e.titel);
}
// Body: {anbieter, text?, eml?, kontext:{heute, projekt, personen[], phasen[{lph,start,ende}]}}
// — eml ist der Inhalt einer .eml-Datei als Text (der Browser liest sie ein).
app.post('/plan-eintrag-aus-mail', requireAuth, requireRecht('proj.plan'), async (req, res) => {
try {
const b = req.body || {};
const kontext = (b.kontext && typeof b.kontext === 'object') ? b.kontext : {};
let mail;
if(typeof b.eml === 'string' && b.eml.trim()) mail = leseEml(b.eml);
else mail = { betreff: '', absender: '', datum: '', text: String(b.text || '') };
mail.text = mailZitateAbschneiden(mail.text).slice(0, 15000);
if(!mail.text && !mail.betreff){
return res.status(400).json({ ok: false, error: 'In der Mail wurde kein Text gefunden.' });
}
let eintraege = null, erkennungsart = 'ohne', erkennungshinweis = '';
const anbieter = b.anbieter;
if(anbieter === 'chatgpt' || anbieter === 'claude'){
try {
eintraege = await mailEintraegeMitKI(mail, kontext, req.user.id, anbieter);
erkennungsart = anbieter;
} catch(err){
console.warn('Termin aus Mail: KI fehlgeschlagen:', err.message);
erkennungshinweis = err.message;
}
} else {
erkennungshinweis = 'Kein KI-Anbieter mit Schlüssel hinterlegt.';
}
if(!eintraege){
// Rückfall: ein leerer Eintrag mit dem Betreff — das Panel öffnet
// sich trotzdem, Termine trägt man selbst ein.
const ersteZeile = mail.text.split('\n').map(z => z.trim()).find(Boolean) || '';
eintraege = [normalisiereMailEintrag({ titel: mail.betreff || ersteZeile.slice(0, 80) })];
}
res.json({ ok: true, mail: { betreff: mail.betreff, absender: mail.absender, datum: mail.datum, text: mail.text },
eintraege, erkennungsart, erkennungshinweis });
} catch(err){
console.error('Termin aus Mail fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Plan Checker (plan_checker_merkmale) ----------
//
// Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt
// einen Bauplan als PDF hoch und lässt eine KI ihn gegen eine Liste von
// Merkmalen prüfen (z. B. "Maßketten vorhanden", "Plankopf vollständig").
// Anders als der Protokoll-Import oben geht es hier nicht um Text, sondern
// um optische Merkmale einer Zeichnung.
//
// Beide Anbieter werden (wieder) auf demselben Weg zugeführt, nach einem
// Zwischenschritt, der sich als unzureichend erwies: Die allererste Fassung
// rendere die PDF-Seite IMMER zu einem einzigen PNG und schickte das an
// beide Anbieter — Erik meldete daraufhin "extrem unzuverlässige" Erkennung
// von Maßketten, auch ein deutlich stärkeres Modell besserte das nicht, was
// auf ein Auflösungsproblem statt eine Modellgrenze hindeutete. Als
// Zwischenschritt bekam Claude testweise das PDF DIREKT (document-
// Content-Block, in der Annahme, Anthropics eigene Dokumenten-Verarbeitung
// löse Auflösung/Aufteilung besser) — half laut Erik aber nicht
// durchgreifend genug: ein erneuter Test zeigte den Plankopf zwar jetzt
// gefunden, aber Projektname, Planersteller, Maßstab, Datum und Plannummer
// blieben "in der vorliegenden Auflösung" nicht sämtlich lesbar. Das
// bestätigte den eigentlichen Engpass: JEDER Anbieter skaliert ein
// übermitteltes Bild bzw. eine PDF-Seite intern auf eine eigene Obergrenze
// herunter, egal auf welchem Weg es ankommt — ein einzelnes Bild der ganzen
// Seite kann Feinschrift in einem kleinen Blattausschnitt (wie dem Plankopf)
// deshalb grundsätzlich nicht zuverlässig lesbar machen.
//
// Jetzt rendern WIR beide Zuführungen selbst (renderPdfAlsBildsatz() weiter
// oben, aufgerufen aus pruefePlanMitKI() unten): eine Übersicht der ganzen
// Seite PLUS mehrere überlappende, einzeln hochaufgelöste Ausschnitte -
// sowohl an ChatGPT (als mehrere image_url-Blöcke) als auch an Claude (als
// mehrere image-Blöcke) auf dieselbe Weise geschickt. ChatGPT (über die hier
// genutzte Chat-Completions-API) sowie jeder lokale, über dieselbe
// Schnittstelle angesprochene Server (LM Studio, Ollama) oder Drittanbieter
// (z. B. Mammouth.ai) kennen in dieser Schnittstelle ohnehin grundsätzlich
// kein PDF-Format - nur Bilder; OpenAIs neuere "Responses API" könnte PDFs
// zwar direkt lesen, wird aber von lokalen Servern und den meisten
// Drittanbietern nicht nachgebildet, eine Umstellung darauf würde also genau
// die lokale/Drittanbieter-Nutzung brechen, die ausdrücklich mit abgedeckt
// sein soll, und bleibt deshalb aus.
//
// Nutzt dieselben Konten-Schlüssel (user_ai_keys, readUserAiKey() oben) wie
// der Protokoll-Import — keine eigene Schlüsselverwaltung nötig.
//
// Nichts wird dauerhaft gespeichert: weder das hochgeladene PDF noch die
// daraus gerenderten Bilder landen auf der Festplatte (anders als
// /project-files oder /project-protokolle/import, die beide immer
// persistieren, siehe dort) — das Ergebnis geht direkt in der Antwort
// zurück. Es gibt bewusst (noch) keinen Prüf-Verlauf: Das ist ein
// Ad-hoc-Werkzeug, kein Dokumentenarchiv.
// Bildunabhängig formuliert ("Planzeichnung" statt "Bild"), da beide
// Anbieter mehrere Bilder (Übersicht + Kacheln) derselben Zeichnung
// bekommen, nicht nur eines.
const PLAN_CHECK_SYSTEM_PROMPT = 'Du prüfst eine technische Planzeichnung (Architektur/TGA) auf das Vorhandensein '
+ 'bestimmter Merkmale. Du bekommst eine nummerierte Liste von Merkmalen mit Prüfanweisung. Gib für JEDES Merkmal aus der '
+ 'Liste genau einen Eintrag zurück, mit dem Titel exakt wie angegeben. "erfuellt" ist nur dann true, wenn das Merkmal auf '
+ 'der Zeichnung eindeutig zu erkennen ist - im Zweifel false. "begruendung" ist HÖCHSTENS 150 ZEICHEN LANG - eine feste '
+ 'Obergrenze, kein Stilwunsch. Zähle NIEMALS einzelne Werte auf (keine Maßzahlen, keine Höhenangaben, keine '
+ 'Koordinaten) - auch nicht als Beispiel ("z. B. ...") und auch nicht, wenn auf der Zeichnung sehr viele davon zu sehen '
+ 'sind: beschreibe stattdessen in einem knappen Satz, DASS und WO solche Angaben vorkommen. Erfinde nichts.';
// Wie bei KI_SYSTEM_PROMPT oben gilt: Formulierungsänderungen können das
// Ergebnis stark verschieben (siehe dortige Projekthistorie). Der erste
// Zusatz ("NIE eine Aufzählung einzelner Werte", ohne Zeichengrenze) kam nach
// einem echten Fehlschlag dazu: Ein lokal über LM Studio angebundenes Modell
// zählte für "Maßketten vorhanden" jede einzelne gefundene Maßzahl einzeln
// auf ("2,50 m, 3,12 m, 1,20 m, 2,10 m, ..."), wodurch die Antwort noch vor
// Abschluss des JSON an max_tokens (s. u.) abgeschnitten wurde und als Ganzes
// nicht mehr parsbar war ("ChatGPT-Antwort war kein gültiges JSON"). Reichte
// demselben Modell beim nächsten Fehlschlag aber IMMER NOCH nicht: Es zählte
// diesmal sogar "1,00, 2,00, 3,00 ... 22,00" durch und sprengte damit auch
// das inzwischen angehobene max_tokens erneut. Eine semantische Anweisung
// ("halt dich kurz") lässt sich offenbar umgehen, eine harte Zahl
// ("höchstens 150 Zeichen") ist für ein Modell mechanisch leichter zu prüfen
// - ebenfalls noch nicht an diesem konkreten Fehlschlag gegengetestet, siehe
// Hinweis bei max_tokens.
// PLAN_CHECK_BILD_ANZAHL: Gesamtzahl der an die KI gesendeten Bilder (1
// Übersicht + die Kacheln, s. renderPdfAlsBildsatz() oben) - bestimmt den
// gültigen Wertebereich für "bildIndex" im Schema direkt unten. Von Hand
// synchron zu PLAN_CHECK_KACHEL_SPALTEN/-ZEILEN gehalten statt daraus
// berechnet, damit die enum-Liste unten als lesbare Literalliste im Schema
// steht (für strict-JSON-Schema bei OpenAI ohnehin nötig).
const PLAN_CHECK_BILD_ANZAHL = 1 + PLAN_CHECK_KACHEL_SPALTEN * PLAN_CHECK_KACHEL_ZEILEN;
const PLAN_CHECK_SCHEMA = {
type: 'object',
properties: {
ergebnisse: {
type: 'array',
items: {
type: 'object',
properties: {
merkmal: { type: 'string' },
erfuellt: { type: 'boolean' },
begruendung: { type: 'string' },
// bildIndex: Eriks Wunsch nach einem Screenshot je Merkmal, NACH
// einem ersten Versuch mit pixelgenauen Bild-Ausschnitten
// (Beschriftungsraster + Zuschnitt einer benannten Zelle), der sich
// in der Praxis als nicht gut genug erwies ("das mit den
// Bildausschnitten hat nicht gut funktioniert") - deutlich
// einfacher und robuster: die KI wählt nur noch AUS den bereits
// gesendeten Bildern dasjenige, das den Nachweis für das Merkmal
// am besten zeigt (0 = Übersicht, 1..N = die Kacheln in der
// gesendeten Reihenfolge). Reine Auswahl aus einer festen, kleinen
// Liste statt einer selbst zu bestimmenden Koordinate oder
// Zellenbezeichnung - und der Server muss dafür NICHTS mehr
// nachträglich rendern, die Bilder liegen schon als Buffer vor
// (s. pruefePlanMitKI() weiter unten).
bildIndex: { type: 'integer', enum: Array.from({ length: PLAN_CHECK_BILD_ANZAHL }, (_, i) => i) }
},
required: ['merkmal', 'erfuellt', 'begruendung', 'bildIndex'],
additionalProperties: false
}
}
},
required: ['ergebnisse'],
additionalProperties: false
};
// baueMerkmalePrompt: formt die Merkmale-Liste (Grundliste + optionales
// Zusatz-Merkmal nur für diese eine Prüfung, siehe POST /plan-checker-pruefen)
// in den Anweisungstext für die KI. Parameter: merkmale (Array {titel, prompt}).
// Rückgabe: String. Erklärt zusätzlich die Bildreihenfolge (Übersicht zuerst,
// dann die hochaufgelösten Kachel-Ausschnitte, s. renderPdfAlsBildsatz()
// oben) - ohne diesen Hinweis hat das Modell keinen Grund, bei mehreren
// Bildern über dieselbe Zeichnung gezielt nach Feinschrift in den hinteren
// Bildern zu suchen, statt sich nur auf das erste zu verlassen. Erklärt
// zusätzlich bildIndex (s. PLAN_CHECK_SCHEMA oben).
function baueMerkmalePrompt(merkmale){
const zeilen = merkmale.map((m, i) => (i + 1) + '. ' + m.titel + ': ' + m.prompt);
return 'Prüfe die Planzeichnung auf folgende Merkmale:\n\n' + zeilen.join('\n')
+ '\n\nDu bekommst mehrere Bilder derselben Zeichnung, nummeriert von 0: Bild 0 ist eine Übersicht der ganzen '
+ 'Seite, die Bilder 1 bis ' + (PLAN_CHECK_BILD_ANZAHL - 1) + ' sind sich überlappende Ausschnitte in höherer '
+ 'Auflösung. Nutze die Ausschnitte gezielt, um kleine Details wie Maßzahlen oder Plankopf-Angaben zu lesen, die '
+ 'auf der Übersicht zu klein sind.'
+ '\n\nGib zu jedem Merkmal zusätzlich unter "bildIndex" die Nummer DESJENIGEN Bildes an, auf dem der '
+ 'Hauptnachweis für dieses Merkmal am besten zu erkennen ist. Lässt sich kein einzelnes Bild sinnvoll '
+ 'bevorzugen, gib 0 (die Übersicht) an.';
}
// pruefePlanMitChatGPT: bilderBase64 ist ein Array (Übersicht zuerst, dann
// die Kachel-Ausschnitte, s. renderPdfAlsBildsatz() weiter oben) - jedes
// Bild wird als eigener image_url-Block gesendet, in derselben Reihenfolge,
// die baueMerkmalePrompt() dem Modell textlich erklärt.
async function pruefePlanMitChatGPT(bilderBase64, merkmale, apiKey, apiUrl, model){
const zielUrl = normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.chatgpt);
let istLokal = false;
try { istLokal = istLoopbackHost(new URL(zielUrl).hostname); } catch(e){ /* ungültige URL: bleibt false */ }
const bildBloecke = bilderBase64.map(b64 => ({
// detail:'high' statt des Vorgabewerts 'auto' — erzwingt die
// feinteiligere, gekachelte Bildverarbeitung von OpenAI für JEDES der
// gesendeten Bilder. Ohne das kann das Modell bei einer dichten
// technischen Zeichnung von sich aus die gröbere Verarbeitung wählen,
// wodurch kleine Maßzahlen und Plankopf-Text unlesbar werden — genau das
// Merkmal, auf das hier geprüft wird.
type: 'image_url', image_url: { url: 'data:image/png;base64,' + b64, detail: 'high' }
}));
const body = {
// gpt-4o statt gpt-4o-mini als VORGABE (anders als beim Protokoll-Import
// oben, der reinen Fließtext liest): Erik meldete, dass die Erkennung
// "extrem unzuverlässig" war - bei temperature:0 (s.u.) deutet
// schwankendes Ergebnis für dieselbe Zeichnung auf eine Fähigkeitsgrenze
// des Modells hin, nicht auf Prompt/Rendering (siehe auch die
// Protokoll-Import-Erfahrung weiter oben: "ein stärkeres Modell hätte
// mehr Spielraum - das bleibt eine Modell-Fähigkeits-Frage, keine
// Prompt-Frage"). Feine Maßzahlen auf einer dichten technischen
// Zeichnung zuverlässig zu lesen ist eine deutlich anspruchsvollere
// Bildaufgabe als Fließtext aus einem Besprechungsprotokoll zu ziehen.
// Konto-Einstellung (user_ai_keys.model) überschreibt auch diese Vorgabe,
// wenn gesetzt - nötig für einen eigenständigen Anbieter mit
// abweichendem Modell-Katalog (z. B. Mammouth.ai).
model: model || 'gpt-4o',
messages: [
{ role: 'system', content: PLAN_CHECK_SYSTEM_PROMPT },
{ role: 'user', content: [
{ type: 'text', text: baueMerkmalePrompt(merkmale) },
...bildBloecke
] }
],
response_format: { type: 'json_schema', json_schema: { name: 'plan_pruefung', strict: true, schema: PLAN_CHECK_SCHEMA } },
// 4096 war zu knapp, dann 8192 - ein lokal angebundenes Modell hielt sich
// trotz zunehmend schärferer Anweisung (s. PLAN_CHECK_SYSTEM_PROMPT oben,
// Projekthistorie dort) wiederholt nicht an die Kürze, und die Antwort
// wurde mitten im JSON abgeschnitten. 16000 angeglichen an das Limit, das
// sich für genau dasselbe Problem beim Protokoll-Import bereits bewährt
// hat (erkennePunkteMitChatGPT() oben) - weiterhin nur ein Sicherheitsnetz
// zusätzlich zur Prompt-Anweisung, keine Garantie: Ein Modell, das
// entschlossen genug aufzählt, kann auch dieses Limit erreichen.
max_tokens: 16000,
temperature: 0
};
// Siehe erkennePunkteMitChatGPT() oben: manche lokale Server laden
// standardmäßig ein Reasoning-Modell, das vor jeder Antwort "denkt" und
// damit jeden Timeout sprengen kann — nur für lokale Ziele abgeschaltet.
if(istLokal) body.reasoning_effort = 'none';
const res = await fetchMitTimeout(zielUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
body: JSON.stringify(body)
});
if(!res.ok){
const errBody = await res.text().catch(() => '');
throw new Error('ChatGPT antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : ''));
}
const data = await res.json();
const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
if(!content) throw new Error('ChatGPT lieferte keine Antwort.');
// Manche lokalen Server (LM Studio, Ollama) oder Modelle halten sich nicht
// strikt an response_format:json_schema und umschließen die Antwort
// trotzdem mit einem Markdown-Codeblock ("```json\n...\n```"); das reine
// JSON innerhalb des Blocks wird dann aussortiert, bevor geparst wird.
const bereinigt = content.trim().replace(/^```[a-zA-Z]*\s*/, '').replace(/```\s*$/, '');
let parsed;
try { parsed = JSON.parse(bereinigt); } catch(e){
// finish_reason:'length' heißt konkret "max_tokens erreicht, bevor das
// Modell fertig war" - der häufigste real beobachtete Grund für
// ungültiges JSON hier (ein Modell, das trotz PLAN_CHECK_SYSTEM_PROMPT
// einzelne Werte aufzählt, s. dortige Projekthistorie). Eine konkrete
// Fehlermeldung dafür erspart das erneute Einschicken des rohen
// Antworttexts zur Diagnose. Rohtext zusätzlich im Fehler, wie bei den
// HTTP-Fehlern oben (errBody.slice(0, 200)) - andere Scheitern-Gründe
// (z. B. ein Modell, das nur EIN Bild pro Anfrage verarbeiten kann und
// bei mehreren Bildern mit Fließtext statt JSON antwortet) bleiben so
// trotzdem sichtbar.
const abgeschnitten = data.choices[0].finish_reason === 'length';
const hinweis = abgeschnitten
? 'Die Antwort wurde abgeschnitten (Modell hat das Token-Limit erreicht, bevor es fertig war - vermutlich zu ausführliche Begründungen). '
: '';
throw new Error('ChatGPT-Antwort war kein gültiges JSON: ' + hinweis + content.slice(0, 300));
}
if(!Array.isArray(parsed.ergebnisse)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.');
return parsed.ergebnisse;
}
// pruefePlanMitClaude: bekam zwischenzeitlich das PDF DIREKT
// (document-Content-Block) statt eines von uns gerenderten Bildes - Grund
// damals: Erik meldete, dass die Erkennung von Maßketten "extrem
// unzuverlässig" war, auch nach dem Wechsel von Haiku auf das deutlich
// stärkere Sonnet 5 KEINE Verbesserung, was gegen eine Modell-
// Fähigkeitsgrenze und FÜR ein Auflösungsproblem sprach (Claude skaliert laut
// Anthropic-Dokumentation auf ca. 1568px lange Kante bzw. ~1,15 Megapixel
// herunter). Die PDF-Direktübergabe half laut Erik aber NICHT durchgreifend
// genug: ein erneuter Test mit korrekt erkanntem Plankopf zeigte trotzdem
// "in der vorliegenden Auflösung" nicht sämtlich lesbare Feinschrift
// (Planersteller, Maßstab, Datum, Plannummer) - vermutlich, weil Anthropics
// eigene Dokumenten-Verarbeitung eine PDF-Seite intern ebenfalls erst zu
// einem einzigen Bild wandelt und DANN derselben Auflösungsgrenze
// unterliegt. bilderBase64 ist jetzt wie bei pruefePlanMitChatGPT() ein Array
// (Übersicht + hochaufgelöste Kachel-Ausschnitte, renderPdfAlsBildsatz()
// weiter oben) - dasselbe, selbst kontrollierte Verfahren für beide
// Anbieter, statt erneut auf unbekanntes anbieterinternes Verhalten zu
// vertrauen.
async function pruefePlanMitClaude(bilderBase64, merkmale, apiKey, apiUrl, model){
const bildBloecke = bilderBase64.map(b64 => ({
type: 'image', source: { type: 'base64', media_type: 'image/png', data: b64 }
}));
const res = await fetchMitTimeout(normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.claude), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({
// claude-sonnet-5 statt claude-haiku-4-5-20251001 als VORGABE (anders
// als beim Protokoll-Import oben), auch wenn das allein die
// Unzuverlässigkeit NICHT behoben hat (s.o.) - ein stärkeres Modell
// bleibt trotzdem die richtige Vorgabe für diese anspruchsvolle
// Bildaufgabe. Konto-Einstellung überschreibt auch diese Vorgabe.
model: model || 'claude-sonnet-5',
// 16000 - dasselbe Sicherheitsnetz, auf denselben Wert angehoben wie bei
// pruefePlanMitChatGPT() oben (Projekthistorie dort), selbes Risiko gilt
// grundsätzlich auch hier (eine zu ausführliche Begründung, die vor
// Abschluss des JSON abgeschnitten wird).
max_tokens: 16000,
temperature: 0,
system: PLAN_CHECK_SYSTEM_PROMPT,
messages: [{ role: 'user', content: [
...bildBloecke,
{ type: 'text', text: baueMerkmalePrompt(merkmale) }
] }],
tools: [{
name: 'plan_pruefung',
description: 'Das Prüfergebnis je Merkmal.',
input_schema: PLAN_CHECK_SCHEMA
}],
tool_choice: { type: 'tool', name: 'plan_pruefung' }
})
});
if(!res.ok){
const errBody = await res.text().catch(() => '');
throw new Error('Claude antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : ''));
}
const data = await res.json();
const toolUse = Array.isArray(data.content) ? data.content.find(b => b.type === 'tool_use') : null;
if(!toolUse || !toolUse.input || !Array.isArray(toolUse.input.ergebnisse)){
throw new Error('Claude-Antwort hatte nicht die erwartete Form.');
}
return toolUse.input.ergebnisse;
}
// pruefePlanMitKI: rendert den Bildsatz (Übersicht + Kachel-Ausschnitte,
// renderPdfAlsBildsatz() weiter oben) jetzt EINMAL, GEMEINSAM für beide
// Anbieter — anders als zuvor, als nur ChatGPT ein eigenes Rendering bekam
// und Claude das PDF direkt (siehe Projekthistorie bei pruefePlanMitClaude()
// oben). Reihenfolge des zurückgegebenen Arrays: Übersicht zuerst, danach
// die Kacheln in Zeilen-/Spaltenreihenfolge — dieselbe Reihenfolge, die
// baueMerkmalePrompt() dem Modell textlich erklärt und die bildIndex im
// Ergebnis referenziert.
//
// Ergänzt NACH der KI-Antwort pro Ergebnis ein "screenshot"-Feld (Data-URL
// oder null) — Eriks Wunsch, im Plan-Checker-Ergebnis zu jedem Merkmal ein
// Bild zu zeigen. Ein erster Versuch (Beschriftungsraster auf der Übersicht
// + nachträglicher Zuschnitt einer von der KI genannten Rasterzelle) erwies
// sich in der Praxis als nicht gut genug ("das mit den Bildausschnitten hat
// nicht gut funktioniert") und wurde durch diese einfachere Variante
// ersetzt: Der Screenshot ist exakt eines der bereits gerenderten Bilder
// (bildsatz.uebersicht oder eine der bildsatz.kacheln), ausgewählt allein
// über den von der KI gelieferten bildIndex — KEIN zweiter Render-Durchlauf
// mehr nötig, die Bilder liegen als Buffer schon vor.
async function pruefePlanMitKI(pdfBuf, merkmale, userId, provider){
const { apiKey, apiUrl, model } = readUserAiKey(userId, provider);
if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.');
if(provider !== 'claude' && provider !== 'chatgpt') throw new Error('Unbekannter Anbieter: ' + provider);
let bildsatz;
try {
bildsatz = await renderPdfAlsBildsatz(pdfBuf);
} catch(err){
console.error('PDF-Rendering fehlgeschlagen:', err);
throw new Error('Die PDF-Datei konnte nicht gelesen werden.');
}
const bilder = [bildsatz.uebersicht, ...bildsatz.kacheln];
const bilderBase64 = bilder.map(b => b.toString('base64'));
const ergebnisse = provider === 'claude'
? await pruefePlanMitClaude(bilderBase64, merkmale, apiKey, apiUrl, model)
: await pruefePlanMitChatGPT(bilderBase64, merkmale, apiKey, apiUrl, model);
// Number.isInteger()-Prüfung statt blindem Vertrauen auf das Schema: ein
// Drittanbieter/lokaler Server hält sich nicht immer an response_format
// (s. Projekthistorie bei pruefePlanMitChatGPT() - derselbe Grund, aus dem
// dort schon Markdown-Codeblöcke toleriert werden). Ein fehlender/
// unbrauchbarer bildIndex liefert einfach keinen Screenshot statt die
// Prüfung scheitern zu lassen.
for(const e of ergebnisse){
const idx = e.bildIndex;
e.screenshot = Number.isInteger(idx) && idx >= 0 && idx < bilder.length
? 'data:image/png;base64,' + bilder[idx].toString('base64')
: null;
}
return ergebnisse;
}
// textZuSicheremHtml: wandelt reinen Klartext (PDF/Word-Extraktion oder
// KI-Erkennung, erkennePunkteAusText()/erkennePunkteMitKI() oben) in
// dasselbe sanitizeStatusHtml()-taugliche HTML, das ein von Hand getippter
// Protokoll-Punkt über sein contenteditable-Feld ohnehin schon erzeugt
// (richtextFeldHtml(), public/js/utils.js) — protokoll_punkte.text gilt seit
// der Umstellung auf contenteditable (Eriks Wunsch nach echter statt mit
// Sternchen markierter Fett-/Kursivschrift) client-seitig immer als
// bereits-sicheres HTML; ohne dieses Escaping würde ein im Originaldokument
// vorkommendes "<"/">" beim Rendern als Markup fehlinterpretiert statt als
// Text angezeigt. Gegenstück zu esc() in public/js/utils.js.
function textZuSicheremHtml(s){
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
.replace(/\r\n|\r|\n/g, '<br>');
}
function legeProtokollAn(opts){
const {
projectNr, projectName, weekLabel, datum, betreff, ort, verfasserUserId,
teilnehmer, zusatzPunkte, quelleDateiname, quelleStoredName
} = opts;
const id = crypto.randomUUID();
const now = new Date().toISOString();
let nummer = 1;
db.exec('BEGIN');
try {
const maxRow = db.prepare('SELECT MAX(nummer) AS m FROM project_protokolle WHERE project_nr = ?').get(projectNr);
nummer = (maxRow && maxRow.m) ? maxRow.m + 1 : 1;
db.prepare(`INSERT INTO project_protokolle
(id, project_nr, project_name, nummer, datum, betreff, ort, verfasser_user_id, week_label,
quelle_dateiname, quelle_stored_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, projectNr, projectName, nummer, String(datum), String(betreff || ''), String(ort || ''),
verfasserUserId, weekLabel, String(quelleDateiname || ''), String(quelleStoredName || ''), now, now);
const insTeilnehmer = db.prepare(`INSERT INTO protokoll_teilnehmer
(id, protokoll_id, name, firma, anwesend, array_order) VALUES (?, ?, ?, ?, ?, ?)`);
(Array.isArray(teilnehmer) ? teilnehmer : []).forEach((t, idx) => {
const name = String((t && t.name) || '').trim();
if(!name) return;
insTeilnehmer.run(crypto.randomUUID(), id, name, String((t && t.firma) || ''), (t && t.anwesend) ? 1 : 0, idx);
});
const insPunkt = db.prepare(`INSERT INTO protokoll_punkte
(id, protokoll_id, stamm_id, nummer, gruppe, neu, thema, text, zustaendig, termin, erledigt, erstellt_am, status, entfaellt_naechstes, array_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
let order = 0;
const prevRow = db.prepare(`SELECT id FROM project_protokolle
WHERE project_nr = ? AND id != ? ORDER BY nummer DESC LIMIT 1`).get(projectNr, id);
if(prevRow){
// Fortschreiben (Eriks Wunsch, Nachtrag zur ursprünglichen Regel "nur
// offen läuft weiter"): 'offen' und 'info' laufen unbegrenzt weiter;
// 'erledigt' bekommt noch GENAU EINE Gnadenrunde — eine Kopie, die
// selbst entfaellt_naechstes=0 trägt (also hier zum ersten Mal als
// erledigt auftaucht oder zwischenzeitlich von Hand wieder auf
// erledigt gesetzt wurde), läuft noch einmal mit; eine Kopie, die
// bereits entfaellt_naechstes=1 trägt (schon einmal fortgeschrieben),
// bleibt hier liegen. Die neue Kopie selbst bekommt entfaellt_naechstes
// NUR gesetzt, wenn ihre Quelle 'erledigt' war — das ist der Hinweis
// "entfällt im nächsten Protokoll" in genau diesem einen Protokoll;
// offen/info-Kopien tragen 0 und laufen so unbegrenzt weiter.
const vorherigePunkte = db.prepare(`SELECT * FROM protokoll_punkte
WHERE protokoll_id = ? ORDER BY array_order`).all(prevRow.id);
const fortzuschreiben = vorherigePunkte.filter(p =>
p.status === 'offen' || p.status === 'info' || (p.status === 'erledigt' && !p.entfaellt_naechstes));
// erstellt_am wandert UNVERÄNDERT mit (wie stamm_id) — ein fortgeschriebener
// Punkt wurde hier nicht neu erstellt, nur übernommen.
fortzuschreiben.forEach(p => {
insPunkt.run(crypto.randomUUID(), id, p.stamm_id, p.nummer, p.gruppe, 0, p.thema, p.text, p.zustaendig, p.termin,
p.status === 'erledigt' ? 1 : 0, p.erstellt_am || '', p.status, p.status === 'erledigt' ? 1 : 0, order++);
});
}
// Zusätzlich erkannte/übergebene Punkte (Import) — immer neu=1, frische
// stamm_id, hinter den fortgeschriebenen Punkten einsortiert. erstellt_am
// ist das Datum DIESES Protokolls — hier entstehen diese Punkte zum
// ersten Mal im System, unabhängig davon, ob das importierte Dokument
// selbst älter ist. status kommt bei der KI-Erkennung direkt vom Modell
// (normalisierePunktAusKi() hat ihn schon gegen VALID_PROTOKOLL_PUNKT_STATUS
// geprüft); die Textmuster-Heuristik (erkennePunkteAusText()) kennt
// weiterhin nur das ältere, boolesche erledigt (kein 'info' — sie liest
// reinen Regex-Textmustern, keine Bedeutung) — dafür hier der Rückfall
// auf das alte Verhalten, falls kein gültiger status mitkommt.
(Array.isArray(zusatzPunkte) ? zusatzPunkte : []).forEach(p => {
const status = VALID_PROTOKOLL_PUNKT_STATUS.has(p.status) ? p.status : (p.erledigt ? 'erledigt' : 'offen');
// entfaellt_naechstes=0: ein importierter/neu erkannter Punkt taucht
// hier zum ersten Mal auf, unabhängig vom erkannten status — selbst
// ein als 'erledigt' erkannter Punkt bekommt dadurch noch seine
// Gnadenrunde im nächsten Protokoll, statt sofort stillschweigend zu
// verschwinden.
insPunkt.run(crypto.randomUUID(), id, crypto.randomUUID(), String(p.nummer || ''), String(p.gruppe || ''), 1,
String(p.thema || ''), textZuSicheremHtml(p.text || ''), String(p.zustaendig || ''), String(p.termin || ''),
status === 'erledigt' ? 1 : 0, String(datum), status, 0, order++);
});
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
throw err;
}
console.log('Protokoll angelegt:', projectNr, '- Nr.', nummer);
return id;
}
app.post('/project-protokolle', requireAuth, requireRecht('proj.protokolle'), (req, res) => {
try {
const projectNr = String(req.query.projectNr || '');
if(!projectNr) return res.status(400).json({ ok: false, error: 'Projekt hat keine Projektnummer.' });
const { datum, betreff, ort, teilnehmer } = req.body || {};
if(!datum) return res.status(400).json({ ok: false, error: 'Datum erforderlich.' });
const id = legeProtokollAn({
projectNr, projectName: String(req.query.projectName || ''), weekLabel: String(req.query.weekLabel || ''),
datum, betreff, ort, verfasserUserId: req.user.id, teilnehmer, zusatzPunkte: []
});
res.json({ ok: true, protokoll: loadProtokollDetail(id) });
} catch(err){
console.error('Protokoll anlegen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: externes Protokoll hochladen und automatisch in Punkte umwandeln.
// Raw-Binär-Body wie POST /project-files. Extrahiert den Text (PDF via
// pdf-parse, Word via mammoth), teilt ihn heuristisch in Punkte auf
// (erkennePunkteAusText()) und legt darüber ein ganz normales Protokoll an
// (legeProtokollAn() — dieselbe Fortschreiben-Logik wie beim manuellen
// Anlegen; die erkannten Punkte kommen zusätzlich dazu, als neu=1). Die
// Original-Datei bleibt unter UPLOADS_DIR gespeichert und über
// GET /project-protokolle/:id/quelldatei abrufbar — Erkennung ist ein
// Entwurf, keine Garantie, siehe CLAUDE.md "### Protokolle".
app.post('/project-protokolle/import', requireAuth, requireRecht('proj.protokolle'), express.raw({ type: '*/*', limit: '25mb' }), async (req, res) => {
try {
const projectNr = String(req.query.projectNr || '');
if(!projectNr) return res.status(400).json({ ok: false, error: 'Projekt hat keine Projektnummer.' });
if(!req.query.datum) return res.status(400).json({ ok: false, error: 'Datum erforderlich.' });
const buf = req.body;
if(!Buffer.isBuffer(buf) || buf.length === 0){
return res.status(400).json({ ok: false, error: 'Keine Datei empfangen.' });
}
let filename = '';
try { filename = decodeURIComponent(String(req.query.filename || '')); }
catch(e){ filename = String(req.query.filename || ''); }
const ext = extractFileExtension(filename);
let text = '';
if(ext !== 'pdf' && ext !== 'docx'){
return res.status(400).json({ ok: false, error: 'Nur PDF- und Word-Dateien (.pdf, .docx) werden unterstützt.' });
}
// Eine beschädigte Datei ist ein Eingabefehler (400), kein Serverfehler.
try {
if(ext === 'pdf') text = await extrahierePdfText(buf);
else text = ((await mammoth.extractRawText({ buffer: buf })).value) || '';
} catch(err){
return res.status(400).json({ ok: false, error: 'Die Datei konnte nicht gelesen werden (beschädigt oder kein echtes ' + ext.toUpperCase() + ').' });
}
// Teilnehmer kommen NUR aus der KI-Erkennung (siehe KI_SYSTEM_PROMPT) —
// erkennePunkteAusText() ist reines Regex und kann keine Teilnehmerliste
// von Fließtext unterscheiden, deshalb bleibt teilnehmerAusDokument beim
// Textmuster-Pfad und bei jedem KI-Fehlschlag (Rückfall) leer; die
// Person trägt sie dann wie schon bisher von Hand in der Detailansicht
// nach. Bewusst KEIN Merge mit dem letzten Protokoll dieses Projekts
// (anders als neuesProtokoll()'s Vorbelegung) — der Import soll genau
// zeigen, was im hochgeladenen Dokument steht, nicht zwei Quellen
// vermischen.
let zusatzPunkte, teilnehmerAusDokument = [], erkennungsart = 'textmuster', erkennungshinweis = '';
const gewuenschterAnbieter = req.query.erkennung;
if(gewuenschterAnbieter === 'chatgpt' || gewuenschterAnbieter === 'claude'){
try {
const ergebnis = await erkennePunkteMitKI(text, req.user.id, gewuenschterAnbieter);
zusatzPunkte = ergebnis.punkte;
teilnehmerAusDokument = ergebnis.teilnehmer;
erkennungsart = gewuenschterAnbieter;
} catch(err){
console.warn('KI-Erkennung fehlgeschlagen, Rückfall auf Textmuster:', err.message);
erkennungshinweis = err.message;
zusatzPunkte = erkennePunkteAusText(text);
}
} else {
zusatzPunkte = erkennePunkteAusText(text);
}
const storedName = crypto.randomUUID() + (ext ? '.' + ext : '');
fs.writeFileSync(path.join(uploadDirFor(), storedName), buf);
const id = legeProtokollAn({
projectNr, projectName: String(req.query.projectName || ''), weekLabel: String(req.query.weekLabel || ''),
datum: req.query.datum, betreff: req.query.betreff, ort: req.query.ort,
verfasserUserId: req.user.id, teilnehmer: teilnehmerAusDokument, zusatzPunkte,
quelleDateiname: filename || storedName, quelleStoredName: storedName
});
console.log('Protokoll importiert:', projectNr, '-', zusatzPunkte.length, 'Punkte,', teilnehmerAusDokument.length, 'Teilnehmer erkannt aus', filename, '(' + erkennungsart + ')');
res.json({ ok: true, protokoll: loadProtokollDetail(id), erkannteAnzahl: zusatzPunkte.length,
erkannteTeilnehmerAnzahl: teilnehmerAusDokument.length, erkennungsart, erkennungshinweis });
} catch(err){
console.error('Protokoll-Import fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Original-Datei eines importierten Protokolls herunterladen —
// Gegenstück zu GET /project-files/:id/download. 404, wenn das Protokoll
// nicht importiert wurde (quelle_stored_name leer, siehe legeProtokollAn()).
app.get('/project-protokolle/:id/quelldatei', requireAuth, (req, res) => {
try {
const row = db.prepare('SELECT quelle_dateiname, quelle_stored_name FROM project_protokolle WHERE id = ?').get(req.params.id);
if(!row || !row.quelle_stored_name){
return res.status(404).json({ ok: false, error: 'Keine Quelldatei vorhanden.' });
}
res.download(path.join(UPLOADS_DIR, row.quelle_stored_name), row.quelle_dateiname || row.quelle_stored_name);
} catch(err){
console.error('Quelldatei-Download fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Protokoll speichern — ersetzt Teilnehmer- und Punkte-Zeilen komplett
// (löschen + neu einfügen, wie deleteWeekData()/serializeWeek()). nummer,
// gruppe, thema, text, zustaendig, termin, erledigt sind reine Anzeige-/
// Bearbeitungsfelder und werden immer vom Client übernommen. stamm_id/neu
// dagegen NIE vom Client vertraut: für einen Punkt mit bekannter id werden
// sie aus der bisherigen Zeile übernommen, ein Punkt ohne id ist neu
// (frische stamm_id, neu=1).
app.put('/project-protokolle/:id', requireAuth, requireRecht('proj.protokolle'), (req, res) => {
try {
const row = db.prepare('SELECT * FROM project_protokolle WHERE id = ?').get(req.params.id);
if(!row) return res.status(404).json({ ok: false, error: 'Protokoll nicht gefunden.' });
const { datum, betreff, ort, teilnehmer, punkte } = req.body || {};
if(!datum) return res.status(400).json({ ok: false, error: 'Datum erforderlich.' });
const now = new Date().toISOString();
db.exec('BEGIN');
try {
db.prepare('UPDATE project_protokolle SET datum = ?, betreff = ?, ort = ?, updated_at = ? WHERE id = ?')
.run(String(datum), String(betreff || ''), String(ort || ''), now, row.id);
db.prepare('DELETE FROM protokoll_teilnehmer WHERE protokoll_id = ?').run(row.id);
const insTeilnehmer = db.prepare(`INSERT INTO protokoll_teilnehmer
(id, protokoll_id, name, firma, anwesend, array_order) VALUES (?, ?, ?, ?, ?, ?)`);
(Array.isArray(teilnehmer) ? teilnehmer : []).forEach((t, idx) => {
const name = String((t && t.name) || '').trim();
if(!name) return;
insTeilnehmer.run(crypto.randomUUID(), row.id, name, String((t && t.firma) || ''), (t && t.anwesend) ? 1 : 0, idx);
});
const bestehende = new Map();
db.prepare('SELECT id, stamm_id, neu, erstellt_am, entfaellt_naechstes FROM protokoll_punkte WHERE protokoll_id = ?').all(row.id)
.forEach(p => bestehende.set(p.id, p));
db.prepare('DELETE FROM protokoll_punkte WHERE protokoll_id = ?').run(row.id);
const insPunkt = db.prepare(`INSERT INTO protokoll_punkte
(id, protokoll_id, stamm_id, nummer, gruppe, neu, thema, text, zustaendig, termin, erledigt, erstellt_am, status, entfaellt_naechstes, array_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
// erstellt_am ist wie stamm_id/neu serverseitig maßgeblich, nie vom
// Client übernommen: ein bekannter Punkt behält sein ursprüngliches
// Datum, ein neu hinzugefügter bekommt das Datum DIESES Protokolls
// (der gerade gespeicherte `datum`-Wert oben) — der Moment, in dem er
// zum ersten Mal gespeichert wird. status wird dagegen VOM CLIENT
// übernommen (offen/erledigt/info, über ein <select> gewählt) — anders
// als neu/stamm_id/erstellt_am ist das kein Herkunfts-, sondern ein
// jederzeit frei änderbares Inhaltsfeld, wie thema oder zustaendig.
// erledigt bleibt als reine Ableitung von status bestehen (siehe
// migrateAddProtokollPunktStatus()). entfaellt_naechstes ist wie
// stamm_id/neu/erstellt_am serverseitig maßgeblich und NIE vom Client
// übernommen — es ist kein frei editierbares Inhaltsfeld, sondern wird
// ausschließlich von legeProtokollAn() beim Fortschreiben gesetzt
// (siehe dort); ein hier bearbeiteter bekannter Punkt behält also
// seinen bisherigen Wert unverändert, ein neuer startet bei 0.
(Array.isArray(punkte) ? punkte : []).forEach((p, idx) => {
const bekannt = p && p.id ? bestehende.get(p.id) : null;
const pid = bekannt ? bekannt.id : crypto.randomUUID();
const stammId = bekannt ? bekannt.stamm_id : crypto.randomUUID();
const neu = bekannt ? bekannt.neu : 1;
const erstelltAm = bekannt ? (bekannt.erstellt_am || '') : String(datum);
const entfaelltNaechstes = bekannt ? bekannt.entfaellt_naechstes : 0;
const status = VALID_PROTOKOLL_PUNKT_STATUS.has(p && p.status) ? p.status : 'offen';
// text trägt wie der Wochen-Kommentar Formatierungs-HTML und wird
// an der Schreibstelle bereinigt (sanitizeFormatHtml()); thema und
// zustaendig sind reine Textfelder und werden beim Anzeigen escaped.
insPunkt.run(pid, row.id, stammId, String((p && p.nummer) || ''), String((p && p.gruppe) || ''), neu,
String((p && p.thema) || ''), sanitizeFormatHtml(p && p.text), String((p && p.zustaendig) || ''),
String((p && p.termin) || ''), status === 'erledigt' ? 1 : 0, erstelltAm, status, entfaelltNaechstes, idx);
});
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
throw err;
}
res.json({ ok: true, protokoll: loadProtokollDetail(row.id) });
} catch(err){
console.error('Protokoll speichern fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Protokoll löschen — Auth genügt (kein admin-only), dieselbe
// Berechtigungsstufe wie das Löschen einer Aufgabe/eines Meilensteins.
// Idempotent wie DELETE /users/:id/absences/:absenceId (kein Fehler, wenn
// bereits gelöscht).
app.delete('/project-protokolle/:id', requireAuth, requireRecht('proj.protokolle'), (req, res) => {
try {
const row = db.prepare('SELECT id, quelle_stored_name FROM project_protokolle WHERE id = ?').get(req.params.id);
if(!row) return res.json({ ok: true });
db.exec('BEGIN');
try {
db.prepare('DELETE FROM protokoll_punkte WHERE protokoll_id = ?').run(row.id);
db.prepare('DELETE FROM protokoll_teilnehmer WHERE protokoll_id = ?').run(row.id);
db.prepare('DELETE FROM project_protokolle WHERE id = ?').run(row.id);
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
throw err;
}
// Die Quelldatei eines importierten Protokolls gleich mit entfernen —
// sonst bliebe sie ohne jeden Verweis in sessions/uploads liegen.
if(row.quelle_stored_name){
fs.unlink(path.join(uploadDirFor(), path.basename(row.quelle_stored_name)), () => {});
}
console.log('Protokoll gelöscht:', row.id);
res.json({ ok: true });
} catch(err){
console.error('Protokoll löschen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Aufgaben-Vorlagen je Leistungsphase (lph_templates) ----------
// VALID_LPHS: gültige Leistungsphasen-Schlüssel (muss zu LPHS in
// public/js/globals.js passen). Dient nur der Eingabeprüfung der
// Vorlagen-Routen — die Fortschrittswerte in project_lph werden bewusst NICHT
// gegen diese Liste geprüft (dort sind historisch gewachsene Schlüssel aus
// Altdaten möglich).
const VALID_LPHS = new Set(['LPH 1','LPH 2','LPH 3','LPH 4','LPH 5','LPH 6','LPH 7','LPH 8','LPH 9','W+M']);
// templateAnteil: Prozentwert eines Vorlagen-Schritts prüfen (Komma oder
// Punkt, 0–100, drei Nachkommastellen). Rückgabe: Zahl oder null (ungültig).
function templateAnteil(v){
if(v === undefined || v === null || v === '') return 0;
const n = Number(String(v).replace(',', '.'));
if(!Number.isFinite(n) || n < 0 || n > 100) return null;
return Math.round(n * 1000) / 1000;
}
// templateAnteilEingabe: Eingabe der Spalte „Anteil %“ — eine Zahl, oder
// „in a)“ / „in a + b“ / „in a u. b“ für einen Schritt, der in anderen
// enthalten ist (Siemon-Tabelle). Rückgabe: {anteil, enthaltenIn} oder null.
function templateAnteilEingabe(v){
const t = String(v === undefined || v === null ? '' : v).trim();
if(/^in\b/i.test(t)){
const buchst = (t.slice(2).match(/[a-z]/gi) || []).map(x => x.toLowerCase())
.filter(x => x !== 'u'); // „u.“ aus „a u. b“
const enthaltenIn = [...new Set(buchst)].join(',');
return enthaltenIn ? { anteil: 0, enthaltenIn } : null;
}
const anteil = templateAnteil(t);
return anteil === null ? null : { anteil, enthaltenIn: '' };
}
// serializeTemplateRow: DB-Zeile -> öffentliche JSON-Form.
function serializeTemplateRow(row){
return {
id: row.id,
lph: row.lph,
gewerk: row.gewerk || '',
anteil: Number(row.anteil) || 0,
enthaltenIn: row.enthalten_in || '',
title: row.title,
offsetDays: row.offset_days,
durationDays: row.duration_days,
sortOrder: row.sort_order,
};
}
// Route: alle Vorlagen lesen (jeder angemeldete Nutzer darf Vorlagen
// einfügen, daher nur requireAuth — nur das Pflegen ist Admin-Sache).
app.get('/lph-templates', requireAuth, (req, res) => {
try {
const rows = db.prepare('SELECT * FROM lph_templates ORDER BY lph, gewerk, sort_order, title').all();
res.json({ ok: true, templates: rows.map(serializeTemplateRow) });
} catch(err){
console.error('Vorlagen laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: neue Vorlagen-Aufgabe anlegen. Admin-only (firmenweite Konfiguration).
app.post('/lph-templates', requireRecht('a.vorlagen'), (req, res) => {
try {
const { lph, gewerk, title, offsetDays, durationDays, anteil } = req.body || {};
if(!VALID_LPHS.has(String(lph))){
return res.status(400).json({ ok: false, error: 'Unbekannte Leistungsphase.' });
}
if(!VALID_DEPARTMENTS.has(String(gewerk))){
return res.status(400).json({ ok: false, error: 'Unbekanntes Gewerk.' });
}
const cleanTitle = String(title || '').trim();
if(!cleanTitle) return res.status(400).json({ ok: false, error: 'Titel fehlt.' });
const offset = Number.isFinite(Number(offsetDays)) ? Math.max(0, Math.trunc(Number(offsetDays))) : 0;
// Dauer 0 = ohne Termin: die Aufgabe wird ohne Start/Ende eingefügt.
const duration = Number.isFinite(Number(durationDays)) ? Math.max(0, Math.trunc(Number(durationDays))) : 1;
// sort_order: ans Ende der Liste dieser Leistungsphase und dieses Gewerks
const maxRow = db.prepare('SELECT MAX(sort_order) AS m FROM lph_templates WHERE lph = ? AND gewerk = ?').get(String(lph), String(gewerk));
const sortOrder = (maxRow && Number.isFinite(maxRow.m) ? maxRow.m : -1) + 1;
const ein = templateAnteilEingabe(anteil);
if(ein === null) return res.status(400).json({ ok: false, error: 'Anteil: eine Zahl von 0 bis 100 oder „in a)“.' });
const id = crypto.randomUUID();
db.prepare(`INSERT INTO lph_templates (id, lph, gewerk, title, offset_days, duration_days, sort_order, anteil, enthalten_in)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, String(lph), String(gewerk), cleanTitle, offset, duration, sortOrder, ein.anteil, ein.enthaltenIn);
res.json({ ok: true, template: serializeTemplateRow({
id, lph: String(lph), gewerk: String(gewerk), title: cleanTitle, offset_days: offset, duration_days: duration, sort_order: sortOrder,
anteil: ein.anteil, enthalten_in: ein.enthaltenIn
})});
} catch(err){
console.error('Vorlage anlegen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Vorlagen-Schritt ändern (Titel, Start-Versatz, Dauer). Admin-only.
// Nur übergebene Felder werden geändert. Bereits eingefügte Aufgaben bleiben
// unberührt (siehe Tabellen-Kommentar bei lph_templates).
app.patch('/lph-templates/:id', requireRecht('a.vorlagen'), (req, res) => {
try {
const row = db.prepare('SELECT * FROM lph_templates WHERE id = ?').get(String(req.params.id));
if(!row) return res.status(404).json({ ok: false, error: 'Schritt nicht gefunden.' });
const b = req.body || {};
let title = row.title, offset = row.offset_days, duration = row.duration_days, anteil = Number(row.anteil) || 0,
enthaltenIn = row.enthalten_in || '';
if(b.anteil !== undefined){
const ein = templateAnteilEingabe(b.anteil);
if(ein === null) return res.status(400).json({ ok: false, error: 'Anteil: eine Zahl von 0 bis 100 oder „in a)“.' });
anteil = ein.anteil; enthaltenIn = ein.enthaltenIn;
}
if(b.title !== undefined){
title = String(b.title || '').trim();
if(!title) return res.status(400).json({ ok: false, error: 'Titel fehlt.' });
}
if(b.offsetDays !== undefined){
if(!Number.isFinite(Number(b.offsetDays))) return res.status(400).json({ ok: false, error: 'Start ungültig.' });
offset = Math.max(0, Math.trunc(Number(b.offsetDays)));
}
if(b.durationDays !== undefined){
if(!Number.isFinite(Number(b.durationDays))) return res.status(400).json({ ok: false, error: 'Dauer ungültig.' });
duration = Math.max(0, Math.trunc(Number(b.durationDays))); // 0 = ohne Termin
}
db.prepare('UPDATE lph_templates SET title = ?, offset_days = ?, duration_days = ?, anteil = ?, enthalten_in = ? WHERE id = ?')
.run(title, offset, duration, anteil, enthaltenIn, row.id);
res.json({ ok: true, template: serializeTemplateRow({ ...row, title, offset_days: offset, duration_days: duration, anteil, enthalten_in: enthaltenIn }) });
} catch(err){
console.error('Vorlage ändern fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Vorlagen-Schritt um eine Stelle nach oben (richtung -1) oder unten
// (+1) verschieben — innerhalb derselben Leistungsphase und desselben
// Gewerks. Nummeriert die Liste dabei lückenlos neu. Admin-only.
app.post('/lph-templates/:id/verschieben', requireRecht('a.vorlagen'), (req, res) => {
try {
const row = db.prepare('SELECT * FROM lph_templates WHERE id = ?').get(String(req.params.id));
if(!row) return res.status(404).json({ ok: false, error: 'Schritt nicht gefunden.' });
const richtung = Number((req.body || {}).richtung) < 0 ? -1 : 1;
const liste = db.prepare('SELECT id FROM lph_templates WHERE lph = ? AND gewerk = ? ORDER BY sort_order, title')
.all(row.lph, row.gewerk).map(r => r.id);
const i = liste.indexOf(row.id), j = i + richtung;
if(j >= 0 && j < liste.length){
[liste[i], liste[j]] = [liste[j], liste[i]];
const upd = db.prepare('UPDATE lph_templates SET sort_order = ? WHERE id = ?');
db.exec('BEGIN');
try { liste.forEach((id, k) => upd.run(k, id)); db.exec('COMMIT'); }
catch(e){ db.exec('ROLLBACK'); throw e; }
}
res.json({ ok: true });
} catch(err){
console.error('Vorlage verschieben fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Vorlagen-Aufgabe löschen. Admin-only. Idempotent (bereits gelöscht
// ist kein Fehler). Bereits eingefügte TODOs bleiben unberührt (siehe
// Tabellen-Kommentar bei lph_templates).
app.delete('/lph-templates/:id', requireRecht('a.vorlagen'), (req, res) => {
try {
db.prepare('DELETE FROM lph_templates WHERE id = ?').run(String(req.params.id));
res.json({ ok: true });
} catch(err){
console.error('Vorlage löschen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Plan-Checker-Merkmale (plan_checker_merkmale) ----------
function serializeMerkmalRow(row){
return { id: row.id, titel: row.titel, prompt: row.prompt, sortOrder: row.sort_order };
}
// Route: alle Merkmale lesen (jeder angemeldete Nutzer braucht die Liste, um
// zu sehen, was geprüft wird — nur das Pflegen ist Admin-Sache).
app.get('/plan-checker-merkmale', requireAuth, (req, res) => {
try {
const rows = db.prepare('SELECT * FROM plan_checker_merkmale ORDER BY sort_order, titel').all();
res.json({ ok: true, merkmale: rows.map(serializeMerkmalRow) });
} catch(err){
console.error('Merkmale laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: neues Merkmal anlegen. Admin-only (firmenweite Konfiguration).
app.post('/plan-checker-merkmale', requireRecht('a.checker'), (req, res) => {
try {
const { titel, prompt } = req.body || {};
const cleanTitel = String(titel || '').trim();
const cleanPrompt = String(prompt || '').trim();
if(!cleanTitel) return res.status(400).json({ ok: false, error: 'Titel fehlt.' });
if(!cleanPrompt) return res.status(400).json({ ok: false, error: 'Prüfanweisung fehlt.' });
const maxRow = db.prepare('SELECT MAX(sort_order) AS m FROM plan_checker_merkmale').get();
const sortOrder = (maxRow && Number.isFinite(maxRow.m) ? maxRow.m : -1) + 1;
const id = crypto.randomUUID();
db.prepare('INSERT INTO plan_checker_merkmale (id, titel, prompt, sort_order) VALUES (?, ?, ?, ?)')
.run(id, cleanTitel, cleanPrompt, sortOrder);
res.json({ ok: true, merkmal: { id, titel: cleanTitel, prompt: cleanPrompt, sortOrder } });
} catch(err){
console.error('Merkmal anlegen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Merkmal löschen. Admin-only. Idempotent.
app.delete('/plan-checker-merkmale/:id', requireRecht('a.checker'), (req, res) => {
try {
db.prepare('DELETE FROM plan_checker_merkmale WHERE id = ?').run(String(req.params.id));
res.json({ ok: true });
} catch(err){
console.error('Merkmal löschen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: einen Plan prüfen. Roh-Binärkörper wie /project-protokolle/import
// (siehe RAW_BODY_ROUTES oben) — provider und ein optionales, nur für DIESE
// Prüfung geltendes Zusatz-Merkmal kommen als Query-Parameter, da der Body
// vollständig die Datei ist. Weder die PDF- noch die daraus gerenderten
// Bilddateien werden gespeichert (siehe Erläuterung bei pruefePlanMitKI()
// oben). Das Rendering zum Bildsatz (Übersicht + Kacheln) passiert NICHT
// hier, sondern innerhalb von pruefePlanMitKI() — für beide Anbieter gleich.
app.post('/plan-checker-pruefen', requireAuth, requireRecht('wz.checker'), express.raw({ type: '*/*', limit: '25mb' }), async (req, res) => {
try {
const buf = req.body;
if(!Buffer.isBuffer(buf) || buf.length === 0){
return res.status(400).json({ ok: false, error: 'Keine Datei empfangen.' });
}
// Einfache Format-Prüfung über die PDF-Magic-Bytes statt einer
// Dateiendung (die Endung kommt hier aus keinem Formularfeld, der Body
// IST die Datei) — ein PDF beginnt immer mit "%PDF".
if(buf.slice(0, 4).toString('latin1') !== '%PDF'){
return res.status(400).json({ ok: false, error: 'Nur PDF-Dateien werden unterstützt.' });
}
const provider = String(req.query.provider || '');
if(provider !== 'chatgpt' && provider !== 'claude'){
return res.status(400).json({ ok: false, error: 'Unbekannter KI-Anbieter.' });
}
const merkmale = db.prepare('SELECT titel, prompt FROM plan_checker_merkmale ORDER BY sort_order, titel').all();
// Projekt-Abgleich (Eriks Wunsch): optional, genau wie das Zusatz-Merkmal
// weiter unten nur für DIESE eine Prüfung und nicht gespeichert - kein
// neues Feld in plan_checker_merkmale, sondern derselbe Mechanismus, nur
// mit einem serverseitig gebauten statt frei getipptem Text. Das Projekt
// selbst kommt aus dem bereits beim Client geladenen db.weeks (die
// Auswahl im Frontend liest currentWeek().projects, kein eigener
// Endpunkt) - der Server bekommt nur Nummer/Bezeichnung als Query-Param,
// prüft sie aber NICHT gegen die Datenbank (reine Texteingabe für den
// Prompt, wie das Zusatz-Merkmal auch). Bewusst nur Projektnummer +
// Bezeichnung: Das sind die einzigen Projekt-Stammdaten, die es im
// Datenmodell überhaupt gibt - ein Plankopf kennt zusätzlich
// Planersteller, Maßstab, Plannummer, Datum, aber dafür gibt es keine
// gespeicherte Vorgabe, gegen die man abgleichen könnte.
const projektNr = String(req.query.projektNr || '').trim();
const projektName = String(req.query.projektName || '').trim();
if(projektNr || projektName){
const soll = [
projektNr ? `Projektnummer "${projektNr}"` : null,
projektName ? `Projektbezeichnung "${projektName}"` : null
].filter(Boolean).join(' und ');
// Erste Fassung fragte direkt "stimmt der Plankopf mit X überein?" -
// ein echter Fehlschlag zeigte, dass das zu leicht als "bestätige X"
// statt "lies ab und vergleiche dann" gelesen wird: Die KI gab exakt
// die vorgegebenen Werte als angeblich abgelesene Werte zurück und
// erklärte sie für übereinstimmend, obwohl im Plankopf tatsächlich eine
// andere Nummer stand (Erik hat das am echten Ergebnis nachgewiesen).
// Jetzt zweistufig erzwungen: ERST ablesen UND DAS GELESENE in der
// Begründung wörtlich nennen, DANN erst vergleichen - macht die
// Vorgabewerte nicht Teil der "Lese"-Aufgabe und macht einen
// Bestätigungsfehler wie diesen für einen Menschen beim Lesen der
// Begründung sofort erkennbar (genau wie Erik ihn gefunden hat).
merkmale.push({
titel: 'Plankopf stimmt mit Projekt überein',
prompt: 'Lies zuerst die im Plankopf (Schriftfeld) TATSÄCHLICH abgedruckte Projektnummer und/oder '
+ 'Projektbezeichnung ab - unabhängig davon, was du gleich vergleichen sollst. Nenne diese tatsächlich '
+ 'abgelesenen Werte als ERSTES in der Begründung, wörtlich wie abgedruckt. Vergleiche ERST DANACH, ob '
+ `diese abgelesenen Werte zu ${soll} passen. Sinngemäße Übereinstimmung genügt (Abkürzungen, leicht `
+ 'abweichende Schreibweise, zusätzliche Wörter), eine erkennbar ANDERE Nummer oder ein erkennbar anderes '
+ 'Projekt dagegen nicht. "erfuellt" ist nur dann true, wenn die TATSÄCHLICH abgelesenen Werte passen - '
+ 'nicht schon deshalb, weil das erwartete Projekt plausibel klingt. Ist die Angabe im Plankopf nicht '
+ 'lesbar, sage das, statt die erwarteten Werte zu wiederholen.'
});
}
const zusatz = String(req.query.zusatzMerkmal || '').trim();
if(zusatz) merkmale.push({ titel: zusatz, prompt: zusatz });
if(merkmale.length === 0){
return res.status(400).json({ ok: false, error: 'Keine Merkmale zum Prüfen hinterlegt.' });
}
try {
const ergebnisse = await pruefePlanMitKI(buf, merkmale, req.user.id, provider);
res.json({ ok: true, ergebnisse });
} catch(err){
// Ein fehlgeschlagener KI-Aufruf (kein Schlüssel, Netzwerk, Zeitüber-
// schreitung, unerwartete Antwort) ist ein gültiges, anzuzeigendes
// Ergebnis — kein Serverfehler (gleiches Muster wie POST
// /my-ai-settings/test).
res.json({ ok: false, error: String(err && err.message || err) });
}
} catch(err){
console.error('Plan-Prüfung fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Honorar-Konfiguration (Anteile je LPH + Stundensatz) ----------
// ---------- Firmenweite Einstellungen (app_settings) ----------
//
// APP_SETTING_DEFS ist die EINE Stelle, an der ein Schlüssel definiert wird:
// Vorgabewert, Typ und Prüfung. readAppSettings() liefert daraus stets ein
// vollständiges Objekt (fehlende oder unbrauchbare Zeilen fallen auf die
// Vorgabe zurück), PUT /app-settings schreibt nur bekannte Schlüssel.
//
// Alle Werte hier waren vorher Konstanten im Code — teils im Server
// (Backups, Sitzungsdauer, Anmeldebremse), teils im Frontend (Aufwands-
// vorlage, Ampel-Schwellen, Balkenskala, Arbeitsstunden je Tag). Sie sind
// nicht deshalb hierher gewandert, weil sie sich oft ändern, sondern weil
// sie die Zahlen ALLER Nutzer verschieben und ihre Pflege damit eine
// Administrationsaufgabe ist, kein Eingriff in den Quelltext.
// DEFAULT_AUFWAND_VORLAGE: firmenweite Gewichtung je Leistungsphase und
// Rolle (0,0–1,0). Sie greift für jedes Projekt, dessen Aufwandsmaske für
// diese Phase/Rolle keinen eigenen Wert trägt — also für die meisten. LPH 9
// steht bewusst auf 0: die Faktoren sind fachlich nicht abgestimmt, und ein
// erfundener Wert flösse unbemerkt in die Auslastung ein.
const DEFAULT_AUFWAND_VORLAGE = {
'LPH 1': { 'Planung': 0.4, 'Projektleitung': 0.2, 'Bauüberwachung': 0 },
'LPH 2': { 'Planung': 0.4, 'Projektleitung': 0.2, 'Bauüberwachung': 0 },
'LPH 3': { 'Planung': 1.0, 'Projektleitung': 0.3, 'Bauüberwachung': 0 },
'LPH 4': { 'Planung': 0.0, 'Projektleitung': 0.3, 'Bauüberwachung': 0 },
'LPH 5': { 'Planung': 1.0, 'Projektleitung': 0.4, 'Bauüberwachung': 0 },
'LPH 6': { 'Planung': 0.2, 'Projektleitung': 0.7, 'Bauüberwachung': 0.3 },
'LPH 7': { 'Planung': 0.1, 'Projektleitung': 0.3, 'Bauüberwachung': 0.5 },
'LPH 8': { 'Planung': 0, 'Projektleitung': 0.2, 'Bauüberwachung': 1.0 },
'LPH 9': { 'Planung': 0, 'Projektleitung': 0, 'Bauüberwachung': 0 }
};
// num / int: Prüf-Helfer für APP_SETTING_DEFS. Beide liefern null, wenn der
// Wert unbrauchbar ist — readAppSettings() nimmt dann die Vorgabe, und
// PUT /app-settings antwortet mit 400.
function settingNumber(min, max){
return v => {
const n = Number(v);
return Number.isFinite(n) && n >= min && n <= max ? n : null;
};
}
function settingInt(min, max){
return v => {
const n = Number(v);
return Number.isInteger(n) && n >= min && n <= max ? n : null;
};
}
const APP_SETTING_DEFS = {
// Honorar/Terminplan
stundensatz: { def: 135, parse: settingNumber(1, 100000),
label: 'Stundensatz (€)' },
arbeitsstundenProTag: { def: 8, parse: settingNumber(1, 24),
label: 'Arbeitsstunden je Person und Werktag' },
// Auslastung
vollzeitStundenProWoche:{ def: 40, parse: settingNumber(1, 80),
label: 'Wochenstunden einer vollen Stelle' },
auslastungSchwelleGelb: { def: 0.75, parse: settingNumber(0.05, 5),
label: 'Ampel gelb ab' },
auslastungSchwelleRot: { def: 1.0, parse: settingNumber(0.05, 5),
label: 'Ampel rot ab' },
aufwandVorlage: { def: DEFAULT_AUFWAND_VORLAGE, json: true, parse: parseAufwandVorlage,
label: 'Aufwand je Leistungsphase und Rolle' },
// Die Auslastung rechnet in STUNDEN: offener Honorarwert der amtierenden
// Phase geteilt durch den Stundensatz, verteilt auf die verbleibenden
// Wochen. Projekte ohne hinterlegtes Honorar — im Bestand die Mehrheit —
// brauchen dafür einen Ersatz: Ihre Größe wird als Zahl zwischen 0 und 1
// am Projekt gepflegt und mit diesem Referenzhonorar multipliziert. 1,0
// entspricht also einem Projekt in Höhe des Referenzwerts.
referenzHonorar: { def: 250000, parse: settingNumber(1000, 100000000),
label: 'Referenzhonorar für die Projektgröße' },
// Verteilzeitraum: Über wie viele Wochen die offenen Stunden verteilt
// werden, solange das Projekt keinen Terminplan hat, aus dem sich ein
// Ende ergibt. Ohne einen solchen Horizont wäre die Wochenlast nicht
// bestimmt — dieselben Reststunden bedeuten über vier Wochen etwas ganz
// anderes als über vierzig.
verteilzeitraumWochen: { def: 12, parse: settingInt(1, 260),
label: 'Verteilzeitraum ohne Terminplan (Wochen)' },
// Sitzungen
autoLockAktiv: { def: 1, parse: settingInt(0, 1),
label: 'Ältere Sitzungen automatisch sperren' },
// Feiertage: das Bundesland bestimmt, welche gesetzlichen Feiertage
// vorgeschlagen werden (holidaysForYear()). Gemerkt, damit es nicht bei
// jedem Jahr neu gewählt werden muss.
feiertagBundesland: { def: 'NW', parse: parseBundesland,
label: 'Bundesland für die Feiertage' },
// Betrieb
maxBackups: { def: DEFAULT_MAX_BACKUPS, parse: settingInt(1, 500),
label: 'Aufbewahrte automatische Backups' },
sessionTage: { def: DEFAULT_SESSION_TAGE, parse: settingInt(1, 365),
label: 'Gültigkeit einer Anmeldung (Tage)' },
loginMaxVersuche: { def: DEFAULT_LOGIN_MAX_ATTEMPTS, parse: settingInt(1, 100),
label: 'Fehlversuche bis zur Anmeldesperre' },
loginSperreMinuten: { def: DEFAULT_LOGIN_BLOCK_MINUTEN, parse: settingInt(1, 1440),
label: 'Dauer der Anmeldesperre (Minuten)' },
};
// parseAufwandVorlage: prüft die Aufwands-Matrix. Erlaubt sind nur bekannte
// Leistungsphasen und Rollen, Werte von 0 bis 1. Nicht genannte Phasen oder
// Rollen fehlen anschließend schlicht — das Frontend behandelt eine fehlende
// Angabe als Faktor 1,0 (voller Aufwand), damit Auslastung nicht
// stillschweigend verschwindet. Rückgabe: geprüftes Objekt oder null.
function parseAufwandVorlage(value){
let obj = value;
if(typeof obj === 'string'){
try { obj = JSON.parse(obj); } catch(e){ return null; }
}
if(!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;
const out = {};
for(const [lph, roles] of Object.entries(obj)){
if(!VALID_LPHS.has(lph)) return null;
if(!roles || typeof roles !== 'object' || Array.isArray(roles)) return null;
const cleanRoles = {};
for(const [role, v] of Object.entries(roles)){
if(!VALID_DISCIPLINE_ROLES.has(role)) return null;
const n = Number(v);
if(!Number.isFinite(n) || n < 0 || n > 1) return null;
cleanRoles[role] = n;
}
out[lph] = cleanRoles;
}
return out;
}
// parseBundesland: prüft ein Bundesland-Kürzel gegen GERMAN_STATES (weiter
// unten definiert; die Prüfung läuft erst zur Anfragezeit).
// Rückgabe: Kürzel oder null.
function parseBundesland(value){
const v = String(value || '').toUpperCase();
return GERMAN_STATES[v] ? v : null;
}
// readAppSettings: liefert ALLE firmenweiten Einstellungen als fertiges
// Objekt. Eine fehlende Zeile ist der Normalfall (die Vorgabe gilt); eine
// unbrauchbare Zeile — etwa aus einem älteren Stand — fällt ebenfalls auf die
// Vorgabe zurück, statt die Rechnung zu vergiften.
// Keine Parameter. Rückgabe: Objekt über alle Schlüssel von APP_SETTING_DEFS.
function readAppSettings(){
const rows = db.prepare('SELECT key, value FROM app_settings').all();
const stored = {};
rows.forEach(r => { stored[r.key] = r.value; });
const out = {};
for(const [key, def] of Object.entries(APP_SETTING_DEFS)){
if(!Object.prototype.hasOwnProperty.call(stored, key)){ out[key] = def.def; continue; }
const parsed = def.parse(stored[key]);
out[key] = parsed === null ? def.def : parsed;
}
return out;
}
// readAppSetting: ein einzelner Wert (Kurzform für die Aufrufer im Server).
// Parameter: key. Rückgabe: geprüfter Wert bzw. Vorgabe.
function readAppSetting(key){
const def = APP_SETTING_DEFS[key];
if(!def) return undefined;
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key);
if(!row) return def.def;
const parsed = def.parse(row.value);
return parsed === null ? def.def : parsed;
}
// Route: alle firmenweiten Einstellungen lesen. Auth, nicht admin-only — das
// Frontend RECHNET damit (Auslastung, Terminplan) und braucht sie deshalb bei
// jedem Konto. Geändert werden dürfen sie nur von Administratoren, siehe PUT.
app.get('/app-settings', requireAuthOderKiosk, (req, res) => {
try {
res.json({ ok: true, settings: readAppSettings() });
} catch(err){
console.error('Einstellungen laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// GET/PUT /my-ai-settings: die beiden KI-API-Schlüssel des ANGEMELDETEN
// Kontos (req.user.id, nie eine vom Client mitgeschickte Kennung — exakt
// wie /change-email/POST). Jeder Nutzer verwaltet seinen eigenen Zugang
// unter Einstellungen → Mein Konto; kein admin-only mehr, siehe CLAUDE.md
// "### Protokolle". GET liefert je Anbieter, OB ein Schlüssel gesetzt ist
// (nie der Schlüssel selbst, wie ein Passwort) sowie die — nicht geheime —
// apiUrl zum Weiterbearbeiten.
// ---------- Kalender-Abo für Outlook (iCalendar) ----------
//
// Eriks Wunsch: die eigenen Termine in Outlook sehen. Jedes Konto bekommt
// unter Einstellungen → Mein Konto eine geheime Adresse
// /kalender/<token>.ics; Outlook abonniert sie („Aus dem Internet“) und holt
// sie selbst regelmäßig. NUR LESEN — geändert wird weiter in der App.
// Inhalt aus der jüngsten echten Sitzung: eigene offene Aufgaben mit Termin,
// Meilensteine der eigenen Projekte (zugeordnet oder mit eigener Aufgabe)
// und die eigenen Abwesenheiten. Ganztägige Einträge (VALUE=DATE), damit
// keine Zeitzone im Spiel ist.
//
// Der Token IST die Anmeldung (Outlook kann sich nicht einloggen) — deshalb
// lang und zufällig, jederzeit neu erzeugbar (alte Adresse ungültig) oder
// abschaltbar. Deaktivierte Konten und reine Wandtafel-Konten bekommen 404.
function kalenderUrl(req, token){
return req.protocol + '://' + req.get('host') + '/kalender/' + token + '.ics';
}
app.get('/mein-kalender', requireAuth, (req, res) => {
const row = db.prepare('SELECT kalender_token FROM users WHERE id = ?').get(req.user.id);
const token = row && row.kalender_token;
res.json({ ok: true, url: token ? kalenderUrl(req, token) : '' });
});
// POST {aus: true} schaltet ab, sonst wird eine NEUE Adresse erzeugt (die
// bisherige wird damit ungültig).
app.post('/mein-kalender', requireAuth, (req, res) => {
try {
const aus = !!(req.body && req.body.aus);
const token = aus ? '' : crypto.randomBytes(24).toString('base64url');
db.prepare('UPDATE users SET kalender_token = ? WHERE id = ?').run(token, req.user.id);
res.json({ ok: true, url: token ? kalenderUrl(req, token) : '' });
} catch(err){
console.error('Kalender-Adresse speichern fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// icsText: Text nach RFC 5545 maskieren.
function icsText(s){
return String(s || '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
}
// icsZeile: Zeilen über 75 Byte falten (Folgezeilen beginnen mit Leerzeichen).
function icsZeile(z){
if(Buffer.byteLength(z, 'utf8') <= 75) return z;
const teile = [];
let rest = z;
// Erste Zeile höchstens 75 Byte, Folgezeilen 74 (plus führendes Leerzeichen).
const grenze = () => teile.length ? 74 : 75;
while(Buffer.byteLength(rest, 'utf8') > grenze()){
let n = Math.min(rest.length, grenze());
while(Buffer.byteLength(rest.slice(0, n), 'utf8') > grenze()) n--;
// Kein Ersatzpaar (Emoji o. ä.) zerschneiden.
const c = rest.charCodeAt(n - 1);
if(c >= 0xD800 && c <= 0xDBFF) n--;
teile.push(rest.slice(0, n));
rest = rest.slice(n);
}
teile.push(rest);
return teile.join('\r\n ');
}
function icsDatum(iso){ return String(iso || '').replace(/-/g, ''); }
// icsTagDanach: DTEND ganztägiger Einträge ist der Tag NACH dem letzten Tag.
function icsTagDanach(iso){
const d = new Date(iso + 'T12:00:00');
d.setDate(d.getDate() + 1);
return d.getFullYear() + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
}
const ICS_DATUM = /^\d{4}-\d{2}-\d{2}$/;
// kalenderEreignisse: die Einträge einer Person aus einer Sitzung plus ihren
// Abwesenheiten. Rein (ohne Datenbank), damit testbar. Parameter: ich
// (person_id), w (Sitzung oder null), abwesenheiten (Zeilen aus
// user_absences). Rückgabe: [{uid, start, ende, titel, projekt, text, frei}].
function kalenderEreignisse(ich, w, abwesenheiten){
const ereignisse = [];
if(w && ich){
(w.projects || []).forEach(p => {
const projekt = [p.nr, p.name].filter(Boolean).join(' ');
const zugeordnet = Object.keys(p.assign || {}).some(k => k.split('|')[0] === ich && Number(p.assign[k]) > 0);
const plan = p.plan || {};
let eigeneAufgabe = false;
(plan.todos || []).forEach(t => {
if(!Array.isArray(t.assignees) || !t.assignees.includes(ich)) return;
eigeneAufgabe = true;
if(t.done || t.lph) return;
const start = ICS_DATUM.test(t.start || '') ? t.start : (ICS_DATUM.test(t.end || '') ? t.end : '');
const ende = ICS_DATUM.test(t.end || '') ? t.end : start;
if(!start) return;
ereignisse.push({
uid: 'aufgabe-' + t.id, start, ende,
titel: t.title || 'Aufgabe', projekt,
text: 'Aufgabe in ' + projekt + (Number(t.progress) > 0 ? ' · ' + Number(t.progress) + ' % erledigt' : '')
+ (t.notiz ? '\n\n' + String(t.notiz).slice(0, 1500) : '')
});
});
if(!zugeordnet && !eigeneAufgabe) return;
(plan.milestones || []).forEach(m => {
if(!ICS_DATUM.test(m.date || '')) return;
ereignisse.push({
uid: 'meilenstein-' + (p.projectUid || p.id) + '-' + m.id, start: m.date, ende: m.date,
titel: '◆ ' + (m.name || 'Meilenstein'), projekt,
text: 'Meilenstein in ' + projekt + (m.notiz ? '\n\n' + String(m.notiz).slice(0, 1500) : '')
});
});
});
}
(abwesenheiten || []).forEach(a => {
if(!ICS_DATUM.test(a.start || '')) return;
ereignisse.push({
uid: 'abwesenheit-' + a.id, start: a.start, ende: ICS_DATUM.test(a.end || '') ? a.end : a.start,
titel: a.type || 'Abwesend', projekt: '', text: a.note || '', frei: true
});
});
return ereignisse;
}
// kalenderText: die iCalendar-Datei (CRLF, gefaltete Zeilen).
function kalenderText(name, ereignisse){
const jetzt = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
const zeilen = [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Projektuebersicht//Kalender//DE', 'CALSCALE:GREGORIAN',
'METHOD:PUBLISH', 'X-WR-CALNAME:' + icsText('Projektübersicht – ' + (name || '')),
'X-WR-TIMEZONE:Europe/Berlin', 'REFRESH-INTERVAL;VALUE=DURATION:PT1H', 'X-PUBLISHED-TTL:PT1H'
];
ereignisse.forEach(e => {
zeilen.push('BEGIN:VEVENT',
'UID:' + icsText(e.uid) + '@projektuebersicht',
'DTSTAMP:' + jetzt,
'DTSTART;VALUE=DATE:' + icsDatum(e.start),
'DTEND;VALUE=DATE:' + icsTagDanach(e.ende < e.start ? e.start : e.ende),
'SUMMARY:' + icsText(e.titel + (e.projekt ? ' (' + e.projekt + ')' : '')),
'DESCRIPTION:' + icsText(e.text),
// Als „frei“ eintragen: Ganztägige Aufgaben sollen die Verfügbarkeit
// in Outlook nicht blockieren; Abwesenheiten zeigen „abwesend“.
'TRANSP:TRANSPARENT',
e.frei ? 'X-MICROSOFT-CDO-BUSYSTATUS:OOF' : 'X-MICROSOFT-CDO-BUSYSTATUS:FREE',
'END:VEVENT');
});
zeilen.push('END:VCALENDAR');
return zeilen.map(icsZeile).join('\r\n') + '\r\n';
}
app.get('/kalender/:datei', (req, res) => {
try {
const token = String(req.params.datei || '').replace(/\.ics$/i, '');
if(token.length < 20) return res.status(404).end();
const u = db.prepare('SELECT id, display_name, person_id, role, active FROM users WHERE kalender_token = ?').get(token);
if(!u || !u.active || !hatRecht({ id: u.id, role: u.role }, 'app')) return res.status(404).end();
const label = echteSitzungenSortiert().map(w => w.label).pop();
const w = label ? loadWeeks(label)[0] : null;
const abw = db.prepare('SELECT id, start, end, type, note FROM user_absences WHERE user_id = ?').all(u.id);
res.set('Content-Type', 'text/calendar; charset=utf-8');
res.set('Cache-Control', 'no-cache');
res.set('Content-Disposition', 'inline; filename="projektuebersicht.ics"');
res.send(kalenderText(u.display_name, kalenderEreignisse(u.person_id || '', w, abw)));
} catch(err){
console.error('Kalender ausliefern fehlgeschlagen:', err);
res.status(500).end();
}
});
app.get('/my-ai-settings', requireAuth, (req, res) => {
try {
const chatgpt = readUserAiKey(req.user.id, 'chatgpt');
const claude = readUserAiKey(req.user.id, 'claude');
res.json({
ok: true,
chatgpt: { configured: !!chatgpt.apiKey, apiUrl: chatgpt.apiUrl, model: chatgpt.model },
claude: { configured: !!claude.apiKey, apiUrl: claude.apiUrl, model: claude.model }
});
} catch(err){
console.error('KI-Einstellungen laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
app.put('/my-ai-settings', requireAuth, (req, res) => {
try {
const body = (req.body && typeof req.body === 'object') ? req.body : {};
for(const provider of ['chatgpt', 'claude']){
const patch = body[provider];
if(!patch || typeof patch !== 'object') continue;
if(Object.prototype.hasOwnProperty.call(patch, 'apiUrl') && patch.apiUrl && !istGueltigeAiUrl(patch.apiUrl)){
return res.status(400).json({ ok: false, error: 'Die API-URL muss eine gültige https-Adresse sein (oder http auf localhost/127.0.0.1 für einen lokalen Server wie LM Studio/Ollama).' });
}
writeUserAiKey(req.user.id, provider, patch);
}
const chatgpt = readUserAiKey(req.user.id, 'chatgpt');
const claude = readUserAiKey(req.user.id, 'claude');
res.json({
ok: true,
chatgpt: { configured: !!chatgpt.apiKey, apiUrl: chatgpt.apiUrl, model: chatgpt.model },
claude: { configured: !!claude.apiKey, apiUrl: claude.apiUrl, model: claude.model }
});
} catch(err){
console.error('KI-Einstellungen speichern fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// POST /my-ai-settings/test: "Neu verbinden" — prüft die GESPEICHERTE
// Konfiguration des aufrufenden Kontos für einen Anbieter, indem
// tatsächlich eine kurze, echte Anfrage über erkennePunkteMitKI() läuft
// (dieselbe Funktion, die auch der Protokoll-Import nutzt — deckt damit
// automatisch Timeout-Wahl, reasoning_effort-Behandlung für lokale Ziele
// und URL-Normalisierung mit ab, ohne eigenen Testpfad pflegen zu müssen).
app.post('/my-ai-settings/test', requireAuth, async (req, res) => {
try {
const provider = req.body && req.body.provider;
if(provider !== 'chatgpt' && provider !== 'claude'){
return res.status(400).json({ ok: false, error: 'Unbekannter Anbieter.' });
}
await erkennePunkteMitKI('1. Testpunkt. Zustaendig: Test. Termin: 01.01.2026', req.user.id, provider);
res.json({ ok: true });
} catch(err){
// Ein fehlgeschlagener Verbindungstest ist ein normales, erwartetes
// Ergebnis, kein Serverfehler — deshalb 200 mit ok:false statt 4xx/5xx,
// der Client unterscheidet Erfolg/Fehlschlag einheitlich über "ok".
res.json({ ok: false, error: err.message || String(err) });
}
});
// Route: firmenweite Einstellungen schreiben. Admin-only. Teilweise Updates
// (nur die im Rumpf genannten Schlüssel werden geschrieben); unbekannte
// Schlüssel und unbrauchbare Werte führen zu 400, bevor irgendetwas
// geschrieben wird — sonst bliebe die Hälfte einer Maske liegen.
// APP_SETTING_RECHT: welches Recht eine Einstellung ändern darf.
const APP_SETTING_RECHT = {
stundensatz: 'a.honorar',
arbeitsstundenProTag: 'a.termine', autoLockAktiv: 'a.termine',
vollzeitStundenProWoche: 'a.auslastung', auslastungSchwelleGelb: 'a.auslastung', auslastungSchwelleRot: 'a.auslastung',
aufwandVorlage: 'a.auslastung', referenzHonorar: 'a.auslastung', verteilzeitraumWochen: 'a.auslastung',
feiertagBundesland: 'a.feiertage',
maxBackups: 'a.betrieb', sessionTage: 'a.betrieb', loginMaxVersuche: 'a.betrieb', loginSperreMinuten: 'a.betrieb',
};
app.put('/app-settings', requireAuth, (req, res) => {
try {
const body = (req.body && typeof req.body === 'object') ? req.body : {};
const writes = [];
for(const [key, value] of Object.entries(body)){
const def = APP_SETTING_DEFS[key];
if(!def) return res.status(400).json({ ok:false, error: 'Unbekannte Einstellung: ' + key });
// Jede Einstellung gehört zu einem Admin-Abschnitt und damit zu dessen
// Recht (Rollen & Rechte) — geprüft je Feld, bevor irgendetwas schreibt.
const recht = APP_SETTING_RECHT[key] || 'a.betrieb';
if(!hatRecht(req.user, recht)) return rechtFehlt(res, recht);
const parsed = def.parse(value);
if(parsed === null) return res.status(400).json({ ok:false, error: 'Unbrauchbarer Wert für „' + def.label + '".' });
writes.push([key, def.json ? JSON.stringify(parsed) : String(parsed)]);
}
// Gelb darf nicht über Rot liegen — sonst zeigte die Ampel nie Gelb.
const merged = Object.assign(readAppSettings(), Object.fromEntries(
writes.map(([k, v]) => [k, APP_SETTING_DEFS[k].parse(v)])
));
if(merged.auslastungSchwelleGelb > merged.auslastungSchwelleRot){
return res.status(400).json({ ok:false, error: 'Die gelbe Schwelle muss unter der roten liegen.' });
}
db.exec('BEGIN');
try {
const stmt = db.prepare(`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`);
writes.forEach(([k, v]) => stmt.run(k, v));
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
throw err;
}
res.json({ ok: true, settings: readAppSettings() });
} catch(err){
console.error('Einstellungen speichern fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Feiertage ----------
// Gesetzliche Feiertage lassen sich vollstaendig BERECHNEN: Ein Teil liegt auf
// festen Kalendertagen, der Rest haengt am Osterdatum. Deshalb holt diese
// Anwendung sie nicht von einem Netzdienst — das haette eine Abhaengigkeit
// von fremder Verfuegbarkeit und einem Internetzugang des Servers bedeutet,
// den es im Buero-Netz nicht zwingend gibt. Berechnet ist das Ergebnis
// jederzeit gleich, funktioniert offline und braucht kein weiteres Paket
// (das Projekt kommt bewusst mit express und cors aus).
//
// Die errechneten Tage werden NICHT automatisch in die Tabelle geschrieben.
// Ein Administrator laesst sie sich vorschlagen und uebernimmt sie bewusst —
// Feiertage verschieben jeden gerechneten Terminplan, das soll nicht
// unbemerkt im Hintergrund passieren.
// GERMAN_STATES: die 16 Bundeslaender mit ihrem amtlichen Kuerzel.
const GERMAN_STATES = {
BW: 'Baden-Württemberg', BY: 'Bayern',
BE: 'Berlin', BB: 'Brandenburg',
HB: 'Bremen', HH: 'Hamburg',
HE: 'Hessen', MV: 'Mecklenburg-Vorpommern',
NI: 'Niedersachsen', NW: 'Nordrhein-Westfalen',
RP: 'Rheinland-Pfalz', SL: 'Saarland',
SN: 'Sachsen', ST: 'Sachsen-Anhalt',
SH: 'Schleswig-Holstein', TH: 'Thüringen',
};
// easterSunday: Ostersonntag eines Jahres nach der gregorianischen
// Osterformel (Gauss/Meeus). Alle beweglichen Feiertage haengen daran.
// Parameter: year (Zahl). Rueckgabe: Date (UTC-Mitternacht).
function easterSunday(year){
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(Date.UTC(year, month - 1, day));
}
// isoFromDate / shiftDays: kleine Datums-Helfer fuer die Berechnung. Gerechnet
// wird durchgehend in UTC, damit keine Sommerzeit-Umstellung einen Tag
// verschiebt.
function isoFromDate(d){ return d.toISOString().slice(0, 10); }
function shiftDays(d, n){ return new Date(d.getTime() + n * 86400000); }
// bussUndBettag: Mittwoch vor dem 23. November (nur Sachsen).
// Parameter: year. Rueckgabe: Date.
function bussUndBettag(year){
let d = new Date(Date.UTC(year, 10, 22)); // 22.11.
while(d.getUTCDay() !== 3) d = shiftDays(d, -1);
return d;
}
// holidaysForYear: die gesetzlichen Feiertage eines Jahres in einem
// Bundesland. Nicht enthalten sind die gemeindeabhaengigen Faelle
// (Fronleichnam in Teilen von Sachsen und Thueringen, Mariä Himmelfahrt in
// ueberwiegend katholischen Gemeinden Bayerns) — die haengen vom Wohnort ab
// und lassen sich nicht aus dem Bundesland ableiten; sie koennen von Hand
// ergaenzt werden.
// Parameter: year (Zahl), state (Kuerzel aus GERMAN_STATES).
// Rueckgabe: Array von { date, name }, nach Datum sortiert.
function holidaysForYear(year, state){
const ostern = easterSunday(year);
const fest = (m, d) => isoFromDate(new Date(Date.UTC(year, m - 1, d)));
const ab = n => isoFromDate(shiftDays(ostern, n));
// Jeder Eintrag: [Datum, Bezeichnung, Bundeslaender oder null = bundesweit]
const alle = [
[fest(1, 1), 'Neujahr', null],
[fest(1, 6), 'Heilige Drei Könige', ['BW','BY','ST']],
[fest(3, 8), 'Internationaler Frauentag', ['BE','MV']],
[ab(-2), 'Karfreitag', null],
[ab(0), 'Ostersonntag', ['BB']],
[ab(1), 'Ostermontag', null],
[fest(5, 1), 'Tag der Arbeit', null],
[ab(39), 'Christi Himmelfahrt', null],
[ab(49), 'Pfingstsonntag', ['BB']],
[ab(50), 'Pfingstmontag', null],
[ab(60), 'Fronleichnam', ['BW','BY','HE','NW','RP','SL']],
[fest(8, 15), 'Mariä Himmelfahrt', ['SL']],
[fest(9, 20), 'Weltkindertag', ['TH']],
[fest(10, 3), 'Tag der Deutschen Einheit', null],
[fest(10, 31), 'Reformationstag', ['BB','HB','HH','MV','NI','SH','SN','ST','TH']],
[fest(11, 1), 'Allerheiligen', ['BW','BY','NW','RP','SL']],
[isoFromDate(bussUndBettag(year)), 'Buß- und Bettag', ['SN']],
[fest(12, 25), '1. Weihnachtstag', null],
[fest(12, 26), '2. Weihnachtstag', null],
];
return alle
.filter(([, , laender]) => laender === null || laender.includes(state))
.map(([date, name]) => ({ date, name }))
.sort((a, b) => a.date.localeCompare(b.date));
}
// Route: gesetzliche Feiertage eines Jahres vorschlagen. Auth (lesend, die
// Berechnung verraet nichts). Jeder Eintrag traegt zusaetzlich `vorhanden`,
// damit die Maske zeigen kann, was schon hinterlegt ist.
app.get('/holidays/suggest', requireAuth, (req, res) => {
try {
const year = Number(req.query.year);
const state = String(req.query.state || readAppSetting('feiertagBundesland'));
if(!Number.isInteger(year) || year < 1970 || year > 2200){
return res.status(400).json({ ok:false, error:'Jahr muss zwischen 1970 und 2200 liegen.' });
}
if(!GERMAN_STATES[state]){
return res.status(400).json({ ok:false, error:'Unbekanntes Bundesland: ' + state });
}
const vorhanden = new Set(db.prepare('SELECT date FROM holidays').all().map(r => r.date));
const holidays = holidaysForYear(year, state).map(h => ({
date: h.date, name: h.name, vorhanden: vorhanden.has(h.date),
}));
res.json({ ok:true, year, state, stateName: GERMAN_STATES[state], holidays, states: GERMAN_STATES });
} catch(err){
console.error('Feiertage berechnen fehlgeschlagen:', err);
res.status(500).json({ ok:false, error: String(err) });
}
});
// Route: berechnete Feiertage uebernehmen. Admin-only. Bereits hinterlegte
// Tage bleiben unveraendert — ein von Hand angepasster Name (etwa ein
// Brueckentag, den jemand auf denselben Tag gelegt hat) soll nicht
// ueberschrieben werden. Die Antwort meldet beides getrennt.
app.post('/holidays/import', requireRecht('a.feiertage'), (req, res) => {
try {
const year = Number((req.body || {}).year);
const state = String((req.body || {}).state || readAppSetting('feiertagBundesland'));
if(!Number.isInteger(year) || year < 1970 || year > 2200){
return res.status(400).json({ ok:false, error:'Jahr muss zwischen 1970 und 2200 liegen.' });
}
if(!GERMAN_STATES[state]){
return res.status(400).json({ ok:false, error:'Unbekanntes Bundesland: ' + state });
}
const liste = holidaysForYear(year, state);
const stmt = db.prepare('INSERT OR IGNORE INTO holidays (date, name) VALUES (?, ?)');
let angelegt = 0;
db.exec('BEGIN');
try {
liste.forEach(h => { angelegt += stmt.run(h.date, h.name).changes; });
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
throw err;
}
res.json({ ok:true, angelegt, uebersprungen: liste.length - angelegt, gesamt: liste.length });
} catch(err){
console.error('Feiertage übernehmen fehlgeschlagen:', err);
res.status(500).json({ ok:false, error: String(err) });
}
});
// Route: Feiertage lesen. Auth — das Frontend rechnet damit in Werktagen.
app.get('/holidays', requireAuthOderKiosk, (req, res) => {
try {
const rows = db.prepare('SELECT date, name FROM holidays ORDER BY date').all();
// Die Bundesländer kommen gleich mit: Der Server ist die eine Stelle, an
// der die Liste steht, und die Pflege-Maske braucht sie sofort — sonst
// hätte ihre Auswahl bis zum ersten Vorschlag nur einen Eintrag.
res.json({
ok: true,
holidays: rows.map(r => ({ date: r.date, name: r.name })),
states: GERMAN_STATES,
});
} catch(err){
console.error('Feiertage laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Feiertag anlegen oder umbenennen. Admin-only. Das Datum ist der
// Primärschlüssel, ein zweites Anlegen desselben Tages ersetzt also nur den
// Namen — das ist gewollt und erspart ein eigenes PATCH.
app.post('/holidays', requireRecht('a.feiertage'), (req, res) => {
try {
const { date, name } = req.body || {};
if(typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)){
return res.status(400).json({ ok:false, error:'Datum im Format JJJJ-MM-TT erforderlich.' });
}
const d = new Date(date + 'T00:00:00Z');
if(Number.isNaN(d.getTime()) || d.toISOString().slice(0,10) !== date){
return res.status(400).json({ ok:false, error:'Kein gültiges Datum.' });
}
const cleanName = String(name || '').trim().slice(0, 120);
db.prepare(`INSERT INTO holidays (date, name) VALUES (?, ?)
ON CONFLICT(date) DO UPDATE SET name = excluded.name`).run(date, cleanName);
res.json({ ok: true, holiday: { date, name: cleanName } });
} catch(err){
console.error('Feiertag anlegen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Feiertag löschen. Admin-only, idempotent.
app.delete('/holidays/:date', requireRecht('a.feiertage'), (req, res) => {
try {
db.prepare('DELETE FROM holidays WHERE date = ?').run(String(req.params.date));
res.json({ ok: true });
} catch(err){
console.error('Feiertag löschen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// readStundensatz: liest den firmenweiten Stundensatz (Vorgabe und Prüfung
// stehen in APP_SETTING_DEFS).
function readStundensatz(){
// Ein Schlüssel, eine Definition: der Stundensatz steht seit der
// Einführung von APP_SETTING_DEFS dort (mit derselben Vorgabe 135) und
// wird weiterhin über /honorar-config gepflegt, weil er fachlich zur
// Honorar-Maske gehört.
return readAppSetting('stundensatz');
}
// Route: Honorar-Konfiguration lesen — Anteile je Leistungsphase und
// Stundensatz. Auth (nicht admin-only): jeder angemeldete Nutzer darf einen
// Terminplan rechnen lassen, nur das Pflegen der Sätze ist Admin-Sache.
app.get('/honorar-config', requireAuthOderKiosk, (req, res) => {
try {
const rows = db.prepare('SELECT lph, percent FROM lph_honorar_anteile').all();
const anteile = {};
rows.forEach(r => { anteile[r.lph] = r.percent; });
res.json({ ok: true, anteile, stundensatz: readStundensatz() });
} catch(err){
console.error('Honorar-Konfiguration laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Honorar-Konfiguration schreiben. Admin-only (firmenweite Vorgabe).
// `anteile` ist ein vollständiger Ersatz (delete + insert in einer
// Transaktion, gleiches Muster wie die Rollen in PATCH /admin/users/:id):
// eine im Objekt fehlende Leistungsphase gilt damit als "kein Anteil".
app.put('/honorar-config', requireRecht('a.honorar'), (req, res) => {
try {
const { anteile, stundensatz } = req.body || {};
if(anteile !== undefined && (typeof anteile !== 'object' || anteile === null)){
return res.status(400).json({ ok: false, error: 'anteile muss ein Objekt sein.' });
}
if(stundensatz !== undefined){
const v = Number(stundensatz);
if(!Number.isFinite(v) || v <= 0){
return res.status(400).json({ ok: false, error: 'Stundensatz muss größer als 0 sein.' });
}
}
// Vor dem Schreiben prüfen, damit eine einzelne unbrauchbare Zeile nicht
// die halbe Konfiguration überschreibt.
const clean = [];
if(anteile){
for(const [lph, pct] of Object.entries(anteile)){
if(!VALID_LPHS.has(lph)){
return res.status(400).json({ ok: false, error: 'Unbekannte Leistungsphase: ' + lph });
}
const v = Number(pct);
if(!Number.isFinite(v) || v < 0 || v > 100){
return res.status(400).json({ ok: false, error: 'Anteil für ' + lph + ' muss zwischen 0 und 100 liegen.' });
}
if(v > 0) clean.push([lph, v]);
}
}
db.exec('BEGIN');
try {
if(anteile){
db.prepare('DELETE FROM lph_honorar_anteile').run();
const ins = db.prepare('INSERT INTO lph_honorar_anteile (lph, percent) VALUES (?, ?)');
clean.forEach(([lph, v]) => ins.run(lph, v));
}
if(stundensatz !== undefined){
db.prepare(`INSERT INTO app_settings (key, value) VALUES ('stundensatz', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(Number(stundensatz)));
}
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
throw err;
}
const rows = db.prepare('SELECT lph, percent FROM lph_honorar_anteile').all();
const out = {};
rows.forEach(r => { out[r.lph] = r.percent; });
res.json({ ok: true, anteile: out, stundensatz: readStundensatz() });
} catch(err){
console.error('Honorar-Konfiguration speichern fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: DB-Snapshot-Download. Erzeugt einen konsistenten Snapshot der
// SQLite-Datenbank (auch mit WAL) und stellt ihn als .db-Datei zum Download
// bereit (Backup für Archivierung/Wiederherstellung, z. B. per "DB Backup"-
// Button im Menü). Die temporäre Datei wird nach dem Senden gelöscht.
// NUR ADMINS: Der Snapshot enthält die komplette Datenbank und damit auch
// user_sessions.token im Klartext. Mit einem fremden Token könnte sich sonst
// jedes Mitglied als Administrator ausgeben (Rechteausweitung) — deshalb ist
// diese Route strenger abgesichert als der Rest der fachlichen Daten-Routen.
app.get('/backup', requireRecht('a.backup'), (req, res) => {
const tmp = path.join(os.tmpdir(), 'projektuebersicht_backup_' + Date.now() + '.db');
try {
createDbSnapshot(tmp);
// heuteIsoLokal() statt toISOString(): Letzteres rechnet nach UTC um und
// datierte das Backup zwischen 0 und 2 Uhr Ortszeit auf den Vortag.
const filename = 'projektuebersicht_backup_' + heuteIsoLokal() + '.db';
res.download(tmp, filename, () => fs.unlink(tmp, () => {}));
console.log('DB-Backup-Download:', filename);
} catch(err){
console.error('DB-Backup fehlgeschlagen:', err);
fs.unlink(tmp, () => {});
if(!res.headersSent) res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: DB-Import (Backup-Wiederherstellung). Erwartet eine .db-Datei aus
// GET /backup bzw. "DB Backup" als rohen Binär-Body (application/octet-stream);
// validiert das Schema und ersetzt den gesamten Datenbestand. VOR dem Import
// wird der Zustand vor dem Import als frisches Backup gesichert, damit ein
// versehentlicher Restore rückgängig gemacht werden kann.
app.post('/import', requireRecht('a.import'), express.raw({ type: '*/*', limit: '50mb' }), (req, res) => {
const buf = req.body;
if(!Buffer.isBuffer(buf) || buf.length === 0){
return res.status(400).json({ ok: false, error: 'Keine Datei empfangen' });
}
const tmp = path.join(os.tmpdir(), 'projektuebersicht_import_' + Date.now() + '.db');
try {
fs.writeFileSync(tmp, buf);
try { pruefeBackupDatei(tmp); }
catch(err){ return res.status(400).json({ ok: false, error: err.message }); }
// Zustand VOR dem Restore sichern (danach wäre der Snapshot bereits der
// importierte Stand und die Wiederherstellung unmöglich).
runAutoBackup();
restoreDbFromBackup(tmp);
// Ältere Backups tragen Kommentare noch gewerkübergreifend in projects.
migrateKommentareJeGewerk();
kommentareVorsatzUmstellen();
// Ältere Backups kennen das Feld leistungsphase noch nicht.
phasenbalkenAusTitel();
console.log('Datenbank aus Backup wiederhergestellt (' + buf.length + ' Bytes)');
ereignisSenden({ typ: 'neu' }, req);
return res.json({ ok: true });
} catch(err){
console.error('Datenbank-Import fehlgeschlagen:', err);
if(!res.headersSent) res.status(500).json({ ok: false, error: String(err && err.message ? err.message : err) });
} finally {
fs.unlink(tmp, () => {});
}
});
// Route: Healthcheck-Endpunkt für Docker/Überwachung
app.get('/health', (req,res)=> res.json({
ok: true,
speicherMb: Math.round(process.memoryUsage().rss / 1048576),
laufzeitMin: Math.round(process.uptime() / 60)
}));
// Route: Liefert die Hauptseite App.html
app.get('/', (req,res)=>{
appSenden(req, res);
});
// Gültige Tab-Slugs für Deep-Link-Routen (muss zu TAB_SLUGS in js/globals.js passen)
// 'Projekte' bleibt bewusst gültig, obwohl der gleichnamige Bereich in der
// Projektliste aufgegangen ist: Weitergegebene Links sollen nicht mit 404
// enden. Die Oberfläche leitet den Abschnitt auf die Projektliste um
// (TAB_BY_SLUG in public/js/globals.js) und erzeugt ihn selbst nicht mehr.
const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Abwesenheiten','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']);
// Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen)
const PLAN_SLUG = 'Projektplan';
// Route: alte Direktlinks auf die entfallene Datei admin-users.html. Sie war
// bis zum Umbau eine eigene Seite mit nachgebauter Kopfzeile; die
// Benutzerverwaltung ist heute ein Abschnitt des Bereichs „Admin" auf der
// Hauptseite, damit die Seitenleiste stehen bleibt.
app.get('/admin-users.html', (req, res) => {
res.redirect(301, '/Admin#benutzer');
});
// Route: alte Direktlinks auf die entfallene Datei project-plan.html. Sie
// war bis zum Umbau ein eigenes Dokument; heute ist der Projektbereich der
// Bereich #plan der Hauptseite. Die Parameter (weekLabel, projNr, projName)
// bleiben erhalten, damit ein alter Link weiterhin dasselbe Projekt öffnet.
app.get('/project-plan.html', (req, res) => {
const params = new URLSearchParams(req.query);
const projName = params.get('projName') || '';
params.delete('projName');
const qs = params.toString();
res.redirect(301, '/' + PLAN_SLUG + '/' + encodeURIComponent(projName) + (qs ? '?' + qs : ''));
});
// Route: alte Direktlinks auf den früheren Dateinamen Test.html — ein nie
// umbenannter Platzhaltername aus der Frühphase des Projekts, inzwischen
// App.html. Anders als bei admin-users.html/project-plan.html gibt es kein
// anderes Ziel als "/" selbst: Test.html WAR die Hauptseite, nur unter einem
// unpassenden Namen.
app.get('/Test.html', (req, res) => {
res.redirect(301, '/');
});
// Route: Deep-Links je Tab, z. B. "/Auslastung" oder "/Projektübersicht"
// (seit Entfernung der Abteilungstrennung nur noch ein Pfad-Segment, vorher
// "/<Abteilung>/<Tab>"). Liefert wie "/" die Hauptseite; unbekannte Tabs
// werden abgelehnt (404), damit keine App.html auf beliebigen Pfaden
// ausgeliefert wird.
app.get('/:tab', (req, res) => {
const { tab } = req.params;
if(tab === PLAN_SLUG){
// Projektbereich eines Projekts, z. B. "/Projektplan?weekLabel=..." —
// seit dem Umbau kein eigenes Dokument mehr, sondern der Bereich #plan
// der Hauptseite (applyUrlRoute() in public/js/ui.js wertet die Adresse
// clientseitig aus).
return appSenden(req, res);
}
if(!VALID_TAB_SLUGS.has(tab)) return res.status(404).send('Nicht gefunden');
appSenden(req, res);
});
// Route: Projektbereich mit Projektname im Pfad, z. B.
// "/Projektplan/Gymnasium%20Hemer?weekLabel=..." – das Pfad-Segment dient nur
// der lesbaren Adresse, die Identifikation erfolgt über weekLabel/projNr.
app.get('/Projektplan/:projName', (req, res) => {
appSenden(req, res);
});
// Route: Weiterleitung alter, noch abteilungspräfixierter Projektplan-Links
// ("/HLS/Projektplan/Foo?weekLabel=...") auf das neue Schema, damit alte
// Lesezeichen nicht brechen. Muss VOR der generischen 2-Segment-Weiterleitung
// stehen (3 Segmente sind eindeutig).
app.get('/:legacyDept/Projektplan/:projName', (req, res) => {
if(!VALID_DEPARTMENTS.has(req.params.legacyDept)) return res.status(404).send('Nicht gefunden');
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
res.redirect(301, '/Projektplan/' + encodeURIComponent(req.params.projName) + qs);
});
// Route: Weiterleitung alter zweisegmentiger Deep-Links ("/ELT/Auslastung")
// auf das neue einsegmentige Schema. Muss NACH "/Projektplan/:projName"
// stehen, sonst würde z.B. "/Projektplan/Foo" hier fälschlich als
// Abteilung="Projektplan" interpretiert.
app.get('/:legacyDept/:tab', (req, res) => {
if(!VALID_DEPARTMENTS.has(req.params.legacyDept)) return res.status(404).send('Nicht gefunden');
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
res.redirect(301, '/' + encodeURIComponent(req.params.tab) + qs);
});
// Fehler aus Body-Parsern (z. B. Datei über dem Limit) als lesbares JSON statt
// der HTML-Fehlerseite von Express.
app.use((err, req, res, next) => {
if(res.headersSent) return next(err);
if(err && (err.type === 'entity.too.large' || err.status === 413)){
const mb = err.limit ? Math.round(err.limit / 1048576) : null;
return res.status(413).json({ ok: false, error: 'Datei zu groß' + (mb ? ' (höchstens ' + mb + ' MB).' : '.') });
}
console.error('Unbehandelter Fehler in', req.method, req.path, err);
res.status(err && err.status || 500).json({ ok: false, error: 'Interner Fehler' });
});
// Serverstart mit Fehlerbehandlung (z.B. Port bereits belegt)
const server = app.listen(PORT, () => {
console.log(`Projektübersicht server running on http://localhost:${PORT}`);
// Automatisches Datenbank-Backup beim Start (sessions/backups/)
runAutoBackup();
// Automatischer Sitzungswechsel: einmal jetzt (holt einen Wochenwechsel
// nach, der while der Server nicht lief passiert ist) und danach stündlich
// erneut — mehr als genug Auflösung für einen Wechsel, der genau einmal
// pro Woche eintritt. Muss NACH allen anderen Definitionen dieser Datei
// stehen (braucht u. a. APP_SETTING_DEFS/readAppSettings(), die erst
// weiter unten im Datei-Durchlauf definiert werden) — deshalb hier im
// app.listen()-Rückruf statt in der früheren Migrations-/Bootstrap-
// Reihenfolge weiter oben.
ensureCurrentWeekRollover();
// Konten, die nach dem Entstehen der aktuellen Sitzung angelegt wurden,
// in deren Teilnehmerkreis nachtragen (siehe kontenInAktuelleSitzung()).
kontenInAktuelleSitzung();
setInterval(ensureCurrentWeekRollover, 60 * 60 * 1000);
// Papierkorb gelöschter Projekte: Einträge nach 30 Tagen entfernen.
setInterval(purgeGeloeschteProjekte, 60 * 60 * 1000);
purgeBenachrichtigungen();
setInterval(purgeBenachrichtigungen, 60 * 60 * 1000);
// Betrieb auf dem Pi: Login-Sperrliste und WAL-Datei klein halten,
// Speicher und Hänger überwachen (siehe betriebsWaechterStarten()).
setInterval(betriebAufraeumen, 60 * 60 * 1000);
betriebsWaechterStarten();
});
// betriebAufraeumen: abgelaufene Login-Sperren entfernen (die Liste wuchs
// sonst mit jeder Fehleingabe) und die WAL-Datei zurück in die Datenbank
// schreiben, damit sie auf der SD-Karte nicht wächst.
function betriebAufraeumen(){
try {
const grenze = Date.now() - loginBlockMs();
loginAttempts.forEach((e, k) => { if(e.first < grenze) loginAttempts.delete(k); });
// Merkliste der last_seen_at-Schreibzeiten (attachUser) nicht endlos
// wachsen lassen: Einträge älter als eine Stunde braucht niemand mehr.
const stundeHer = Date.now() - 60 * 60 * 1000;
sitzungZuletztGeschrieben.forEach((t, k) => { if(t < stundeHer) sitzungZuletztGeschrieben.delete(k); });
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
} catch(err){ console.error('Aufräumen fehlgeschlagen:', err.message); }
}
// betriebsWaechterStarten: zwei Wächter für den unbeaufsichtigten Betrieb.
// (1) SPEICHER: Über SPEICHER_MAX_MB (Umgebungsvariable, im Docker-Container
// gesetzt; ohne sie aus) beendet sich der Server sauber mit Code 1 —
// Docker (restart: always) startet ihn mit frischem Speicher neu, bevor
// der Kernel den Container hart abschießt.
// (2) HÄNGER: Ein Worker-Thread prüft den Herzschlag des Haupt-Threads. Steht
// der Event-Loop länger als HAENGER_MAX_S (Vorgabe 90 s), beendet der
// Worker den Prozess hart. Docker startet "unhealthy" Container NICHT
// neu — ohne diesen Wächter bliebe ein hängender Server hängen.
function betriebsWaechterStarten(){
const maxMb = Number(process.env.SPEICHER_MAX_MB) || 0;
if(maxMb > 0){
setInterval(() => {
const mb = process.memoryUsage().rss / 1048576;
if(mb > maxMb){
console.error('Speicher-Wächter: ' + Math.round(mb) + ' MB > ' + maxMb + ' MB — Neustart.');
shutdown('Speicher-Wächter', 1);
}
}, 30 * 1000).unref();
}
const haengerS = Number(process.env.HAENGER_MAX_S) || 90;
const { Worker } = require('worker_threads');
const herz = new Float64Array(new SharedArrayBuffer(8));
herz[0] = Date.now();
setInterval(() => { herz[0] = Date.now(); }, 5000).unref();
const w = new Worker(`
const { workerData } = require('worker_threads');
const herz = new Float64Array(workerData.puffer);
setInterval(() => {
if(Date.now() - herz[0] > workerData.maxMs){
// console im Worker läuft über den (hängenden) Haupt-Thread — direkt schreiben.
require('fs').writeSync(2, 'Hänger-Wächter: Server reagiert seit ' + Math.round((Date.now() - herz[0]) / 1000) + ' s nicht — Neustart.\\n');
process.kill(process.pid, 'SIGKILL');
}
}, 5000);
`, { eval: true, workerData: { puffer: herz.buffer, maxMs: haengerS * 1000 }, resourceLimits: { maxOldGenerationSizeMb: 8 } });
w.unref();
w.on('error', err => console.error('Hänger-Wächter ausgefallen:', err.message));
}
// Unerwartete Fehler: protokollieren und mit Code 1 beenden — ein Prozess in
// unbekanntem Zustand soll nicht weiterlaufen, Docker startet ihn neu.
// Abgelehnte Promises betreffen dagegen fast immer nur eine Anfrage: nur
// protokollieren (Node beendete sonst den ganzen Server).
process.on('uncaughtException', (err) => {
console.error('Unerwarteter Fehler — Server wird neu gestartet:', err);
shutdown('uncaughtException', 1);
});
process.on('unhandledRejection', (grund) => {
console.error('Nicht abgefangene Promise-Ablehnung:', grund);
});
server.on('error', (err) => {
console.error('Server konnte nicht starten:', err.message);
if(err.code === 'EADDRINUSE'){
console.error(`Port ${PORT} ist bereits belegt. Bitte einen anderen PORT verwenden, z.B. PORT=3004 npm start`);
}
process.exit(1);
});
// Sauberes Beenden auf Strg+C/Cmd+C (SIGINT) oder `kill` (SIGTERM): schließt
// zuerst den HTTP-Server (keine neuen Verbindungen mehr, laufende werden zu
// Ende bedient) und danach die Datenbank (sauberer WAL-Checkpoint statt
// eines abrupten Abbruchs mitten im Schreiben). Ohne diesen Handler würde
// Node ebenfalls beenden — kein anderer Code hier überschreibt das
// Standardverhalten —, aber lautlos und ohne den Checkpoint.
let beendetBereits = false;
function shutdown(signal, code = 0){
if(beendetBereits) return;
beendetBereits = true;
console.log(`\n${signal} empfangen, Server wird beendet…`);
// Sicherheitsnetz: eine offene Keep-Alive-Verbindung kann server.close()
// unbegrenzt blockieren (bekannte Node-Eigenheit) — nach 3s notfalls hart
// beenden, damit der Prozess in jedem Fall nur bis zum Tastendruck läuft.
const notbremse = setTimeout(() => process.exit(code), 3000);
notbremse.unref();
// Offene Live-Verbindungen (GET /ereignisse) hielten server.close() sonst
// bis zur Notbremse auf.
if(typeof server.closeAllConnections === 'function') server.closeIdleConnections();
try { ereignisKunden.forEach(k => { try { k.res.end(); } catch(e){} }); } catch(e){}
server.close(() => {
try { db.close(); } catch(e){ /* bereits geschlossen */ }
console.log('Server beendet.');
process.exit(code);
});
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));