Files
projektuebersicht/server.js
T
erikandClaude Opus 5 93505e0e48 Beteiligte in die Projekteinstellungen, TODOs aus Protokoll, Bewegung
Beteiligten-Zuordnung verlagert: p.assign ist jetzt ein Stammdatum und
nur noch in den Projekteinstellungen editierbar (planCycleAssign()).
Wochenbericht und Projektboard zeigen sie nur noch an, mit Verweis
dorthin (springZuEinstellungen()). cycleAssign() ist damit entfallen.

Neu "TODOs aus Protokoll": ein externes Besprechungsprotokoll als PDF
wird per KI eingelesen und daraus in einem zweiten KI-Schritt Aufgaben
abgeleitet. Eigener Bereich in der Aufgaben-/GANTT-Leiste, bewusst
unabhaengig von den gespeicherten Protokollen - nichts davon wird
abgelegt. Drei Schritte in einem Dialog mit fester Fusszeile; die
Vorschlagsliste als kompakte, aufklappbare Zeilen, weil ein echter
Import leicht 20 Vorschlaege liefert.

Sichtbarkeit je Aufgabe im GANTT: neue Spalte plan_todos.show_in_gantt
(Vorgabe 1, idempotente Migration) und eine Checkbox im Aufgaben-Dialog.
Eine terminierte Aufgabe kann damit gezielt aus dem Terminplan
herausgehalten werden, bleibt sonst aber eine vollwertige Aufgabe.
plan-build-gantt.js und plan-drag.js pruefen dieselbe Bedingung, damit
Zeilenzahl und Umsortierung nicht auseinanderlaufen.

KI-Timeout dritte Stufe: ein abweichender, aber entfernter Endpunkt
(Drittanbieter-Proxy) bekommt 2 Minuten statt der 45 Sekunden der
echten Anbieter-API. Gemessen: die Punkte-Erkennung eines realen
Protokolls brauchte dort 39 Sekunden, lag also stets knapp unter dem
alten Limit und brach bei jeder Schwankung ab.

Bewegung: Hover- und Klick-Rueckmeldung als Token-Satz in :root,
angewandt auf die anklickbaren Zeilen, Schalter und Dialoge. Die
Akzentleiste ist ein inset-Schatten statt eines positionierten
Elements, damit Drag-and-Drop unberuehrt bleibt; prefers-reduced-motion
war bisher gar nicht vorhanden und kippt jetzt einfach die Tokens.

Plan Checker: optionaler Projektabgleich des Plankopfs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-17 15:14:26 +02:00

6591 lines
333 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');
let pdfjsLibPromise = null;
function ladePdfjs(){
if(!pdfjsLibPromise) pdfjsLibPromise = import('pdfjs-dist/legacy/build/pdf.mjs');
return pdfjsLibPromise;
}
async function extrahierePdfText(buf){
const pdfjsLib = await ladePdfjs();
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf) });
try {
const doc = await loadingTask.promise;
let text = '';
for(let i = 1; i <= doc.numPages; i++){
const page = await doc.getPage(i);
const content = await page.getTextContent();
// Textfragmente stehen ohne Zeilenumbrüche in content.items — erkennePunkteAusText()
// braucht aber echte Zeilen (Punkt-Marker müssen am Zeilenanfang stehen). Ein
// Umbruch wird eingefügt, sobald sich die Y-Koordinate (transform[5]) ändert,
// exakt wie es das vormals genutzte pdf-parse intern selbst tat.
let lastY = null;
content.items.forEach(item => {
if(lastY !== null && item.transform[5] !== lastY) text += '\n';
else if(lastY !== null) text += ' ';
text += item.str;
lastY = item.transform[5];
});
text += '\n\n';
}
return text;
} finally {
loadingTask.destroy();
}
}
// ladeCanvas/renderPdfAlsBildsatz: fürs "Plan Checker"-Feature weiter unten
// (POST /plan-checker-pruefen, aufgerufen aus pruefePlanMitKI()) — rendert
// ausschließlich die ERSTE Seite eines PDFs zu einem Bildsatz (Übersicht +
// hochaufgelöste Kachel-Ausschnitte, s. renderPdfAlsBildsatz() dort), für
// BEIDE Anbieter gleich (siehe Projekthistorie bei pruefePlanMitClaude()
// unten — ein einzelnes, von uns oder vom Anbieter selbst erzeugtes
// Gesamtbild der Seite erwies sich für feine Maßzahlen/Plankopf-Text
// wiederholt als zu grob). Ein Planblatt pro Prüfung ist der Normalfall;
// mehrseitige PDFs mit mehreren Planblättern sind vorerst nicht vorgesehen.
let canvasLibPromise = null;
function ladeCanvas(){
if(!canvasLibPromise) canvasLibPromise = import('@napi-rs/canvas');
return canvasLibPromise;
}
// PDFJS_STANDARD_FONTS: Pfad zu den von pdfjs-dist mitgelieferten Standard-
// Schriftdaten (Foxit-Ersatzschriften für Helvetica/Times/Symbol/...), mit
// abschließendem Trenner wie von pdf.js intern per String-Konkatenation
// erwartet (`${standardFontDataUrl}${dateiname}`). OHNE diesen Parameter
// wirft pdf.js beim Rendern pro fehlender Schriftart die Warnung
// "UnknownErrorException: Ensure that the standardFontDataUrl API parameter
// is provided" und kann Zeichen nicht auf Glyphen abbilden — ein einfacher
// Testtext (reine Helvetica-Buchstaben) rendert trotzdem noch halbwegs über
// einen groben Ersatz, aber bei echten CAD-/Architektur-Export-PDFs
// (Revit/AutoCAD, oft mit eigens eingebetteten oder nicht eingebetteten
// Spezialschriften für Maßzahlen und Plankopf-Text) blieben genau diese
// Textstellen dadurch leer oder unleserlich — die Maßketten-LINIEN kamen an
// (reine Vektorgrafik, schriftunabhängig), die Zahlen und der Plankopf-Text
// nicht. Nur für renderPdfAlsBildsatz() relevant: extrahierePdfText()
// liest ausschließlich getTextContent() (die im PDF gespeicherten
// Zeichencodes), ohne je eine Glyphe zu rendern, und ist von fehlenden
// Schriftdaten nicht betroffen.
const PDFJS_STANDARD_FONTS = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts') + path.sep;
// PDFJS_WASM: Pfad zu den von pdfjs-dist mitgelieferten WASM-Modulen
// (u. a. jbig2.wasm, openjpeg.wasm), im selben Muster wie PDFJS_STANDARD_FONTS
// oben. OHNE diesen Parameter wirft pdf.js beim Laden eines WASM-Decoders
// "UnknownErrorException: Ensure that the wasmUrl API parameter is provided"
// und versucht dann, JBIG2-kodierte Bildinhalte (verbreitetes Kompressions-
// format für eingebettete Scan-/Rasterbilder, z. B. ein eingescanntes Logo im
// Plankopf) über einen Fallback-Pfad zu laden, der mangels `wasmUrl` zu einem
// kaputten Modulnamen zusammengesetzt wird ("nulljbig2_nowasm_fallback.js")
// und mit ERR_MODULE_NOT_FOUND scheitert — der betroffene Bildinhalt fehlt
// dadurch im gerenderten PNG, genau wie zuvor die Ebenen/Schriften.
const PDFJS_WASM = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'wasm') + path.sep;
// PLAN_CHECK_KACHEL_*: Rastergröße und Überlappung für renderPdfAlsBildsatz()
// unten. 2x2 (vier Kacheln) mit 12% Überlappung je Rand - genug
// Sicherheitsrand, falls ein Feld (z. B. der Plankopf) genau auf einer
// Kachelgrenze liegt, ohne die Zahl der pro Prüfung gesendeten Bilder
// (= Kosten/Dauer je Anbieter-Anfrage) unnötig in die Höhe zu treiben.
const PLAN_CHECK_KACHEL_SPALTEN = 2;
const PLAN_CHECK_KACHEL_ZEILEN = 2;
const PLAN_CHECK_KACHEL_UEBERLAPPUNG = 0.12;
const PLAN_CHECK_UEBERSICHT_BREITE = 2000;
const PLAN_CHECK_KACHEL_BREITE = 2200;
// renderPdfAlsBildsatz: löst die vorherige renderPdfErstePageAlsPng() ab, die
// die ganze Planseite als EIN einzelnes PNG rendert hat. Grund für die
// Umstellung: Nach den vorherigen Fixes (Ebenen/Schrift/WASM, s.o.) meldete
// Erik einen neuen, grundsätzlicheren Befund - der Plankopf wurde zwar jetzt
// gefunden und der Plantitel korrekt gelesen, aber Projektname,
// Planersteller, Maßstab, Datum und Plannummer blieben "in der vorliegenden
// Auflösung" nicht sämtlich lesbar. Das bestätigt in letzter Konsequenz den
// Verdacht, der schon zur vorherigen PDF-Direktübergabe an Claude geführt
// hatte (s. historischer Kommentar bei pruefePlanMitClaude() weiter unten):
// JEDER Anbieter skaliert ein übermitteltes Bild bzw. eine PDF-Seite intern
// auf eine eigene Obergrenze herunter, BEVOR das Modell draufschaut (bei
// Claude laut Dokumentation ca. 1568px lange Kante, bei OpenAI vergleichbar).
// Ein Plankopf ist nur ein kleiner Ausschnitt eines großen Planblatts - seine
// Feinschrift liegt nach dieser anbieterseitigen Herunterskalierung weit
// unter Lesbarkeit, UNABHÄNGIG davon, wie hoch WIR die Ausgangsauflösung
// wählen (mehr Pixel in der Quelle ändern nichts, wenn der Anbieter ohnehin
// wieder auf dieselbe Obergrenze herunterrechnet). Claudes PDF-Direktübergabe
// half laut Erik auch nicht durchgreifend genug - vermutlich, weil Anthropics
// eigene Dokumenten-Verarbeitung intern denselben Weg geht (PDF-Seite zu
// einem einzigen Bild, dann dieselbe Auflösungsgrenze).
//
// Der einzige verlässliche Ausweg: der KI ZUSÄTZLICH zur Übersicht mehrere,
// überlappende Ausschnitte der Seite in höherer Pixel-Dichte mitgeben, sodass
// Feinschrift gleich welcher Lage auf dem Blatt in mindestens einem Bild
// unterhalb der anbieterseitigen Herunterskalierungs-Schwelle bleibt - OHNE
// dafür die Lage des Plankopfs erkennen zu müssen (die variiert je
// Büro-Vorlage: beim getesteten Plan oben rechts, DIN-Konvention wäre unten
// rechts). Jede Kachel wird NICHT aus einem fertigen Raster-Bild
// zugeschnitten (das brächte keine zusätzliche Schärfe, nur eine vergrößerte
// Unschärfe), sondern pdf.js erneut mit einer höheren scale-Vorgabe gerendert
// und per Context-Transform (ctx.translate) auf den gewünschten Ausschnitt
// verschoben - der Ausschnitt entsteht also selbst aus dem Vektor-Original in
// höherer Auflösung, nicht durch Hochskalieren eines bereits gerenderten
// Bilds. Gilt jetzt für BEIDE Anbieter (Eriks ausdrückliche Entscheidung):
// Claude bekommt dieselben gerenderten Bilder wie ChatGPT statt wieder der
// rohen PDF, damit für beide dasselbe, selbst kontrollierte Verfahren gilt
// statt für Claude erneut auf unbekanntes anbieterinternes Verhalten zu
// vertrauen.
//
// Rückgabe: { uebersicht: Buffer, kacheln: Buffer[] } - je ein PNG-Buffer.
async function renderPdfAlsBildsatz(buf){
const pdfjsLib = await ladePdfjs();
const { createCanvas } = await ladeCanvas();
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf), standardFontDataUrl: PDFJS_STANDARD_FONTS, wasmUrl: PDFJS_WASM });
try {
const doc = await loadingTask.promise;
const page = await doc.getPage(1);
const basisViewport = page.getViewport({ scale: 1 });
// Alle optionalen Ebenen (PDF Optional Content Groups) erzwungen
// sichtbar schalten, BEVOR gerendert wird: CAD-/BIM-Exporte (AutoCAD,
// Revit, ...) legen technische Pläne sehr häufig in Ebenen an (z. B.
// "Architektur-Grundriss", "ELT-Planung", "Legende", "Plankopf") und
// markieren einen Teil davon im PDF selbst als standardmäßig AUS - oft
// bewusst so exportiert, damit jedes Gewerk seine eigene Ebene in
// Acrobat selbst einschaltet. Ohne diese Ergänzung rendert pdf.js exakt
// diesen Vorgabe-Zustand nach: bei einem echten Plan blieben dadurch nur
// der Architektur-Grundriss übrig, ELT-Planung, Legende UND Plankopf
// fehlten im gerenderten Bild komplett (von Erik direkt bestätigt, nach
// Ansehen des gespeicherten Debug-PNGs) - die KI konnte diese Merkmale
// also gar nicht als "nicht vorhanden" FALSCH einstufen, sie bekam sie
// schlicht nie zu sehen.
const ebenen = await doc.getOptionalContentConfig();
for(const [ebenenId] of ebenen){
ebenen.setVisibility(ebenenId, true);
}
// ausschnitt (optional): {x, y, breite, hoehe} in Geräte-Pixeln DIESES
// scale-Werts. Ohne ausschnitt wird die ganze Seite gerendert.
async function renderAusschnitt(scale, ausschnitt){
const viewport = page.getViewport({ scale });
const breite = ausschnitt ? Math.round(ausschnitt.breite) : Math.round(viewport.width);
const hoehe = ausschnitt ? Math.round(ausschnitt.hoehe) : Math.round(viewport.height);
const canvas = createCanvas(breite, hoehe);
const ctx = canvas.getContext('2d');
if(ausschnitt) ctx.translate(-ausschnitt.x, -ausschnitt.y);
await page.render({ canvasContext: ctx, viewport, optionalContentConfigPromise: Promise.resolve(ebenen) }).promise;
return canvas.toBuffer('image/png');
}
// Übersicht: die ganze Seite, moderate Auflösung - liefert der KI den
// Gesamtkontext (Art/Lage der Zeichnung), aber keine verlässliche
// Feinschrift.
const uebersicht = await renderAusschnitt(PLAN_CHECK_UEBERSICHT_BREITE / basisViewport.width, null);
// Kacheln: Raster mit Überlappung, JEDE Kachel mit eigener, hoher
// Skalierung gerendert (nicht aus der Übersicht zugeschnitten, s. o.).
const anteilBreite = (1 / PLAN_CHECK_KACHEL_SPALTEN) + PLAN_CHECK_KACHEL_UEBERLAPPUNG;
const kachelScale = PLAN_CHECK_KACHEL_BREITE / (basisViewport.width * anteilBreite);
const kachelViewport = page.getViewport({ scale: kachelScale });
const kacheln = [];
for(let zeile = 0; zeile < PLAN_CHECK_KACHEL_ZEILEN; zeile++){
for(let spalte = 0; spalte < PLAN_CHECK_KACHEL_SPALTEN; spalte++){
const startAnteilX = Math.max(0, spalte / PLAN_CHECK_KACHEL_SPALTEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2);
const startAnteilY = Math.max(0, zeile / PLAN_CHECK_KACHEL_ZEILEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2);
const endAnteilX = Math.min(1, (spalte + 1) / PLAN_CHECK_KACHEL_SPALTEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2);
const endAnteilY = Math.min(1, (zeile + 1) / PLAN_CHECK_KACHEL_ZEILEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2);
kacheln.push(await renderAusschnitt(kachelScale, {
x: startAnteilX * kachelViewport.width,
y: startAnteilY * kachelViewport.height,
breite: (endAnteilX - startAnteilX) * kachelViewport.width,
hoehe: (endAnteilY - startAnteilY) * kachelViewport.height
}));
}
}
return { uebersicht, kacheln };
} finally {
loadingTask.destroy();
}
}
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']);
// VALID_ROLES: die Systemrollen eines Kontos (users.role, keine CHECK-
// Constraint im Schema, daher hier die eine Stelle, die einen Wert
// zurückweist). 'kiosk' ist das Wandtafel-Konto (siehe CLAUDE.md
// "Wandtafel"): darf nur die lesenden Routen erreichen, die die Wandtafel
// braucht (requireAuthOderKiosk weiter unten), überall sonst wie requireAuth
// abgewiesen — und darf keine Fachrolle tragen (dort geprüft, wo roles
// validiert wird).
const VALID_ROLES = new Set(['admin', 'member', 'kiosk']);
// 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);
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res, path) => {
if(path.endsWith('.js') || path.endsWith('.css') || path.endsWith('.html')){
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
}
}
}));
// 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
// ---------- 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,
-- groesse: Umfang des Projekts als Zahl zwischen 0 und 1, gemessen am
-- Referenzhonorar (app_settings.referenzHonorar). Sie tritt an die Stelle
-- des Honorars, wenn keines hinterlegt ist, und ist damit die Grundlage
-- der Auslastungsrechnung in Stunden. 0 = keine Angabe: Das Projekt ist
-- dann nicht bewertbar und wird in der Auslastung als Lücke ausgewiesen,
-- nicht stillschweigend als Null gezählt.
groesse REAL 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)
)`);
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,
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,
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
)`);
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. 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
// (mindestens 1). 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)');
// 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);
});
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, groesse, 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 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)
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)
VALUES (?, ?, ?, ?, ?, ?, ?)`);
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id)
VALUES (?, ?, ?, ?)`);
(Array.isArray(week.projects) ? week.projects : []).forEach((p, idx) => {
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.
insProject.run(weekLabel, projId, String(p.nr || ''), String(p.name || ''),
sanitizeFormatHtml(p.status), String(p.statusAuthor || ''), String(p.statusAt || ''),
String(p.group || ''), idx, typeof p.order === 'number' ? p.order : idx,
String(p.projectUid || ''), Number(p.honorar) || 0, p.besprochen ? 1 : 0,
normalizeAuftragsstatus(p.auftragsstatus), normalizeGroesse(p.groesse), 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 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);
(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);
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || '')));
});
}
});
// Rückgabe der Revision für den Aufrufer (saveWeekRelational -> Route)
return nowIso;
}
// 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_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:[]}.
function loadGlobalPeople(){
// 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 != '' 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 absByUser = {}; absences.forEach(a => (absByUser[a.user_id] = absByUser[a.user_id] || []).push({ id:a.id, start:a.start, end:a.end, type:a.type, note:a.note }));
return users.map(u => ({
id: u.person_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(){
const weeks = db.prepare('SELECT * FROM weeks ORDER BY label').all();
const weekPeople = db.prepare('SELECT * FROM week_people ORDER BY array_order').all();
const weekRoles = db.prepare('SELECT * FROM week_people_roles ORDER BY role_order').all();
const attendance = db.prepare('SELECT * FROM attendance').all();
const projects = db.prepare('SELECT * FROM projects ORDER BY array_order').all();
const tags = db.prepare('SELECT * FROM project_tags ORDER BY tag_order').all();
const lph = db.prepare('SELECT * FROM project_lph').all();
const assign = db.prepare('SELECT * FROM project_assign').all();
const aufwand = db.prepare('SELECT * FROM project_aufwand').all();
const aktuelleLp = db.prepare('SELECT * FROM project_aktuelle_lp').all();
const beauftragteLp = db.prepare('SELECT * FROM project_beauftragte_lph').all();
const gewerke = db.prepare('SELECT * FROM project_gewerke').all();
const plans = db.prepare('SELECT * FROM project_plan').all();
const planPeople = db.prepare('SELECT * FROM plan_people ORDER BY array_order').all();
const todos = db.prepare('SELECT * FROM plan_todos ORDER BY sort_order').all();
const todoAssignees = db.prepare('SELECT * FROM plan_todo_assignees').all();
const todoDeps = db.prepare('SELECT * FROM plan_todo_deps').all();
const milestones = db.prepare('SELECT * FROM plan_milestones ORDER BY sort_order').all();
const milestoneDeps = db.prepare('SELECT * FROM plan_milestone_deps').all();
// 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, status: r.status,
statusAuthor: r.status_author || '', statusAt: r.status_at || '',
group: r.group,
tags: [], lph: {}, assign: {}, order: r.order_value, plan: null,
projectUid: r.project_uid,
honorar: r.honorar,
besprochen: !!r.besprochen,
groesse: normalizeGroesse(r.groesse),
// 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);
});
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 };
});
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: [] };
});
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'];
// 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', 'groesse', 'besprochen'];
// 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).
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) 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));
// 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.');
}
}
// migrateAddProjectGroesse: rüstet die Spalte `groesse` nach (idempotentes
// ALTER TABLE, gleiches Muster wie migrateAddProjectBesprochen()).
// Bestandszeilen starten mit 0 = keine Angabe; sie erscheinen in der
// Auslastung als nicht bewertbar, bis jemand entweder ein Honorar oder eine
// Größe pflegt. Absichtlich kein Vorgabewert wie 0,5: Eine erfundene Größe
// erzeugte Zahlen, die nach Messung aussehen.
// normalizeGroesse: Projektgröße auf den gültigen Bereich bringen.
// Erlaubt ist 0 bis 1 (0 = keine Angabe, 1 = Projekt in Höhe des
// Referenzhonorars). Unbrauchbare Werte werden zu 0 und damit zu einer
// sichtbaren Lücke, nicht zu einer erfundenen Zahl.
// Parameter: wert (beliebig). Rückgabe: Zahl zwischen 0 und 1.
function normalizeGroesse(wert){
const n = Number(wert);
if(!Number.isFinite(n) || n <= 0) return 0;
return n > 1 ? 1 : n;
}
// 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);
}
}
function migrateAddProjectGroesse(){
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 ADD COLUMN groesse REAL NOT NULL DEFAULT 0');
console.log('Projekt-Tabelle um groesse ergänzt.');
}
}
// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach
// (idempotent, gleiches ALTER-TABLE-Muster wie migrateAddProjectGroesse()).
// 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.
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 migrateAddProjectGroesse()). 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 migrateAddProjectGroesse()). 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 migrateAddProjectGroesse()). 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 {
const echteWochen = loadWeeks().filter(w => w.label !== SANDBOX_WEEK_LABEL);
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();
p.status = '';
p.statusAuthor = '';
p.statusAt = '';
p.besprochen = false;
delete p.statusState;
});
}
saveWeekRelational(neueWoche);
console.log('Automatischer Sitzungswechsel: neue Sitzung "' + neuesLabel + '" angelegt.');
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);
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.');
}
}
// 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();
migrateAddProjectGroesse();
migrateAddProjectUpdatedAt();
migrateAddPlanTodoShowInGantt();
migrateAddUserAiKeyModel();
migrateAddProtokollQuelle();
migrateAddProtokollPunktErstelltAm();
migrateAddProtokollPunktStatus();
migrateAddProtokollPunktEntfaelltNaechstes();
migrateUsersDepartment();
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);
}
// ---------- 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
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 || ''
};
try {
db.prepare('UPDATE user_sessions SET last_seen_at = ? WHERE token = ?').run(new Date().toISOString(), token);
} catch(err){
console.error('Konnte last_seen_at nicht aktualisieren:', err);
}
next();
}
// (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.' });
// Die Kiosk-Rolle (Wandtafel-Konto) ist überall, wo requireAuth genügte,
// bewusst AUSGESCHLOSSEN — sie darf nichts schreiben und nichts lesen, was
// die Wandtafel nicht selbst braucht. Die wenigen Routen, die sie braucht,
// nutzen requireAuthOderKiosk statt requireAuth.
if(req.user.role === 'kiosk') 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.
function loginClientKey(req, username){
const ip = String((req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || 'unbekannt');
return String(username || '').toLowerCase() + '|' + 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.' });
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.' });
}
const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
// 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) => {
res.json({ ok:true, user: req.user || null });
});
// 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) 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 sind `requireRole('admin')`-geschützt 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,
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', requireRole('admin'), (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', requireRole('admin'), (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', requireRole('admin'), (req, res) => {
const { username, displayName, email, role, personId, department, initials, capacityHours, roles, password, generatePassword } = req.body || {};
if(!username || !String(username).trim()) return res.status(400).json({ ok: false, error: 'Benutzername erforderlich.' });
if(!displayName || !String(displayName).trim()) return res.status(400).json({ ok: false, error: 'Anzeigename erforderlich.' });
const finalRole = role ? String(role) : 'member';
if(!VALID_ROLES.has(finalRole)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' });
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(finalRole === 'kiosk' && roleList.length > 0){
return res.status(400).json({ ok: false, error: 'Ein Kiosk-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, 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(), String(displayName).trim(), 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 }) };
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', requireRole('admin'), (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 && !VALID_ROLES.has(String(role))){
return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' });
}
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(effectiveRole === 'kiosk'){
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 Kiosk-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 && row.role === 'admin' && role !== 'admin';
if(isSelf && (wouldDeactivate || wouldDemote)){
return res.status(400).json({ ok: false, error: 'Sie können sich nicht selbst deaktivieren oder degradieren.' });
}
const fields = [];
const values = [];
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);
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);
}
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
const updatedRoles = roleList !== undefined ? roleList : (loadUserRolesByUserId()[id] || []);
res.json({ ok: true, user: Object.assign(serializeUserRow(updated), { roles: updatedRoles }) });
} 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', requireRole('admin'), (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.
app.delete('/admin/users/:id', requireRole('admin'), (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 referenced = db.prepare(`SELECT
(SELECT COUNT(*) FROM project_files WHERE uploaded_by_user_id = ?) +
(SELECT COUNT(*) FROM project_file_reviewers WHERE user_id = ?) +
(SELECT COUNT(*) FROM project_file_comments WHERE user_id = ?) AS c`)
.get(id, id, id);
if(referenced.c > 0){
return res.status(409).json({ ok: false, error: 'Benutzer wird noch referenziert und kann nicht endgültig gelöscht werden.' });
}
// user_sessions dieses Users werden durch ON DELETE CASCADE automatisch mitgelöscht.
db.prepare('DELETE FROM users WHERE id = ?').run(id);
return res.json({ ok: true, hard: true });
}
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);
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 (requireRole('admin')) 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 && !(req.user && req.user.role === 'admin')){
return res.status(403).json({
ok: false,
error: 'Die Sitzung "' + week.label + '" ist gesperrt. Nur Administratoren können sie wieder öffnen.'
});
}
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: ''
});
}
}
// `updatedAt` zurückgeben, damit der Client seine lokale Revisions-Basis
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
const updatedAt = saveWeekRelational(week);
console.log('Saved week:', week.label);
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) });
}
});
// Route: Löscht eine Woche (fehlende Woche wird toleriert)
app.post('/delete-week', requireRole('admin'), (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)');
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', requireRole('admin'), (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);
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.
app.get('/load-all', requireAuthOderKiosk, (req, res) => {
try {
const allWeeks = 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();
res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt });
} 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).
const unlocked = db.prepare('SELECT label FROM weeks WHERE locked = 0').all();
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').all();
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) 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'
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().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, nicht requireRole('admin')),
// 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 role != 'kiosk' 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) });
}
});
// Route: Abwesenheit für einen Benutzer anlegen. Bewusst NICHT admin-only und
// bewusst auch für FREMDE Konten erlaubt: In der Praxis trägt die
// Projektleitung Urlaube ihres Teams mit ein. Wer den Eintrag angelegt hat,
// wird in created_by festgehalten — das Löschen ist dadurch nachvollziehbar
// eingeschränkt (siehe DELETE-Route unten).
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.' });
const { start, end, type, note } = req.body || {};
if(!start || !String(start).trim() || !end || !String(end).trim()){
return res.status(400).json({ ok: false, error: 'Start- und Enddatum erforderlich.' });
}
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);
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) });
}
});
// Route: Abwesenheit löschen (idempotent — auch ok, wenn nichts gelöscht wurde).
// Erlaubt für: die betroffene Person selbst, wer den Eintrag angelegt hat, und
// Administratoren. Ohne diese Prüfung konnte jedes angemeldete Konto fremde
// Urlaube entfernen und damit unbemerkt die Ressourcenplanung verfälschen.
app.delete('/users/:id/absences/:absenceId', requireAuth, (req, res) => {
try {
const targetUserId = Number(req.params.id);
const row = db.prepare('SELECT created_by FROM user_absences WHERE user_id = ? AND id = ?')
.get(targetUserId, req.params.absenceId);
if(row){
const istAdmin = req.user.role === 'admin';
const istBetroffener = req.user.id === targetUserId;
const istErsteller = row.created_by && row.created_by === req.user.id;
if(!istAdmin && !istBetroffener && !istErsteller){
return res.status(403).json({
ok: false,
error: 'Diese Abwesenheit wurde von einer anderen Person eingetragen. Nur sie selbst, die betroffene Person oder ein Administrator kann sie entfernen.'
});
}
}
db.prepare('DELETE FROM user_absences WHERE user_id = ? AND id = ?').run(targetUserId, req.params.absenceId);
res.json({ ok: true });
} catch(err){
console.error('Abwesenheit löschen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// 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, express.raw({ type: '*/*', limit: '200mb' }), (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 || '');
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);
// 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);
const activeUsers = db.prepare('SELECT id FROM users WHERE active = 1').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();
fs.writeFileSync(path.join(dir, storedName), buf);
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, buf.length, 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: buf.length, 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) 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) 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, (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) 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, (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 = req.user.role === 'admin';
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,
offenCount, erledigtCount: punkte.length - offenCount, 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[äa]ndig|verantwortlich|termin|frist)\\s*:)';
const PROTOKOLL_LABEL_MUSTER = [
{ feld: 'zustaendig', regex: new RegExp('(?:zust[ä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 }.
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);
return { nummer: t.nummer, gruppe: '', thema, text: restText,
zustaendig: t.zustaendig, termin: t.termin, erledigt };
});
}
// ---------- 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: String((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, express.raw({ type: '*/*', limit: '50mb' }), 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.' });
}
const text = await extrahierePdfText(buf);
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, async (req, res) => {
try {
const { anbieter, punkte } = req.body || {};
if(!Array.isArray(punkte) || 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]
}));
}
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) });
}
});
// ---------- 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, 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, (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, express.raw({ type: '*/*', limit: '50mb' }), 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'){
text = await extrahierePdfText(buf);
} else if(ext === 'docx'){
const result = await mammoth.extractRawText({ buffer: buf });
text = result.value || '';
} else {
return res.status(400).json({ ok: false, error: 'Nur PDF- und Word-Dateien (.pdf, .docx) werden unterstützt.' });
}
// 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, (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, (req, res) => {
try {
const row = db.prepare('SELECT id 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;
}
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']);
// serializeTemplateRow: DB-Zeile -> öffentliche JSON-Form.
function serializeTemplateRow(row){
return {
id: row.id,
lph: row.lph,
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, 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', requireRole('admin'), (req, res) => {
try {
const { lph, title, offsetDays, durationDays } = req.body || {};
if(!VALID_LPHS.has(String(lph))){
return res.status(400).json({ ok: false, error: 'Unbekannte Leistungsphase.' });
}
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;
const duration = Number.isFinite(Number(durationDays)) ? Math.max(1, Math.trunc(Number(durationDays))) : 1;
// sort_order: ans Ende der jeweiligen Leistungsphase anhängen
const maxRow = db.prepare('SELECT MAX(sort_order) AS m FROM lph_templates WHERE lph = ?').get(String(lph));
const sortOrder = (maxRow && Number.isFinite(maxRow.m) ? maxRow.m : -1) + 1;
const id = crypto.randomUUID();
db.prepare(`INSERT INTO lph_templates (id, lph, title, offset_days, duration_days, sort_order)
VALUES (?, ?, ?, ?, ?, ?)`).run(id, String(lph), cleanTitle, offset, duration, sortOrder);
res.json({ ok: true, template: serializeTemplateRow({
id, lph: String(lph), title: cleanTitle, offset_days: offset, duration_days: duration, sort_order: sortOrder
})});
} catch(err){
console.error('Vorlage anlegen 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', requireRole('admin'), (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', requireRole('admin'), (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', requireRole('admin'), (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, express.raw({ type: '*/*', limit: '40mb' }), 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.
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.put('/app-settings', requireRole('admin'), (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 });
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', requireRole('admin'), (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', requireRole('admin'), (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', requireRole('admin'), (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', requireRole('admin'), (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', requireRole('admin'), (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', requireRole('admin'), express.raw({ type: '*/*', limit: '100mb' }), (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);
// Zustand VOR dem Restore sichern (danach wäre der Snapshot bereits der
// importierte Stand und die Wiederherstellung unmöglich).
runAutoBackup();
restoreDbFromBackup(tmp);
console.log('Datenbank aus Backup wiederhergestellt (' + buf.length + ' Bytes)');
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}));
// Route: Liefert die Hauptseite App.html
app.get('/', (req,res)=>{
res.sendFile(path.join(__dirname, 'public', 'App.html'));
});
// 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','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 res.sendFile(path.join(__dirname, 'public', 'App.html'));
}
if(!VALID_TAB_SLUGS.has(tab)) return res.status(404).send('Nicht gefunden');
res.sendFile(path.join(__dirname, 'public', 'App.html'));
});
// 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) => {
res.sendFile(path.join(__dirname, 'public', 'App.html'));
});
// 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);
});
// 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();
setInterval(ensureCurrentWeekRollover, 60 * 60 * 1000);
});
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.
function shutdown(signal){
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(0), 3000);
notbremse.unref();
server.close(() => {
try { db.close(); } catch(e){ /* bereits geschlossen */ }
console.log('Server beendet.');
process.exit(0);
});
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));