- Outlook: Kalender-Abo je Konto (eigene Aufgaben, Meilensteine, Abwesenheiten als .ics), einzurichten unter Einstellungen - Teams: Nachricht in einen Kanal bei neuen Aufgaben, mit Link direkt auf die Aufgabe; einzurichten unter Admin → Teams - Sitzungen nach Bedarf laden: beim Start nur die aktuelle und drei davor, im Verlauf „Weitere laden“ in Fünferschritten - Termin aus Mail: Wahl Aufgabe/Meilenstein, Notiz auch bei Meilensteinen, nur aktiv mit eingerichteter KI - Kalender zeigt die KW, auch statt der Browser-Datumsfelder - Pfeile bei den Abwesenheiten, kleinere Server-Korrekturen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9344 lines
479 KiB
JavaScript
9344 lines
479 KiB
JavaScript
/*
|
||
* Express-Server der Projektübersicht: liefert die statischen Frontend-Dateien aus
|
||
* public/ (App.html unter "/") und stellt den Frontend-Modulen unter public/js/
|
||
* eine JSON-API zum Laden und Speichern der Sitzungsdaten bereit. Die Daten liegen
|
||
* in einer SQLite-Datenbank (node:sqlite, Node >= 22.13) unter sessions/projektuebersicht.db.
|
||
*
|
||
* Das Schema ist voll relational normalisiert: statt eines grossen JSON-Dokuments
|
||
* pro Woche gibt es eigene Tabellen für Wochen-Metadaten, Wochen-Personen, Rollen,
|
||
* Anwesenheit, Projekte, LPH-Werte, Projekt-Zuordnungen, Projektpläne, TODOs,
|
||
* Meilensteine, Abhängigkeiten sowie die globalen Personen inkl. Rollen und
|
||
* Abwesenheiten. Die HTTP-API bleibt davon unberührt: Das Frontend sendet und
|
||
* erhält weiterhin komplette Wochen- und Personen-Objekte (JSON); der Server
|
||
* verteilt sie beim Speichern auf die Tabellen und setzt sie beim Laden wieder
|
||
* Zusammen (Serializer/Deserializer).
|
||
*
|
||
* Eine bestehende Datenbank aus dem alten Ein-Dokument-pro-Woche-Format wird
|
||
* automatisch auf das relationale Schema umgestellt.
|
||
*
|
||
* Standardport ist 3003, überschreibbar über die Umgebungsvariable PORT.
|
||
* Endpunkte: GET /, GET /health, GET /load-all, POST /save-week,
|
||
* POST /delete-week,
|
||
* GET /backup (SQLite-Snapshot als .db-Download; zusätzlich automatische Backups
|
||
* nach sessions/backups/ beim Serverstart), POST /import (Backup-Wiederherstellung,
|
||
* roher Binär-Body, schema-validiert).
|
||
* Ungültige Abteilungen fallen auf ELT zurück.
|
||
*/
|
||
const express = require('express');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const zlib = require('zlib');
|
||
const crypto = require('crypto');
|
||
const { DatabaseSync } = require('node:sqlite');
|
||
// pdfjs-dist/mammoth: reine Textauszug-Bibliotheken für den Protokoll-Import
|
||
// (POST /project-protokolle/import) — erste Abhängigkeiten außer
|
||
// express/cors, siehe CLAUDE.md "### Protokolle". pdfjs-dist statt des
|
||
// zunächst gewählten pdf-parse: dessen mitgelieferte, seit 2017
|
||
// eingefrorene pdf.js-Kopie liefert in diesem Prozess reproduzierbar
|
||
// "bad XRef entry", sobald Node's eigenes http-Modul geladen ist (bereits
|
||
// durch das bloße `require('http')` allein auslösbar, unabhängig vom
|
||
// tatsächlichen PDF-Inhalt) — in einem Express-Server also immer, da
|
||
// express selbst transitiv http lädt. Ein eigenständiges pdfjs-dist ist
|
||
// nicht betroffen.
|
||
//
|
||
// @napi-rs/canvas: zunächst bewusst draußen gehalten (siehe Git-Historie
|
||
// der .npmrc) — das Seiten-Rendering zu Bildern war hier nie gebraucht,
|
||
// solange nur TEXT aus einem PDF gezogen wurde. Für den Plan Checker
|
||
// (siehe "Plan Checker" weiter unten) ändert sich das: Maßketten und
|
||
// Plankopf sind optische Merkmale einer Zeichnung, kein Fließtext, also
|
||
// muss die Planseite als BILD an die KI gehen. Jetzt eine echte, direkte
|
||
// Abhängigkeit (nicht mehr nur pdfjs-dists optionale) — die .npmrc mit
|
||
// `omit=optional` ist deshalb entfallen, sie hätte sonst auch das für
|
||
// @napi-rs/canvas selbst nötige, plattformspezifische Binärpaket (z. B.
|
||
// @napi-rs/canvas-darwin-arm64) ausgeschlossen.
|
||
const mammoth = require('mammoth');
|
||
// undici: NICHT für fetch() selbst (das liefert Node schon global) — nur für
|
||
// die Agent-Klasse, um der KI-Erkennung gegen einen eigenen Server (siehe
|
||
// fetchMitTimeout() weiter unten) ein höheres Verbindungslimit als Node/
|
||
// undicis eigenen Vorgabewert mitzugeben, siehe dort.
|
||
const { Agent: UndiciAgent } = require('undici');
|
||
// PDF-Arbeit im Kindprozess (pdf-arbeit.js): ein Auftrag je Prozess, höchstens
|
||
// einer gleichzeitig (Warteschlange), mit Zeitlimit. Stirbt das Kind am
|
||
// Speicher, scheitert nur dieser eine Import mit verständlicher Meldung.
|
||
const { fork } = require('child_process');
|
||
const { PLAN_CHECK_KACHEL_SPALTEN, PLAN_CHECK_KACHEL_ZEILEN } = require('./pdf-arbeit.js');
|
||
const PDF_KIND_TIMEOUT_MS = 3 * 60 * 1000;
|
||
let pdfWarteschlange = Promise.resolve();
|
||
function pdfImKindprozess(art, buf){
|
||
const lauf = pdfWarteschlange.then(() => new Promise((resolve, reject) => {
|
||
const kind = fork(path.join(__dirname, 'pdf-arbeit.js'), [], {
|
||
execArgv: ['--max-old-space-size=' + (Number(process.env.PDF_KIND_HEAP_MB) || 192)],
|
||
serialization: 'advanced', stdio: ['ignore', 'inherit', 'inherit', 'ipc']
|
||
});
|
||
let fertig = false;
|
||
const ende = (fn, wert) => { if(fertig) return; fertig = true; clearTimeout(uhr); fn(wert); };
|
||
const uhr = setTimeout(() => { kind.kill('SIGKILL'); ende(reject, new Error('Das PDF konnte nicht in angemessener Zeit gelesen werden.')); }, PDF_KIND_TIMEOUT_MS);
|
||
kind.on('message', m => m && m.ok ? ende(resolve, m.ergebnis) : ende(reject, new Error((m && m.fehler) || 'PDF konnte nicht gelesen werden.')));
|
||
kind.on('exit', code => ende(reject, new Error(code === 99 || code === null
|
||
? 'Das PDF ist zu groß für den Server (Arbeitsspeicher).'
|
||
: 'PDF konnte nicht gelesen werden (Code ' + code + ').')));
|
||
kind.on('error', err => ende(reject, err));
|
||
kind.send({ art, daten: buf });
|
||
}));
|
||
pdfWarteschlange = lauf.catch(() => {});
|
||
return lauf;
|
||
}
|
||
function extrahierePdfText(buf){ return pdfImKindprozess('text', buf); }
|
||
async function renderPdfAlsBildsatz(buf){
|
||
const r = await pdfImKindprozess('bild', buf);
|
||
// IPC ("advanced") liefert Uint8Array statt Buffer zurück.
|
||
return { uebersicht: Buffer.from(r.uebersicht), kacheln: r.kacheln.map(k => Buffer.from(k)) };
|
||
}
|
||
|
||
const app = express();
|
||
|
||
// Wichtige Konstanten: Serverport (PORT-Env) und die gültigen Abteilungsordner
|
||
const PORT = process.env.PORT || 3003;
|
||
const VALID_DEPARTMENTS = new Set(['ELT','HLS','ARCH']);
|
||
|
||
// SANDBOX_WEEK_LABEL: die eine, dauerhaft reservierte "Sitzung", in der alle
|
||
// Planung-Terminpläne liegen (CLAUDE.md, "Planung — projektunabhängige
|
||
// Terminplan-Sandbox"). Kein echtes Datum, keine echte Kalenderwoche — der
|
||
// Name selbst ist der Marker, der dieses `weeks`-Label von jeder echten
|
||
// Sitzung unterscheidet. MUSS mit der gleichnamigen Konstante in
|
||
// public/js/globals.js übereinstimmen (kein gemeinsames Modulsystem zwischen
|
||
// Server und Client in diesem Projekt, siehe "Frontend architecture").
|
||
const SANDBOX_WEEK_LABEL = '__sandbox__';
|
||
// Projekt-Zuordnungs-Rollen aus ROLES in public/js/globals.js (bewusst dupliziert,
|
||
// kein Bundler vorhanden — NICHT zu verwechseln mit users.role, s. VALID_ROLES).
|
||
const VALID_DISCIPLINE_ROLES = new Set(['Planung','Projektleitung','Bauüberwachung']);
|
||
// Systemrollen eines Kontos (users.role) stehen seit „Rollen & Rechte“ in der
|
||
// Tabelle roles (siehe RECHTE weiter unten) — geprüft über rolleExistiert().
|
||
// Nicht zu verwechseln mit den Fachrollen (user_roles), die nur die
|
||
// Projektzuordnung betreffen und keine Berechtigung verleihen.
|
||
// GELOESCHTE_PROJEKTE_TAGE: so lange bleibt ein aus der Projektliste
|
||
// gelöschtes Projekt im Papierkorb (deleted_projects) und ist für
|
||
// Administratoren wiederherstellbar; danach entfernt es
|
||
// purgeGeloeschteProjekte() endgültig.
|
||
const GELOESCHTE_PROJEKTE_TAGE = 30;
|
||
// VALID_AUFTRAGSSTATUS: der Weg von der Anfrage zum abgeschlossenen Auftrag
|
||
// (projects.auftragsstatus). Die Reihenfolge ist die fachliche Abfolge und
|
||
// steht genauso in AUFTRAGSSTATUS in public/js/auftrag.js — bewusst
|
||
// dupliziert, es gibt keinen Bundler. Serverseitig geprüft wird das nicht
|
||
// wegen der Oberfläche, sondern weil /save-week der einzige Schreibweg ist:
|
||
// ein Tippfehler im Wert wäre sonst dauerhaft in der Datenbank und jede
|
||
// Auswertung ("welche Projekte sind noch Angebot?") liefe daran vorbei.
|
||
const VALID_AUFTRAGSSTATUS = new Set(['anfrage','angebot','beauftragt','abgeschlossen']);
|
||
// DEFAULT_AUFTRAGSSTATUS: Vorgabe für Bestandsdaten und für unbelegte Felder.
|
||
// 'beauftragt' und nicht 'anfrage', weil der gesamte Altbestand beauftragte
|
||
// Arbeit ist — jeder andere Anfangswert hätte alle laufenden Projekte
|
||
// rückwirkend zu Akquise erklärt.
|
||
const DEFAULT_AUFTRAGSSTATUS = 'beauftragt';
|
||
// AUFTRAGSSTATUS_LABELS: lesbare Bezeichnungen für den CSV-Export. Der
|
||
// Schlüssel selbst ('anfrage') steht in der Datenbank, in einer Tabelle für
|
||
// Menschen hat er nichts zu suchen.
|
||
const AUFTRAGSSTATUS_LABELS = {
|
||
anfrage: 'Anfrage', angebot: 'Angebot',
|
||
beauftragt: 'Beauftragt', abgeschlossen: 'Abgeschlossen'
|
||
};
|
||
|
||
// normalizeAuftragsstatus: bringt einen eingehenden Wert auf einen gültigen
|
||
// Schlüssel. Wird beim SCHREIBEN gebraucht (serializeWeek) — dort ist der Wert
|
||
// bereits von der Route geprüft, leer oder fehlend darf er aber sein (alte
|
||
// Clients, Demo-Daten) und wird dann zur Vorgabe.
|
||
function normalizeAuftragsstatus(value){
|
||
const v = String(value || '').trim();
|
||
return VALID_AUFTRAGSSTATUS.has(v) ? v : DEFAULT_AUFTRAGSSTATUS;
|
||
}
|
||
|
||
// findInvalidAuftragsstatus: sucht in einer eingehenden Woche das erste Projekt
|
||
// mit unbekanntem Auftragsstatus. Getrennt von normalizeAuftragsstatus(), weil
|
||
// der Schreibweg BEIM SPEICHERN nicht stillschweigend zurechtbiegen darf: Ein
|
||
// unbekannter Wert ist ein Fehler im Aufruf und wird mit 400 abgewiesen,
|
||
// sonst käme aus der Oberfläche "Angebot" heraus und in der Datenbank stünde
|
||
// "beauftragt". Ein fehlendes/leeres Feld ist dagegen erlaubt (alte Clients).
|
||
// Rückgabe: { nr, name, wert } oder null, wenn alles gültig ist.
|
||
function findInvalidAuftragsstatus(week){
|
||
const projects = Array.isArray(week && week.projects) ? week.projects : [];
|
||
for(const p of projects){
|
||
const raw = p && p.auftragsstatus;
|
||
if(raw === undefined || raw === null || raw === '') continue;
|
||
const v = String(raw).trim();
|
||
if(!VALID_AUFTRAGSSTATUS.has(v)){
|
||
return { nr: String((p && p.nr) || ''), name: String((p && p.name) || ''), wert: v };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Globale Middleware: CORS, JSON-Body-Limit (2 MB) und statisches Serving von public/ ohne Cache für Skripte/Styles
|
||
app.use(require('cors')());
|
||
// Der JSON-Parser darf NICHT über die Routen mit rohem Binär-Body laufen:
|
||
// Der Browser schickt beim Datei-Upload den Content-Type der Datei mit, und
|
||
// bei einer .json-Datei würde express.json() den Datenstrom verbrauchen, bevor
|
||
// express.raw() ihn bekommt ("Keine Datei empfangen"). 8 MB statt 2 MB, weil
|
||
// eine Sitzung mit vielen Projekten und langen Kommentaren sonst irgendwann
|
||
// still am 413 scheitert.
|
||
const RAW_BODY_ROUTES = new Set(['/import', '/project-files', '/project-protokolle/import', '/plan-checker-pruefen', '/plan-todos-aus-protokoll/einlesen']);
|
||
const jsonBodyParser = express.json({ limit: '8mb' });
|
||
app.use((req, res, next) => {
|
||
if(req.method === 'POST' && RAW_BODY_ROUTES.has(req.path)) return next();
|
||
jsonBodyParser(req, res, next);
|
||
});
|
||
// attachUser liest bei jedem Request das Session-Cookie und setzt req.user
|
||
// (siehe Definition weiter unten im Abschnitt "Auth: Sessions, Middleware,
|
||
// Endpunkte" — als function-Deklaration ist sie im gesamten Modul gehoistet).
|
||
// Muss vor express.static laufen, damit sie garantiert für jeden Request greift.
|
||
app.use(attachUser);
|
||
// Kompression: Texte (JSON, JS, CSS, HTML, SVG) werden gzip-komprimiert
|
||
// ausgeliefert, wenn der Browser es anbietet — /load-all schrumpft so von
|
||
// rund 150 KB auf einen Bruchteil. Statische Dateien werden je Datei und
|
||
// Änderungszeit einmal komprimiert und im Speicher gehalten.
|
||
app.use(gzipJson);
|
||
const gzipPublic = gzipStatisch(path.join(__dirname, 'public'));
|
||
app.use(gzipPublic);
|
||
// Zwischenspeichern: Jede JS-/CSS-Datei wird in App.html mit ?v=N
|
||
// eingebunden und die Nummer bei jeder Änderung erhöht — eine solche
|
||
// Adresse ändert ihren Inhalt nie und darf deshalb ein Jahr im Browser
|
||
// liegen. Alles andere (App.html selbst, Dateien ohne ?v) wird bei jedem
|
||
// Aufruf nachgefragt (no-cache + ETag, im Normalfall nur ein kurzes 304).
|
||
// Vorher: no-store, also bei JEDEM Laden rund 1,4 MB neu.
|
||
app.use(express.static(path.join(__dirname, 'public'), {
|
||
setHeaders: (res, path) => {
|
||
if(path.endsWith('.js') || path.endsWith('.css') || path.endsWith('.html') || path.endsWith('.svg')){
|
||
const versioniert = res.req && /[?&]v=/.test(res.req.originalUrl || '');
|
||
res.set('Cache-Control', versioniert ? 'public, max-age=31536000, immutable' : 'no-cache');
|
||
}
|
||
}
|
||
}));
|
||
|
||
// gzipJson: komprimiert res.json()-Antworten ab 1 KB, sofern der Browser
|
||
// gzip annimmt. Middleware; ersetzt res.json nur für diese Anfrage.
|
||
function gzipJson(req, res, next){
|
||
if(!/\bgzip\b/.test(req.headers['accept-encoding'] || '')) return next();
|
||
const original = res.json.bind(res);
|
||
res.json = function(body){
|
||
const text = JSON.stringify(body);
|
||
if(text === undefined || text.length < 1024) return original(body);
|
||
const gz = zlib.gzipSync(text, { level: 6 });
|
||
if(!res.get('Content-Type')) res.set('Content-Type', 'application/json; charset=utf-8');
|
||
res.set('Content-Encoding', 'gzip');
|
||
res.set('Vary', 'Accept-Encoding');
|
||
return res.send(gz);
|
||
};
|
||
next();
|
||
}
|
||
|
||
// gzipStatisch: liefert JS/CSS/HTML/SVG aus wurzel komprimiert aus (GET/HEAD,
|
||
// nur wenn der Browser gzip annimmt). Sonst — und für alles andere — weiter
|
||
// an express.static. Cache: Pfad -> { mtime, gz, etag }.
|
||
function gzipStatisch(wurzel){
|
||
const TYPEN = { '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
|
||
'.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml' };
|
||
const cache = new Map();
|
||
const mw = (req, res, next) => {
|
||
if(req.method !== 'GET' && req.method !== 'HEAD') return next();
|
||
let rel;
|
||
try { rel = decodeURIComponent(req.path); } catch(e){ return next(); }
|
||
if(rel.endsWith('/')) rel += 'index.html';
|
||
const datei = path.join(wurzel, rel);
|
||
if(!datei.startsWith(wurzel + path.sep)) return next();
|
||
sende(req, res, next, datei);
|
||
};
|
||
// sende: eine Datei komprimiert ausliefern, sonst next() (auch nutzbar für
|
||
// die Seitenrouten, die App.html unter anderen Pfaden liefern).
|
||
const sende = (req, res, next, datei) => {
|
||
if(!/\bgzip\b/.test(req.headers['accept-encoding'] || '')) return next();
|
||
const typ = TYPEN[path.extname(datei).toLowerCase()];
|
||
if(!typ) return next();
|
||
let st;
|
||
try { st = fs.statSync(datei); } catch(e){ return next(); }
|
||
if(!st.isFile()) return next();
|
||
let e = cache.get(datei);
|
||
if(!e || e.mtime !== st.mtimeMs){
|
||
const gz = zlib.gzipSync(fs.readFileSync(datei), { level: 6 });
|
||
e = { mtime: st.mtimeMs, gz, etag: 'W/"gz-' + st.size.toString(16) + '-' + Math.floor(st.mtimeMs).toString(16) + '"' };
|
||
cache.set(datei, e);
|
||
}
|
||
const versioniert = /[?&]v=/.test(req.originalUrl || '');
|
||
res.set('Cache-Control', versioniert ? 'public, max-age=31536000, immutable' : 'no-cache');
|
||
res.set('Vary', 'Accept-Encoding');
|
||
res.set('ETag', e.etag);
|
||
if(req.headers['if-none-match'] === e.etag) return res.status(304).end();
|
||
res.set('Content-Type', typ);
|
||
res.set('Content-Encoding', 'gzip');
|
||
res.set('Content-Length', String(e.gz.length));
|
||
if(req.method === 'HEAD') return res.end();
|
||
res.end(e.gz);
|
||
};
|
||
mw.sende = sende;
|
||
return mw;
|
||
}
|
||
|
||
// appSenden: App.html für die Seitenrouten — komprimiert, sonst wie bisher.
|
||
function appSenden(req, res){
|
||
const datei = path.join(__dirname, 'public', 'App.html');
|
||
gzipPublic.sende(req, res, () => res.sendFile(datei), datei);
|
||
}
|
||
|
||
// Datenverzeichnis sessions/ bei Bedarf anlegen (bind-mounted in Docker, enthält die DB)
|
||
const SESSIONS_DIR = path.join(__dirname, 'sessions');
|
||
if(!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
||
|
||
// SQLite-Datenbank öffnen (wird angelegt, falls nicht vorhanden). Der Pfad ist
|
||
// über die Umgebungsvariable DB_PATH übersteuerbar (für Tests/Backups), sonst
|
||
// standardmäßig sessions/projektuebersicht.db.
|
||
const DB_PATH = process.env.DB_PATH ? path.resolve(process.env.DB_PATH) : path.join(SESSIONS_DIR, 'projektuebersicht.db');
|
||
const db = new DatabaseSync(DB_PATH);
|
||
db.exec('PRAGMA journal_mode = WAL;');
|
||
db.exec('PRAGMA foreign_keys = ON;');
|
||
db.exec('PRAGMA busy_timeout = 5000;'); // 5 s warten statt SQLITE_BUSY-Fehlern bei parallelen Schreibern
|
||
// synchronous=NORMAL ist im WAL-Modus die übliche, sichere Einstellung: Die
|
||
// Datenbank bleibt bei einem Absturz konsistent, nur ein Stromausfall kann die
|
||
// allerletzten Schreibvorgänge kosten. Mit der Vorgabe FULL wartete JEDES
|
||
// Schreiben auf die SD-Karte des Pi — spürbar bei jedem Seitenaufruf.
|
||
db.exec('PRAGMA synchronous = NORMAL;');
|
||
|
||
// ---------- Schema (voll relational normalisiert) ----------
|
||
|
||
// Der serialisierte Datenbestand einer Woche besteht aus folgenden Entitäten:
|
||
// - weeks: Metadaten (id, label, date, locked)
|
||
// - week_people / week_people_roles: personen-Kopien pro Woche
|
||
// - attendance: Anwesenheit pro Woche/Person
|
||
// - projects: Projekt-Metadaten pro Woche
|
||
// - project_lph: LPH-Fortschrittswerte pro Projekt
|
||
// - project_assign: Zuordnungen (Haupt/Optional) pro Projekt/Person/Rolle
|
||
// - project_plan: Plan-Kopf (start, end, progress) pro Projekt
|
||
// - plan_people: im Plan beteiligte Personen
|
||
// - plan_todos / plan_todo_assignees / plan_todo_deps: TODO-Listen
|
||
// - plan_milestones / plan_milestone_deps: Meilensteine
|
||
// - globale Personen pro Abteilung kommen seit der Ablösung von
|
||
// people/person_roles/person_absences aus users/user_roles/user_absences
|
||
// (siehe loadGlobalPeople, migrateRetirePeopleTablesIntoUsers)
|
||
|
||
db.exec(`CREATE TABLE IF NOT EXISTS weeks (
|
||
label TEXT NOT NULL,
|
||
week_id TEXT NOT NULL DEFAULT '',
|
||
date TEXT NOT NULL DEFAULT '',
|
||
locked INTEGER NOT NULL DEFAULT 0,
|
||
updated_at TEXT NOT NULL DEFAULT '',
|
||
PRIMARY KEY (label)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS week_people (
|
||
week_label TEXT NOT NULL,
|
||
person_id TEXT NOT NULL,
|
||
name TEXT NOT NULL DEFAULT '',
|
||
full_name TEXT NOT NULL DEFAULT '',
|
||
array_order INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, person_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS week_people_roles (
|
||
week_label TEXT NOT NULL,
|
||
person_id TEXT NOT NULL,
|
||
role TEXT NOT NULL,
|
||
role_order INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, person_id, role)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS attendance (
|
||
week_label TEXT NOT NULL,
|
||
person_id TEXT NOT NULL,
|
||
present INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, person_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS projects (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
nr TEXT NOT NULL DEFAULT '',
|
||
name TEXT NOT NULL DEFAULT '',
|
||
status TEXT NOT NULL DEFAULT '',
|
||
status_author TEXT NOT NULL DEFAULT '',
|
||
status_at TEXT NOT NULL DEFAULT '',
|
||
"group" TEXT NOT NULL DEFAULT '',
|
||
array_order INTEGER NOT NULL DEFAULT 0,
|
||
order_value INTEGER NOT NULL DEFAULT 0,
|
||
-- besprochen: im Teammeeting abgehakt. Gehört zur SITZUNG, nicht zum
|
||
-- Projekt: Jede Woche wird jedes Projekt neu durchgegangen, und der
|
||
-- Haken beantwortet die Frage "hatten wir das schon?" nur für diese eine
|
||
-- Sitzung. Deshalb steht er hier und nicht an einer wochenunabhängigen
|
||
-- Stelle; beim Duplizieren einer Sitzung wird er zurückgesetzt.
|
||
besprochen INTEGER NOT NULL DEFAULT 0,
|
||
-- auftragsstatus: Wo das Projekt zwischen Anfrage und Abschluss steht
|
||
-- ('anfrage'|'angebot'|'beauftragt'|'abgeschlossen', siehe
|
||
-- VALID_AUFTRAGSSTATUS). Im Unterschied zu besprochen gehört der Wert zum
|
||
-- PROJEKT und nicht zur Sitzung: Ein Angebot bleibt ein Angebot, bis es
|
||
-- beauftragt wird, und wandert beim Duplizieren der Sitzung deshalb mit.
|
||
-- Die Gruppe "In Anbahnung" deutete das bisher nur an; sie bleibt die
|
||
-- Sortierung auf dem Board, sagt aber nichts darüber, ob schon ein Angebot
|
||
-- draußen ist.
|
||
auftragsstatus TEXT NOT NULL DEFAULT 'beauftragt',
|
||
-- updated_at: Zeitstempel der letzten Speicherung DIESES Projekts. Bis zur
|
||
-- Planung-Sandbox (CLAUDE.md, "Planung — projektunabhängige Terminplan-
|
||
-- Sandbox") gab es nur eine Revision je WOCHE (weeks.updated_at) — genug,
|
||
-- solange jede Sitzung nur wenige Projekte auf einmal hat. Die Sandbox legt
|
||
-- aber mehrere, voneinander unabhängige Terminpläne in EINER reservierten
|
||
-- Woche ab; ohne eine Zeitangabe je Projekt ließe sich "zuletzt bearbeitet"
|
||
-- in der Planungsliste nicht je Plan unterscheiden. Für echte Projekte
|
||
-- harmlos mitgeführt, aber ungenutzt.
|
||
updated_at TEXT NOT NULL DEFAULT '',
|
||
PRIMARY KEY (week_label, project_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_tags (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
tag TEXT NOT NULL,
|
||
tag_order INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, project_id, tag)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_lph (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
lph TEXT NOT NULL,
|
||
value TEXT NOT NULL DEFAULT '',
|
||
PRIMARY KEY (week_label, project_id, lph)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_assign (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
person_id TEXT NOT NULL,
|
||
role TEXT NOT NULL DEFAULT '',
|
||
state INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, project_id, person_id, role)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_aufwand (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
lph TEXT NOT NULL,
|
||
role TEXT NOT NULL DEFAULT '',
|
||
value REAL NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, project_id, lph, role)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_aktuelle_lp (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
lph TEXT NOT NULL,
|
||
PRIMARY KEY (week_label, project_id, lph)
|
||
)`);
|
||
// project_beauftragte_lph: welche Leistungsphasen für dieses Projekt
|
||
// BEAUFTRAGT sind — nicht zu verwechseln mit project_aktuelle_lp (die eine
|
||
// gerade amtierende Phase) oder project_lph (Fortschritt in Prozent).
|
||
// Grundlage für die Honorar-Terminplanung (siehe js/plan/plan-honorar.js):
|
||
// das vereinbarte Honorar wird auf genau diese Phasen verteilt.
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_beauftragte_lph (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
lph TEXT NOT NULL,
|
||
PRIMARY KEY (week_label, project_id, lph)
|
||
)`);
|
||
// project_gewerke: welche Fachbereiche des Büros das Projekt bearbeiten
|
||
// ('ELT'|'HLS'|'ARCH', dieselben Werte wie users.department). Ein Projekt
|
||
// kann zu mehreren gehören — ein Schulanbau etwa zu ELT und HLS.
|
||
//
|
||
// Wozu: Die Auswahl der Projektbeteiligten wird darauf eingeschränkt. Ein
|
||
// reines Elektro-Projekt soll nicht die ganze Belegschaft zur Auswahl
|
||
// stellen; wer trotzdem jemanden aus einem anderen Gewerk braucht, holt ihn
|
||
// ausdrücklich dazu (siehe den Wizard in js/projekt-wizard.js).
|
||
//
|
||
// Eigene Tabelle statt einer Spalte mit Trennzeichen — gleiche Bauart wie
|
||
// project_beauftragte_lph, und damit dieselben Wege beim Serialisieren.
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_gewerke (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
gewerk TEXT NOT NULL,
|
||
PRIMARY KEY (week_label, project_id, gewerk)
|
||
)`);
|
||
// project_kommentare: Sitzungskommentar und Besprochen-Haken JE GEWERK.
|
||
// ELT, HLS und ARCH besprechen ihre Projekte in getrennten Sitzungen (Eriks
|
||
// Wunsch: "Die Kommentare und Anwesenheiten von ELT brauchen nicht in HLS
|
||
// oder Arch zu sein" / "auch wenn das Projekt in ELT besprochen ist, heißt
|
||
// das nicht, dass HLS das Projekt auch besprochen hat"). Löst die Spalten
|
||
// projects.status/status_author/status_at/besprochen ab, die nur noch für
|
||
// den Übergang (migrateKommentareJeGewerk()) gelesen und sonst leer
|
||
// geschrieben werden. Client-Form: p.kommentare = { ELT: {status,
|
||
// statusAuthor, statusAt, besprochen}, … }.
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_kommentare (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
gewerk TEXT NOT NULL,
|
||
status TEXT NOT NULL DEFAULT '',
|
||
status_author TEXT NOT NULL DEFAULT '',
|
||
status_at TEXT NOT NULL DEFAULT '',
|
||
besprochen INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, project_id, gewerk)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_plan (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
start TEXT NOT NULL DEFAULT '',
|
||
end TEXT NOT NULL DEFAULT '',
|
||
progress INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, project_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS plan_people (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
person_id TEXT NOT NULL,
|
||
array_order INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, project_id, person_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS plan_todos (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
todo_id TEXT NOT NULL,
|
||
title TEXT NOT NULL DEFAULT '',
|
||
start TEXT NOT NULL DEFAULT '',
|
||
end TEXT NOT NULL DEFAULT '',
|
||
done INTEGER NOT NULL DEFAULT 0,
|
||
progress INTEGER NOT NULL DEFAULT 0,
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
show_in_gantt INTEGER NOT NULL DEFAULT 1,
|
||
leistungsphase TEXT NOT NULL DEFAULT '',
|
||
aufwand_stunden REAL NOT NULL DEFAULT 0,
|
||
notiz TEXT NOT NULL DEFAULT '',
|
||
PRIMARY KEY (week_label, project_id, todo_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
todo_id TEXT NOT NULL,
|
||
person_id TEXT NOT NULL,
|
||
PRIMARY KEY (week_label, project_id, todo_id, person_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_deps (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
todo_id TEXT NOT NULL,
|
||
depends_on_id TEXT NOT NULL,
|
||
PRIMARY KEY (week_label, project_id, todo_id, depends_on_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestones (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
milestone_id TEXT NOT NULL,
|
||
name TEXT NOT NULL DEFAULT '',
|
||
date TEXT NOT NULL DEFAULT '',
|
||
position TEXT NOT NULL DEFAULT '',
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
notiz TEXT NOT NULL DEFAULT '',
|
||
PRIMARY KEY (week_label, project_id, milestone_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps (
|
||
week_label TEXT NOT NULL,
|
||
project_id TEXT NOT NULL,
|
||
milestone_id TEXT NOT NULL,
|
||
depends_on_id TEXT NOT NULL,
|
||
PRIMARY KEY (week_label, project_id, milestone_id, depends_on_id)
|
||
)`);
|
||
// people / person_roles / person_absences (globale Personen pro Abteilung) sind
|
||
// abgelöst worden (siehe migrateRetirePeopleTablesIntoUsers weiter unten) —
|
||
// werden deshalb hier bewusst NICHT mehr unconditionally angelegt. Für Alt-DBs
|
||
// im früheren Ein-Dokument-Format legt migrateLegacySchema() sie transient an,
|
||
// migrateRetirePeopleTablesIntoUsers() überführt/droppt sie danach beim selben
|
||
// Start.
|
||
|
||
// ---------- User-Management, Sessions, Datei-Freigabe (Schema, additiv) ----------
|
||
db.exec(`CREATE TABLE IF NOT EXISTS users (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
username TEXT NOT NULL UNIQUE,
|
||
display_name TEXT NOT NULL DEFAULT '',
|
||
email TEXT NOT NULL DEFAULT '',
|
||
password_hash TEXT NOT NULL,
|
||
password_salt TEXT NOT NULL,
|
||
role TEXT NOT NULL DEFAULT 'member',
|
||
person_id TEXT NOT NULL DEFAULT '',
|
||
active INTEGER NOT NULL DEFAULT 1,
|
||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL DEFAULT '',
|
||
updated_at TEXT NOT NULL DEFAULT ''
|
||
)`);
|
||
db.exec(`CREATE INDEX IF NOT EXISTS idx_users_person_id ON users(person_id)`);
|
||
|
||
db.exec(`CREATE TABLE IF NOT EXISTS user_sessions (
|
||
token TEXT PRIMARY KEY,
|
||
user_id INTEGER NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT '',
|
||
last_seen_at TEXT NOT NULL DEFAULT '',
|
||
expires_at TEXT NOT NULL DEFAULT '',
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)`);
|
||
|
||
// user_roles/user_absences: Rollen (Planung/Projektleitung/Bauüberwachung) und
|
||
// Abwesenheiten je Benutzer — lösen die alten people/person_roles/
|
||
// person_absences-Tabellen vollständig ab (loadGlobalPeople liest nur noch
|
||
// hieraus, migrateRetirePeopleTablesIntoUsers überführt/droppt die Altdaten).
|
||
db.exec(`CREATE TABLE IF NOT EXISTS user_roles (
|
||
user_id INTEGER NOT NULL, role TEXT NOT NULL, role_order INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (user_id, role),
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)`);
|
||
// created_by: wer den Eintrag angelegt hat. Grundlage der Löschberechtigung —
|
||
// eintragen darf jeder für jeden (die Projektleitung pflegt Urlaube ihres
|
||
// Teams mit), aber entfernen nur die betroffene Person selbst, wer ihn
|
||
// eingetragen hat, oder ein Administrator. 0 = Altbestand ohne Angabe.
|
||
db.exec(`CREATE TABLE IF NOT EXISTS user_absences (
|
||
user_id INTEGER NOT NULL, id TEXT NOT NULL,
|
||
start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '',
|
||
type TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
|
||
created_by INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (user_id, id),
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)`);
|
||
// Persönliche KI-Zugangsdaten für den Protokoll-Import (POST
|
||
// /project-protokolle/import), siehe readUserAiKey()/writeUserAiKey() und
|
||
// CLAUDE.md "### Protokolle": jedes Konto pflegt seinen eigenen Schlüssel +
|
||
// optionale abweichende API-URL (z. B. ein Firmen-Proxy statt der
|
||
// offiziellen Cloud-API) unter Einstellungen → Mein Konto — bewusst eine
|
||
// eigene, kleine User-Kind-Tabelle statt Zeilen in app_settings, das
|
||
// strukturell ein firmenweiter Singleton-Speicher ist.
|
||
db.exec(`CREATE TABLE IF NOT EXISTS user_ai_keys (
|
||
user_id INTEGER NOT NULL, provider TEXT NOT NULL,
|
||
api_key TEXT NOT NULL DEFAULT '', api_url TEXT NOT NULL DEFAULT '',
|
||
PRIMARY KEY (user_id, provider),
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)`);
|
||
|
||
// benachrichtigungen: Hinweise an ein Konto, bisher nur "neue Aufgabe
|
||
// zugewiesen" (art 'aufgabe'). Entstehen in POST /save-week, wenn eine
|
||
// Aufgabe einen Zuständigen bekommt, den sie vorher nicht hatte
|
||
// (neueZuweisungenMelden()). Gelesen = in der Glocke angeklickt oder "alle
|
||
// gelesen". Keine Kopie in IMPORT_TABLES: wie die Konten selbst kontobezogen,
|
||
// ein DB-Import lässt sie unberührt. Alte Einträge räumt
|
||
// purgeBenachrichtigungen() auf.
|
||
db.exec(`CREATE TABLE IF NOT EXISTS benachrichtigungen (
|
||
id TEXT PRIMARY KEY,
|
||
user_id INTEGER NOT NULL,
|
||
art TEXT NOT NULL DEFAULT 'aufgabe',
|
||
titel TEXT NOT NULL DEFAULT '',
|
||
project_id TEXT NOT NULL DEFAULT '',
|
||
project_nr TEXT NOT NULL DEFAULT '',
|
||
project_name TEXT NOT NULL DEFAULT '',
|
||
week_label TEXT NOT NULL DEFAULT '',
|
||
todo_id TEXT NOT NULL DEFAULT '',
|
||
faellig TEXT NOT NULL DEFAULT '',
|
||
von_name TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL,
|
||
gelesen INTEGER NOT NULL DEFAULT 0,
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)`);
|
||
db.exec('CREATE INDEX IF NOT EXISTS idx_benachrichtigungen_user ON benachrichtigungen(user_id, gelesen, created_at)');
|
||
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_files (
|
||
id TEXT PRIMARY KEY,
|
||
project_nr TEXT NOT NULL,
|
||
project_name TEXT NOT NULL DEFAULT '',
|
||
original_filename TEXT NOT NULL,
|
||
stored_name TEXT NOT NULL,
|
||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||
uploaded_by_user_id INTEGER NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'pending',
|
||
created_at TEXT NOT NULL DEFAULT '',
|
||
updated_at TEXT NOT NULL DEFAULT ''
|
||
)`);
|
||
db.exec(`CREATE INDEX IF NOT EXISTS idx_project_files_scope ON project_files(project_nr)`);
|
||
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_file_reviewers (
|
||
file_id TEXT NOT NULL,
|
||
user_id INTEGER NOT NULL,
|
||
decision TEXT NOT NULL DEFAULT 'pending',
|
||
decided_at TEXT NOT NULL DEFAULT '',
|
||
note TEXT NOT NULL DEFAULT '',
|
||
PRIMARY KEY (file_id, user_id)
|
||
)`);
|
||
db.exec(`CREATE INDEX IF NOT EXISTS idx_pfr_user_pending ON project_file_reviewers(user_id, decision)`);
|
||
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_file_comments (
|
||
id TEXT PRIMARY KEY,
|
||
file_id TEXT NOT NULL,
|
||
user_id INTEGER NOT NULL,
|
||
body TEXT NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT ''
|
||
)`);
|
||
|
||
// Protokolle (Besprechungsprotokolle je Projekt) — wie project_files über
|
||
// project_nr mit dem Projekt verknüpft, NICHT über week_label/project_id:
|
||
// project_id wird bei jedem automatischen Sitzungswechsel
|
||
// (ensureCurrentWeekRollover()) neu vergeben, ein Protokoll muss aber über
|
||
// Sitzungen hinweg bestehen bleiben. week_label ist hier reine Information
|
||
// (welche Sitzung lief beim Anlegen), keine Scoping-Spalte.
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_protokolle (
|
||
id TEXT PRIMARY KEY,
|
||
project_nr TEXT NOT NULL,
|
||
project_name TEXT NOT NULL DEFAULT '',
|
||
nummer INTEGER NOT NULL,
|
||
datum TEXT NOT NULL DEFAULT '',
|
||
betreff TEXT NOT NULL DEFAULT '',
|
||
ort TEXT NOT NULL DEFAULT '',
|
||
verfasser_user_id INTEGER NOT NULL,
|
||
week_label TEXT NOT NULL DEFAULT '',
|
||
quelle_dateiname TEXT NOT NULL DEFAULT '',
|
||
quelle_stored_name TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL DEFAULT '',
|
||
updated_at TEXT NOT NULL DEFAULT ''
|
||
)`);
|
||
db.exec(`CREATE INDEX IF NOT EXISTS idx_protokolle_project ON project_protokolle(project_nr)`);
|
||
|
||
// protokoll_teilnehmer: Freitext-Teilnehmerliste je Protokoll (Name + Firma),
|
||
// unabhängig von Benutzerkonten — Baubesprechungen haben typischerweise
|
||
// externe Teilnehmer (Bauherr, ausführende Firmen) ohne Login hier.
|
||
db.exec(`CREATE TABLE IF NOT EXISTS protokoll_teilnehmer (
|
||
id TEXT PRIMARY KEY,
|
||
protokoll_id TEXT NOT NULL,
|
||
name TEXT NOT NULL DEFAULT '',
|
||
firma TEXT NOT NULL DEFAULT '',
|
||
anwesend INTEGER NOT NULL DEFAULT 1,
|
||
array_order INTEGER NOT NULL DEFAULT 0
|
||
)`);
|
||
db.exec(`CREATE INDEX IF NOT EXISTS idx_protokoll_teilnehmer_protokoll ON protokoll_teilnehmer(protokoll_id)`);
|
||
|
||
// protokoll_punkte: die eigentlichen Besprechungspunkte. stamm_id verbindet
|
||
// alle fortgeschriebenen Kopien "desselben" Punkts über mehrere Protokolle
|
||
// hinweg (siehe createProtokoll()) — nur daran hängt, ob ein Punkt als neu
|
||
// gilt (neu=1), NICHT an nummer/gruppe: beides ist freier, jederzeit vom
|
||
// Nutzer änderbarer Anzeige-Text (nummer z.B. "1", "1.1", "2.3"; gruppe z.B.
|
||
// "ELT"/"HLS" — keine feste Liste, entsteht einfach durchs Eintragen).
|
||
db.exec(`CREATE TABLE IF NOT EXISTS protokoll_punkte (
|
||
id TEXT PRIMARY KEY,
|
||
protokoll_id TEXT NOT NULL,
|
||
stamm_id TEXT NOT NULL,
|
||
nummer TEXT NOT NULL DEFAULT '',
|
||
gruppe TEXT NOT NULL DEFAULT '',
|
||
neu INTEGER NOT NULL DEFAULT 0,
|
||
thema TEXT NOT NULL DEFAULT '',
|
||
text TEXT NOT NULL DEFAULT '',
|
||
zustaendig TEXT NOT NULL DEFAULT '',
|
||
termin TEXT NOT NULL DEFAULT '',
|
||
erledigt INTEGER NOT NULL DEFAULT 0,
|
||
erstellt_am TEXT NOT NULL DEFAULT '',
|
||
status TEXT NOT NULL DEFAULT 'offen',
|
||
array_order INTEGER NOT NULL DEFAULT 0
|
||
)`);
|
||
db.exec(`CREATE INDEX IF NOT EXISTS idx_protokoll_punkte_protokoll ON protokoll_punkte(protokoll_id)`);
|
||
|
||
// lph_templates: firmenweite Aufgaben-Vorlagen je Leistungsphase und Gewerk
|
||
// (Spalte gewerk, nachgerüstet von migrateAddLphTemplateGewerk(); '' = gilt
|
||
// für alle Gewerke, nur Altbestand). Anders als
|
||
// alle Tabellen darüber hängt diese an KEINER Sitzung und an keinem Projekt —
|
||
// sie ist globale Konfiguration ("in LPH 5 machen wir immer diese Schritte"),
|
||
// aus der auf der Projektplan-Seite per Klick TODOs erzeugt werden (siehe
|
||
// js/plan/plan-templates.js). offset_days = Start relativ zum beim Einfügen
|
||
// gewählten Datum (0 = am Starttag), duration_days = Dauer in Kalendertagen;
|
||
// 0 = ohne Termin (die Aufgabe wird ohne Start/Ende eingefügt). Die erzeugten TODOs sind danach ganz normale plan_todos und
|
||
// haben keinerlei Verbindung mehr zur Vorlage — spätere Änderungen an der
|
||
// Vorlage wirken sich also NICHT auf bereits eingefügte Aufgaben aus (bewusst:
|
||
// ein laufender Projektplan soll sich nicht rückwirkend ändern).
|
||
db.exec(`CREATE TABLE IF NOT EXISTS lph_templates (
|
||
id TEXT PRIMARY KEY,
|
||
lph TEXT NOT NULL,
|
||
title TEXT NOT NULL DEFAULT '',
|
||
offset_days INTEGER NOT NULL DEFAULT 0,
|
||
duration_days INTEGER NOT NULL DEFAULT 1,
|
||
sort_order INTEGER NOT NULL DEFAULT 0
|
||
)`);
|
||
db.exec('CREATE INDEX IF NOT EXISTS idx_lph_templates_lph ON lph_templates(lph)');
|
||
|
||
// ---------- Rollen und Rechte ----------
|
||
// Eriks Wunsch: Rollen und ihre Rechte werden in der Oberfläche gepflegt
|
||
// (Admin → Rollen & Rechte, js/admin/rollen.js), neue Rollen lassen sich
|
||
// anlegen. RECHTE ist der feste Katalog — die Schlüssel stehen im Code, nur
|
||
// ihre Zuordnung zu Rollen ist frei. Fachrollen (user_roles: Planung,
|
||
// Projektleitung, Bauüberwachung) haben damit NICHTS zu tun.
|
||
//
|
||
// roles: key (= users.role), name, sort_order, fest (1 = Administrator: hat
|
||
// immer alle Rechte, ist weder änderbar noch löschbar — sonst könnte man sich
|
||
// aussperren). role_rechte: eine Zeile je Haken. Beide NICHT in
|
||
// IMPORT_TABLES: ein Backup-Import lässt Rechte wie Konten unberührt.
|
||
//
|
||
// Eine Rolle OHNE das Recht „app“ ist eine reine Wandtafel-Rolle: sie
|
||
// erreicht nur die lesenden Routen der Wandtafel (requireAuthOderKiosk) und
|
||
// zählt nirgends als Teammitglied — das frühere fest verdrahtete 'kiosk'.
|
||
const RECHTE = [
|
||
{ gruppe: 'Grundzugang', rechte: [
|
||
['app', 'Anwendung nutzen', 'Projekte, Wochenbericht, Auslastung und Meine Aufgaben sehen'],
|
||
['wand', 'Wandtafel anzeigen', ''] ]},
|
||
{ gruppe: 'Projekte', rechte: [
|
||
['proj.anlegen', 'Projekte anlegen und löschen', ''],
|
||
['proj.stamm', 'Stammdaten und Beteiligte bearbeiten', 'Projekteinstellungen'],
|
||
['proj.plan', 'Terminplan bearbeiten', 'Aufgaben, Meilensteine, Phasen, Vorlagen'],
|
||
['proj.dateien', 'Dateien hochladen, prüfen und kommentieren', ''],
|
||
['proj.dateienFremd', 'Fremde Dateien löschen', ''],
|
||
['proj.protokolle', 'Protokolle bearbeiten', 'anlegen, importieren, löschen'] ]},
|
||
{ gruppe: 'Wochenbericht', rechte: [
|
||
['wb.kommentar', 'Kommentar und Fortschritt bearbeiten', ''],
|
||
['wb.besprochen', '„Besprochen“ abhaken', ''],
|
||
['wb.anwesenheit', 'Anwesenheit erfassen', ''] ]},
|
||
{ gruppe: 'Team', rechte: [
|
||
['team.abw', 'Abwesenheiten für mich', 'eigene eintragen, ändern, löschen'],
|
||
['team.abwProjekte', 'Abwesenheiten für Personen meiner Projekte', 'Beteiligte der eigenen Projekte (aktuelle Sitzung)'],
|
||
['team.abwGewerk', 'Abwesenheiten für mein Gewerk', 'alle Konten mit demselben Gewerk'],
|
||
['team.abwAlle', 'Abwesenheiten für alle', ''] ]},
|
||
{ gruppe: 'Werkzeuge', rechte: [
|
||
['wz.planung', 'Terminplan', 'Terminpläne ohne Projekt (Seitenleiste „Terminplan“)'],
|
||
['wz.checker', 'Plan Checker', ''] ]},
|
||
{ gruppe: 'Sitzungen', rechte: [
|
||
['s.entsperren', 'Sitzung entsperren', ''],
|
||
['s.loeschen', 'Sitzung löschen', ''],
|
||
['s.gesperrt', 'Gesperrte Sitzung bearbeiten', ''] ]},
|
||
{ gruppe: 'Admin-Bereich', rechte: [
|
||
['a.benutzer', 'Benutzerverwaltung', ''], ['a.honorar', 'Honorar', ''],
|
||
['a.auslastung', 'Auslastung', ''], ['a.termine', 'Termine & Sitzungen', ''],
|
||
['a.feiertage', 'Feiertage', ''], ['a.vorlagen', 'Aufgaben-Vorlagen', ''],
|
||
['a.checker', 'Plan-Checker-Merkmale', ''], ['a.betrieb', 'Betrieb & Sicherheit', ''],
|
||
['a.papierkorb', 'Papierkorb', ''], ['a.backup', 'Datenbank-Backup', ''],
|
||
['a.import', 'Datenbank-Import', ''], ['a.rollen', 'Rollen & Rechte', ''],
|
||
['a.teams', 'Teams-Anbindung', ''] ]},
|
||
];
|
||
const RECHT_LABEL = new Map(RECHTE.flatMap(g => g.rechte.map(r => [r[0], r[1]])));
|
||
const RECHT_KEYS = new Set(RECHT_LABEL.keys());
|
||
|
||
db.exec(`CREATE TABLE IF NOT EXISTS roles (
|
||
key TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
fest INTEGER NOT NULL DEFAULT 0
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS role_rechte (
|
||
role_key TEXT NOT NULL,
|
||
recht TEXT NOT NULL,
|
||
PRIMARY KEY (role_key, recht)
|
||
)`);
|
||
|
||
// Startbelegung = Stand vor dem Umbau: Projektleiter und Mitglied alles aus
|
||
// Grundzugang bis Werkzeuge (Mitglied ohne „Projekte anlegen und löschen“),
|
||
// Wandtafel nur die Wandtafel. Läuft nur, solange roles leer ist.
|
||
(function rollenStartbelegung(){
|
||
if(db.prepare('SELECT COUNT(*) AS n FROM roles').get().n > 0) return;
|
||
const basis = RECHTE.slice(0, 5).flatMap(g => g.rechte.map(r => r[0]));
|
||
const start = [
|
||
['admin', 'Administrator', 1, []],
|
||
['projektleiter', 'Projektleiter', 0, basis],
|
||
['member', 'Mitglied', 0, basis.filter(k => k !== 'proj.anlegen')],
|
||
['kiosk', 'Wandtafel', 0, ['wand']],
|
||
];
|
||
const insR = db.prepare('INSERT INTO roles (key, name, sort_order, fest) VALUES (?, ?, ?, ?)');
|
||
const insH = db.prepare('INSERT OR IGNORE INTO role_rechte (role_key, recht) VALUES (?, ?)');
|
||
db.exec('BEGIN');
|
||
try {
|
||
start.forEach(([k, n, fest, rechte], i) => { insR.run(k, n, i, fest); rechte.forEach(r => insH.run(k, r)); });
|
||
db.exec('COMMIT');
|
||
console.log('Rollen & Rechte: Startbelegung angelegt.');
|
||
} catch(e){ db.exec('ROLLBACK'); throw e; }
|
||
})();
|
||
|
||
// rollenCache: key → { key, name, sortOrder, fest, rechte:Set } — jede
|
||
// Anfrage prüft gegen diesen Speicher, neu geladen nach jeder Änderung.
|
||
let rollenCache = new Map();
|
||
function ladeRollen(){
|
||
const m = new Map();
|
||
db.prepare('SELECT * FROM roles ORDER BY sort_order, name').all().forEach(r => {
|
||
m.set(r.key, { key: r.key, name: r.name, sortOrder: r.sort_order, fest: !!r.fest, rechte: new Set() });
|
||
});
|
||
db.prepare('SELECT * FROM role_rechte').all().forEach(r => {
|
||
const x = m.get(r.role_key);
|
||
if(x && RECHT_KEYS.has(r.recht)) x.rechte.add(r.recht);
|
||
});
|
||
rollenCache = m;
|
||
}
|
||
ladeRollen();
|
||
// Umstellung der Abwesenheits-Rechte (Eriks Wunsch): „Fremde Abwesenheiten
|
||
// löschen“ (team.abwFremd) entfällt. Startbelegung wie im Entwurf: Mitglied
|
||
// nur „für mich“, Projektleiter „für mein Gewerk“ und „für Personen meiner
|
||
// Projekte“. Selbst angelegte Rollen mit team.abwFremd bekommen „für alle“
|
||
// (Mitglied und Projektleiter hatten es aus der Startbelegung, nicht bewusst).
|
||
(function abwesenheitsRechteUmstellen(){
|
||
db.exec('CREATE TABLE IF NOT EXISTS umstellungen (name TEXT PRIMARY KEY, am TEXT NOT NULL)');
|
||
if(db.prepare("SELECT 1 FROM umstellungen WHERE name = 'abwesenheit-rechte'").get()) return;
|
||
const ins = db.prepare('INSERT OR IGNORE INTO role_rechte (role_key, recht) VALUES (?, ?)');
|
||
db.exec('BEGIN');
|
||
try {
|
||
db.prepare("SELECT role_key FROM role_rechte WHERE recht = 'team.abwFremd' AND role_key NOT IN ('member', 'projektleiter')").all()
|
||
.forEach(r => { ins.run(r.role_key, 'team.abwAlle'); ins.run(r.role_key, 'team.abw'); });
|
||
if(db.prepare("SELECT 1 FROM roles WHERE key = 'projektleiter'").get()){
|
||
['team.abw', 'team.abwGewerk', 'team.abwProjekte'].forEach(r => ins.run('projektleiter', r));
|
||
}
|
||
db.exec("DELETE FROM role_rechte WHERE recht = 'team.abwFremd'");
|
||
db.prepare("INSERT INTO umstellungen (name, am) VALUES ('abwesenheit-rechte', ?)").run(new Date().toISOString());
|
||
db.exec('COMMIT');
|
||
} catch(e){ db.exec('ROLLBACK'); throw e; }
|
||
ladeRollen();
|
||
})();
|
||
|
||
// abwesenheitZiele: für wen ein Konto Abwesenheiten eintragen, ändern und
|
||
// löschen darf (und bei wem es die Art sieht). Rückgabe: { alle:Boolean,
|
||
// ids:Set<user_id> }. Das eigene Konto gehört dazu, sobald irgendein
|
||
// Abwesenheits-Recht gesetzt ist. „Meine Projekte“ = Projekte der jüngsten
|
||
// echten Sitzung, in denen beide als Beteiligte (state > 0) stehen.
|
||
function abwesenheitZiele(user){
|
||
const ids = new Set();
|
||
if(!user) return { alle: false, ids };
|
||
if(hatRecht(user, 'team.abwAlle')) return { alle: true, ids };
|
||
const gewerk = hatRecht(user, 'team.abwGewerk');
|
||
const projekte = hatRecht(user, 'team.abwProjekte');
|
||
if(hatRecht(user, 'team.abw') || gewerk || projekte) ids.add(user.id);
|
||
if(gewerk && user.department){
|
||
db.prepare('SELECT id FROM users WHERE department = ? AND active = 1').all(user.department).forEach(r => ids.add(r.id));
|
||
}
|
||
if(projekte && user.personId){
|
||
const label = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL)
|
||
.map(r => r.label).sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b))).pop();
|
||
if(label){
|
||
db.prepare(`SELECT DISTINCT u.id FROM project_assign a
|
||
JOIN project_assign b ON b.week_label = a.week_label AND b.project_id = a.project_id AND b.state > 0
|
||
JOIN users u ON u.person_id = b.person_id
|
||
WHERE a.week_label = ? AND a.person_id = ? AND a.state > 0`).all(label, user.personId).forEach(r => ids.add(r.id));
|
||
}
|
||
}
|
||
return { alle: false, ids };
|
||
}
|
||
function darfAbwesenheit(user, zielId){
|
||
const z = abwesenheitZiele(user);
|
||
return z.alle || z.ids.has(Number(zielId));
|
||
}
|
||
|
||
function rolleExistiert(key){ return rollenCache.has(String(key)); }
|
||
// rechteDerRolle: Liste der Rechte einer Rolle (fest = alle).
|
||
function rechteDerRolle(key){
|
||
const r = rollenCache.get(String(key));
|
||
if(!r) return [];
|
||
return r.fest ? [...RECHT_KEYS] : [...r.rechte];
|
||
}
|
||
// hatRecht: darf dieses Konto das? Parameter: user (req.user), recht.
|
||
function hatRecht(user, recht){
|
||
if(!user) return false;
|
||
const r = rollenCache.get(user.role);
|
||
return !!r && (r.fest || r.rechte.has(recht));
|
||
}
|
||
// istNurWandtafel: Rolle ohne Grundzugang „Anwendung nutzen“.
|
||
function istNurWandtafel(roleKey){
|
||
const r = rollenCache.get(String(roleKey));
|
||
return !r || (!r.fest && !r.rechte.has('app'));
|
||
}
|
||
// sqlOhneWandtafel: SQL-Bedingung, die Konten reiner Wandtafel-Rollen
|
||
// ausschließt (ersetzt das frühere role != 'kiosk'). Rollenschlüssel sind
|
||
// beim Anlegen auf [a-z0-9_-] beschränkt, daher sicher einzusetzen.
|
||
function sqlOhneWandtafel(spalte){
|
||
const keys = [...rollenCache.keys()].filter(k => istNurWandtafel(k)).filter(k => /^[a-z0-9_-]+$/.test(k));
|
||
const s = spalte || 'role';
|
||
return keys.length ? `${s} NOT IN (${keys.map(k => `'${k}'`).join(',')})` : '1=1';
|
||
}
|
||
// rechtFehlt: einheitliche 403-Antwort, nennt das fehlende Recht.
|
||
function rechtFehlt(res, recht){
|
||
return res.status(403).json({ ok: false, recht, error: 'Keine Berechtigung: „' + (RECHT_LABEL.get(recht) || recht) + '“ fehlt.' });
|
||
}
|
||
// requireRecht: Middleware — angemeldet UND Recht vorhanden.
|
||
function requireRecht(recht){
|
||
return function(req, res, next){
|
||
if(!req.user) return res.status(401).json({ ok: false, error: 'Nicht angemeldet.' });
|
||
if(!hatRecht(req.user, recht)) return rechtFehlt(res, recht);
|
||
next();
|
||
};
|
||
}
|
||
|
||
// plan_checker_merkmale: firmenweite Grundliste der Merkmale, gegen die der
|
||
// Plan Checker (Seitenleisten-Bereich "Studenten") einen hochgeladenen Plan
|
||
// prüft (z. B. "Maßketten vorhanden"). "prompt" ist die Prüfanweisung, die
|
||
// an die KI geht — "titel" ist zugleich die Anzeige-Bezeichnung UND der
|
||
// Schlüssel, unter dem die KI ihr Ergebnis zurückgibt. Anders als
|
||
// lph_templates gibt es hier kein Gruppierungsfeld: die Liste gilt
|
||
// unterschiedslos für jede Prüfung (plus ein optionales, NICHT
|
||
// gespeichertes Zusatz-Merkmal nur für die jeweils eine Prüfung, siehe
|
||
// POST /plan-checker-pruefen).
|
||
db.exec(`CREATE TABLE IF NOT EXISTS plan_checker_merkmale (
|
||
id TEXT PRIMARY KEY,
|
||
titel TEXT NOT NULL,
|
||
prompt TEXT NOT NULL,
|
||
sort_order INTEGER NOT NULL DEFAULT 0
|
||
)`);
|
||
|
||
// lph_honorar_anteile: firmenweite Honorar-Anteile je Leistungsphase in
|
||
// Prozent (HOAI-Sätze). Bewusst NICHT als Konstante im Code, sondern
|
||
// pflegbar — die Sätze hängen vom Leistungsbild ab (Technische Ausrüstung
|
||
// nach Anlage 15 unterscheidet sich von der Objektplanung nach Anlage 10)
|
||
// und ändern sich mit HOAI-Novellen. Startet leer und wird in der
|
||
// Honorar-Maske auf der Projektplan-Seite von Administratoren befüllt.
|
||
db.exec(`CREATE TABLE IF NOT EXISTS lph_honorar_anteile (
|
||
lph TEXT PRIMARY KEY,
|
||
percent REAL NOT NULL DEFAULT 0
|
||
)`);
|
||
|
||
// app_settings: firmenweite Einzelwerte als Schlüssel/Wert-Paare. Werte
|
||
// stehen als TEXT und werden beim Lesen gewandelt; welche Schlüssel es gibt,
|
||
// welchen Typ und welche Vorgabe sie haben, steht in APP_SETTING_DEFS weiter
|
||
// unten — dort liegt auch die Prüfung. Eine Zeile existiert erst, wenn der
|
||
// Wert einmal gesetzt wurde; sonst gilt die Vorgabe. Damit brauchen neue
|
||
// Einstellungen weder eine eigene Tabelle noch eine Migration.
|
||
db.exec(`CREATE TABLE IF NOT EXISTS app_settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL DEFAULT ''
|
||
)`);
|
||
|
||
// holidays: firmenweite Feiertage und sonstige arbeitsfreie Tage. Bewusst
|
||
// eine eigene Tabelle statt eines JSON-Werts in app_settings — es ist eine
|
||
// Liste, die wächst, und sie soll sich einzeln pflegen lassen. Sie wirkt
|
||
// überall dort, wo in WERKTAGEN gerechnet wird: workdaysBetween()/
|
||
// addWorkdays() in public/js/utils.js, also in der Terminplan-Berechnung aus
|
||
// dem Honorar und in der Ressourcen-Auslastung des Projektbereichs. Die
|
||
// Terminplanung über Abhängigkeiten (Auto-Schedule) rechnet weiterhin in
|
||
// Kalendertagen — das war schon vorher so.
|
||
db.exec(`CREATE TABLE IF NOT EXISTS holidays (
|
||
date TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL DEFAULT ''
|
||
)`);
|
||
|
||
// ---------- Migration: altes Ein-Dokument-Format -> relationales Schema ----------
|
||
|
||
// hasLegacyWeeksTable: erkennt das alte Schema an der Daten-Spalte in `weeks`.
|
||
function hasLegacyWeeksTable(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('weeks') WHERE name = 'data'").all();
|
||
return cols.length > 0;
|
||
}
|
||
|
||
// ---------- Serverseitige Bereinigung von Formatierungs-HTML ----------
|
||
//
|
||
// Gegenstück zu sanitizeStatusHtml() (public/js/utils.js) für den Server.
|
||
// Dieselbe Positivliste, dieselben Regeln — aber ohne DOM, deshalb ein
|
||
// eigener, kleiner Tokenizer statt eines Parsers.
|
||
//
|
||
// Warum überhaupt serverseitig, wo der Client doch schon bereinigt: Weil die
|
||
// Bereinigung im Client eine ANZEIGE-Maßnahme ist, keine Eingangskontrolle.
|
||
// Wer eine gültige Anmeldung hat, kann die Routen direkt ansprechen und
|
||
// beliebiges HTML in einen Kommentar oder Protokollpunkt schreiben. Bis
|
||
// hierher wurde das unverändert gespeichert und erst beim Anzeigen entschärft
|
||
// — jede Stelle, die das Feld künftig einmal ohne Bereinigung anfasst, wäre
|
||
// damit sofort ein Einfallstor. Die Prüfung gehört an die Schreibstelle.
|
||
//
|
||
// Bewusst keine Abhängigkeit: Das Projekt kommt mit express/cors plus den
|
||
// drei Protokoll-Import-Bibliotheken aus; ein Sanitizer-Paket dafür
|
||
// aufzunehmen stünde in keinem Verhältnis zu diesen gut hundert Zeilen.
|
||
|
||
const SICHERE_TAGS = new Set(['b','strong','i','em','u','p','br','div','span','font']);
|
||
// Tags, deren INHALT mitverschwindet, statt ausgepackt zu werden: Bei
|
||
// <script> wäre der ausgepackte Rest sonst der sichtbare Quelltext.
|
||
const TAGS_MIT_INHALT_VERWERFEN = new Set(['script','style','noscript','template','iframe','object','embed']);
|
||
// Leere Elemente haben kein schließendes Gegenstück.
|
||
const LEERE_TAGS = new Set(['br']);
|
||
|
||
// htmlEscape: maskiert Text für die Ausgabe in HTML (wie esc() im Client).
|
||
function htmlEscape(s){
|
||
return String(s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
// 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 += '<';
|
||
i++;
|
||
continue;
|
||
}
|
||
i = tag.ende;
|
||
if(tag.kommentar) continue;
|
||
|
||
if(verwerfenBis){
|
||
// Innerhalb eines verworfenen Blocks zählt nur, wann er wieder endet.
|
||
if(tag.name === verwerfenBis){
|
||
if(tag.schliessend){ verwerfenTiefe--; if(verwerfenTiefe <= 0) verwerfenBis = null; }
|
||
else verwerfenTiefe++;
|
||
}
|
||
continue;
|
||
}
|
||
if(TAGS_MIT_INHALT_VERWERFEN.has(tag.name)){
|
||
if(!tag.schliessend){ verwerfenBis = tag.name; verwerfenTiefe = 1; }
|
||
continue;
|
||
}
|
||
if(!SICHERE_TAGS.has(tag.name)) continue; // auspacken: Tag weg, Inhalt bleibt
|
||
|
||
if(tag.schliessend){
|
||
// Nur schliessen, wenn das Tag auch offen ist — sonst ignorieren.
|
||
const pos = offen.lastIndexOf(tag.name);
|
||
if(pos !== -1){
|
||
// Zwischendrin offen gebliebene Tags mitschliessen (korrekte Schachtelung).
|
||
for(let k = offen.length - 1; k >= pos; k--) aus += '</' + offen[k] + '>';
|
||
offen.length = pos;
|
||
}
|
||
continue;
|
||
}
|
||
const attrs = attributeLesen(tag.attribute);
|
||
const style = sicheresStyleAttribut(attrs.style);
|
||
aus += '<' + tag.name + (style ? ' style="' + htmlEscape(style) + '"' : '') + '>';
|
||
if(!LEERE_TAGS.has(tag.name)) offen.push(tag.name);
|
||
}
|
||
for(let k = offen.length - 1; k >= 0; k--) aus += '</' + offen[k] + '>';
|
||
return aus;
|
||
}
|
||
|
||
// serializeWeek: zerlegt ein Wochen-Objekt (JSON vom Frontend) in die relationalen
|
||
// Tabellen. Wird in einer Transaktion vom Aufrufer verwendet (deleteWeekData +
|
||
// serializeWeek). Ersetzt alle Zeilen der Woche.
|
||
function serializeWeek(week){
|
||
const weekLabel = String(week.label || '');
|
||
const nowIso = new Date().toISOString(); // Revision: wird bei jedem Speichern aktualisiert
|
||
const upsertWeek = db.prepare(`INSERT INTO weeks (label, week_id, date, locked, updated_at) VALUES (?, ?, ?, ?, ?)
|
||
ON CONFLICT(label) DO UPDATE SET week_id = excluded.week_id, date = excluded.date, locked = excluded.locked, updated_at = excluded.updated_at`);
|
||
upsertWeek.run(weekLabel, String(week.id || ''), String(week.date || ''), week.locked ? 1 : 0, nowIso);
|
||
|
||
const insWeekPerson = db.prepare(`INSERT INTO week_people (week_label, person_id, name, full_name, array_order)
|
||
VALUES (?, ?, ?, ?, ?)`);
|
||
const insWeekPersonRole = db.prepare(`INSERT OR IGNORE INTO week_people_roles (week_label, person_id, role, role_order)
|
||
VALUES (?, ?, ?, ?)`);
|
||
const insAttendance = db.prepare(`INSERT OR REPLACE INTO attendance (week_label, person_id, present)
|
||
VALUES (?, ?, ?)`);
|
||
|
||
// Sicherheitsnetz gegen doppelte Personen-Kennungen im Teilnehmerkreis:
|
||
// ein zweiter Eintrag mit derselben id würde den Primärschlüssel verletzen
|
||
// und die gesamte Transaktion (= das Speichern der Sitzung) abbrechen.
|
||
const seenPersonIds = new Set();
|
||
(Array.isArray(week.people) ? week.people : []).filter(p => {
|
||
const pid = String((p && p.id) || '');
|
||
if(!pid || seenPersonIds.has(pid)) return false;
|
||
seenPersonIds.add(pid);
|
||
return true;
|
||
}).forEach((p, idx) => {
|
||
insWeekPerson.run(weekLabel, String(p.id || ''), String(p.name || ''), String(p.fullName || ''), idx);
|
||
(Array.isArray(p.role) ? p.role : []).forEach((r, ridx) => insWeekPersonRole.run(weekLabel, String(p.id || ''), String(r || ''), ridx));
|
||
});
|
||
const att = week.attendance || {};
|
||
Object.keys(att).forEach(pid => {
|
||
insAttendance.run(weekLabel, pid, att[pid] ? 1 : 0);
|
||
});
|
||
|
||
(Array.isArray(week.projects) ? week.projects : []).forEach((p, idx) => serializeProject(weekLabel, p, idx, nowIso));
|
||
// Rückgabe der Revision für den Aufrufer (saveWeekRelational -> Route)
|
||
return nowIso;
|
||
}
|
||
|
||
// projektStatements: die vorbereiteten INSERTs für ein Projekt, einmal angelegt.
|
||
let projektStatementsCache = null;
|
||
function projektStatements(){
|
||
if(projektStatementsCache) return projektStatementsCache;
|
||
const insProject = db.prepare(`INSERT INTO projects (week_label, project_id, nr, name, status, status_author, status_at, "group", array_order, order_value, project_uid, honorar, besprochen, auftragsstatus, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||
const insBeauftragteLp = db.prepare(`INSERT OR IGNORE INTO project_beauftragte_lph (week_label, project_id, lph)
|
||
VALUES (?, ?, ?)`);
|
||
const insGewerk = db.prepare(`INSERT OR IGNORE INTO project_gewerke (week_label, project_id, gewerk)
|
||
VALUES (?, ?, ?)`);
|
||
const insKommentar = db.prepare(`INSERT OR REPLACE INTO project_kommentare (week_label, project_id, gewerk, status, status_author, status_at, besprochen)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||
const insTag = db.prepare(`INSERT OR IGNORE INTO project_tags (week_label, project_id, tag, tag_order)
|
||
VALUES (?, ?, ?, ?)`);
|
||
const insLph = db.prepare(`INSERT OR REPLACE INTO project_lph (week_label, project_id, lph, value)
|
||
VALUES (?, ?, ?, ?)`);
|
||
const insAssign = db.prepare(`INSERT OR REPLACE INTO project_assign (week_label, project_id, person_id, role, state)
|
||
VALUES (?, ?, ?, ?, ?)`);
|
||
const insAufwand = db.prepare(`INSERT OR REPLACE INTO project_aufwand (week_label, project_id, lph, role, value)
|
||
VALUES (?, ?, ?, ?, ?)`);
|
||
const insAktuelleLp = db.prepare(`INSERT OR IGNORE INTO project_aktuelle_lp (week_label, project_id, lph)
|
||
VALUES (?, ?, ?)`);
|
||
const insPlan = db.prepare(`INSERT OR REPLACE INTO project_plan (week_label, project_id, start, end, progress)
|
||
VALUES (?, ?, ?, ?, ?)`);
|
||
const insPlanPerson = db.prepare(`INSERT OR IGNORE INTO plan_people (week_label, project_id, person_id, array_order)
|
||
VALUES (?, ?, ?, ?)`);
|
||
const insTodo = db.prepare(`INSERT INTO plan_todos (week_label, project_id, todo_id, title, start, end, done, progress, sort_order, show_in_gantt, leistungsphase, aufwand_stunden, notiz)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||
const insTodoAssignee = db.prepare(`INSERT OR IGNORE INTO plan_todo_assignees (week_label, project_id, todo_id, person_id)
|
||
VALUES (?, ?, ?, ?)`);
|
||
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id)
|
||
VALUES (?, ?, ?, ?)`);
|
||
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order, notiz)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
||
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id)
|
||
VALUES (?, ?, ?, ?)`);
|
||
|
||
projektStatementsCache = { insProject, insBeauftragteLp, insGewerk, insKommentar, insTag, insLph, insAssign, insAufwand, insAktuelleLp, insPlan, insPlanPerson, insTodo, insTodoAssignee, insTodoDep, insMilestone, insMilestoneDep };
|
||
return projektStatementsCache;
|
||
}
|
||
|
||
// serializeProject: schreibt EIN Projekt einer Sitzung in alle Projekttabellen
|
||
// (die Zeilen müssen vorher fehlen — deleteWeekData() bzw. deleteProjectData()).
|
||
// Gemeinsam genutzt von serializeWeek() und POST /save-projects.
|
||
// Parameter: weekLabel, p (Projekt), idx (Position, array_order), nowIso
|
||
// (Revision des Projekts, projects.updated_at).
|
||
function serializeProject(weekLabel, p, idx, nowIso){
|
||
const { insProject, insBeauftragteLp, insGewerk, insKommentar, insTag, insLph, insAssign, insAufwand, insAktuelleLp, insPlan, insPlanPerson, insTodo, insTodoAssignee, insTodoDep, insMilestone, insMilestoneDep } = projektStatements();
|
||
const projId = String(p.id || '');
|
||
// status trägt Formatierungs-HTML (Fett/Kursiv/Farbe aus dem
|
||
// Kommentar-Editor) und wird hier — an der Schreibstelle — auf die
|
||
// erlaubte Positivliste reduziert, nicht erst beim Anzeigen. Siehe
|
||
// sanitizeFormatHtml() oben.
|
||
// status/status_author/status_at/besprochen bleiben leer: Kommentar und
|
||
// Haken stehen je Gewerk in project_kommentare (siehe dort).
|
||
insProject.run(weekLabel, projId, String(p.nr || ''), String(p.name || ''),
|
||
'', '', '',
|
||
String(p.group || ''), idx, typeof p.order === 'number' ? p.order : idx,
|
||
String(p.projectUid || ''), Number(p.honorar) || 0, 0,
|
||
normalizeAuftragsstatus(p.auftragsstatus), nowIso);
|
||
(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []).forEach(lph => {
|
||
insBeauftragteLp.run(weekLabel, projId, String(lph || ''));
|
||
});
|
||
// Nur gültige Gewerke schreiben: Ein unbekannter Wert wäre stiller
|
||
// Datenmüll, der die Beteiligten-Auswahl später leer laufen ließe.
|
||
(Array.isArray(p.gewerke) ? p.gewerke : []).forEach(g => {
|
||
const wert = String(g || '').trim().toUpperCase();
|
||
if(VALID_DEPARTMENTS.has(wert)) insGewerk.run(weekLabel, projId, wert);
|
||
});
|
||
(Array.isArray(p.tags) ? p.tags : []).forEach((tag, tidx) => {
|
||
const clean = String(tag || '').trim();
|
||
if(clean) insTag.run(weekLabel, projId, clean, tidx);
|
||
});
|
||
const kommentare = (p.kommentare && typeof p.kommentare === 'object') ? p.kommentare : {};
|
||
Object.keys(kommentare).forEach(g => {
|
||
const wert = String(g || '').trim().toUpperCase();
|
||
const k = kommentare[g] || {};
|
||
const text = sanitizeFormatHtml(k.status);
|
||
// Leere Einträge nicht speichern — kein Kommentar und kein Haken ist
|
||
// derselbe Zustand wie keine Zeile.
|
||
if(!VALID_DEPARTMENTS.has(wert) || (!text && !k.besprochen)) return;
|
||
insKommentar.run(weekLabel, projId, wert, text,
|
||
text ? String(k.statusAuthor || '') : '', text ? String(k.statusAt || '') : '', k.besprochen ? 1 : 0);
|
||
});
|
||
const lph = p.lph || {};
|
||
Object.keys(lph).forEach(l => insLph.run(weekLabel, projId, String(l || ''), String(lph[l] === undefined ? '' : lph[l])));
|
||
const assign = p.assign || {};
|
||
Object.keys(assign).forEach(key => {
|
||
// Key-Format: "personId|Rolle"
|
||
const sep = key.indexOf('|');
|
||
const pid = sep >= 0 ? key.slice(0, sep) : key;
|
||
const role = sep >= 0 ? key.slice(sep + 1) : '';
|
||
insAssign.run(weekLabel, projId, pid, role, Number(assign[key]) || 0);
|
||
});
|
||
const aufwand = p.aufwandProLP || {};
|
||
Object.keys(aufwand).forEach(lph => {
|
||
const roles = aufwand[lph] || {};
|
||
Object.keys(roles).forEach(role => {
|
||
const v = Number(roles[role]);
|
||
if(!isNaN(v)) insAufwand.run(weekLabel, projId, String(lph || ''), String(role || ''), v);
|
||
});
|
||
});
|
||
(Array.isArray(p.aktuelleLP) ? p.aktuelleLP : []).forEach(lph => {
|
||
insAktuelleLp.run(weekLabel, projId, String(lph || ''));
|
||
});
|
||
const plan = p.plan || null;
|
||
if(plan){
|
||
insPlan.run(weekLabel, projId, String(plan.start || ''), String(plan.end || ''), Number(plan.progress) || 0);
|
||
(Array.isArray(plan.people) ? plan.people : []).forEach((pid, pidx) => insPlanPerson.run(weekLabel, projId, String(pid || ''), pidx));
|
||
(Array.isArray(plan.todos) ? plan.todos : []).forEach((t, tidx) => {
|
||
insTodo.run(weekLabel, projId, String(t.id || ''), String(t.title || ''),
|
||
String(t.start || ''), String(t.end || ''), t.done ? 1 : 0, Number(t.progress) || 0, tidx,
|
||
t.showInGantt === false ? 0 : 1, String(t.lph || ''),
|
||
Math.max(0, Number(t.aufwand) || 0), String(t.notiz || '').slice(0, 20000));
|
||
(Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => insTodoAssignee.run(weekLabel, projId, String(t.id || ''), String(pid || '')));
|
||
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || '')));
|
||
});
|
||
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => {
|
||
insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx, String(m.notiz || '').slice(0, 20000));
|
||
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || '')));
|
||
});
|
||
}
|
||
}
|
||
|
||
// deleteProjectData: entfernt alle Zeilen EINES Projekts einer Sitzung.
|
||
function deleteProjectData(weekLabel, projectId){
|
||
['project_tags','project_lph','project_assign','project_aufwand','project_aktuelle_lp',
|
||
'project_beauftragte_lph','project_gewerke','project_kommentare','project_plan','plan_people',
|
||
'plan_todos','plan_todo_assignees','plan_todo_deps','plan_milestones','plan_milestone_deps','projects']
|
||
.forEach(t => db.prepare(`DELETE FROM ${t} WHERE week_label = ? AND project_id = ?`).run(weekLabel, projectId));
|
||
}
|
||
|
||
// deleteWeekData: entfernt alle relationalen Zeilen einer Woche (Label)
|
||
function deleteWeekData(weekLabel){
|
||
const tables = ['week_people_roles','week_people','attendance','project_tags','project_lph','project_assign',
|
||
'project_aufwand','project_aktuelle_lp','project_beauftragte_lph','project_gewerke','project_kommentare',
|
||
'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps',
|
||
'plan_milestones','plan_milestone_deps','projects'];
|
||
tables.forEach(t => {
|
||
db.prepare(`DELETE FROM ${t} WHERE week_label = ?`).run(weekLabel);
|
||
});
|
||
// Die Wochen-Tabelle selbst nutzt `label` statt `week_label`
|
||
db.prepare('DELETE FROM weeks WHERE label = ?').run(weekLabel);
|
||
}
|
||
|
||
// saveWeekRelational: speichert eine Woche vollständig relational (Transaktion).
|
||
// Rückgabe: die neu gesetzte Revision `updated_at` (ISO-Zeitstempel), damit der
|
||
// Client seine lokale Basis aktualisieren kann (sonst würde er beim nächsten
|
||
// Speichern einen falschen Konflikt mit sich selbst melden).
|
||
function saveWeekRelational(week){
|
||
const weekLabel = String(week.label || '');
|
||
const tx = db.exec('BEGIN');
|
||
try {
|
||
deleteWeekData(weekLabel);
|
||
const updatedAt = serializeWeek(week);
|
||
db.exec('COMMIT');
|
||
return updatedAt;
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// loadGlobalPeople: setzt die (jetzt firmenweite, nicht mehr abteilungs-
|
||
// gescopte) Personenliste aus den Tabellen users/user_roles/user_absences
|
||
// zusammen. Rückgabeform unverändert: Array von
|
||
// {id,name,fullName,email,role:[],absences:[]}.
|
||
// fuerUser (optional): Art und Notiz einer Abwesenheit sieht nur die Person
|
||
// selbst und der Administrator (feste Rolle 'admin'); für alle anderen kommen
|
||
// fremde Abwesenheiten als „abwesend“ ohne Notiz an — unabhängig von weiteren
|
||
// Rechten (Eriks Vorgabe: „jeder User sieht nur bei sich die genauen Gründe“,
|
||
// „der Admin sieht alles“). darfAbw sagt
|
||
// dem Browser, für wen er eintragen darf. Ohne fuerUser (Sitzungswechsel
|
||
// u. ä., nie an einen Browser) ungefiltert.
|
||
function loadGlobalPeople(fuerUser){
|
||
// person_id != '' : Ein Konto ohne Personen-Kennung (z. B. das Bootstrap-
|
||
// Konto "admin") ist keine planbare Person. Ohne diesen Filter landen
|
||
// mehrere solcher Konten mit derselben leeren Kennung im Teilnehmerkreis
|
||
// einer Sitzung und verletzen dort den Primärschlüssel
|
||
// (week_label, person_id) — das Speichern schlüge dauerhaft mit 500 fehl.
|
||
const users = db.prepare(`SELECT * FROM users WHERE active = 1 AND person_id != '' AND ${sqlOhneWandtafel()} ORDER BY display_name, username`).all();
|
||
const roles = db.prepare(`SELECT * FROM user_roles ORDER BY role_order`).all();
|
||
const absences = db.prepare(`SELECT * FROM user_absences`).all();
|
||
const rolesByUser = {}; roles.forEach(r => (rolesByUser[r.user_id] = rolesByUser[r.user_id] || []).push(r.role));
|
||
const ziele = fuerUser ? abwesenheitZiele(fuerUser) : null;
|
||
const sichtbar = id => !fuerUser || fuerUser.role === 'admin' || id === fuerUser.id;
|
||
const absByUser = {}; absences.forEach(a => (absByUser[a.user_id] = absByUser[a.user_id] || []).push(sichtbar(a.user_id)
|
||
? { id:a.id, start:a.start, end:a.end, type:a.type, note:a.note }
|
||
: { id:a.id, start:a.start, end:a.end, type:'abwesend', note:'', verdeckt:true }));
|
||
return users.map(u => ({
|
||
id: u.person_id,
|
||
userId: u.id,
|
||
darfAbw: !!ziele && (ziele.alle || ziele.ids.has(u.id)),
|
||
name: u.initials || u.username,
|
||
fullName: u.display_name,
|
||
email: u.email,
|
||
role: rolesByUser[u.id] || [],
|
||
absences: absByUser[u.id] || [],
|
||
// Vertragliche Wochenstunden; 0 heißt "nicht gepflegt" und wird im
|
||
// Frontend wie eine volle Stelle behandelt (js/load/load.js).
|
||
capacityHours: Number(u.capacity_hours) || 0,
|
||
// Gewerk der Person (users.department, ein VALID_DEPARTMENTS-Wert).
|
||
// Nötig für die Auswahl der Projektbeteiligten: Ein Projekt trägt seine
|
||
// Gewerke (project_gewerke), und angeboten werden zunächst nur die
|
||
// Mitarbeiter daraus. Leer = keinem Gewerk zugeordnet; solche Personen
|
||
// erscheinen nur über die ausdrückliche Auswahl "aus anderen Gewerken".
|
||
gewerk: u.department || '',
|
||
}));
|
||
}
|
||
|
||
// loadWeeks: setzt alle Wochen (jetzt eine einzige globale Reihe, nicht mehr
|
||
// pro Abteilung) aus den relationalen Tabellen wieder zu kompletten
|
||
// Wochen-Objekten zusammen. Rückgabe: Array von Wochen-Objekten, sortiert
|
||
// nach Label.
|
||
function loadWeeks(nurLabel){
|
||
// nurLabel (optional): nur diese eine Sitzung laden — für GET /week und die
|
||
// Live-Ereignisse, die sonst jedes Mal alle Sitzungen zusammensetzen müssten.
|
||
// Auch eine Liste von Labels (für /load-all und /weeks-aelter, die nur
|
||
// einige Sitzungen liefern).
|
||
const labels = nurLabel === undefined ? null : (Array.isArray(nurLabel) ? nurLabel : [nurLabel]).map(String);
|
||
if(labels && labels.length === 0) return [];
|
||
const alle = (tabelle, reihenfolge) => {
|
||
const spalte = tabelle === 'weeks' ? 'label' : 'week_label';
|
||
const wo = labels ? ` WHERE ${spalte} IN (${labels.map(() => '?').join(',')})` : '';
|
||
const st = db.prepare(`SELECT * FROM ${tabelle}${wo}${reihenfolge ? ' ' + reihenfolge : ''}`);
|
||
return labels ? st.all(...labels) : st.all();
|
||
};
|
||
const weeks = alle('weeks', 'ORDER BY label');
|
||
const weekPeople = alle('week_people', 'ORDER BY array_order');
|
||
const weekRoles = alle('week_people_roles', 'ORDER BY role_order');
|
||
const attendance = alle('attendance');
|
||
const projects = alle('projects', 'ORDER BY array_order');
|
||
const tags = alle('project_tags', 'ORDER BY tag_order');
|
||
const lph = alle('project_lph');
|
||
const assign = alle('project_assign');
|
||
const aufwand = alle('project_aufwand');
|
||
const aktuelleLp = alle('project_aktuelle_lp');
|
||
const beauftragteLp = alle('project_beauftragte_lph');
|
||
const gewerke = alle('project_gewerke');
|
||
const kommentare = alle('project_kommentare');
|
||
const plans = alle('project_plan');
|
||
const planPeople = alle('plan_people', 'ORDER BY array_order');
|
||
const todos = alle('plan_todos', 'ORDER BY sort_order');
|
||
const todoAssignees = alle('plan_todo_assignees');
|
||
const todoDeps = alle('plan_todo_deps');
|
||
const milestones = alle('plan_milestones', 'ORDER BY sort_order');
|
||
const milestoneDeps = alle('plan_milestone_deps');
|
||
|
||
// Index-Karten für schnellen Zugriff
|
||
const weekKey = (label) => label;
|
||
const projKey = (label, pid) => label + '|' + pid;
|
||
const todoKey = (label, pid, tid) => label + '|' + pid + '|' + tid;
|
||
const msKey = (label, pid, mid) => label + '|' + pid + '|' + mid;
|
||
|
||
const weekPeopleMap = {};
|
||
const weekRolesMap = {};
|
||
const attendanceMap = {};
|
||
// Anhängen statt per Index setzen: Die Abfrage ist bereits nach array_order
|
||
// sortiert. Eine Index-Zuweisung würde bei zwei gleichen Ordnungszahlen in
|
||
// derselben Woche (möglich nach der Abteilungs-Zusammenführung) die erste
|
||
// Person still überschreiben und Lücken im Array hinterlassen.
|
||
weekPeople.forEach(r => {
|
||
const k = weekKey(r.week_label);
|
||
(weekPeopleMap[k] = weekPeopleMap[k] || []).push({ id: r.person_id, name: r.name, fullName: r.full_name });
|
||
});
|
||
weekRoles.forEach(r => {
|
||
const k = weekKey(r.week_label);
|
||
const arr = (weekRolesMap[k] = weekRolesMap[k] || {});
|
||
(arr[r.person_id] = arr[r.person_id] || []).push(r.role);
|
||
});
|
||
attendance.forEach(r => {
|
||
attendanceMap[weekKey(r.week_label) + '|' + r.person_id] = !!r.present;
|
||
});
|
||
|
||
const projectsMap = {};
|
||
projects.forEach(r => {
|
||
projectsMap[projKey(r.week_label, r.project_id)] = {
|
||
id: r.project_id, nr: r.nr, name: r.name,
|
||
// Kommentar und Besprochen-Haken je Gewerk (project_kommentare).
|
||
kommentare: {},
|
||
group: r.group,
|
||
tags: [], lph: {}, assign: {}, order: r.order_value, plan: null,
|
||
projectUid: r.project_uid,
|
||
honorar: r.honorar,
|
||
// Rückfall auf die Vorgabe, weil Zeilen aus der Zeit vor der Spalte
|
||
// (und aus einem älteren Backup, siehe IMPORT_OPTIONAL_COLS) einen
|
||
// leeren Wert tragen können — die Oberfläche soll keinen Leerzustand
|
||
// anzeigen müssen.
|
||
auftragsstatus: normalizeAuftragsstatus(r.auftragsstatus),
|
||
beauftragteLP: [],
|
||
gewerke: [],
|
||
updatedAt: r.updated_at || '',
|
||
_arrayOrder: r.array_order,
|
||
};
|
||
});
|
||
tags.forEach(r => { const p = projectsMap[projKey(r.week_label, r.project_id)]; if(p) p.tags.push(r.tag); });
|
||
lph.forEach(r => { const p = projectsMap[projKey(r.week_label, r.project_id)]; if(p) p.lph[r.lph] = r.value; });
|
||
assign.forEach(r => {
|
||
const p = projectsMap[projKey(r.week_label, r.project_id)];
|
||
if(p) p.assign[r.person_id + (r.role ? '|' + r.role : '')] = r.state;
|
||
});
|
||
aufwand.forEach(r => {
|
||
const p = projectsMap[projKey(r.week_label, r.project_id)];
|
||
if(p){ p.aufwandProLP = p.aufwandProLP || {}; (p.aufwandProLP[r.lph] = p.aufwandProLP[r.lph] || {})[r.role] = r.value; }
|
||
});
|
||
aktuelleLp.forEach(r => {
|
||
const p = projectsMap[projKey(r.week_label, r.project_id)];
|
||
if(p){ p.aktuelleLP = p.aktuelleLP || []; if(!p.aktuelleLP.includes(r.lph)) p.aktuelleLP.push(r.lph); }
|
||
});
|
||
beauftragteLp.forEach(r => {
|
||
const p = projectsMap[projKey(r.week_label, r.project_id)];
|
||
if(p && !p.beauftragteLP.includes(r.lph)) p.beauftragteLP.push(r.lph);
|
||
});
|
||
gewerke.forEach(r => {
|
||
const p = projectsMap[projKey(r.week_label, r.project_id)];
|
||
if(p && !p.gewerke.includes(r.gewerk)) p.gewerke.push(r.gewerk);
|
||
});
|
||
kommentare.forEach(r => {
|
||
const p = projectsMap[projKey(r.week_label, r.project_id)];
|
||
if(p) p.kommentare[r.gewerk] = { status: r.status || '', statusAuthor: r.status_author || '', statusAt: r.status_at || '', besprochen: !!r.besprochen };
|
||
});
|
||
|
||
const planMap = {};
|
||
plans.forEach(r => { planMap[projKey(r.week_label, r.project_id)] = { start: r.start, end: r.end, progress: r.progress, people: [], todos: [], milestones: [] }; });
|
||
planPeople.forEach(r => {
|
||
const plan = planMap[projKey(r.week_label, r.project_id)];
|
||
if(plan) plan.people.push(r.person_id);
|
||
});
|
||
|
||
const todoMap = {};
|
||
todos.forEach(r => {
|
||
const k = todoKey(r.week_label, r.project_id, r.todo_id);
|
||
todoMap[k] = { id: r.todo_id, title: r.title, start: r.start, end: r.end, done: !!r.done, dependsOn: [], assignees: [], progress: r.progress, showInGantt: !!r.show_in_gantt, lph: r.leistungsphase || '', aufwand: Number(r.aufwand_stunden) || 0, notiz: r.notiz || '' };
|
||
});
|
||
todoAssignees.forEach(r => {
|
||
const t = todoMap[todoKey(r.week_label, r.project_id, r.todo_id)];
|
||
if(t) t.assignees.push(r.person_id);
|
||
});
|
||
todoDeps.forEach(r => {
|
||
const t = todoMap[todoKey(r.week_label, r.project_id, r.todo_id)];
|
||
if(t) t.dependsOn.push(r.depends_on_id);
|
||
});
|
||
|
||
const msMap = {};
|
||
milestones.forEach(r => {
|
||
const k = msKey(r.week_label, r.project_id, r.milestone_id);
|
||
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [], notiz: r.notiz || '' };
|
||
});
|
||
milestoneDeps.forEach(r => {
|
||
const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)];
|
||
if(m) m.dependsOn.push(r.depends_on_id);
|
||
});
|
||
|
||
// TODO- und Meilenstein-Objekte in die Projekt-Pläne einsortieren
|
||
Object.keys(todoMap).forEach(k => {
|
||
const parts = k.split('|');
|
||
const plan = planMap[parts[0] + '|' + parts[1]];
|
||
if(plan) plan.todos.push(todoMap[k]);
|
||
});
|
||
Object.keys(msMap).forEach(k => {
|
||
const parts = k.split('|');
|
||
const plan = planMap[parts[0] + '|' + parts[1]];
|
||
if(plan) plan.milestones.push(msMap[k]);
|
||
});
|
||
|
||
// Wochen-Objekte zusammensetzen
|
||
return weeks.map(w => {
|
||
const wkey = weekKey(w.label);
|
||
const people = (weekPeopleMap[wkey] || []).filter(Boolean).map(p => {
|
||
const roles = (weekRolesMap[wkey] || {})[p.id] || [];
|
||
const full = Object.assign({}, p);
|
||
if(roles.length) full.role = roles;
|
||
return full;
|
||
});
|
||
const att = {};
|
||
(weekPeopleMap[wkey] || []).filter(Boolean).forEach(p => {
|
||
const present = attendanceMap[wkey + '|' + p.id];
|
||
if(present !== undefined) att[p.id] = present;
|
||
});
|
||
const weekProjects = Object.keys(projectsMap)
|
||
.filter(k => k.startsWith(w.label + '|'))
|
||
.map(k => {
|
||
const p = projectsMap[k];
|
||
const plan = planMap[k];
|
||
if(plan) p.plan = plan;
|
||
return p;
|
||
})
|
||
.sort((a,b) => a._arrayOrder - b._arrayOrder)
|
||
.map(({ _arrayOrder, ...rest }) => rest);
|
||
return {
|
||
id: w.week_id, label: w.label, date: w.date,
|
||
people, projects: weekProjects, attendance: att, locked: !!w.locked,
|
||
updatedAt: w.updated_at || '',
|
||
};
|
||
});
|
||
}
|
||
|
||
// ---------- Datenbank-Backup ----------
|
||
//
|
||
// Zwei Wege: (1) Beim Serverstart wird automatisch eine Kopie der SQLite-DB
|
||
// als zeitgestempelte Datei in sessions/backups/ abgelegt (nur die letzten
|
||
// aufbewahrten Backups werden behalten). (2) GET /backup liefert einen konsistenten
|
||
// Snapshot der DB als herunterladbare .db-Datei (z. B. über den Button
|
||
// "DB Backup" im Menü der Hauptseite). Beide nutzen `VACUUM INTO`, das auch
|
||
// bei aktivem WAL-Mode eine konsistente Einzeldatei erzeugt (WAL-Inhalte
|
||
// werden einbezogen).
|
||
|
||
// BACKUP_DIR: Zielordner der automatischen Backups (sessions/backups, Docker-
|
||
// bind-mounted → bleibt persistent).
|
||
const BACKUP_DIR = path.join(SESSIONS_DIR, 'backups');
|
||
// Anzahl der automatisch behaltenen Backups (die ältesten werden verworfen)
|
||
// Wie viele automatische Backups aufbewahrt werden, ist seit dem Ausbau der
|
||
// Einstellungen firmenweit pflegbar (Schlüssel 'maxBackups'); dieser Wert ist
|
||
// nur noch die Vorgabe, falls nichts gesetzt ist.
|
||
const DEFAULT_MAX_BACKUPS = 10;
|
||
|
||
// createDbSnapshot: erzeugt einen konsistenten Snapshot der SQLite-DB in die
|
||
// angegebene Zieldatei (VACUUM INTO; funktioniert auch bei WAL-Mode).
|
||
function createDbSnapshot(targetPath){
|
||
const quoted = String(targetPath).replace(/'/g, "''");
|
||
db.exec(`VACUUM INTO '${quoted}'`);
|
||
}
|
||
|
||
// runAutoBackup: legt ein zeitgestempeltes Backup in BACKUP_DIR ab und
|
||
// verwirft ältere, sobald die eingestellte Anzahl überschritten ist. Fehler werden nur
|
||
// geloggt (Backup darf den Serverstart nicht verhindern).
|
||
function runAutoBackup(){
|
||
try {
|
||
if(!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||
// Zeitstempel mit Millisekunden, damit auch zwei Backups innerhalb einer
|
||
// Sekunde (z. B. direkt nach dem Import) eindeutige Dateinamen erhalten.
|
||
const base = 'projektuebersicht_' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23);
|
||
let file = path.join(BACKUP_DIR, base + '.db');
|
||
let suffix = 1;
|
||
while(fs.existsSync(file)) file = path.join(BACKUP_DIR, base + '-' + (suffix++) + '.db');
|
||
createDbSnapshot(file);
|
||
console.log('Datenbank-Backup erstellt:', file);
|
||
|
||
// Nur die letzten (einstellbar, Schlüssel 'maxBackups') behalten
|
||
const backups = fs.readdirSync(BACKUP_DIR)
|
||
.filter(f => /^projektuebersicht_.+\.db$/.test(f))
|
||
.map(f => ({ name: f, mtime: fs.statSync(path.join(BACKUP_DIR, f)).mtimeMs }))
|
||
.sort((a, b) => b.mtime - a.mtime);
|
||
backups.slice(readAppSetting('maxBackups')).forEach(b => {
|
||
fs.unlinkSync(path.join(BACKUP_DIR, b.name));
|
||
});
|
||
} catch(err){
|
||
console.error('Automatisches Backup fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
|
||
// ---------- Datenbank-Import (Wiederherstellung aus Backup) ----------
|
||
//
|
||
// POST /import nimmt eine mit "DB Backup" erzeugte .db-Datei als rohen
|
||
// Binär-Body entgegen, validiert das Schema und ersetzt den gesamten
|
||
// Datenbestand der laufenden Datenbank durch den Inhalt der Backup-Datei.
|
||
// Das Aufspielen erfolgt per ATTACH + DELETE/INSERT-SELECT in einer
|
||
// Transaktion: Die Live-Verbindung bleibt stabil, bei Fehlern wird
|
||
// zurückgerollt. Vor dem Import wird der Zustand vor dem Import als frisches
|
||
// Backup gesichert (runAutoBackup), damit er rückgängig gemacht werden kann.
|
||
|
||
// IMPORT_TABLES: die Sitzungs-/Projektdaten-Tabellen des relationalen Schemas;
|
||
// Grundlage für Validierung, Löschvorgang und Datentransfer. Die Reihenfolge
|
||
// ist ohne Bedeutung, da das Schema keine Foreign-Key-Constraints deklariert.
|
||
// BEWUSST NICHT enthalten sind die firmenweiten Konfigurations- und
|
||
// Kontotabellen (users, user_sessions, user_roles, user_absences,
|
||
// project_files, project_file_reviewers, project_file_comments sowie
|
||
// lph_templates): eine Wiederherstellung soll Sitzungsdaten zurückrollen,
|
||
// aber niemals Konten, Freigabestände oder Aufgaben-Vorlagen überschreiben.
|
||
const IMPORT_TABLES = ['weeks','week_people','week_people_roles','attendance','projects',
|
||
'project_tags','project_lph','project_assign','project_aufwand','project_aktuelle_lp',
|
||
'project_beauftragte_lph',
|
||
'project_gewerke',
|
||
'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps',
|
||
'plan_milestones','plan_milestone_deps',
|
||
'project_protokolle','protokoll_teilnehmer','protokoll_punkte',
|
||
'project_kommentare'];
|
||
// IMPORT_OPTIONAL_TABLES: Tabellen, die ältere Backups noch nicht kennen.
|
||
// Fehlen sie, bleiben sie nach dem Import leer; migrateKommentareJeGewerk()
|
||
// verteilt die alten Kommentare danach aus projects.status.
|
||
const IMPORT_OPTIONAL_TABLES = ['project_kommentare'];
|
||
|
||
// dbTableColumns: liefert die Spaltennamen einer Tabelle (funktioniert gegen
|
||
// jede DatabaseSync-Instanz, auch read-only Backups) via pragma_table_info.
|
||
function dbTableColumns(conn, table){
|
||
return conn.prepare('SELECT name FROM pragma_table_info(?)').all(table).map(r => r.name);
|
||
}
|
||
|
||
// IMPORT_OPTIONAL_COLS: Spalten, die in älteren Backups fehlen dürfen (sie
|
||
// werden beim Import mit dem Default-Wert der Live-Tabelle neu belegt).
|
||
// Die Liste muss mitwachsen, wenn Spalten ergänzt werden — sonst ist
|
||
// ausgerechnet das automatische Backup, das unmittelbar VOR einer
|
||
// Wiederherstellung geschrieben wird, nach einem Versionssprung nicht mehr
|
||
// einspielbar.
|
||
const IMPORT_OPTIONAL_COLS = ['updated_at', 'project_uid', 'honorar', 'status_author', 'status_at',
|
||
'auftragsstatus', 'besprochen', 'leistungsphase', 'aufwand_stunden', 'notiz'];
|
||
|
||
// restoreDbFromBackup: validiert die Backup-Datei (muss das bekannte Schema
|
||
// mit identischen Spalten wie die Live-DB besitzen) und ersetzt den
|
||
// Datenbestand der Live-DB durch den Snapshot. Parameter: backupPath (Pfad).
|
||
// Wirft bei ungültigem Schema oder Transferfehler (Transaktion wird gerollt).
|
||
// pruefeBackupDatei: öffnet die Datei und prüft das Schema (Schritt 1 von
|
||
// restoreDbFromBackup()) — eigenständig, damit /import eine ungültige Datei
|
||
// mit 400 abweisen kann, BEVOR runAutoBackup() läuft. Vorher erzeugte jeder
|
||
// Fehlversuch ein Auto-Backup und verdrängte so echte ältere.
|
||
// Wirft mit verständlicher Meldung. Parameter: backupPath.
|
||
function pruefeBackupDatei(backupPath){
|
||
let conn;
|
||
try { conn = new DatabaseSync(backupPath, { readOnly: true }); conn.prepare('SELECT 1 FROM sqlite_master LIMIT 1').get(); }
|
||
catch(err){ if(conn) conn.close(); throw new Error('Die Datei ist keine gültige Datenbank-Sicherung.'); }
|
||
try {
|
||
for(const t of IMPORT_TABLES){
|
||
const liveCols = dbTableColumns(db, t);
|
||
const backCols = dbTableColumns(conn, t);
|
||
if(backCols.length === 0){
|
||
if(IMPORT_OPTIONAL_TABLES.includes(t)) continue;
|
||
throw new Error('Ungültiges Backup (Tabelle fehlt): ' + t);
|
||
}
|
||
const missing = liveCols.filter(c => !backCols.includes(c) && !IMPORT_OPTIONAL_COLS.includes(c));
|
||
if(missing.length > 0) throw new Error('Backup-Schema passt nicht zur Live-DB (Tabelle ' + t + ', fehlt: ' + missing.join(', ') + ')');
|
||
}
|
||
} finally {
|
||
conn.close();
|
||
}
|
||
}
|
||
|
||
function restoreDbFromBackup(backupPath){
|
||
const backupConn = new DatabaseSync(backupPath, { readOnly: true });
|
||
try {
|
||
// 1) Schema-Validierung: Tabelle muss existieren und jede Spalte der
|
||
// Live-DB (außer den als optional markierten) enthalten. Zusätzliche
|
||
// Spalten im Backup (z. B. aus einer neueren Version) werden ignoriert.
|
||
for(const t of IMPORT_TABLES){
|
||
const liveCols = dbTableColumns(db, t);
|
||
const backCols = dbTableColumns(backupConn, t);
|
||
if(liveCols.length === 0) throw new Error('Live-Schema defekt (Tabelle fehlt): ' + t);
|
||
if(backCols.length === 0){
|
||
if(IMPORT_OPTIONAL_TABLES.includes(t)) continue;
|
||
throw new Error('Ungültiges Backup (Tabelle fehlt): ' + t);
|
||
}
|
||
const missing = liveCols.filter(c => !backCols.includes(c) && !IMPORT_OPTIONAL_COLS.includes(c));
|
||
if(missing.length > 0) throw new Error('Backup-Schema passt nicht zur Live-DB (Tabelle ' + t + ', fehlt: ' + missing.join(', ') + ')');
|
||
}
|
||
|
||
// 2) Datentransfer: Backup anbinden, Bestand löschen, Snapshot übernehmen.
|
||
// ATTACH und BEGIN stehen INNERHALB des try: Bricht BEGIN ab (z. B. weil
|
||
// noch eine Transaktion offen ist), muss das finally trotzdem abhängen —
|
||
// sonst bliebe import_backup dauerhaft gebunden und jeder weitere Import
|
||
// scheiterte bis zum Serverneustart.
|
||
try {
|
||
db.exec(`ATTACH DATABASE '${String(backupPath).replace(/'/g, "''")}' AS import_backup`);
|
||
db.exec('BEGIN');
|
||
IMPORT_TABLES.forEach(t => db.exec('DELETE FROM ' + t));
|
||
for(const t of IMPORT_TABLES){
|
||
const liveCols = dbTableColumns(db, t);
|
||
const backCols = dbTableColumns(backupConn, t);
|
||
// Nur Spalten übertragen, die BEIDE Schemas enthalten (alte Backups
|
||
// ohne updated_at bleiben so importierbar).
|
||
const common = liveCols.filter(c => backCols.includes(c));
|
||
if(common.length === 0) continue; // optionale Tabelle fehlt im Backup
|
||
// Anführungszeichen um jede Spalte: `projects` enthält die
|
||
// SQL-Schlüsselwort-Spalte "group", die sonst einen Syntaxfehler wirft.
|
||
const quoted = common.map(c => '"' + c + '"').join(', ');
|
||
const marks = common.map(() => '?').join(', ');
|
||
const rows = backupConn.prepare('SELECT ' + quoted + ' FROM ' + t).all();
|
||
const ins = db.prepare('INSERT INTO ' + t + ' (' + quoted + ') VALUES (' + marks + ')');
|
||
rows.forEach(r => ins.run(...common.map(c => r[c])));
|
||
}
|
||
db.exec('COMMIT');
|
||
} catch(err){
|
||
// Beide Aufräumschritte dürfen den ursprünglichen Fehler nicht
|
||
// verdecken: Scheiterte schon ATTACH, gibt es weder Transaktion noch
|
||
// angehängte Datenbank, und ROLLBACK/DETACH würden ihrerseits werfen.
|
||
try { db.exec('ROLLBACK'); } catch(e){ /* keine offene Transaktion */ }
|
||
throw err;
|
||
} finally {
|
||
try { db.exec('DETACH DATABASE import_backup'); } catch(e){ /* nie angehängt */ }
|
||
}
|
||
} finally {
|
||
backupConn.close();
|
||
}
|
||
}
|
||
|
||
// ---------- Migration: altes Ein-Dokument-Format -> relationales Schema ----------
|
||
|
||
// migrateLegacySchema: überführt eine bestehende Datenbank aus dem alten Format
|
||
// (eine `weeks`-Tabelle mit JSON-`data`-Spalte sowie `people` mit JSON-`data`)
|
||
// in das relationale Schema. Wird einmalig beim Start ausgeführt.
|
||
function migrateLegacySchema(){
|
||
if(!hasLegacyWeeksTable()) return;
|
||
console.log('Migriere altes Wochen-Dokument-Format in relationales Schema …');
|
||
|
||
// Alte Wochen-Dokumente lesen
|
||
const legacyWeeks = db.prepare('SELECT department, label, data FROM weeks').all();
|
||
// Alte globale Personen-Dokumente lesen (eine JSON-Array-Zeile pro Abteilung)
|
||
const legacyPeopleRows = [];
|
||
const hasLegacyPeople = db.prepare("SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table' AND name='people' AND sql LIKE '%data%'").get().c > 0;
|
||
if(hasLegacyPeople){
|
||
legacyPeopleRows.push(...db.prepare('SELECT department, data FROM people').all());
|
||
}
|
||
const tx = db.exec('BEGIN');
|
||
try {
|
||
// Alte Tabellen entfernen (Reihenfolge: erst Kind-Tabellen, dann Stamm)
|
||
db.exec(`DROP TABLE IF EXISTS week_people_roles; DROP TABLE IF EXISTS week_people;
|
||
DROP TABLE IF EXISTS attendance; DROP TABLE IF EXISTS project_lph;
|
||
DROP TABLE IF EXISTS project_assign; DROP TABLE IF EXISTS project_aufwand;
|
||
DROP TABLE IF EXISTS project_aktuelle_lp; DROP TABLE IF EXISTS project_plan;
|
||
DROP TABLE IF EXISTS plan_people; DROP TABLE IF EXISTS plan_todos;
|
||
DROP TABLE IF EXISTS plan_todo_assignees; DROP TABLE IF EXISTS plan_todo_deps;
|
||
DROP TABLE IF EXISTS plan_milestones; DROP TABLE IF EXISTS plan_milestone_deps;
|
||
DROP TABLE IF EXISTS projects; DROP TABLE IF EXISTS weeks;
|
||
DROP TABLE IF EXISTS people; DROP TABLE IF EXISTS person_roles; DROP TABLE IF EXISTS person_absences;`);
|
||
|
||
// Neue Tabellen anlegen (Schema-DDL oben ausführen)
|
||
db.exec(`CREATE TABLE IF NOT EXISTS weeks (
|
||
department TEXT NOT NULL, label TEXT NOT NULL, week_id TEXT NOT NULL DEFAULT '',
|
||
date TEXT NOT NULL DEFAULT '', locked INTEGER NOT NULL DEFAULT 0,
|
||
updated_at TEXT NOT NULL DEFAULT '',
|
||
PRIMARY KEY (department, label)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS week_people (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL,
|
||
name TEXT NOT NULL DEFAULT '', full_name TEXT NOT NULL DEFAULT '',
|
||
array_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS week_people_roles (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL,
|
||
role TEXT NOT NULL, role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id, role)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS attendance (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL,
|
||
present INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS projects (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||
nr TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT '',
|
||
"group" TEXT NOT NULL DEFAULT '', array_order INTEGER NOT NULL DEFAULT 0,
|
||
order_value INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (department, week_label, project_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_lph (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||
lph TEXT NOT NULL, value TEXT NOT NULL DEFAULT '',
|
||
PRIMARY KEY (department, week_label, project_id, lph)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_assign (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||
person_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT '', state INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (department, week_label, project_id, person_id, role)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_aufwand (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||
lph TEXT NOT NULL, role TEXT NOT NULL DEFAULT '', value REAL NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (department, week_label, project_id, lph, role)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_aktuelle_lp (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||
lph TEXT NOT NULL, PRIMARY KEY (department, week_label, project_id, lph)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_plan (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||
start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '', progress INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (department, week_label, project_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS plan_people (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||
person_id TEXT NOT NULL, array_order INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (department, week_label, project_id, person_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS plan_todos (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||
todo_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', start TEXT NOT NULL DEFAULT '',
|
||
end TEXT NOT NULL DEFAULT '', done INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0,
|
||
sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id, todo_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||
todo_id TEXT NOT NULL, person_id TEXT NOT NULL,
|
||
PRIMARY KEY (department, week_label, project_id, todo_id, person_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_deps (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||
todo_id TEXT NOT NULL, depends_on_id TEXT NOT NULL,
|
||
PRIMARY KEY (department, week_label, project_id, todo_id, depends_on_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestones (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||
milestone_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '',
|
||
position TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (department, week_label, project_id, milestone_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps (
|
||
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||
milestone_id TEXT NOT NULL, depends_on_id TEXT NOT NULL,
|
||
PRIMARY KEY (department, week_label, project_id, milestone_id, depends_on_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS people (
|
||
department TEXT NOT NULL, person_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '',
|
||
full_name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '',
|
||
sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, person_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS person_roles (
|
||
department TEXT NOT NULL, person_id TEXT NOT NULL, role TEXT NOT NULL,
|
||
role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, person_id, role)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS person_absences (
|
||
department TEXT NOT NULL, person_id TEXT NOT NULL, absence TEXT NOT NULL,
|
||
PRIMARY KEY (department, person_id, absence)
|
||
)`);
|
||
|
||
// Alte Dokumente einlesen und relational speichern. Die Tabellen wurden
|
||
// gerade neu angelegt und sind leer; `serializeWeek` genügt (kein eigener
|
||
// Transaktions-Wrapper, da die äußere BEGIN/COMMIT der Migration aktiv ist).
|
||
legacyWeeks.forEach(row => {
|
||
let week;
|
||
try { week = JSON.parse(row.data); } catch(e){ console.error('Kaputte Woche bei Migration:', row.label, e.message); return; }
|
||
if(!week || !week.label) return;
|
||
serializeWeek(week);
|
||
});
|
||
// Globale Personen aus dem alten Dokument-Format migrieren. serializeGlobalPeople
|
||
// startet selbst eine Transaktion, daher werden die Zeilen hier direkt in die
|
||
// neuen Tabellen eingefügt (serializeGlobalPeople ist in Transaktionen nicht
|
||
// aufrufbar, weil BEGIN innerhalb BEGIN nicht erlaubt ist).
|
||
legacyPeopleRows.forEach(row => {
|
||
let people;
|
||
try { people = JSON.parse(row.data); } catch(e){ console.error('Kaputte Personen-Liste bei Migration:', row.department, e.message); return; }
|
||
if(!Array.isArray(people)) return;
|
||
const insPerson = db.prepare(`INSERT INTO people (department, person_id, name, full_name, email, sort_order)
|
||
VALUES (?, ?, ?, ?, ?, ?)`);
|
||
const insRole = db.prepare(`INSERT OR IGNORE INTO person_roles (department, person_id, role, role_order) VALUES (?, ?, ?, ?)`);
|
||
const insAbsence = db.prepare(`INSERT OR IGNORE INTO person_absences (department, person_id, absence) VALUES (?, ?, ?)`);
|
||
people.forEach((p, idx) => {
|
||
insPerson.run(row.department, String(p.id || ''), String(p.name || ''), String(p.fullName || ''), String(p.email || ''), idx);
|
||
(Array.isArray(p.role) ? p.role : []).forEach((r, ridx) => insRole.run(row.department, String(p.id || ''), String(r || ''), ridx));
|
||
(Array.isArray(p.absences) ? p.absences : []).forEach(a => insAbsence.run(row.department, String(p.id || ''), String(a || '')));
|
||
});
|
||
});
|
||
db.exec('COMMIT');
|
||
console.log('Migration abgeschlossen (' + legacyWeeks.length + ' Wochen).');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
console.error('Migration fehlgeschlagen:', err.message);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// migrateWeeksUpdatedAt: ergänzt die `updated_at`-Spalte in bestehenden
|
||
// relationalen Datenbanken (idempotent; neue DBs haben die Spalte bereits).
|
||
function migrateWeeksUpdatedAt(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('weeks')").all().map(r => r.name);
|
||
if(!cols.includes('updated_at')){
|
||
db.exec("ALTER TABLE weeks ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''");
|
||
console.log('Wochen-Tabelle um updated_at ergänzt.');
|
||
}
|
||
}
|
||
|
||
// migrateRemoveDepartmentPartition: löst die bisherige Abteilungs-
|
||
// Partitionierung (ELT/HLS/ARCH als getrennte Wochen-/Projekt-Reihen) auf.
|
||
// Idempotent (Guard: `weeks` hat noch eine `department`-Spalte). Zweistufig:
|
||
// (1) Wochen mit gleichem Label aus verschiedenen Ex-Abteilungen werden zu
|
||
// einer globalen Woche zusammengeführt (week_people/-roles/attendance
|
||
// ebenfalls vereinigt).
|
||
// (2) Innerhalb jeder Woche werden Projekte mit gleicher, nicht-leerer `nr`
|
||
// aus verschiedenen Ex-Abteilungen zu EINEM Projekt verschmolzen
|
||
// (bestätigt vom Nutzer: es handelt sich um dieselben realen Bauprojekte,
|
||
// die bisher getrennt je Gewerk geführt wurden) — inkl. Team, Leistungs-
|
||
// phasen, Gantt/TODOs. Jede beteiligte Ex-Abteilung wird zum Tag
|
||
// (project_tags). Konflikte (abweichende Namen/Werte) werden mit
|
||
// deterministischer Präzedenz ELT→HLS→ARCH aufgelöst und vollständig
|
||
// protokolliert, niemals still verworfen.
|
||
function migrateRemoveDepartmentPartition(){
|
||
const weeksCols = db.prepare("SELECT name FROM pragma_table_info('weeks')").all().map(r => r.name);
|
||
if(!weeksCols.includes('department')) return; // bereits migriert oder frische DB
|
||
|
||
console.log('Migriere: Abteilungs-Partitionierung wird entfernt (Wochen und gleichnummerige Projekte werden zusammengeführt) …');
|
||
|
||
// Zusätzlicher, eigens benannter Sicherheits-Schnappschuss VOR jeder
|
||
// Änderung (zusätzlich zum regulären Auto-Backup, das erst NACH dem
|
||
// Serverstart läuft) — falls je eine manuelle Wiederherstellung nötig wird.
|
||
try {
|
||
if(!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||
const snapshotPath = path.join(BACKUP_DIR, 'pre-department-migration-' + new Date().toISOString().replace(/[:.]/g, '-') + '.db');
|
||
createDbSnapshot(snapshotPath);
|
||
console.log('Vor-Migrations-Snapshot gespeichert:', snapshotPath);
|
||
} catch(err){
|
||
console.error('Vor-Migrations-Snapshot fehlgeschlagen (Migration wird trotzdem fortgesetzt):', err.message);
|
||
}
|
||
|
||
const DEPT_ORDER = ['ELT', 'HLS', 'ARCH'];
|
||
const deptRank = (d) => { const i = DEPT_ORDER.indexOf(d); return i === -1 ? DEPT_ORDER.length : i; };
|
||
const conflicts = [];
|
||
|
||
db.exec('BEGIN');
|
||
try {
|
||
// ---------- Stufe 1: Wochen zusammenführen (nach label) ----------
|
||
db.exec(`CREATE TABLE weeks_new (
|
||
label TEXT NOT NULL, week_id TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '',
|
||
locked INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT '', PRIMARY KEY (label)
|
||
)`);
|
||
const labels = db.prepare('SELECT DISTINCT label FROM weeks').all().map(r => r.label);
|
||
const insWeekNew = db.prepare('INSERT INTO weeks_new (label, week_id, date, locked, updated_at) VALUES (?,?,?,?,?)');
|
||
labels.forEach(label => {
|
||
const rows = db.prepare('SELECT * FROM weeks WHERE label = ?').all(label)
|
||
.sort((a, b) => deptRank(a.department) - deptRank(b.department));
|
||
if(rows.length > 1){
|
||
conflicts.push(`Woche "${label}": ${rows.length} Abteilungs-Kopien zusammengeführt (${rows.map(r => r.department + '=' + r.date).join(', ')}).`);
|
||
}
|
||
const canonical = rows[0];
|
||
const maxUpdatedAt = rows.map(r => r.updated_at || '').sort().slice(-1)[0] || '';
|
||
insWeekNew.run(label, canonical.week_id, canonical.date, canonical.locked, maxUpdatedAt);
|
||
});
|
||
db.exec('DROP TABLE weeks');
|
||
db.exec('ALTER TABLE weeks_new RENAME TO weeks');
|
||
|
||
// week_people: bei gleicher (week_label,person_id) aus mehreren
|
||
// Ex-Abteilungen gewinnt die Präzedenz-Reihenfolge.
|
||
db.exec(`CREATE TABLE week_people_new (
|
||
week_label TEXT NOT NULL, person_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '',
|
||
full_name TEXT NOT NULL DEFAULT '', array_order INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, person_id)
|
||
)`);
|
||
{
|
||
const wpInsert = db.prepare('INSERT INTO week_people_new (week_label,person_id,name,full_name,array_order) VALUES (?,?,?,?,?)');
|
||
const seen = new Set();
|
||
db.prepare('SELECT * FROM week_people').all()
|
||
.sort((a, b) => deptRank(a.department) - deptRank(b.department) || a.array_order - b.array_order)
|
||
.forEach(r => {
|
||
const key = r.week_label + '|' + r.person_id;
|
||
if(seen.has(key)){
|
||
conflicts.push(`week_people ${r.week_label}/${r.person_id}: Duplikat aus ${r.department} verworfen (Erst-Eintrag gewinnt).`);
|
||
return;
|
||
}
|
||
seen.add(key);
|
||
wpInsert.run(r.week_label, r.person_id, r.name, r.full_name, r.array_order);
|
||
});
|
||
}
|
||
db.exec('DROP TABLE week_people');
|
||
db.exec('ALTER TABLE week_people_new RENAME TO week_people');
|
||
|
||
// week_people_roles: reine Mengenvereinigung, keine Konflikte möglich
|
||
// (gleiche Rolle mehrfach ist harmlos, INSERT OR IGNORE übernimmt das).
|
||
db.exec(`CREATE TABLE week_people_roles_new (
|
||
week_label TEXT NOT NULL, person_id TEXT NOT NULL, role TEXT NOT NULL,
|
||
role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, person_id, role)
|
||
)`);
|
||
db.exec(`INSERT OR IGNORE INTO week_people_roles_new (week_label,person_id,role,role_order)
|
||
SELECT week_label,person_id,role,role_order FROM week_people_roles`);
|
||
db.exec('DROP TABLE week_people_roles');
|
||
db.exec('ALTER TABLE week_people_roles_new RENAME TO week_people_roles');
|
||
|
||
// attendance: bei widersprüchlicher Anwesenheit gewinnt die Präzedenz.
|
||
db.exec(`CREATE TABLE attendance_new (
|
||
week_label TEXT NOT NULL, person_id TEXT NOT NULL, present INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, person_id)
|
||
)`);
|
||
{
|
||
const attInsert = db.prepare('INSERT INTO attendance_new (week_label,person_id,present) VALUES (?,?,?)');
|
||
const seen = new Map();
|
||
db.prepare('SELECT * FROM attendance').all()
|
||
.sort((a, b) => deptRank(a.department) - deptRank(b.department))
|
||
.forEach(r => {
|
||
const key = r.week_label + '|' + r.person_id;
|
||
if(seen.has(key)){
|
||
if(seen.get(key) !== r.present){
|
||
conflicts.push(`attendance ${r.week_label}/${r.person_id}: widersprüchliche Anwesenheit (${r.department}=${r.present}) verworfen, Erst-Eintrag gewinnt.`);
|
||
}
|
||
return;
|
||
}
|
||
seen.set(key, r.present);
|
||
attInsert.run(r.week_label, r.person_id, r.present);
|
||
});
|
||
}
|
||
db.exec('DROP TABLE attendance');
|
||
db.exec('ALTER TABLE attendance_new RENAME TO attendance');
|
||
|
||
// ---------- Stufe 2: Projekte je Woche nach `nr` zusammenführen ----------
|
||
db.exec(`CREATE TABLE projects_new (
|
||
week_label TEXT NOT NULL, project_id TEXT NOT NULL, nr TEXT NOT NULL DEFAULT '',
|
||
name TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT '', "group" TEXT NOT NULL DEFAULT '',
|
||
array_order INTEGER NOT NULL DEFAULT 0, order_value INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, project_id)
|
||
)`);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS project_tags (
|
||
week_label TEXT NOT NULL, project_id TEXT NOT NULL, tag TEXT NOT NULL,
|
||
tag_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, project_id, tag)
|
||
)`);
|
||
|
||
// Gruppierung: gleiche (week_label,nr) mit nicht-leerer nr gehören
|
||
// zusammen; jede Zeile mit leerer nr bildet ihre eigene Einzel-Gruppe
|
||
// (nichts zum Abgleichen vorhanden, bleibt eigenständig).
|
||
const groups = new Map();
|
||
let soloCounter = 0;
|
||
db.prepare('SELECT * FROM projects').all().forEach(r => {
|
||
const groupKey = (r.nr && r.nr.trim() !== '')
|
||
? r.week_label + '|nr:' + r.nr
|
||
: r.week_label + '|solo:' + (soloCounter++);
|
||
if(!groups.has(groupKey)) groups.set(groupKey, []);
|
||
groups.get(groupKey).push(r);
|
||
});
|
||
|
||
const insProjectNew = db.prepare(`INSERT INTO projects_new (week_label,project_id,nr,name,status,"group",array_order,order_value)
|
||
VALUES (?,?,?,?,?,?,?,?)`);
|
||
const insTagNew = db.prepare('INSERT OR IGNORE INTO project_tags (week_label,project_id,tag,tag_order) VALUES (?,?,?,?)');
|
||
// projectIdMap: alter Schlüssel "department|week_label|project_id" -> neue
|
||
// (überlebende) project_id innerhalb derselben week_label.
|
||
const projectIdMap = new Map();
|
||
|
||
groups.forEach(rawRows => {
|
||
const rows = rawRows.slice().sort((a, b) => deptRank(a.department) - deptRank(b.department));
|
||
const survivor = rows[0];
|
||
const weekLabel = survivor.week_label;
|
||
const newProjectId = survivor.project_id;
|
||
|
||
rows.forEach(r => projectIdMap.set(r.department + '|' + r.week_label + '|' + r.project_id, newProjectId));
|
||
|
||
if(rows.length > 1){
|
||
if(new Set(rows.map(r => r.name)).size > 1){
|
||
conflicts.push(`Projekt ${survivor.nr} (${weekLabel}): unterschiedliche Namen zusammengeführt (${rows.map(r => r.department + '="' + r.name + '"').join(', ')}), "${survivor.name}" übernommen.`);
|
||
}
|
||
if(new Set(rows.map(r => r.group)).size > 1){
|
||
conflicts.push(`Projekt ${survivor.nr} (${weekLabel}): unterschiedliche Gruppen (${rows.map(r => r.department + '="' + r.group + '"').join(', ')}), "${survivor.group}" übernommen.`);
|
||
}
|
||
}
|
||
// Status (freier Text): alle nicht-leeren Texte mit Abteilungs-Präfix
|
||
// zusammenführen statt einen zu verwerfen — kein Informationsverlust.
|
||
// Identischer Text aus mehreren Abteilungen (z. B. eine gemeinsame
|
||
// Besprechungsnotiz) wird dedupliziert statt wortgleich doppelt gezeigt.
|
||
const statusByText = new Map();
|
||
rows.forEach(r => {
|
||
const t = (r.status || '').trim();
|
||
if(!t) return;
|
||
if(!statusByText.has(t)) statusByText.set(t, []);
|
||
statusByText.get(t).push(r.department);
|
||
});
|
||
const mergedStatus = Array.from(statusByText.entries())
|
||
.map(([text, depts]) => `[${depts.join('+')}] ${text}`)
|
||
.join(' | ');
|
||
|
||
insProjectNew.run(weekLabel, newProjectId, survivor.nr, survivor.name, mergedStatus, survivor.group, survivor.array_order, survivor.order_value);
|
||
|
||
Array.from(new Set(rows.map(r => r.department))).forEach((d, idx) => insTagNew.run(weekLabel, newProjectId, d, idx));
|
||
});
|
||
db.exec('DROP TABLE projects');
|
||
db.exec('ALTER TABLE projects_new RENAME TO projects');
|
||
|
||
// mapProjectId: löst für eine alte (department,week_label,project_id) die
|
||
// neue (verschmolzene) project_id auf. Fällt auf die alte ID zurück, falls
|
||
// aus irgendeinem Grund kein Mapping existiert (sollte nie vorkommen, da
|
||
// JEDE alte projects-Zeile oben ein Mapping bekommen hat).
|
||
function mapProjectId(department, weekLabel, projectId){
|
||
return projectIdMap.get(department + '|' + weekLabel + '|' + projectId) || projectId;
|
||
}
|
||
|
||
// project_lph: Vereinigung je (week_label,neue_project_id,lph); bei
|
||
// widersprüchlichen Werten gewinnt das Maximum (weiter fortgeschrittene
|
||
// Abteilung bestimmt den gemeinsamen Fortschritt dieser Leistungsphase).
|
||
db.exec(`CREATE TABLE project_lph_new (
|
||
week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL,
|
||
value TEXT NOT NULL DEFAULT '', PRIMARY KEY (week_label, project_id, lph)
|
||
)`);
|
||
{
|
||
const merged = new Map();
|
||
db.prepare('SELECT * FROM project_lph').all()
|
||
.sort((a, b) => deptRank(a.department) - deptRank(b.department))
|
||
.forEach(r => {
|
||
const newPid = mapProjectId(r.department, r.week_label, r.project_id);
|
||
const key = r.week_label + '|' + newPid + '|' + r.lph;
|
||
const existing = merged.get(key);
|
||
if(existing){
|
||
if(existing.value !== r.value){
|
||
const a = Number(existing.value), b = Number(r.value);
|
||
const winner = (!isNaN(a) && !isNaN(b)) ? (b > a ? r.value : existing.value) : existing.value;
|
||
conflicts.push(`project_lph ${r.week_label}/${newPid}/LP${r.lph}: unterschiedliche Werte (${existing.value} vs ${r.value}) — Maximum "${winner}" übernommen.`);
|
||
existing.value = winner;
|
||
}
|
||
return;
|
||
}
|
||
merged.set(key, { weekLabel: r.week_label, projectId: newPid, lph: r.lph, value: r.value });
|
||
});
|
||
const ins = db.prepare('INSERT INTO project_lph_new (week_label,project_id,lph,value) VALUES (?,?,?,?)');
|
||
merged.forEach(v => ins.run(v.weekLabel, v.projectId, v.lph, v.value));
|
||
}
|
||
db.exec('DROP TABLE project_lph');
|
||
db.exec('ALTER TABLE project_lph_new RENAME TO project_lph');
|
||
|
||
// project_assign: Vereinigung je (week_label,neue_project_id,person_id,role);
|
||
// bei widersprüchlichem Status (0/1/2) gewinnt das Maximum.
|
||
db.exec(`CREATE TABLE project_assign_new (
|
||
week_label TEXT NOT NULL, project_id TEXT NOT NULL, person_id TEXT NOT NULL,
|
||
role TEXT NOT NULL DEFAULT '', state INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, project_id, person_id, role)
|
||
)`);
|
||
{
|
||
const merged = new Map();
|
||
db.prepare('SELECT * FROM project_assign').all()
|
||
.sort((a, b) => deptRank(a.department) - deptRank(b.department))
|
||
.forEach(r => {
|
||
const newPid = mapProjectId(r.department, r.week_label, r.project_id);
|
||
const key = r.week_label + '|' + newPid + '|' + r.person_id + '|' + r.role;
|
||
const existing = merged.get(key);
|
||
if(existing){
|
||
if(existing.state !== r.state){
|
||
const winner = Math.max(existing.state, r.state);
|
||
conflicts.push(`project_assign ${r.week_label}/${newPid}/${r.person_id}|${r.role}: unterschiedlicher Status (${existing.state} vs ${r.state}) — Maximum ${winner} übernommen.`);
|
||
existing.state = winner;
|
||
}
|
||
return;
|
||
}
|
||
merged.set(key, { weekLabel: r.week_label, projectId: newPid, personId: r.person_id, role: r.role, state: r.state });
|
||
});
|
||
const ins = db.prepare('INSERT INTO project_assign_new (week_label,project_id,person_id,role,state) VALUES (?,?,?,?,?)');
|
||
merged.forEach(v => ins.run(v.weekLabel, v.projectId, v.personId, v.role, v.state));
|
||
}
|
||
db.exec('DROP TABLE project_assign');
|
||
db.exec('ALTER TABLE project_assign_new RENAME TO project_assign');
|
||
|
||
// project_aufwand: Vereinigung je (week_label,neue_project_id,lph,role);
|
||
// bei Widerspruch gewinnt die Präzedenz (Gewichtungsfaktoren sind eher
|
||
// Konfiguration als Fortschritt, kein "Maximum" sinnvoll).
|
||
db.exec(`CREATE TABLE project_aufwand_new (
|
||
week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL,
|
||
role TEXT NOT NULL DEFAULT '', value REAL NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, project_id, lph, role)
|
||
)`);
|
||
{
|
||
const merged = new Map();
|
||
db.prepare('SELECT * FROM project_aufwand').all()
|
||
.sort((a, b) => deptRank(a.department) - deptRank(b.department))
|
||
.forEach(r => {
|
||
const newPid = mapProjectId(r.department, r.week_label, r.project_id);
|
||
const key = r.week_label + '|' + newPid + '|' + r.lph + '|' + r.role;
|
||
const existing = merged.get(key);
|
||
if(existing){
|
||
if(existing.value !== r.value){
|
||
conflicts.push(`project_aufwand ${r.week_label}/${newPid}/LP${r.lph}/${r.role}: unterschiedliche Werte (${existing.value} vs ${r.value}) — erster Wert (${existing.value}) übernommen.`);
|
||
}
|
||
return;
|
||
}
|
||
merged.set(key, { weekLabel: r.week_label, projectId: newPid, lph: r.lph, role: r.role, value: r.value });
|
||
});
|
||
const ins = db.prepare('INSERT INTO project_aufwand_new (week_label,project_id,lph,role,value) VALUES (?,?,?,?,?)');
|
||
merged.forEach(v => ins.run(v.weekLabel, v.projectId, v.lph, v.role, v.value));
|
||
}
|
||
db.exec('DROP TABLE project_aufwand');
|
||
db.exec('ALTER TABLE project_aufwand_new RENAME TO project_aufwand');
|
||
|
||
// project_aktuelle_lp: reine Mengenvereinigung (ein Projekt kann jetzt
|
||
// mehrere gleichzeitig aktive Leistungsphasen zeigen — gewollt).
|
||
db.exec(`CREATE TABLE project_aktuelle_lp_new (
|
||
week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL,
|
||
PRIMARY KEY (week_label, project_id, lph)
|
||
)`);
|
||
{
|
||
const ins = db.prepare('INSERT OR IGNORE INTO project_aktuelle_lp_new (week_label,project_id,lph) VALUES (?,?,?)');
|
||
db.prepare('SELECT * FROM project_aktuelle_lp').all().forEach(r => {
|
||
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.lph);
|
||
});
|
||
}
|
||
db.exec('DROP TABLE project_aktuelle_lp');
|
||
db.exec('ALTER TABLE project_aktuelle_lp_new RENAME TO project_aktuelle_lp');
|
||
|
||
// project_plan (Gantt-Rahmen je Projekt): start = frühester, end =
|
||
// spätester Termin (gemeinsame Zeitspanne beider Arbeitsanteile);
|
||
// progress-Widerspruch -> Maximum.
|
||
db.exec(`CREATE TABLE project_plan_new (
|
||
week_label TEXT NOT NULL, project_id TEXT NOT NULL, start TEXT NOT NULL DEFAULT '',
|
||
end TEXT NOT NULL DEFAULT '', progress INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, project_id)
|
||
)`);
|
||
{
|
||
const merged = new Map();
|
||
db.prepare('SELECT * FROM project_plan').all()
|
||
.sort((a, b) => deptRank(a.department) - deptRank(b.department))
|
||
.forEach(r => {
|
||
const newPid = mapProjectId(r.department, r.week_label, r.project_id);
|
||
const key = r.week_label + '|' + newPid;
|
||
const existing = merged.get(key);
|
||
if(existing){
|
||
if(r.start && (!existing.start || r.start < existing.start)) existing.start = r.start;
|
||
if(r.end && (!existing.end || r.end > existing.end)) existing.end = r.end;
|
||
if(existing.progress !== r.progress){
|
||
const winner = Math.max(existing.progress, r.progress);
|
||
conflicts.push(`project_plan ${r.week_label}/${newPid}: unterschiedlicher Fortschritt (${existing.progress}% vs ${r.progress}%) — Maximum ${winner}% übernommen.`);
|
||
existing.progress = winner;
|
||
}
|
||
return;
|
||
}
|
||
merged.set(key, { weekLabel: r.week_label, projectId: newPid, start: r.start, end: r.end, progress: r.progress });
|
||
});
|
||
const ins = db.prepare('INSERT INTO project_plan_new (week_label,project_id,start,end,progress) VALUES (?,?,?,?,?)');
|
||
merged.forEach(v => ins.run(v.weekLabel, v.projectId, v.start, v.end, v.progress));
|
||
}
|
||
db.exec('DROP TABLE project_plan');
|
||
db.exec('ALTER TABLE project_plan_new RENAME TO project_plan');
|
||
|
||
// plan_people: reine Mengenvereinigung.
|
||
db.exec(`CREATE TABLE plan_people_new (
|
||
week_label TEXT NOT NULL, project_id TEXT NOT NULL, person_id TEXT NOT NULL,
|
||
array_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, project_id, person_id)
|
||
)`);
|
||
{
|
||
const ins = db.prepare('INSERT OR IGNORE INTO plan_people_new (week_label,project_id,person_id,array_order) VALUES (?,?,?,?)');
|
||
db.prepare('SELECT * FROM plan_people ORDER BY array_order').all().forEach(r => {
|
||
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.person_id, r.array_order);
|
||
});
|
||
}
|
||
db.exec('DROP TABLE plan_people');
|
||
db.exec('ALTER TABLE plan_people_new RENAME TO plan_people');
|
||
|
||
// plan_todos / plan_todo_assignees / plan_todo_deps / plan_milestones /
|
||
// plan_milestone_deps: todo_id/milestone_id sind zufällige uid()-Strings,
|
||
// daher praktisch kein Kollisionsrisiko — reines Umhängen auf die neue
|
||
// project_id (das Gantt/TODO des vereinten Projekts zeigt danach schlicht
|
||
// die Aufgaben aller beteiligten Abteilungen zusammen).
|
||
db.exec(`CREATE TABLE plan_todos_new (
|
||
week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL,
|
||
title TEXT NOT NULL DEFAULT '', start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '',
|
||
done INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (week_label, project_id, todo_id)
|
||
)`);
|
||
{
|
||
const ins = db.prepare(`INSERT OR IGNORE INTO plan_todos_new (week_label,project_id,todo_id,title,start,end,done,progress,sort_order) VALUES (?,?,?,?,?,?,?,?,?)`);
|
||
db.prepare('SELECT * FROM plan_todos ORDER BY sort_order').all().forEach(r => {
|
||
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.todo_id, r.title, r.start, r.end, r.done, r.progress, r.sort_order);
|
||
});
|
||
}
|
||
db.exec('DROP TABLE plan_todos');
|
||
db.exec('ALTER TABLE plan_todos_new RENAME TO plan_todos');
|
||
|
||
db.exec(`CREATE TABLE plan_todo_assignees_new (
|
||
week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL, person_id TEXT NOT NULL,
|
||
PRIMARY KEY (week_label, project_id, todo_id, person_id)
|
||
)`);
|
||
{
|
||
const ins = db.prepare('INSERT OR IGNORE INTO plan_todo_assignees_new (week_label,project_id,todo_id,person_id) VALUES (?,?,?,?)');
|
||
db.prepare('SELECT * FROM plan_todo_assignees').all().forEach(r => {
|
||
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.todo_id, r.person_id);
|
||
});
|
||
}
|
||
db.exec('DROP TABLE plan_todo_assignees');
|
||
db.exec('ALTER TABLE plan_todo_assignees_new RENAME TO plan_todo_assignees');
|
||
|
||
db.exec(`CREATE TABLE plan_todo_deps_new (
|
||
week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL, depends_on_id TEXT NOT NULL,
|
||
PRIMARY KEY (week_label, project_id, todo_id, depends_on_id)
|
||
)`);
|
||
{
|
||
const ins = db.prepare('INSERT OR IGNORE INTO plan_todo_deps_new (week_label,project_id,todo_id,depends_on_id) VALUES (?,?,?,?)');
|
||
db.prepare('SELECT * FROM plan_todo_deps').all().forEach(r => {
|
||
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.todo_id, r.depends_on_id);
|
||
});
|
||
}
|
||
db.exec('DROP TABLE plan_todo_deps');
|
||
db.exec('ALTER TABLE plan_todo_deps_new RENAME TO plan_todo_deps');
|
||
|
||
db.exec(`CREATE TABLE plan_milestones_new (
|
||
week_label TEXT NOT NULL, project_id TEXT NOT NULL, milestone_id TEXT NOT NULL,
|
||
name TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '', position TEXT NOT NULL DEFAULT '',
|
||
sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, project_id, milestone_id)
|
||
)`);
|
||
{
|
||
const ins = db.prepare(`INSERT OR IGNORE INTO plan_milestones_new (week_label,project_id,milestone_id,name,date,position,sort_order) VALUES (?,?,?,?,?,?,?)`);
|
||
db.prepare('SELECT * FROM plan_milestones ORDER BY sort_order').all().forEach(r => {
|
||
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.milestone_id, r.name, r.date, r.position, r.sort_order);
|
||
});
|
||
}
|
||
db.exec('DROP TABLE plan_milestones');
|
||
db.exec('ALTER TABLE plan_milestones_new RENAME TO plan_milestones');
|
||
|
||
db.exec(`CREATE TABLE plan_milestone_deps_new (
|
||
week_label TEXT NOT NULL, project_id TEXT NOT NULL, milestone_id TEXT NOT NULL, depends_on_id TEXT NOT NULL,
|
||
PRIMARY KEY (week_label, project_id, milestone_id, depends_on_id)
|
||
)`);
|
||
{
|
||
const ins = db.prepare('INSERT OR IGNORE INTO plan_milestone_deps_new (week_label,project_id,milestone_id,depends_on_id) VALUES (?,?,?,?)');
|
||
db.prepare('SELECT * FROM plan_milestone_deps').all().forEach(r => {
|
||
ins.run(r.week_label, mapProjectId(r.department, r.week_label, r.project_id), r.milestone_id, r.depends_on_id);
|
||
});
|
||
}
|
||
db.exec('DROP TABLE plan_milestone_deps');
|
||
db.exec('ALTER TABLE plan_milestone_deps_new RENAME TO plan_milestone_deps');
|
||
|
||
// project_files: department-Spalte entfernen (jetzt korrekt über
|
||
// project_nr allein identifiziert, da gleichnummerige Projekte ab jetzt
|
||
// als EIN Projekt gelten). Physische Dateien lagen bisher unter
|
||
// sessions/uploads/<department>/<datei>, künftig flach unter
|
||
// sessions/uploads/<datei> — best-effort verschoben (Tabelle ist aktuell
|
||
// in der Praxis leer, dieser Pfad daher selten).
|
||
const pfCols = db.prepare("SELECT name FROM pragma_table_info('project_files')").all().map(r => r.name);
|
||
if(pfCols.includes('department')){
|
||
db.exec(`CREATE TABLE project_files_new (
|
||
id TEXT PRIMARY KEY, project_nr TEXT NOT NULL, project_name TEXT NOT NULL DEFAULT '',
|
||
original_filename TEXT NOT NULL, stored_name TEXT NOT NULL,
|
||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream', size_bytes INTEGER NOT NULL DEFAULT 0,
|
||
uploaded_by_user_id INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending',
|
||
created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT ''
|
||
)`);
|
||
db.exec(`INSERT INTO project_files_new (id,project_nr,project_name,original_filename,stored_name,content_type,size_bytes,uploaded_by_user_id,status,created_at,updated_at)
|
||
SELECT id,project_nr,project_name,original_filename,stored_name,content_type,size_bytes,uploaded_by_user_id,status,created_at,updated_at FROM project_files`);
|
||
const oldFileRows = db.prepare('SELECT id, department, stored_name FROM project_files').all();
|
||
db.exec('DROP TABLE project_files');
|
||
db.exec('ALTER TABLE project_files_new RENAME TO project_files');
|
||
db.exec('DROP INDEX IF EXISTS idx_project_files_scope');
|
||
db.exec('CREATE INDEX idx_project_files_scope ON project_files(project_nr)');
|
||
oldFileRows.forEach(r => {
|
||
try {
|
||
const oldPath = path.join(SESSIONS_DIR, 'uploads', r.department, r.stored_name);
|
||
const newPath = path.join(SESSIONS_DIR, 'uploads', r.stored_name);
|
||
if(fs.existsSync(oldPath) && !fs.existsSync(newPath)) fs.renameSync(oldPath, newPath);
|
||
} catch(err){
|
||
conflicts.push(`project_files ${r.id}: physische Datei konnte nicht verschoben werden (${err.message}).`);
|
||
}
|
||
});
|
||
}
|
||
|
||
db.exec('COMMIT');
|
||
console.log(`Migration abgeschlossen. ${conflicts.length} Konflikt(e)/Hinweis(e) protokolliert:`);
|
||
conflicts.forEach(c => console.log(' - ' + c));
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
console.error('Department-Migration fehlgeschlagen, Datenbank unverändert:', err);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// migrateAddProjectUid: vergibt jeder projects-Zeile eine dauerhafte,
|
||
// wochenunabhängige project_uid (Basis für echte Projekt-Historie über
|
||
// mehrere Sitzungen hinweg, siehe "Projekt-Historie"-Architekturnotiz).
|
||
// Anders als project_id (wird bei jedem cloneWeek() neu vergeben, siehe
|
||
// js/weeks.js) und nr (informell stabil, aber änderbar/leer möglich) wird
|
||
// project_uid einmal bei echter Projekt-Entstehung erzeugt
|
||
// (saveNewProj()/demoProj()) und danach nie wieder verändert — cloneWeek()
|
||
// kopiert sie unverändert mit. Läuft NACH migrateRemoveDepartmentPartition()
|
||
// (setzt eine bereits globale, nicht mehr abteilungsgetrennte projects-
|
||
// Tabelle voraus). Idempotent (Guard: Spalte existiert bereits).
|
||
// migrateAddProjectBesprochen: rüstet die Spalte `besprochen` nach
|
||
// (idempotentes ALTER TABLE, gleiches Muster wie migrateUsersDepartment()).
|
||
// Bestandszeilen starten mit 0 — für vergangene Sitzungen ist der Haken
|
||
// ohnehin bedeutungslos.
|
||
function migrateAddProjectBesprochen(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
|
||
if(!cols.includes('besprochen')){
|
||
db.exec('ALTER TABLE projects ADD COLUMN besprochen INTEGER NOT NULL DEFAULT 0');
|
||
console.log('Projekt-Tabelle um besprochen ergänzt.');
|
||
}
|
||
}
|
||
|
||
|
||
// migrateBackfillProjectGewerkeAusTags: setzt für Projekte OHNE jede
|
||
// project_gewerke-Zeile das Gewerk aus dem Schlagwort, das
|
||
// migrateRemoveDepartmentPartition() beim Zusammenführen der früheren
|
||
// Abteilungs-Sitzungen automatisch als project_tags-Eintrag vergeben hat
|
||
// (ELT/HLS/ARCH). Ohne diese Übernahme verschwände jedes Bestandsprojekt aus
|
||
// jeder Gewerke-gefilterten Ansicht des Wochenberichts (berichtProjekte()),
|
||
// weil project_gewerke — der Assistent "Neues Projekt" schreibt hierhin —
|
||
// für alle vor dem Assistenten angelegten Projekte leer ist. Idempotent:
|
||
// sobald ein Projekt mindestens eine project_gewerke-Zeile hat (egal woher,
|
||
// auch aus dieser Migration selbst), wird es bei künftigen Läufen
|
||
// übersprungen — eine spätere manuelle Korrektur in den Projekteinstellungen
|
||
// wird also nicht rückgängig gemacht.
|
||
function migrateBackfillProjectGewerkeAusTags(){
|
||
const kandidaten = db.prepare(`
|
||
SELECT DISTINCT pt.week_label, pt.project_id, pt.tag
|
||
FROM project_tags pt
|
||
WHERE pt.tag IN ('ELT','HLS','ARCH')
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM project_gewerke pg
|
||
WHERE pg.week_label = pt.week_label AND pg.project_id = pt.project_id
|
||
)
|
||
`).all();
|
||
if(!kandidaten.length) return;
|
||
const ins = db.prepare('INSERT OR IGNORE INTO project_gewerke (week_label, project_id, gewerk) VALUES (?,?,?)');
|
||
db.exec('BEGIN');
|
||
try {
|
||
kandidaten.forEach(r => ins.run(r.week_label, r.project_id, r.tag));
|
||
db.exec('COMMIT');
|
||
console.log('Gewerke aus Schlagwörtern übernommen:', kandidaten.length, 'Zuordnung(en).');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
console.error('Gewerke-Backfill aus Schlagwörtern fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
|
||
// migrateKommentareJeGewerk: verteilt die alten, gewerkübergreifenden
|
||
// Kommentare und Haken (projects.status/status_author/status_at/besprochen)
|
||
// auf project_kommentare. Läuft bei jedem Start und nach jedem DB-Import;
|
||
// idempotent, weil die Altspalten danach geleert werden.
|
||
// Zuordnung je Kommentar: das Gewerk des Verfassers (users.department über
|
||
// den Anzeigenamen), wenn das Projekt dieses Gewerk hat; sonst das einzige
|
||
// Gewerk des Projekts; bei mehreren und unbekanntem Verfasser alle Gewerke
|
||
// des Projekts — welches gemeint war, lässt sich dann nicht mehr sagen.
|
||
// Projekte ohne Gewerk bleiben unverändert (sie erscheinen ohnehin in keinem
|
||
// Gewerke-Wochenbericht).
|
||
function migrateKommentareJeGewerk(){
|
||
const zeilen = db.prepare(`SELECT week_label, project_id, status, status_author, status_at, besprochen
|
||
FROM projects WHERE status != '' OR besprochen = 1`).all();
|
||
if(!zeilen.length) return;
|
||
const gewerkeVon = db.prepare('SELECT gewerk FROM project_gewerke WHERE week_label = ? AND project_id = ?');
|
||
const abteilungVon = db.prepare("SELECT department FROM users WHERE display_name = ? AND department != '' LIMIT 1");
|
||
const vorhanden = db.prepare('SELECT 1 FROM project_kommentare WHERE week_label = ? AND project_id = ? AND gewerk = ?');
|
||
const ins = db.prepare(`INSERT INTO project_kommentare (week_label, project_id, gewerk, status, status_author, status_at, besprochen)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||
const leeren = db.prepare(`UPDATE projects SET status = '', status_author = '', status_at = '', besprochen = 0
|
||
WHERE week_label = ? AND project_id = ?`);
|
||
let anzahl = 0;
|
||
db.exec('BEGIN');
|
||
try {
|
||
zeilen.forEach(r => {
|
||
const gewerke = gewerkeVon.all(r.week_label, r.project_id).map(x => x.gewerk);
|
||
if(!gewerke.length) return;
|
||
let ziele = gewerke;
|
||
if(r.status && gewerke.length > 1){
|
||
const a = r.status_author ? abteilungVon.get(r.status_author) : null;
|
||
if(a && gewerke.includes(a.department)) ziele = [a.department];
|
||
}
|
||
ziele.forEach(g => {
|
||
if(vorhanden.get(r.week_label, r.project_id, g)) return;
|
||
ins.run(r.week_label, r.project_id, g, r.status || '', r.status_author || '', r.status_at || '', r.besprochen ? 1 : 0);
|
||
});
|
||
leeren.run(r.week_label, r.project_id);
|
||
anzahl++;
|
||
});
|
||
db.exec('COMMIT');
|
||
if(anzahl) console.log('Kommentare je Gewerk übernommen:', anzahl, 'Projekt-Fassung(en).');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
console.error('Übernahme der Kommentare je Gewerk fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
|
||
// migrateDropProjectGroesse: entfernt die frühere Spalte `groesse`
|
||
// (Projektgröße 0–1, Ersatz für ein fehlendes Honorar in der alten
|
||
// Auslastungsrechnung). Seit die Auslastung allein aus Aufgaben-Stunden
|
||
// entsteht, hat sie keine Verwendung mehr — Eriks Wunsch, sie auch aus der
|
||
// Datenbank zu nehmen. Idempotent; ein Backup mit der Spalte bleibt
|
||
// importierbar (überzählige Spalten werden beim Import ignoriert).
|
||
function migrateDropProjectGroesse(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
|
||
if(cols.includes('groesse')){
|
||
db.exec('ALTER TABLE projects DROP COLUMN groesse');
|
||
console.log('Spalte projects.groesse entfernt.');
|
||
}
|
||
}
|
||
|
||
// einmaligeUmstellung: führt eine Datenumstellung genau einmal je
|
||
// Datenbank aus (Merker in der Tabelle umstellungen). Für Umstellungen, die
|
||
// nicht an ein neu angelegtes Feld gekoppelt werden können.
|
||
function einmaligeUmstellung(name, fn){
|
||
db.exec('CREATE TABLE IF NOT EXISTS umstellungen (name TEXT PRIMARY KEY, am TEXT NOT NULL)');
|
||
if(db.prepare('SELECT 1 FROM umstellungen WHERE name = ?').get(name)) return;
|
||
fn();
|
||
db.prepare('INSERT INTO umstellungen (name, am) VALUES (?, ?)').run(name, new Date().toISOString());
|
||
}
|
||
|
||
// kommentarSpiegelstricheUmbrechen: Eriks Wunsch für die Bestandskommentare
|
||
// — jeder Spiegelstrich "- " beginnt eine eigene Zeile. Ältere Kommentare
|
||
// (aus der Zeit des Markup-Editors bzw. aus eingefügtem Text) tragen ihre
|
||
// Stichpunkte oft als eine Zeile "- A - B - C". Rein textuell, ohne DOM:
|
||
// Das HTML wird in Tags und Text zerlegt; Block-Tags (br/div/p) gelten als
|
||
// Zeilenanfang, Inline-Tags (b/i/span/font …) nicht. Ein "-" mit
|
||
// Leerzeichen (oder Anfang) davor und Leerzeichen/ danach bekommt ein
|
||
// <br> vorangestellt — außer es steht schon am Zeilenanfang oder nur hinter
|
||
// einem Gewerke-Vorsatz wie "[ELT]" ("[ELT] - Text" bleibt eine Zeile).
|
||
// Bindestriche in Wörtern ("SV-Abnahme") bleiben unberührt. Repariert
|
||
// nebenbei als Text gespeicherte " " (siehe unten).
|
||
// Parameter: html (String). Rückgabe: String (unverändert, wenn nichts zu tun).
|
||
function kommentarSpiegelstricheUmbrechen(html){
|
||
// Eine ältere Fassung des Kommentarfelds hat das geschützte Leerzeichen nach
|
||
// dem Strich als Text " " gespeichert (sichtbar als "- ").
|
||
if(html) html = String(html).replace(/&nbsp;/g, ' ');
|
||
if(!html || html.indexOf('-') === -1) return html;
|
||
const teile = String(html).split(/(<[^>]*>)/);
|
||
let zeile = ''; // sichtbarer Text der laufenden Zeile bis hierher
|
||
const aus = teile.map(t => {
|
||
if(t.startsWith('<')){
|
||
if(/^<\/?(br|div|p)\b/i.test(t)) zeile = '';
|
||
return t;
|
||
}
|
||
const neu = t.replace(/(^|\s)-(?=\s| |\u00a0)/g, (m, vor, pos, ganz) => {
|
||
const sichtbar = (zeile + ganz.slice(0, pos)).replace(/ /g, ' ').trim();
|
||
if(sichtbar === '' || /^\[[^\]]{1,20}\]$/.test(sichtbar)) return m;
|
||
return '<br>-';
|
||
}).replace(/\s+<br>-/g, '<br>-');
|
||
zeile += t;
|
||
return neu;
|
||
});
|
||
return aus.join('');
|
||
}
|
||
|
||
// kommentarVorsatzEntfernen: nimmt den Gewerke-Vorsatz "[ELT] ", "[ELT+HLS] "
|
||
// am Textanfang weg. Ihn hat migrateRemoveDepartmentPartition() beim
|
||
// Zusammenführen der Abteilungen vor jeden Kommentar gesetzt; seit es
|
||
// Kommentare je Gewerk gibt, steht das Gewerk ohnehin am Eintrag und wird
|
||
// in der Anzeige als eigene Beschriftung gezeigt (Eriks Wunsch: "nur als
|
||
// Systemtext, komplett weg vom normalen Text"). Ein direkt folgendes <br>
|
||
// fällt mit weg, damit keine Leerzeile am Anfang bleibt.
|
||
// Parameter: html (String). Rückgabe: String.
|
||
function kommentarVorsatzEntfernen(html){
|
||
if(!html) return html;
|
||
return String(html).replace(/^\s*\[(?:ELT|HLS|ARCH)(?:\s*\+\s*(?:ELT|HLS|ARCH))*\]\s*(?:<br\s*\/?>\s*)?/i, '');
|
||
}
|
||
|
||
function kommentareVorsatzUmstellen(){
|
||
const zeilen = db.prepare("SELECT week_label, project_id, gewerk, status FROM project_kommentare WHERE status LIKE '%[%'").all();
|
||
const upd = db.prepare('UPDATE project_kommentare SET status = ? WHERE week_label = ? AND project_id = ? AND gewerk = ?');
|
||
let n = 0;
|
||
db.exec('BEGIN');
|
||
try {
|
||
zeilen.forEach(r => {
|
||
const neu = kommentarVorsatzEntfernen(r.status);
|
||
if(neu !== r.status){ upd.run(neu, r.week_label, r.project_id, r.gewerk); n++; }
|
||
});
|
||
db.exec('COMMIT');
|
||
} catch(e){ db.exec('ROLLBACK'); throw e; }
|
||
if(n) console.log('Gewerke-Vorsatz aus ' + n + ' Kommentaren entfernt.');
|
||
}
|
||
|
||
function kommentareSpiegelstricheUmstellen(){
|
||
const zeilen = db.prepare("SELECT week_label, project_id, gewerk, status FROM project_kommentare WHERE status != ''").all();
|
||
const upd = db.prepare('UPDATE project_kommentare SET status = ? WHERE week_label = ? AND project_id = ? AND gewerk = ?');
|
||
let n = 0;
|
||
db.exec('BEGIN');
|
||
try {
|
||
zeilen.forEach(r => {
|
||
const neu = kommentarSpiegelstricheUmbrechen(r.status);
|
||
if(neu !== r.status){ upd.run(neu, r.week_label, r.project_id, r.gewerk); n++; }
|
||
});
|
||
db.exec('COMMIT');
|
||
} catch(e){ db.exec('ROLLBACK'); throw e; }
|
||
console.log('Spiegelstriche in ' + n + ' Bestandskommentaren auf eigene Zeilen gesetzt.');
|
||
}
|
||
|
||
// migrateAddPlanTodoLeistungsphase: rüstet die Spalte `leistungsphase` nach
|
||
// (idempotent). Kennzeichnet eine Aufgabe als Balken einer Leistungsphase
|
||
// ("LPH 5") — die Auslastung verteilt die Reststunden einer Phase auf die
|
||
// Wochen ihres Balkens. Gesetzt von "Terminplan aus Honorar" und vom
|
||
// Projekt-Assistenten
|
||
// und "+ Neu" → "Leistungsphase"; ältere Balken kennzeichnet
|
||
// phasenbalkenAusTitel() einmalig.
|
||
function migrateAddPlanTodoLeistungsphase(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name);
|
||
if(!cols.includes('leistungsphase')){
|
||
db.exec("ALTER TABLE plan_todos ADD COLUMN leistungsphase TEXT NOT NULL DEFAULT ''");
|
||
console.log('plan_todos um leistungsphase ergänzt.');
|
||
}
|
||
// Phasenbalken sind seit Eriks Wunsch NUR noch über leistungsphase eine
|
||
// Phase, nicht mehr über ihren Titel. Bestehende Balken, die bisher allein
|
||
// am Titel erkannt wurden ("LPH 5 (307 h)", "LPH 5 …", "W+M"), werden
|
||
// einmalig gekennzeichnet — danach zählt nur das Feld.
|
||
einmaligeUmstellung('phasenbalken-aus-titel', phasenbalkenAusTitel);
|
||
}
|
||
|
||
// phasenbalkenAusTitel: kennzeichnet Balken, die bisher allein am Titel als
|
||
// Phase erkannt wurden. Läuft einmal je Datenbank und nach einem
|
||
// Backup-Import (ältere Backups kennen das Feld noch nicht).
|
||
function phasenbalkenAusTitel(){
|
||
const n = db.prepare(`UPDATE plan_todos SET leistungsphase = CASE
|
||
WHEN title GLOB 'W+M' OR title GLOB 'W+M *' OR title GLOB 'W+M(*' THEN 'W+M'
|
||
ELSE substr(title, 1, 5) END
|
||
WHERE leistungsphase = '' AND (title GLOB 'LPH [1-9]' OR title GLOB 'LPH [1-9] *' OR title GLOB 'LPH [1-9](*'
|
||
OR title GLOB 'W+M' OR title GLOB 'W+M *' OR title GLOB 'W+M(*')`).run().changes;
|
||
if(n) console.log(n + ' Phasenbalken anhand ihres Titels als Leistungsphase gekennzeichnet.');
|
||
}
|
||
|
||
// migrateAddPlanTodoAufwand: aufwand_stunden — geschätzter Aufwand einer
|
||
// Aufgabe; zählt zur Auslastung der Zuständigen (verteilt auf den Zeitraum
|
||
// der Aufgabe). Idempotent.
|
||
function migrateAddPlanTodoAufwand(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name);
|
||
if(!cols.includes('aufwand_stunden')){
|
||
db.exec('ALTER TABLE plan_todos ADD COLUMN aufwand_stunden REAL NOT NULL DEFAULT 0');
|
||
console.log('plan_todos um aufwand_stunden ergänzt.');
|
||
}
|
||
}
|
||
|
||
// migrateAddPlanTodoNotiz: notiz — freier Text zur Aufgabe, z. B. die Mail,
|
||
// aus der „Termin aus Mail“ sie erzeugt hat (Absender, Datum, Text). Idempotent.
|
||
function migrateAddPlanTodoNotiz(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name);
|
||
if(!cols.includes('notiz')){
|
||
db.exec("ALTER TABLE plan_todos ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
|
||
console.log('plan_todos um notiz ergänzt.');
|
||
}
|
||
const msCols = db.prepare("SELECT name FROM pragma_table_info('plan_milestones')").all().map(r => r.name);
|
||
if(!msCols.includes('notiz')){
|
||
db.exec("ALTER TABLE plan_milestones ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
|
||
console.log('plan_milestones um notiz ergänzt.');
|
||
}
|
||
}
|
||
|
||
// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach
|
||
// (idempotent, gleiches ALTER-TABLE-Muster wie migrateAddProjectBesprochen()).
|
||
// DEFAULT 1, damit jede bestehende Aufgabe ihr bisheriges Verhalten behält
|
||
// (sichtbar im GANTT, sobald Start UND Ende gesetzt sind) — die neue
|
||
// Checkbox im Aufgaben-Dialog ("Im Terminplan anzeigen", plan-modals.js)
|
||
// blendet gezielt EINZELNE Aufgaben aus, ändert an diesem Normalfall nichts.
|
||
// Erik auf eigenen Wunsch, nachdem "TODOs aus Protokoll" (plan-protokoll-
|
||
// todos.js) gezeigt hatte, dass ein einzelner Import leicht 15-25 Aufgaben
|
||
// auf einmal anlegt — alle MIT Datum (siehe dortiger Kopfkommentar), also
|
||
// bisher alle zwingend auch als GANTT-Balken.
|
||
// migrateAddLphTemplateGewerk: Aufgaben-Vorlagen je Gewerk (Eriks Wunsch).
|
||
// Bestandsvorlagen behalten gewerk = '' und gelten dann für alle Gewerke.
|
||
function migrateAddLphTemplateGewerk(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('lph_templates')").all().map(r => r.name);
|
||
if(!cols.includes('gewerk')){
|
||
db.exec("ALTER TABLE lph_templates ADD COLUMN gewerk TEXT NOT NULL DEFAULT ''");
|
||
console.log('lph_templates um gewerk ergänzt.');
|
||
}
|
||
// anteil: Anteil des Schritts in Prozent (z. B. nach Siemon-Tabelle).
|
||
// Beim Einfügen bekommt jeder Schritt diesen Anteil an der Dauer der Phase
|
||
// (js/plan/plan-templates.js) — Eriks Wunsch. 0 = kein Anteil hinterlegt.
|
||
if(!cols.includes('anteil')){
|
||
db.exec('ALTER TABLE lph_templates ADD COLUMN anteil REAL NOT NULL DEFAULT 0');
|
||
console.log('lph_templates um anteil ergänzt.');
|
||
}
|
||
// enthalten_in: Buchstaben der Schritte, in denen dieser enthalten ist
|
||
// (Siemon-Tabelle „in a) enth.“, z. B. 'a' oder 'a,b') — er läuft beim
|
||
// Einfügen zeitgleich mit ihnen statt mit eigenem Anteil.
|
||
if(!cols.includes('enthalten_in')){
|
||
db.exec("ALTER TABLE lph_templates ADD COLUMN enthalten_in TEXT NOT NULL DEFAULT ''");
|
||
console.log('lph_templates um enthalten_in ergänzt.');
|
||
}
|
||
}
|
||
|
||
function migrateAddPlanTodoShowInGantt(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name);
|
||
if(!cols.includes('show_in_gantt')){
|
||
db.exec('ALTER TABLE plan_todos ADD COLUMN show_in_gantt INTEGER NOT NULL DEFAULT 1');
|
||
console.log('plan_todos um show_in_gantt ergänzt.');
|
||
}
|
||
}
|
||
|
||
// migrateAddProjectUpdatedAt: rüstet die Spalte `updated_at` nach (idempotent,
|
||
// gleiches ALTER-TABLE-Muster wie migrateAddProjectBesprochen()). Gebraucht von
|
||
// der Planung-Sandbox, siehe Spaltenkommentar am CREATE TABLE oben.
|
||
function migrateAddProjectUpdatedAt(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
|
||
if(!cols.includes('updated_at')){
|
||
db.exec("ALTER TABLE projects ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''");
|
||
console.log('Projekt-Tabelle um updated_at ergänzt.');
|
||
}
|
||
}
|
||
|
||
// migrateAddUserAiKeyModel: rüstet die Spalte `model` nach (idempotent,
|
||
// gleiches Muster wie migrateAddProjectBesprochen()). Leer = die in
|
||
// erkennePunkteMitChatGPT()/erkennePunkteMitClaude()/pruefePlanMitChatGPT()/
|
||
// pruefePlanMitClaude() hartcodierte Vorgabe gilt weiterhin — nötig, seit
|
||
// Erik mit Mammouth.ai einen eigenständigen Anbieter mit eigenem
|
||
// Modell-Katalog anbinden wollte: "gpt-4o-mini"/"gpt-4o" sind feste
|
||
// OpenAI-Modellnamen, die bei einem fremden Anbieter schlicht nicht
|
||
// existieren (HTTP 400 "Invalid model name ... Call /v1/models to view
|
||
// available models for your key"). Bisher ließ sich bei "Eigener Server"
|
||
// nur die URL überschreiben, nie der Modellname — dieselbe Lücke, die schon
|
||
// bei der API-URL bestand, bevor sie ergänzt wurde (siehe
|
||
// istGueltigeAiUrl()/normalisiereAiUrl() oben).
|
||
function migrateAddUserAiKeyModel(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('user_ai_keys')").all().map(r => r.name);
|
||
if(!cols.includes('model')){
|
||
db.exec("ALTER TABLE user_ai_keys ADD COLUMN model TEXT NOT NULL DEFAULT ''");
|
||
console.log('user_ai_keys-Tabelle um model ergänzt.');
|
||
}
|
||
}
|
||
|
||
// migrateAddProtokollQuelle: rüstet die beiden Spalten nach, die ein
|
||
// importiertes Protokoll von einem manuell angelegten unterscheiden
|
||
// (idempotent, gleiches Muster wie migrateAddProjectBesprochen()). Beide
|
||
// bleiben für ein manuell angelegtes Protokoll leer — siehe
|
||
// legeProtokollAn() und CLAUDE.md "### Protokolle".
|
||
function migrateAddProtokollQuelle(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('project_protokolle')").all().map(r => r.name);
|
||
if(!cols.includes('quelle_dateiname')){
|
||
db.exec("ALTER TABLE project_protokolle ADD COLUMN quelle_dateiname TEXT NOT NULL DEFAULT ''");
|
||
db.exec("ALTER TABLE project_protokolle ADD COLUMN quelle_stored_name TEXT NOT NULL DEFAULT ''");
|
||
console.log('Protokoll-Tabelle um Quelle-Spalten ergänzt.');
|
||
}
|
||
}
|
||
|
||
// migrateAddProtokollPunktErstelltAm: rüstet das Datum nach, an dem ein
|
||
// Punkt zum ERSTEN Mal erfasst wurde (Erik's Wunsch) — bestehende Zeilen
|
||
// bleiben leer, da ihr echtes Entstehungsdatum nicht mehr rekonstruierbar
|
||
// ist (idempotent, gleiches Muster wie migrateAddProtokollQuelle()).
|
||
function migrateAddProtokollPunktErstelltAm(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('protokoll_punkte')").all().map(r => r.name);
|
||
if(!cols.includes('erstellt_am')){
|
||
db.exec("ALTER TABLE protokoll_punkte ADD COLUMN erstellt_am TEXT NOT NULL DEFAULT ''");
|
||
console.log('Protokoll-Punkte-Tabelle um erstellt_am ergänzt.');
|
||
}
|
||
}
|
||
|
||
// migrateAddProtokollPunktStatus: löst das bisherige boolesche `erledigt`
|
||
// in einen Status mit drei Werten auf (offen/erledigt/info, Eriks Wunsch
|
||
// nach einem dritten Zustand für rein informative Punkte ohne eigene
|
||
// Handlung). `erledigt` bleibt als Spalte bestehen und wird weiterhin
|
||
// synchron mitgeschrieben (reine Rückwärtskompatibilität, z. B. für
|
||
// IMPORT_TABLES-Restores älterer Backups) — maßgeblich für die Anwendung
|
||
// ist ab hier ausschließlich `status`. Bestehende Zeilen werden anhand
|
||
// ihres bisherigen `erledigt`-Werts befüllt, der ADD-COLUMN-Standardwert
|
||
// 'offen' allein würde erledigte Altpunkte fälschlich wieder öffnen.
|
||
function migrateAddProtokollPunktStatus(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('protokoll_punkte')").all().map(r => r.name);
|
||
if(!cols.includes('status')){
|
||
db.exec("ALTER TABLE protokoll_punkte ADD COLUMN status TEXT NOT NULL DEFAULT 'offen'");
|
||
db.exec("UPDATE protokoll_punkte SET status = 'erledigt' WHERE erledigt = 1");
|
||
console.log('Protokoll-Punkte-Tabelle um status ergänzt.');
|
||
}
|
||
}
|
||
|
||
// migrateAddProtokollPunktEntfaelltNaechstes: rüstet das Merkmal nach, das
|
||
// einem erledigten Punkt EINE Gnadenrunde gibt (Eriks Wunsch): 'offen' und
|
||
// 'info' laufen wie bisher unbegrenzt weiter, aber 'erledigt' soll nicht
|
||
// sofort verschwinden, sondern genau einmal noch — als erledigt markiert,
|
||
// zusätzlich mit einem Hinweis "entfällt im nächsten Protokoll" — im
|
||
// nächsten Protokoll auftauchen und erst im übernächsten entfallen. Dieses
|
||
// Flag steht NICHT auf dem Punkt, der gerade erledigt markiert wurde,
|
||
// sondern wird erst von legeProtokollAn() auf die fortgeschriebene KOPIE
|
||
// gesetzt, wenn ihre Quelle status='erledigt' hatte — siehe dort. Idempotent,
|
||
// gleiches Muster wie migrateAddProtokollPunktStatus().
|
||
function migrateAddProtokollPunktEntfaelltNaechstes(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('protokoll_punkte')").all().map(r => r.name);
|
||
if(!cols.includes('entfaellt_naechstes')){
|
||
db.exec("ALTER TABLE protokoll_punkte ADD COLUMN entfaellt_naechstes INTEGER NOT NULL DEFAULT 0");
|
||
console.log('Protokoll-Punkte-Tabelle um entfaellt_naechstes ergänzt.');
|
||
}
|
||
}
|
||
|
||
// ensureSandboxWeek: legt die reservierte `weeks`-Zeile für die Planung-
|
||
// Sandbox einmalig an (INSERT OR IGNORE — beim zweiten und jedem weiteren
|
||
// Start ist sie schon da). Kein echtes Datum, nie gesperrt. Alle Terminpläne
|
||
// der Planung hängen an diesem einen `week_label`, siehe SANDBOX_WEEK_LABEL.
|
||
function ensureSandboxWeek(){
|
||
db.prepare(`INSERT OR IGNORE INTO weeks (label, week_id, date, locked, updated_at)
|
||
VALUES (?, 'sandbox', '', 0, '')`).run(SANDBOX_WEEK_LABEL);
|
||
}
|
||
|
||
// getIsoWeekInfo: ISO-Kalenderwoche UND -Wochenjahr eines Datums (das
|
||
// Wochenjahr kann an Jahresgrenzen vom Kalenderjahr abweichen — Silvester
|
||
// kann in KW 1 des Folgejahres liegen). Portiert von getWeekNumber()
|
||
// (public/js/utils.js) — reines, browserunabhängiges JS, hier um das
|
||
// Wochenjahr erweitert, das für den Sitzungswechsel unten gebraucht wird.
|
||
// Parameter: date (Date). Rückgabe: {week, year}.
|
||
function getIsoWeekInfo(date){
|
||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||
const dayNum = d.getUTCDay() || 7;
|
||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||
const year = d.getUTCFullYear();
|
||
const yearStart = new Date(Date.UTC(year, 0, 1));
|
||
const week = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
||
return { week, year };
|
||
}
|
||
|
||
// formatWeekLabel: "KW <n zweistellig> / <JJJJ>" — das Format, das sowohl
|
||
// validateWeekLabel()s Regex (früher public/js/weeks.js) als auch
|
||
// weekSortKey()/weekSortKeyServer() erwarten.
|
||
function formatWeekLabel(week, year){
|
||
return 'KW ' + String(week).padStart(2, '0') + ' / ' + year;
|
||
}
|
||
|
||
// ensureCurrentWeekRollover: legt automatisch eine neue Sitzung an, sobald
|
||
// der Kalender in eine neue ISO-Woche gewechselt hat — unabhängig davon, ob
|
||
// irgendjemand die Anwendung gerade nutzt (siehe CLAUDE.md, Abschnitt zum
|
||
// automatischen Sitzungswechsel). Ersetzt die früheren, manuellen Knöpfe
|
||
// "Neue Sitzung"/"Sitzung duplizieren" (public/js/weeks.js, entfernt).
|
||
// Repliziert exakt das Rücksetzverhalten des früheren cloneWeek(): neue
|
||
// week-/Projekt-ids, Kommentar/Verfasser/Zeitstempel/besprochen geleert,
|
||
// alles andere (Beteiligte, Zuordnungen, Terminpläne, Honorar, beauftragte
|
||
// Leistungsphasen, Gewerke, project_uid) bleibt unverändert erhalten. Sperrt
|
||
// anschließend — sofern app_settings.autoLockAktiv aktiv ist — alle anderen
|
||
// echten Sitzungen, dieselbe Invariante, die applyAutoLock() bisher nur
|
||
// clientseitig durchsetzte. Ohne jede bestehende echte Sitzung (frische
|
||
// Installation) wird stattdessen eine leere Sitzung angelegt (wie es früher
|
||
// newWeek() tat), damit ein frisches System nicht für immer ohne jede
|
||
// Sitzung dasteht. Fehler werden geloggt, nie geworfen — darf den
|
||
// Serverbetrieb nie stören. Keine Parameter, keine Rückgabe.
|
||
function ensureCurrentWeekRollover(){
|
||
try {
|
||
// Nur die jüngste Sitzung laden — alle Sitzungen stündlich komplett
|
||
// zusammenzusetzen kostete auf dem Pi Zero unnötig Speicher.
|
||
const juengstesLabel = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL)
|
||
.map(r => r.label)
|
||
.sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b)))
|
||
.pop();
|
||
const echteWochen = juengstesLabel === undefined ? [] : loadWeeks(juengstesLabel);
|
||
const { week, year } = getIsoWeekInfo(new Date());
|
||
const heuteKey = String(year) + '-' + String(week).padStart(2, '0');
|
||
const neuesLabel = formatWeekLabel(week, year);
|
||
|
||
let neueWoche;
|
||
if(echteWochen.length === 0){
|
||
// Frische Installation: keine Quelle zum Kopieren vorhanden.
|
||
neueWoche = {
|
||
id: crypto.randomUUID(), label: neuesLabel, date: heuteIsoLokal(),
|
||
people: loadGlobalPeople(), projects: [], attendance: {}, locked: false
|
||
};
|
||
} else {
|
||
const sortiert = echteWochen.slice()
|
||
.sort((a, b) => weekSortKeyServer(a.label).localeCompare(weekSortKeyServer(b.label)));
|
||
const letzte = sortiert[sortiert.length - 1];
|
||
const letzteKey = weekSortKeyServer(letzte.label);
|
||
if(heuteKey <= letzteKey) return; // schon auf dem Stand der aktuellen Kalenderwoche
|
||
|
||
neueWoche = JSON.parse(JSON.stringify(letzte));
|
||
neueWoche.id = crypto.randomUUID();
|
||
neueWoche.label = neuesLabel;
|
||
neueWoche.date = heuteIsoLokal();
|
||
neueWoche.attendance = {};
|
||
neueWoche.locked = false;
|
||
delete neueWoche.updatedAt;
|
||
(neueWoche.projects || []).forEach(p => {
|
||
p.id = crypto.randomUUID();
|
||
// Eriks Wunsch: Der Kommentar der Vorwoche wird je Gewerk übernommen
|
||
// und in der Besprechung angepasst; nur der Besprochen-Haken gehört
|
||
// zur Sitzung und beginnt leer.
|
||
const vorher = p.kommentare || {};
|
||
p.kommentare = {};
|
||
Object.keys(vorher).forEach(g => {
|
||
const k = vorher[g] || {};
|
||
if(!String(k.status || '').trim()) return;
|
||
p.kommentare[g] = { status: k.status, statusAuthor: k.statusAuthor || '',
|
||
statusAt: k.statusAt || '', besprochen: false };
|
||
});
|
||
delete p.statusState;
|
||
});
|
||
}
|
||
|
||
saveWeekRelational(neueWoche);
|
||
console.log('Automatischer Sitzungswechsel: neue Sitzung "' + neuesLabel + '" angelegt.');
|
||
if(typeof ereignisSenden === 'function') ereignisSenden({ typ: 'neu' }, null);
|
||
|
||
if(Number(readAppSettings().autoLockAktiv)){
|
||
const nowIso = new Date().toISOString();
|
||
db.prepare(`UPDATE weeks SET locked = 1, updated_at = ?
|
||
WHERE label != ? AND label != ?`).run(nowIso, neuesLabel, SANDBOX_WEEK_LABEL);
|
||
}
|
||
} catch(err){
|
||
console.error('Automatischer Sitzungswechsel fehlgeschlagen:', err);
|
||
}
|
||
}
|
||
|
||
function migrateAddProjectUid(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
|
||
if(!cols.includes('project_uid')){
|
||
db.exec("ALTER TABLE projects ADD COLUMN project_uid TEXT NOT NULL DEFAULT ''");
|
||
} else {
|
||
// Die Spalte allein genügt als Abschluss-Kennzeichen NICHT: Das ALTER
|
||
// TABLE läuft außerhalb der Transaktion, das Nachfüllen darin. Brach das
|
||
// Nachfüllen einmal ab (z. B. SQLITE_BUSY), existierte die Spalte, war
|
||
// aber überall leer — und jeder weitere Start übersprang die Migration.
|
||
// Die Projekt-Historie über Sitzungen hinweg wäre dauerhaft tot gewesen.
|
||
const offen = db.prepare("SELECT COUNT(*) AS c FROM projects WHERE project_uid = ''").get();
|
||
if(!offen || offen.c === 0) return; // wirklich fertig
|
||
console.log('Migriere: project_uid wird nachgefüllt (' + offen.c + ' Zeile(n) ohne Kennung) …');
|
||
}
|
||
|
||
// Backfill für Bestandsdaten: Zeilen mit gleicher, nicht-leerer nr über
|
||
// alle Wochen hinweg gelten als dasselbe reale Projekt und bekommen
|
||
// dieselbe uid. Zeilen ohne nr können nicht zusammengeführt werden (kein
|
||
// anderer stabiler Schlüssel vorhanden) und bekommen je eine eigene uid.
|
||
// Bekannte Grenze dieses einmaligen Rückblicks: eine in der Vergangenheit
|
||
// wiederverwendete nr (zwei echte Projekte) wird fälschlich zusammengeführt,
|
||
// eine mitten in der Historie geänderte nr wird fälschlich getrennt — ab
|
||
// dem Zeitpunkt dieser Migration tritt das Problem nicht mehr auf, da
|
||
// project_uid danach nie wieder aus nr neu berechnet wird.
|
||
const rows = db.prepare('SELECT week_label, project_id, nr, project_uid FROM projects').all();
|
||
const uidByNr = new Map();
|
||
// Bereits vergebene Kennungen als Vorgabe übernehmen: Beim Nachfüllen eines
|
||
// Teilbestands (siehe Guard oben) dürfen vorhandene Kennungen NICHT neu
|
||
// gewürfelt werden — sonst risse die Verbindung desselben Projekts über
|
||
// mehrere Sitzungen hinweg ab.
|
||
rows.forEach(r => {
|
||
const key = (r.nr || '').trim();
|
||
if(key && r.project_uid && !uidByNr.has(key)) uidByNr.set(key, r.project_uid);
|
||
});
|
||
const update = db.prepare('UPDATE projects SET project_uid = ? WHERE week_label = ? AND project_id = ?');
|
||
|
||
db.exec('BEGIN');
|
||
try {
|
||
rows.forEach(r => {
|
||
if(r.project_uid) return; // schon gesetzt, nicht anfassen
|
||
const key = (r.nr || '').trim();
|
||
let projectUid;
|
||
if(key){
|
||
if(!uidByNr.has(key)) uidByNr.set(key, crypto.randomUUID());
|
||
projectUid = uidByNr.get(key);
|
||
} else {
|
||
projectUid = crypto.randomUUID();
|
||
}
|
||
update.run(projectUid, r.week_label, r.project_id);
|
||
});
|
||
db.exec('COMMIT');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
console.error('project_uid-Migration fehlgeschlagen, Datenbank unverändert:', err);
|
||
throw err;
|
||
}
|
||
console.log(`project_uid vergeben: ${rows.length} Zeile(n), ${uidByNr.size} eindeutige Projekt-Historie(n) über nr gruppiert.`);
|
||
}
|
||
|
||
// migrateAddProjectHonorar: ergänzt die Spalte `honorar` in der Tabelle
|
||
// `projects` (vereinbartes Netto-Honorar in Euro, Grundlage der
|
||
// Honorar-Terminplanung). Idempotent, gleiches Muster wie
|
||
// migrateWeeksUpdatedAt(). Bestandsprojekte bekommen 0 und gelten damit als
|
||
// "kein Honorar hinterlegt" — die Honorar-Maske verlangt dann eine Eingabe,
|
||
// bevor sie rechnet.
|
||
function migrateAddProjectHonorar(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
|
||
if(cols.includes('honorar')) return;
|
||
db.exec('ALTER TABLE projects ADD COLUMN honorar REAL NOT NULL DEFAULT 0');
|
||
console.log('Projekte-Tabelle um honorar ergänzt.');
|
||
}
|
||
|
||
// migrateAddProjectStatusAuthor: ergänzt die Spalten `status_author` und
|
||
// `status_at` in der Tabelle `projects` — Verfasser (Anzeigename zum
|
||
// Zeitpunkt des Schreibens) und Zeitstempel des Wochenkommentars. Bewusst
|
||
// der Anzeigename und nicht die user_id: der Kommentar ist ein historischer
|
||
// Eintrag, er soll auch nach dem Löschen oder Umbenennen eines Kontos noch
|
||
// aussagen, wer ihn damals verfasst hat. Idempotent, gleiches Muster wie
|
||
// migrateAddProjectHonorar(). Bestandskommentare bekommen leere Werte und
|
||
// werden in der Oberfläche als "unbekannt" ausgewiesen — der Verfasser ist
|
||
// rückwirkend nicht ermittelbar.
|
||
function migrateAddProjectStatusAuthor(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
|
||
if(cols.includes('status_author')) return;
|
||
db.exec("ALTER TABLE projects ADD COLUMN status_author TEXT NOT NULL DEFAULT ''");
|
||
db.exec("ALTER TABLE projects ADD COLUMN status_at TEXT NOT NULL DEFAULT ''");
|
||
console.log('Projekt-Tabelle um status_author/status_at ergänzt.');
|
||
}
|
||
|
||
// migrateAddProjectAuftragsstatus: rüstet die Spalte `auftragsstatus` nach
|
||
// (idempotentes ALTER TABLE, gleiches Muster wie
|
||
// migrateAddProjectBesprochen()). Bestandszeilen bekommen über den
|
||
// Spalten-Default 'beauftragt' — der Altbestand ist beauftragte Arbeit, und
|
||
// jeder andere Anfangswert hätte laufende Projekte rückwirkend zu Akquise
|
||
// erklärt (siehe DEFAULT_AUFTRAGSSTATUS).
|
||
function migrateAddProjectAuftragsstatus(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name);
|
||
if(!cols.includes('auftragsstatus')){
|
||
db.exec("ALTER TABLE projects ADD COLUMN auftragsstatus TEXT NOT NULL DEFAULT 'beauftragt'");
|
||
console.log('Projekt-Tabelle um auftragsstatus ergänzt.');
|
||
}
|
||
}
|
||
|
||
// migrateUsersDepartment: ergänzt die Spalten `department`/`initials` in
|
||
// bestehenden users-Tabellen (idempotent; neue DBs haben sie bereits).
|
||
function migrateUsersDepartment(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('users')").all().map(r => r.name);
|
||
// einfuehrung_am: wann das Konto den Rundgang (public/js/einfuehrung.js)
|
||
// abgeschlossen hat; leer = steht noch aus. Bewusst auch für bestehende
|
||
// Konten leer (Eriks Wunsch: jeder macht den Rundgang einmal).
|
||
if(!cols.includes('einfuehrung_am')){
|
||
db.exec("ALTER TABLE users ADD COLUMN einfuehrung_am TEXT NOT NULL DEFAULT ''");
|
||
console.log('Benutzer-Tabelle um einfuehrung_am ergänzt.');
|
||
}
|
||
// dashboard_reihenfolge: eigene Reihenfolge der Projekte auf "Meine
|
||
// Aufgaben" (JSON-Liste von Projekt-Schlüsseln, meist project_uid); leer =
|
||
// wie in der Projektliste. Je Konto, damit sie auf jedem Gerät gilt.
|
||
// kalender_token: geheime Adresse des Kalender-Abos (/kalender/<token>.ics),
|
||
// leer = abgeschaltet. Siehe "Kalender-Abo für Outlook".
|
||
if(!cols.includes('kalender_token')){
|
||
db.exec("ALTER TABLE users ADD COLUMN kalender_token TEXT NOT NULL DEFAULT ''");
|
||
console.log('Benutzer-Tabelle um kalender_token ergänzt.');
|
||
}
|
||
if(!cols.includes('dashboard_reihenfolge')){
|
||
db.exec("ALTER TABLE users ADD COLUMN dashboard_reihenfolge TEXT NOT NULL DEFAULT ''");
|
||
console.log('Benutzer-Tabelle um dashboard_reihenfolge ergänzt.');
|
||
}
|
||
if(!cols.includes('department')){
|
||
db.exec("ALTER TABLE users ADD COLUMN department TEXT NOT NULL DEFAULT ''");
|
||
console.log('Benutzer-Tabelle um department ergänzt.');
|
||
}
|
||
if(!cols.includes('initials')){
|
||
db.exec("ALTER TABLE users ADD COLUMN initials TEXT NOT NULL DEFAULT ''");
|
||
console.log('Benutzer-Tabelle um initials ergänzt.');
|
||
}
|
||
// capacity_hours: vertragliche Wochenstunden. Bis dahin kannte die
|
||
// Auslastungsrechnung keine Teilzeit — sie maß jede Person an einer vollen
|
||
// Stelle, eine halbe Stelle erschien deshalb dauerhaft unterbeschäftigt
|
||
// (in js/load/load.js ausdrücklich als offener Punkt vermerkt). 0 bedeutet
|
||
// "nicht gepflegt" und wird wie eine volle Stelle behandelt, damit
|
||
// Bestandskonten ihr bisheriges Bild behalten.
|
||
if(!cols.includes('capacity_hours')){
|
||
db.exec('ALTER TABLE users ADD COLUMN capacity_hours REAL NOT NULL DEFAULT 0');
|
||
console.log('Benutzer-Tabelle um capacity_hours ergänzt.');
|
||
}
|
||
}
|
||
|
||
// migrateUsersVorNachname: Vor- und Nachname als eigene Spalten
|
||
// (first_name/last_name). Bis dahin gab es nur den Anzeigenamen; daraus
|
||
// ließen sich Benutzername und E-Mail-Adresse nicht verlässlich ableiten (ein
|
||
// einzelnes Wort im Feld ergab z. B. nur den Nachnamen). display_name bleibt
|
||
// bestehen und wird beim Anlegen/Bearbeiten als "Vorname Nachname" gesetzt —
|
||
// alle übrigen Stellen lesen weiter display_name. Bestandskonten bekommen
|
||
// Vor-/Nachname einmalig aus display_name (erstes Wort / Rest). Benutzernamen
|
||
// bleiben unverändert: Angemeldet wird mit dem Anfang der E-Mail-Adresse
|
||
// (z. B. e.dunin), und genau so heißen die Bestandskonten bereits.
|
||
function migrateUsersVorNachname(){
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('users')").all().map(r => r.name);
|
||
if(cols.includes('first_name') && cols.includes('last_name')) return;
|
||
if(!cols.includes('first_name')) db.exec("ALTER TABLE users ADD COLUMN first_name TEXT NOT NULL DEFAULT ''");
|
||
if(!cols.includes('last_name')) db.exec("ALTER TABLE users ADD COLUMN last_name TEXT NOT NULL DEFAULT ''");
|
||
const rows = db.prepare('SELECT id, display_name FROM users').all();
|
||
const setNamen = db.prepare('UPDATE users SET first_name = ?, last_name = ? WHERE id = ?');
|
||
rows.forEach(r => {
|
||
const woerter = String(r.display_name || '').trim().split(/\s+/).filter(Boolean);
|
||
setNamen.run(woerter[0] || '', woerter.slice(1).join(' '), r.id);
|
||
});
|
||
console.log('Benutzer-Tabelle um Vor-/Nachname ergänzt (' + rows.length + ' Konten aus dem Anzeigenamen befüllt).');
|
||
}
|
||
|
||
// dedupeUserPersonIds: person_id musste bisher nur je Abteilung eindeutig
|
||
// sein (alte people-Tabelle); jetzt identifiziert person_id global einen
|
||
// Benutzer, daher müssen Bestandskollisionen vor dem UNIQUE INDEX aufgelöst
|
||
// werden. Alle bis auf die erste Zeile je Kollision bekommen "_<id>" angehängt.
|
||
function dedupeUserPersonIds(){
|
||
const dupes = db.prepare(`SELECT person_id, COUNT(*) c FROM users WHERE person_id != '' GROUP BY person_id HAVING c > 1`).all();
|
||
dupes.forEach(d => {
|
||
const rows = db.prepare('SELECT id FROM users WHERE person_id = ? ORDER BY id').all(d.person_id);
|
||
rows.slice(1).forEach(r => {
|
||
const newId = d.person_id + '_' + r.id;
|
||
db.prepare('UPDATE users SET person_id = ? WHERE id = ?').run(newId, r.id);
|
||
console.warn(`Person-ID-Kollision behoben: Benutzer #${r.id} bekommt "${newId}" statt "${d.person_id}" (bitte in der Benutzerverwaltung prüfen).`);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ensureUserPersonIdUniqueIndex: löst zuerst Bestandskollisionen auf (siehe
|
||
// dedupeUserPersonIds) und legt danach den UNIQUE-Index an (idempotent,
|
||
// überspringt leere person_id-Werte per partiellem Index).
|
||
function ensureUserPersonIdUniqueIndex(){
|
||
dedupeUserPersonIds();
|
||
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_person_id_unique ON users(person_id) WHERE person_id != ''`);
|
||
}
|
||
|
||
// migrateRetirePeopleTablesIntoUsers: löst das alte, abteilungsbezogene
|
||
// Projektbeteiligte-Schema (people/person_roles/person_absences) endgültig ab,
|
||
// nachdem es durch die globalen User-Accounts (users/user_roles/user_absences)
|
||
// ersetzt wurde. Idempotent: läuft nur, solange die Tabelle `people` noch
|
||
// existiert. person_absences wird bewusst NICHT übernommen (enthielt bereits
|
||
// vorher keine sinnvollen Daten, siehe serializeGlobalPeople-Altcode), und für
|
||
// historische people-Zeilen ohne passenden User wird KEIN Platzhalter-User
|
||
// angelegt (würde die Benutzerliste mit nicht-loginfähigen Karteileichen
|
||
// verschmutzen) — stattdessen wird einmalig auf der Konsole protokolliert.
|
||
function migrateRetirePeopleTablesIntoUsers(){
|
||
const stillExists = db.prepare("SELECT COUNT(*) c FROM sqlite_master WHERE type='table' AND name='people'").get().c > 0;
|
||
if(!stillExists) return;
|
||
console.log('Migriere altes Projektbeteiligte-Schema (people/person_roles/person_absences) …');
|
||
const legacyPeople = db.prepare('SELECT * FROM people').all();
|
||
const legacyRoles = db.prepare('SELECT * FROM person_roles').all();
|
||
let merged = 0; const unmatched = [];
|
||
db.exec('BEGIN');
|
||
try {
|
||
legacyPeople.forEach(p => {
|
||
const user = db.prepare('SELECT id FROM users WHERE department = ? AND person_id = ?').get(p.department, p.person_id);
|
||
if(!user){ unmatched.push(p.department + '/' + p.person_id + ' (' + p.full_name + ')'); return; }
|
||
legacyRoles.filter(r => r.department === p.department && r.person_id === p.person_id)
|
||
.forEach(r => db.prepare('INSERT OR IGNORE INTO user_roles (user_id, role, role_order) VALUES (?,?,?)').run(user.id, r.role, r.role_order));
|
||
merged++;
|
||
});
|
||
db.exec('DROP TABLE people; DROP TABLE person_roles; DROP TABLE person_absences;');
|
||
db.exec('COMMIT');
|
||
console.log(`Migration abgeschlossen: ${merged} Personen-Rollen übernommen. ${unmatched.length} alte Einträge ohne passenden Benutzer (nicht automatisch migrierbar):`);
|
||
unmatched.forEach(u => console.log(' - ' + u));
|
||
} catch(err){ db.exec('ROLLBACK'); console.error('Migration fehlgeschlagen:', err.message); throw err; }
|
||
}
|
||
|
||
// ---------- Auth: Passwort-Hashing ----------
|
||
|
||
// hashPassword: erzeugt Salt + scrypt-Hash für ein neues/geändertes Passwort.
|
||
function hashPassword(password){
|
||
const salt = crypto.randomBytes(16).toString('hex');
|
||
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
|
||
return { hash, salt };
|
||
}
|
||
|
||
// verifyPassword: prüft ein eingegebenes Passwort gegen gespeicherten Salt+Hash
|
||
// (zeitkonstanter Vergleich gegen Timing-Angriffe).
|
||
function verifyPassword(password, salt, expectedHash){
|
||
const hash = crypto.scryptSync(password, salt, 64);
|
||
const expected = Buffer.from(expectedHash, 'hex');
|
||
return hash.length === expected.length && crypto.timingSafeEqual(hash, expected);
|
||
}
|
||
|
||
// bootstrapInitialAdmin: legt beim allerersten Start (users-Tabelle leer) ein
|
||
// Admin-Konto mit zufällig generiertem Passwort an (kein Hardcoded-Passwort im
|
||
// Code). Läuft danach nie wieder (Guard: COUNT(*) > 0). Das Klartext-Passwort
|
||
// wird einmalig auf die Konsole ausgegeben und zusätzlich nach
|
||
// sessions/INITIAL_ADMIN_PASSWORD.txt geschrieben (liegt außerhalb von
|
||
// public/, wird also nie von express.static ausgeliefert).
|
||
function bootstrapInitialAdmin(){
|
||
const row = db.prepare('SELECT COUNT(*) AS c FROM users').get();
|
||
if(row.c > 0) return;
|
||
const password = crypto.randomBytes(15).toString('base64url');
|
||
const { hash, salt } = hashPassword(password);
|
||
const now = new Date().toISOString();
|
||
db.prepare('INSERT INTO users (username, display_name, role, password_hash, password_salt, must_change_password, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)')
|
||
.run('admin', 'Administrator', 'admin', hash, salt, now, now);
|
||
const msg = 'Erstmaliges Admin-Konto angelegt.\n Benutzername: admin\n Passwort: ' + password + '\n (Bitte nach dem ersten Login sofort ändern.)';
|
||
console.log(msg);
|
||
try {
|
||
fs.writeFileSync(path.join(SESSIONS_DIR, 'INITIAL_ADMIN_PASSWORD.txt'), msg + '\n', 'utf8');
|
||
} catch(err){
|
||
console.error('Konnte INITIAL_ADMIN_PASSWORD.txt nicht schreiben:', err);
|
||
}
|
||
}
|
||
|
||
// Migration beim Serverstart ausführen
|
||
migrateLegacySchema();
|
||
migrateWeeksUpdatedAt();
|
||
migrateRemoveDepartmentPartition();
|
||
migrateBackfillProjectGewerkeAusTags();
|
||
migrateAddProjectUid();
|
||
migrateAddProjectBesprochen();
|
||
migrateAddProjectHonorar();
|
||
migrateAddProjectStatusAuthor();
|
||
migrateAddProjectAuftragsstatus();
|
||
migrateDropProjectGroesse();
|
||
migrateAddProjectUpdatedAt();
|
||
migrateAddPlanTodoShowInGantt();
|
||
migrateAddLphTemplateGewerk();
|
||
migrateAddPlanTodoLeistungsphase();
|
||
migrateAddPlanTodoAufwand();
|
||
migrateAddPlanTodoNotiz();
|
||
migrateAddUserAiKeyModel();
|
||
migrateAddProtokollQuelle();
|
||
migrateAddProtokollPunktErstelltAm();
|
||
migrateAddProtokollPunktStatus();
|
||
migrateAddProtokollPunktEntfaelltNaechstes();
|
||
migrateUsersDepartment();
|
||
migrateUsersVorNachname();
|
||
migrateKommentareJeGewerk();
|
||
// ---------- Aufgaben-Vorlagen: Grundleistungen nach HOAI ----------
|
||
// Eriks Wunsch: die Aufgaben-Vorlagen je Gewerk mit den Grundleistungen der
|
||
// HOAI füllen — ARCH nach Anlage 10.1 (Gebäude und Innenräume), ELT und HLS
|
||
// nach Anlage 15.1 (Technische Ausrüstung; die HOAI unterscheidet die
|
||
// Anlagengruppen bei den Grundleistungen nicht). Titel sind gekürzt, der
|
||
// Buchstabe der HOAI steht am Ende. Ohne Termin (duration_days = 0, siehe
|
||
// lph_templates): Die Dauer legt man im Projekt fest. Die Prozentanteile der
|
||
// Siemon-Tabelle sind bewusst NICHT hinterlegt — sie lagen nicht vor.
|
||
// Läuft einmal (einmaligeUmstellung) und nur für Gewerke ohne eigene Vorlagen.
|
||
const HOAI_GRUNDLEISTUNGEN = {
|
||
gebaeude: {
|
||
'LPH 1': [
|
||
'Aufgabenstellung klären (Vorgaben, Bedarfsplanung) (a)',
|
||
'Ortsbesichtigung (b)',
|
||
'Zum Leistungs- und Untersuchungsbedarf beraten (c)',
|
||
'Entscheidungshilfen zur Auswahl weiterer Fachplaner formulieren (d)',
|
||
'Ergebnisse zusammenfassen, erläutern und dokumentieren (e)'],
|
||
'LPH 2': [
|
||
'Grundlagen analysieren, Leistungen mit Fachplanern abstimmen (a)',
|
||
'Zielvorstellungen abstimmen, auf Zielkonflikte hinweisen (b)',
|
||
'Vorplanung erarbeiten, Varianten untersuchen und bewerten (c)',
|
||
'Wesentliche Zusammenhänge, Vorgaben und Bedingungen klären (d)',
|
||
'Ergebnisse für Fachplaner bereitstellen, Koordination und Integration (e)',
|
||
'Vorverhandlungen über die Genehmigungsfähigkeit (f)',
|
||
'Kostenschätzung nach DIN 276, Vergleich mit Budget (g)',
|
||
'Terminplan mit den wesentlichen Vorgängen erstellen (h)',
|
||
'Ergebnisse zusammenfassen, erläutern und dokumentieren (i)'],
|
||
'LPH 3': [
|
||
'Entwurfsplanung bis zum vollständigen Entwurf erarbeiten (a)',
|
||
'Ergebnisse für Fachplaner bereitstellen, Koordination und Integration (b)',
|
||
'Objektbeschreibung (c)',
|
||
'Verhandlungen über die Genehmigungsfähigkeit (d)',
|
||
'Kostenberechnung nach DIN 276, Vergleich mit Kostenschätzung (e)',
|
||
'Terminplan fortschreiben (f)',
|
||
'Ergebnisse zusammenfassen, erläutern und dokumentieren (g)'],
|
||
'LPH 4': [
|
||
'Vorlagen und Nachweise für Genehmigungen erarbeiten und zusammenstellen (a)',
|
||
'Vorlagen einreichen (b)',
|
||
'Planungsunterlagen, Beschreibungen und Berechnungen ergänzen und anpassen (c)'],
|
||
'LPH 5': [
|
||
'Ausführungsplanung mit allen Einzelangaben erarbeiten (a)',
|
||
'Ausführungs-, Detail- und Konstruktionszeichnungen (b)',
|
||
'Ergebnisse für Fachplaner bereitstellen, Koordination und Integration (c)',
|
||
'Terminplan fortschreiben (d)',
|
||
'Ausführungsplanung während der Objektausführung fortschreiben (e)',
|
||
'Montagepläne auf Übereinstimmung mit der Ausführungsplanung prüfen (f)'],
|
||
'LPH 6': [
|
||
'Vergabeterminplan aufstellen (a)',
|
||
'Leistungsbeschreibungen mit Leistungsverzeichnissen aufstellen, Mengen ermitteln (b)',
|
||
'Schnittstellen zu den Leistungsbeschreibungen der Fachplaner abstimmen (c)',
|
||
'Kosten anhand bepreister Leistungsverzeichnisse ermitteln (d)',
|
||
'Kostenkontrolle: bepreiste LV mit Kostenberechnung vergleichen (e)',
|
||
'Vergabeunterlagen für alle Leistungsbereiche zusammenstellen (f)'],
|
||
'LPH 7': [
|
||
'Vergaben der Fachplaner koordinieren (a)',
|
||
'Angebote einholen (b)',
|
||
'Angebote prüfen und werten, Preisspiegel aufstellen (c)',
|
||
'Bietergespräche führen (d)',
|
||
'Vergabevorschläge erstellen, Vergabeverfahren dokumentieren (e)',
|
||
'Vertragsunterlagen für alle Leistungsbereiche zusammenstellen (f)',
|
||
'Kostenkontrolle: Ausschreibungsergebnisse mit bepreisten LV vergleichen (g)',
|
||
'Bei der Auftragserteilung mitwirken (h)'],
|
||
'LPH 8': [
|
||
'Ausführung überwachen (Genehmigung, Verträge, Pläne, Regeln der Technik) (a)',
|
||
'Ausführung einfacher Tragwerke auf Standsicherheitsnachweis überwachen (b)',
|
||
'An der Objektüberwachung Beteiligte koordinieren (c)',
|
||
'Terminplan (Balkendiagramm) aufstellen, fortschreiben, überwachen (d)',
|
||
'Bauablauf dokumentieren (Bautagebuch) (e)',
|
||
'Gemeinsames Aufmaß mit den ausführenden Unternehmen (f)',
|
||
'Rechnungsprüfung einschließlich Prüfen der Aufmaße (g)',
|
||
'Rechnungsprüfungen mit Auftragssummen inkl. Nachträgen vergleichen (h)',
|
||
'Kostenkontrolle: Abrechnung mit Vertragspreisen vergleichen (i)',
|
||
'Kostenfeststellung nach DIN 276 (j)',
|
||
'Abnahme organisieren, Mängel feststellen, Abnahmeempfehlung (k)',
|
||
'Öffentlich-rechtliche Abnahmen beantragen und teilnehmen (l)',
|
||
'Dokumentation, Zeichnungen und Berechnungen systematisch zusammenstellen (m)',
|
||
'Objekt übergeben (n)',
|
||
'Verjährungsfristen für Mängelansprüche auflisten (o)',
|
||
'Beseitigung der Abnahmemängel überwachen (p)'],
|
||
'LPH 9': [
|
||
'Innerhalb der Verjährungsfristen festgestellte Mängel fachlich bewerten (a)',
|
||
'Objektbegehung vor Ablauf der Verjährungsfristen (b)',
|
||
'Bei der Freigabe von Sicherheitsleistungen mitwirken (c)'],
|
||
},
|
||
technik: {
|
||
'LPH 1': [
|
||
'Aufgabenstellung im Benehmen mit dem Objektplaner klären (a)',
|
||
'Planungsrandbedingungen ermitteln, zum Leistungsbedarf und zur Erschließung beraten (b)',
|
||
'Ergebnisse zusammenfassen, erläutern und dokumentieren (c)'],
|
||
'LPH 2': [
|
||
'Grundlagen analysieren, beim Abstimmen der Leistungen mitwirken (a)',
|
||
'Planungskonzept erarbeiten: Vordimensionierung, Varianten, Raumbedarf (b)',
|
||
'Funktionsschema bzw. Prinzipschaltbild je Anlage aufstellen (c)',
|
||
'Fachübergreifende Prozesse, Randbedingungen und Schnittstellen klären (d)',
|
||
'Vorverhandlungen mit Behörden und Versorgern (e)',
|
||
'Kostenschätzung nach DIN 276 (2. Ebene) und Terminplanung (f)',
|
||
'Ergebnisse zusammenfassen, erläutern und dokumentieren (g)'],
|
||
'LPH 3': [
|
||
'Planungskonzept bis zum vollständigen Entwurf durcharbeiten (a)',
|
||
'Alle Systeme und Anlagenteile festlegen (b)',
|
||
'Anlagen berechnen und bemessen, Entwurf zeichnen, Schemata fortschreiben (c)',
|
||
'Berechnungsergebnisse übergeben, Durchführungen und Lasten für die Tragwerksplanung (d)',
|
||
'Verhandlungen mit Behörden über die Genehmigungsfähigkeit (e)',
|
||
'Kostenberechnung nach DIN 276 (3. Ebene) und Terminplanung (f)',
|
||
'Kostenkontrolle: Kostenberechnung mit Kostenschätzung vergleichen (g)',
|
||
'Ergebnisse zusammenfassen, erläutern und dokumentieren (h)'],
|
||
'LPH 4': [
|
||
'Vorlagen und Nachweise für Genehmigungen erarbeiten, bei Behördenverhandlungen mitwirken (a)',
|
||
'Planungsunterlagen, Beschreibungen und Berechnungen vervollständigen und anpassen (b)'],
|
||
'LPH 5': [
|
||
'Ausführungsplanung bis zur ausführungsreifen Lösung erarbeiten (a)',
|
||
'Berechnungen fortschreiben, Anlagen zeichnen, Schemata detaillieren, abstimmen (b)',
|
||
'Schlitz- und Durchbruchspläne anfertigen (c)',
|
||
'Terminplan fortschreiben (d)',
|
||
'Ausführungsplanung auf Stand der Ausschreibung fortschreiben und übergeben (e)',
|
||
'Montage- und Werkstattpläne prüfen und anerkennen (f)'],
|
||
'LPH 6': [
|
||
'Mengen als Grundlage für die Leistungsverzeichnisse ermitteln (a)',
|
||
'Vergabeunterlagen mit Leistungsverzeichnissen inkl. Wartung aufstellen (b)',
|
||
'Beim Abstimmen der Schnittstellen zu anderen Leistungsbeschreibungen mitwirken (c)',
|
||
'Kosten anhand bepreister Leistungsverzeichnisse ermitteln (d)',
|
||
'Kostenkontrolle: bepreiste LV mit Kostenberechnung vergleichen (e)',
|
||
'Vergabeunterlagen zusammenstellen (f)'],
|
||
'LPH 7': [
|
||
'Angebote einholen (a)',
|
||
'Angebote prüfen und werten, Preisspiegel aufstellen (b)',
|
||
'Bietergespräche führen (c)',
|
||
'Ausschreibungsergebnisse mit bepreisten LV und Kostenberechnung vergleichen (d)',
|
||
'Vergabevorschläge erstellen, bei der Dokumentation mitwirken (e)',
|
||
'Vertragsunterlagen zusammenstellen, bei der Auftragserteilung mitwirken (f)'],
|
||
'LPH 8': [
|
||
'Ausführung überwachen (Genehmigung, Verträge, Montagepläne, Regeln der Technik) (a)',
|
||
'Bei der Koordination der Projektbeteiligten mitwirken (b)',
|
||
'Terminplan (Balkendiagramm) aufstellen, fortschreiben, überwachen (c)',
|
||
'Bauablauf dokumentieren (Bautagebuch) (d)',
|
||
'Nachträge prüfen und bewerten (e)',
|
||
'Gemeinsames Aufmaß mit den ausführenden Unternehmen (f)',
|
||
'Rechnungsprüfung, Leistungsstand bescheinigen (g)',
|
||
'Kostenkontrolle: Abrechnung mit Vertragspreisen und Kostenanschlag vergleichen (h)',
|
||
'Kostenfeststellung (i)',
|
||
'Bei Leistungs- und Funktionsprüfungen mitwirken (j)',
|
||
'Fachtechnische Abnahme, Abnahmeprotokoll, Mängel feststellen (k)',
|
||
'Behördliche Abnahmen beantragen und teilnehmen (l)',
|
||
'Revisionsunterlagen auf Vollständigkeit prüfen (m)',
|
||
'Verjährungsfristen der Mängelansprüche auflisten (n)',
|
||
'Beseitigung der Abnahmemängel überwachen (o)',
|
||
'Dokumentation, Zeichnungen und Berechnungen systematisch zusammenstellen (p)'],
|
||
'LPH 9': [
|
||
'Innerhalb der Verjährungsfristen festgestellte Mängel fachlich bewerten (a)',
|
||
'Objektbegehung vor Ablauf der Verjährungsfristen (b)',
|
||
'Bei der Freigabe von Sicherheitsleistungen mitwirken (c)'],
|
||
},
|
||
};
|
||
function hoaiVorlagenEinfuegen(){
|
||
const vorhanden = new Set(db.prepare('SELECT DISTINCT gewerk FROM lph_templates').all().map(r => r.gewerk));
|
||
const ins = db.prepare(`INSERT INTO lph_templates (id, lph, gewerk, title, offset_days, duration_days, sort_order)
|
||
VALUES (?, ?, ?, ?, 0, 0, ?)`);
|
||
const ziele = [['ARCH', 'gebaeude'], ['ELT', 'technik'], ['HLS', 'technik']];
|
||
db.exec('BEGIN');
|
||
try {
|
||
let n = 0;
|
||
ziele.forEach(([gewerk, art]) => {
|
||
if(vorhanden.has(gewerk)) return; // eigene Vorlagen nie überschreiben
|
||
Object.entries(HOAI_GRUNDLEISTUNGEN[art]).forEach(([lph, titel]) => {
|
||
titel.forEach((t, i) => { ins.run(crypto.randomUUID(), lph, gewerk, t, i); n++; });
|
||
});
|
||
});
|
||
db.exec('COMMIT');
|
||
if(n) console.log('Aufgaben-Vorlagen: ' + n + ' HOAI-Grundleistungen angelegt.');
|
||
} catch(e){ db.exec('ROLLBACK'); throw e; }
|
||
}
|
||
einmaligeUmstellung('kommentar-spiegelstriche', kommentareSpiegelstricheUmstellen);
|
||
// kommentareInAktuelleSitzungUebernehmen: einmalig zur Einführung der
|
||
// Kommentar-Übernahme im Sitzungswechsel. Füllt in der jüngsten echten
|
||
// Sitzung jeden LEEREN Kommentar je Gewerk mit dem zuletzt geschriebenen aus
|
||
// einer früheren Sitzung (gleiche project_uid). Der Besprochen-Haken bleibt.
|
||
function kommentareInAktuelleSitzungUebernehmen(){
|
||
const labels = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL)
|
||
.map(r => r.label)
|
||
.sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b)));
|
||
if(labels.length < 2) return;
|
||
const aktuell = labels[labels.length - 1];
|
||
const rang = new Map(labels.map((l, i) => [l, i]));
|
||
const projekte = db.prepare("SELECT project_id, project_uid FROM projects WHERE week_label = ? AND project_uid != ''").all(aktuell);
|
||
const frueher = db.prepare(`SELECT k.week_label, k.gewerk, k.status, k.status_author, k.status_at
|
||
FROM project_kommentare k JOIN projects p ON p.week_label = k.week_label AND p.project_id = k.project_id
|
||
WHERE p.project_uid = ? AND k.week_label != ? AND k.week_label != ? AND trim(k.status) != ''`);
|
||
const schreiben = db.prepare(`INSERT INTO project_kommentare (week_label, project_id, gewerk, status, status_author, status_at, besprochen)
|
||
VALUES (?, ?, ?, ?, ?, ?, 0)
|
||
ON CONFLICT(week_label, project_id, gewerk) DO UPDATE SET status = excluded.status,
|
||
status_author = excluded.status_author, status_at = excluded.status_at
|
||
WHERE trim(project_kommentare.status) = ''`);
|
||
const jetzt = new Date().toISOString();
|
||
let n = 0;
|
||
db.exec('BEGIN');
|
||
try {
|
||
projekte.forEach(pr => {
|
||
const juengste = new Map();
|
||
frueher.all(pr.project_uid, aktuell, SANDBOX_WEEK_LABEL).forEach(r => {
|
||
if(!rang.has(r.week_label)) return;
|
||
const bisher = juengste.get(r.gewerk);
|
||
if(!bisher || rang.get(r.week_label) > rang.get(bisher.week_label)) juengste.set(r.gewerk, r);
|
||
});
|
||
juengste.forEach((r, g) => {
|
||
const erg = schreiben.run(aktuell, pr.project_id, g, r.status, r.status_author || '', r.status_at || '');
|
||
if(erg.changes){
|
||
n++;
|
||
db.prepare('UPDATE projects SET updated_at = ? WHERE week_label = ? AND project_id = ?').run(jetzt, aktuell, pr.project_id);
|
||
}
|
||
});
|
||
});
|
||
if(n) db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, aktuell);
|
||
db.exec('COMMIT');
|
||
if(n) console.log('Kommentar-Übernahme: ' + n + ' Kommentar(e) in "' + aktuell + '" übernommen.');
|
||
} catch(e){ db.exec('ROLLBACK'); throw e; }
|
||
}
|
||
einmaligeUmstellung('kommentare-uebernehmen', kommentareInAktuelleSitzungUebernehmen);
|
||
// SIEMON_ANTEILE: Teilleistungsbewertung nach dem Siemon-Gutachten zur
|
||
// HOAI 2021 (Anlagenband zum Gutachten 2021-1, Anlage 1.1 Gebäude und 1.7
|
||
// Technische Ausrüstung, veröffentlicht u. a. auf architektenhonorar.de):
|
||
// ein Wert je Grundleistung in Prozent des Gesamthonorars, Summe je Phase =
|
||
// HOAI-Prozentsatz. Löst die zuerst hinterlegten Mittelwerte der Spannen aus
|
||
// den Siemon-Tabellen 2013 ab (SIEMON_2013 unten, nur noch zum Erkennen
|
||
// unveränderter Werte bei der Umstellung) — Eriks Meldung „die Werte sind
|
||
// nicht richtig“.
|
||
const SIEMON_ANTEILE = {
|
||
gebaeude: {
|
||
'LPH 1': { a: 0.75, b: 0.10, c: 0.75, d: 0.25, e: 0.15 },
|
||
'LPH 2': { a: 0.50, b: 0.10, c: 3.00, d: 0.85, e: 0.50, f: 0.30, g: 1.00, h: 0.50, i: 0.25 },
|
||
'LPH 3': { a: 11.00, b: 1.15, c: 0.40, d: 0.20, e: 1.25, f: 0.35, g: 0.65 },
|
||
'LPH 4': { a: 2.50, b: 0.25, c: 0.25 },
|
||
'LPH 5': { a: 10.00, b: 9.50, c: 3.00, d: 0.85, e: 0.40, f: 1.25 },
|
||
'LPH 6': { a: 0.20, b: 7.60, c: 0.35, d: 1.50, e: 0.25, f: 0.10 },
|
||
'LPH 7': { a: 0.25, b: 0.10, c: 2.00, d: 0.25, e: 0.80, f: 0.10, g: 0.25, h: 0.25 },
|
||
'LPH 8': { a: 18.00, b: 0.10, c: 1.50, d: 1.60, e: 0.75, f: 1.00, g: 3.75, h: 0.25, i: 0.85, j: 1.00,
|
||
k: 1.50, l: 0.10, m: 0.25, n: 0.25, o: 0.10, p: 1.00 },
|
||
'LPH 9': { a: 1.00, b: 0.80, c: 0.20 },
|
||
},
|
||
technik: {
|
||
'LPH 1': { a: 1.50, b: 0.30, c: 0.20 },
|
||
'LPH 2': { a: 0.80, b: 5.25, c: 1.00, d: 0.80, e: 0.20, f: 0.70, g: 0.25 },
|
||
'LPH 3': { a: 5.50, b: 0.25, c: 9.95, d: 0.25, e: 0.15, f: 0.65, g: 0.10, h: 0.15 },
|
||
'LPH 4': { a: 1.75, b: 0.25 },
|
||
'LPH 5': { a: 4.50, b: 8.50, c: 4.00, d: 0.50, e: 0.50, f: 4.00 },
|
||
'LPH 6': { a: 2.90, b: 3.05, c: 0.10, d: 0.75, e: 0.10, f: 0.10 },
|
||
'LPH 7': { a: 0.10, b: 3.50, c: 0.50, d: 0.25, e: 0.50, f: 0.15 },
|
||
'LPH 8': { a: 17.50, b: 0.30, c: 0.65, d: 0.50, e: 0.10, f: 2.25, g: 6.50, h: 0.85, i: 0.80, j: 0.50,
|
||
k: 2.50, l: 0.10, m: 0.75, n: 0.10, o: 1.50, p: 0.10 },
|
||
'LPH 9': { a: 0.50, b: 0.40, c: 0.10 },
|
||
},
|
||
};
|
||
// SIEMON_2013: die zuerst eingetragenen Werte (Mittelwerte der Spannen der
|
||
// Siemon-Tabellen 2013, Buchstabe = „in x) enthalten“). Nur noch dafür da,
|
||
// bei der Umstellung auf 2021 zu erkennen, was niemand verändert hat.
|
||
const SIEMON_2013 = {
|
||
gebaeude: {
|
||
'LPH 1': { a: 0.875, b: 'a', c: 0.875, d: 'c', e: 0.3 },
|
||
'LPH 2': { a: 0.375, b: 'a', c: 3.25, d: 1.5, e: 'd', f: 0.3, g: 1.125, h: 0.3, i: 0.3 },
|
||
'LPH 3': { a: 11, b: 1, c: 0.5, d: 0.75, e: 1.5, f: 0.375, g: 0.375 },
|
||
'LPH 4': { a: 3, b: 'a', c: 'a' },
|
||
'LPH 5': { a: 11.5, b: 11.5, c: 'a,b', d: 0.5, e: 0.75, f: 'a,b' },
|
||
'LPH 6': { a: 0.125, b: 8.5, c: 'b', d: 1.5, e: 'd', f: 'b' },
|
||
'LPH 7': { a: 0.3, b: 0.125, c: 3.125, d: 'c', e: 'c', f: 0.175, g: 0.375, h: 0.125 },
|
||
'LPH 8': { a: 21.5, b: 'a', c: 'a', d: 0.75, e: 0.375, f: 'g', g: 5.5, h: 1.25, i: 'h', j: 0.75,
|
||
k: 2, l: 'k', m: 0.175, n: 'k', o: 'k', p: 0.875 },
|
||
'LPH 9': { a: 0.625, b: 1.375, c: 'b' },
|
||
},
|
||
technik: {
|
||
'LPH 1': { a: 0.875, b: 0.875, c: 0.175 },
|
||
'LPH 2': { a: 0.375, b: 6, c: 'b', d: 1.5, e: 'd', f: 0.75, g: 0.175 },
|
||
'LPH 3': { a: 5.5, b: 'a', c: 10.5, d: 0.175, e: 'a', f: 1.125, g: 'f', h: 0.175 },
|
||
'LPH 4': { a: 2, b: 'a' },
|
||
'LPH 5': { a: 5, b: 9.5, c: 3, d: 0.3, e: 0.75, f: 3 },
|
||
'LPH 6': { a: 2.625, b: 3, c: 'a,b', d: 1.5, e: 'd', f: 'b' },
|
||
'LPH 7': { a: 0.05, b: 3.875, c: 'b', d: 0.75, e: 'b', f: 0.175 },
|
||
'LPH 8': { a: 19, b: 0.75, c: 0.375, d: 0.375, e: 0.5, f: 'g', g: 9, h: 1, i: 'h', j: 0.175,
|
||
k: 3.25, l: 'k', m: 0.625, n: 'k', o: 0.875, p: 0.175 },
|
||
'LPH 9': { a: 0.5, b: 0.625, c: 'b' },
|
||
},
|
||
};
|
||
// siemon2021Umstellen: ersetzt die Werte von 2013 durch die von 2021 — nur
|
||
// dort, wo noch genau der 2013 eingetragene Wert steht (selbst Gepflegtes
|
||
// bleibt). Einmalig (einmaligeUmstellung('siemon-anteile-2021')).
|
||
function siemon2021Umstellen(){
|
||
const art = { ARCH: 'gebaeude', ELT: 'technik', HLS: 'technik' };
|
||
const upd = db.prepare("UPDATE lph_templates SET anteil = ?, enthalten_in = '' WHERE id = ?");
|
||
let n = 0;
|
||
db.exec('BEGIN');
|
||
try {
|
||
db.prepare('SELECT * FROM lph_templates').all().forEach(t => {
|
||
const a = art[t.gewerk];
|
||
const m = String(t.title).match(/\(([a-p])\)\s*$/);
|
||
if(!a || !m) return;
|
||
const alt = (SIEMON_2013[a][t.lph] || {})[m[1]];
|
||
const neuW = (SIEMON_ANTEILE[a][t.lph] || {})[m[1]];
|
||
if(alt === undefined || neuW === undefined) return;
|
||
const unveraendert = typeof alt === 'number'
|
||
? (Math.abs((Number(t.anteil) || 0) - alt) < 1e-9 && !t.enthalten_in)
|
||
: (t.enthalten_in === alt && !(Number(t.anteil) > 0));
|
||
if(!unveraendert) return;
|
||
upd.run(neuW, t.id); n++;
|
||
});
|
||
db.exec('COMMIT');
|
||
if(n) console.log('Aufgaben-Vorlagen: ' + n + ' Anteile auf Siemon-Gutachten 2021 umgestellt.');
|
||
} catch(e){ db.exec('ROLLBACK'); throw e; }
|
||
}
|
||
// siemonAnteileEintragen: trägt die Werte in die HOAI-Vorlagen ein — je
|
||
// Gewerk und Phase über den Buchstaben am Ende des Titels („… (c)“). Nur
|
||
// Schritte ohne Anteil und ohne „enthalten in“, also nie etwas, das jemand
|
||
// schon selbst gepflegt hat. Einmalig (einmaligeUmstellung).
|
||
function siemonAnteileEintragen(){
|
||
const art = { ARCH: 'gebaeude', ELT: 'technik', HLS: 'technik' };
|
||
const upd = db.prepare('UPDATE lph_templates SET anteil = ?, enthalten_in = ? WHERE id = ?');
|
||
let n = 0;
|
||
db.exec('BEGIN');
|
||
try {
|
||
db.prepare("SELECT * FROM lph_templates WHERE anteil = 0 AND enthalten_in = ''").all().forEach(t => {
|
||
const tab = SIEMON_ANTEILE[art[t.gewerk]];
|
||
const m = String(t.title).match(/\(([a-p])\)\s*$/);
|
||
if(!tab || !tab[t.lph] || !m) return;
|
||
const wert = tab[t.lph][m[1]];
|
||
if(wert === undefined) return;
|
||
if(typeof wert === 'number') upd.run(wert, '', t.id); else upd.run(0, wert, t.id);
|
||
n++;
|
||
});
|
||
db.exec('COMMIT');
|
||
if(n) console.log('Aufgaben-Vorlagen: Siemon-Anteile für ' + n + ' Schritte eingetragen.');
|
||
} catch(e){ db.exec('ROLLBACK'); throw e; }
|
||
}
|
||
einmaligeUmstellung('hoai-aufgabenvorlagen', hoaiVorlagenEinfuegen);
|
||
einmaligeUmstellung('siemon-anteile', siemonAnteileEintragen);
|
||
einmaligeUmstellung('siemon-anteile-2021', siemon2021Umstellen);
|
||
// Idempotent (ein bereits entfernter Vorsatz ist weg) — läuft deshalb bei
|
||
// jedem Start und nach /import, damit ältere Backups ihn nicht zurückbringen.
|
||
kommentareVorsatzUmstellen();
|
||
ensureSandboxWeek();
|
||
ensureUserPersonIdUniqueIndex();
|
||
migrateRetirePeopleTablesIntoUsers();
|
||
// migrateAbsencesCreatedBy: ergänzt die Spalte created_by in bestehenden
|
||
// Datenbanken (idempotent). Altbestand behält 0 und ist damit nur von der
|
||
// betroffenen Person selbst oder von Administratoren löschbar.
|
||
(function migrateAbsencesCreatedBy(){
|
||
try {
|
||
const cols = db.prepare("SELECT name FROM pragma_table_info('user_absences')").all().map(r => r.name);
|
||
if(!cols.includes('created_by')){
|
||
db.exec("ALTER TABLE user_absences ADD COLUMN created_by INTEGER NOT NULL DEFAULT 0");
|
||
console.log('Migriert: user_absences.created_by ergänzt.');
|
||
}
|
||
} catch(err){
|
||
console.error('Migration user_absences.created_by fehlgeschlagen:', err.message);
|
||
}
|
||
})();
|
||
|
||
bootstrapInitialAdmin();
|
||
|
||
// Abgelaufene Sitzungen beim Start entfernen. Sie wurden bisher nur ignoriert,
|
||
// blieben aber für immer in der Tabelle stehen — und damit auch in jedem
|
||
// Backup-Snapshot.
|
||
try {
|
||
const removed = db.prepare('DELETE FROM user_sessions WHERE expires_at <= ?').run(new Date().toISOString());
|
||
if(removed && removed.changes) console.log('Abgelaufene Sitzungen entfernt:', removed.changes);
|
||
} catch(err){
|
||
console.error('Aufräumen der Sitzungen fehlgeschlagen:', err.message);
|
||
}
|
||
|
||
// ---------- Papierkorb gelöschter Projekte ----------
|
||
// deleted_projects: ein Schnappschuss (das vollständige Projekt-Objekt als
|
||
// JSON, wie es /load-all liefert — inkl. Terminplan, Beteiligten, LPH) je
|
||
// gelöschtem Projekt. Bewusst JSON statt relational: Der Eintrag wird nie
|
||
// abgefragt oder ausgewertet, nur 1:1 wieder in eine Sitzung eingesetzt.
|
||
// Dateien und Protokolle hängen an project_nr (nicht an der Projekt-ID) und
|
||
// werden beim Löschen gar nicht angefasst — nach dem Wiederherstellen sind
|
||
// sie deshalb ohne weiteres Zutun wieder da.
|
||
db.exec(`CREATE TABLE IF NOT EXISTS deleted_projects (
|
||
id TEXT PRIMARY KEY,
|
||
week_label TEXT NOT NULL DEFAULT '',
|
||
project_id TEXT NOT NULL DEFAULT '',
|
||
project_uid TEXT NOT NULL DEFAULT '',
|
||
nr TEXT NOT NULL DEFAULT '',
|
||
name TEXT NOT NULL DEFAULT '',
|
||
data TEXT NOT NULL,
|
||
deleted_by_user_id INTEGER NOT NULL DEFAULT 0,
|
||
deleted_by_name TEXT NOT NULL DEFAULT '',
|
||
deleted_at TEXT NOT NULL
|
||
)`);
|
||
|
||
// purgeGeloeschteProjekte: entfernt Papierkorb-Einträge, die älter als
|
||
// GELOESCHTE_PROJEKTE_TAGE sind. Läuft beim Start und stündlich mit dem
|
||
// Sitzungswechsel (siehe app.listen()).
|
||
function purgeGeloeschteProjekte(){
|
||
try {
|
||
const grenze = new Date(Date.now() - GELOESCHTE_PROJEKTE_TAGE * 24 * 60 * 60 * 1000).toISOString();
|
||
const r = db.prepare('DELETE FROM deleted_projects WHERE deleted_at < ?').run(grenze);
|
||
if(r && r.changes) console.log('Papierkorb: ' + r.changes + ' Projekt(e) nach ' + GELOESCHTE_PROJEKTE_TAGE + ' Tagen endgültig entfernt.');
|
||
} catch(err){
|
||
console.error('Aufräumen des Projekt-Papierkorbs fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
purgeGeloeschteProjekte();
|
||
|
||
// ---------- Auth: Sessions, Middleware, Endpunkte ----------
|
||
|
||
const SESSION_COOKIE = 'puebersicht_session';
|
||
// Gültigkeitsdauer einer Anmeldung. Vorgabe 30 Tage, firmenweit über den
|
||
// Schlüssel 'sessionTage' pflegbar (siehe sessionMaxAgeMs()).
|
||
const DEFAULT_SESSION_TAGE = 30;
|
||
|
||
// sessionMaxAgeMs: aktuelle Gültigkeitsdauer in Millisekunden. Wirkt auf neu
|
||
// erzeugte Sitzungen; bestehende behalten ihr gespeichertes expires_at.
|
||
function sessionMaxAgeMs(){
|
||
return readAppSetting('sessionTage') * 24 * 60 * 60 * 1000;
|
||
}
|
||
|
||
// parseCookies: minimaler handgeschriebener Cookie-Parser (kein cookie-parser-
|
||
// Paket — Projekt-Konvention: nur express+cors als npm-Abhängigkeiten).
|
||
function parseCookies(req){
|
||
const header = req.headers.cookie || '';
|
||
const out = {};
|
||
header.split(';').forEach(part => {
|
||
const idx = part.indexOf('=');
|
||
if(idx === -1) return;
|
||
const k = part.slice(0, idx).trim();
|
||
const v = part.slice(idx + 1).trim();
|
||
if(k) out[k] = decodeURIComponent(v);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// setSessionCookie / clearSessionCookie: setzen bzw. löschen das Session-Cookie
|
||
// (HttpOnly, SameSite=Lax, 30 Tage Laufzeit, Secure nur bei HTTPS-Requests).
|
||
function setSessionCookie(res, token, req){
|
||
const secure = req && req.secure ? '; Secure' : '';
|
||
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(sessionMaxAgeMs()/1000)}${secure}`);
|
||
}
|
||
|
||
function clearSessionCookie(res){
|
||
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||
}
|
||
|
||
// attachUser: liest das Session-Cookie, lädt (falls gültig) Session+User und
|
||
// setzt req.user entsprechend (oder null). Läuft für JEDEN Request (auch vor
|
||
// dem statischen Datei-Ausliefern), damit spätere Routen sich stets auf
|
||
// req.user verlassen können. Aktualisiert nebenbei last_seen_at der Session.
|
||
function attachUser(req, res, next){
|
||
req.user = null;
|
||
const cookies = parseCookies(req);
|
||
const token = cookies[SESSION_COOKIE];
|
||
if(!token) return next();
|
||
const row = db.prepare(`
|
||
SELECT s.expires_at AS expires_at, u.id AS id, u.username AS username,
|
||
u.display_name AS display_name, u.email AS email, u.role AS role,
|
||
u.person_id AS person_id, u.active AS active,
|
||
u.must_change_password AS must_change_password, u.department AS department,
|
||
u.einfuehrung_am AS einfuehrung_am, u.dashboard_reihenfolge AS dashboard_reihenfolge
|
||
FROM user_sessions s JOIN users u ON u.id = s.user_id
|
||
WHERE s.token = ?
|
||
`).get(token);
|
||
if(!row || !row.active || row.expires_at <= new Date().toISOString()) return next();
|
||
req.user = {
|
||
id: row.id,
|
||
username: row.username,
|
||
displayName: row.display_name,
|
||
email: row.email,
|
||
role: row.role,
|
||
personId: row.person_id,
|
||
mustChangePassword: !!row.must_change_password,
|
||
// department: das eigene Gewerk des Kontos — der Wochenbericht schlägt es
|
||
// als Vorauswahl vor (js/wochenbericht/wochenbericht.js), damit man nicht
|
||
// jedes Mal manuell das eigene Gewerk wählen muss.
|
||
department: row.department || '',
|
||
// einfuehrungFaellig: Rundgang noch nicht abgeschlossen (js/einfuehrung.js)
|
||
einfuehrungFaellig: !row.einfuehrung_am,
|
||
// dashboardReihenfolge: eigene Projektreihenfolge auf "Meine Aufgaben"
|
||
dashboardReihenfolge: (() => { try { const a = JSON.parse(row.dashboard_reihenfolge || '[]'); return Array.isArray(a) ? a : []; } catch(e){ return []; } })()
|
||
};
|
||
// last_seen_at höchstens einmal je Minute und Sitzung schreiben. Vorher
|
||
// schrieb JEDE Anfrage — auch jede der rund 60 JS/CSS-Dateien eines
|
||
// Seitenaufrufs — in die Datenbank; auf der SD-Karte des Pi der größte
|
||
// Teil der Ladezeit.
|
||
const jetzt = Date.now();
|
||
if(jetzt - (sitzungZuletztGeschrieben.get(token) || 0) > 60 * 1000){
|
||
sitzungZuletztGeschrieben.set(token, jetzt);
|
||
try {
|
||
db.prepare('UPDATE user_sessions SET last_seen_at = ? WHERE token = ?').run(new Date(jetzt).toISOString(), token);
|
||
} catch(err){
|
||
console.error('Konnte last_seen_at nicht aktualisieren:', err);
|
||
}
|
||
}
|
||
next();
|
||
}
|
||
const sitzungZuletztGeschrieben = new Map();
|
||
// (Registrierung via app.use(attachUser) erfolgt bewusst weiter oben, direkt
|
||
// nach express.json() und vor express.static — siehe dort.)
|
||
|
||
// requireAuth / requireRole: Middleware-Fabriken für spätere Phasen, die damit
|
||
// bestehende Routen absichern. In dieser Phase werden sie auf KEINE bestehende
|
||
// Route angewendet.
|
||
function requireAuth(req, res, next){
|
||
if(!req.user) return res.status(401).json({ ok:false, error:'Nicht angemeldet.' });
|
||
// Rollen ohne „Anwendung nutzen“ (früher fest: 'kiosk') sind überall, wo
|
||
// requireAuth genügte, bewusst AUSGESCHLOSSEN — sie dürfen nichts schreiben
|
||
// und nichts lesen, was die Wandtafel nicht selbst braucht. Die wenigen
|
||
// Routen, die sie braucht, nutzen requireAuthOderKiosk statt requireAuth.
|
||
if(istNurWandtafel(req.user.role)) return res.status(403).json({ ok:false, error:'Dieses Konto hat nur Lesezugriff für die Wandtafel.' });
|
||
next();
|
||
}
|
||
// requireAuthOderKiosk: wie requireAuth, lässt zusätzlich die Kiosk-Rolle zu —
|
||
// ausschließlich für die wenigen Routen, die ein Kiosk-Konto selbst braucht:
|
||
// die lesenden Routen, die die Wandtafel beim Laden aufruft (GET /load-all,
|
||
// /app-settings, /holidays, /honorar-config), /logout (ein Kiosk-Konto darf
|
||
// sich abmelden können, auch wenn die Oberfläche dort aktuell keinen Knopf
|
||
// dafür zeigt) sowie /change-password und /change-email (auf das EIGENE
|
||
// Konto beschränkt — jedes neu angelegte Konto, auch ein Kiosk-Konto, trägt
|
||
// must_change_password und MUSS sein Passwort beim ersten Login ändern
|
||
// können, sonst bliebe der erzwungene Dialog für immer unerfüllbar).
|
||
function requireAuthOderKiosk(req, res, next){
|
||
if(!req.user) return res.status(401).json({ ok:false, error:'Nicht angemeldet.' });
|
||
next();
|
||
}
|
||
function requireRole(role){
|
||
return function(req, res, next){
|
||
if(!req.user) return res.status(401).json({ ok:false, error:'Nicht angemeldet.' });
|
||
if(req.user.role !== role) return res.status(403).json({ ok:false, error:'Keine Berechtigung.' });
|
||
next();
|
||
};
|
||
}
|
||
|
||
// Route: Login — prüft Benutzername/Passwort, legt bei Erfolg eine neue Session
|
||
// an und setzt das Session-Cookie. Liefert bei falschem Benutzernamen, falschem
|
||
// Passwort oder inaktivem Konto absichtlich dieselbe Fehlermeldung (kein
|
||
// Username-Enumeration-Unterschied).
|
||
// loginAttempts: Fehlversuche je Absender-Adresse (nur im Arbeitsspeicher,
|
||
// kein Zustand in der Datenbank). Nach den eingestellten Fehlversuchen ist
|
||
// die Anmeldung für die eingestellte Dauer gesperrt — ohne diese Bremse kann ein
|
||
// Passwort beliebig oft durchprobiert werden.
|
||
const loginAttempts = new Map();
|
||
// Vorgaben der Anmeldebremse; firmenweit über 'loginMaxVersuche' und
|
||
// 'loginSperreMinuten' pflegbar (siehe loginMaxAttempts()/loginBlockMs()).
|
||
const DEFAULT_LOGIN_MAX_ATTEMPTS = 10;
|
||
const DEFAULT_LOGIN_BLOCK_MINUTEN = 10;
|
||
function loginMaxAttempts(){ return readAppSetting('loginMaxVersuche'); }
|
||
function loginBlockMs(){ return readAppSetting('loginSperreMinuten') * 60 * 1000; }
|
||
|
||
// Schlüssel aus Benutzername UND Absender-Adresse: Nur nach der Adresse zu
|
||
// sperren würde im Büro alle hinter derselben Leitung aussperren, sobald sich
|
||
// eine Person zehnmal vertippt. So ist immer nur das betroffene Konto von
|
||
// dieser Stelle aus gebremst.
|
||
// Schlüssel aus der NORMALISIERTEN Kennung (wie sie /login auch sucht: ohne
|
||
// "@…", ohne Leerzeichen, klein) — sonst bekam jede Schreibweise
|
||
// ("x@a.de", " x") einen eigenen Zähler und die Sperre ließ sich beliebig
|
||
// umgehen. Die Adresse kommt nur noch aus der Verbindung selbst: Der
|
||
// Header X-Forwarded-For ist frei wählbar, und vor diesem Server steht kein
|
||
// Proxy, der ihn setzen würde.
|
||
function loginKennung(username){
|
||
const eingabe = String(username || '').trim();
|
||
return (eingabe.includes('@') ? eingabe.slice(0, eingabe.indexOf('@')) : eingabe).trim().toLowerCase();
|
||
}
|
||
function loginClientKey(req, username){
|
||
const ip = String(req.socket.remoteAddress || 'unbekannt');
|
||
return loginKennung(username) + '|' + ip;
|
||
}
|
||
|
||
function loginIsBlocked(key){
|
||
const entry = loginAttempts.get(key);
|
||
if(!entry) return false;
|
||
if(Date.now() - entry.first > loginBlockMs()){ loginAttempts.delete(key); return false; }
|
||
return entry.count >= loginMaxAttempts();
|
||
}
|
||
|
||
function loginNoteFailure(key){
|
||
const entry = loginAttempts.get(key);
|
||
if(!entry || Date.now() - entry.first > loginBlockMs()) loginAttempts.set(key, { count: 1, first: Date.now() });
|
||
else entry.count++;
|
||
}
|
||
|
||
app.post('/login', (req, res) => {
|
||
const { username, password } = req.body || {};
|
||
if(!username || !password) return res.status(400).json({ ok:false, error:'Benutzername und Passwort erforderlich.' });
|
||
// Nur Zeichenketten — eine Zahl ließ scrypt mit 500 abbrechen.
|
||
if(typeof username !== 'string' || typeof password !== 'string'){
|
||
return res.status(400).json({ ok:false, error:'Benutzername und Passwort erforderlich.' });
|
||
}
|
||
const clientKey = loginClientKey(req, username);
|
||
if(loginIsBlocked(clientKey)){
|
||
return res.status(429).json({ ok:false, error:'Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.' });
|
||
}
|
||
// Benutzername = Anfang der E-Mail-Adresse (z. B. e.dunin). Wer die
|
||
// ganze Adresse eintippt, wird ebenso erkannt (alles ab "@" fällt weg),
|
||
// und Groß-/Kleinschreibung spielt keine Rolle ("E.Dunin").
|
||
const kennung = loginKennung(username);
|
||
const row = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(kennung);
|
||
// Auch bei unbekanntem Benutzernamen einmal hashen: Sonst verrät die
|
||
// Antwortzeit (scrypt läuft bewusst langsam), welche Konten es gibt.
|
||
const salt = row ? row.password_salt : 'unbekannt';
|
||
const expected = row ? row.password_hash : crypto.scryptSync('x', 'unbekannt', 64).toString('hex');
|
||
const passwordOk = verifyPassword(password, salt, expected);
|
||
if(!row || !row.active || !passwordOk){
|
||
loginNoteFailure(clientKey);
|
||
return res.status(401).json({ ok:false, error:'Benutzername oder Passwort falsch.' });
|
||
}
|
||
loginAttempts.delete(clientKey);
|
||
const token = crypto.randomBytes(32).toString('hex');
|
||
const now = new Date().toISOString();
|
||
const expiresAt = new Date(Date.now() + sessionMaxAgeMs()).toISOString();
|
||
db.prepare('INSERT INTO user_sessions (token, user_id, created_at, last_seen_at, expires_at) VALUES (?,?,?,?,?)')
|
||
.run(token, row.id, now, now, expiresAt);
|
||
setSessionCookie(res, token, req);
|
||
res.json({
|
||
ok: true,
|
||
user: {
|
||
id: row.id,
|
||
username: row.username,
|
||
displayName: row.display_name,
|
||
role: row.role,
|
||
personId: row.person_id,
|
||
mustChangePassword: !!row.must_change_password
|
||
}
|
||
});
|
||
});
|
||
|
||
// Route: Logout — löscht die Session des aktuellen Cookies und leert das Cookie.
|
||
app.post('/logout', requireAuthOderKiosk, (req, res) => {
|
||
const cookies = parseCookies(req);
|
||
const token = cookies[SESSION_COOKIE];
|
||
if(token) db.prepare('DELETE FROM user_sessions WHERE token = ?').run(token);
|
||
clearSessionCookie(res);
|
||
res.json({ ok:true });
|
||
});
|
||
|
||
// Route: liefert den aktuell angemeldeten Benutzer (oder null) — Basis für das
|
||
// clientseitige Login-Overlay.
|
||
app.get('/whoami', (req, res) => {
|
||
// rechte/rolleName: für die Oberfläche (Knöpfe ein-/ausblenden). Die
|
||
// eigentliche Grenze sind requireRecht()/hatRecht() an den Routen.
|
||
const user = req.user ? Object.assign({}, req.user, {
|
||
rechte: rechteDerRolle(req.user.role),
|
||
rolleName: (rollenCache.get(req.user.role) || {}).name || req.user.role
|
||
}) : null;
|
||
res.json({ ok:true, user });
|
||
});
|
||
|
||
// POST /einfuehrung {erledigt: true}: Rundgang abgeschlossen bzw.
|
||
// übersprungen — nur für das eigene Konto. Neu starten lässt er sich jederzeit
|
||
// unter Einstellungen, ohne diesen Stand zu ändern.
|
||
app.post('/einfuehrung', requireAuth, (req, res) => {
|
||
try {
|
||
const wert = (req.body || {}).erledigt === false ? '' : new Date().toISOString();
|
||
db.prepare('UPDATE users SET einfuehrung_am = ? WHERE id = ?').run(wert, req.user.id);
|
||
res.json({ ok: true });
|
||
} catch(err){ console.error('Einführung speichern fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
|
||
});
|
||
|
||
// POST /dashboard-reihenfolge {reihenfolge:[schlüssel]} — eigene Reihenfolge
|
||
// der Projekte auf "Meine Aufgaben"; leere Liste = wie in der Projektliste.
|
||
// Nur das eigene Konto.
|
||
app.post('/dashboard-reihenfolge', requireAuth, (req, res) => {
|
||
try {
|
||
const liste = (req.body || {}).reihenfolge;
|
||
if(!Array.isArray(liste)) return res.status(400).json({ ok: false, error: 'reihenfolge muss eine Liste sein.' });
|
||
const sauber = [...new Set(liste.map(x => String(x || '').slice(0, 100)).filter(Boolean))].slice(0, 500);
|
||
db.prepare('UPDATE users SET dashboard_reihenfolge = ? WHERE id = ?').run(sauber.length ? JSON.stringify(sauber) : '', req.user.id);
|
||
res.json({ ok: true, reihenfolge: sauber });
|
||
} catch(err){ console.error('Dashboard-Reihenfolge speichern fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
|
||
});
|
||
|
||
// ---------- Rollen & Rechte: Routen ----------
|
||
// GET liefert Katalog und Rollen (mit Anzahl Konten) — jedes angemeldete
|
||
// Konto mit Grundzugang, weil die Benutzerverwaltung die Liste für ihre
|
||
// Rollenauswahl braucht. Ändern nur mit Recht „Rollen & Rechte“.
|
||
function rollenAntwort(){
|
||
const zaehl = new Map(db.prepare('SELECT role, COUNT(*) AS n FROM users WHERE active = 1 GROUP BY role').all().map(r => [r.role, r.n]));
|
||
return {
|
||
ok: true,
|
||
katalog: RECHTE.map(g => ({ gruppe: g.gruppe, rechte: g.rechte.map(([key, name, hinweis]) => ({ key, name, hinweis })) })),
|
||
rollen: [...rollenCache.values()].map(r => ({ key: r.key, name: r.name, fest: r.fest, konten: zaehl.get(r.key) || 0, rechte: rechteDerRolle(r.key) }))
|
||
};
|
||
}
|
||
app.get('/rollen', requireAuth, (req, res) => {
|
||
try { res.json(rollenAntwort()); }
|
||
catch(err){ console.error('Rollen laden fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
|
||
});
|
||
|
||
// POST /rollen {name, vonRolle?}: neue Rolle, optional mit den Rechten einer
|
||
// bestehenden. Der Schlüssel wird aus dem Namen gebildet (a-z, 0-9, -).
|
||
app.post('/rollen', requireRecht('a.rollen'), (req, res) => {
|
||
try {
|
||
const name = String((req.body || {}).name || '').trim();
|
||
if(!name) return res.status(400).json({ ok: false, error: 'Name fehlt.' });
|
||
if([...rollenCache.values()].some(r => r.name.toLowerCase() === name.toLowerCase())){
|
||
return res.status(409).json({ ok: false, error: 'Eine Rolle mit diesem Namen gibt es schon.' });
|
||
}
|
||
const basis = name.toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30) || 'rolle';
|
||
let key = basis, i = 2;
|
||
while(rollenCache.has(key)) key = basis + '-' + (i++);
|
||
const von = (req.body || {}).vonRolle ? String(req.body.vonRolle) : '';
|
||
if(von && !rolleExistiert(von)) return res.status(400).json({ ok: false, error: 'Unbekannte Vorlage-Rolle.' });
|
||
const sort = (db.prepare('SELECT MAX(sort_order) AS m FROM roles').get().m || 0) + 1;
|
||
db.exec('BEGIN');
|
||
try {
|
||
db.prepare('INSERT INTO roles (key, name, sort_order, fest) VALUES (?, ?, ?, 0)').run(key, name, sort);
|
||
const ins = db.prepare('INSERT OR IGNORE INTO role_rechte (role_key, recht) VALUES (?, ?)');
|
||
(von ? rechteDerRolle(von) : []).forEach(r => ins.run(key, r));
|
||
db.exec('COMMIT');
|
||
} catch(e){ db.exec('ROLLBACK'); throw e; }
|
||
ladeRollen();
|
||
res.json(Object.assign(rollenAntwort(), { neu: key }));
|
||
} catch(err){ console.error('Rolle anlegen fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
|
||
});
|
||
|
||
// PATCH /rollen/:key {name}: umbenennen. Administrator bleibt, wie er ist.
|
||
app.patch('/rollen/:key', requireRecht('a.rollen'), (req, res) => {
|
||
try {
|
||
const r = rollenCache.get(String(req.params.key));
|
||
if(!r) return res.status(404).json({ ok: false, error: 'Rolle nicht gefunden.' });
|
||
if(r.fest) return res.status(400).json({ ok: false, error: 'Die Rolle Administrator ist fest.' });
|
||
const name = String((req.body || {}).name || '').trim();
|
||
if(!name) return res.status(400).json({ ok: false, error: 'Name fehlt.' });
|
||
if([...rollenCache.values()].some(x => x.key !== r.key && x.name.toLowerCase() === name.toLowerCase())){
|
||
return res.status(409).json({ ok: false, error: 'Eine Rolle mit diesem Namen gibt es schon.' });
|
||
}
|
||
db.prepare('UPDATE roles SET name = ? WHERE key = ?').run(name, r.key);
|
||
ladeRollen();
|
||
res.json(rollenAntwort());
|
||
} catch(err){ console.error('Rolle umbenennen fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
|
||
});
|
||
|
||
// PUT /rollen/:key/rechte {rechte:[…], an:true|false}: setzt oder entzieht
|
||
// ein oder mehrere Rechte (ein Schalter oder „alle/keine“ einer Gruppe).
|
||
app.put('/rollen/:key/rechte', requireRecht('a.rollen'), (req, res) => {
|
||
try {
|
||
const r = rollenCache.get(String(req.params.key));
|
||
if(!r) return res.status(404).json({ ok: false, error: 'Rolle nicht gefunden.' });
|
||
if(r.fest) return res.status(400).json({ ok: false, error: 'Die Rolle Administrator hat immer alle Rechte.' });
|
||
const liste = Array.isArray((req.body || {}).rechte) ? req.body.rechte.map(String) : [];
|
||
if(!liste.length || liste.some(k => !RECHT_KEYS.has(k))) return res.status(400).json({ ok: false, error: 'Unbekanntes Recht.' });
|
||
const an = !!req.body.an;
|
||
// Sich selbst „Rollen & Rechte“ zu entziehen, sperrte einen sofort aus.
|
||
if(!an && r.key === req.user.role && liste.includes('a.rollen')){
|
||
return res.status(400).json({ ok: false, error: 'Du kannst deiner eigenen Rolle „Rollen & Rechte“ nicht entziehen.' });
|
||
}
|
||
const stmt = an
|
||
? db.prepare('INSERT OR IGNORE INTO role_rechte (role_key, recht) VALUES (?, ?)')
|
||
: db.prepare('DELETE FROM role_rechte WHERE role_key = ? AND recht = ?');
|
||
db.exec('BEGIN');
|
||
try { liste.forEach(k => stmt.run(r.key, k)); db.exec('COMMIT'); }
|
||
catch(e){ db.exec('ROLLBACK'); throw e; }
|
||
ladeRollen();
|
||
res.json(rollenAntwort());
|
||
} catch(err){ console.error('Rechte setzen fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
|
||
});
|
||
|
||
// DELETE /rollen/:key: nur ohne zugeordnete Konten (auch deaktivierte).
|
||
app.delete('/rollen/:key', requireRecht('a.rollen'), (req, res) => {
|
||
try {
|
||
const r = rollenCache.get(String(req.params.key));
|
||
if(!r) return res.json(rollenAntwort());
|
||
if(r.fest) return res.status(400).json({ ok: false, error: 'Die Rolle Administrator kann nicht gelöscht werden.' });
|
||
const konten = db.prepare('SELECT display_name FROM users WHERE role = ? ORDER BY display_name').all(r.key).map(x => x.display_name);
|
||
if(konten.length){
|
||
return res.status(409).json({ ok: false, error: 'Der Rolle sind noch Konten zugeordnet: ' + konten.join(', ') + '. Erst eine andere Rolle zuweisen.' });
|
||
}
|
||
db.exec('BEGIN');
|
||
try {
|
||
db.prepare('DELETE FROM role_rechte WHERE role_key = ?').run(r.key);
|
||
db.prepare('DELETE FROM roles WHERE key = ?').run(r.key);
|
||
db.exec('COMMIT');
|
||
} catch(e){ db.exec('ROLLBACK'); throw e; }
|
||
ladeRollen();
|
||
res.json(rollenAntwort());
|
||
} catch(err){ console.error('Rolle löschen fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); }
|
||
});
|
||
|
||
// Route: Passwort ändern — verlangt immer das aktuelle Passwort (auch bei
|
||
// erzwungenem must_change_password), neues Passwort muss mind. 8 Zeichen haben.
|
||
app.post('/change-password', requireAuthOderKiosk, (req, res) => {
|
||
const { currentPassword, newPassword } = req.body || {};
|
||
if(!currentPassword || !newPassword || typeof currentPassword !== 'string' || typeof newPassword !== 'string'){
|
||
return res.status(400).json({ ok:false, error:'Aktuelles und neues Passwort erforderlich.' });
|
||
}
|
||
if(newPassword.length < 8) return res.status(400).json({ ok:false, error:'Neues Passwort muss mindestens 8 Zeichen haben.' });
|
||
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||
if(!row || !verifyPassword(currentPassword, row.password_salt, row.password_hash)){
|
||
return res.status(401).json({ ok:false, error:'Aktuelles Passwort falsch.' });
|
||
}
|
||
const { hash, salt } = hashPassword(newPassword);
|
||
db.prepare('UPDATE users SET password_hash = ?, password_salt = ?, must_change_password = 0, updated_at = ? WHERE id = ?')
|
||
.run(hash, salt, new Date().toISOString(), row.id);
|
||
res.json({ ok:true });
|
||
});
|
||
|
||
// Route: eigene E-Mail-Adresse ändern — im Unterschied zu Anzeigename, Rolle
|
||
// und Personen-Zuordnung (weiterhin nur PATCH /admin/users/:id) pflegt jedes
|
||
// Konto seine E-Mail selbst, deshalb hier statt in der Admin-Benutzerverwaltung.
|
||
// Kein Formatzwang (wie auch beim Admin-Pfad) — nur eine leere Eingabe wird
|
||
// abgelehnt, das Leeren des Feldes geschieht wie gehabt über die
|
||
// Benutzerverwaltung.
|
||
app.post('/change-email', requireAuthOderKiosk, (req, res) => {
|
||
const { email } = req.body || {};
|
||
const trimmed = String(email || '').trim();
|
||
if(!trimmed) return res.status(400).json({ ok:false, error:'E-Mail-Adresse erforderlich.' });
|
||
db.prepare('UPDATE users SET email = ?, updated_at = ? WHERE id = ?')
|
||
.run(trimmed, new Date().toISOString(), req.user.id);
|
||
res.json({ ok:true, email: trimmed });
|
||
});
|
||
|
||
// ---------- Admin-Benutzerverwaltung ----------
|
||
//
|
||
// Alle Routen dieses Abschnitts verlangen das Recht „Benutzerverwaltung“ und
|
||
// dienen dem Abschnitt Benutzerverwaltung des Bereichs Admin. Sie legen
|
||
// Benutzerkonten an, ändern sie, setzen Passwörter zurück und löschen Konten
|
||
// (Soft-Delete standardmäßig, Hard-Delete nur ohne Referenzen in
|
||
// project_files/project_file_reviewers/project_file_comments).
|
||
|
||
// parseCapacityHours: prüft die vertraglichen Wochenstunden eines Kontos.
|
||
// Leer/undefiniert ergibt 0 = "nicht gepflegt"; das Frontend rechnet dann mit
|
||
// einer vollen Stelle, damit Bestandskonten ihr bisheriges Auslastungsbild
|
||
// behalten. Rückgabe: Zahl 0–80 oder null bei unbrauchbarer Eingabe.
|
||
function parseCapacityHours(value){
|
||
if(value === undefined || value === null || value === '') return 0;
|
||
const n = Number(value);
|
||
if(!Number.isFinite(n) || n < 0 || n > 80) return null;
|
||
return n;
|
||
}
|
||
|
||
// generateRandomPassword: erzeugt ein zufälliges Klartext-Passwort für neu
|
||
// angelegte Accounts bzw. einen Passwort-Reset (gleiche Methode wie
|
||
// bootstrapInitialAdmin).
|
||
function generateRandomPassword(){
|
||
return crypto.randomBytes(15).toString('base64url');
|
||
}
|
||
|
||
// serializeUserRow: wandelt eine users-Zeile in die öffentliche Repräsentation
|
||
// um (OHNE password_hash/password_salt).
|
||
function serializeUserRow(row){
|
||
return {
|
||
id: row.id,
|
||
username: row.username,
|
||
displayName: row.display_name,
|
||
firstName: row.first_name || '',
|
||
lastName: row.last_name || '',
|
||
email: row.email,
|
||
role: row.role,
|
||
personId: row.person_id,
|
||
department: row.department,
|
||
initials: row.initials,
|
||
capacityHours: Number(row.capacity_hours) || 0,
|
||
active: !!row.active,
|
||
mustChangePassword: !!row.must_change_password,
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
};
|
||
}
|
||
|
||
// loadUserRolesByUserId: Batch-Query aller user_roles, gruppiert nach user_id
|
||
// (analog zum bestehenden Muster in loadGlobalPeople() für person_roles) —
|
||
// vermeidet eine Einzelabfrage pro Zeile in GET /admin/users bzw. GET /users.
|
||
function loadUserRolesByUserId(){
|
||
const rows = db.prepare('SELECT * FROM user_roles ORDER BY role_order').all();
|
||
const byUser = {};
|
||
rows.forEach(r => { (byUser[r.user_id] = byUser[r.user_id] || []).push(r.role); });
|
||
return byUser;
|
||
}
|
||
|
||
// Route: Liste aller Benutzer (ohne Passwortfelder), sortiert nach Benutzername.
|
||
app.get('/admin/users', requireRecht('a.benutzer'), (req, res) => {
|
||
try {
|
||
const rows = db.prepare('SELECT * FROM users ORDER BY username').all();
|
||
const rolesByUser = loadUserRolesByUserId();
|
||
res.json({ ok: true, users: rows.map(r => Object.assign(serializeUserRow(r), { roles: rolesByUser[r.id] || [] })) });
|
||
} catch(err){
|
||
console.error('Admin-Benutzerliste fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Abwesenheiten eines einzelnen Benutzers, sortiert nach Start-Datum.
|
||
// GET /admin/users liefert bewusst keine Abwesenheiten mit (würde die Liste
|
||
// unnötig aufblähen) — das Bearbeiten-Modal in admin-users.js lädt sie beim
|
||
// Öffnen separat über diese Route nach.
|
||
app.get('/admin/users/:id/absences', requireRecht('a.benutzer'), (req, res) => {
|
||
const id = Number(req.params.id);
|
||
try {
|
||
const rows = db.prepare('SELECT id, start, end, type, note FROM user_absences WHERE user_id = ? ORDER BY start').all(id);
|
||
res.json({ ok: true, absences: rows });
|
||
} catch(err){
|
||
console.error('Abwesenheiten laden fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Legt einen neuen Benutzer an. Passwort entweder explizit (mind. 8
|
||
// Zeichen) oder automatisch generiert (generatePassword: true); in jedem Fall
|
||
// muss der neue Account beim ersten Login sein Passwort ändern. `personId`
|
||
// wird 1:1 vom Client übernommen (Berechnung/Vorschau via personIdForName()
|
||
// passiert clientseitig).
|
||
app.post('/admin/users', requireRecht('a.benutzer'), (req, res) => {
|
||
const { username, email, role, personId, department, initials, capacityHours, roles, password, generatePassword } = req.body || {};
|
||
const firstName = String((req.body || {}).firstName || '').trim();
|
||
const lastName = String((req.body || {}).lastName || '').trim();
|
||
// Anzeigename = "Vorname Nachname"; ältere Clients schicken nur displayName.
|
||
const displayName = (firstName || lastName) ? [firstName, lastName].filter(Boolean).join(' ') : String((req.body || {}).displayName || '').trim();
|
||
if(!username || !String(username).trim()) return res.status(400).json({ ok: false, error: 'Benutzername erforderlich.' });
|
||
if(!displayName) return res.status(400).json({ ok: false, error: 'Vor- und Nachname erforderlich.' });
|
||
if(db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE').get(String(username).trim())){
|
||
return res.status(409).json({ ok: false, error: 'Benutzername bereits vergeben.' });
|
||
}
|
||
const finalRole = role ? String(role) : 'member';
|
||
if(!rolleExistiert(finalRole)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' });
|
||
// Administrator vergibt nur, wer selbst Administrator ist — sonst könnte
|
||
// sich eine Rolle mit „Benutzerverwaltung“ selbst alle Rechte verschaffen.
|
||
if(rollenCache.get(finalRole).fest && !(rollenCache.get(req.user.role) || {}).fest){
|
||
return res.status(403).json({ ok: false, error: 'Nur ein Administrator kann die Rolle Administrator vergeben.' });
|
||
}
|
||
if(typeof department !== 'string' || !VALID_DEPARTMENTS.has(department)){
|
||
return res.status(400).json({ ok: false, error: 'Ungültige oder fehlende Abteilung.' });
|
||
}
|
||
const roleList = Array.isArray(roles) ? roles : [];
|
||
for(const r of roleList){
|
||
if(!VALID_DISCIPLINE_ROLES.has(r)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle: ' + r });
|
||
}
|
||
// Ein Kiosk-Konto hat keine Fachrolle — es erscheint in keinem
|
||
// Projektboard und wird keiner Rolle zugeordnet, die Felder wären also
|
||
// ohnehin wirkungslos; ihr Setzen wird deshalb zurückgewiesen statt
|
||
// stillschweigend ignoriert.
|
||
if(istNurWandtafel(finalRole) && roleList.length > 0){
|
||
return res.status(400).json({ ok: false, error: 'Ein reines Wandtafel-Konto kann keine Fachrolle haben.' });
|
||
}
|
||
// capacityHours: vertragliche Wochenstunden (Teilzeit). 0 bzw. keine
|
||
// Angabe heißt "nicht gepflegt" — das Frontend rechnet dann mit einer
|
||
// vollen Stelle weiter.
|
||
const capacity = parseCapacityHours(capacityHours);
|
||
if(capacity === null) return res.status(400).json({ ok: false, error: 'Wochenstunden müssen zwischen 0 und 80 liegen.' });
|
||
|
||
let plainPassword;
|
||
if(generatePassword === true){
|
||
plainPassword = generateRandomPassword();
|
||
} else if(typeof password === 'string' && password){
|
||
if(password.length < 8) return res.status(400).json({ ok: false, error: 'Passwort muss mindestens 8 Zeichen haben.' });
|
||
plainPassword = password;
|
||
} else {
|
||
return res.status(400).json({ ok: false, error: 'Passwort erforderlich (manuell oder generatePassword).' });
|
||
}
|
||
|
||
const { hash, salt } = hashPassword(plainPassword);
|
||
const now = new Date().toISOString();
|
||
try {
|
||
const info = db.prepare(`INSERT INTO users
|
||
(username, display_name, first_name, last_name, email, role, person_id, department, initials, capacity_hours, password_hash, password_salt, active, must_change_password, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`)
|
||
.run(String(username).trim(), displayName, firstName, lastName, String(email || ''), finalRole, String(personId || ''), department, String(initials || ''), capacity, hash, salt, now, now);
|
||
const insRole = db.prepare('INSERT INTO user_roles (user_id, role, role_order) VALUES (?, ?, ?)');
|
||
roleList.forEach((r, idx) => insRole.run(info.lastInsertRowid, r, idx));
|
||
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid);
|
||
const result = { ok: true, user: Object.assign(serializeUserRow(row), { roles: roleList }) };
|
||
result.sitzungGeaendert = kontenInAktuelleSitzung([row.id]) > 0;
|
||
if(generatePassword === true) result.generatedPassword = plainPassword;
|
||
res.json(result);
|
||
} catch(err){
|
||
if(err && String(err.message || '').includes('person_id')){
|
||
return res.status(409).json({ ok: false, error: 'Person-ID bereits vergeben.' });
|
||
}
|
||
if(err && String(err.message || '').includes('UNIQUE')){
|
||
return res.status(409).json({ ok: false, error: 'Benutzername bereits vergeben.' });
|
||
}
|
||
console.error('Benutzer anlegen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Aktualisiert einen bestehenden Benutzer (nur die übergebenen Felder).
|
||
// Schützt einen Admin davor, sich selbst zu deaktivieren oder zu degradieren.
|
||
app.patch('/admin/users/:id', requireRecht('a.benutzer'), (req, res) => {
|
||
const id = Number(req.params.id);
|
||
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||
if(!row) return res.status(404).json({ ok: false, error: 'Benutzer nicht gefunden.' });
|
||
|
||
const { displayName, email, role, active, personId, department, initials, capacityHours, roles } = req.body || {};
|
||
if(role !== undefined && !rolleExistiert(role)){
|
||
return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' });
|
||
}
|
||
// Administrator vergeben oder entziehen darf nur ein Administrator.
|
||
const ichAdmin = !!(rollenCache.get(req.user.role) || {}).fest;
|
||
const zielAdminVorher = !!(rollenCache.get(row.role) || {}).fest;
|
||
if(role !== undefined && String(role) !== row.role && !ichAdmin
|
||
&& (zielAdminVorher || (rollenCache.get(String(role)) || {}).fest)){
|
||
return res.status(403).json({ ok: false, error: 'Nur ein Administrator kann die Rolle Administrator vergeben oder entziehen.' });
|
||
}
|
||
if(department !== undefined && !VALID_DEPARTMENTS.has(department)){
|
||
return res.status(400).json({ ok: false, error: 'Ungültige Abteilung.' });
|
||
}
|
||
let capacity;
|
||
if(capacityHours !== undefined){
|
||
capacity = parseCapacityHours(capacityHours);
|
||
if(capacity === null) return res.status(400).json({ ok: false, error: 'Wochenstunden müssen zwischen 0 und 80 liegen.' });
|
||
}
|
||
let roleList;
|
||
if(roles !== undefined){
|
||
roleList = Array.isArray(roles) ? roles : [];
|
||
for(const r of roleList){
|
||
if(!VALID_DISCIPLINE_ROLES.has(r)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle: ' + r });
|
||
}
|
||
}
|
||
// Ein Kiosk-Konto hat keine Fachrolle — geprüft gegen die Rolle UND die
|
||
// Fachrollen-Liste, wie sie nach diesem PATCH jeweils gelten würden (eines
|
||
// von beiden kann unverändert bleiben, wenn es im Body fehlt).
|
||
const effectiveRole = role !== undefined ? String(role) : row.role;
|
||
if(istNurWandtafel(effectiveRole)){
|
||
const effectiveRoles = roles !== undefined
|
||
? roleList
|
||
: db.prepare('SELECT role FROM user_roles WHERE user_id = ?').all(id).map(r => r.role);
|
||
if(effectiveRoles.length > 0){
|
||
return res.status(400).json({ ok: false, error: 'Ein reines Wandtafel-Konto kann keine Fachrolle haben.' });
|
||
}
|
||
}
|
||
const isSelf = req.user.id === id;
|
||
const wouldDeactivate = active !== undefined && !active;
|
||
// Degradierung schließt jeden Wechsel WEG von admin ein, nicht nur zu
|
||
// 'member' — sonst gäbe es für den Selbstschutz eine Kiosk-förmige Lücke.
|
||
const wouldDemote = role !== undefined && zielAdminVorher && !(rollenCache.get(String(role)) || {}).fest;
|
||
if(isSelf && (wouldDeactivate || wouldDemote)){
|
||
return res.status(400).json({ ok: false, error: 'Sie können sich nicht selbst deaktivieren oder degradieren.' });
|
||
}
|
||
// Der letzte aktive Administrator bleibt: sonst kann niemand mehr Rollen
|
||
// und Rechte pflegen.
|
||
if(zielAdminVorher && row.active && (wouldDeactivate || wouldDemote)){
|
||
const festRollen = [...rollenCache.values()].filter(r => r.fest).map(r => r.key);
|
||
const n = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE active = 1 AND role IN (${festRollen.map(() => '?').join(',')})`).get(...festRollen).n;
|
||
if(n <= 1) return res.status(400).json({ ok: false, error: 'Der letzte aktive Administrator kann nicht deaktiviert oder herabgestuft werden.' });
|
||
}
|
||
|
||
const fields = [];
|
||
const values = [];
|
||
const { firstName, lastName } = req.body || {};
|
||
if(firstName !== undefined || lastName !== undefined){
|
||
// Vor-/Nachname setzen auch den Anzeigenamen — er ist ihre Verbindung.
|
||
const alt = db.prepare('SELECT first_name, last_name FROM users WHERE id = ?').get(id) || {};
|
||
const vn = firstName !== undefined ? String(firstName).trim() : (alt.first_name || '');
|
||
const nn = lastName !== undefined ? String(lastName).trim() : (alt.last_name || '');
|
||
if(!vn && !nn) return res.status(400).json({ ok: false, error: 'Vor- und Nachname erforderlich.' });
|
||
fields.push('first_name = ?', 'last_name = ?', 'display_name = ?');
|
||
values.push(vn, nn, [vn, nn].filter(Boolean).join(' '));
|
||
} else if(displayName !== undefined){ fields.push('display_name = ?'); values.push(String(displayName)); }
|
||
if(email !== undefined){ fields.push('email = ?'); values.push(String(email)); }
|
||
if(role !== undefined){ fields.push('role = ?'); values.push(role); }
|
||
if(active !== undefined){ fields.push('active = ?'); values.push(active ? 1 : 0); }
|
||
if(personId !== undefined){ fields.push('person_id = ?'); values.push(String(personId)); }
|
||
if(department !== undefined){ fields.push('department = ?'); values.push(department); }
|
||
if(initials !== undefined){ fields.push('initials = ?'); values.push(String(initials)); }
|
||
if(capacity !== undefined){ fields.push('capacity_hours = ?'); values.push(capacity); }
|
||
const now = new Date().toISOString();
|
||
fields.push('updated_at = ?'); values.push(now);
|
||
values.push(id);
|
||
try {
|
||
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||
// Neue Person-ID: in der laufenden Sitzung und der Planung-Sandbox
|
||
// mitziehen. Sonst legte kontenInAktuelleSitzung() die Person unter der
|
||
// neuen ID ein zweites Mal an, und ihre Zuordnungen, Aufgaben und
|
||
// Anwesenheit hingen weiter an der alten. Ältere Sitzungen bleiben
|
||
// Historie (wie beim endgültigen Löschen).
|
||
const alteId = String(row.person_id || ''), neueId = personId !== undefined ? String(personId) : alteId;
|
||
if(alteId && neueId && alteId !== neueId){
|
||
const echte = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL).map(r => r.label)
|
||
.sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b)));
|
||
[echte[echte.length - 1], SANDBOX_WEEK_LABEL].filter(Boolean).forEach(w => {
|
||
['week_people','week_people_roles','attendance','project_assign','plan_todo_assignees','plan_people'].forEach(t => {
|
||
db.prepare(`UPDATE OR IGNORE ${t} SET person_id = ? WHERE week_label = ? AND person_id = ?`).run(neueId, w, alteId);
|
||
db.prepare(`DELETE FROM ${t} WHERE week_label = ? AND person_id = ?`).run(w, alteId);
|
||
});
|
||
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(new Date().toISOString(), w);
|
||
});
|
||
}
|
||
if(roleList !== undefined){
|
||
db.prepare('DELETE FROM user_roles WHERE user_id = ?').run(id);
|
||
const insRole = db.prepare('INSERT INTO user_roles (user_id, role, role_order) VALUES (?, ?, ?)');
|
||
roleList.forEach((r, idx) => insRole.run(id, r, idx));
|
||
}
|
||
// Deaktivierung erzwingt sofortigen Logout (alle Sessions löschen).
|
||
if(wouldDeactivate){
|
||
db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(id);
|
||
offenePruefungenAufraeumen(id);
|
||
}
|
||
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||
const updatedRoles = roleList !== undefined ? roleList : (loadUserRolesByUserId()[id] || []);
|
||
const sitzungGeaendert = kontenInAktuelleSitzung([id]) > 0;
|
||
res.json({ ok: true, user: Object.assign(serializeUserRow(updated), { roles: updatedRoles }), sitzungGeaendert });
|
||
} catch(err){
|
||
if(err && String(err.message || '').includes('person_id')){
|
||
return res.status(409).json({ ok: false, error: 'Person-ID bereits vergeben.' });
|
||
}
|
||
console.error('Benutzer aktualisieren fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Setzt das Passwort eines Benutzers auf ein neues Zufallspasswort
|
||
// zurück, erzwingt eine Passwortänderung beim nächsten Login und löscht alle
|
||
// bestehenden Sessions (erzwingt Neuanmeldung).
|
||
app.post('/admin/users/:id/reset-password', requireRecht('a.benutzer'), (req, res) => {
|
||
const id = Number(req.params.id);
|
||
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||
if(!row) return res.status(404).json({ ok: false, error: 'Benutzer nicht gefunden.' });
|
||
const plainPassword = generateRandomPassword();
|
||
const { hash, salt } = hashPassword(plainPassword);
|
||
const now = new Date().toISOString();
|
||
try {
|
||
db.prepare('UPDATE users SET password_hash = ?, password_salt = ?, must_change_password = 1, updated_at = ? WHERE id = ?')
|
||
.run(hash, salt, now, id);
|
||
db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(id);
|
||
res.json({ ok: true, generatedPassword: plainPassword });
|
||
} catch(err){
|
||
console.error('Passwort zurücksetzen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Löscht einen Benutzer. Standardmäßig Soft-Delete (active = 0 +
|
||
// Sessions löschen, erzwingt Logout); mit `?hard=1` endgültiges Löschen,
|
||
// sofern der Benutzer nirgends referenziert wird (project_files.uploaded_by_user_id,
|
||
// project_file_reviewers.user_id, project_file_comments.user_id — diese Tabellen
|
||
// sind schon angelegt, aber aktuell noch leer). Ein Admin kann sich selbst
|
||
// weder soft- noch hard-löschen.
|
||
// loescheBenutzerEndgueltig: entfernt ein Konto samt seiner Verweise, in
|
||
// einer Transaktion. Auf Eriks Wunsch verschwindet die Person dabei aus den
|
||
// Projekten und damit aus der Auslastung — aber nur in der AKTUELLEN Sitzung
|
||
// (jüngste echte Woche) und in den Planung-Terminplänen. Ältere Sitzungen
|
||
// sind Historie (Verlauf im Wochenbericht) und bleiben unverändert.
|
||
// Welche Kennungen gemeint sind, bestimmt je Woche personenKennungenInWoche():
|
||
// users.person_id UND jede Kennung im Teilnehmerkreis dieser Woche mit
|
||
// demselben vollen Namen — Altbestand trägt Kurzkennungen (p_ap statt
|
||
// p_alexander_plum), an denen die Zuordnungen hängen; dieselbe Brücke
|
||
// über den Namen nutzt kapazitaetsFaktor() in public/js/load/load.js.
|
||
// Entfernt werden dort: Teilnehmerkreis
|
||
// (week_people/_roles), Anwesenheit, Projektzuordnungen (project_assign —
|
||
// daraus rechnet die Auslastung), Beteiligte des Terminplans (plan_people)
|
||
// und Aufgaben-Zuständigkeiten (plan_todo_assignees). Die betroffenen
|
||
// Wochen bekommen eine neue Revision, damit ein Browser mit altem Stand
|
||
// beim nächsten Speichern einen Konflikt bekommt und neu lädt, statt die
|
||
// Person wieder hineinzuschreiben.
|
||
// Dateien: Hochgeladene Dateien und Kommentare bleiben und erscheinen als
|
||
// „Gelöschter Benutzer" (LEFT JOIN + COALESCE in den Datei-Abfragen).
|
||
// Prüfaufträge an die Person werden entfernt und der Gesamtstatus der
|
||
// betroffenen, noch offenen Dateien neu berechnet (dieselbe Regel wie in
|
||
// POST /project-files/:id/review). Sitzungen, Rollen, Abwesenheiten und
|
||
// KI-Schlüssel fallen per ON DELETE CASCADE mit weg.
|
||
// Rückgabe: Zähler dessen, was entfernt wurde.
|
||
function personenKennungenInWoche(row, woche){
|
||
const ids = new Set();
|
||
if(row.person_id) ids.add(String(row.person_id));
|
||
const name = String(row.display_name || '').trim();
|
||
if(name){
|
||
db.prepare('SELECT person_id FROM week_people WHERE week_label = ? AND full_name = ?').all(woche, name)
|
||
.forEach(r => ids.add(r.person_id));
|
||
}
|
||
return [...ids].filter(Boolean);
|
||
}
|
||
|
||
// kontenInAktuelleSitzung: trägt aktive Konten in den Teilnehmerkreis der
|
||
// AKTUELLEN Sitzung (jüngste echte Woche) ein bzw. gleicht dort Kürzel,
|
||
// Name und Fachrollen mit dem Konto ab. Der Teilnehmerkreis einer Sitzung
|
||
// ist eine Kopie, die beim Sitzungswechsel entsteht — ein später angelegtes
|
||
// Konto fehlte darin, und der Assistent "Neues Projekt" bot niemanden an
|
||
// (aufgefallen auf einer frisch aufgesetzten Datenbank, deren Sitzung vor den
|
||
// Konten entstand). Ältere Sitzungen bleiben als Historie unberührt.
|
||
// userIds: nur diese Konten; ohne Angabe alle aktiven (Abgleich beim Start).
|
||
// Bei einer Änderung bekommt die Woche eine neue Revision, damit ein Browser
|
||
// mit altem Stand neu lädt, statt die Person beim Speichern wieder zu tilgen.
|
||
function kontenInAktuelleSitzung(userIds){
|
||
const echte = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL).map(r => r.label)
|
||
.sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b)));
|
||
const woche = echte[echte.length - 1];
|
||
if(!woche) return 0;
|
||
const filter = Array.isArray(userIds) ? new Set(userIds.map(Number)) : null;
|
||
// Kiosk-Konten (Wandtafel) gehören nicht in den Teilnehmerkreis: Sie
|
||
// haben keine Fachrolle und könnten keine Aufgabe je bearbeiten.
|
||
const konten = db.prepare(`SELECT * FROM users WHERE active = 1 AND person_id != '' AND ${sqlOhneWandtafel()}`).all()
|
||
.filter(u => !filter || filter.has(u.id));
|
||
let geaendert = 0;
|
||
db.exec('BEGIN');
|
||
try {
|
||
// Bereits eingetragene Kiosk-Konten wieder austragen — solange sie keinem
|
||
// Projekt der Sitzung zugeordnet sind (dann bleibt die Zuordnung lesbar).
|
||
db.prepare(`SELECT person_id FROM users WHERE NOT (${sqlOhneWandtafel()}) AND person_id != ''`).all().forEach(k => {
|
||
const zugeordnet = db.prepare('SELECT 1 FROM project_assign WHERE week_label = ? AND person_id = ? AND state > 0 LIMIT 1').get(woche, k.person_id);
|
||
if(zugeordnet) return;
|
||
const weg = db.prepare('DELETE FROM week_people WHERE week_label = ? AND person_id = ?').run(woche, k.person_id).changes;
|
||
if(weg){
|
||
db.prepare('DELETE FROM week_people_roles WHERE week_label = ? AND person_id = ?').run(woche, k.person_id);
|
||
db.prepare('DELETE FROM attendance WHERE week_label = ? AND person_id = ?').run(woche, k.person_id);
|
||
geaendert++;
|
||
}
|
||
});
|
||
konten.forEach(u => {
|
||
const rollen = db.prepare('SELECT role FROM user_roles WHERE user_id = ? ORDER BY role_order').all(u.id).map(r => r.role);
|
||
const kuerzel = u.initials || u.username;
|
||
const da = db.prepare('SELECT * FROM week_people WHERE week_label = ? AND person_id = ?').get(woche, u.person_id);
|
||
const alteRollen = db.prepare('SELECT role FROM week_people_roles WHERE week_label = ? AND person_id = ? ORDER BY role_order')
|
||
.all(woche, u.person_id).map(r => r.role);
|
||
if(da && da.name === kuerzel && da.full_name === u.display_name && alteRollen.join('|') === rollen.join('|')) return;
|
||
if(da){
|
||
db.prepare('UPDATE week_people SET name = ?, full_name = ? WHERE week_label = ? AND person_id = ?')
|
||
.run(kuerzel, u.display_name, woche, u.person_id);
|
||
} else {
|
||
const ende = db.prepare('SELECT COALESCE(MAX(array_order), -1) + 1 n FROM week_people WHERE week_label = ?').get(woche).n;
|
||
db.prepare('INSERT INTO week_people (week_label, person_id, name, full_name, array_order) VALUES (?, ?, ?, ?, ?)')
|
||
.run(woche, u.person_id, kuerzel, u.display_name, ende);
|
||
}
|
||
db.prepare('DELETE FROM week_people_roles WHERE week_label = ? AND person_id = ?').run(woche, u.person_id);
|
||
const insRolle = db.prepare('INSERT INTO week_people_roles (week_label, person_id, role, role_order) VALUES (?, ?, ?, ?)');
|
||
rollen.forEach((r, i) => insRolle.run(woche, u.person_id, r, i));
|
||
geaendert++;
|
||
});
|
||
if(geaendert) db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(new Date().toISOString(), woche);
|
||
db.exec('COMMIT');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
console.error('Teilnehmerkreis der aktuellen Sitzung konnte nicht abgeglichen werden:', err);
|
||
return 0;
|
||
}
|
||
if(geaendert) console.log(`Teilnehmerkreis "${woche}": ${geaendert} Konto/Konten eingetragen bzw. abgeglichen.`);
|
||
return geaendert;
|
||
}
|
||
|
||
// offenePruefungenAufraeumen: nimmt ein deaktiviertes Konto aus den noch
|
||
// offenen Dateiprüfungen und berechnet deren Gesamtstatus neu. Ohne das blieb
|
||
// eine Datei, deren Prüfer deaktiviert wurde, für immer "ausstehend" — die
|
||
// übrigen Prüfer konnten zustimmen, so viel sie wollten. War das Konto der
|
||
// EINZIGE Prüfer, bleibt die Prüfung stehen (eine Datei ganz ohne Prüfer
|
||
// wäre ebenso festgefahren; nach einer Reaktivierung geht es dort weiter).
|
||
// Parameter: userId. Keine Rückgabe.
|
||
function offenePruefungenAufraeumen(userId){
|
||
const jetzt = new Date().toISOString();
|
||
const offen = db.prepare(`SELECT r.file_id FROM project_file_reviewers r JOIN project_files f ON f.id = r.file_id
|
||
WHERE r.user_id = ? AND r.decision = 'pending' AND f.status = 'pending'`).all(userId);
|
||
offen.forEach(({ file_id }) => {
|
||
const andere = db.prepare('SELECT decision FROM project_file_reviewers WHERE file_id = ? AND user_id != ?').all(file_id, userId).map(r => r.decision);
|
||
if(!andere.length) return;
|
||
db.prepare('DELETE FROM project_file_reviewers WHERE file_id = ? AND user_id = ?').run(file_id, userId);
|
||
let status = 'pending';
|
||
if(andere.some(d => d === 'rejected')) status = 'rejected';
|
||
else if(andere.every(d => d === 'approved')) status = 'approved';
|
||
if(status !== 'pending') db.prepare('UPDATE project_files SET status = ?, updated_at = ? WHERE id = ?').run(status, jetzt, file_id);
|
||
});
|
||
}
|
||
|
||
function loescheBenutzerEndgueltig(row){
|
||
const echte = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL).map(r => r.label)
|
||
.sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b)));
|
||
const wochen = [echte[echte.length - 1], SANDBOX_WEEK_LABEL].filter(Boolean);
|
||
const zaehler = { zuordnungen: 0, aufgaben: 0, pruefauftraege: 0 };
|
||
const jetzt = new Date().toISOString();
|
||
|
||
db.exec('BEGIN');
|
||
try {
|
||
wochen.forEach(w => {
|
||
const ids = personenKennungenInWoche(row, w);
|
||
if(!ids.length) return;
|
||
ids.forEach(pid => {
|
||
zaehler.zuordnungen += db.prepare('DELETE FROM project_assign WHERE week_label = ? AND person_id = ?').run(w, pid).changes;
|
||
zaehler.aufgaben += db.prepare('DELETE FROM plan_todo_assignees WHERE week_label = ? AND person_id = ?').run(w, pid).changes;
|
||
['plan_people', 'week_people_roles', 'week_people', 'attendance'].forEach(t => {
|
||
db.prepare(`DELETE FROM ${t} WHERE week_label = ? AND person_id = ?`).run(w, pid);
|
||
});
|
||
});
|
||
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, w);
|
||
});
|
||
|
||
const dateien = db.prepare('SELECT file_id FROM project_file_reviewers WHERE user_id = ?').all(row.id).map(r => r.file_id);
|
||
zaehler.pruefauftraege = db.prepare('DELETE FROM project_file_reviewers WHERE user_id = ?').run(row.id).changes;
|
||
dateien.forEach(fileId => {
|
||
const datei = db.prepare('SELECT status FROM project_files WHERE id = ?').get(fileId);
|
||
if(!datei || datei.status !== 'pending') return;
|
||
const entscheidungen = db.prepare('SELECT decision FROM project_file_reviewers WHERE file_id = ?').all(fileId).map(r => r.decision);
|
||
let status = 'pending';
|
||
if(entscheidungen.some(d => d === 'rejected')) status = 'rejected';
|
||
else if(entscheidungen.length > 0 && entscheidungen.every(d => d === 'approved')) status = 'approved';
|
||
if(status !== 'pending') db.prepare('UPDATE project_files SET status = ?, updated_at = ? WHERE id = ?').run(status, jetzt, fileId);
|
||
});
|
||
|
||
db.prepare('DELETE FROM users WHERE id = ?').run(row.id);
|
||
db.exec('COMMIT');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
return zaehler;
|
||
}
|
||
|
||
app.delete('/admin/users/:id', requireRecht('a.benutzer'), (req, res) => {
|
||
const id = Number(req.params.id);
|
||
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||
if(!row) return res.status(404).json({ ok: false, error: 'Benutzer nicht gefunden.' });
|
||
if(req.user.id === id) return res.status(400).json({ ok: false, error: 'Sie können Ihren eigenen Account nicht löschen.' });
|
||
|
||
const hard = req.query.hard === '1';
|
||
try {
|
||
if(hard){
|
||
const ergebnis = loescheBenutzerEndgueltig(row);
|
||
console.log('Benutzer endgültig gelöscht:', row.username, ergebnis);
|
||
return res.json(Object.assign({ ok: true, hard: true }, ergebnis));
|
||
}
|
||
const now = new Date().toISOString();
|
||
db.prepare('UPDATE users SET active = 0, updated_at = ? WHERE id = ?').run(now, id);
|
||
db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(id);
|
||
offenePruefungenAufraeumen(id);
|
||
res.json({ ok: true, hard: false });
|
||
} catch(err){
|
||
console.error('Benutzer löschen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Speichert eine komplette Woche (als JSON-Objekt) für eine Abteilung
|
||
app.post('/save-week', requireAuth, (req, res) => {
|
||
const { week, baseUpdatedAt } = req.body || {};
|
||
if(!week || typeof week.label !== 'string' || !week.label) return res.status(400).json({ ok: false, error: 'Missing week or label' });
|
||
// Auftragsstatus prüfen, BEVOR irgendetwas geschrieben wird: /save-week ist
|
||
// der einzige Schreibweg für Projektdaten, ein unbekannter Wert bliebe also
|
||
// dauerhaft stehen (siehe findInvalidAuftragsstatus()).
|
||
const badStatus = findInvalidAuftragsstatus(week);
|
||
if(badStatus){
|
||
return res.status(400).json({
|
||
ok: false,
|
||
error: 'Unbekannter Auftragsstatus "' + badStatus.wert + '" am Projekt '
|
||
+ ((badStatus.nr ? badStatus.nr + ' ' : '') + badStatus.name).trim()
|
||
+ '. Erlaubt: ' + Array.from(VALID_AUFTRAGSSTATUS).join(', ') + '.'
|
||
});
|
||
}
|
||
try {
|
||
// Revisions-/Konflikt-Check: Mit `baseUpdatedAt` meldet der Client die
|
||
// Version, auf der seine lokalen Daten beruhen. Ist die Woche auf dem
|
||
// Server zwischenzeitlich von jemand anderem gespeichert worden, wird
|
||
// NICHT überschrieben, sondern ein Konflikt gemeldet (HTTP 409).
|
||
// Wichtig: Auch eine leere Basis (updated_at='' direkt nach der Migration)
|
||
// wird geprüft – nur Clients OHNE baseUpdatedAt-Feld (alte Versionen)
|
||
// überspringen die Prüfung. Der Check läuft synchron vor dem Schreiben,
|
||
// daher gibt es im Prozess keine Race-Condition zwischen Prüfung und
|
||
// Transaktion.
|
||
// Sperr-Status serverseitig durchsetzen: Eine gesperrte Sitzung darf NUR
|
||
// über /unlock-week (Recht „Sitzung entsperren“) wieder geöffnet werden. Das
|
||
// Sperren selbst (false -> true) bleibt jedem erlaubt, weil es die
|
||
// Auto-Sperre des Clients (applyAutoLock(), public/js/weeks.js) beim
|
||
// normalen Speichern mitschreibt. Ohne diese Prüfung wäre die Sperre reine
|
||
// Client-Logik: ein beliebiges Mitglied könnte die Woche mit locked:false
|
||
// zurückschreiben und die Admin-Grenze umgehen.
|
||
const lockRow = db.prepare('SELECT locked FROM weeks WHERE label = ?').get(String(week.label));
|
||
if(lockRow && lockRow.locked && !week.locked && !hatRecht(req.user, 's.gesperrt')){
|
||
return res.status(403).json({
|
||
ok: false,
|
||
error: 'Die Sitzung "' + week.label + '" ist gesperrt. Nur Administratoren können sie wieder öffnen.'
|
||
});
|
||
}
|
||
// Auch der INHALT einer gesperrten Sitzung ist für Nicht-Admins tabu —
|
||
// bisher wurde nur das Entsperren abgewiesen, Kommentare und Aufgaben
|
||
// einer gesperrten Woche ließen sich über die API weiter ändern. Als 409
|
||
// (Konflikt) statt 403: Ein Browser, der die Woche noch offen glaubt (etwa
|
||
// weil der Sitzungswechsel sie inzwischen gesperrt hat), lädt sie dann neu
|
||
// und zeigt sie gesperrt, statt in einer Fehlerschleife zu hängen.
|
||
// Das Mitschreiben der Auto-Sperre ist nicht betroffen: Es trifft nur
|
||
// Wochen, die am Server noch offen sind.
|
||
if(lockRow && lockRow.locked && !hatRecht(req.user, 's.gesperrt')){
|
||
const stand = db.prepare('SELECT updated_at FROM weeks WHERE label = ?').get(String(week.label));
|
||
return res.status(409).json({
|
||
ok: false, conflict: true,
|
||
error: 'Die Sitzung "' + week.label + '" ist gesperrt und kann nicht mehr geändert werden.',
|
||
updatedAt: stand ? stand.updated_at : ''
|
||
});
|
||
}
|
||
|
||
if(typeof baseUpdatedAt === 'string'){
|
||
const row = db.prepare('SELECT updated_at FROM weeks WHERE label = ?').get(String(week.label));
|
||
if(row){
|
||
if(baseUpdatedAt !== row.updated_at){
|
||
return res.status(409).json({
|
||
ok: false,
|
||
conflict: true,
|
||
error: 'Die Sitzung "' + week.label + '" wurde zwischenzeitlich von einer anderen Person geändert.',
|
||
updatedAt: row.updated_at
|
||
});
|
||
}
|
||
} else if(baseUpdatedAt !== ''){
|
||
// Die Woche existiert am Server nicht mehr (vermutlich gelöscht). Ein
|
||
// Save mit nicht-leerer, also älterer Revision darf eine gelöschte
|
||
// Sitzung nicht wiederbeleben; nur neue Wochen (leere/fehlende Basis)
|
||
// dürfen angelegt werden.
|
||
return res.status(409).json({
|
||
ok: false,
|
||
conflict: true,
|
||
error: 'Die Sitzung "' + week.label + '" wurde zwischenzeitlich gelöscht.',
|
||
updatedAt: ''
|
||
});
|
||
}
|
||
}
|
||
// Angelegte und gelöschte Projekte erkennen: /save-week schreibt immer
|
||
// die ganze Woche, beides ist also nur am Vergleich mit dem gespeicherten
|
||
// Stand zu sehen (projektlisteAenderungen()). Nur Projektleiter und
|
||
// Administratoren dürfen das (403 sonst, bevor irgendetwas geschrieben
|
||
// wird); gelöschte Projekte wandern in den Papierkorb
|
||
// (deleted_projects). Die Planung-Sandbox ist ausgenommen — ihre
|
||
// Terminpläne sind keine Projekte der Projektliste und werden dort von
|
||
// jedem angelegt und gelöscht.
|
||
// Jede Projekt-id und jede projectUid darf in einer Woche nur einmal
|
||
// vorkommen. Eine doppelte id ließ das Speichern mit 500 (UNIQUE) scheitern;
|
||
// eine doppelte uid ließ eine Kopie eines bestehenden Projekts als
|
||
// "bekannt" durchgehen — so konnte auch ein Mitglied Projekte anlegen,
|
||
// und das spätere Löschen der Kopie landete nicht im Papierkorb.
|
||
{
|
||
const ids = new Set(), uids = new Set();
|
||
for(const p of (Array.isArray(week.projects) ? week.projects : []).filter(Boolean)){
|
||
const id = String(p.id || ''), uid = String(p.projectUid || '');
|
||
if((id && ids.has(id)) || (uid && uids.has(uid))){
|
||
return res.status(400).json({ ok: false, error: 'Ein Projekt kommt in der Sitzung doppelt vor (' + String(p.nr || p.name || id) + ').' });
|
||
}
|
||
if(id) ids.add(id);
|
||
if(uid) uids.add(uid);
|
||
}
|
||
}
|
||
const aenderung = week.label === SANDBOX_WEEK_LABEL
|
||
? { neu: [], entfernt: [] } : projektlisteAenderungen(week);
|
||
const entfernt = aenderung.entfernt;
|
||
if((aenderung.neu.length || entfernt.length) && !darfProjektlisteAendern(req.user)){
|
||
return res.status(403).json({
|
||
ok: false,
|
||
error: aenderung.neu.length
|
||
? 'Keine Berechtigung: „Projekte anlegen und löschen“ fehlt.'
|
||
: 'Keine Berechtigung: „Projekte anlegen und löschen“ fehlt.',
|
||
recht: 'proj.anlegen'
|
||
});
|
||
}
|
||
{
|
||
const fehlt = fehlendesBearbeitungsrecht(req.user, week.label, week.projects, week);
|
||
if(fehlt) return rechtFehlt(res, fehlt);
|
||
}
|
||
|
||
// Zuständige VOR dem Speichern festhalten — danach lassen sich neu
|
||
// hinzugekommene erkennen (neueZuweisungenMelden()). Eine Woche, die es
|
||
// noch nicht gab, meldet nichts: Sie entsteht als Ganzes, nicht durch
|
||
// eine Zuweisung.
|
||
const wocheGabEs = !!db.prepare('SELECT 1 FROM weeks WHERE label = ?').get(String(week.label));
|
||
const alteZuweisungen = new Set(db.prepare('SELECT todo_id, person_id FROM plan_todo_assignees WHERE week_label = ?')
|
||
.all(String(week.label)).map(r => r.todo_id + '|' + r.person_id));
|
||
|
||
// `updatedAt` zurückgeben, damit der Client seine lokale Revisions-Basis
|
||
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
|
||
const updatedAt = saveWeekRelational(week);
|
||
if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user);
|
||
if(wocheGabEs) neueZuweisungenMelden(week, alteZuweisungen, req.user, req);
|
||
console.log('Saved week:', week.label);
|
||
ereignisSenden({ typ: 'woche', label: week.label, updatedAt }, req);
|
||
return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
|
||
} catch(err){
|
||
console.error('Save week error', err);
|
||
return res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// ---------- Teams: Nachricht bei neuen Aufgaben ----------
|
||
//
|
||
// Eriks Wunsch: Wird jemandem eine Aufgabe zugewiesen, erscheint in einem
|
||
// Teams-Kanal eine Nachricht mit Link direkt auf die Aufgabe im Projekt.
|
||
// Eingerichtet in Teams über „Workflows“ → „Beim Empfang einer
|
||
// Webhook-Anfrage in einem Kanal posten“; die dort erzeugte Adresse trägt
|
||
// ein Admin unter Admin → Teams ein (Recht a.teams). Die Adresse ist ein
|
||
// Geheimnis (wer sie kennt, kann in den Kanal schreiben) — sie steht in
|
||
// `integrationen`, nicht in app_settings (das jedes Konto lesen darf), und
|
||
// geht nie an den Browser zurück. Nicht in IMPORT_TABLES.
|
||
//
|
||
// Senden braucht Internetzugang nach außen; ein Fehler wird nur geloggt,
|
||
// das Speichern der Sitzung scheitert nie daran.
|
||
|
||
db.exec(`CREATE TABLE IF NOT EXISTS integrationen (
|
||
schluessel TEXT PRIMARY KEY,
|
||
wert TEXT NOT NULL DEFAULT ''
|
||
)`);
|
||
|
||
function integrationLesen(k){
|
||
const r = db.prepare('SELECT wert FROM integrationen WHERE schluessel = ?').get(k);
|
||
return r ? r.wert : '';
|
||
}
|
||
function integrationSchreiben(k, v){
|
||
db.prepare('INSERT INTO integrationen (schluessel, wert) VALUES (?, ?) ON CONFLICT(schluessel) DO UPDATE SET wert = excluded.wert')
|
||
.run(k, String(v));
|
||
}
|
||
|
||
// teamsKarte: Adaptive Card im Umschlag, den der Teams-Workflow erwartet.
|
||
function teamsKarte(titel, zeilen, fakten, link, linkText){
|
||
const body = [{ type: 'TextBlock', text: titel, weight: 'Bolder', size: 'Medium', wrap: true }];
|
||
zeilen.forEach(z => body.push({ type: 'TextBlock', text: z, wrap: true, spacing: 'Small' }));
|
||
if(fakten.length) body.push({ type: 'FactSet', facts: fakten.map(([title, value]) => ({ title, value })) });
|
||
const content = {
|
||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||
type: 'AdaptiveCard', version: '1.4', body,
|
||
actions: link ? [{ type: 'Action.OpenUrl', title: linkText || 'Öffnen', url: link }] : []
|
||
};
|
||
return { type: 'message', attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', contentUrl: null, content }] };
|
||
}
|
||
|
||
// teamsSenden: an die eingetragene Adresse schicken. Rückgabe: Promise —
|
||
// wirft bei Fehler (für den Test-Knopf); Aufrufer im Hintergrund fangen ab.
|
||
async function teamsSenden(nachricht, url){
|
||
const ziel = url || integrationLesen('teams_webhook');
|
||
if(!ziel) throw new Error('Keine Teams-Adresse eingetragen.');
|
||
const res = await fetchMitTimeout(ziel, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(nachricht)
|
||
});
|
||
if(!res.ok){
|
||
const t = await res.text().catch(() => '');
|
||
throw new Error('Teams antwortete mit HTTP ' + res.status + (t ? ': ' + t.slice(0, 200) : ''));
|
||
}
|
||
}
|
||
|
||
// aufgabenLink: Adresse der Aufgabe im Projekt (der Browser öffnet sie direkt
|
||
// im Aufgaben-Panel, Parameter aufgabe=). Host aus der speichernden Anfrage —
|
||
// also die Adresse, unter der die App im Büro aufgerufen wird.
|
||
function aufgabenLink(req, weekLabel, p, todoId){
|
||
if(!req) return '';
|
||
const u = new URL('/Projektplan/' + encodeURIComponent(p.name || ''), req.protocol + '://' + req.get('host'));
|
||
u.searchParams.set('weekLabel', weekLabel);
|
||
u.searchParams.set('projNr', p.nr || '');
|
||
u.searchParams.set('aufgabe', String(todoId));
|
||
u.hash = 'aufgaben';
|
||
return u.toString();
|
||
}
|
||
|
||
function teamsDatum(iso){
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''));
|
||
return m ? m[3] + '.' + m[2] + '.' + m[1] : '';
|
||
}
|
||
|
||
// teamsNeueAufgaben: je Aufgabe mit neu Zuständigen EINE Nachricht. neu =
|
||
// Map todoId -> { p, t, personen:[ids] }. Läuft im Hintergrund.
|
||
function teamsNeueAufgaben(weekLabel, neu, user, req){
|
||
if(!neu.size || integrationLesen('teams_neue_aufgaben') !== '1' || !integrationLesen('teams_webhook')) return;
|
||
const namen = new Map(db.prepare('SELECT person_id, name, full_name FROM week_people WHERE week_label = ?').all(weekLabel)
|
||
.map(r => [r.person_id, r.full_name || r.name || r.person_id]));
|
||
const von = (user && (user.displayName || user.username)) || '';
|
||
neu.forEach(({ p, t, personen }) => {
|
||
const wer = personen.map(id => namen.get(id) || id).join(', ');
|
||
const zeitraum = t.start && t.end && t.start !== t.end ? teamsDatum(t.start) + ' – ' + teamsDatum(t.end) : '';
|
||
const fakten = [['Projekt', [p.nr, p.name].filter(Boolean).join(' ')], ['Zuständig', wer]];
|
||
if(t.end) fakten.push(['Fällig', teamsDatum(t.end)]);
|
||
if(zeitraum) fakten.push(['Zeitraum', zeitraum]);
|
||
if(Number(t.aufwand) > 0) fakten.push(['Aufwand', String(t.aufwand).replace('.', ',') + ' h']);
|
||
if(von) fakten.push(['Zugewiesen von', von]);
|
||
const karte = teamsKarte('Neue Aufgabe: ' + (t.title || 'Aufgabe'), [], fakten,
|
||
aufgabenLink(req, weekLabel, p, t.id), 'Aufgabe öffnen');
|
||
teamsSenden(karte).catch(err => console.warn('Teams-Nachricht nicht gesendet:', err.message));
|
||
});
|
||
}
|
||
|
||
app.get('/admin/teams', requireRecht('a.teams'), (req, res) => {
|
||
const url = integrationLesen('teams_webhook');
|
||
let host = '';
|
||
try { host = url ? new URL(url).host : ''; } catch(e){ /* egal */ }
|
||
res.json({ ok: true, konfiguriert: !!url, host, neueAufgaben: integrationLesen('teams_neue_aufgaben') === '1' });
|
||
});
|
||
|
||
// PUT {webhook?, neueAufgaben?} — webhook '' entfernt die Adresse.
|
||
app.put('/admin/teams', requireRecht('a.teams'), (req, res) => {
|
||
try {
|
||
const b = req.body || {};
|
||
if(typeof b.webhook === 'string'){
|
||
const w = b.webhook.trim();
|
||
if(w){
|
||
let u;
|
||
try { u = new URL(w); } catch(e){ return res.status(400).json({ ok: false, error: 'Das ist keine gültige Adresse.' }); }
|
||
if(u.protocol !== 'https:') return res.status(400).json({ ok: false, error: 'Die Teams-Adresse muss mit https:// beginnen.' });
|
||
}
|
||
integrationSchreiben('teams_webhook', w);
|
||
}
|
||
if(typeof b.neueAufgaben === 'boolean') integrationSchreiben('teams_neue_aufgaben', b.neueAufgaben ? '1' : '0');
|
||
res.json({ ok: true });
|
||
} catch(err){
|
||
console.error('Teams-Einstellung speichern fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// POST /admin/teams/test — Testnachricht; Fehler als {ok:false, error} mit
|
||
// HTTP 200 (ein fehlgeschlagener Test ist ein erwartetes Ergebnis).
|
||
app.post('/admin/teams/test', requireRecht('a.teams'), async (req, res) => {
|
||
try {
|
||
const link = req.protocol + '://' + req.get('host') + '/';
|
||
await teamsSenden(teamsKarte('Projektübersicht ist mit Teams verbunden',
|
||
['Ab jetzt erscheinen hier neue Aufgaben mit einem Link direkt zur Aufgabe im Projekt.'],
|
||
[['Eingerichtet von', req.user.displayName || req.user.username]], link, 'Projektübersicht öffnen'));
|
||
res.json({ ok: true });
|
||
} catch(err){
|
||
res.json({ ok: false, error: err.message });
|
||
}
|
||
});
|
||
|
||
// neueZuweisungenMelden: legt für jede Person, die einer offenen Aufgabe neu
|
||
// als zuständig zugewiesen wurde, eine Benachrichtigung an — sofern zu der
|
||
// Person ein aktives Konto gehört (users.person_id; Kiosk-Konten nicht) und
|
||
// sie sich nicht selbst zugewiesen hat. Läuft NACH dem erfolgreichen
|
||
// Speichern und darf es nie scheitern lassen (Fehler werden nur geloggt).
|
||
// Parameter: week (gespeichertes Wochenobjekt), alt (Set "todoId|personId"
|
||
// vor dem Speichern), user (req.user). Keine Rückgabe.
|
||
function neueZuweisungenMelden(week, alt, user, req){
|
||
try {
|
||
const konto = db.prepare(`SELECT id FROM users WHERE person_id = ? AND person_id != '' AND active = 1 AND ${sqlOhneWandtafel()}`);
|
||
const schonDa = db.prepare('SELECT 1 FROM benachrichtigungen WHERE user_id = ? AND todo_id = ? AND gelesen = 0');
|
||
const ins = db.prepare(`INSERT INTO benachrichtigungen
|
||
(id, user_id, art, titel, project_id, project_nr, project_name, week_label, todo_id, faellig, von_name, created_at)
|
||
VALUES (?, ?, 'aufgabe', ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||
const jetzt = new Date().toISOString();
|
||
const von = (user && (user.displayName || user.username)) || '';
|
||
let anzahl = 0;
|
||
// Für Teams: je Aufgabe die neu Zuständigen — auch Personen ohne Konto,
|
||
// aber nicht, wer sich selbst zuweist; nie aus der Planung-Sandbox.
|
||
const fuerTeams = new Map();
|
||
const sandbox = week.label === SANDBOX_WEEK_LABEL;
|
||
const ichPerson = user && user.personId ? String(user.personId) : '';
|
||
(Array.isArray(week.projects) ? week.projects : []).forEach(p => {
|
||
const todos = (p && p.plan && Array.isArray(p.plan.todos)) ? p.plan.todos : [];
|
||
todos.forEach(t => {
|
||
if(!t || t.done) return;
|
||
(Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => {
|
||
if(alt.has(String(t.id) + '|' + String(pid))) return;
|
||
if(!sandbox && !t.lph && String(pid) !== ichPerson){
|
||
if(!fuerTeams.has(String(t.id))) fuerTeams.set(String(t.id), { p, t, personen: [] });
|
||
fuerTeams.get(String(t.id)).personen.push(String(pid));
|
||
}
|
||
const k = konto.get(String(pid));
|
||
if(!k || (user && k.id === user.id)) return;
|
||
if(schonDa.get(k.id, String(t.id))) return;
|
||
ins.run(crypto.randomUUID(), k.id, String(t.title || 'Aufgabe'), String(p.id || ''), String(p.nr || ''),
|
||
String(p.name || ''), String(week.label || ''), String(t.id || ''), String(t.end || ''), von, jetzt);
|
||
anzahl++;
|
||
});
|
||
});
|
||
});
|
||
if(anzahl) console.log('Benachrichtigungen angelegt:', anzahl, '(neue Aufgaben-Zuweisungen)');
|
||
teamsNeueAufgaben(String(week.label || ''), fuerTeams, user, req);
|
||
} catch(err){
|
||
console.error('Benachrichtigungen konnten nicht angelegt werden:', err.message);
|
||
}
|
||
}
|
||
|
||
// purgeBenachrichtigungen: gelesene nach 60, alle nach 180 Tagen entfernen.
|
||
function purgeBenachrichtigungen(){
|
||
try {
|
||
const tag = 864e5;
|
||
const n = db.prepare('DELETE FROM benachrichtigungen WHERE (gelesen = 1 AND created_at < ?) OR created_at < ?')
|
||
.run(new Date(Date.now() - 60 * tag).toISOString(), new Date(Date.now() - 180 * tag).toISOString()).changes;
|
||
if(n) console.log('Alte Benachrichtigungen entfernt:', n);
|
||
} catch(err){ console.error('Benachrichtigungen aufräumen fehlgeschlagen:', err.message); }
|
||
}
|
||
|
||
// GET /benachrichtigungen: die 30 jüngsten des angemeldeten Kontos plus die
|
||
// Zahl der ungelesenen (für die Glocke). Kiosk-Konten bekommen 403 über
|
||
// requireAuth wie überall.
|
||
app.get('/benachrichtigungen', requireAuth, (req, res) => {
|
||
try {
|
||
const rows = db.prepare(`SELECT * FROM benachrichtigungen WHERE user_id = ?
|
||
ORDER BY created_at DESC LIMIT 30`).all(req.user.id);
|
||
const ungelesen = db.prepare('SELECT COUNT(*) AS n FROM benachrichtigungen WHERE user_id = ? AND gelesen = 0').get(req.user.id).n;
|
||
res.json({ ok: true, ungelesen, eintraege: rows.map(r => ({
|
||
id: r.id, art: r.art, titel: r.titel, projectId: r.project_id, projectNr: r.project_nr,
|
||
projectName: r.project_name, weekLabel: r.week_label, todoId: r.todo_id, faellig: r.faellig,
|
||
von: r.von_name, erstellt: r.created_at, gelesen: !!r.gelesen,
|
||
})) });
|
||
} catch(err){
|
||
console.error('Benachrichtigungen laden fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// POST /benachrichtigungen/gelesen {ids:[…]} oder {alle:true}: markiert als
|
||
// gelesen — immer nur Einträge des eigenen Kontos.
|
||
app.post('/benachrichtigungen/gelesen', requireAuth, (req, res) => {
|
||
try {
|
||
const { ids, alle } = req.body || {};
|
||
let n = 0;
|
||
if(alle){
|
||
n = db.prepare('UPDATE benachrichtigungen SET gelesen = 1 WHERE user_id = ? AND gelesen = 0').run(req.user.id).changes;
|
||
} else if(Array.isArray(ids)){
|
||
const upd = db.prepare('UPDATE benachrichtigungen SET gelesen = 1 WHERE user_id = ? AND id = ?');
|
||
ids.slice(0, 200).forEach(id => { n += upd.run(req.user.id, String(id)).changes; });
|
||
} else {
|
||
return res.status(400).json({ ok: false, error: 'ids oder alle angeben.' });
|
||
}
|
||
res.json({ ok: true, markiert: n });
|
||
} catch(err){
|
||
console.error('Benachrichtigungen markieren fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// POST /benachrichtigungen/loeschen {ids:[…]} oder {alle:true}: entfernt
|
||
// Einträge endgültig (Eriks Wunsch: für die User löschbar) — wie beim
|
||
// Markieren immer nur Einträge des eigenen Kontos.
|
||
app.post('/benachrichtigungen/loeschen', requireAuth, (req, res) => {
|
||
try {
|
||
const { ids, alle } = req.body || {};
|
||
let n = 0;
|
||
if(alle){
|
||
n = db.prepare('DELETE FROM benachrichtigungen WHERE user_id = ?').run(req.user.id).changes;
|
||
} else if(Array.isArray(ids)){
|
||
const del = db.prepare('DELETE FROM benachrichtigungen WHERE user_id = ? AND id = ?');
|
||
ids.slice(0, 200).forEach(id => { n += del.run(req.user.id, String(id)).changes; });
|
||
} else {
|
||
return res.status(400).json({ ok: false, error: 'ids oder alle angeben.' });
|
||
}
|
||
res.json({ ok: true, geloescht: n });
|
||
} catch(err){
|
||
console.error('Benachrichtigungen löschen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// ---------- Live-Ereignisse (Server-Sent Events) ----------
|
||
//
|
||
// Eriks Wunsch: Änderungen sollen sofort bei allen ankommen, nicht erst beim
|
||
// nächsten Abfragen (vorher alle 20 Sekunden), und nur der geänderte Teil.
|
||
// GET /ereignisse hält je offenem Browser eine Verbindung offen; nach jedem
|
||
// Speichern meldet ereignisSenden() allen anderen, was sich geändert hat:
|
||
// { typ:'projekte', label, updatedAt, projekte:[…] } — nur die geänderten
|
||
// Projekte, so wie sie jetzt in der Datenbank stehen (POST /save-projects)
|
||
// { typ:'woche', label, updatedAt, geloescht? } — eine ganze Sitzung hat sich
|
||
// geändert (POST /save-week, Entsperren, Papierkorb, Löschen); der
|
||
// Browser holt sie über GET /week nach
|
||
// { typ:'neu' } — neue Sitzung oder Backup eingespielt: alles neu laden
|
||
// Der Absender bekommt sein eigenes Ereignis nicht (Kopfzeile X-Client-Id,
|
||
// je Browser-Tab zufällig). Keine Abhängigkeit, kein WebSocket: SSE ist
|
||
// reines HTTP und läuft unverändert durch Docker. Ein vorgeschalteter Proxy
|
||
// (nginx) darf diese Antwort nicht puffern — X-Accel-Buffering: no.
|
||
const ereignisKunden = new Set(); // { res, token, clientId }
|
||
|
||
app.get('/ereignisse', requireAuthOderKiosk, (req, res) => {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||
'Cache-Control': 'no-cache, no-transform',
|
||
'Connection': 'keep-alive',
|
||
'X-Accel-Buffering': 'no'
|
||
});
|
||
res.write('retry: 3000\n\n');
|
||
const kunde = {
|
||
res,
|
||
token: parseCookies(req)[SESSION_COOKIE] || '',
|
||
clientId: String(req.query.client || '')
|
||
};
|
||
ereignisKunden.add(kunde);
|
||
req.on('close', () => ereignisKunden.delete(kunde));
|
||
});
|
||
|
||
// Herzschlag: hält die Verbindung durch Router/Proxys offen, die stille
|
||
// Verbindungen sonst nach einer Weile trennen.
|
||
setInterval(() => { ereignisKunden.forEach(k => { try { k.res.write(': ping\n\n'); } catch(e){} }); }, 25000);
|
||
|
||
// ereignisSenden: verteilt ein Ereignis an alle verbundenen Browser außer dem
|
||
// auslösenden. Abgelaufene oder abgemeldete Sitzungen bekommen nichts mehr
|
||
// und werden getrennt. Parameter: daten (Objekt), req (auslösende Anfrage
|
||
// oder null).
|
||
function ereignisSenden(daten, req){
|
||
if(ereignisKunden.size === 0) return;
|
||
const von = req ? String(req.get('x-client-id') || '') : '';
|
||
const text = 'data: ' + JSON.stringify(daten) + '\n\n';
|
||
const jetzt = new Date().toISOString();
|
||
const gueltig = db.prepare('SELECT 1 FROM user_sessions WHERE token = ? AND expires_at > ?');
|
||
ereignisKunden.forEach(k => {
|
||
if(von && k.clientId === von) return;
|
||
if(!gueltig.get(k.token, jetzt)){ ereignisKunden.delete(k); try { k.res.end(); } catch(e){} return; }
|
||
try { k.res.write(text); } catch(e){ ereignisKunden.delete(k); }
|
||
});
|
||
}
|
||
|
||
// GET /week?label=: eine einzelne Sitzung (statt /load-all mit allen) — für
|
||
// das Nachladen nach einem Ereignis und beim Zusammenführen. Die Sandbox
|
||
// kommt in derselben Form wie bei /load-all (nur ihre Projekte).
|
||
app.get('/week', requireAuthOderKiosk, (req, res) => {
|
||
try {
|
||
const label = String(req.query.label || '');
|
||
const w = label ? loadWeeks(label)[0] : null;
|
||
if(!w) return res.status(404).json({ ok: false, error: 'Sitzung nicht gefunden' });
|
||
res.json({ ok: true, week: w });
|
||
} catch(err){
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// POST /save-projects {label, projects:[{project, baseUpdatedAt}]}: speichert
|
||
// NUR die übergebenen Projekte einer Sitzung — der Browser schickt, was sich
|
||
// gegenüber dem zuletzt bekannten Server-Stand geändert hat, statt der ganzen
|
||
// Sitzung. Konflikte werden je PROJEKT geprüft (projects.updated_at): Zwei
|
||
// Personen an verschiedenen Projekten kommen sich nicht mehr in die Quere.
|
||
// Nur für bestehende Projekte; Anlegen, Löschen, Umsortieren und Änderungen
|
||
// an der Sitzung selbst (Teilnehmer, Anwesenheit, Sperre) laufen weiter über
|
||
// /save-week. Antwort: { ok, updatedAt (Sitzung), projekte: {id: updatedAt} };
|
||
// 409 { conflict, projekte:[ids] }, wenn ein Projekt inzwischen anders steht.
|
||
app.post('/save-projects', requireAuth, (req, res) => {
|
||
const { label, projects } = req.body || {};
|
||
if(typeof label !== 'string' || !label || !Array.isArray(projects) || projects.length === 0){
|
||
return res.status(400).json({ ok: false, error: 'label und projects erforderlich' });
|
||
}
|
||
const liste = projects.filter(e => e && e.project && e.project.id);
|
||
const badStatus = findInvalidAuftragsstatus({ projects: liste.map(e => e.project) });
|
||
if(badStatus){
|
||
return res.status(400).json({ ok: false, error: 'Unbekannter Auftragsstatus "' + badStatus.wert + '".' });
|
||
}
|
||
try {
|
||
const wocheRow = db.prepare('SELECT locked, updated_at FROM weeks WHERE label = ?').get(label);
|
||
if(!wocheRow) return res.status(409).json({ ok: false, conflict: true, error: 'Die Sitzung "' + label + '" gibt es nicht mehr.', projekte: [] });
|
||
if(wocheRow.locked && !hatRecht(req.user, 's.gesperrt')){
|
||
return res.status(409).json({ ok: false, conflict: true, error: 'Die Sitzung "' + label + '" ist gesperrt.', projekte: [] });
|
||
}
|
||
const stand = db.prepare('SELECT array_order, updated_at, project_uid FROM projects WHERE week_label = ? AND project_id = ?');
|
||
const konflikte = [];
|
||
const gefunden = liste.map(e => {
|
||
const row = stand.get(label, String(e.project.id));
|
||
if(!row || String(e.baseUpdatedAt || '') !== String(row.updated_at || '')
|
||
|| String(e.project.projectUid || '') !== String(row.project_uid || '')) konflikte.push(String(e.project.id));
|
||
return { e, row };
|
||
});
|
||
if(konflikte.length){
|
||
return res.status(409).json({ ok: false, conflict: true, projekte: konflikte,
|
||
error: 'Ein Projekt wurde zwischenzeitlich von einer anderen Person geändert.' });
|
||
}
|
||
{
|
||
const fehlt = fehlendesBearbeitungsrecht(req.user, label, liste.map(e => e.project), null);
|
||
if(fehlt) return rechtFehlt(res, fehlt);
|
||
}
|
||
const alteZuweisungen = new Set(db.prepare('SELECT todo_id, person_id FROM plan_todo_assignees WHERE week_label = ?')
|
||
.all(label).map(r => r.todo_id + '|' + r.person_id));
|
||
const jetzt = new Date().toISOString();
|
||
db.exec('BEGIN');
|
||
try {
|
||
gefunden.forEach(({ e, row }) => {
|
||
deleteProjectData(label, String(e.project.id));
|
||
serializeProject(label, e.project, row.array_order, jetzt);
|
||
});
|
||
// Die Sitzung als Ganzes ist damit auch geändert: Ein gleichzeitiges
|
||
// /save-week auf altem Stand muss das als Konflikt bemerken.
|
||
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, label);
|
||
db.exec('COMMIT');
|
||
} catch(err){ db.exec('ROLLBACK'); throw err; }
|
||
neueZuweisungenMelden({ label, projects: liste.map(e => e.project) }, alteZuweisungen, req.user, req);
|
||
const ids = new Set(liste.map(e => String(e.project.id)));
|
||
const frisch = (loadWeeks(label)[0] || { projects: [] }).projects.filter(p => ids.has(String(p.id)));
|
||
ereignisSenden({ typ: 'projekte', label, updatedAt: jetzt, projekte: frisch }, req);
|
||
const projekte = {};
|
||
frisch.forEach(p => { projekte[p.id] = p.updatedAt; });
|
||
console.log('Saved projects:', label, Array.from(ids).length);
|
||
res.json({ ok: true, updatedAt: jetzt, projekte });
|
||
} catch(err){
|
||
console.error('Save projects error', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// GET /week-revisionen: der Revisionsstand (weeks.updated_at) jeder Sitzung —
|
||
// winzig, deshalb fragt der Browser ihn alle 20 Sekunden ab und holt nur bei
|
||
// einer Abweichung die geänderte Sitzung nach (Live-Abgleich beim
|
||
// gleichzeitigen Arbeiten, js/store.js). Für die Wandtafel ebenfalls lesbar.
|
||
// GET /weeks-aelter?vor=<label>&anzahl=5: die nächsten älteren Sitzungen vor
|
||
// <label> vollständig ("Weitere laden" im Verlauf), dazu wie viele danach
|
||
// noch übrig sind.
|
||
app.get('/weeks-aelter', requireAuthOderKiosk, (req, res) => {
|
||
try {
|
||
const liste = echteSitzungenSortiert();
|
||
const vor = String(req.query.vor || '');
|
||
const anzahl = Math.min(50, Math.max(1, Math.floor(Number(req.query.anzahl)) || 5));
|
||
const idx = liste.findIndex(w => w.label === vor);
|
||
const bis = idx < 0 ? liste.length : idx;
|
||
const von = Math.max(0, bis - anzahl);
|
||
const weeks = loadWeeks(liste.slice(von, bis).map(w => w.label));
|
||
res.json({ ok: true, weeks, uebrig: von });
|
||
} catch(err){
|
||
console.error('Ältere Sitzungen laden fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
app.get('/week-revisionen', requireAuthOderKiosk, (req, res) => {
|
||
try {
|
||
const revisionen = {};
|
||
db.prepare('SELECT label, updated_at FROM weeks').all().forEach(r => { revisionen[r.label] = r.updated_at || ''; });
|
||
res.json({ ok: true, revisionen });
|
||
} catch(err){
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// ---------- Bearbeitungsrechte beim Speichern ----------
|
||
// /save-week und /save-projects schreiben ganze Projekte. Welches Recht eine
|
||
// Änderung braucht, zeigt erst der Vergleich mit dem gespeicherten Stand:
|
||
// Kommentar und Leistungsphasen-Fortschritt → wb.kommentar, „besprochen“ →
|
||
// wb.besprochen, Terminplan (plan) → proj.plan, alles andere am Projekt →
|
||
// proj.stamm, Anwesenheit der Sitzung → wb.anwesenheit, die Planung-Sandbox
|
||
// → wz.planung. Wer alle diese Rechte hat (Startbelegung), überspringt den
|
||
// Vergleich ganz — nur eingeschränkte Rollen zahlen dafür.
|
||
const BEARBEITUNGS_RECHTE = ['wb.kommentar', 'wb.besprochen', 'wb.anwesenheit', 'proj.stamm', 'proj.plan'];
|
||
const PROJEKT_FELDER_OHNE_RECHT = new Set(['id', 'projectUid', 'updatedAt', 'order',
|
||
'status', 'statusAuthor', 'statusAt', 'besprochen', 'statusState']);
|
||
|
||
// normWert: vergleichbare Form — leere Werte (undefined, null, '', false, 0,
|
||
// [], {}) gelten als gleich, Zahlen und Text ebenso („5“ = 5), Schlüssel-
|
||
// reihenfolge zählt nicht. Sonst meldete ein Feld, das der Browser nur
|
||
// anders schreibt, eine Änderung, die niemand gemacht hat.
|
||
function normWert(v){
|
||
if(v === undefined || v === null || v === '' || v === false || v === 0) return null;
|
||
if(Array.isArray(v)){ const a = v.map(normWert); return a.length ? a : null; }
|
||
if(typeof v === 'object'){
|
||
const o = {};
|
||
Object.keys(v).sort().forEach(k => { const x = normWert(v[k]); if(x !== null) o[k] = x; });
|
||
return Object.keys(o).length ? o : null;
|
||
}
|
||
return String(v);
|
||
}
|
||
const gleichNorm = (a, b) => JSON.stringify(normWert(a)) === JSON.stringify(normWert(b));
|
||
|
||
// fehlendesBearbeitungsrecht: erstes fehlende Recht für die Änderungen oder
|
||
// null. Parameter: user, label, projekte (eingehend), woche (bei /save-week,
|
||
// für die Anwesenheit; sonst null).
|
||
function fehlendesBearbeitungsrecht(user, label, projekte, woche){
|
||
if(String(label) === SANDBOX_WEEK_LABEL) return hatRecht(user, 'wz.planung') ? null : 'wz.planung';
|
||
if(BEARBEITUNGS_RECHTE.every(r => hatRecht(user, r))) return null;
|
||
const alt = loadWeeks(String(label))[0];
|
||
if(!alt) return null; // neue Sitzung: legt nur der Server an
|
||
if(woche && !hatRecht(user, 'wb.anwesenheit') && !gleichNorm(woche.attendance, alt.attendance)) return 'wb.anwesenheit';
|
||
const altJeId = new Map(), altJeUid = new Map();
|
||
(alt.projects || []).forEach(p => { altJeId.set(String(p.id), p); if(p.projectUid) altJeUid.set(String(p.projectUid), p); });
|
||
for(const p of (projekte || []).filter(Boolean)){
|
||
const a = altJeId.get(String(p.id)) || (p.projectUid && altJeUid.get(String(p.projectUid)));
|
||
if(!a) continue; // neu angelegt — prüft darfProjektlisteAendern()
|
||
const keys = new Set([...Object.keys(p), ...Object.keys(a)]);
|
||
for(const k of keys){
|
||
if(PROJEKT_FELDER_OHNE_RECHT.has(k) || gleichNorm(p[k], a[k])) continue;
|
||
if(k === 'kommentare'){
|
||
const kn = p.kommentare || {}, ka = a.kommentare || {};
|
||
for(const g of new Set([...Object.keys(kn), ...Object.keys(ka)])){
|
||
const x = kn[g] || {}, y = ka[g] || {};
|
||
if(!gleichNorm({ s: x.status, a: x.statusAuthor, t: x.statusAt }, { s: y.status, a: y.statusAuthor, t: y.statusAt })
|
||
&& !hatRecht(user, 'wb.kommentar')) return 'wb.kommentar';
|
||
if(!gleichNorm(x.besprochen, y.besprochen) && !hatRecht(user, 'wb.besprochen')) return 'wb.besprochen';
|
||
}
|
||
continue;
|
||
}
|
||
const recht = k === 'lph' ? 'wb.kommentar' : k === 'plan' ? 'proj.plan' : 'proj.stamm';
|
||
if(!hatRecht(user, recht)) return recht;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// darfProjektlisteAendern: Recht „Projekte anlegen und löschen“ (Rollen &
|
||
// Rechte). Dieselbe Regel steht clientseitig in
|
||
// darfProjektlisteAendern() (public/js/auth.js), dort nur als UI-Gate.
|
||
function darfProjektlisteAendern(user){
|
||
return hatRecht(user, 'proj.anlegen');
|
||
}
|
||
|
||
// projektlisteAenderungen: vergleicht die eingehende Woche mit dem
|
||
// gespeicherten Stand. Ein Projekt gilt als dasselbe, wenn seine ID ODER
|
||
// seine projectUid übereinstimmt. Rückgabe:
|
||
// neu — eingehende Projekte ohne Gegenstück (angelegt)
|
||
// entfernt — vollständige gespeicherte Projekt-Objekte (aus loadWeeks())
|
||
// ohne Gegenstück (gelöscht); der teure loadWeeks()-Aufruf läuft
|
||
// nur, wenn die günstige Vorprüfung überhaupt eines findet.
|
||
// Neue Sitzungen legt nur der Server selbst an (ensureCurrentWeekRollover(),
|
||
// ohne /save-week) — eine über /save-week neu eingehende Woche mit Projekten
|
||
// zählt deshalb ebenfalls als Anlegen.
|
||
function projektlisteAenderungen(week){
|
||
const label = String(week.label);
|
||
const gespeichert = db.prepare('SELECT project_id, project_uid FROM projects WHERE week_label = ?').all(label);
|
||
const eingehend = (Array.isArray(week.projects) ? week.projects : []).filter(Boolean);
|
||
|
||
const altIds = new Set(gespeichert.map(r => r.project_id));
|
||
const altUids = new Set(gespeichert.map(r => r.project_uid).filter(Boolean));
|
||
const neu = eingehend.filter(p => !altIds.has(String(p.id || '')) && !(p.projectUid && altUids.has(String(p.projectUid))));
|
||
|
||
const ids = new Set(eingehend.map(p => String(p.id || '')).filter(Boolean));
|
||
const uids = new Set(eingehend.map(p => String(p.projectUid || '')).filter(Boolean));
|
||
const fehlend = gespeichert.filter(r => !ids.has(r.project_id) && !(r.project_uid && uids.has(r.project_uid)));
|
||
if(!fehlend.length) return { neu, entfernt: [] };
|
||
// Nur diese eine Sitzung laden, nicht alle (loadWeeks(label)).
|
||
const w = loadWeeks(label)[0];
|
||
const fehlendIds = new Set(fehlend.map(r => r.project_id));
|
||
return { neu, entfernt: w ? (w.projects || []).filter(p => fehlendIds.has(String(p.id))) : [] };
|
||
}
|
||
|
||
// legeInPapierkorb: schreibt je gelöschtem Projekt einen Schnappschuss nach
|
||
// deleted_projects. Läuft NACH dem erfolgreichen Speichern der Woche — ein
|
||
// fehlgeschlagenes Speichern darf keinen Papierkorb-Eintrag für ein Projekt
|
||
// hinterlassen, das in Wahrheit noch da ist.
|
||
function legeInPapierkorb(projekte, weekLabel, user){
|
||
const ins = db.prepare(`INSERT INTO deleted_projects
|
||
(id, week_label, project_id, project_uid, nr, name, data, deleted_by_user_id, deleted_by_name, deleted_at)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?)`);
|
||
const jetzt = new Date().toISOString();
|
||
projekte.forEach(p => {
|
||
try {
|
||
ins.run(crypto.randomUUID(), String(weekLabel), String(p.id || ''), String(p.projectUid || ''),
|
||
String(p.nr || ''), String(p.name || ''), JSON.stringify(p),
|
||
user ? Number(user.id) || 0 : 0, user ? String(user.displayName || user.username || '') : '', jetzt);
|
||
console.log('Projekt in den Papierkorb gelegt:', ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim());
|
||
} catch(err){
|
||
console.error('Papierkorb-Eintrag fehlgeschlagen:', err.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Route: Papierkorb — listet die gelöschten Projekte der letzten
|
||
// GELOESCHTE_PROJEKTE_TAGE Tage (ohne den Schnappschuss selbst).
|
||
app.get('/admin/deleted-projects', requireRecht('a.papierkorb'), (req, res) => {
|
||
try {
|
||
purgeGeloeschteProjekte();
|
||
const rows = db.prepare(`SELECT id, week_label, nr, name, deleted_by_name, deleted_at
|
||
FROM deleted_projects ORDER BY deleted_at DESC`).all();
|
||
const tagMs = 24 * 60 * 60 * 1000;
|
||
res.json({
|
||
ok: true,
|
||
aufbewahrungTage: GELOESCHTE_PROJEKTE_TAGE,
|
||
projekte: rows.map(r => ({
|
||
id: r.id, weekLabel: r.week_label, nr: r.nr, name: r.name,
|
||
deletedBy: r.deleted_by_name, deletedAt: r.deleted_at,
|
||
endgueltigAm: new Date(Date.parse(r.deleted_at) + GELOESCHTE_PROJEKTE_TAGE * tagMs).toISOString()
|
||
}))
|
||
});
|
||
} catch(err){
|
||
console.error('Papierkorb laden fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Papierkorb — stellt ein gelöschtes Projekt wieder her. Ziel ist
|
||
// immer die aktuelle (jüngste echte) Sitzung, nicht die, aus der es gelöscht
|
||
// wurde: Ältere Sitzungen sind gesperrte Historie. Eine Nummern-Kollision mit
|
||
// einem inzwischen neu angelegten Projekt wird mit 409 abgewiesen (Dateien
|
||
// und Protokolle hängen an der Nummer und gehörten sonst zwei Projekten).
|
||
app.post('/admin/deleted-projects/:id/restore', requireRecht('a.papierkorb'), (req, res) => {
|
||
try {
|
||
const row = db.prepare('SELECT * FROM deleted_projects WHERE id = ?').get(String(req.params.id));
|
||
if(!row) return res.status(404).json({ ok: false, error: 'Eintrag nicht gefunden (evtl. schon wiederhergestellt oder abgelaufen).' });
|
||
const echte = loadWeeks().filter(w => w.label !== SANDBOX_WEEK_LABEL)
|
||
.sort((a, b) => weekSortKeyServer(a.label).localeCompare(weekSortKeyServer(b.label)));
|
||
const ziel = echte[echte.length - 1];
|
||
if(!ziel) return res.status(409).json({ ok: false, error: 'Es gibt keine Sitzung, in die das Projekt zurückkehren könnte.' });
|
||
|
||
const projekt = JSON.parse(row.data);
|
||
const projekte = Array.isArray(ziel.projects) ? ziel.projects : (ziel.projects = []);
|
||
const nr = String(projekt.nr || '').trim();
|
||
if(nr && projekte.some(p => String(p.nr || '').trim() === nr)){
|
||
return res.status(409).json({ ok: false, error: 'In der Sitzung "' + ziel.label + '" gibt es bereits ein Projekt mit der Nummer ' + nr + '.' });
|
||
}
|
||
if(projekt.projectUid && projekte.some(p => p.projectUid === projekt.projectUid)){
|
||
return res.status(409).json({ ok: false, error: 'Dieses Projekt steht bereits in der Sitzung "' + ziel.label + '".' });
|
||
}
|
||
// Neue ID, falls die alte in der Zielsitzung schon vergeben ist (nach
|
||
// einem Sitzungswechsel praktisch ausgeschlossen, aber billig abzusichern).
|
||
if(!projekt.id || projekte.some(p => p.id === projekt.id)) projekt.id = crypto.randomUUID();
|
||
// Ans Ende seiner Gruppe — die alte Position kann inzwischen belegt sein.
|
||
const maxOrder = projekte.filter(p => p.group === projekt.group)
|
||
.reduce((m, p) => Math.max(m, Number(p.order) || 0), -1);
|
||
projekt.order = maxOrder + 1;
|
||
// Der Sitzungskommentar gehört zur Sitzung, aus der das Projekt stammt.
|
||
if(row.week_label !== ziel.label){
|
||
projekt.kommentare = {}; delete projekt.statusState;
|
||
}
|
||
projekte.push(projekt);
|
||
|
||
const updatedAt = saveWeekRelational(ziel);
|
||
db.prepare('DELETE FROM deleted_projects WHERE id = ?').run(row.id);
|
||
console.log('Projekt aus dem Papierkorb wiederhergestellt:', ((projekt.nr ? projekt.nr + ' ' : '') + (projekt.name || '')).trim(), '→', ziel.label);
|
||
ereignisSenden({ typ: 'woche', label: ziel.label, updatedAt }, req);
|
||
res.json({ ok: true, weekLabel: ziel.label, updatedAt });
|
||
} catch(err){
|
||
console.error('Wiederherstellen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Papierkorb — entfernt einen Eintrag sofort endgültig.
|
||
app.delete('/admin/deleted-projects/:id', requireRecht('a.papierkorb'), (req, res) => {
|
||
try {
|
||
db.prepare('DELETE FROM deleted_projects WHERE id = ?').run(String(req.params.id));
|
||
res.json({ ok: true });
|
||
} catch(err){
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Löscht eine Woche (fehlende Woche wird toleriert)
|
||
app.post('/delete-week', requireRecht('s.loeschen'), (req, res) => {
|
||
const { label } = req.body || {};
|
||
if(!label) return res.status(400).json({ ok: false, error: 'Missing label' });
|
||
if(label === SANDBOX_WEEK_LABEL) return res.status(400).json({ ok: false, error: 'Die Planung-Sandbox ist keine Sitzung und kann hier nicht gelöscht werden.' });
|
||
try {
|
||
const row = db.prepare('SELECT 1 FROM weeks WHERE label = ?').get(String(label));
|
||
const existed = !!row;
|
||
// In einer Transaktion: deleteWeekData() räumt 18 Tabellen leer. Bricht
|
||
// eines der DELETEs ab, blieben sonst Waisen-Zeilen zurück, die eine
|
||
// später gleichnamig angelegte Sitzung wieder einsammeln würde.
|
||
db.exec('BEGIN');
|
||
try {
|
||
deleteWeekData(String(label));
|
||
db.exec('COMMIT');
|
||
} catch(err){
|
||
try { db.exec('ROLLBACK'); } catch(e){ /* keine offene Transaktion */ }
|
||
throw err;
|
||
}
|
||
console.log('Deleted week:', label, existed ? '' : '(nicht vorhanden)');
|
||
if(existed) ereignisSenden({ typ: 'woche', label: String(label), geloescht: true }, req);
|
||
return res.json({ ok: true, deleted: existed });
|
||
} catch(err){
|
||
console.error('Delete week error', err);
|
||
return res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Entsperrt eine gesperrte Woche (Admin-only — ersetzt die bisher rein
|
||
// clientseitige Entsperr-Logik). Setzt `locked = 0` und schreibt eine neue
|
||
// Revision (`updated_at`), damit der Client seine lokale Sync-Basis
|
||
// aktualisieren kann (gleiche Konvention wie bei /save-week).
|
||
app.post('/unlock-week', requireRecht('s.entsperren'), (req, res) => {
|
||
const { label } = req.body || {};
|
||
if(!label) return res.status(400).json({ ok: false, error: 'Missing label' });
|
||
if(label === SANDBOX_WEEK_LABEL) return res.status(400).json({ ok: false, error: 'Die Planung-Sandbox ist keine Sitzung und wird nie gesperrt.' });
|
||
try {
|
||
const row = db.prepare('SELECT 1 FROM weeks WHERE label = ?').get(String(label));
|
||
if(!row) return res.status(404).json({ ok: false, error: 'Sitzung nicht gefunden.' });
|
||
const updatedAt = new Date().toISOString();
|
||
db.prepare('UPDATE weeks SET locked = 0, updated_at = ? WHERE label = ?').run(updatedAt, String(label));
|
||
console.log('Unlocked week:', label);
|
||
ereignisSenden({ typ: 'woche', label: String(label), updatedAt }, req);
|
||
return res.json({ ok: true, updatedAt });
|
||
} catch(err){
|
||
console.error('Unlock week error', err);
|
||
return res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Lädt alle Wochen (jetzt eine einzige globale Reihe) plus die
|
||
// firmenweite Personenliste. Liefert strukturierte JSON-Objekte (aus den
|
||
// relationalen Tabellen zusammengesetzt); nach Label sortiert.
|
||
// echteSitzungenSortiert: alle echten Sitzungen (ohne Sandbox) als schlanke
|
||
// Liste, älteste zuerst — {id, label, date, locked, updatedAt}.
|
||
function echteSitzungenSortiert(){
|
||
return db.prepare('SELECT week_id, label, date, locked, updated_at FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL)
|
||
.map(r => ({ id: r.week_id, label: r.label, date: r.date || '', locked: !!r.locked, updatedAt: r.updated_at || '' }))
|
||
.sort((a, b) => weekSortKeyServer(a.label).localeCompare(weekSortKeyServer(b.label)));
|
||
}
|
||
|
||
// GET /load-all?wochen=N: Mit wochen nur die jüngsten N Sitzungen vollständig
|
||
// (Eriks Wunsch: aktuelle + drei davor), dazu wochenListe = ALLE Sitzungen
|
||
// schlank (Name, Datum, Sperre) für Admin → Sitzungen und das Nachladen.
|
||
// Ohne wochen wie früher alle (z. B. ältere Browser-Stände).
|
||
app.get('/load-all', requireAuthOderKiosk, (req, res) => {
|
||
try {
|
||
const anzahl = Math.floor(Number(req.query.wochen));
|
||
const wochenListe = echteSitzungenSortiert();
|
||
const allWeeks = anzahl > 0
|
||
? loadWeeks(wochenListe.slice(-anzahl).map(w => w.label).concat([SANDBOX_WEEK_LABEL]))
|
||
: loadWeeks();
|
||
// Die reservierte Sandbox-"Sitzung" (Planung, siehe SANDBOX_WEEK_LABEL)
|
||
// ist keine echte Sitzung — sie wird separat als sandboxPlans
|
||
// ausgeliefert, statt in weeks[] zu landen. So kommt sie clientseitig
|
||
// strukturell nie in db.weeks an und kann dort auch nicht versehentlich
|
||
// in Board/Wochenbericht/Auslastung/Sitzungsverwaltung auftauchen.
|
||
const weeks = allWeeks.filter(w => w.label !== SANDBOX_WEEK_LABEL);
|
||
const sandbox = allWeeks.find(w => w.label === SANDBOX_WEEK_LABEL);
|
||
const sandboxPlans = sandbox ? sandbox.projects : [];
|
||
const sandboxUpdatedAt = sandbox ? sandbox.updatedAt : '';
|
||
const people = loadGlobalPeople(req.user);
|
||
res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt, wochenListe });
|
||
} catch(err){
|
||
console.error('Load error', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// ---------- Dashboard ----------
|
||
|
||
// heuteIsoLokal / isoPlusTageLokal: Datum als YYYY-MM-TT in der ZEITZONE DES
|
||
// SERVERS, nicht in UTC.
|
||
//
|
||
// new Date().toISOString().slice(0,10) rechnet nach UTC um: In deutscher
|
||
// Sommerzeit (UTC+2) liefert es zwischen 00:00 und 02:00 Ortszeit noch den
|
||
// Vortag. Termine von heute galten in diesem Fenster als überfällig, und die
|
||
// Abwesenheitsliste ließ eine heute beginnende Abwesenheit aus. Voraussetzung
|
||
// ist, dass der Server auf die Zeitzone des Büros gestellt ist — im
|
||
// Docker-Container also TZ=Europe/Berlin.
|
||
// Parameter: (isoPlusTageLokal) n = Tage in die Zukunft, auch negativ.
|
||
// Rückgabe: String.
|
||
function heuteIsoLokal(){
|
||
return isoPlusTageLokal(0);
|
||
}
|
||
function isoPlusTageLokal(n){
|
||
const d = new Date();
|
||
d.setDate(d.getDate() + (Number(n) || 0));
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const t = String(d.getDate()).padStart(2, '0');
|
||
return d.getFullYear() + '-' + m + '-' + t;
|
||
}
|
||
|
||
// weekSortKeyServer: serverseitige Portierung von weekSortKey()
|
||
// (public/js/utils.js) — wird NUR im Fallback von /my-dashboard gebraucht,
|
||
// wenn die applyAutoLock()-Invariante (genau eine entsperrte Woche je
|
||
// Abteilung) ausnahmsweise verletzt ist (0 oder >1 entsperrte Wochen).
|
||
function weekSortKeyServer(label){
|
||
const m = String(label || '').match(/KW\s*(\d+)[^\d]+(\d{4})/i);
|
||
if(m){
|
||
const kw = m[1].padStart(2, '0');
|
||
return `${m[2]}-${kw}`;
|
||
}
|
||
return String(label || '').toLowerCase();
|
||
}
|
||
|
||
// Route: persönliches Dashboard des angemeldeten Benutzers — liefert die
|
||
// offenen TODOs des eigenen person_id in der aktuell entsperrten (firmenweit
|
||
// globalen) Sitzung, ausstehende Datei-Freigaben sowie `colleagueAbsences`
|
||
// (aktuelle/anstehende Abwesenheiten aller anderen aktiven Nutzer, siehe
|
||
// loadGlobalPeople()). Jede Aufgabe bekommt zusätzlich die Tags ihres
|
||
// Projekts (`tags: string[]`, ggf. leer) mit — separat nachgeladen aus
|
||
// project_tags, gruppiert nach project_id (siehe Kommentar unten), NICHT per
|
||
// JOIN (würde bei mehreren Tags Aufgaben-Zeilen verdoppeln).
|
||
app.get('/my-dashboard', requireAuth, (req, res) => {
|
||
try {
|
||
// Normalfall: applyAutoLock() garantiert genau eine entsperrte Woche
|
||
// (jetzt firmenweit global, nicht mehr je Abteilung).
|
||
// Die Planung-Sandbox ist immer entsperrt und keine echte Sitzung — ohne
|
||
// diesen Ausschluss gab es stets zwei entsperrte Wochen, und die richtige
|
||
// wurde nur zufällig über die Sortierung im Rückfallzweig gefunden.
|
||
const unlocked = db.prepare('SELECT label FROM weeks WHERE locked = 0 AND label != ?').all(SANDBOX_WEEK_LABEL);
|
||
let weekLabel = null;
|
||
if(unlocked.length === 1){
|
||
weekLabel = unlocked[0].label;
|
||
} else {
|
||
// Fallback (0 oder >1 entsperrte Wochen, sollte normalerweise nicht
|
||
// vorkommen): jüngste Woche nach weekSortKeyServer() wählen.
|
||
const all = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL);
|
||
if(all.length > 0){
|
||
weekLabel = all.map(r => r.label)
|
||
.sort((a, b) => weekSortKeyServer(b).localeCompare(weekSortKeyServer(a)))[0];
|
||
}
|
||
}
|
||
let tasks = [];
|
||
if(weekLabel && req.user.personId){
|
||
tasks = db.prepare(`
|
||
SELECT t.todo_id AS todoId, t.title AS title, t.start AS start, t.end AS end,
|
||
t.progress AS progress, t.project_id AS projectId, p.nr AS projectNr, p.name AS projectName
|
||
FROM plan_todos t
|
||
JOIN plan_todo_assignees a
|
||
ON a.week_label = t.week_label
|
||
AND a.project_id = t.project_id AND a.todo_id = t.todo_id
|
||
JOIN projects p
|
||
ON p.week_label = t.week_label
|
||
AND p.project_id = t.project_id
|
||
WHERE t.week_label = ? AND t.done = 0 AND a.person_id = ?
|
||
`).all(weekLabel, req.user.personId);
|
||
// Sortierung nach Fälligkeit aufsteigend, leere Werte ans Ende.
|
||
tasks.sort((a, b) => {
|
||
if(!a.end && !b.end) return 0;
|
||
if(!a.end) return 1;
|
||
if(!b.end) return -1;
|
||
return a.end < b.end ? -1 : (a.end > b.end ? 1 : 0);
|
||
});
|
||
// Tags der zugehörigen Projekte nachladen: project_tags hat keine
|
||
// todo_id, ein direkter JOIN würde also bei mehreren Tags pro Projekt
|
||
// die TODO-Zeile vervielfachen. Separate, nach (week_label, project_id)
|
||
// gruppierte Abfrage stattdessen (gleiches Muster wie beim Zusammenbau
|
||
// der Projekte in loadWeeks()). project_id ist nur ein internes
|
||
// Zuordnungsfeld für diese Nachladung und wird vor der Antwort wieder
|
||
// entfernt (kein Teil der bisherigen /my-dashboard-Antwortform).
|
||
const projectIds = [...new Set(tasks.map(t => t.projectId))];
|
||
if(projectIds.length > 0){
|
||
const placeholders = projectIds.map(() => '?').join(',');
|
||
const tagRows = db.prepare(`
|
||
SELECT project_id AS projectId, tag FROM project_tags
|
||
WHERE week_label = ? AND project_id IN (${placeholders})
|
||
ORDER BY tag_order
|
||
`).all(weekLabel, ...projectIds);
|
||
const tagsByProject = {};
|
||
tagRows.forEach(r => { (tagsByProject[r.projectId] || (tagsByProject[r.projectId] = [])).push(r.tag); });
|
||
tasks.forEach(t => { t.tags = tagsByProject[t.projectId] || []; });
|
||
}
|
||
tasks.forEach(t => { delete t.projectId; });
|
||
}
|
||
|
||
// ---------- Termine der eigenen Projekte ----------
|
||
//
|
||
// Nicht dasselbe wie `tasks` oben: Dort stehen die Aufgaben, die der
|
||
// Person PERSÖNLICH zugewiesen sind — auch in Projekten, mit denen sie
|
||
// sonst nichts zu tun hat. Hier stehen die Termine der PROJEKTE, denen
|
||
// sie zugeordnet ist (project_assign), unabhängig davon, wer die einzelne
|
||
// Aufgabe erledigt. Eine Projektleitung muss wissen, wann im eigenen
|
||
// Projekt etwas fällig wird, auch wenn ein anderer es abarbeitet.
|
||
//
|
||
// Doppelungen werden unterdrückt: Was schon unter `tasks` steht,
|
||
// erscheint hier nicht noch einmal (siehe eigeneTodoIds).
|
||
//
|
||
// Der Zeitraum endet nach 21 Tagen. Ohne Grenze stünden Termine aus einem
|
||
// Jahr in der Liste und die dringenden gingen darin unter; überfällige
|
||
// bleiben dagegen ohne Altersgrenze stehen, denn eine Frist verfällt
|
||
// nicht dadurch, dass sie lange gerissen ist.
|
||
const HORIZONT_TAGE = 21;
|
||
let projectDates = [];
|
||
if(weekLabel && req.user.personId){
|
||
const grenzeIso = isoPlusTageLokal(HORIZONT_TAGE);
|
||
const heuteIso = heuteIsoLokal();
|
||
|
||
// Projekte, denen die Person in irgendeiner Rolle zugeordnet ist.
|
||
// state 1 = Hauptverantwortung, 2 = Unterstützung; 0 kommt in der
|
||
// Tabelle nicht vor (cycleAssign() löscht den Eintrag stattdessen),
|
||
// wird aber sicherheitshalber ausgeschlossen.
|
||
const meineProjekte = db.prepare(`
|
||
SELECT DISTINCT a.project_id AS projectId
|
||
FROM project_assign a
|
||
WHERE a.week_label = ? AND a.person_id = ? AND a.state IN (1, 2)
|
||
`).all(weekLabel, req.user.personId).map(r => r.projectId);
|
||
|
||
if(meineProjekte.length > 0){
|
||
const platzhalter = meineProjekte.map(() => '?').join(',');
|
||
const eigeneTodoIds = new Set(tasks.map(t => t.todoId));
|
||
|
||
// Aufgaben: offen, mit Fälligkeit, in den eigenen Projekten.
|
||
const todos = db.prepare(`
|
||
SELECT t.todo_id AS id, t.title AS titel, t.end AS datum, t.progress AS progress,
|
||
t.project_id AS projectId, p.nr AS projectNr, p.name AS projectName
|
||
FROM plan_todos t
|
||
JOIN projects p ON p.week_label = t.week_label AND p.project_id = t.project_id
|
||
WHERE t.week_label = ? AND t.done = 0 AND t.end != ''
|
||
AND t.project_id IN (${platzhalter})
|
||
`).all(weekLabel, ...meineProjekte);
|
||
|
||
// Meilensteine: sie tragen kein Erledigt-Kennzeichen, deshalb zählt
|
||
// allein das Datum.
|
||
const milestones = db.prepare(`
|
||
SELECT m.milestone_id AS id, m.name AS titel, m.date AS datum,
|
||
m.project_id AS projectId, p.nr AS projectNr, p.name AS projectName
|
||
FROM plan_milestones m
|
||
JOIN projects p ON p.week_label = m.week_label AND p.project_id = m.project_id
|
||
WHERE m.week_label = ? AND m.date != ''
|
||
AND m.project_id IN (${platzhalter})
|
||
`).all(weekLabel, ...meineProjekte);
|
||
|
||
// Zuständige je Aufgabe nachladen — getrennt, weil ein JOIN über
|
||
// plan_todo_assignees die Aufgabenzeile bei mehreren Zuständigen
|
||
// vervielfachen würde (gleiches Muster wie bei den Tags oben).
|
||
const namen = {};
|
||
db.prepare(`
|
||
SELECT a.todo_id AS todoId, a.project_id AS projectId, a.person_id AS personId
|
||
FROM plan_todo_assignees a
|
||
WHERE a.week_label = ? AND a.project_id IN (${platzhalter})
|
||
`).all(weekLabel, ...meineProjekte).forEach(r => {
|
||
const key = r.projectId + '|' + r.todoId;
|
||
(namen[key] || (namen[key] = [])).push(r.personId);
|
||
});
|
||
// Namen der Zuständigen: aus week_people der Sitzung, NICHT aus
|
||
// loadGlobalPeople(). Die globale Liste enthält nur Personen mit
|
||
// Benutzerkonto; die Teilnehmerliste einer Sitzung führt dagegen
|
||
// jede Person, die dort mitarbeitet. Ohne diesen Weg stand in der
|
||
// Terminliste "p_ap" statt "Alexander Plum".
|
||
const personenNamen = {};
|
||
db.prepare(`
|
||
SELECT person_id AS id, name, full_name AS fullName
|
||
FROM week_people WHERE week_label = ?
|
||
`).all(weekLabel).forEach(w => {
|
||
personenNamen[w.id] = w.fullName || w.name || w.id;
|
||
});
|
||
|
||
projectDates = todos.map(t => ({
|
||
art: 'aufgabe',
|
||
id: t.id,
|
||
titel: t.titel,
|
||
datum: t.datum,
|
||
progress: t.progress,
|
||
projectNr: t.projectNr,
|
||
projectName: t.projectName,
|
||
zustaendig: (namen[t.projectId + '|' + t.id] || [])
|
||
.map(pid => personenNamen[pid] || pid),
|
||
selbst: eigeneTodoIds.has(t.id),
|
||
})).concat(milestones.map(m => ({
|
||
art: 'meilenstein',
|
||
id: m.id,
|
||
titel: m.titel,
|
||
datum: m.datum,
|
||
projectNr: m.projectNr,
|
||
projectName: m.projectName,
|
||
zustaendig: [],
|
||
selbst: false,
|
||
})));
|
||
|
||
projectDates = projectDates
|
||
// Was bereits unter "Meine Aufgaben" steht, hier nicht wiederholen.
|
||
.filter(d => !d.selbst)
|
||
.filter(d => d.datum <= grenzeIso)
|
||
.sort((a, b) => (a.datum < b.datum ? -1 : (a.datum > b.datum ? 1 : 0)));
|
||
|
||
// Der Server liefert auch gleich die Einordnung mit, damit Browser
|
||
// und PDF/Export nicht jeweils eigene Stichtage rechnen.
|
||
projectDates.forEach(d => {
|
||
d.ueberfaellig = d.datum < heuteIso;
|
||
d.heute = d.datum === heuteIso;
|
||
});
|
||
}
|
||
}
|
||
|
||
const pendingReviews = db.prepare(`
|
||
SELECT f.id AS fileId, f.project_nr AS projectNr,
|
||
f.project_name AS projectName, f.original_filename AS originalFilename,
|
||
COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS uploadedBy,
|
||
f.created_at AS createdAt
|
||
FROM project_file_reviewers r
|
||
JOIN project_files f ON f.id = r.file_id
|
||
LEFT JOIN users u ON u.id = f.uploaded_by_user_id
|
||
WHERE r.user_id = ? AND r.decision = 'pending' AND f.status = 'pending'
|
||
ORDER BY f.created_at DESC
|
||
`).all(req.user.id);
|
||
|
||
// Abwesenheiten der Kollegen: firmenweite Personenliste (loadGlobalPeople(),
|
||
// keine Abteilungstrennung mehr), gefiltert auf Abwesenheiten, die heute
|
||
// laufen oder innerhalb der nächsten 14 Tage beginnen — der Aufrufer selbst
|
||
// wird per person_id ausgeschlossen. Sortiert nach Startdatum; `current`
|
||
// markiert eine heute laufende Abwesenheit fürs Hervorheben im Dashboard.
|
||
const todayIso = heuteIsoLokal();
|
||
const horizonIso = isoPlusTageLokal(14);
|
||
const colleagueAbsences = [];
|
||
loadGlobalPeople(req.user).forEach(person => {
|
||
if(person.id && person.id === req.user.personId) return;
|
||
(person.absences || []).forEach(a => {
|
||
if(!a.start || !a.end) return;
|
||
if(a.end < todayIso || a.start > horizonIso) return;
|
||
colleagueAbsences.push({
|
||
personId: person.id,
|
||
name: person.fullName || person.name,
|
||
type: a.type,
|
||
note: a.note,
|
||
start: a.start,
|
||
end: a.end,
|
||
current: a.start <= todayIso && todayIso <= a.end,
|
||
});
|
||
});
|
||
});
|
||
colleagueAbsences.sort((a, b) => (a.start < b.start ? -1 : (a.start > b.start ? 1 : 0)));
|
||
|
||
res.json({ ok: true, weekLabel, tasks, projectDates, pendingReviews, colleagueAbsences });
|
||
} catch(err){
|
||
console.error('Dashboard-Fehler', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// ---------- Datei-Upload, Freigabe, Kommentare ----------
|
||
//
|
||
// Dateien werden über project_nr allein mit Projekten verknüpft, NICHT über
|
||
// (week_label, project_id) — project_id wird bei jedem Wochen-Klon neu
|
||
// generiert (cloneWeek(), public/js/weeks.js), project_nr bleibt über den
|
||
// gesamten Projektlebenszyklus stabil und identifiziert seit der Zusammen-
|
||
// führung gleichnummeriger Projekte über ehemalige Abteilungsgrenzen hinweg
|
||
// eindeutig das eine logische Projekt. Ein Projekt ohne project_nr kann daher
|
||
// keine Dateien erhalten.
|
||
//
|
||
// Sicherheitskritisch: original_filename/project_nr/project_name/filename
|
||
// (Client-Eingaben) werden NIEMALS in einen Dateisystempfad eingesetzt. Der
|
||
// Pfad setzt sich ausschließlich aus einer serverseitig erzeugten UUID
|
||
// (stored_name) zusammen; original_filename dient nur als Anzeige-/
|
||
// Download-Header-Wert.
|
||
|
||
// UPLOADS_DIR: Basisverzeichnis für hochgeladene Projektdateien
|
||
// ein Unterordner (lazy angelegt, siehe uploadDirFor()).
|
||
const UPLOADS_DIR = path.join(SESSIONS_DIR, 'uploads');
|
||
|
||
// uploadDirFor: liefert (und legt bei Bedarf an) das Upload-Verzeichnis einer
|
||
// Abteilung. Parameter department MUSS bereits gegen VALID_DEPARTMENTS
|
||
// geprüft sein (nur dann sind ausschließlich 'ELT'/'HLS'/'ARCH' möglich).
|
||
function uploadDirFor(){
|
||
if(!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||
return UPLOADS_DIR;
|
||
}
|
||
|
||
// extractFileExtension: extrahiert per Whitelist-Regex eine kurze
|
||
// alphanumerische Dateiendung aus einem (nicht vertrauenswürdigen)
|
||
// Dateinamen. Kein Treffer (z. B. kein Punkt, zu lange/exotische Endung) →
|
||
// leerer String, kein Fehler.
|
||
function extractFileExtension(filename){
|
||
const m = String(filename || '').match(/\.([a-zA-Z0-9]{1,8})$/);
|
||
return m ? m[1].toLowerCase() : '';
|
||
}
|
||
|
||
// serializeFileRow: wandelt eine project_files-Zeile (ggf. mit JOIN-Feldern)
|
||
// in die öffentliche Repräsentation für GET /project-files um.
|
||
function serializeFileRow(row){
|
||
return {
|
||
id: row.id,
|
||
projectNr: row.project_nr,
|
||
projectName: row.project_name,
|
||
originalFilename: row.original_filename,
|
||
contentType: row.content_type,
|
||
sizeBytes: row.size_bytes,
|
||
status: row.status,
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
uploadedBy: row.uploadedBy,
|
||
// Uploader-ID mitgeben: Der Client kann den "Löschen"-Knopf sonst nicht
|
||
// an dieselbe Regel binden, die der Server durchsetzt (Uploader, solange
|
||
// die Datei offen ist — oder Admin).
|
||
uploadedByUserId: row.uploaded_by_user_id,
|
||
};
|
||
}
|
||
|
||
// Route: Liste aller aktiven Benutzer (ohne Passwortfelder, ohne inaktive
|
||
// Konten) — für die Prüfer-Auswahl beim Datei-Upload. Bewusst für JEDEN
|
||
// eingeloggten Benutzer zugänglich (requireAuth, kein Admin-Recht),
|
||
// da jeder Uploader Prüfer auswählen muss; getrennt von GET /admin/users
|
||
// (admin-only, zeigt zusätzlich inaktive Konten + mehr Felder).
|
||
app.get('/users', requireAuth, (req, res) => {
|
||
try {
|
||
// role != 'kiosk': das Wandtafel-Konto ist kein Teammitglied — es soll
|
||
// weder im Datei-Freigabe-Prüfer-Auswahlfeld noch in der
|
||
// abteilungsübergreifenden Besetzung zur Wahl stehen.
|
||
const rows = db.prepare(`SELECT id, username, display_name, email, person_id, department, initials FROM users WHERE active = 1 AND ${sqlOhneWandtafel()} ORDER BY display_name, username`).all();
|
||
const rolesByUser = loadUserRolesByUserId();
|
||
res.json({ ok: true, users: rows.map(r => ({
|
||
id: r.id, username: r.username, displayName: r.display_name, email: r.email,
|
||
personId: r.person_id, department: r.department, initials: r.initials,
|
||
roles: rolesByUser[r.id] || [],
|
||
})) });
|
||
} catch(err){
|
||
console.error('Benutzerliste fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// ---------- Abwesenheiten ----------
|
||
// Rechte (Rollen & Rechte, Gruppe „Team“): team.abw (für mich),
|
||
// team.abwProjekte, team.abwGewerk, team.abwAlle — geprüft über
|
||
// darfAbwesenheit() für Anlegen, Ändern und Löschen gleichermaßen.
|
||
const ABWESENHEIT_ARTEN = new Set(['Urlaub', 'Krank', 'Fortbildung', 'Sonstiges']);
|
||
// abwesenheitPruefen: Eingaben prüfen. Rückgabe: Fehlertext oder ''.
|
||
function abwesenheitPruefen(start, end, type){
|
||
const iso = /^\d{4}-\d{2}-\d{2}$/;
|
||
if(!iso.test(String(start || '')) || !iso.test(String(end || ''))) return 'Start- und Enddatum erforderlich.';
|
||
if(String(end) < String(start)) return 'Das Ende liegt vor dem Beginn.';
|
||
if(!ABWESENHEIT_ARTEN.has(String(type || ''))) return 'Unbekannte Art der Abwesenheit.';
|
||
return '';
|
||
}
|
||
const ABW_KEIN_RECHT = 'Für diese Person darfst du keine Abwesenheiten eintragen.';
|
||
|
||
// GET /personen: die Personenliste wie in /load-all (mit den Abwesenheiten
|
||
// aus Sicht des Aufrufers) — zum Nachladen nach einem Live-Ereignis.
|
||
app.get('/personen', requireAuthOderKiosk, (req, res) => {
|
||
res.json({ ok: true, people: loadGlobalPeople(req.user) });
|
||
});
|
||
|
||
app.post('/users/:id/absences', requireAuth, (req, res) => {
|
||
const id = Number(req.params.id);
|
||
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
|
||
if(!user) return res.status(404).json({ ok: false, error: 'Benutzer nicht gefunden.' });
|
||
if(!darfAbwesenheit(req.user, id)) return res.status(403).json({ ok: false, error: ABW_KEIN_RECHT });
|
||
const { start, end, type, note } = req.body || {};
|
||
const fehler = abwesenheitPruefen(start, end, type);
|
||
if(fehler) return res.status(400).json({ ok: false, error: fehler });
|
||
try {
|
||
const absenceId = crypto.randomUUID();
|
||
db.prepare('INSERT INTO user_absences (user_id, id, start, end, type, note, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||
.run(id, absenceId, String(start), String(end), String(type), String(note || ''), req.user.id);
|
||
ereignisSenden({ typ: 'personen' }, req);
|
||
res.json({ ok: true, absence: { id: absenceId, start: String(start), end: String(end), type: String(type), note: String(note || '') } });
|
||
} catch(err){
|
||
console.error('Abwesenheit anlegen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
app.patch('/users/:id/absences/:absenceId', requireAuth, (req, res) => {
|
||
const id = Number(req.params.id);
|
||
if(!darfAbwesenheit(req.user, id)) return res.status(403).json({ ok: false, error: ABW_KEIN_RECHT });
|
||
const row = db.prepare('SELECT * FROM user_absences WHERE user_id = ? AND id = ?').get(id, req.params.absenceId);
|
||
if(!row) return res.status(404).json({ ok: false, error: 'Abwesenheit nicht gefunden.' });
|
||
const b = req.body || {};
|
||
const start = b.start !== undefined ? b.start : row.start;
|
||
const end = b.end !== undefined ? b.end : row.end;
|
||
const type = b.type !== undefined ? b.type : row.type;
|
||
const note = b.note !== undefined ? String(b.note) : row.note;
|
||
const fehler = abwesenheitPruefen(start, end, type);
|
||
if(fehler) return res.status(400).json({ ok: false, error: fehler });
|
||
db.prepare('UPDATE user_absences SET start = ?, end = ?, type = ?, note = ? WHERE user_id = ? AND id = ?')
|
||
.run(String(start), String(end), String(type), note, id, req.params.absenceId);
|
||
ereignisSenden({ typ: 'personen' }, req);
|
||
res.json({ ok: true, absence: { id: row.id, start: String(start), end: String(end), type: String(type), note } });
|
||
});
|
||
|
||
// Löschen: idempotent (auch ok, wenn nichts gelöscht wurde).
|
||
app.delete('/users/:id/absences/:absenceId', requireAuth, (req, res) => {
|
||
try {
|
||
const targetUserId = Number(req.params.id);
|
||
if(!darfAbwesenheit(req.user, targetUserId)) return res.status(403).json({ ok: false, error: ABW_KEIN_RECHT });
|
||
const r = db.prepare('DELETE FROM user_absences WHERE user_id = ? AND id = ?').run(targetUserId, req.params.absenceId);
|
||
if(r.changes) ereignisSenden({ typ: 'personen' }, req);
|
||
res.json({ ok: true });
|
||
} catch(err){
|
||
console.error('Abwesenheit löschen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// empfangeInDatei: schreibt den rohen Anfrage-Body als Datenstrom in ziel,
|
||
// ohne ihn im Speicher zu sammeln. Über maxBytes: Abbruch, Datei weg, 413.
|
||
// Rückgabe: Promise<Anzahl Bytes>.
|
||
function empfangeInDatei(req, ziel, maxBytes){
|
||
return new Promise((resolve, reject) => {
|
||
const aus = fs.createWriteStream(ziel);
|
||
let n = 0, abgebrochen = false;
|
||
const fehler = (err) => {
|
||
if(abgebrochen) return; abgebrochen = true;
|
||
req.unpipe(aus); aus.destroy(); fs.unlink(ziel, () => {});
|
||
reject(err);
|
||
};
|
||
req.on('data', chunk => {
|
||
n += chunk.length;
|
||
if(n > maxBytes){ const e = new Error('Datei zu groß (höchstens ' + Math.round(maxBytes / 1048576) + ' MB).'); e.status = 413; fehler(e); req.resume(); }
|
||
});
|
||
req.on('aborted', () => fehler(new Error('Upload abgebrochen.')));
|
||
aus.on('error', fehler);
|
||
aus.on('finish', () => { if(!abgebrochen) resolve(n); });
|
||
req.pipe(aus);
|
||
});
|
||
}
|
||
|
||
// Route: Datei-Upload für ein Projekt. Roher Binär-Body (wie /import), Metadaten
|
||
// als Query-Parameter, da der Body ausschließlich die Datei selbst enthält.
|
||
app.post('/project-files', requireAuth, requireRecht('proj.dateien'), async (req, res) => {
|
||
try {
|
||
const projectNr = String(req.query.projectNr || '');
|
||
if(!projectNr) return res.status(400).json({ ok: false, error: 'Projekt hat keine Projektnummer.' });
|
||
const projectName = String(req.query.projectName || '');
|
||
|
||
let filename = '';
|
||
try { filename = decodeURIComponent(String(req.query.filename || '')); }
|
||
catch(e){ filename = String(req.query.filename || ''); }
|
||
const ext = extractFileExtension(filename);
|
||
|
||
// Reviewer-Parameter: Komma-getrennte IDs -> Zahlen -> nur existierende,
|
||
// aktive Benutzer -> Uploader (kein Self-Review) entfernen.
|
||
const rawReviewerIds = String(req.query.reviewers || '')
|
||
.split(',')
|
||
.map(s => Number(s.trim()))
|
||
.filter(n => Number.isInteger(n) && n > 0);
|
||
// Kiosk-Konten können nie entscheiden (requireAuth weist sie ab) — als
|
||
// Prüfer würden sie die Datei für immer offen halten.
|
||
const activeUsers = db.prepare(`SELECT id FROM users WHERE active = 1 AND ${sqlOhneWandtafel()}`).all().map(r => r.id);
|
||
const activeSet = new Set(activeUsers);
|
||
const reviewerIds = Array.from(new Set(rawReviewerIds))
|
||
.filter(id => activeSet.has(id) && id !== req.user.id);
|
||
if(reviewerIds.length === 0){
|
||
return res.status(400).json({ ok: false, error: 'Mindestens ein Prüfer erforderlich.' });
|
||
}
|
||
|
||
const id = crypto.randomUUID();
|
||
const storedName = id + (ext ? '.' + ext : '');
|
||
const dir = uploadDirFor();
|
||
// Direkt auf die Platte statt erst ganz in den Arbeitsspeicher (Pi Zero:
|
||
// eine 200-MB-Datei im RAM brachte den Server zum Absturz).
|
||
let groesse;
|
||
try { groesse = await empfangeInDatei(req, path.join(dir, storedName), 200 * 1024 * 1024); }
|
||
catch(err){ return res.status(err.status || 400).json({ ok: false, error: err.message }); }
|
||
if(!groesse){
|
||
fs.unlink(path.join(dir, storedName), () => {});
|
||
return res.status(400).json({ ok: false, error: 'Keine Datei empfangen' });
|
||
}
|
||
|
||
const contentType = req.headers['content-type'] || 'application/octet-stream';
|
||
const now = new Date().toISOString();
|
||
|
||
db.exec('BEGIN');
|
||
try {
|
||
db.prepare(`INSERT INTO project_files
|
||
(id, project_nr, project_name, original_filename, stored_name, content_type, size_bytes, uploaded_by_user_id, status, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`)
|
||
.run(id, projectNr, projectName, filename || storedName, storedName, contentType, groesse, req.user.id, now, now);
|
||
const insReviewer = db.prepare(`INSERT INTO project_file_reviewers (file_id, user_id, decision, decided_at, note) VALUES (?, ?, 'pending', '', '')`);
|
||
reviewerIds.forEach(uid => insReviewer.run(id, uid));
|
||
db.exec('COMMIT');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
// Bereits geschriebene Datei bei Fehler wieder entfernen (best effort).
|
||
fs.unlink(path.join(dir, storedName), () => {});
|
||
throw err;
|
||
}
|
||
|
||
console.log('Datei hochgeladen:', projectNr, '-', filename || storedName);
|
||
res.json({
|
||
ok: true,
|
||
file: {
|
||
id, projectNr, projectName,
|
||
originalFilename: filename || storedName,
|
||
contentType, sizeBytes: groesse, status: 'pending', createdAt: now,
|
||
uploadedBy: req.user.displayName,
|
||
}
|
||
});
|
||
} catch(err){
|
||
console.error('Datei-Upload fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Liste aller Dateien eines Projekts (project_nr), inkl. Uploader-Name,
|
||
// Prüferstatus je Prüfer und Kommentaranzahl.
|
||
app.get('/project-files', requireAuth, (req, res) => {
|
||
try {
|
||
const projectNr = String(req.query.projectNr || '');
|
||
const rows = db.prepare(`
|
||
SELECT f.*, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS uploadedBy
|
||
FROM project_files f
|
||
LEFT JOIN users u ON u.id = f.uploaded_by_user_id
|
||
WHERE f.project_nr = ?
|
||
ORDER BY f.created_at DESC
|
||
`).all(projectNr);
|
||
const reviewerStmt = db.prepare(`
|
||
SELECT r.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS displayName, r.decision AS decision
|
||
FROM project_file_reviewers r
|
||
LEFT JOIN users u ON u.id = r.user_id
|
||
WHERE r.file_id = ?
|
||
`);
|
||
const commentCountStmt = db.prepare('SELECT COUNT(*) AS c FROM project_file_comments WHERE file_id = ?');
|
||
const files = rows.map(row => {
|
||
const out = serializeFileRow(row);
|
||
out.reviewers = reviewerStmt.all(row.id);
|
||
out.commentCount = commentCountStmt.get(row.id).c;
|
||
return out;
|
||
});
|
||
res.json({ ok: true, files });
|
||
} catch(err){
|
||
console.error('Dateiliste fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Datei-Download. Der Dateisystempfad besteht ausschließlich aus der
|
||
// (server-seitig erzeugten) stored_name-UUID — original_filename fließt NUR
|
||
// in den Content-Disposition-Header ein (zweites res.download()-Argument),
|
||
// niemals in den Pfad.
|
||
app.get('/project-files/:id/download', requireAuth, (req, res) => {
|
||
try {
|
||
const row = db.prepare('SELECT * FROM project_files WHERE id = ?').get(req.params.id);
|
||
if(!row) return res.status(404).json({ ok: false, error: 'Datei nicht gefunden.' });
|
||
const filePath = path.join(UPLOADS_DIR, row.stored_name);
|
||
res.download(filePath, row.original_filename, (err) => {
|
||
if(err && !res.headersSent){
|
||
console.error('Datei-Download fehlgeschlagen:', err);
|
||
res.status(404).json({ ok: false, error: 'Datei nicht gefunden.' });
|
||
}
|
||
});
|
||
} catch(err){
|
||
console.error('Datei-Download fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Freigabe-Entscheidung eines Prüfers. Nur möglich, solange der
|
||
// Gesamtstatus der Datei noch nicht terminal (approved/rejected) ist, und nur
|
||
// für Benutzer, die tatsächlich als Prüfer für diese Datei eingetragen sind.
|
||
// Neuberechnung des Gesamtstatus: eine Ablehnung blockiert sofort; erst wenn
|
||
// ALLE Prüfer zugestimmt haben, gilt die Datei als freigegeben.
|
||
app.post('/project-files/:id/review', requireAuth, requireRecht('proj.dateien'), (req, res) => {
|
||
try {
|
||
const fileRow = db.prepare('SELECT * FROM project_files WHERE id = ?').get(req.params.id);
|
||
if(!fileRow) return res.status(404).json({ ok: false, error: 'Datei nicht gefunden.' });
|
||
|
||
const { decision, note } = req.body || {};
|
||
if(decision !== 'approved' && decision !== 'rejected'){
|
||
return res.status(400).json({ ok: false, error: 'Ungültige Entscheidung.' });
|
||
}
|
||
|
||
const reviewerRow = db.prepare('SELECT * FROM project_file_reviewers WHERE file_id = ? AND user_id = ?').get(fileRow.id, req.user.id);
|
||
if(!reviewerRow) return res.status(403).json({ ok: false, error: 'Sie sind für diese Datei nicht als Prüfer eingetragen.' });
|
||
|
||
if(fileRow.status === 'approved' || fileRow.status === 'rejected'){
|
||
return res.status(409).json({ ok: false, error: 'Über diese Datei wurde bereits abschließend entschieden.' });
|
||
}
|
||
|
||
const now = new Date().toISOString();
|
||
db.prepare('UPDATE project_file_reviewers SET decision = ?, decided_at = ?, note = ? WHERE file_id = ? AND user_id = ?')
|
||
.run(decision, now, String(note || ''), fileRow.id, req.user.id);
|
||
|
||
const allDecisions = db.prepare('SELECT decision FROM project_file_reviewers WHERE file_id = ?').all(fileRow.id).map(r => r.decision);
|
||
let newStatus = 'pending';
|
||
if(allDecisions.some(d => d === 'rejected')) newStatus = 'rejected';
|
||
else if(allDecisions.length > 0 && allDecisions.every(d => d === 'approved')) newStatus = 'approved';
|
||
|
||
db.prepare('UPDATE project_files SET status = ?, updated_at = ? WHERE id = ?').run(newStatus, now, fileRow.id);
|
||
console.log('Datei-Freigabe:', fileRow.id, '-', req.user.username, '->', decision, '(Gesamtstatus:', newStatus + ')');
|
||
res.json({ ok: true, status: newStatus });
|
||
} catch(err){
|
||
console.error('Freigabe-Entscheidung fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Kommentare einer Datei lesen (chronologisch aufsteigend).
|
||
app.get('/project-files/:id/comments', requireAuth, (req, res) => {
|
||
try {
|
||
const rows = db.prepare(`
|
||
SELECT c.id AS id, c.user_id AS userId, COALESCE(NULLIF(u.display_name, ''), u.username, 'Gelöschter Benutzer') AS displayName,
|
||
c.body AS body, c.created_at AS createdAt
|
||
FROM project_file_comments c
|
||
LEFT JOIN users u ON u.id = c.user_id
|
||
WHERE c.file_id = ?
|
||
ORDER BY c.created_at ASC
|
||
`).all(req.params.id);
|
||
res.json({ ok: true, comments: rows });
|
||
} catch(err){
|
||
console.error('Kommentare laden fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Kommentar zu einer Datei hinzufügen. Jeder eingeloggte Benutzer darf
|
||
// kommentieren, nicht nur die eingetragenen Prüfer.
|
||
app.post('/project-files/:id/comments', requireAuth, requireRecht('proj.dateien'), (req, res) => {
|
||
try {
|
||
const fileRow = db.prepare('SELECT id FROM project_files WHERE id = ?').get(req.params.id);
|
||
if(!fileRow) return res.status(404).json({ ok: false, error: 'Datei nicht gefunden.' });
|
||
const body = String((req.body || {}).body || '').trim();
|
||
if(!body) return res.status(400).json({ ok: false, error: 'Kommentartext erforderlich.' });
|
||
const id = crypto.randomUUID();
|
||
const now = new Date().toISOString();
|
||
db.prepare('INSERT INTO project_file_comments (id, file_id, user_id, body, created_at) VALUES (?, ?, ?, ?, ?)')
|
||
.run(id, fileRow.id, req.user.id, body, now);
|
||
res.json({ ok: true, comment: { id, userId: req.user.id, displayName: req.user.displayName, body, createdAt: now } });
|
||
} catch(err){
|
||
console.error('Kommentar anlegen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Datei löschen. Erlaubt für den Uploader, solange die Datei noch
|
||
// 'pending' ist, oder jederzeit für Admins. Löscht zuerst die DB-Zeile
|
||
// (inkl. abhängiger Prüfer-/Kommentar-Zeilen — explizit statt sich auf eine
|
||
// FK-CASCADE zu verlassen, da project_file_reviewers/project_file_comments
|
||
// keine deklarierten FOREIGN KEY-Constraints auf project_files haben), dann
|
||
// best-effort die physische Datei.
|
||
app.delete('/project-files/:id', requireAuth, (req, res) => {
|
||
try {
|
||
const row = db.prepare('SELECT * FROM project_files WHERE id = ?').get(req.params.id);
|
||
if(!row) return res.status(404).json({ ok: false, error: 'Datei nicht gefunden.' });
|
||
|
||
const isOwnerAndPending = (req.user.id === row.uploaded_by_user_id) && row.status === 'pending';
|
||
const isAdmin = hatRecht(req.user, 'proj.dateienFremd');
|
||
if(!isOwnerAndPending && !isAdmin){
|
||
return res.status(403).json({ ok: false, error: 'Keine Berechtigung, diese Datei zu löschen.' });
|
||
}
|
||
|
||
db.exec('BEGIN');
|
||
try {
|
||
db.prepare('DELETE FROM project_file_comments WHERE file_id = ?').run(row.id);
|
||
db.prepare('DELETE FROM project_file_reviewers WHERE file_id = ?').run(row.id);
|
||
db.prepare('DELETE FROM project_files WHERE id = ?').run(row.id);
|
||
db.exec('COMMIT');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
fs.unlink(path.join(UPLOADS_DIR, row.stored_name), (err) => {
|
||
if(err) console.error('Physische Datei konnte nicht gelöscht werden:', err);
|
||
});
|
||
console.log('Datei gelöscht:', row.id, '-', row.original_filename);
|
||
res.json({ ok: true });
|
||
} catch(err){
|
||
console.error('Datei löschen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// ---------- Protokolle (Besprechungsprotokolle je Projekt) ----------
|
||
//
|
||
// Verknüpft über project_nr (siehe Tabellendefinition oben), nicht über
|
||
// week_label/project_id. Speichermuster: eigene Routen, aber "ganzes Objekt
|
||
// ersetzen" wie saveWeekRelational()/deleteWeekData() (Zeilen löschen, neu
|
||
// einfügen, in einer Transaktion) statt vieler kleiner Einzel-Feld-Routen —
|
||
// Punkte und Teilnehmer werden ohnehin gemeinsam in einer Sitzung bearbeitet.
|
||
|
||
// loadProtokollDetail: baut ein volles Protokoll (Kopfdaten + Teilnehmer +
|
||
// Punkte) für die Antwort von GET/POST/PUT zusammen. Parameter: id.
|
||
// Rückgabe: Objekt oder null, wenn nicht gefunden.
|
||
function loadProtokollDetail(id){
|
||
const row = db.prepare(`SELECT p.*, u.display_name AS verfasser_name
|
||
FROM project_protokolle p LEFT JOIN users u ON u.id = p.verfasser_user_id
|
||
WHERE p.id = ?`).get(id);
|
||
if(!row) return null;
|
||
const teilnehmer = db.prepare('SELECT * FROM protokoll_teilnehmer WHERE protokoll_id = ? ORDER BY array_order').all(row.id)
|
||
.map(t => ({ id: t.id, name: t.name, firma: t.firma, anwesend: !!t.anwesend }));
|
||
|
||
// vorherigeWerte: Thema/Text des UNMITTELBAR vorangegangenen Protokolls
|
||
// (chronologisch davor, per nummer), je stamm_id — Grundlage für
|
||
// themaGeaendert/textGeaendert unten (Eriks Wunsch): ein fortgeschriebener
|
||
// Punkt (neu=0), dessen Thema oder Text sich gegenüber der letzten Fassung
|
||
// geändert hat, soll das erkennbar machen, aber nur AN DEM GEÄNDERTEN FELD
|
||
// (gelb), nicht an der ganzen Zeile wie bei einem echten neuen Punkt
|
||
// (.is-neu) — ein neuer Punkt hat ohnehin keine Vorfassung zum Vergleichen.
|
||
const prevRow = db.prepare(`SELECT id FROM project_protokolle
|
||
WHERE project_nr = ? AND nummer < ? ORDER BY nummer DESC LIMIT 1`).get(row.project_nr, row.nummer);
|
||
const vorherigeWerte = new Map();
|
||
if(prevRow){
|
||
db.prepare('SELECT stamm_id, thema, text FROM protokoll_punkte WHERE protokoll_id = ?').all(prevRow.id)
|
||
.forEach(p => vorherigeWerte.set(p.stamm_id, { thema: p.thema, text: p.text }));
|
||
}
|
||
|
||
const punkte = db.prepare('SELECT * FROM protokoll_punkte WHERE protokoll_id = ? ORDER BY array_order').all(row.id)
|
||
.map(p => {
|
||
const vorher = !p.neu ? vorherigeWerte.get(p.stamm_id) : null;
|
||
return { id: p.id, nummer: p.nummer, gruppe: p.gruppe, neu: !!p.neu, thema: p.thema, text: p.text,
|
||
zustaendig: p.zustaendig, termin: p.termin, status: p.status || 'offen', erstelltAm: p.erstellt_am || '',
|
||
entfaelltNaechstes: !!p.entfaellt_naechstes,
|
||
themaGeaendert: !!(vorher && vorher.thema !== p.thema),
|
||
textGeaendert: !!(vorher && vorher.text !== p.text) };
|
||
});
|
||
return {
|
||
id: row.id, projectNr: row.project_nr, projectName: row.project_name, nummer: row.nummer,
|
||
datum: row.datum, betreff: row.betreff, ort: row.ort, verfasserName: row.verfasser_name || '',
|
||
weekLabel: row.week_label, quelleDateiname: row.quelle_dateiname || '',
|
||
createdAt: row.created_at, updatedAt: row.updated_at,
|
||
teilnehmer, punkte
|
||
};
|
||
}
|
||
|
||
// Route: Liste aller Protokolle eines Projekts (Zusammenfassung je Protokoll
|
||
// für die Listenansicht — offene/erledigte Punkte, Teilnehmerzahl —, nicht
|
||
// die vollen Punkte/Teilnehmer, das liefert erst GET /:id).
|
||
app.get('/project-protokolle', requireAuth, (req, res) => {
|
||
try {
|
||
const projectNr = String(req.query.projectNr || '');
|
||
if(!projectNr) return res.status(400).json({ ok: false, error: 'Projekt hat keine Projektnummer.' });
|
||
const rows = db.prepare(`SELECT p.*, u.display_name AS verfasser_name
|
||
FROM project_protokolle p LEFT JOIN users u ON u.id = p.verfasser_user_id
|
||
WHERE p.project_nr = ? ORDER BY p.nummer DESC`).all(projectNr);
|
||
const protokolle = rows.map(row => {
|
||
const punkte = db.prepare('SELECT status FROM protokoll_punkte WHERE protokoll_id = ?').all(row.id);
|
||
const teilnehmerCount = db.prepare('SELECT COUNT(*) AS c FROM protokoll_teilnehmer WHERE protokoll_id = ?').get(row.id).c;
|
||
const offenCount = punkte.filter(p => p.status === 'offen').length;
|
||
return {
|
||
id: row.id, nummer: row.nummer, datum: row.datum, betreff: row.betreff, ort: row.ort,
|
||
verfasserName: row.verfasser_name || '', createdAt: row.created_at,
|
||
// Info-Punkte sind weder offen noch erledigt — eigene Zahl statt
|
||
// sie bei "erledigt" mitzuzählen.
|
||
offenCount, erledigtCount: punkte.filter(p => p.status === 'erledigt').length,
|
||
infoCount: punkte.filter(p => p.status === 'info').length, teilnehmerCount
|
||
};
|
||
});
|
||
res.json({ ok: true, protokolle });
|
||
} catch(err){
|
||
console.error('Protokolle laden fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
app.get('/project-protokolle/:id', requireAuth, (req, res) => {
|
||
try {
|
||
const protokoll = loadProtokollDetail(req.params.id);
|
||
if(!protokoll) return res.status(404).json({ ok: false, error: 'Protokoll nicht gefunden.' });
|
||
res.json({ ok: true, protokoll });
|
||
} catch(err){
|
||
console.error('Protokoll laden fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: neues Protokoll anlegen. nummer wird serverseitig vergeben
|
||
// (MAX(nummer)+1 je project_nr, sonst 1); Teilnehmer kommen vorbelegt vom
|
||
// Client (siehe js/plan/plan-protokoll.js); offene Punkte des zuletzt
|
||
// angelegten Protokolls desselben Projekts werden KOPIERT (neue id, gleiche
|
||
// stamm_id/nummer, neu=0) — das "Fortschreiben". Im allerersten Protokoll
|
||
// eines Projekts gibt es nichts zu kopieren, dort sind zwangsläufig alle
|
||
// später hinzugefügten Punkte neu.
|
||
// legeProtokollAn: die gemeinsame Anlege-Logik für ein neues Protokoll —
|
||
// genutzt sowohl von POST /project-protokolle (manuelles Anlegen) als auch
|
||
// von POST /project-protokolle/import (Datei-Import, siehe unten). Beide
|
||
// Wege sollen ein Protokoll auf exakt dieselbe Art in die Sitzungsfolge
|
||
// einreihen: nummer fortlaufend je project_nr, offene Punkte des zuletzt
|
||
// angelegten Protokolls desselben Projekts kopiert (das "Fortschreiben",
|
||
// siehe CLAUDE.md "### Protokolle"). Der Import hängt danach zusätzlich
|
||
// erkannte Punkte an (`zusatzPunkte`) — beim manuellen Anlegen ist dieses
|
||
// Array einfach leer.
|
||
// Parameter: { projectNr, projectName, weekLabel, datum, betreff, ort,
|
||
// verfasserUserId, teilnehmer, zusatzPunkte, quelleDateiname,
|
||
// quelleStoredName } — die letzten beiden bleiben leer für ein normal
|
||
// angelegtes Protokoll. Läuft in einer eigenen Transaktion. Rückgabe: die
|
||
// neue Protokoll-`id`. Wirft bei einem Fehler (Aufrufer fangen und in eine
|
||
// HTTP-Antwort übersetzen).
|
||
// VALID_PROTOKOLL_PUNKT_STATUS: der Status eines Punkts — 'offen' (läuft
|
||
// weiter, wird fortgeschrieben), 'erledigt' (abgeschlossen) oder 'info'
|
||
// (rein informativ, keine eigene Handlung nötig — z. B. eine bereits
|
||
// zur Kenntnis genommene Mitteilung). Nur 'offen' wird beim Anlegen des
|
||
// nächsten Protokolls fortgeschrieben (legeProtokollAn()); sowohl
|
||
// 'erledigt' als auch 'info' laufen nicht weiter mit, aus demselben Grund:
|
||
// beides ist nichts mehr, worauf jemand noch reagieren muss.
|
||
const VALID_PROTOKOLL_PUNKT_STATUS = new Set(['offen', 'erledigt', 'info']);
|
||
// PROTOKOLL_PUNKT_MARKER: erkennt einen Zeilenanfang wie "1.1", "TOP 3",
|
||
// "Punkt 2.4" oder "1." als Beginn eines neuen Punkts; der Rest der Zeile
|
||
// wird dessen Thema. Ein Regex-Satz für mehrere gängige Schreibweisen, kein
|
||
// Anspruch auf Vollständigkeit — siehe erkennePunkteAusText().
|
||
const PROTOKOLL_PUNKT_MARKER = /^\s*(?:top|punkt)?\s*(\d+(?:\.\d+)*)[.\):]?\s+(.+)$/i;
|
||
// PROTOKOLL_LABEL_MUSTER: Label-Muster, die INNERHALB eines erkannten
|
||
// Punkttexts nach Zuständig/Termin suchen — nur bei eindeutigem Treffer
|
||
// übernommen (siehe erkennePunkteAusText()).
|
||
// Nicht-gierig bis zum nächsten Satzende, Zeilenende ODER dem nächsten
|
||
// bekannten Label — sonst reißt z. B. "Zuständig: Firma Muster. Termin: …"
|
||
// (beides auf einer Zeile, ohne Komma dazwischen) den Termin-Teil mit in den
|
||
// Zuständig-Treffer hinein. Ein Punkt gilt nur als Satzende, wenn ihm keine
|
||
// Ziffer folgt — sonst würde ein Datum wie "15.10.2026" beim ersten Punkt
|
||
// abgeschnitten.
|
||
const PROTOKOLL_LABEL_STOP = '(?=[,;]|\\.(?!\\d)|$|\\s*(?:zust(?:ä|ae|a)ndig|verantwortlich|termin|frist)\\s*:)';
|
||
const PROTOKOLL_LABEL_MUSTER = [
|
||
{ feld: 'zustaendig', regex: new RegExp('(?:zust(?:ä|ae|a)ndig|verantwortlich)\\s*:\\s*([^,;]+?)' + PROTOKOLL_LABEL_STOP, 'i') },
|
||
{ feld: 'termin', regex: new RegExp('(?:termin|frist)\\s*:\\s*([^,;]+?)' + PROTOKOLL_LABEL_STOP, 'i') }
|
||
];
|
||
|
||
// erkennePunkteAusText: reines String-Processing ohne I/O (kein Netzwerk,
|
||
// keine KI) — teilt einen aus PDF/Word extrahierten Freitext heuristisch in
|
||
// Protokoll-Punkte auf. Siehe CLAUDE.md "### Protokolle" für die Begründung
|
||
// jeder Regel. Erkennt PROTOKOLL_PUNKT_MARKER-Zeilen als neue Punkte, alles
|
||
// dazwischen wird deren Text; findet sich darin ein PROTOKOLL_LABEL_MUSTER,
|
||
// wird der Wert herausgelöst statt im Fließtext zu bleiben. `gruppe` wird
|
||
// bewusst NIE geraten — jeder erkannte Punkt landet in "Ohne Gruppe", der
|
||
// Nutzer ordnet in der ohnehin vorhandenen Detailansicht selbst zu. Findet
|
||
// sich im ganzen Text kein einziger Marker (keine erkennbare Nummerierung),
|
||
// greift ein Fallback: jeder nicht-leere Absatz (durch Leerzeile getrennt)
|
||
// wird ein eigener Punkt — liefert immer ein Grundgerüst statt eines leeren
|
||
// Protokolls. `nummer` hier ist rein informationell (die im Quelldokument
|
||
// gefundene Nummer) — angezeigt wird sie ohnehin nie aus der Datenbank,
|
||
// sondern immer frisch aus Gruppe+Reihenfolge errechnet
|
||
// (berechneProtokollNummern(), public/js/plan/plan-protokoll.js).
|
||
// Parameter: text (String). Rückgabe: Array von
|
||
// { nummer, gruppe, thema, text, zustaendig, termin, erledigt }.
|
||
// normalisiereTermin: bringt ein erkanntes Datum auf ISO (JJJJ-MM-TT), das
|
||
// Format, das Protokoll-Ansicht, PDF (parseDateISO()) und <input type=date>
|
||
// erwarten. Protokolle schreiben "15.10.2026" oder "5.1.26" — das wurde
|
||
// bisher unverändert gespeichert und erschien dann überall als "kein
|
||
// Termin"; bei "TODOs aus Protokoll" ging die Fälligkeit still verloren.
|
||
// Was kein erkennbares Datum ist ("KW 45", "laufend"), bleibt wie es ist.
|
||
// Parameter: wert. Rückgabe: String.
|
||
function normalisiereTermin(wert){
|
||
const s = String(wert || '').trim();
|
||
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/);
|
||
if(!m) return s;
|
||
const jahr = m[3].length === 2 ? '20' + m[3] : m[3];
|
||
const monat = Number(m[2]), tag = Number(m[1]);
|
||
if(monat < 1 || monat > 12 || tag < 1 || tag > 31) return s;
|
||
return jahr + '-' + String(monat).padStart(2, '0') + '-' + String(tag).padStart(2, '0');
|
||
}
|
||
|
||
function erkennePunkteAusText(text){
|
||
const zeilen = String(text || '').split(/\r?\n/).map(z => z.trim());
|
||
|
||
const treffer = [];
|
||
let aktuell = null;
|
||
zeilen.forEach(zeile => {
|
||
if(!zeile) return;
|
||
const m = zeile.match(PROTOKOLL_PUNKT_MARKER);
|
||
if(m){
|
||
if(aktuell) treffer.push(aktuell);
|
||
aktuell = { nummer: m[1], thema: m[2].trim(), textZeilen: [], zustaendig: '', termin: '', erledigt: false };
|
||
} else if(aktuell){
|
||
aktuell.textZeilen.push(zeile);
|
||
}
|
||
});
|
||
if(aktuell) treffer.push(aktuell);
|
||
|
||
if(treffer.length === 0){
|
||
return String(text || '').split(/\r?\n\s*\r?\n/)
|
||
.map(a => a.replace(/\s+/g, ' ').trim())
|
||
.filter(Boolean)
|
||
.map(a => ({
|
||
nummer: '', gruppe: '', thema: a.slice(0, 60), text: a.length > 60 ? a : '',
|
||
zustaendig: '', termin: '', erledigt: false
|
||
}));
|
||
}
|
||
|
||
return treffer.map(t => {
|
||
// Labels wie "Zuständig:"/"Termin:" stehen bei kurzen Punkten oft in
|
||
// derselben Zeile wie das Thema (ein Word-Absatz je Punkt), nicht in
|
||
// einer eigenen Folgezeile — deshalb wird über Thema UND die
|
||
// nachfolgenden Zeilen gemeinsam gesucht, nicht nur über Letztere.
|
||
let thema = t.thema;
|
||
let restText = t.textZeilen.join(' ').replace(/\s+/g, ' ').trim();
|
||
PROTOKOLL_LABEL_MUSTER.forEach(({ feld, regex }) => {
|
||
let lm = thema.match(regex);
|
||
if(lm){
|
||
t[feld] = lm[1].trim();
|
||
thema = thema.replace(lm[0], '').trim();
|
||
return;
|
||
}
|
||
lm = restText.match(regex);
|
||
if(lm){
|
||
t[feld] = lm[1].trim();
|
||
restText = restText.replace(lm[0], '').trim();
|
||
}
|
||
});
|
||
// Entfernen eines Labels aus der Mitte des Themas kann einen
|
||
// verwaisten Satzpunkt zurücklassen ("…abzuschließen. ." statt
|
||
// "…abzuschließen.") — am Ende auf einen einzigen Punkt einkürzen.
|
||
thema = thema.replace(/[.\s]+$/, m => (m.indexOf('.') !== -1 ? '.' : '')).trim();
|
||
const erledigt = /\berledigt\b|\bgeschlossen\b/i.test(restText) || /\berledigt\b|\bgeschlossen\b/i.test(thema);
|
||
// status zusätzlich zu erledigt: Der Client (TODOs aus Protokoll) wählt
|
||
// offene Punkte über status vor — ohne das Feld war nichts vorausgewählt.
|
||
return { nummer: t.nummer, gruppe: '', thema, text: restText,
|
||
zustaendig: t.zustaendig, termin: normalisiereTermin(t.termin), erledigt,
|
||
status: erledigt ? 'erledigt' : 'offen' };
|
||
});
|
||
}
|
||
|
||
// ---------- KI-gestützte Erkennung (ChatGPT/Claude) für den Protokoll-Import ----------
|
||
//
|
||
// Alternative zu erkennePunkteAusText() oben: statt starrer Regex-Muster
|
||
// liest ein Sprachmodell den Text und liefert dieselbe Punkt-Form zurück.
|
||
// Bewusst zusätzlich zur Heuristik, nicht statt ihr — siehe CLAUDE.md
|
||
// "### Protokolle": ohne hinterlegten Schlüssel oder bei jedem Fehler
|
||
// (Netzwerk, falscher Schlüssel, Zeitüberschreitung, unerwartete Antwort)
|
||
// fällt der Aufrufer (POST /project-protokolle/import) automatisch auf
|
||
// erkennePunkteAusText() zurück — der Import funktioniert immer, auch ohne
|
||
// Internetzugang des Servers. Beide Anbieter laufen über das in Node bereits
|
||
// global verfügbare fetch() — keine neue npm-Abhängigkeit.
|
||
|
||
const KI_MAX_TEXT_LAENGE = 40000; // ~10k Tokens; deckelt Kosten/Dauer bei
|
||
// einer versehentlich riesigen Datei, reicht für jedes normale Protokoll.
|
||
const KI_TIMEOUT_MS_CLOUD = 45000;
|
||
// Ein lokal laufender Server (LM Studio, Ollama) rechnet auf der eigenen
|
||
// Maschine, oft ohne oder mit begrenzter GPU-Beschleunigung — eine einzelne
|
||
// Antwort kann mehrere Minuten dauern, weit jenseits dessen, was bei einer
|
||
// Cloud-API schon ein Hänger wäre. Mit dem kurzen, einheitlichen Timeout
|
||
// brach die Anfrage bei Erik vorzeitig ab ("This operation was aborted"),
|
||
// obwohl LM Studio nachweislich weiterrechnete und fertig geworden wäre
|
||
// ("läuft aber durch"). Ein kurzer, einheitlicher Timeout hätte lokale
|
||
// Modelle damit praktisch unbenutzbar gemacht. Ursprünglich 300000 (5 min),
|
||
// dann 900000 (15 min) — beide brachen bei Erik immer noch ab, vor allem
|
||
// seit der Prompt jetzt für jeden erkannten Punkt echten Fließtext erzeugt
|
||
// (also deutlich mehr Tokens generiert als die frühere, kaputte Fassung,
|
||
// die oft fast leer blieb): bei ~12.000 generierten Tokens auf seiner
|
||
// Hardware lief LM Studio selbst nachweislich sauber bis zum Ende durch
|
||
// (vom eigenen "Fertig"-Zustand im LM-Studio-Fenster bestätigt), nur die
|
||
// eigene AbortController-Schranke hier griff vorher. Auf 30 Minuten
|
||
// angehoben, mit entsprechendem Headroom bei LOKAL_DISPATCHER (unten).
|
||
// Der Import zeigt währenddessen den Ladehinweis im Dialog
|
||
// (#protokollImportLoading, plan-protokoll.js), das lange Warten ist also
|
||
// sichtbar, kein scheinbares Hängenbleiben.
|
||
const KI_TIMEOUT_MS_LOKAL = 1800000;
|
||
|
||
// KI_TIMEOUT_MS_CLOUD_FREMD: ein abweichender, aber weiterhin REMOTE
|
||
// liegender Endpunkt (z. B. Mammouth.ai, OpenRouter, ein Firmen-Proxy) ist
|
||
// weder "die echte, bekanntermaßen schnelle OpenAI/Anthropic-API" (dort
|
||
// gilt zurecht der scharfe KI_TIMEOUT_MS_CLOUD) noch "lokale CPU-Inferenz,
|
||
// die Minuten dauern darf" (KI_TIMEOUT_MS_LOKAL) — ein eigener,
|
||
// mittlerer Wert. Gefunden bei einem echten Fehlschlag: "TODOs aus
|
||
// Protokoll" brach bei Erik zuverlässig mit "This operation was aborted"
|
||
// ab, obwohl der Protokoll-Import grundsätzlich funktionierte — derselbe
|
||
// Konto-Schlüssel zeigte auf https://api.mammouth.ai/v1 (Modell
|
||
// "gpt-6-luna"), kein Loopback, also griff bislang der scharfe
|
||
// KI_TIMEOUT_MS_CLOUD (45s). Direkt gegen den echten Schlüssel nachgestellt
|
||
// (derselbe Aufruf, derselbe Prompt, dasselbe Dokument wie im Fehlerfall):
|
||
// ein einzelnes triviales Prompt beantwortete der Anbieter in 1,3s, die
|
||
// tatsächliche Punkte-Erkennung eines realen Protokolls (29.594 Zeichen)
|
||
// brauchte dagegen 39,2s — schon im Erfolgsfall nur 6s unter dem alten
|
||
// Limit, jede normale Schwankung (Tageszeit, Dokumentlänge, Anbieterlast)
|
||
// reichte seither zuverlässig zum Abbruch. Ein Drittanbieter-Proxy ist
|
||
// grundsätzlich langsamer und weniger vorhersagbar als die Original-API,
|
||
// aber immer noch ein verwalteter Cloud-Dienst, keine CPU-Inferenz auf
|
||
// fremder Hardware — 2 Minuten geben das Dreifache der gemessenen Dauer
|
||
// als Spielraum, ohne in die Größenordnung von KI_TIMEOUT_MS_LOKAL zu gehen.
|
||
const KI_TIMEOUT_MS_CLOUD_FREMD = 120000;
|
||
|
||
// LOKAL_DISPATCHER: Node's globales fetch() läuft intern über undici, dessen
|
||
// eigener Standard-Agent Verbindungen nach headersTimeout/bodyTimeout = 5
|
||
// Minuten von sich aus kappt — UNABHÄNGIG vom eigenen AbortController unten.
|
||
// Solange KI_TIMEOUT_MS_LOKAL ebenfalls bei 5 Minuten lag, fiel das nicht
|
||
// auf: beide Zeitgeber liefen praktisch gleichzeitig ab, und man sah immer
|
||
// den eigenen, sauberen "This operation was aborted"-Abbruch. Nach dem
|
||
// Anheben auf 15 Minuten griff undicis eigenes 5-Minuten-Limit als Erstes —
|
||
// bei Erik reproduzierbar als "fetch failed", obwohl LM Studio selbst
|
||
// nachweislich sauber durchlief. Ein eigener Agent mit angehobenem Limit
|
||
// ist die einzige Möglichkeit, das zu umgehen: fetch() selbst bietet dafür
|
||
// keine Option, nur das (nicht standardisierte, aber von Node übernommene)
|
||
// `dispatcher`-Feld in den Request-Optionen. Nur für lokale Ziele
|
||
// verwendet — eine Cloud-API bleibt beim kurzen KI_TIMEOUT_MS_CLOUD (45s),
|
||
// weit unter undicis Vorgabewert, dort besteht das Problem gar nicht.
|
||
const LOKAL_DISPATCHER = new UndiciAgent({
|
||
headersTimeout: KI_TIMEOUT_MS_LOKAL + 10000,
|
||
bodyTimeout: KI_TIMEOUT_MS_LOKAL + 10000
|
||
});
|
||
|
||
// KI_SYSTEM_PROMPT wurde NICHT nach Stil-/Rechtschreibkonvention geglättet,
|
||
// sondern bewusst wortgleich zu der Fassung übernommen, die gegen ein
|
||
// echtes, mehrstufig nummeriertes Besprechungsprotokoll (PromanX/Phenix-
|
||
// Format: "01" Kapitel, "01.0" Unterkapitel, "01.0 01.03" der eigentliche
|
||
// Punkt) tatsächlich funktioniert hat — gegen Eriks lokales LM-Studio-Modell
|
||
// erwies sich das Ergebnis als erstaunlich instabil gegenüber minimalen
|
||
// Formulierungsänderungen: dieselbe Aufgabe lieferte je nach Umformulierung
|
||
// zwischen 0 und 11 erkannten Punkten, mal mit vollem Text, mal mit
|
||
// durchgehend LEEREM "text" (reproduzierbar bei temperature:0, also keine
|
||
// bloße Zufallsstreuung). Ursache war u. a. eine vorangestellte, allgemein
|
||
// gehaltene "erfinde nichts, lass im Zweifel leer"-Anweisung, die das
|
||
// Modell offenbar dazu brachte, aus Vorsicht auch "text" leer zu lassen,
|
||
// obwohl der Inhalt eindeutig im Dokument stand — das genaue Gegenteil des
|
||
// gemeldeten Fehlers ("kopiert die Texte nicht mit, erstellt nur die
|
||
// Überschriften"). Eine zweite, von Erik angestoßene Runde ergänzte GENAU
|
||
// EINE weitere, chirurgisch kurze Anweisung — mehrere eigenständige Punkte
|
||
// unter derselben Überschrift einzeln auszugeben, UND mehrere aufeinander-
|
||
// folgende, je datierte Textabsätze desselben Punkts (eine im Quelldokument
|
||
// selbst geführte Revisionshistorie über mehrere Besprechungen) zu einem
|
||
// zusammenhängenden "text" zusammenzuführen, statt sie als eigene, von
|
||
// ihrem Thema abgetrennte Punkte auszugeben — und bestand denselben
|
||
// Stabilitätstest (reales Dokument, temperature:0): korrekt erkannte
|
||
// Punkte mit vollständig zusammengeführtem Text statt verwaister, themenloser
|
||
// Fragmente. Jede WEITERE Ergänzung darüber hinaus (z. B. eine explizite
|
||
// Teilnehmer-Anleitung) hat in denselben Tests dagegen zuverlässig wieder
|
||
// zur Regression geführt — deshalb bleibt "teilnehmer" bewusst ungeleitet,
|
||
// das Schema allein genügt dafür. Ein eigener Blindgang dabei: gegen einen
|
||
// künstlich verkürzten Testausschnitt OHNE die echte Teilnehmer-/Verteiler-
|
||
// Tabelle des Dokuments geriet das Modell beim Versuch, "teilnehmer" trotzdem
|
||
// zu befüllen, in eine Wiederholungsschleife (tausendfach dasselbe Namens-
|
||
// Fragment, bis das Token-Limit die Antwort mitten im JSON abschnitt) — mit
|
||
// der echten Tabelle (wie jeder reale Import sie tatsächlich schickt) trat
|
||
// das nicht wieder auf, lief sogar spürbar schneller durch. frequency_penalty/
|
||
// presence_penalty unten bleiben trotzdem als zusätzliches Sicherheitsnetz
|
||
// gegen genau solche Wiederholungsschleifen bestehen.
|
||
const KI_SYSTEM_PROMPT = 'Extrahiere ALLE Besprechungspunkte aus diesem Protokoll-Text, auch wenn es viele sind. '
|
||
+ 'Das Dokument ist mehrstufig nummeriert (z.B. "01" Kapitel, "01.0" Unterkapitel, "01.0 01.03" der echte Punkt mit Text). '
|
||
+ 'Nur Zeilen mit eigenem Fließtext darunter werden ein Punkt - reine Kapitelüberschriften ohne Text NICHT. '
|
||
+ 'Stehen unter einer Überschrift mehrere eigene Punkte, gib jeden einzeln aus. Folgen auf einen Punkt weitere '
|
||
+ 'Absätze ohne neue eigene Nummer, gehören sie zu diesem Punkt dazu - auch wenn ein spaeterer Absatz einen '
|
||
+ 'anderen Status zeigt, bleibt es EIN Punkt mit dem Status des letzten Absatzes. '
|
||
+ '"status" ist offen, erledigt oder info - info wenn der Text den Punkt selbst so kennzeichnet (z.B. das Wort '
|
||
+ '"Info" direkt beim Punkt) oder der Punkt rein informativ ohne eigene Handlung ist, erledigt nur bei '
|
||
+ 'ausdruecklicher Erledigt-/Geschlossen-Kennzeichnung, sonst offen. '
|
||
+ 'Gib fuer jeden Punkt nummer, thema, text, zustaendig, termin, status, gruppe (leer lassen wenn unklar) zurueck. Erfinde nichts.';
|
||
|
||
// readUserAiKey/writeUserAiKey: Schlüssel+URL liegen PERSÖNLICH je Konto in
|
||
// user_ai_keys (siehe CREATE TABLE weiter oben bei den übrigen User-Kind-
|
||
// Tabellen), nicht firmenweit in app_settings — jeder Nutzer verwaltet
|
||
// seinen eigenen Zugang unter Einstellungen → Mein Konto
|
||
// (js/settings/settings.js), genau wie E-Mail und Passwort. Klartext in der
|
||
// SQLite-Datei, wie jeder andere Wert dort auch.
|
||
function readUserAiKey(userId, provider){
|
||
const row = db.prepare('SELECT api_key, api_url, model FROM user_ai_keys WHERE user_id = ? AND provider = ?').get(userId, provider);
|
||
return { apiKey: row ? row.api_key : '', apiUrl: row ? row.api_url : '', model: row ? row.model : '' };
|
||
}
|
||
|
||
function writeUserAiKey(userId, provider, patch){
|
||
const bestehend = readUserAiKey(userId, provider);
|
||
const apiKey = Object.prototype.hasOwnProperty.call(patch, 'apiKey') ? String(patch.apiKey || '') : bestehend.apiKey;
|
||
const apiUrl = Object.prototype.hasOwnProperty.call(patch, 'apiUrl') ? String(patch.apiUrl || '') : bestehend.apiUrl;
|
||
// model: leer = die im jeweiligen Aufrufer hartcodierte Vorgabe gilt
|
||
// (gpt-4o-mini/gpt-4o bzw. claude-haiku/claude-sonnet-5) - nur bei einem
|
||
// eigenständigen Anbieter mit abweichendem Modell-Katalog (z. B.
|
||
// Mammouth.ai) muss das hier überschrieben werden.
|
||
const model = Object.prototype.hasOwnProperty.call(patch, 'model') ? String(patch.model || '') : bestehend.model;
|
||
db.prepare(`INSERT INTO user_ai_keys (user_id, provider, api_key, api_url, model) VALUES (?, ?, ?, ?, ?)
|
||
ON CONFLICT(user_id, provider) DO UPDATE SET api_key = excluded.api_key, api_url = excluded.api_url, model = excluded.model`)
|
||
.run(userId, provider, apiKey, apiUrl, model);
|
||
}
|
||
|
||
// KI_DEFAULT_URL: vor istLoopbackHost()/fetchMitTimeout() platziert (stand
|
||
// ursprünglich weiter unten bei normalisiereAiUrl()), weil fetchMitTimeout()
|
||
// unten die beiden Adressen jetzt selbst braucht, um einen ECHTEN
|
||
// Anbieter-Endpunkt von einem abweichenden (lokal ODER ein fremder
|
||
// Cloud-Proxy) zu unterscheiden — siehe istStandardCloudHost() und den
|
||
// Fund bei KI_TIMEOUT_MS_CLOUD_FREMD weiter unten.
|
||
const KI_DEFAULT_URL = {
|
||
chatgpt: 'https://api.openai.com/v1/chat/completions',
|
||
claude: 'https://api.anthropic.com/v1/messages'
|
||
};
|
||
|
||
// istLoopbackHost: gemeinsame Grundlage für istGueltigeAiUrl() (URL-Prüfung
|
||
// beim Speichern) und fetchMitTimeout() (Timeout-Wahl beim Aufruf) — beide
|
||
// müssen dieselbe Vorstellung von "läuft auf der eigenen Maschine" teilen.
|
||
function istLoopbackHost(hostname){
|
||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
|
||
}
|
||
|
||
// istStandardCloudHost: true nur für die beiden ECHTEN Anbieter-Hosts aus
|
||
// KI_DEFAULT_URL (api.openai.com/api.anthropic.com) — die einzigen, bei
|
||
// denen "eine Antwort dauert ungewöhnlich lange" zuverlässig "hier ist
|
||
// etwas kaputt" bedeutet. Grundlage für fetchMitTimeout()s dritte Stufe
|
||
// (KI_TIMEOUT_MS_CLOUD_FREMD) direkt darunter.
|
||
function istStandardCloudHost(hostname){
|
||
return hostname === new URL(KI_DEFAULT_URL.chatgpt).hostname || hostname === new URL(KI_DEFAULT_URL.claude).hostname;
|
||
}
|
||
|
||
// istGueltigeAiUrl: https wird grundsätzlich verlangt (der Schlüssel reist
|
||
// sonst im Klartext über ein Netz, das nicht die eigene Maschine ist) — mit
|
||
// EINER Ausnahme: http auf dem Loopback. Ein lokal laufender Server wie
|
||
// LM Studio oder Ollama bietet praktisch nie ein TLS-Zertifikat für
|
||
// localhost an; ihn auszuschließen hätte den naheliegendsten Anwendungsfall
|
||
// einer eigenen API-URL — ein kostenloses, lokales Modell statt eines
|
||
// kostenpflichtigen Cloud-Zugangs — unmöglich gemacht. Der Klartext
|
||
// verlässt dabei nie die eigene Maschine, das Risiko besteht also nicht.
|
||
// Rückgabe: bool.
|
||
function istGueltigeAiUrl(url){
|
||
let parsed;
|
||
try { parsed = new URL(url); } catch(e){ return false; }
|
||
if(parsed.protocol === 'https:') return true;
|
||
return parsed.protocol === 'http:' && istLoopbackHost(parsed.hostname);
|
||
}
|
||
|
||
async function fetchMitTimeout(url, options){
|
||
// Drei Stufen statt zwei: echter Anbieter-Host (knapp, 45s) < abweichender
|
||
// Remote-Host wie ein Drittanbieter-Proxy (mittel, 2min, s.
|
||
// KI_TIMEOUT_MS_CLOUD_FREMD) < Loopback (großzügig, 30min) — siehe dortigen
|
||
// Kommentar für den konkreten Fehlschlag, der die mittlere Stufe nötig machte.
|
||
let istLokal = false;
|
||
let istStandard = true;
|
||
try {
|
||
const hostname = new URL(url).hostname;
|
||
istLokal = istLoopbackHost(hostname);
|
||
istStandard = istStandardCloudHost(hostname);
|
||
} catch(e){ /* ungültige URL: Vorgabe bleibt der knappe Standard-Timeout */ }
|
||
const timeoutMs = istLokal ? KI_TIMEOUT_MS_LOKAL : (istStandard ? KI_TIMEOUT_MS_CLOUD : KI_TIMEOUT_MS_CLOUD_FREMD);
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
const fetchOptions = Object.assign({}, options, { signal: controller.signal });
|
||
if(istLokal) fetchOptions.dispatcher = LOKAL_DISPATCHER;
|
||
return await fetch(url, fetchOptions);
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
// normalisierePunktAusKi: eine KI-Antwortzeile in dieselbe Form wie ein
|
||
// erkennePunkteAusText()-Punkt bringen — beide Aufrufer (POST-Route,
|
||
// legeProtokollAn) müssen nicht wissen, woher ein Punkt stammt.
|
||
function normalisierePunktAusKi(p){
|
||
return {
|
||
nummer: String((p && p.nummer) || ''),
|
||
gruppe: String((p && p.gruppe) || ''),
|
||
thema: String((p && p.thema) || ''),
|
||
text: String((p && p.text) || ''),
|
||
zustaendig: String((p && p.zustaendig) || ''),
|
||
termin: normalisiereTermin((p && p.termin) || ''),
|
||
status: VALID_PROTOKOLL_PUNKT_STATUS.has(p && p.status) ? p.status : 'offen'
|
||
};
|
||
}
|
||
|
||
// normalisiereTeilnehmerAusKi: Gegenstück zu normalisierePunktAusKi() für
|
||
// das zweite KI_PUNKT_SCHEMA-Feld. "anwesend" defaultet — anders als
|
||
// "erledigt" oben — auf true, nicht auf false: eine erkannte Person gilt als
|
||
// anwesend, außer der Prompt weist das Modell ausdrücklich an, sie als
|
||
// entschuldigt/abwesend zu kennzeichnen (dieselbe Grundannahme wie beim
|
||
// manuellen "+ Teilnehmer" in der Detailansicht, js/plan/plan-protokoll.js).
|
||
function normalisiereTeilnehmerAusKi(t){
|
||
return {
|
||
name: String((t && t.name) || ''),
|
||
firma: String((t && t.firma) || ''),
|
||
anwesend: !(t && t.anwesend === false)
|
||
};
|
||
}
|
||
|
||
const KI_PUNKT_SCHEMA = {
|
||
type: 'object',
|
||
properties: {
|
||
punkte: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
nummer: { type: 'string' },
|
||
gruppe: { type: 'string' },
|
||
thema: { type: 'string' },
|
||
text: { type: 'string' },
|
||
zustaendig: { type: 'string' },
|
||
termin: { type: 'string' },
|
||
status: { type: 'string', enum: ['offen', 'erledigt', 'info'] }
|
||
},
|
||
required: ['nummer', 'gruppe', 'thema', 'text', 'zustaendig', 'termin', 'status'],
|
||
additionalProperties: false
|
||
}
|
||
},
|
||
teilnehmer: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
name: { type: 'string' },
|
||
firma: { type: 'string' },
|
||
anwesend: { type: 'boolean' }
|
||
},
|
||
required: ['name', 'firma', 'anwesend'],
|
||
additionalProperties: false
|
||
}
|
||
}
|
||
},
|
||
required: ['punkte', 'teilnehmer'],
|
||
additionalProperties: false
|
||
};
|
||
|
||
// KI_DEFAULT_URL ist jetzt weiter oben definiert, direkt vor
|
||
// istLoopbackHost()/fetchMitTimeout() — siehe Kommentar dort.
|
||
|
||
// normalisiereAiUrl: wer eine eigene API-URL hinterlegt, tippt bei einem
|
||
// lokalen Server wie LM Studio/Ollama erfahrungsgemäß nur die Basisadresse
|
||
// (z. B. "http://localhost:1234"), nicht den vollen Completions-Pfad —
|
||
// beide Tools bewerben sich selbst über genau diese kurze Adresse. Ohne
|
||
// diese Ergänzung landet die Anfrage dort auf "/" statt auf
|
||
// "/v1/chat/completions" (bzw. "/v1/messages" bei Claude): der lokale
|
||
// Server antwortet trotzdem mit HTTP 200, aber mit einem Text, der keine
|
||
// gültige Chat-Completion ist — reproduzierbar mit Eriks eigenem Fehler
|
||
// ("Received request: POST to /" in LM Studios Log). Nur ein FEHLENDER
|
||
// oder leerer Pfad wird ergänzt; ein ausdrücklich angegebener Pfad (z. B.
|
||
// ein Firmen-Proxy mit eigener Route) bleibt unangetastet.
|
||
function normalisiereAiUrl(apiUrl, defaultUrl){
|
||
if(!apiUrl) return defaultUrl;
|
||
try {
|
||
const parsed = new URL(apiUrl);
|
||
const defaultPath = new URL(defaultUrl).pathname; // z. B. '/v1/chat/completions'
|
||
const leer = !parsed.pathname || parsed.pathname === '/';
|
||
// Viele OpenAI-kompatible Anbieter (Mammouth.ai, OpenRouter und weitere)
|
||
// dokumentieren ihre Basis-URL nach der offiziellen OpenAI-SDK-Konvention
|
||
// als ".../v1" - ohne den eigentlichen Completions-Pfad dahinter. Das
|
||
// Eingabefeld (Einstellungen → Mein Konto) schlägt mit seinem Platzhalter
|
||
// ("z. B. http://localhost:1234 für LM Studio") genau diese Art bloßer
|
||
// Basis-URL vor, und wer sie nach der für den jeweiligen Anbieter
|
||
// dokumentierten Form mit angehängtem "/v1" einträgt, bekam bisher einen
|
||
// 404: Die Anfrage landete exakt auf ".../v1" statt auf
|
||
// ".../v1/chat/completions" (reproduzierbar bei Mammouth.ai, dessen
|
||
// FastAPI-typische {"detail":"Not Found"}-Antwort das auch so ausweist).
|
||
// Nur ein Pfad, der GENAU auf "/v1" endet, wird so behandelt wie ein
|
||
// leerer - ein abweichender, bewusst eigener Pfad (z. B. ein
|
||
// Firmen-Proxy mit eigener Route) bleibt wie bisher unangetastet.
|
||
const nurBasisPfad = /\/v1\/?$/.test(parsed.pathname) && defaultPath.startsWith('/v1/');
|
||
if(leer || nurBasisPfad){
|
||
parsed.pathname = defaultPath;
|
||
return parsed.toString();
|
||
}
|
||
return apiUrl;
|
||
} catch(e){
|
||
return apiUrl;
|
||
}
|
||
}
|
||
|
||
async function erkennePunkteMitChatGPT(text, apiKey, apiUrl, model){
|
||
const zielUrl = normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.chatgpt);
|
||
let istLokal = false;
|
||
try { istLokal = istLoopbackHost(new URL(zielUrl).hostname); } catch(e){ /* ungültige URL: bleibt false */ }
|
||
|
||
const body = {
|
||
// model (Konto-Einstellung, user_ai_keys.model) überschreibt die Vorgabe
|
||
// nur, wenn gesetzt — nötig für einen eigenständigen Anbieter mit
|
||
// abweichendem Modell-Katalog (z. B. Mammouth.ai), siehe
|
||
// migrateAddUserAiKeyModel() oben.
|
||
model: model || 'gpt-4o-mini',
|
||
messages: [
|
||
{ role: 'system', content: KI_SYSTEM_PROMPT },
|
||
{ role: 'user', content: text }
|
||
],
|
||
response_format: { type: 'json_schema', json_schema: { name: 'protokoll_punkte', strict: true, schema: KI_PUNKT_SCHEMA } },
|
||
// Ohne eigenes max_tokens bricht mancher lokale Server (anders als die
|
||
// echte OpenAI-API, die hier großzügig ist) die Antwort bei einem langen
|
||
// Protokoll mit vielen Punkten vorzeitig ab, sobald ein internes
|
||
// Standardlimit erreicht ist — die JSON-Antwort bleibt dabei gültig,
|
||
// enthält aber nur die ersten paar Punkte statt aller.
|
||
max_tokens: 16000,
|
||
// temperature:0 macht die Erkennung reproduzierbar — bei Streuung (dem
|
||
// Standardwert) lieferte ein und dieselbe Anfrage an Eriks lokales
|
||
// Modell je nach Lauf 0 bis 11 erkannte Punkte, was Fehlersuche und
|
||
// Nutzervertrauen gleichermaßen unmöglich macht. Für diese Aufgabe
|
||
// (Fakten aus einem Text ziehen) ist Kreativität ohnehin unerwünscht.
|
||
temperature: 0,
|
||
// Sicherheitsnetz gegen Wiederholungsschleifen: bei einem Testlauf ohne
|
||
// echte Teilnehmerdaten im Text geriet Eriks lokales Modell beim
|
||
// Befüllen von "teilnehmer" in eine Endlosschleife (dasselbe
|
||
// Namens-Fragment tausendfach), bis max_tokens die Antwort mitten im
|
||
// JSON abschnitt — mit echten Teilnehmerdaten (wie jeder reale Import
|
||
// sie liefert) trat das nicht auf, aber diese beiden Parameter machen
|
||
// eine erneute Schleife unabhängig vom Dokumentinhalt unwahrscheinlicher.
|
||
frequency_penalty: 0.4,
|
||
presence_penalty: 0.2
|
||
};
|
||
// Manche lokale Server laden standardmäßig ein Reasoning-Modell (z. B.
|
||
// Qwen3 in LM Studio), das vor jeder Antwort ausführlich "denkt" — für
|
||
// diese Aufgabe direkt gegen ein echtes LM-Studio-Setup gemessen: 260 von
|
||
// 264 Tokens (98,5 %) fürs Denken, 15s statt 0,4s für eine triviale
|
||
// Anfrage; bei einem langen Besprechungsprotokoll mit vielen Punkten
|
||
// reicht das leicht über jeden vertretbaren Timeout hinaus, obwohl das
|
||
// Modell am Ende korrekt "durchläuft". reasoning_effort:'none' schaltet
|
||
// das Denken ab (ebenfalls live verifiziert — mit dem echten System-
|
||
// Prompt und Schema unten korrekt und in Sekunden statt Minuten). Nur
|
||
// für lokale Ziele gesetzt: die echte OpenAI-API für gpt-4o-mini (kein
|
||
// Reasoning-Modell) kennt den Parameter nicht und soll ihn nie sehen.
|
||
if(istLokal) body.reasoning_effort = 'none';
|
||
|
||
const res = await fetchMitTimeout(zielUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if(!res.ok){
|
||
const body = await res.text().catch(() => '');
|
||
throw new Error('ChatGPT antwortete mit HTTP ' + res.status + (body ? ': ' + body.slice(0, 200) : ''));
|
||
}
|
||
const data = await res.json();
|
||
const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
|
||
if(!content) throw new Error('ChatGPT lieferte keine Antwort.');
|
||
let parsed;
|
||
try { parsed = JSON.parse(content); } catch(e){ throw new Error('ChatGPT-Antwort war kein gültiges JSON.'); }
|
||
if(!Array.isArray(parsed.punkte)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.');
|
||
return {
|
||
punkte: parsed.punkte.map(normalisierePunktAusKi),
|
||
teilnehmer: Array.isArray(parsed.teilnehmer) ? parsed.teilnehmer.map(normalisiereTeilnehmerAusKi) : []
|
||
};
|
||
}
|
||
|
||
async function erkennePunkteMitClaude(text, apiKey, apiUrl, model){
|
||
const res = await fetchMitTimeout(normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.claude), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
||
body: JSON.stringify({
|
||
model: model || 'claude-haiku-4-5-20251001',
|
||
// wie beim ChatGPT-Pfad: ein langes Protokoll mit vielen Punkten
|
||
// braucht mehr als das ursprüngliche Minimum, sonst bricht die
|
||
// Antwort vor dem letzten Punkt ab.
|
||
max_tokens: 8192,
|
||
// Wie beim ChatGPT-Pfad: reproduzierbares Ergebnis statt Streuung
|
||
// zwischen Läufen (siehe Begründung bei KI_SYSTEM_PROMPT oben).
|
||
temperature: 0,
|
||
system: KI_SYSTEM_PROMPT,
|
||
messages: [{ role: 'user', content: text }],
|
||
tools: [{
|
||
name: 'protokoll_punkte',
|
||
description: 'Die aus dem Text erkannten Besprechungspunkte.',
|
||
input_schema: KI_PUNKT_SCHEMA
|
||
}],
|
||
tool_choice: { type: 'tool', name: 'protokoll_punkte' }
|
||
})
|
||
});
|
||
if(!res.ok){
|
||
const body = await res.text().catch(() => '');
|
||
throw new Error('Claude antwortete mit HTTP ' + res.status + (body ? ': ' + body.slice(0, 200) : ''));
|
||
}
|
||
const data = await res.json();
|
||
const toolUse = Array.isArray(data.content) ? data.content.find(b => b.type === 'tool_use') : null;
|
||
if(!toolUse || !toolUse.input || !Array.isArray(toolUse.input.punkte)){
|
||
throw new Error('Claude-Antwort hatte nicht die erwartete Form.');
|
||
}
|
||
return {
|
||
punkte: toolUse.input.punkte.map(normalisierePunktAusKi),
|
||
teilnehmer: Array.isArray(toolUse.input.teilnehmer) ? toolUse.input.teilnehmer.map(normalisiereTeilnehmerAusKi) : []
|
||
};
|
||
}
|
||
|
||
async function erkennePunkteMitKI(text, userId, provider){
|
||
const { apiKey, apiUrl, model } = readUserAiKey(userId, provider);
|
||
if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.');
|
||
const gekuerzterText = text.length > KI_MAX_TEXT_LAENGE ? text.slice(0, KI_MAX_TEXT_LAENGE) : text;
|
||
if(provider === 'chatgpt') return erkennePunkteMitChatGPT(gekuerzterText, apiKey, apiUrl, model);
|
||
if(provider === 'claude') return erkennePunkteMitClaude(gekuerzterText, apiKey, apiUrl, model);
|
||
throw new Error('Unbekannter Anbieter: ' + provider);
|
||
}
|
||
|
||
// ---------- "TODOs aus Protokoll" (public/js/plan/plan-protokoll-todos.js) ----------
|
||
//
|
||
// Eigenständige, zweite KI-Stufe: nimmt bereits erkannte Besprechungspunkte
|
||
// (egal ob frisch aus einem Ad-hoc-PDF-Upload oder sonstwoher) und leitet
|
||
// daraus Aufgaben-VORSCHLÄGE ab (Titel, Beschreibung, Fälligkeit, Zuständig-
|
||
// Vorschlag) — anders als erkennePunkteMitKI() oben, das nur Punkte aus
|
||
// Fließtext EXTRAHIERT, beurteilt dieser Schritt, was davon tatsächlich zu
|
||
// tun ist: mehrere Punkte können zu einer Aufgabe zusammengehören, rein
|
||
// informative Punkte liefern keine. Komplett unabhängig von
|
||
// project_protokolle/protokoll_punkte — die Punkte kommen direkt im
|
||
// Request-Body, nichts wird hier gelesen oder geschrieben.
|
||
|
||
// normalisiereAufgabeAusKi: Analoges Gegenstück zu normalisierePunktAusKi()
|
||
// oben, für eine KI-Antwortzeile dieser zweiten Stufe.
|
||
function normalisiereAufgabeAusKi(a){
|
||
return {
|
||
titel: String((a && a.titel) || ''),
|
||
beschreibung: String((a && a.beschreibung) || ''),
|
||
faelligkeit: String((a && a.faelligkeit) || ''),
|
||
zustaendigVorschlag: String((a && a.zustaendigVorschlag) || ''),
|
||
quellRefs: Array.isArray(a && a.quellRefs) ? a.quellRefs.filter(r => Number.isInteger(r)) : []
|
||
};
|
||
}
|
||
|
||
const KI_AUFGABEN_SCHEMA = {
|
||
type: 'object',
|
||
properties: {
|
||
aufgaben: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
titel: { type: 'string' },
|
||
beschreibung: { type: 'string' },
|
||
faelligkeit: { type: 'string' },
|
||
zustaendigVorschlag: { type: 'string' },
|
||
quellRefs: { type: 'array', items: { type: 'integer' } }
|
||
},
|
||
required: ['titel', 'beschreibung', 'faelligkeit', 'zustaendigVorschlag', 'quellRefs'],
|
||
additionalProperties: false
|
||
}
|
||
}
|
||
},
|
||
required: ['aufgaben'],
|
||
additionalProperties: false
|
||
};
|
||
|
||
// KI_AUFGABEN_SYSTEM_PROMPT: eigener, von KI_SYSTEM_PROMPT bewusst getrennter
|
||
// Prompt — andere Aufgabe (Aufgaben aus bereits strukturierten Punkten
|
||
// ABLEITEN, nicht Punkte aus Fließtext EXTRAHIEREN). Wie bei KI_SYSTEM_PROMPT/
|
||
// PLAN_CHECK_SYSTEM_PROMPT gilt: der Wortlaut ist ein Startpunkt, kein
|
||
// Festbetrag — vor dem produktiven Einsatz mit einem echten externen
|
||
// Protokoll bei temperature:0 testen, Änderungen einzeln bewerten (siehe
|
||
// CLAUDE.md "### Protokolle").
|
||
const KI_AUFGABEN_SYSTEM_PROMPT = 'Du bekommst Besprechungspunkte eines Projektprotokolls (je Punkt: ref, thema, '
|
||
+ 'text, zustaendig, termin). Leite daraus konkrete, umsetzbare Aufgaben ab. Mehrere Punkte, die dieselbe Handlung '
|
||
+ 'betreffen, können in einer Aufgabe zusammengefasst werden; rein informative Punkte ohne notwendige Handlung '
|
||
+ 'liefern KEINE Aufgabe. Je Aufgabe: ein kurzer Titel (Tätigkeit zuerst), eine kurze Beschreibung, ein '
|
||
+ 'Fälligkeitsdatum NUR wenn im Text ein konkretes Datum genannt ist (sonst leer lassen, nichts erfinden), ein '
|
||
+ 'Vorschlag für die zuständige Person (wörtlich aus dem Feld "zustaendig" übernommen, nichts erfinden), und die '
|
||
+ 'refs der Punkte, aus denen sie entstand.';
|
||
|
||
// aufgabenTextFuerKi: baut den Nutzer-Content für beide Anbieter aus der
|
||
// Punkteliste — jeder Punkt trägt seine Array-Position als "ref", damit die
|
||
// Antwort referenzierbar ist, ohne dass ein id-Konzept nötig wäre (nichts
|
||
// hier ist persistiert).
|
||
function aufgabenTextFuerKi(punkte){
|
||
return punkte.map((p, i) => `ref ${i}\nThema: ${p.thema || ''}\nText: ${p.text || ''}\n`
|
||
+ `Zuständig: ${p.zustaendig || ''}\nTermin: ${p.termin || ''}`).join('\n\n');
|
||
}
|
||
|
||
async function leiteAufgabenMitChatGPT(punkte, apiKey, apiUrl, model){
|
||
const zielUrl = normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.chatgpt);
|
||
let istLokal = false;
|
||
try { istLokal = istLoopbackHost(new URL(zielUrl).hostname); } catch(e){ /* ungültige URL: bleibt false */ }
|
||
|
||
const body = {
|
||
model: model || 'gpt-4o-mini',
|
||
messages: [
|
||
{ role: 'system', content: KI_AUFGABEN_SYSTEM_PROMPT },
|
||
{ role: 'user', content: aufgabenTextFuerKi(punkte) }
|
||
],
|
||
response_format: { type: 'json_schema', json_schema: { name: 'protokoll_aufgaben', strict: true, schema: KI_AUFGABEN_SCHEMA } },
|
||
// Kleiner als bei der Punkte-Erkennung (erkennePunkteMitChatGPT oben):
|
||
// die Ausgabe ist nur eine Handvoll kurzer Aufgaben, kein ganzes Protokoll.
|
||
max_tokens: 4096,
|
||
temperature: 0
|
||
};
|
||
if(istLokal) body.reasoning_effort = 'none';
|
||
|
||
const res = await fetchMitTimeout(zielUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if(!res.ok){
|
||
const errBody = await res.text().catch(() => '');
|
||
throw new Error('ChatGPT antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : ''));
|
||
}
|
||
const data = await res.json();
|
||
const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
|
||
if(!content) throw new Error('ChatGPT lieferte keine Antwort.');
|
||
let parsed;
|
||
try { parsed = JSON.parse(content); } catch(e){ throw new Error('ChatGPT-Antwort war kein gültiges JSON.'); }
|
||
if(!Array.isArray(parsed.aufgaben)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.');
|
||
return parsed.aufgaben.map(normalisiereAufgabeAusKi);
|
||
}
|
||
|
||
async function leiteAufgabenMitClaude(punkte, apiKey, apiUrl, model){
|
||
const res = await fetchMitTimeout(normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.claude), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
||
body: JSON.stringify({
|
||
model: model || 'claude-haiku-4-5-20251001',
|
||
max_tokens: 4096,
|
||
temperature: 0,
|
||
system: KI_AUFGABEN_SYSTEM_PROMPT,
|
||
messages: [{ role: 'user', content: aufgabenTextFuerKi(punkte) }],
|
||
tools: [{
|
||
name: 'protokoll_aufgaben',
|
||
description: 'Die aus den Besprechungspunkten abgeleiteten Aufgaben.',
|
||
input_schema: KI_AUFGABEN_SCHEMA
|
||
}],
|
||
tool_choice: { type: 'tool', name: 'protokoll_aufgaben' }
|
||
})
|
||
});
|
||
if(!res.ok){
|
||
const errBody = await res.text().catch(() => '');
|
||
throw new Error('Claude antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : ''));
|
||
}
|
||
const data = await res.json();
|
||
const toolUse = Array.isArray(data.content) ? data.content.find(b => b.type === 'tool_use') : null;
|
||
if(!toolUse || !toolUse.input || !Array.isArray(toolUse.input.aufgaben)){
|
||
throw new Error('Claude-Antwort hatte nicht die erwartete Form.');
|
||
}
|
||
return toolUse.input.aufgaben.map(normalisiereAufgabeAusKi);
|
||
}
|
||
|
||
async function leiteAufgabenMitKI(punkte, userId, provider){
|
||
const { apiKey, apiUrl, model } = readUserAiKey(userId, provider);
|
||
if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.');
|
||
if(provider === 'chatgpt') return leiteAufgabenMitChatGPT(punkte, apiKey, apiUrl, model);
|
||
if(provider === 'claude') return leiteAufgabenMitClaude(punkte, apiKey, apiUrl, model);
|
||
throw new Error('Unbekannter Anbieter: ' + provider);
|
||
}
|
||
|
||
// Route 1/2: liest ein hochgeladenes PDF (roh im Body, wie
|
||
// /project-protokolle/import) und erkennt daraus Besprechungspunkte — nutzt
|
||
// DIESELBE Erkennung wie der echte Protokoll-Import (erkennePunkteMitKI()/
|
||
// erkennePunkteAusText()), legt aber NICHTS an: kein project_protokolle-
|
||
// Eintrag, keine Datei auf der Festplatte (anders als der Import, der beides
|
||
// immer persistiert — diese Funktion ist bewusst ein Ad-hoc-Werkzeug ohne
|
||
// Archiv, siehe Kopfkommentar oben). Die erkannten Punkte gehen direkt an
|
||
// den Client zurück und werden von der zweiten Route unten (ohne erneuten
|
||
// Server-Lookup) weiterverarbeitet.
|
||
app.post('/plan-todos-aus-protokoll/einlesen', requireAuth, requireRecht('proj.plan'), express.raw({ type: '*/*', limit: '25mb' }), async (req, res) => {
|
||
try {
|
||
const buf = req.body;
|
||
if(!Buffer.isBuffer(buf) || buf.length === 0){
|
||
return res.status(400).json({ ok: false, error: 'Keine Datei empfangen.' });
|
||
}
|
||
let text;
|
||
try { text = await extrahierePdfText(buf); }
|
||
catch(err){ return res.status(400).json({ ok: false, error: 'Die Datei konnte nicht als PDF gelesen werden.' }); }
|
||
|
||
let punkte, erkennungsart = 'textmuster', erkennungshinweis = '';
|
||
const anbieter = req.query.anbieter;
|
||
if(anbieter === 'chatgpt' || anbieter === 'claude'){
|
||
try {
|
||
const ergebnis = await erkennePunkteMitKI(text, req.user.id, anbieter);
|
||
punkte = ergebnis.punkte;
|
||
erkennungsart = anbieter;
|
||
} catch(err){
|
||
console.warn('KI-Erkennung (TODOs aus Protokoll) fehlgeschlagen, Rückfall auf Textmuster:', err.message);
|
||
erkennungshinweis = err.message;
|
||
punkte = erkennePunkteAusText(text);
|
||
}
|
||
} else {
|
||
punkte = erkennePunkteAusText(text);
|
||
}
|
||
|
||
res.json({ ok: true, punkte, erkennungsart, erkennungshinweis });
|
||
} catch(err){
|
||
console.error('TODOs aus Protokoll (Einlesen) fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route 2/2: leitet aus den (vom Client ausgewählten) Punkten Aufgaben-
|
||
// Vorschläge ab. Die Punkte kommen direkt im Body — kein Bezug zu
|
||
// project_protokolle, siehe Kopfkommentar oben.
|
||
app.post('/plan-todos-aus-protokoll/aufgaben-vorschlag', requireAuth, requireRecht('proj.plan'), async (req, res) => {
|
||
try {
|
||
const { anbieter } = req.body || {};
|
||
const punkte = Array.isArray((req.body || {}).punkte)
|
||
? req.body.punkte.filter(p => p && typeof p === 'object') : [];
|
||
if(punkte.length === 0){
|
||
return res.status(400).json({ ok: false, error: 'Keine Punkte ausgewählt.' });
|
||
}
|
||
|
||
let vorschlaege, erkennungsart = 'direkt', erkennungshinweis = '';
|
||
if(anbieter === 'chatgpt' || anbieter === 'claude'){
|
||
try {
|
||
vorschlaege = await leiteAufgabenMitKI(punkte, req.user.id, anbieter);
|
||
erkennungsart = anbieter;
|
||
} catch(err){
|
||
console.warn('KI-Ableitung (TODOs aus Protokoll) fehlgeschlagen, Rückfall auf 1:1-Übernahme:', err.message);
|
||
erkennungshinweis = err.message;
|
||
vorschlaege = null;
|
||
}
|
||
}
|
||
if(!vorschlaege){
|
||
// Direkter Rückfall: je ausgewähltem Punkt ein Vorschlag, derselbe
|
||
// Rückfall-Geist wie erkennePunkteAusText() beim Import.
|
||
vorschlaege = punkte.map((p, i) => ({
|
||
titel: String(p.thema || '').trim() || String(p.text || '').slice(0, 80),
|
||
beschreibung: String(p.text || ''),
|
||
faelligkeit: String(p.termin || ''),
|
||
zustaendigVorschlag: String(p.zustaendig || ''),
|
||
quellRefs: [i]
|
||
}));
|
||
}
|
||
|
||
vorschlaege = vorschlaege.map(v => Object.assign({}, v, { faelligkeit: normalisiereTermin(v.faelligkeit) }));
|
||
res.json({ ok: true, vorschlaege, erkennungsart, erkennungshinweis });
|
||
} catch(err){
|
||
console.error('TODOs aus Protokoll (Aufgaben-Vorschlag) fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// ---------- Termin aus Mail (Drag & Drop in Terminplan/Aufgaben) ----------
|
||
//
|
||
// Eriks Wunsch: Mailtext (markiert oder als ganze .eml-Datei) auf den
|
||
// Terminplan ziehen, eine KI macht daraus Aufgaben. Wie "TODOs aus
|
||
// Protokoll" wird hier NICHTS gespeichert — die Route liefert nur
|
||
// Vorschläge, angelegt wird erst im Browser nach Prüfung im Aufgaben-Panel.
|
||
// Mit dem Mailtext als Notiz der Aufgabe (plan_todos.notiz).
|
||
|
||
// mailWoerterDekodieren: RFC-2047-Wörter in Kopfzeilen ("=?utf-8?B?…?=").
|
||
function mailWoerterDekodieren(s){
|
||
return String(s || '').replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=(\s+(?==\?))?/g, (m, cs, enc, txt) => {
|
||
try {
|
||
const buf = enc.toUpperCase() === 'B'
|
||
? Buffer.from(txt, 'base64')
|
||
: Buffer.from(txt.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
|
||
return mailZeichensatz(buf, cs);
|
||
} catch(e){ return m; }
|
||
});
|
||
}
|
||
|
||
function mailZeichensatz(buf, charset){
|
||
const cs = String(charset || 'utf-8').toLowerCase().replace(/^"|"$/g, '');
|
||
try { return new TextDecoder(cs).decode(buf); }
|
||
catch(e){ return buf.toString(cs.includes('8859') || cs.includes('1252') ? 'latin1' : 'utf8'); }
|
||
}
|
||
|
||
function mailKopfTeilen(roh){
|
||
const trenn = roh.search(/\r?\n\r?\n/);
|
||
const kopfText = trenn < 0 ? roh : roh.slice(0, trenn);
|
||
const rumpf = trenn < 0 ? '' : roh.slice(trenn).replace(/^\r?\n\r?\n/, '');
|
||
const kopf = {};
|
||
kopfText.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/).forEach(z => {
|
||
const i = z.indexOf(':');
|
||
if(i > 0){ const k = z.slice(0, i).trim().toLowerCase(); if(!(k in kopf)) kopf[k] = z.slice(i + 1).trim(); }
|
||
});
|
||
return { kopf, rumpf };
|
||
}
|
||
|
||
function mailKopfParam(wert, name){
|
||
const m = String(wert || '').match(new RegExp(name + '\\s*=\\s*("([^"]*)"|[^;\\s]+)', 'i'));
|
||
return m ? (m[2] !== undefined ? m[2] : m[1]) : '';
|
||
}
|
||
|
||
// mailTeilLesen: liefert {plain, html} eines (ggf. mehrteiligen) Mailteils.
|
||
function mailTeilLesen(roh, tiefe){
|
||
const { kopf, rumpf } = mailKopfTeilen(roh);
|
||
const typ = String(kopf['content-type'] || 'text/plain').toLowerCase();
|
||
if(typ.startsWith('multipart/') && tiefe < 6){
|
||
const grenze = mailKopfParam(kopf['content-type'], 'boundary');
|
||
if(!grenze) return { plain: rumpf, html: '' };
|
||
const teile = rumpf.split('--' + grenze).slice(1).filter(t => !t.startsWith('--'));
|
||
const erg = { plain: '', html: '' };
|
||
teile.forEach(t => {
|
||
const r = mailTeilLesen(t.replace(/^\r?\n/, ''), tiefe + 1);
|
||
if(!erg.plain && r.plain) erg.plain = r.plain;
|
||
if(!erg.html && r.html) erg.html = r.html;
|
||
});
|
||
return erg;
|
||
}
|
||
if(/attachment/i.test(kopf['content-disposition'] || '')) return { plain: '', html: '' };
|
||
if(!typ.startsWith('text/')) return { plain: '', html: '' };
|
||
const enc = String(kopf['content-transfer-encoding'] || '').toLowerCase();
|
||
let buf;
|
||
if(enc === 'base64') buf = Buffer.from(rumpf.replace(/\s+/g, ''), 'base64');
|
||
else if(enc === 'quoted-printable'){
|
||
buf = Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
|
||
} else buf = Buffer.from(rumpf, 'latin1');
|
||
const text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8');
|
||
return typ.startsWith('text/html') ? { plain: '', html: text } : { plain: text, html: '' };
|
||
}
|
||
|
||
function htmlZuText(html){
|
||
return String(html || '')
|
||
.replace(/<(script|style|head)[\s\S]*?<\/\1>/gi, '')
|
||
.replace(/<br\s*\/?>/gi, '\n')
|
||
.replace(/<\/(p|div|li|tr|h\d|blockquote)>/gi, '\n')
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||
.replace(/&#(\d+);/g, (m, n) => String.fromCharCode(Number(n))).replace(/&/g, '&')
|
||
.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||
}
|
||
|
||
// leseEml: .eml-Datei (als Text) → {betreff, absender, datum, text}.
|
||
function leseEml(roh){
|
||
const s = String(roh || '');
|
||
const { kopf } = mailKopfTeilen(s);
|
||
const teil = mailTeilLesen(s, 0);
|
||
let datum = '';
|
||
const d = new Date(kopf['date'] || '');
|
||
if(!isNaN(d)) datum = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||
return {
|
||
betreff: mailWoerterDekodieren(kopf['subject'] || ''),
|
||
absender: mailWoerterDekodieren(kopf['from'] || ''),
|
||
datum,
|
||
text: (teil.plain || htmlZuText(teil.html)).trim()
|
||
};
|
||
}
|
||
|
||
// mailZitateAbschneiden: entfernt zitierte Vorgänger-Mails — sonst griffe
|
||
// die KI Termine aus alten Mails derselben Unterhaltung auf.
|
||
function mailZitateAbschneiden(text){
|
||
const zeilen = String(text || '').replace(/\r\n/g, '\n').split('\n');
|
||
let ende = zeilen.length;
|
||
for(let i = 1; i < zeilen.length; i++){
|
||
const z = zeilen[i].trim();
|
||
const naechste = zeilen.slice(i + 1, i + 5).join('\n');
|
||
if(/^-{2,}\s*(Ursprüngliche Nachricht|Original Message)/i.test(z)
|
||
|| /^(Am|On) .+(schrieb|wrote).*:$/i.test(z)
|
||
|| (/^(Von|From):\s/i.test(z) && /^(Gesendet|Sent|Datum|Date):/im.test(naechste))){
|
||
ende = i; break;
|
||
}
|
||
}
|
||
return zeilen.slice(0, ende).filter(z => !/^\s*>/.test(z)).join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
||
}
|
||
|
||
const KI_MAIL_SCHEMA = {
|
||
type: 'object',
|
||
properties: {
|
||
eintraege: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
art: { type: 'string', enum: ['aufgabe', 'meilenstein'] },
|
||
titel: { type: 'string' },
|
||
start: { type: 'string' },
|
||
ende: { type: 'string' },
|
||
zustaendig: { type: 'array', items: { type: 'string' } },
|
||
aufwandStunden: { type: 'number' },
|
||
zitat: { type: 'string' }
|
||
},
|
||
required: ['art', 'titel', 'start', 'ende', 'zustaendig', 'aufwandStunden', 'zitat'],
|
||
additionalProperties: false
|
||
}
|
||
}
|
||
},
|
||
required: ['eintraege'],
|
||
additionalProperties: false
|
||
};
|
||
|
||
// Wie bei den übrigen KI-Prompts: Startpunkt, vor Änderungen mit echten
|
||
// Mails bei temperature:0 einzeln prüfen (siehe CLAUDE.md "### Protokolle").
|
||
const KI_MAIL_SYSTEM_PROMPT = 'Du bekommst eine E-Mail aus einem Ingenieurbüro und Angaben zum Projekt. Mache daraus '
|
||
+ 'Einträge für den Terminplan des Projekts: je Termin, Frist oder zu erledigender Arbeit einen Eintrag. '
|
||
+ 'art: "meilenstein" für einen festen Stichtag ohne eigene Arbeit (Abgabe, Termin, Freigabe, Besprechung), '
|
||
+ '"aufgabe" für zu erledigende Arbeit. Titel kurz, Tätigkeit zuerst. Datumsangaben als JJJJ-MM-TT; relative Angaben wie "nächsten Freitag" '
|
||
+ 'rechnest du vom Datum der Mail aus. Ein einzelner Termin oder eine Frist: start und ende gleich. '
|
||
+ 'Ohne Datum in der Mail bleiben start und ende leer. zustaendig: nur Namen aus der Liste der Beteiligten, '
|
||
+ 'die in der Mail als verantwortlich genannt sind, sonst leer. aufwandStunden nur wenn genannt, sonst 0. zitat: der Satz aus der Mail, '
|
||
+ 'auf dem der Eintrag beruht. Enthält die Mail nichts für den Terminplan, gib eine leere Liste zurück.';
|
||
|
||
function mailTextFuerKi(m, kontext){
|
||
const teile = [
|
||
'Heute: ' + (kontext.heute || heuteIsoLokal()),
|
||
'Datum der Mail: ' + (m.datum || 'unbekannt'),
|
||
'Projekt: ' + (kontext.projekt || ''),
|
||
'Beteiligte: ' + ((kontext.personen || []).join(', ') || 'keine'),
|
||
'Leistungsphasen im Terminplan: ' + ((kontext.phasen || []).map(p => `${p.lph} ${p.start}–${p.ende}`).join('; ') || 'keine'),
|
||
'',
|
||
'Von: ' + (m.absender || ''),
|
||
'Betreff: ' + (m.betreff || ''),
|
||
'',
|
||
m.text
|
||
];
|
||
return teile.join('\n');
|
||
}
|
||
|
||
function normalisiereMailEintrag(e){
|
||
const iso = v => { const s = normalisiereTermin(v); return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : ''; };
|
||
let start = iso(e && e.start), ende = iso(e && e.ende);
|
||
if(start && !ende) ende = start;
|
||
if(ende && !start) start = ende;
|
||
if(start && ende && ende < start){ const t = start; start = ende; ende = t; }
|
||
return {
|
||
art: (e && e.art) === 'meilenstein' ? 'meilenstein' : 'aufgabe',
|
||
titel: String((e && e.titel) || '').trim().slice(0, 200),
|
||
start, ende,
|
||
zustaendig: Array.isArray(e && e.zustaendig) ? e.zustaendig.map(String).filter(Boolean) : [],
|
||
aufwandStunden: Math.max(0, Number(e && e.aufwandStunden) || 0),
|
||
zitat: String((e && e.zitat) || '').trim().slice(0, 500)
|
||
};
|
||
}
|
||
|
||
async function mailEintraegeMitKI(m, kontext, userId, provider){
|
||
const { apiKey, apiUrl, model } = readUserAiKey(userId, provider);
|
||
if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.');
|
||
const inhalt = mailTextFuerKi(m, kontext);
|
||
let liste;
|
||
if(provider === 'chatgpt'){
|
||
const zielUrl = normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.chatgpt);
|
||
let istLokal = false;
|
||
try { istLokal = istLoopbackHost(new URL(zielUrl).hostname); } catch(e){ /* bleibt false */ }
|
||
const body = {
|
||
model: model || 'gpt-4o-mini',
|
||
messages: [{ role: 'system', content: KI_MAIL_SYSTEM_PROMPT }, { role: 'user', content: inhalt }],
|
||
response_format: { type: 'json_schema', json_schema: { name: 'terminplan_eintraege', strict: true, schema: KI_MAIL_SCHEMA } },
|
||
max_tokens: 4096,
|
||
temperature: 0
|
||
};
|
||
if(istLokal) body.reasoning_effort = 'none';
|
||
const res = await fetchMitTimeout(zielUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if(!res.ok){
|
||
const t = await res.text().catch(() => '');
|
||
throw new Error('ChatGPT antwortete mit HTTP ' + res.status + (t ? ': ' + t.slice(0, 200) : ''));
|
||
}
|
||
const data = await res.json();
|
||
const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
|
||
let parsed;
|
||
try { parsed = JSON.parse(content || ''); } catch(e){ throw new Error('ChatGPT-Antwort war kein gültiges JSON.'); }
|
||
liste = parsed && parsed.eintraege;
|
||
} else if(provider === 'claude'){
|
||
const res = await fetchMitTimeout(normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.claude), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
||
body: JSON.stringify({
|
||
model: model || 'claude-haiku-4-5-20251001',
|
||
max_tokens: 4096,
|
||
temperature: 0,
|
||
system: KI_MAIL_SYSTEM_PROMPT,
|
||
messages: [{ role: 'user', content: inhalt }],
|
||
tools: [{ name: 'terminplan_eintraege', description: 'Die Einträge für den Terminplan.', input_schema: KI_MAIL_SCHEMA }],
|
||
tool_choice: { type: 'tool', name: 'terminplan_eintraege' }
|
||
})
|
||
});
|
||
if(!res.ok){
|
||
const t = await res.text().catch(() => '');
|
||
throw new Error('Claude antwortete mit HTTP ' + res.status + (t ? ': ' + t.slice(0, 200) : ''));
|
||
}
|
||
const data = await res.json();
|
||
const toolUse = Array.isArray(data.content) ? data.content.find(b => b.type === 'tool_use') : null;
|
||
liste = toolUse && toolUse.input && toolUse.input.eintraege;
|
||
} else {
|
||
throw new Error('Unbekannter Anbieter: ' + provider);
|
||
}
|
||
if(!Array.isArray(liste)) throw new Error('Die KI-Antwort hatte nicht die erwartete Form.');
|
||
return liste.map(normalisiereMailEintrag).filter(e => e.titel);
|
||
}
|
||
|
||
// Body: {anbieter, text?, eml?, kontext:{heute, projekt, personen[], phasen[{lph,start,ende}]}}
|
||
// — eml ist der Inhalt einer .eml-Datei als Text (der Browser liest sie ein).
|
||
app.post('/plan-eintrag-aus-mail', requireAuth, requireRecht('proj.plan'), async (req, res) => {
|
||
try {
|
||
const b = req.body || {};
|
||
const kontext = (b.kontext && typeof b.kontext === 'object') ? b.kontext : {};
|
||
let mail;
|
||
if(typeof b.eml === 'string' && b.eml.trim()) mail = leseEml(b.eml);
|
||
else mail = { betreff: '', absender: '', datum: '', text: String(b.text || '') };
|
||
mail.text = mailZitateAbschneiden(mail.text).slice(0, 15000);
|
||
if(!mail.text && !mail.betreff){
|
||
return res.status(400).json({ ok: false, error: 'In der Mail wurde kein Text gefunden.' });
|
||
}
|
||
|
||
let eintraege = null, erkennungsart = 'ohne', erkennungshinweis = '';
|
||
const anbieter = b.anbieter;
|
||
if(anbieter === 'chatgpt' || anbieter === 'claude'){
|
||
try {
|
||
eintraege = await mailEintraegeMitKI(mail, kontext, req.user.id, anbieter);
|
||
erkennungsart = anbieter;
|
||
} catch(err){
|
||
console.warn('Termin aus Mail: KI fehlgeschlagen:', err.message);
|
||
erkennungshinweis = err.message;
|
||
}
|
||
} else {
|
||
erkennungshinweis = 'Kein KI-Anbieter mit Schlüssel hinterlegt.';
|
||
}
|
||
if(!eintraege){
|
||
// Rückfall: ein leerer Eintrag mit dem Betreff — das Panel öffnet
|
||
// sich trotzdem, Termine trägt man selbst ein.
|
||
const ersteZeile = mail.text.split('\n').map(z => z.trim()).find(Boolean) || '';
|
||
eintraege = [normalisiereMailEintrag({ titel: mail.betreff || ersteZeile.slice(0, 80) })];
|
||
}
|
||
res.json({ ok: true, mail: { betreff: mail.betreff, absender: mail.absender, datum: mail.datum, text: mail.text },
|
||
eintraege, erkennungsart, erkennungshinweis });
|
||
} catch(err){
|
||
console.error('Termin aus Mail fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// ---------- Plan Checker (plan_checker_merkmale) ----------
|
||
//
|
||
// Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt
|
||
// einen Bauplan als PDF hoch und lässt eine KI ihn gegen eine Liste von
|
||
// Merkmalen prüfen (z. B. "Maßketten vorhanden", "Plankopf vollständig").
|
||
// Anders als der Protokoll-Import oben geht es hier nicht um Text, sondern
|
||
// um optische Merkmale einer Zeichnung.
|
||
//
|
||
// Beide Anbieter werden (wieder) auf demselben Weg zugeführt, nach einem
|
||
// Zwischenschritt, der sich als unzureichend erwies: Die allererste Fassung
|
||
// rendere die PDF-Seite IMMER zu einem einzigen PNG und schickte das an
|
||
// beide Anbieter — Erik meldete daraufhin "extrem unzuverlässige" Erkennung
|
||
// von Maßketten, auch ein deutlich stärkeres Modell besserte das nicht, was
|
||
// auf ein Auflösungsproblem statt eine Modellgrenze hindeutete. Als
|
||
// Zwischenschritt bekam Claude testweise das PDF DIREKT (document-
|
||
// Content-Block, in der Annahme, Anthropics eigene Dokumenten-Verarbeitung
|
||
// löse Auflösung/Aufteilung besser) — half laut Erik aber nicht
|
||
// durchgreifend genug: ein erneuter Test zeigte den Plankopf zwar jetzt
|
||
// gefunden, aber Projektname, Planersteller, Maßstab, Datum und Plannummer
|
||
// blieben "in der vorliegenden Auflösung" nicht sämtlich lesbar. Das
|
||
// bestätigte den eigentlichen Engpass: JEDER Anbieter skaliert ein
|
||
// übermitteltes Bild bzw. eine PDF-Seite intern auf eine eigene Obergrenze
|
||
// herunter, egal auf welchem Weg es ankommt — ein einzelnes Bild der ganzen
|
||
// Seite kann Feinschrift in einem kleinen Blattausschnitt (wie dem Plankopf)
|
||
// deshalb grundsätzlich nicht zuverlässig lesbar machen.
|
||
//
|
||
// Jetzt rendern WIR beide Zuführungen selbst (renderPdfAlsBildsatz() weiter
|
||
// oben, aufgerufen aus pruefePlanMitKI() unten): eine Übersicht der ganzen
|
||
// Seite PLUS mehrere überlappende, einzeln hochaufgelöste Ausschnitte -
|
||
// sowohl an ChatGPT (als mehrere image_url-Blöcke) als auch an Claude (als
|
||
// mehrere image-Blöcke) auf dieselbe Weise geschickt. ChatGPT (über die hier
|
||
// genutzte Chat-Completions-API) sowie jeder lokale, über dieselbe
|
||
// Schnittstelle angesprochene Server (LM Studio, Ollama) oder Drittanbieter
|
||
// (z. B. Mammouth.ai) kennen in dieser Schnittstelle ohnehin grundsätzlich
|
||
// kein PDF-Format - nur Bilder; OpenAIs neuere "Responses API" könnte PDFs
|
||
// zwar direkt lesen, wird aber von lokalen Servern und den meisten
|
||
// Drittanbietern nicht nachgebildet, eine Umstellung darauf würde also genau
|
||
// die lokale/Drittanbieter-Nutzung brechen, die ausdrücklich mit abgedeckt
|
||
// sein soll, und bleibt deshalb aus.
|
||
//
|
||
// Nutzt dieselben Konten-Schlüssel (user_ai_keys, readUserAiKey() oben) wie
|
||
// der Protokoll-Import — keine eigene Schlüsselverwaltung nötig.
|
||
//
|
||
// Nichts wird dauerhaft gespeichert: weder das hochgeladene PDF noch die
|
||
// daraus gerenderten Bilder landen auf der Festplatte (anders als
|
||
// /project-files oder /project-protokolle/import, die beide immer
|
||
// persistieren, siehe dort) — das Ergebnis geht direkt in der Antwort
|
||
// zurück. Es gibt bewusst (noch) keinen Prüf-Verlauf: Das ist ein
|
||
// Ad-hoc-Werkzeug, kein Dokumentenarchiv.
|
||
|
||
// Bildunabhängig formuliert ("Planzeichnung" statt "Bild"), da beide
|
||
// Anbieter mehrere Bilder (Übersicht + Kacheln) derselben Zeichnung
|
||
// bekommen, nicht nur eines.
|
||
const PLAN_CHECK_SYSTEM_PROMPT = 'Du prüfst eine technische Planzeichnung (Architektur/TGA) auf das Vorhandensein '
|
||
+ 'bestimmter Merkmale. Du bekommst eine nummerierte Liste von Merkmalen mit Prüfanweisung. Gib für JEDES Merkmal aus der '
|
||
+ 'Liste genau einen Eintrag zurück, mit dem Titel exakt wie angegeben. "erfuellt" ist nur dann true, wenn das Merkmal auf '
|
||
+ 'der Zeichnung eindeutig zu erkennen ist - im Zweifel false. "begruendung" ist HÖCHSTENS 150 ZEICHEN LANG - eine feste '
|
||
+ 'Obergrenze, kein Stilwunsch. Zähle NIEMALS einzelne Werte auf (keine Maßzahlen, keine Höhenangaben, keine '
|
||
+ 'Koordinaten) - auch nicht als Beispiel ("z. B. ...") und auch nicht, wenn auf der Zeichnung sehr viele davon zu sehen '
|
||
+ 'sind: beschreibe stattdessen in einem knappen Satz, DASS und WO solche Angaben vorkommen. Erfinde nichts.';
|
||
// Wie bei KI_SYSTEM_PROMPT oben gilt: Formulierungsänderungen können das
|
||
// Ergebnis stark verschieben (siehe dortige Projekthistorie). Der erste
|
||
// Zusatz ("NIE eine Aufzählung einzelner Werte", ohne Zeichengrenze) kam nach
|
||
// einem echten Fehlschlag dazu: Ein lokal über LM Studio angebundenes Modell
|
||
// zählte für "Maßketten vorhanden" jede einzelne gefundene Maßzahl einzeln
|
||
// auf ("2,50 m, 3,12 m, 1,20 m, 2,10 m, ..."), wodurch die Antwort noch vor
|
||
// Abschluss des JSON an max_tokens (s. u.) abgeschnitten wurde und als Ganzes
|
||
// nicht mehr parsbar war ("ChatGPT-Antwort war kein gültiges JSON"). Reichte
|
||
// demselben Modell beim nächsten Fehlschlag aber IMMER NOCH nicht: Es zählte
|
||
// diesmal sogar "1,00, 2,00, 3,00 ... 22,00" durch und sprengte damit auch
|
||
// das inzwischen angehobene max_tokens erneut. Eine semantische Anweisung
|
||
// ("halt dich kurz") lässt sich offenbar umgehen, eine harte Zahl
|
||
// ("höchstens 150 Zeichen") ist für ein Modell mechanisch leichter zu prüfen
|
||
// - ebenfalls noch nicht an diesem konkreten Fehlschlag gegengetestet, siehe
|
||
// Hinweis bei max_tokens.
|
||
|
||
// PLAN_CHECK_BILD_ANZAHL: Gesamtzahl der an die KI gesendeten Bilder (1
|
||
// Übersicht + die Kacheln, s. renderPdfAlsBildsatz() oben) - bestimmt den
|
||
// gültigen Wertebereich für "bildIndex" im Schema direkt unten. Von Hand
|
||
// synchron zu PLAN_CHECK_KACHEL_SPALTEN/-ZEILEN gehalten statt daraus
|
||
// berechnet, damit die enum-Liste unten als lesbare Literalliste im Schema
|
||
// steht (für strict-JSON-Schema bei OpenAI ohnehin nötig).
|
||
const PLAN_CHECK_BILD_ANZAHL = 1 + PLAN_CHECK_KACHEL_SPALTEN * PLAN_CHECK_KACHEL_ZEILEN;
|
||
|
||
const PLAN_CHECK_SCHEMA = {
|
||
type: 'object',
|
||
properties: {
|
||
ergebnisse: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
merkmal: { type: 'string' },
|
||
erfuellt: { type: 'boolean' },
|
||
begruendung: { type: 'string' },
|
||
// bildIndex: Eriks Wunsch nach einem Screenshot je Merkmal, NACH
|
||
// einem ersten Versuch mit pixelgenauen Bild-Ausschnitten
|
||
// (Beschriftungsraster + Zuschnitt einer benannten Zelle), der sich
|
||
// in der Praxis als nicht gut genug erwies ("das mit den
|
||
// Bildausschnitten hat nicht gut funktioniert") - deutlich
|
||
// einfacher und robuster: die KI wählt nur noch AUS den bereits
|
||
// gesendeten Bildern dasjenige, das den Nachweis für das Merkmal
|
||
// am besten zeigt (0 = Übersicht, 1..N = die Kacheln in der
|
||
// gesendeten Reihenfolge). Reine Auswahl aus einer festen, kleinen
|
||
// Liste statt einer selbst zu bestimmenden Koordinate oder
|
||
// Zellenbezeichnung - und der Server muss dafür NICHTS mehr
|
||
// nachträglich rendern, die Bilder liegen schon als Buffer vor
|
||
// (s. pruefePlanMitKI() weiter unten).
|
||
bildIndex: { type: 'integer', enum: Array.from({ length: PLAN_CHECK_BILD_ANZAHL }, (_, i) => i) }
|
||
},
|
||
required: ['merkmal', 'erfuellt', 'begruendung', 'bildIndex'],
|
||
additionalProperties: false
|
||
}
|
||
}
|
||
},
|
||
required: ['ergebnisse'],
|
||
additionalProperties: false
|
||
};
|
||
|
||
// baueMerkmalePrompt: formt die Merkmale-Liste (Grundliste + optionales
|
||
// Zusatz-Merkmal nur für diese eine Prüfung, siehe POST /plan-checker-pruefen)
|
||
// in den Anweisungstext für die KI. Parameter: merkmale (Array {titel, prompt}).
|
||
// Rückgabe: String. Erklärt zusätzlich die Bildreihenfolge (Übersicht zuerst,
|
||
// dann die hochaufgelösten Kachel-Ausschnitte, s. renderPdfAlsBildsatz()
|
||
// oben) - ohne diesen Hinweis hat das Modell keinen Grund, bei mehreren
|
||
// Bildern über dieselbe Zeichnung gezielt nach Feinschrift in den hinteren
|
||
// Bildern zu suchen, statt sich nur auf das erste zu verlassen. Erklärt
|
||
// zusätzlich bildIndex (s. PLAN_CHECK_SCHEMA oben).
|
||
function baueMerkmalePrompt(merkmale){
|
||
const zeilen = merkmale.map((m, i) => (i + 1) + '. ' + m.titel + ': ' + m.prompt);
|
||
return 'Prüfe die Planzeichnung auf folgende Merkmale:\n\n' + zeilen.join('\n')
|
||
+ '\n\nDu bekommst mehrere Bilder derselben Zeichnung, nummeriert von 0: Bild 0 ist eine Übersicht der ganzen '
|
||
+ 'Seite, die Bilder 1 bis ' + (PLAN_CHECK_BILD_ANZAHL - 1) + ' sind sich überlappende Ausschnitte in höherer '
|
||
+ 'Auflösung. Nutze die Ausschnitte gezielt, um kleine Details wie Maßzahlen oder Plankopf-Angaben zu lesen, die '
|
||
+ 'auf der Übersicht zu klein sind.'
|
||
+ '\n\nGib zu jedem Merkmal zusätzlich unter "bildIndex" die Nummer DESJENIGEN Bildes an, auf dem der '
|
||
+ 'Hauptnachweis für dieses Merkmal am besten zu erkennen ist. Lässt sich kein einzelnes Bild sinnvoll '
|
||
+ 'bevorzugen, gib 0 (die Übersicht) an.';
|
||
}
|
||
|
||
// pruefePlanMitChatGPT: bilderBase64 ist ein Array (Übersicht zuerst, dann
|
||
// die Kachel-Ausschnitte, s. renderPdfAlsBildsatz() weiter oben) - jedes
|
||
// Bild wird als eigener image_url-Block gesendet, in derselben Reihenfolge,
|
||
// die baueMerkmalePrompt() dem Modell textlich erklärt.
|
||
async function pruefePlanMitChatGPT(bilderBase64, merkmale, apiKey, apiUrl, model){
|
||
const zielUrl = normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.chatgpt);
|
||
let istLokal = false;
|
||
try { istLokal = istLoopbackHost(new URL(zielUrl).hostname); } catch(e){ /* ungültige URL: bleibt false */ }
|
||
|
||
const bildBloecke = bilderBase64.map(b64 => ({
|
||
// detail:'high' statt des Vorgabewerts 'auto' — erzwingt die
|
||
// feinteiligere, gekachelte Bildverarbeitung von OpenAI für JEDES der
|
||
// gesendeten Bilder. Ohne das kann das Modell bei einer dichten
|
||
// technischen Zeichnung von sich aus die gröbere Verarbeitung wählen,
|
||
// wodurch kleine Maßzahlen und Plankopf-Text unlesbar werden — genau das
|
||
// Merkmal, auf das hier geprüft wird.
|
||
type: 'image_url', image_url: { url: 'data:image/png;base64,' + b64, detail: 'high' }
|
||
}));
|
||
|
||
const body = {
|
||
// gpt-4o statt gpt-4o-mini als VORGABE (anders als beim Protokoll-Import
|
||
// oben, der reinen Fließtext liest): Erik meldete, dass die Erkennung
|
||
// "extrem unzuverlässig" war - bei temperature:0 (s.u.) deutet
|
||
// schwankendes Ergebnis für dieselbe Zeichnung auf eine Fähigkeitsgrenze
|
||
// des Modells hin, nicht auf Prompt/Rendering (siehe auch die
|
||
// Protokoll-Import-Erfahrung weiter oben: "ein stärkeres Modell hätte
|
||
// mehr Spielraum - das bleibt eine Modell-Fähigkeits-Frage, keine
|
||
// Prompt-Frage"). Feine Maßzahlen auf einer dichten technischen
|
||
// Zeichnung zuverlässig zu lesen ist eine deutlich anspruchsvollere
|
||
// Bildaufgabe als Fließtext aus einem Besprechungsprotokoll zu ziehen.
|
||
// Konto-Einstellung (user_ai_keys.model) überschreibt auch diese Vorgabe,
|
||
// wenn gesetzt - nötig für einen eigenständigen Anbieter mit
|
||
// abweichendem Modell-Katalog (z. B. Mammouth.ai).
|
||
model: model || 'gpt-4o',
|
||
messages: [
|
||
{ role: 'system', content: PLAN_CHECK_SYSTEM_PROMPT },
|
||
{ role: 'user', content: [
|
||
{ type: 'text', text: baueMerkmalePrompt(merkmale) },
|
||
...bildBloecke
|
||
] }
|
||
],
|
||
response_format: { type: 'json_schema', json_schema: { name: 'plan_pruefung', strict: true, schema: PLAN_CHECK_SCHEMA } },
|
||
// 4096 war zu knapp, dann 8192 - ein lokal angebundenes Modell hielt sich
|
||
// trotz zunehmend schärferer Anweisung (s. PLAN_CHECK_SYSTEM_PROMPT oben,
|
||
// Projekthistorie dort) wiederholt nicht an die Kürze, und die Antwort
|
||
// wurde mitten im JSON abgeschnitten. 16000 angeglichen an das Limit, das
|
||
// sich für genau dasselbe Problem beim Protokoll-Import bereits bewährt
|
||
// hat (erkennePunkteMitChatGPT() oben) - weiterhin nur ein Sicherheitsnetz
|
||
// zusätzlich zur Prompt-Anweisung, keine Garantie: Ein Modell, das
|
||
// entschlossen genug aufzählt, kann auch dieses Limit erreichen.
|
||
max_tokens: 16000,
|
||
temperature: 0
|
||
};
|
||
// Siehe erkennePunkteMitChatGPT() oben: manche lokale Server laden
|
||
// standardmäßig ein Reasoning-Modell, das vor jeder Antwort "denkt" und
|
||
// damit jeden Timeout sprengen kann — nur für lokale Ziele abgeschaltet.
|
||
if(istLokal) body.reasoning_effort = 'none';
|
||
|
||
const res = await fetchMitTimeout(zielUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if(!res.ok){
|
||
const errBody = await res.text().catch(() => '');
|
||
throw new Error('ChatGPT antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : ''));
|
||
}
|
||
const data = await res.json();
|
||
const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
|
||
if(!content) throw new Error('ChatGPT lieferte keine Antwort.');
|
||
// Manche lokalen Server (LM Studio, Ollama) oder Modelle halten sich nicht
|
||
// strikt an response_format:json_schema und umschließen die Antwort
|
||
// trotzdem mit einem Markdown-Codeblock ("```json\n...\n```"); das reine
|
||
// JSON innerhalb des Blocks wird dann aussortiert, bevor geparst wird.
|
||
const bereinigt = content.trim().replace(/^```[a-zA-Z]*\s*/, '').replace(/```\s*$/, '');
|
||
let parsed;
|
||
try { parsed = JSON.parse(bereinigt); } catch(e){
|
||
// finish_reason:'length' heißt konkret "max_tokens erreicht, bevor das
|
||
// Modell fertig war" - der häufigste real beobachtete Grund für
|
||
// ungültiges JSON hier (ein Modell, das trotz PLAN_CHECK_SYSTEM_PROMPT
|
||
// einzelne Werte aufzählt, s. dortige Projekthistorie). Eine konkrete
|
||
// Fehlermeldung dafür erspart das erneute Einschicken des rohen
|
||
// Antworttexts zur Diagnose. Rohtext zusätzlich im Fehler, wie bei den
|
||
// HTTP-Fehlern oben (errBody.slice(0, 200)) - andere Scheitern-Gründe
|
||
// (z. B. ein Modell, das nur EIN Bild pro Anfrage verarbeiten kann und
|
||
// bei mehreren Bildern mit Fließtext statt JSON antwortet) bleiben so
|
||
// trotzdem sichtbar.
|
||
const abgeschnitten = data.choices[0].finish_reason === 'length';
|
||
const hinweis = abgeschnitten
|
||
? 'Die Antwort wurde abgeschnitten (Modell hat das Token-Limit erreicht, bevor es fertig war - vermutlich zu ausführliche Begründungen). '
|
||
: '';
|
||
throw new Error('ChatGPT-Antwort war kein gültiges JSON: ' + hinweis + content.slice(0, 300));
|
||
}
|
||
if(!Array.isArray(parsed.ergebnisse)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.');
|
||
return parsed.ergebnisse;
|
||
}
|
||
|
||
// pruefePlanMitClaude: bekam zwischenzeitlich das PDF DIREKT
|
||
// (document-Content-Block) statt eines von uns gerenderten Bildes - Grund
|
||
// damals: Erik meldete, dass die Erkennung von Maßketten "extrem
|
||
// unzuverlässig" war, auch nach dem Wechsel von Haiku auf das deutlich
|
||
// stärkere Sonnet 5 KEINE Verbesserung, was gegen eine Modell-
|
||
// Fähigkeitsgrenze und FÜR ein Auflösungsproblem sprach (Claude skaliert laut
|
||
// Anthropic-Dokumentation auf ca. 1568px lange Kante bzw. ~1,15 Megapixel
|
||
// herunter). Die PDF-Direktübergabe half laut Erik aber NICHT durchgreifend
|
||
// genug: ein erneuter Test mit korrekt erkanntem Plankopf zeigte trotzdem
|
||
// "in der vorliegenden Auflösung" nicht sämtlich lesbare Feinschrift
|
||
// (Planersteller, Maßstab, Datum, Plannummer) - vermutlich, weil Anthropics
|
||
// eigene Dokumenten-Verarbeitung eine PDF-Seite intern ebenfalls erst zu
|
||
// einem einzigen Bild wandelt und DANN derselben Auflösungsgrenze
|
||
// unterliegt. bilderBase64 ist jetzt wie bei pruefePlanMitChatGPT() ein Array
|
||
// (Übersicht + hochaufgelöste Kachel-Ausschnitte, renderPdfAlsBildsatz()
|
||
// weiter oben) - dasselbe, selbst kontrollierte Verfahren für beide
|
||
// Anbieter, statt erneut auf unbekanntes anbieterinternes Verhalten zu
|
||
// vertrauen.
|
||
async function pruefePlanMitClaude(bilderBase64, merkmale, apiKey, apiUrl, model){
|
||
const bildBloecke = bilderBase64.map(b64 => ({
|
||
type: 'image', source: { type: 'base64', media_type: 'image/png', data: b64 }
|
||
}));
|
||
const res = await fetchMitTimeout(normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.claude), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
||
body: JSON.stringify({
|
||
// claude-sonnet-5 statt claude-haiku-4-5-20251001 als VORGABE (anders
|
||
// als beim Protokoll-Import oben), auch wenn das allein die
|
||
// Unzuverlässigkeit NICHT behoben hat (s.o.) - ein stärkeres Modell
|
||
// bleibt trotzdem die richtige Vorgabe für diese anspruchsvolle
|
||
// Bildaufgabe. Konto-Einstellung überschreibt auch diese Vorgabe.
|
||
model: model || 'claude-sonnet-5',
|
||
// 16000 - dasselbe Sicherheitsnetz, auf denselben Wert angehoben wie bei
|
||
// pruefePlanMitChatGPT() oben (Projekthistorie dort), selbes Risiko gilt
|
||
// grundsätzlich auch hier (eine zu ausführliche Begründung, die vor
|
||
// Abschluss des JSON abgeschnitten wird).
|
||
max_tokens: 16000,
|
||
temperature: 0,
|
||
system: PLAN_CHECK_SYSTEM_PROMPT,
|
||
messages: [{ role: 'user', content: [
|
||
...bildBloecke,
|
||
{ type: 'text', text: baueMerkmalePrompt(merkmale) }
|
||
] }],
|
||
tools: [{
|
||
name: 'plan_pruefung',
|
||
description: 'Das Prüfergebnis je Merkmal.',
|
||
input_schema: PLAN_CHECK_SCHEMA
|
||
}],
|
||
tool_choice: { type: 'tool', name: 'plan_pruefung' }
|
||
})
|
||
});
|
||
if(!res.ok){
|
||
const errBody = await res.text().catch(() => '');
|
||
throw new Error('Claude antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : ''));
|
||
}
|
||
const data = await res.json();
|
||
const toolUse = Array.isArray(data.content) ? data.content.find(b => b.type === 'tool_use') : null;
|
||
if(!toolUse || !toolUse.input || !Array.isArray(toolUse.input.ergebnisse)){
|
||
throw new Error('Claude-Antwort hatte nicht die erwartete Form.');
|
||
}
|
||
return toolUse.input.ergebnisse;
|
||
}
|
||
|
||
// pruefePlanMitKI: rendert den Bildsatz (Übersicht + Kachel-Ausschnitte,
|
||
// renderPdfAlsBildsatz() weiter oben) jetzt EINMAL, GEMEINSAM für beide
|
||
// Anbieter — anders als zuvor, als nur ChatGPT ein eigenes Rendering bekam
|
||
// und Claude das PDF direkt (siehe Projekthistorie bei pruefePlanMitClaude()
|
||
// oben). Reihenfolge des zurückgegebenen Arrays: Übersicht zuerst, danach
|
||
// die Kacheln in Zeilen-/Spaltenreihenfolge — dieselbe Reihenfolge, die
|
||
// baueMerkmalePrompt() dem Modell textlich erklärt und die bildIndex im
|
||
// Ergebnis referenziert.
|
||
//
|
||
// Ergänzt NACH der KI-Antwort pro Ergebnis ein "screenshot"-Feld (Data-URL
|
||
// oder null) — Eriks Wunsch, im Plan-Checker-Ergebnis zu jedem Merkmal ein
|
||
// Bild zu zeigen. Ein erster Versuch (Beschriftungsraster auf der Übersicht
|
||
// + nachträglicher Zuschnitt einer von der KI genannten Rasterzelle) erwies
|
||
// sich in der Praxis als nicht gut genug ("das mit den Bildausschnitten hat
|
||
// nicht gut funktioniert") und wurde durch diese einfachere Variante
|
||
// ersetzt: Der Screenshot ist exakt eines der bereits gerenderten Bilder
|
||
// (bildsatz.uebersicht oder eine der bildsatz.kacheln), ausgewählt allein
|
||
// über den von der KI gelieferten bildIndex — KEIN zweiter Render-Durchlauf
|
||
// mehr nötig, die Bilder liegen als Buffer schon vor.
|
||
async function pruefePlanMitKI(pdfBuf, merkmale, userId, provider){
|
||
const { apiKey, apiUrl, model } = readUserAiKey(userId, provider);
|
||
if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.');
|
||
if(provider !== 'claude' && provider !== 'chatgpt') throw new Error('Unbekannter Anbieter: ' + provider);
|
||
|
||
let bildsatz;
|
||
try {
|
||
bildsatz = await renderPdfAlsBildsatz(pdfBuf);
|
||
} catch(err){
|
||
console.error('PDF-Rendering fehlgeschlagen:', err);
|
||
throw new Error('Die PDF-Datei konnte nicht gelesen werden.');
|
||
}
|
||
const bilder = [bildsatz.uebersicht, ...bildsatz.kacheln];
|
||
const bilderBase64 = bilder.map(b => b.toString('base64'));
|
||
|
||
const ergebnisse = provider === 'claude'
|
||
? await pruefePlanMitClaude(bilderBase64, merkmale, apiKey, apiUrl, model)
|
||
: await pruefePlanMitChatGPT(bilderBase64, merkmale, apiKey, apiUrl, model);
|
||
|
||
// Number.isInteger()-Prüfung statt blindem Vertrauen auf das Schema: ein
|
||
// Drittanbieter/lokaler Server hält sich nicht immer an response_format
|
||
// (s. Projekthistorie bei pruefePlanMitChatGPT() - derselbe Grund, aus dem
|
||
// dort schon Markdown-Codeblöcke toleriert werden). Ein fehlender/
|
||
// unbrauchbarer bildIndex liefert einfach keinen Screenshot statt die
|
||
// Prüfung scheitern zu lassen.
|
||
for(const e of ergebnisse){
|
||
const idx = e.bildIndex;
|
||
e.screenshot = Number.isInteger(idx) && idx >= 0 && idx < bilder.length
|
||
? 'data:image/png;base64,' + bilder[idx].toString('base64')
|
||
: null;
|
||
}
|
||
|
||
return ergebnisse;
|
||
}
|
||
|
||
// textZuSicheremHtml: wandelt reinen Klartext (PDF/Word-Extraktion oder
|
||
// KI-Erkennung, erkennePunkteAusText()/erkennePunkteMitKI() oben) in
|
||
// dasselbe sanitizeStatusHtml()-taugliche HTML, das ein von Hand getippter
|
||
// Protokoll-Punkt über sein contenteditable-Feld ohnehin schon erzeugt
|
||
// (richtextFeldHtml(), public/js/utils.js) — protokoll_punkte.text gilt seit
|
||
// der Umstellung auf contenteditable (Eriks Wunsch nach echter statt mit
|
||
// Sternchen markierter Fett-/Kursivschrift) client-seitig immer als
|
||
// bereits-sicheres HTML; ohne dieses Escaping würde ein im Originaldokument
|
||
// vorkommendes "<"/">" beim Rendern als Markup fehlinterpretiert statt als
|
||
// Text angezeigt. Gegenstück zu esc() in public/js/utils.js.
|
||
function textZuSicheremHtml(s){
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''')
|
||
.replace(/\r\n|\r|\n/g, '<br>');
|
||
}
|
||
|
||
function legeProtokollAn(opts){
|
||
const {
|
||
projectNr, projectName, weekLabel, datum, betreff, ort, verfasserUserId,
|
||
teilnehmer, zusatzPunkte, quelleDateiname, quelleStoredName
|
||
} = opts;
|
||
|
||
const id = crypto.randomUUID();
|
||
const now = new Date().toISOString();
|
||
let nummer = 1;
|
||
|
||
db.exec('BEGIN');
|
||
try {
|
||
const maxRow = db.prepare('SELECT MAX(nummer) AS m FROM project_protokolle WHERE project_nr = ?').get(projectNr);
|
||
nummer = (maxRow && maxRow.m) ? maxRow.m + 1 : 1;
|
||
|
||
db.prepare(`INSERT INTO project_protokolle
|
||
(id, project_nr, project_name, nummer, datum, betreff, ort, verfasser_user_id, week_label,
|
||
quelle_dateiname, quelle_stored_name, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||
.run(id, projectNr, projectName, nummer, String(datum), String(betreff || ''), String(ort || ''),
|
||
verfasserUserId, weekLabel, String(quelleDateiname || ''), String(quelleStoredName || ''), now, now);
|
||
|
||
const insTeilnehmer = db.prepare(`INSERT INTO protokoll_teilnehmer
|
||
(id, protokoll_id, name, firma, anwesend, array_order) VALUES (?, ?, ?, ?, ?, ?)`);
|
||
(Array.isArray(teilnehmer) ? teilnehmer : []).forEach((t, idx) => {
|
||
const name = String((t && t.name) || '').trim();
|
||
if(!name) return;
|
||
insTeilnehmer.run(crypto.randomUUID(), id, name, String((t && t.firma) || ''), (t && t.anwesend) ? 1 : 0, idx);
|
||
});
|
||
|
||
const insPunkt = db.prepare(`INSERT INTO protokoll_punkte
|
||
(id, protokoll_id, stamm_id, nummer, gruppe, neu, thema, text, zustaendig, termin, erledigt, erstellt_am, status, entfaellt_naechstes, array_order)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||
let order = 0;
|
||
|
||
const prevRow = db.prepare(`SELECT id FROM project_protokolle
|
||
WHERE project_nr = ? AND id != ? ORDER BY nummer DESC LIMIT 1`).get(projectNr, id);
|
||
if(prevRow){
|
||
// Fortschreiben (Eriks Wunsch, Nachtrag zur ursprünglichen Regel "nur
|
||
// offen läuft weiter"): 'offen' und 'info' laufen unbegrenzt weiter;
|
||
// 'erledigt' bekommt noch GENAU EINE Gnadenrunde — eine Kopie, die
|
||
// selbst entfaellt_naechstes=0 trägt (also hier zum ersten Mal als
|
||
// erledigt auftaucht oder zwischenzeitlich von Hand wieder auf
|
||
// erledigt gesetzt wurde), läuft noch einmal mit; eine Kopie, die
|
||
// bereits entfaellt_naechstes=1 trägt (schon einmal fortgeschrieben),
|
||
// bleibt hier liegen. Die neue Kopie selbst bekommt entfaellt_naechstes
|
||
// NUR gesetzt, wenn ihre Quelle 'erledigt' war — das ist der Hinweis
|
||
// "entfällt im nächsten Protokoll" in genau diesem einen Protokoll;
|
||
// offen/info-Kopien tragen 0 und laufen so unbegrenzt weiter.
|
||
const vorherigePunkte = db.prepare(`SELECT * FROM protokoll_punkte
|
||
WHERE protokoll_id = ? ORDER BY array_order`).all(prevRow.id);
|
||
const fortzuschreiben = vorherigePunkte.filter(p =>
|
||
p.status === 'offen' || p.status === 'info' || (p.status === 'erledigt' && !p.entfaellt_naechstes));
|
||
// erstellt_am wandert UNVERÄNDERT mit (wie stamm_id) — ein fortgeschriebener
|
||
// Punkt wurde hier nicht neu erstellt, nur übernommen.
|
||
fortzuschreiben.forEach(p => {
|
||
insPunkt.run(crypto.randomUUID(), id, p.stamm_id, p.nummer, p.gruppe, 0, p.thema, p.text, p.zustaendig, p.termin,
|
||
p.status === 'erledigt' ? 1 : 0, p.erstellt_am || '', p.status, p.status === 'erledigt' ? 1 : 0, order++);
|
||
});
|
||
}
|
||
|
||
// Zusätzlich erkannte/übergebene Punkte (Import) — immer neu=1, frische
|
||
// stamm_id, hinter den fortgeschriebenen Punkten einsortiert. erstellt_am
|
||
// ist das Datum DIESES Protokolls — hier entstehen diese Punkte zum
|
||
// ersten Mal im System, unabhängig davon, ob das importierte Dokument
|
||
// selbst älter ist. status kommt bei der KI-Erkennung direkt vom Modell
|
||
// (normalisierePunktAusKi() hat ihn schon gegen VALID_PROTOKOLL_PUNKT_STATUS
|
||
// geprüft); die Textmuster-Heuristik (erkennePunkteAusText()) kennt
|
||
// weiterhin nur das ältere, boolesche erledigt (kein 'info' — sie liest
|
||
// reinen Regex-Textmustern, keine Bedeutung) — dafür hier der Rückfall
|
||
// auf das alte Verhalten, falls kein gültiger status mitkommt.
|
||
(Array.isArray(zusatzPunkte) ? zusatzPunkte : []).forEach(p => {
|
||
const status = VALID_PROTOKOLL_PUNKT_STATUS.has(p.status) ? p.status : (p.erledigt ? 'erledigt' : 'offen');
|
||
// entfaellt_naechstes=0: ein importierter/neu erkannter Punkt taucht
|
||
// hier zum ersten Mal auf, unabhängig vom erkannten status — selbst
|
||
// ein als 'erledigt' erkannter Punkt bekommt dadurch noch seine
|
||
// Gnadenrunde im nächsten Protokoll, statt sofort stillschweigend zu
|
||
// verschwinden.
|
||
insPunkt.run(crypto.randomUUID(), id, crypto.randomUUID(), String(p.nummer || ''), String(p.gruppe || ''), 1,
|
||
String(p.thema || ''), textZuSicheremHtml(p.text || ''), String(p.zustaendig || ''), String(p.termin || ''),
|
||
status === 'erledigt' ? 1 : 0, String(datum), status, 0, order++);
|
||
});
|
||
|
||
db.exec('COMMIT');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
|
||
console.log('Protokoll angelegt:', projectNr, '- Nr.', nummer);
|
||
return id;
|
||
}
|
||
|
||
app.post('/project-protokolle', requireAuth, requireRecht('proj.protokolle'), (req, res) => {
|
||
try {
|
||
const projectNr = String(req.query.projectNr || '');
|
||
if(!projectNr) return res.status(400).json({ ok: false, error: 'Projekt hat keine Projektnummer.' });
|
||
const { datum, betreff, ort, teilnehmer } = req.body || {};
|
||
if(!datum) return res.status(400).json({ ok: false, error: 'Datum erforderlich.' });
|
||
|
||
const id = legeProtokollAn({
|
||
projectNr, projectName: String(req.query.projectName || ''), weekLabel: String(req.query.weekLabel || ''),
|
||
datum, betreff, ort, verfasserUserId: req.user.id, teilnehmer, zusatzPunkte: []
|
||
});
|
||
res.json({ ok: true, protokoll: loadProtokollDetail(id) });
|
||
} catch(err){
|
||
console.error('Protokoll anlegen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: externes Protokoll hochladen und automatisch in Punkte umwandeln.
|
||
// Raw-Binär-Body wie POST /project-files. Extrahiert den Text (PDF via
|
||
// pdf-parse, Word via mammoth), teilt ihn heuristisch in Punkte auf
|
||
// (erkennePunkteAusText()) und legt darüber ein ganz normales Protokoll an
|
||
// (legeProtokollAn() — dieselbe Fortschreiben-Logik wie beim manuellen
|
||
// Anlegen; die erkannten Punkte kommen zusätzlich dazu, als neu=1). Die
|
||
// Original-Datei bleibt unter UPLOADS_DIR gespeichert und über
|
||
// GET /project-protokolle/:id/quelldatei abrufbar — Erkennung ist ein
|
||
// Entwurf, keine Garantie, siehe CLAUDE.md "### Protokolle".
|
||
app.post('/project-protokolle/import', requireAuth, requireRecht('proj.protokolle'), express.raw({ type: '*/*', limit: '25mb' }), async (req, res) => {
|
||
try {
|
||
const projectNr = String(req.query.projectNr || '');
|
||
if(!projectNr) return res.status(400).json({ ok: false, error: 'Projekt hat keine Projektnummer.' });
|
||
if(!req.query.datum) return res.status(400).json({ ok: false, error: 'Datum erforderlich.' });
|
||
|
||
const buf = req.body;
|
||
if(!Buffer.isBuffer(buf) || buf.length === 0){
|
||
return res.status(400).json({ ok: false, error: 'Keine Datei empfangen.' });
|
||
}
|
||
|
||
let filename = '';
|
||
try { filename = decodeURIComponent(String(req.query.filename || '')); }
|
||
catch(e){ filename = String(req.query.filename || ''); }
|
||
const ext = extractFileExtension(filename);
|
||
|
||
let text = '';
|
||
if(ext !== 'pdf' && ext !== 'docx'){
|
||
return res.status(400).json({ ok: false, error: 'Nur PDF- und Word-Dateien (.pdf, .docx) werden unterstützt.' });
|
||
}
|
||
// Eine beschädigte Datei ist ein Eingabefehler (400), kein Serverfehler.
|
||
try {
|
||
if(ext === 'pdf') text = await extrahierePdfText(buf);
|
||
else text = ((await mammoth.extractRawText({ buffer: buf })).value) || '';
|
||
} catch(err){
|
||
return res.status(400).json({ ok: false, error: 'Die Datei konnte nicht gelesen werden (beschädigt oder kein echtes ' + ext.toUpperCase() + ').' });
|
||
}
|
||
|
||
// Teilnehmer kommen NUR aus der KI-Erkennung (siehe KI_SYSTEM_PROMPT) —
|
||
// erkennePunkteAusText() ist reines Regex und kann keine Teilnehmerliste
|
||
// von Fließtext unterscheiden, deshalb bleibt teilnehmerAusDokument beim
|
||
// Textmuster-Pfad und bei jedem KI-Fehlschlag (Rückfall) leer; die
|
||
// Person trägt sie dann wie schon bisher von Hand in der Detailansicht
|
||
// nach. Bewusst KEIN Merge mit dem letzten Protokoll dieses Projekts
|
||
// (anders als neuesProtokoll()'s Vorbelegung) — der Import soll genau
|
||
// zeigen, was im hochgeladenen Dokument steht, nicht zwei Quellen
|
||
// vermischen.
|
||
let zusatzPunkte, teilnehmerAusDokument = [], erkennungsart = 'textmuster', erkennungshinweis = '';
|
||
const gewuenschterAnbieter = req.query.erkennung;
|
||
if(gewuenschterAnbieter === 'chatgpt' || gewuenschterAnbieter === 'claude'){
|
||
try {
|
||
const ergebnis = await erkennePunkteMitKI(text, req.user.id, gewuenschterAnbieter);
|
||
zusatzPunkte = ergebnis.punkte;
|
||
teilnehmerAusDokument = ergebnis.teilnehmer;
|
||
erkennungsart = gewuenschterAnbieter;
|
||
} catch(err){
|
||
console.warn('KI-Erkennung fehlgeschlagen, Rückfall auf Textmuster:', err.message);
|
||
erkennungshinweis = err.message;
|
||
zusatzPunkte = erkennePunkteAusText(text);
|
||
}
|
||
} else {
|
||
zusatzPunkte = erkennePunkteAusText(text);
|
||
}
|
||
|
||
const storedName = crypto.randomUUID() + (ext ? '.' + ext : '');
|
||
fs.writeFileSync(path.join(uploadDirFor(), storedName), buf);
|
||
|
||
const id = legeProtokollAn({
|
||
projectNr, projectName: String(req.query.projectName || ''), weekLabel: String(req.query.weekLabel || ''),
|
||
datum: req.query.datum, betreff: req.query.betreff, ort: req.query.ort,
|
||
verfasserUserId: req.user.id, teilnehmer: teilnehmerAusDokument, zusatzPunkte,
|
||
quelleDateiname: filename || storedName, quelleStoredName: storedName
|
||
});
|
||
|
||
console.log('Protokoll importiert:', projectNr, '-', zusatzPunkte.length, 'Punkte,', teilnehmerAusDokument.length, 'Teilnehmer erkannt aus', filename, '(' + erkennungsart + ')');
|
||
res.json({ ok: true, protokoll: loadProtokollDetail(id), erkannteAnzahl: zusatzPunkte.length,
|
||
erkannteTeilnehmerAnzahl: teilnehmerAusDokument.length, erkennungsart, erkennungshinweis });
|
||
} catch(err){
|
||
console.error('Protokoll-Import fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Original-Datei eines importierten Protokolls herunterladen —
|
||
// Gegenstück zu GET /project-files/:id/download. 404, wenn das Protokoll
|
||
// nicht importiert wurde (quelle_stored_name leer, siehe legeProtokollAn()).
|
||
app.get('/project-protokolle/:id/quelldatei', requireAuth, (req, res) => {
|
||
try {
|
||
const row = db.prepare('SELECT quelle_dateiname, quelle_stored_name FROM project_protokolle WHERE id = ?').get(req.params.id);
|
||
if(!row || !row.quelle_stored_name){
|
||
return res.status(404).json({ ok: false, error: 'Keine Quelldatei vorhanden.' });
|
||
}
|
||
res.download(path.join(UPLOADS_DIR, row.quelle_stored_name), row.quelle_dateiname || row.quelle_stored_name);
|
||
} catch(err){
|
||
console.error('Quelldatei-Download fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Protokoll speichern — ersetzt Teilnehmer- und Punkte-Zeilen komplett
|
||
// (löschen + neu einfügen, wie deleteWeekData()/serializeWeek()). nummer,
|
||
// gruppe, thema, text, zustaendig, termin, erledigt sind reine Anzeige-/
|
||
// Bearbeitungsfelder und werden immer vom Client übernommen. stamm_id/neu
|
||
// dagegen NIE vom Client vertraut: für einen Punkt mit bekannter id werden
|
||
// sie aus der bisherigen Zeile übernommen, ein Punkt ohne id ist neu
|
||
// (frische stamm_id, neu=1).
|
||
app.put('/project-protokolle/:id', requireAuth, requireRecht('proj.protokolle'), (req, res) => {
|
||
try {
|
||
const row = db.prepare('SELECT * FROM project_protokolle WHERE id = ?').get(req.params.id);
|
||
if(!row) return res.status(404).json({ ok: false, error: 'Protokoll nicht gefunden.' });
|
||
const { datum, betreff, ort, teilnehmer, punkte } = req.body || {};
|
||
if(!datum) return res.status(400).json({ ok: false, error: 'Datum erforderlich.' });
|
||
|
||
const now = new Date().toISOString();
|
||
db.exec('BEGIN');
|
||
try {
|
||
db.prepare('UPDATE project_protokolle SET datum = ?, betreff = ?, ort = ?, updated_at = ? WHERE id = ?')
|
||
.run(String(datum), String(betreff || ''), String(ort || ''), now, row.id);
|
||
|
||
db.prepare('DELETE FROM protokoll_teilnehmer WHERE protokoll_id = ?').run(row.id);
|
||
const insTeilnehmer = db.prepare(`INSERT INTO protokoll_teilnehmer
|
||
(id, protokoll_id, name, firma, anwesend, array_order) VALUES (?, ?, ?, ?, ?, ?)`);
|
||
(Array.isArray(teilnehmer) ? teilnehmer : []).forEach((t, idx) => {
|
||
const name = String((t && t.name) || '').trim();
|
||
if(!name) return;
|
||
insTeilnehmer.run(crypto.randomUUID(), row.id, name, String((t && t.firma) || ''), (t && t.anwesend) ? 1 : 0, idx);
|
||
});
|
||
|
||
const bestehende = new Map();
|
||
db.prepare('SELECT id, stamm_id, neu, erstellt_am, entfaellt_naechstes FROM protokoll_punkte WHERE protokoll_id = ?').all(row.id)
|
||
.forEach(p => bestehende.set(p.id, p));
|
||
|
||
db.prepare('DELETE FROM protokoll_punkte WHERE protokoll_id = ?').run(row.id);
|
||
const insPunkt = db.prepare(`INSERT INTO protokoll_punkte
|
||
(id, protokoll_id, stamm_id, nummer, gruppe, neu, thema, text, zustaendig, termin, erledigt, erstellt_am, status, entfaellt_naechstes, array_order)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||
// erstellt_am ist wie stamm_id/neu serverseitig maßgeblich, nie vom
|
||
// Client übernommen: ein bekannter Punkt behält sein ursprüngliches
|
||
// Datum, ein neu hinzugefügter bekommt das Datum DIESES Protokolls
|
||
// (der gerade gespeicherte `datum`-Wert oben) — der Moment, in dem er
|
||
// zum ersten Mal gespeichert wird. status wird dagegen VOM CLIENT
|
||
// übernommen (offen/erledigt/info, über ein <select> gewählt) — anders
|
||
// als neu/stamm_id/erstellt_am ist das kein Herkunfts-, sondern ein
|
||
// jederzeit frei änderbares Inhaltsfeld, wie thema oder zustaendig.
|
||
// erledigt bleibt als reine Ableitung von status bestehen (siehe
|
||
// migrateAddProtokollPunktStatus()). entfaellt_naechstes ist wie
|
||
// stamm_id/neu/erstellt_am serverseitig maßgeblich und NIE vom Client
|
||
// übernommen — es ist kein frei editierbares Inhaltsfeld, sondern wird
|
||
// ausschließlich von legeProtokollAn() beim Fortschreiben gesetzt
|
||
// (siehe dort); ein hier bearbeiteter bekannter Punkt behält also
|
||
// seinen bisherigen Wert unverändert, ein neuer startet bei 0.
|
||
(Array.isArray(punkte) ? punkte : []).forEach((p, idx) => {
|
||
const bekannt = p && p.id ? bestehende.get(p.id) : null;
|
||
const pid = bekannt ? bekannt.id : crypto.randomUUID();
|
||
const stammId = bekannt ? bekannt.stamm_id : crypto.randomUUID();
|
||
const neu = bekannt ? bekannt.neu : 1;
|
||
const erstelltAm = bekannt ? (bekannt.erstellt_am || '') : String(datum);
|
||
const entfaelltNaechstes = bekannt ? bekannt.entfaellt_naechstes : 0;
|
||
const status = VALID_PROTOKOLL_PUNKT_STATUS.has(p && p.status) ? p.status : 'offen';
|
||
// text trägt wie der Wochen-Kommentar Formatierungs-HTML und wird
|
||
// an der Schreibstelle bereinigt (sanitizeFormatHtml()); thema und
|
||
// zustaendig sind reine Textfelder und werden beim Anzeigen escaped.
|
||
insPunkt.run(pid, row.id, stammId, String((p && p.nummer) || ''), String((p && p.gruppe) || ''), neu,
|
||
String((p && p.thema) || ''), sanitizeFormatHtml(p && p.text), String((p && p.zustaendig) || ''),
|
||
String((p && p.termin) || ''), status === 'erledigt' ? 1 : 0, erstelltAm, status, entfaelltNaechstes, idx);
|
||
});
|
||
|
||
db.exec('COMMIT');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
|
||
res.json({ ok: true, protokoll: loadProtokollDetail(row.id) });
|
||
} catch(err){
|
||
console.error('Protokoll speichern fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Protokoll löschen — Auth genügt (kein admin-only), dieselbe
|
||
// Berechtigungsstufe wie das Löschen einer Aufgabe/eines Meilensteins.
|
||
// Idempotent wie DELETE /users/:id/absences/:absenceId (kein Fehler, wenn
|
||
// bereits gelöscht).
|
||
app.delete('/project-protokolle/:id', requireAuth, requireRecht('proj.protokolle'), (req, res) => {
|
||
try {
|
||
const row = db.prepare('SELECT id, quelle_stored_name FROM project_protokolle WHERE id = ?').get(req.params.id);
|
||
if(!row) return res.json({ ok: true });
|
||
db.exec('BEGIN');
|
||
try {
|
||
db.prepare('DELETE FROM protokoll_punkte WHERE protokoll_id = ?').run(row.id);
|
||
db.prepare('DELETE FROM protokoll_teilnehmer WHERE protokoll_id = ?').run(row.id);
|
||
db.prepare('DELETE FROM project_protokolle WHERE id = ?').run(row.id);
|
||
db.exec('COMMIT');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
// Die Quelldatei eines importierten Protokolls gleich mit entfernen —
|
||
// sonst bliebe sie ohne jeden Verweis in sessions/uploads liegen.
|
||
if(row.quelle_stored_name){
|
||
fs.unlink(path.join(uploadDirFor(), path.basename(row.quelle_stored_name)), () => {});
|
||
}
|
||
console.log('Protokoll gelöscht:', row.id);
|
||
res.json({ ok: true });
|
||
} catch(err){
|
||
console.error('Protokoll löschen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// ---------- Aufgaben-Vorlagen je Leistungsphase (lph_templates) ----------
|
||
|
||
// VALID_LPHS: gültige Leistungsphasen-Schlüssel (muss zu LPHS in
|
||
// public/js/globals.js passen). Dient nur der Eingabeprüfung der
|
||
// Vorlagen-Routen — die Fortschrittswerte in project_lph werden bewusst NICHT
|
||
// gegen diese Liste geprüft (dort sind historisch gewachsene Schlüssel aus
|
||
// Altdaten möglich).
|
||
const VALID_LPHS = new Set(['LPH 1','LPH 2','LPH 3','LPH 4','LPH 5','LPH 6','LPH 7','LPH 8','LPH 9','W+M']);
|
||
|
||
// templateAnteil: Prozentwert eines Vorlagen-Schritts prüfen (Komma oder
|
||
// Punkt, 0–100, drei Nachkommastellen). Rückgabe: Zahl oder null (ungültig).
|
||
function templateAnteil(v){
|
||
if(v === undefined || v === null || v === '') return 0;
|
||
const n = Number(String(v).replace(',', '.'));
|
||
if(!Number.isFinite(n) || n < 0 || n > 100) return null;
|
||
return Math.round(n * 1000) / 1000;
|
||
}
|
||
// templateAnteilEingabe: Eingabe der Spalte „Anteil %“ — eine Zahl, oder
|
||
// „in a)“ / „in a + b“ / „in a u. b“ für einen Schritt, der in anderen
|
||
// enthalten ist (Siemon-Tabelle). Rückgabe: {anteil, enthaltenIn} oder null.
|
||
function templateAnteilEingabe(v){
|
||
const t = String(v === undefined || v === null ? '' : v).trim();
|
||
if(/^in\b/i.test(t)){
|
||
const buchst = (t.slice(2).match(/[a-z]/gi) || []).map(x => x.toLowerCase())
|
||
.filter(x => x !== 'u'); // „u.“ aus „a u. b“
|
||
const enthaltenIn = [...new Set(buchst)].join(',');
|
||
return enthaltenIn ? { anteil: 0, enthaltenIn } : null;
|
||
}
|
||
const anteil = templateAnteil(t);
|
||
return anteil === null ? null : { anteil, enthaltenIn: '' };
|
||
}
|
||
|
||
// serializeTemplateRow: DB-Zeile -> öffentliche JSON-Form.
|
||
function serializeTemplateRow(row){
|
||
return {
|
||
id: row.id,
|
||
lph: row.lph,
|
||
gewerk: row.gewerk || '',
|
||
anteil: Number(row.anteil) || 0,
|
||
enthaltenIn: row.enthalten_in || '',
|
||
title: row.title,
|
||
offsetDays: row.offset_days,
|
||
durationDays: row.duration_days,
|
||
sortOrder: row.sort_order,
|
||
};
|
||
}
|
||
|
||
// Route: alle Vorlagen lesen (jeder angemeldete Nutzer darf Vorlagen
|
||
// einfügen, daher nur requireAuth — nur das Pflegen ist Admin-Sache).
|
||
app.get('/lph-templates', requireAuth, (req, res) => {
|
||
try {
|
||
const rows = db.prepare('SELECT * FROM lph_templates ORDER BY lph, gewerk, sort_order, title').all();
|
||
res.json({ ok: true, templates: rows.map(serializeTemplateRow) });
|
||
} catch(err){
|
||
console.error('Vorlagen laden fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: neue Vorlagen-Aufgabe anlegen. Admin-only (firmenweite Konfiguration).
|
||
app.post('/lph-templates', requireRecht('a.vorlagen'), (req, res) => {
|
||
try {
|
||
const { lph, gewerk, title, offsetDays, durationDays, anteil } = req.body || {};
|
||
if(!VALID_LPHS.has(String(lph))){
|
||
return res.status(400).json({ ok: false, error: 'Unbekannte Leistungsphase.' });
|
||
}
|
||
if(!VALID_DEPARTMENTS.has(String(gewerk))){
|
||
return res.status(400).json({ ok: false, error: 'Unbekanntes Gewerk.' });
|
||
}
|
||
const cleanTitle = String(title || '').trim();
|
||
if(!cleanTitle) return res.status(400).json({ ok: false, error: 'Titel fehlt.' });
|
||
const offset = Number.isFinite(Number(offsetDays)) ? Math.max(0, Math.trunc(Number(offsetDays))) : 0;
|
||
// Dauer 0 = ohne Termin: die Aufgabe wird ohne Start/Ende eingefügt.
|
||
const duration = Number.isFinite(Number(durationDays)) ? Math.max(0, Math.trunc(Number(durationDays))) : 1;
|
||
// sort_order: ans Ende der Liste dieser Leistungsphase und dieses Gewerks
|
||
const maxRow = db.prepare('SELECT MAX(sort_order) AS m FROM lph_templates WHERE lph = ? AND gewerk = ?').get(String(lph), String(gewerk));
|
||
const sortOrder = (maxRow && Number.isFinite(maxRow.m) ? maxRow.m : -1) + 1;
|
||
const ein = templateAnteilEingabe(anteil);
|
||
if(ein === null) return res.status(400).json({ ok: false, error: 'Anteil: eine Zahl von 0 bis 100 oder „in a)“.' });
|
||
const id = crypto.randomUUID();
|
||
db.prepare(`INSERT INTO lph_templates (id, lph, gewerk, title, offset_days, duration_days, sort_order, anteil, enthalten_in)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, String(lph), String(gewerk), cleanTitle, offset, duration, sortOrder, ein.anteil, ein.enthaltenIn);
|
||
res.json({ ok: true, template: serializeTemplateRow({
|
||
id, lph: String(lph), gewerk: String(gewerk), title: cleanTitle, offset_days: offset, duration_days: duration, sort_order: sortOrder,
|
||
anteil: ein.anteil, enthalten_in: ein.enthaltenIn
|
||
})});
|
||
} catch(err){
|
||
console.error('Vorlage anlegen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Vorlagen-Schritt ändern (Titel, Start-Versatz, Dauer). Admin-only.
|
||
// Nur übergebene Felder werden geändert. Bereits eingefügte Aufgaben bleiben
|
||
// unberührt (siehe Tabellen-Kommentar bei lph_templates).
|
||
app.patch('/lph-templates/:id', requireRecht('a.vorlagen'), (req, res) => {
|
||
try {
|
||
const row = db.prepare('SELECT * FROM lph_templates WHERE id = ?').get(String(req.params.id));
|
||
if(!row) return res.status(404).json({ ok: false, error: 'Schritt nicht gefunden.' });
|
||
const b = req.body || {};
|
||
let title = row.title, offset = row.offset_days, duration = row.duration_days, anteil = Number(row.anteil) || 0,
|
||
enthaltenIn = row.enthalten_in || '';
|
||
if(b.anteil !== undefined){
|
||
const ein = templateAnteilEingabe(b.anteil);
|
||
if(ein === null) return res.status(400).json({ ok: false, error: 'Anteil: eine Zahl von 0 bis 100 oder „in a)“.' });
|
||
anteil = ein.anteil; enthaltenIn = ein.enthaltenIn;
|
||
}
|
||
if(b.title !== undefined){
|
||
title = String(b.title || '').trim();
|
||
if(!title) return res.status(400).json({ ok: false, error: 'Titel fehlt.' });
|
||
}
|
||
if(b.offsetDays !== undefined){
|
||
if(!Number.isFinite(Number(b.offsetDays))) return res.status(400).json({ ok: false, error: 'Start ungültig.' });
|
||
offset = Math.max(0, Math.trunc(Number(b.offsetDays)));
|
||
}
|
||
if(b.durationDays !== undefined){
|
||
if(!Number.isFinite(Number(b.durationDays))) return res.status(400).json({ ok: false, error: 'Dauer ungültig.' });
|
||
duration = Math.max(0, Math.trunc(Number(b.durationDays))); // 0 = ohne Termin
|
||
}
|
||
db.prepare('UPDATE lph_templates SET title = ?, offset_days = ?, duration_days = ?, anteil = ?, enthalten_in = ? WHERE id = ?')
|
||
.run(title, offset, duration, anteil, enthaltenIn, row.id);
|
||
res.json({ ok: true, template: serializeTemplateRow({ ...row, title, offset_days: offset, duration_days: duration, anteil, enthalten_in: enthaltenIn }) });
|
||
} catch(err){
|
||
console.error('Vorlage ändern fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Vorlagen-Schritt um eine Stelle nach oben (richtung -1) oder unten
|
||
// (+1) verschieben — innerhalb derselben Leistungsphase und desselben
|
||
// Gewerks. Nummeriert die Liste dabei lückenlos neu. Admin-only.
|
||
app.post('/lph-templates/:id/verschieben', requireRecht('a.vorlagen'), (req, res) => {
|
||
try {
|
||
const row = db.prepare('SELECT * FROM lph_templates WHERE id = ?').get(String(req.params.id));
|
||
if(!row) return res.status(404).json({ ok: false, error: 'Schritt nicht gefunden.' });
|
||
const richtung = Number((req.body || {}).richtung) < 0 ? -1 : 1;
|
||
const liste = db.prepare('SELECT id FROM lph_templates WHERE lph = ? AND gewerk = ? ORDER BY sort_order, title')
|
||
.all(row.lph, row.gewerk).map(r => r.id);
|
||
const i = liste.indexOf(row.id), j = i + richtung;
|
||
if(j >= 0 && j < liste.length){
|
||
[liste[i], liste[j]] = [liste[j], liste[i]];
|
||
const upd = db.prepare('UPDATE lph_templates SET sort_order = ? WHERE id = ?');
|
||
db.exec('BEGIN');
|
||
try { liste.forEach((id, k) => upd.run(k, id)); db.exec('COMMIT'); }
|
||
catch(e){ db.exec('ROLLBACK'); throw e; }
|
||
}
|
||
res.json({ ok: true });
|
||
} catch(err){
|
||
console.error('Vorlage verschieben fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Vorlagen-Aufgabe löschen. Admin-only. Idempotent (bereits gelöscht
|
||
// ist kein Fehler). Bereits eingefügte TODOs bleiben unberührt (siehe
|
||
// Tabellen-Kommentar bei lph_templates).
|
||
app.delete('/lph-templates/:id', requireRecht('a.vorlagen'), (req, res) => {
|
||
try {
|
||
db.prepare('DELETE FROM lph_templates WHERE id = ?').run(String(req.params.id));
|
||
res.json({ ok: true });
|
||
} catch(err){
|
||
console.error('Vorlage löschen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// ---------- Plan-Checker-Merkmale (plan_checker_merkmale) ----------
|
||
|
||
function serializeMerkmalRow(row){
|
||
return { id: row.id, titel: row.titel, prompt: row.prompt, sortOrder: row.sort_order };
|
||
}
|
||
|
||
// Route: alle Merkmale lesen (jeder angemeldete Nutzer braucht die Liste, um
|
||
// zu sehen, was geprüft wird — nur das Pflegen ist Admin-Sache).
|
||
app.get('/plan-checker-merkmale', requireAuth, (req, res) => {
|
||
try {
|
||
const rows = db.prepare('SELECT * FROM plan_checker_merkmale ORDER BY sort_order, titel').all();
|
||
res.json({ ok: true, merkmale: rows.map(serializeMerkmalRow) });
|
||
} catch(err){
|
||
console.error('Merkmale laden fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: neues Merkmal anlegen. Admin-only (firmenweite Konfiguration).
|
||
app.post('/plan-checker-merkmale', requireRecht('a.checker'), (req, res) => {
|
||
try {
|
||
const { titel, prompt } = req.body || {};
|
||
const cleanTitel = String(titel || '').trim();
|
||
const cleanPrompt = String(prompt || '').trim();
|
||
if(!cleanTitel) return res.status(400).json({ ok: false, error: 'Titel fehlt.' });
|
||
if(!cleanPrompt) return res.status(400).json({ ok: false, error: 'Prüfanweisung fehlt.' });
|
||
const maxRow = db.prepare('SELECT MAX(sort_order) AS m FROM plan_checker_merkmale').get();
|
||
const sortOrder = (maxRow && Number.isFinite(maxRow.m) ? maxRow.m : -1) + 1;
|
||
const id = crypto.randomUUID();
|
||
db.prepare('INSERT INTO plan_checker_merkmale (id, titel, prompt, sort_order) VALUES (?, ?, ?, ?)')
|
||
.run(id, cleanTitel, cleanPrompt, sortOrder);
|
||
res.json({ ok: true, merkmal: { id, titel: cleanTitel, prompt: cleanPrompt, sortOrder } });
|
||
} catch(err){
|
||
console.error('Merkmal anlegen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Merkmal löschen. Admin-only. Idempotent.
|
||
app.delete('/plan-checker-merkmale/:id', requireRecht('a.checker'), (req, res) => {
|
||
try {
|
||
db.prepare('DELETE FROM plan_checker_merkmale WHERE id = ?').run(String(req.params.id));
|
||
res.json({ ok: true });
|
||
} catch(err){
|
||
console.error('Merkmal löschen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: einen Plan prüfen. Roh-Binärkörper wie /project-protokolle/import
|
||
// (siehe RAW_BODY_ROUTES oben) — provider und ein optionales, nur für DIESE
|
||
// Prüfung geltendes Zusatz-Merkmal kommen als Query-Parameter, da der Body
|
||
// vollständig die Datei ist. Weder die PDF- noch die daraus gerenderten
|
||
// Bilddateien werden gespeichert (siehe Erläuterung bei pruefePlanMitKI()
|
||
// oben). Das Rendering zum Bildsatz (Übersicht + Kacheln) passiert NICHT
|
||
// hier, sondern innerhalb von pruefePlanMitKI() — für beide Anbieter gleich.
|
||
app.post('/plan-checker-pruefen', requireAuth, requireRecht('wz.checker'), express.raw({ type: '*/*', limit: '25mb' }), async (req, res) => {
|
||
try {
|
||
const buf = req.body;
|
||
if(!Buffer.isBuffer(buf) || buf.length === 0){
|
||
return res.status(400).json({ ok: false, error: 'Keine Datei empfangen.' });
|
||
}
|
||
// Einfache Format-Prüfung über die PDF-Magic-Bytes statt einer
|
||
// Dateiendung (die Endung kommt hier aus keinem Formularfeld, der Body
|
||
// IST die Datei) — ein PDF beginnt immer mit "%PDF".
|
||
if(buf.slice(0, 4).toString('latin1') !== '%PDF'){
|
||
return res.status(400).json({ ok: false, error: 'Nur PDF-Dateien werden unterstützt.' });
|
||
}
|
||
const provider = String(req.query.provider || '');
|
||
if(provider !== 'chatgpt' && provider !== 'claude'){
|
||
return res.status(400).json({ ok: false, error: 'Unbekannter KI-Anbieter.' });
|
||
}
|
||
|
||
const merkmale = db.prepare('SELECT titel, prompt FROM plan_checker_merkmale ORDER BY sort_order, titel').all();
|
||
|
||
// Projekt-Abgleich (Eriks Wunsch): optional, genau wie das Zusatz-Merkmal
|
||
// weiter unten nur für DIESE eine Prüfung und nicht gespeichert - kein
|
||
// neues Feld in plan_checker_merkmale, sondern derselbe Mechanismus, nur
|
||
// mit einem serverseitig gebauten statt frei getipptem Text. Das Projekt
|
||
// selbst kommt aus dem bereits beim Client geladenen db.weeks (die
|
||
// Auswahl im Frontend liest currentWeek().projects, kein eigener
|
||
// Endpunkt) - der Server bekommt nur Nummer/Bezeichnung als Query-Param,
|
||
// prüft sie aber NICHT gegen die Datenbank (reine Texteingabe für den
|
||
// Prompt, wie das Zusatz-Merkmal auch). Bewusst nur Projektnummer +
|
||
// Bezeichnung: Das sind die einzigen Projekt-Stammdaten, die es im
|
||
// Datenmodell überhaupt gibt - ein Plankopf kennt zusätzlich
|
||
// Planersteller, Maßstab, Plannummer, Datum, aber dafür gibt es keine
|
||
// gespeicherte Vorgabe, gegen die man abgleichen könnte.
|
||
const projektNr = String(req.query.projektNr || '').trim();
|
||
const projektName = String(req.query.projektName || '').trim();
|
||
if(projektNr || projektName){
|
||
const soll = [
|
||
projektNr ? `Projektnummer "${projektNr}"` : null,
|
||
projektName ? `Projektbezeichnung "${projektName}"` : null
|
||
].filter(Boolean).join(' und ');
|
||
// Erste Fassung fragte direkt "stimmt der Plankopf mit X überein?" -
|
||
// ein echter Fehlschlag zeigte, dass das zu leicht als "bestätige X"
|
||
// statt "lies ab und vergleiche dann" gelesen wird: Die KI gab exakt
|
||
// die vorgegebenen Werte als angeblich abgelesene Werte zurück und
|
||
// erklärte sie für übereinstimmend, obwohl im Plankopf tatsächlich eine
|
||
// andere Nummer stand (Erik hat das am echten Ergebnis nachgewiesen).
|
||
// Jetzt zweistufig erzwungen: ERST ablesen UND DAS GELESENE in der
|
||
// Begründung wörtlich nennen, DANN erst vergleichen - macht die
|
||
// Vorgabewerte nicht Teil der "Lese"-Aufgabe und macht einen
|
||
// Bestätigungsfehler wie diesen für einen Menschen beim Lesen der
|
||
// Begründung sofort erkennbar (genau wie Erik ihn gefunden hat).
|
||
merkmale.push({
|
||
titel: 'Plankopf stimmt mit Projekt überein',
|
||
prompt: 'Lies zuerst die im Plankopf (Schriftfeld) TATSÄCHLICH abgedruckte Projektnummer und/oder '
|
||
+ 'Projektbezeichnung ab - unabhängig davon, was du gleich vergleichen sollst. Nenne diese tatsächlich '
|
||
+ 'abgelesenen Werte als ERSTES in der Begründung, wörtlich wie abgedruckt. Vergleiche ERST DANACH, ob '
|
||
+ `diese abgelesenen Werte zu ${soll} passen. Sinngemäße Übereinstimmung genügt (Abkürzungen, leicht `
|
||
+ 'abweichende Schreibweise, zusätzliche Wörter), eine erkennbar ANDERE Nummer oder ein erkennbar anderes '
|
||
+ 'Projekt dagegen nicht. "erfuellt" ist nur dann true, wenn die TATSÄCHLICH abgelesenen Werte passen - '
|
||
+ 'nicht schon deshalb, weil das erwartete Projekt plausibel klingt. Ist die Angabe im Plankopf nicht '
|
||
+ 'lesbar, sage das, statt die erwarteten Werte zu wiederholen.'
|
||
});
|
||
}
|
||
|
||
const zusatz = String(req.query.zusatzMerkmal || '').trim();
|
||
if(zusatz) merkmale.push({ titel: zusatz, prompt: zusatz });
|
||
if(merkmale.length === 0){
|
||
return res.status(400).json({ ok: false, error: 'Keine Merkmale zum Prüfen hinterlegt.' });
|
||
}
|
||
|
||
try {
|
||
const ergebnisse = await pruefePlanMitKI(buf, merkmale, req.user.id, provider);
|
||
res.json({ ok: true, ergebnisse });
|
||
} catch(err){
|
||
// Ein fehlgeschlagener KI-Aufruf (kein Schlüssel, Netzwerk, Zeitüber-
|
||
// schreitung, unerwartete Antwort) ist ein gültiges, anzuzeigendes
|
||
// Ergebnis — kein Serverfehler (gleiches Muster wie POST
|
||
// /my-ai-settings/test).
|
||
res.json({ ok: false, error: String(err && err.message || err) });
|
||
}
|
||
} catch(err){
|
||
console.error('Plan-Prüfung fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// ---------- Honorar-Konfiguration (Anteile je LPH + Stundensatz) ----------
|
||
|
||
// ---------- Firmenweite Einstellungen (app_settings) ----------
|
||
//
|
||
// APP_SETTING_DEFS ist die EINE Stelle, an der ein Schlüssel definiert wird:
|
||
// Vorgabewert, Typ und Prüfung. readAppSettings() liefert daraus stets ein
|
||
// vollständiges Objekt (fehlende oder unbrauchbare Zeilen fallen auf die
|
||
// Vorgabe zurück), PUT /app-settings schreibt nur bekannte Schlüssel.
|
||
//
|
||
// Alle Werte hier waren vorher Konstanten im Code — teils im Server
|
||
// (Backups, Sitzungsdauer, Anmeldebremse), teils im Frontend (Aufwands-
|
||
// vorlage, Ampel-Schwellen, Balkenskala, Arbeitsstunden je Tag). Sie sind
|
||
// nicht deshalb hierher gewandert, weil sie sich oft ändern, sondern weil
|
||
// sie die Zahlen ALLER Nutzer verschieben und ihre Pflege damit eine
|
||
// Administrationsaufgabe ist, kein Eingriff in den Quelltext.
|
||
|
||
// DEFAULT_AUFWAND_VORLAGE: firmenweite Gewichtung je Leistungsphase und
|
||
// Rolle (0,0–1,0). Sie greift für jedes Projekt, dessen Aufwandsmaske für
|
||
// diese Phase/Rolle keinen eigenen Wert trägt — also für die meisten. LPH 9
|
||
// steht bewusst auf 0: die Faktoren sind fachlich nicht abgestimmt, und ein
|
||
// erfundener Wert flösse unbemerkt in die Auslastung ein.
|
||
const DEFAULT_AUFWAND_VORLAGE = {
|
||
'LPH 1': { 'Planung': 0.4, 'Projektleitung': 0.2, 'Bauüberwachung': 0 },
|
||
'LPH 2': { 'Planung': 0.4, 'Projektleitung': 0.2, 'Bauüberwachung': 0 },
|
||
'LPH 3': { 'Planung': 1.0, 'Projektleitung': 0.3, 'Bauüberwachung': 0 },
|
||
'LPH 4': { 'Planung': 0.0, 'Projektleitung': 0.3, 'Bauüberwachung': 0 },
|
||
'LPH 5': { 'Planung': 1.0, 'Projektleitung': 0.4, 'Bauüberwachung': 0 },
|
||
'LPH 6': { 'Planung': 0.2, 'Projektleitung': 0.7, 'Bauüberwachung': 0.3 },
|
||
'LPH 7': { 'Planung': 0.1, 'Projektleitung': 0.3, 'Bauüberwachung': 0.5 },
|
||
'LPH 8': { 'Planung': 0, 'Projektleitung': 0.2, 'Bauüberwachung': 1.0 },
|
||
'LPH 9': { 'Planung': 0, 'Projektleitung': 0, 'Bauüberwachung': 0 }
|
||
};
|
||
|
||
// num / int: Prüf-Helfer für APP_SETTING_DEFS. Beide liefern null, wenn der
|
||
// Wert unbrauchbar ist — readAppSettings() nimmt dann die Vorgabe, und
|
||
// PUT /app-settings antwortet mit 400.
|
||
function settingNumber(min, max){
|
||
return v => {
|
||
const n = Number(v);
|
||
return Number.isFinite(n) && n >= min && n <= max ? n : null;
|
||
};
|
||
}
|
||
function settingInt(min, max){
|
||
return v => {
|
||
const n = Number(v);
|
||
return Number.isInteger(n) && n >= min && n <= max ? n : null;
|
||
};
|
||
}
|
||
|
||
const APP_SETTING_DEFS = {
|
||
// Honorar/Terminplan
|
||
stundensatz: { def: 135, parse: settingNumber(1, 100000),
|
||
label: 'Stundensatz (€)' },
|
||
arbeitsstundenProTag: { def: 8, parse: settingNumber(1, 24),
|
||
label: 'Arbeitsstunden je Person und Werktag' },
|
||
// Auslastung
|
||
vollzeitStundenProWoche:{ def: 40, parse: settingNumber(1, 80),
|
||
label: 'Wochenstunden einer vollen Stelle' },
|
||
auslastungSchwelleGelb: { def: 0.75, parse: settingNumber(0.05, 5),
|
||
label: 'Ampel gelb ab' },
|
||
auslastungSchwelleRot: { def: 1.0, parse: settingNumber(0.05, 5),
|
||
label: 'Ampel rot ab' },
|
||
aufwandVorlage: { def: DEFAULT_AUFWAND_VORLAGE, json: true, parse: parseAufwandVorlage,
|
||
label: 'Aufwand je Leistungsphase und Rolle' },
|
||
// Die Auslastung rechnet in STUNDEN: offener Honorarwert der amtierenden
|
||
// Phase geteilt durch den Stundensatz, verteilt auf die verbleibenden
|
||
// Wochen. Projekte ohne hinterlegtes Honorar — im Bestand die Mehrheit —
|
||
// brauchen dafür einen Ersatz: Ihre Größe wird als Zahl zwischen 0 und 1
|
||
// am Projekt gepflegt und mit diesem Referenzhonorar multipliziert. 1,0
|
||
// entspricht also einem Projekt in Höhe des Referenzwerts.
|
||
referenzHonorar: { def: 250000, parse: settingNumber(1000, 100000000),
|
||
label: 'Referenzhonorar für die Projektgröße' },
|
||
// Verteilzeitraum: Über wie viele Wochen die offenen Stunden verteilt
|
||
// werden, solange das Projekt keinen Terminplan hat, aus dem sich ein
|
||
// Ende ergibt. Ohne einen solchen Horizont wäre die Wochenlast nicht
|
||
// bestimmt — dieselben Reststunden bedeuten über vier Wochen etwas ganz
|
||
// anderes als über vierzig.
|
||
verteilzeitraumWochen: { def: 12, parse: settingInt(1, 260),
|
||
label: 'Verteilzeitraum ohne Terminplan (Wochen)' },
|
||
// Sitzungen
|
||
autoLockAktiv: { def: 1, parse: settingInt(0, 1),
|
||
label: 'Ältere Sitzungen automatisch sperren' },
|
||
// Feiertage: das Bundesland bestimmt, welche gesetzlichen Feiertage
|
||
// vorgeschlagen werden (holidaysForYear()). Gemerkt, damit es nicht bei
|
||
// jedem Jahr neu gewählt werden muss.
|
||
feiertagBundesland: { def: 'NW', parse: parseBundesland,
|
||
label: 'Bundesland für die Feiertage' },
|
||
// Betrieb
|
||
maxBackups: { def: DEFAULT_MAX_BACKUPS, parse: settingInt(1, 500),
|
||
label: 'Aufbewahrte automatische Backups' },
|
||
sessionTage: { def: DEFAULT_SESSION_TAGE, parse: settingInt(1, 365),
|
||
label: 'Gültigkeit einer Anmeldung (Tage)' },
|
||
loginMaxVersuche: { def: DEFAULT_LOGIN_MAX_ATTEMPTS, parse: settingInt(1, 100),
|
||
label: 'Fehlversuche bis zur Anmeldesperre' },
|
||
loginSperreMinuten: { def: DEFAULT_LOGIN_BLOCK_MINUTEN, parse: settingInt(1, 1440),
|
||
label: 'Dauer der Anmeldesperre (Minuten)' },
|
||
};
|
||
|
||
// parseAufwandVorlage: prüft die Aufwands-Matrix. Erlaubt sind nur bekannte
|
||
// Leistungsphasen und Rollen, Werte von 0 bis 1. Nicht genannte Phasen oder
|
||
// Rollen fehlen anschließend schlicht — das Frontend behandelt eine fehlende
|
||
// Angabe als Faktor 1,0 (voller Aufwand), damit Auslastung nicht
|
||
// stillschweigend verschwindet. Rückgabe: geprüftes Objekt oder null.
|
||
function parseAufwandVorlage(value){
|
||
let obj = value;
|
||
if(typeof obj === 'string'){
|
||
try { obj = JSON.parse(obj); } catch(e){ return null; }
|
||
}
|
||
if(!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;
|
||
const out = {};
|
||
for(const [lph, roles] of Object.entries(obj)){
|
||
if(!VALID_LPHS.has(lph)) return null;
|
||
if(!roles || typeof roles !== 'object' || Array.isArray(roles)) return null;
|
||
const cleanRoles = {};
|
||
for(const [role, v] of Object.entries(roles)){
|
||
if(!VALID_DISCIPLINE_ROLES.has(role)) return null;
|
||
const n = Number(v);
|
||
if(!Number.isFinite(n) || n < 0 || n > 1) return null;
|
||
cleanRoles[role] = n;
|
||
}
|
||
out[lph] = cleanRoles;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// parseBundesland: prüft ein Bundesland-Kürzel gegen GERMAN_STATES (weiter
|
||
// unten definiert; die Prüfung läuft erst zur Anfragezeit).
|
||
// Rückgabe: Kürzel oder null.
|
||
function parseBundesland(value){
|
||
const v = String(value || '').toUpperCase();
|
||
return GERMAN_STATES[v] ? v : null;
|
||
}
|
||
|
||
// readAppSettings: liefert ALLE firmenweiten Einstellungen als fertiges
|
||
// Objekt. Eine fehlende Zeile ist der Normalfall (die Vorgabe gilt); eine
|
||
// unbrauchbare Zeile — etwa aus einem älteren Stand — fällt ebenfalls auf die
|
||
// Vorgabe zurück, statt die Rechnung zu vergiften.
|
||
// Keine Parameter. Rückgabe: Objekt über alle Schlüssel von APP_SETTING_DEFS.
|
||
function readAppSettings(){
|
||
const rows = db.prepare('SELECT key, value FROM app_settings').all();
|
||
const stored = {};
|
||
rows.forEach(r => { stored[r.key] = r.value; });
|
||
|
||
const out = {};
|
||
for(const [key, def] of Object.entries(APP_SETTING_DEFS)){
|
||
if(!Object.prototype.hasOwnProperty.call(stored, key)){ out[key] = def.def; continue; }
|
||
const parsed = def.parse(stored[key]);
|
||
out[key] = parsed === null ? def.def : parsed;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// readAppSetting: ein einzelner Wert (Kurzform für die Aufrufer im Server).
|
||
// Parameter: key. Rückgabe: geprüfter Wert bzw. Vorgabe.
|
||
function readAppSetting(key){
|
||
const def = APP_SETTING_DEFS[key];
|
||
if(!def) return undefined;
|
||
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key);
|
||
if(!row) return def.def;
|
||
const parsed = def.parse(row.value);
|
||
return parsed === null ? def.def : parsed;
|
||
}
|
||
|
||
// Route: alle firmenweiten Einstellungen lesen. Auth, nicht admin-only — das
|
||
// Frontend RECHNET damit (Auslastung, Terminplan) und braucht sie deshalb bei
|
||
// jedem Konto. Geändert werden dürfen sie nur von Administratoren, siehe PUT.
|
||
app.get('/app-settings', requireAuthOderKiosk, (req, res) => {
|
||
try {
|
||
res.json({ ok: true, settings: readAppSettings() });
|
||
} catch(err){
|
||
console.error('Einstellungen laden fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// GET/PUT /my-ai-settings: die beiden KI-API-Schlüssel des ANGEMELDETEN
|
||
// Kontos (req.user.id, nie eine vom Client mitgeschickte Kennung — exakt
|
||
// wie /change-email/POST). Jeder Nutzer verwaltet seinen eigenen Zugang
|
||
// unter Einstellungen → Mein Konto; kein admin-only mehr, siehe CLAUDE.md
|
||
// "### Protokolle". GET liefert je Anbieter, OB ein Schlüssel gesetzt ist
|
||
// (nie der Schlüssel selbst, wie ein Passwort) sowie die — nicht geheime —
|
||
// apiUrl zum Weiterbearbeiten.
|
||
// ---------- Kalender-Abo für Outlook (iCalendar) ----------
|
||
//
|
||
// Eriks Wunsch: die eigenen Termine in Outlook sehen. Jedes Konto bekommt
|
||
// unter Einstellungen → Mein Konto eine geheime Adresse
|
||
// /kalender/<token>.ics; Outlook abonniert sie („Aus dem Internet“) und holt
|
||
// sie selbst regelmäßig. NUR LESEN — geändert wird weiter in der App.
|
||
// Inhalt aus der jüngsten echten Sitzung: eigene offene Aufgaben mit Termin,
|
||
// Meilensteine der eigenen Projekte (zugeordnet oder mit eigener Aufgabe)
|
||
// und die eigenen Abwesenheiten. Ganztägige Einträge (VALUE=DATE), damit
|
||
// keine Zeitzone im Spiel ist.
|
||
//
|
||
// Der Token IST die Anmeldung (Outlook kann sich nicht einloggen) — deshalb
|
||
// lang und zufällig, jederzeit neu erzeugbar (alte Adresse ungültig) oder
|
||
// abschaltbar. Deaktivierte Konten und reine Wandtafel-Konten bekommen 404.
|
||
|
||
function kalenderUrl(req, token){
|
||
return req.protocol + '://' + req.get('host') + '/kalender/' + token + '.ics';
|
||
}
|
||
|
||
app.get('/mein-kalender', requireAuth, (req, res) => {
|
||
const row = db.prepare('SELECT kalender_token FROM users WHERE id = ?').get(req.user.id);
|
||
const token = row && row.kalender_token;
|
||
res.json({ ok: true, url: token ? kalenderUrl(req, token) : '' });
|
||
});
|
||
|
||
// POST {aus: true} schaltet ab, sonst wird eine NEUE Adresse erzeugt (die
|
||
// bisherige wird damit ungültig).
|
||
app.post('/mein-kalender', requireAuth, (req, res) => {
|
||
try {
|
||
const aus = !!(req.body && req.body.aus);
|
||
const token = aus ? '' : crypto.randomBytes(24).toString('base64url');
|
||
db.prepare('UPDATE users SET kalender_token = ? WHERE id = ?').run(token, req.user.id);
|
||
res.json({ ok: true, url: token ? kalenderUrl(req, token) : '' });
|
||
} catch(err){
|
||
console.error('Kalender-Adresse speichern fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// icsText: Text nach RFC 5545 maskieren.
|
||
function icsText(s){
|
||
return String(s || '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
|
||
}
|
||
// icsZeile: Zeilen über 75 Byte falten (Folgezeilen beginnen mit Leerzeichen).
|
||
function icsZeile(z){
|
||
if(Buffer.byteLength(z, 'utf8') <= 75) return z;
|
||
const teile = [];
|
||
let rest = z;
|
||
// Erste Zeile höchstens 75 Byte, Folgezeilen 74 (plus führendes Leerzeichen).
|
||
const grenze = () => teile.length ? 74 : 75;
|
||
while(Buffer.byteLength(rest, 'utf8') > grenze()){
|
||
let n = Math.min(rest.length, grenze());
|
||
while(Buffer.byteLength(rest.slice(0, n), 'utf8') > grenze()) n--;
|
||
// Kein Ersatzpaar (Emoji o. ä.) zerschneiden.
|
||
const c = rest.charCodeAt(n - 1);
|
||
if(c >= 0xD800 && c <= 0xDBFF) n--;
|
||
teile.push(rest.slice(0, n));
|
||
rest = rest.slice(n);
|
||
}
|
||
teile.push(rest);
|
||
return teile.join('\r\n ');
|
||
}
|
||
function icsDatum(iso){ return String(iso || '').replace(/-/g, ''); }
|
||
// icsTagDanach: DTEND ganztägiger Einträge ist der Tag NACH dem letzten Tag.
|
||
function icsTagDanach(iso){
|
||
const d = new Date(iso + 'T12:00:00');
|
||
d.setDate(d.getDate() + 1);
|
||
return d.getFullYear() + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
|
||
}
|
||
const ICS_DATUM = /^\d{4}-\d{2}-\d{2}$/;
|
||
|
||
// kalenderEreignisse: die Einträge einer Person aus einer Sitzung plus ihren
|
||
// Abwesenheiten. Rein (ohne Datenbank), damit testbar. Parameter: ich
|
||
// (person_id), w (Sitzung oder null), abwesenheiten (Zeilen aus
|
||
// user_absences). Rückgabe: [{uid, start, ende, titel, projekt, text, frei}].
|
||
function kalenderEreignisse(ich, w, abwesenheiten){
|
||
const ereignisse = [];
|
||
if(w && ich){
|
||
(w.projects || []).forEach(p => {
|
||
const projekt = [p.nr, p.name].filter(Boolean).join(' ');
|
||
const zugeordnet = Object.keys(p.assign || {}).some(k => k.split('|')[0] === ich && Number(p.assign[k]) > 0);
|
||
const plan = p.plan || {};
|
||
let eigeneAufgabe = false;
|
||
(plan.todos || []).forEach(t => {
|
||
if(!Array.isArray(t.assignees) || !t.assignees.includes(ich)) return;
|
||
eigeneAufgabe = true;
|
||
if(t.done || t.lph) return;
|
||
const start = ICS_DATUM.test(t.start || '') ? t.start : (ICS_DATUM.test(t.end || '') ? t.end : '');
|
||
const ende = ICS_DATUM.test(t.end || '') ? t.end : start;
|
||
if(!start) return;
|
||
ereignisse.push({
|
||
uid: 'aufgabe-' + t.id, start, ende,
|
||
titel: t.title || 'Aufgabe', projekt,
|
||
text: 'Aufgabe in ' + projekt + (Number(t.progress) > 0 ? ' · ' + Number(t.progress) + ' % erledigt' : '')
|
||
+ (t.notiz ? '\n\n' + String(t.notiz).slice(0, 1500) : '')
|
||
});
|
||
});
|
||
if(!zugeordnet && !eigeneAufgabe) return;
|
||
(plan.milestones || []).forEach(m => {
|
||
if(!ICS_DATUM.test(m.date || '')) return;
|
||
ereignisse.push({
|
||
uid: 'meilenstein-' + (p.projectUid || p.id) + '-' + m.id, start: m.date, ende: m.date,
|
||
titel: '◆ ' + (m.name || 'Meilenstein'), projekt,
|
||
text: 'Meilenstein in ' + projekt + (m.notiz ? '\n\n' + String(m.notiz).slice(0, 1500) : '')
|
||
});
|
||
});
|
||
});
|
||
}
|
||
(abwesenheiten || []).forEach(a => {
|
||
if(!ICS_DATUM.test(a.start || '')) return;
|
||
ereignisse.push({
|
||
uid: 'abwesenheit-' + a.id, start: a.start, ende: ICS_DATUM.test(a.end || '') ? a.end : a.start,
|
||
titel: a.type || 'Abwesend', projekt: '', text: a.note || '', frei: true
|
||
});
|
||
});
|
||
return ereignisse;
|
||
}
|
||
|
||
// kalenderText: die iCalendar-Datei (CRLF, gefaltete Zeilen).
|
||
function kalenderText(name, ereignisse){
|
||
const jetzt = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
||
const zeilen = [
|
||
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Projektuebersicht//Kalender//DE', 'CALSCALE:GREGORIAN',
|
||
'METHOD:PUBLISH', 'X-WR-CALNAME:' + icsText('Projektübersicht – ' + (name || '')),
|
||
'X-WR-TIMEZONE:Europe/Berlin', 'REFRESH-INTERVAL;VALUE=DURATION:PT1H', 'X-PUBLISHED-TTL:PT1H'
|
||
];
|
||
ereignisse.forEach(e => {
|
||
zeilen.push('BEGIN:VEVENT',
|
||
'UID:' + icsText(e.uid) + '@projektuebersicht',
|
||
'DTSTAMP:' + jetzt,
|
||
'DTSTART;VALUE=DATE:' + icsDatum(e.start),
|
||
'DTEND;VALUE=DATE:' + icsTagDanach(e.ende < e.start ? e.start : e.ende),
|
||
'SUMMARY:' + icsText(e.titel + (e.projekt ? ' (' + e.projekt + ')' : '')),
|
||
'DESCRIPTION:' + icsText(e.text),
|
||
// Als „frei“ eintragen: Ganztägige Aufgaben sollen die Verfügbarkeit
|
||
// in Outlook nicht blockieren; Abwesenheiten zeigen „abwesend“.
|
||
'TRANSP:TRANSPARENT',
|
||
e.frei ? 'X-MICROSOFT-CDO-BUSYSTATUS:OOF' : 'X-MICROSOFT-CDO-BUSYSTATUS:FREE',
|
||
'END:VEVENT');
|
||
});
|
||
zeilen.push('END:VCALENDAR');
|
||
return zeilen.map(icsZeile).join('\r\n') + '\r\n';
|
||
}
|
||
|
||
app.get('/kalender/:datei', (req, res) => {
|
||
try {
|
||
const token = String(req.params.datei || '').replace(/\.ics$/i, '');
|
||
if(token.length < 20) return res.status(404).end();
|
||
const u = db.prepare('SELECT id, display_name, person_id, role, active FROM users WHERE kalender_token = ?').get(token);
|
||
if(!u || !u.active || !hatRecht({ id: u.id, role: u.role }, 'app')) return res.status(404).end();
|
||
const label = echteSitzungenSortiert().map(w => w.label).pop();
|
||
const w = label ? loadWeeks(label)[0] : null;
|
||
const abw = db.prepare('SELECT id, start, end, type, note FROM user_absences WHERE user_id = ?').all(u.id);
|
||
res.set('Content-Type', 'text/calendar; charset=utf-8');
|
||
res.set('Cache-Control', 'no-cache');
|
||
res.set('Content-Disposition', 'inline; filename="projektuebersicht.ics"');
|
||
res.send(kalenderText(u.display_name, kalenderEreignisse(u.person_id || '', w, abw)));
|
||
} catch(err){
|
||
console.error('Kalender ausliefern fehlgeschlagen:', err);
|
||
res.status(500).end();
|
||
}
|
||
});
|
||
|
||
app.get('/my-ai-settings', requireAuth, (req, res) => {
|
||
try {
|
||
const chatgpt = readUserAiKey(req.user.id, 'chatgpt');
|
||
const claude = readUserAiKey(req.user.id, 'claude');
|
||
res.json({
|
||
ok: true,
|
||
chatgpt: { configured: !!chatgpt.apiKey, apiUrl: chatgpt.apiUrl, model: chatgpt.model },
|
||
claude: { configured: !!claude.apiKey, apiUrl: claude.apiUrl, model: claude.model }
|
||
});
|
||
} catch(err){
|
||
console.error('KI-Einstellungen laden fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
app.put('/my-ai-settings', requireAuth, (req, res) => {
|
||
try {
|
||
const body = (req.body && typeof req.body === 'object') ? req.body : {};
|
||
for(const provider of ['chatgpt', 'claude']){
|
||
const patch = body[provider];
|
||
if(!patch || typeof patch !== 'object') continue;
|
||
if(Object.prototype.hasOwnProperty.call(patch, 'apiUrl') && patch.apiUrl && !istGueltigeAiUrl(patch.apiUrl)){
|
||
return res.status(400).json({ ok: false, error: 'Die API-URL muss eine gültige https-Adresse sein (oder http auf localhost/127.0.0.1 für einen lokalen Server wie LM Studio/Ollama).' });
|
||
}
|
||
writeUserAiKey(req.user.id, provider, patch);
|
||
}
|
||
const chatgpt = readUserAiKey(req.user.id, 'chatgpt');
|
||
const claude = readUserAiKey(req.user.id, 'claude');
|
||
res.json({
|
||
ok: true,
|
||
chatgpt: { configured: !!chatgpt.apiKey, apiUrl: chatgpt.apiUrl, model: chatgpt.model },
|
||
claude: { configured: !!claude.apiKey, apiUrl: claude.apiUrl, model: claude.model }
|
||
});
|
||
} catch(err){
|
||
console.error('KI-Einstellungen speichern fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// POST /my-ai-settings/test: "Neu verbinden" — prüft die GESPEICHERTE
|
||
// Konfiguration des aufrufenden Kontos für einen Anbieter, indem
|
||
// tatsächlich eine kurze, echte Anfrage über erkennePunkteMitKI() läuft
|
||
// (dieselbe Funktion, die auch der Protokoll-Import nutzt — deckt damit
|
||
// automatisch Timeout-Wahl, reasoning_effort-Behandlung für lokale Ziele
|
||
// und URL-Normalisierung mit ab, ohne eigenen Testpfad pflegen zu müssen).
|
||
app.post('/my-ai-settings/test', requireAuth, async (req, res) => {
|
||
try {
|
||
const provider = req.body && req.body.provider;
|
||
if(provider !== 'chatgpt' && provider !== 'claude'){
|
||
return res.status(400).json({ ok: false, error: 'Unbekannter Anbieter.' });
|
||
}
|
||
await erkennePunkteMitKI('1. Testpunkt. Zustaendig: Test. Termin: 01.01.2026', req.user.id, provider);
|
||
res.json({ ok: true });
|
||
} catch(err){
|
||
// Ein fehlgeschlagener Verbindungstest ist ein normales, erwartetes
|
||
// Ergebnis, kein Serverfehler — deshalb 200 mit ok:false statt 4xx/5xx,
|
||
// der Client unterscheidet Erfolg/Fehlschlag einheitlich über "ok".
|
||
res.json({ ok: false, error: err.message || String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: firmenweite Einstellungen schreiben. Admin-only. Teilweise Updates
|
||
// (nur die im Rumpf genannten Schlüssel werden geschrieben); unbekannte
|
||
// Schlüssel und unbrauchbare Werte führen zu 400, bevor irgendetwas
|
||
// geschrieben wird — sonst bliebe die Hälfte einer Maske liegen.
|
||
// APP_SETTING_RECHT: welches Recht eine Einstellung ändern darf.
|
||
const APP_SETTING_RECHT = {
|
||
stundensatz: 'a.honorar',
|
||
arbeitsstundenProTag: 'a.termine', autoLockAktiv: 'a.termine',
|
||
vollzeitStundenProWoche: 'a.auslastung', auslastungSchwelleGelb: 'a.auslastung', auslastungSchwelleRot: 'a.auslastung',
|
||
aufwandVorlage: 'a.auslastung', referenzHonorar: 'a.auslastung', verteilzeitraumWochen: 'a.auslastung',
|
||
feiertagBundesland: 'a.feiertage',
|
||
maxBackups: 'a.betrieb', sessionTage: 'a.betrieb', loginMaxVersuche: 'a.betrieb', loginSperreMinuten: 'a.betrieb',
|
||
};
|
||
app.put('/app-settings', requireAuth, (req, res) => {
|
||
try {
|
||
const body = (req.body && typeof req.body === 'object') ? req.body : {};
|
||
const writes = [];
|
||
for(const [key, value] of Object.entries(body)){
|
||
const def = APP_SETTING_DEFS[key];
|
||
if(!def) return res.status(400).json({ ok:false, error: 'Unbekannte Einstellung: ' + key });
|
||
// Jede Einstellung gehört zu einem Admin-Abschnitt und damit zu dessen
|
||
// Recht (Rollen & Rechte) — geprüft je Feld, bevor irgendetwas schreibt.
|
||
const recht = APP_SETTING_RECHT[key] || 'a.betrieb';
|
||
if(!hatRecht(req.user, recht)) return rechtFehlt(res, recht);
|
||
const parsed = def.parse(value);
|
||
if(parsed === null) return res.status(400).json({ ok:false, error: 'Unbrauchbarer Wert für „' + def.label + '".' });
|
||
writes.push([key, def.json ? JSON.stringify(parsed) : String(parsed)]);
|
||
}
|
||
// Gelb darf nicht über Rot liegen — sonst zeigte die Ampel nie Gelb.
|
||
const merged = Object.assign(readAppSettings(), Object.fromEntries(
|
||
writes.map(([k, v]) => [k, APP_SETTING_DEFS[k].parse(v)])
|
||
));
|
||
if(merged.auslastungSchwelleGelb > merged.auslastungSchwelleRot){
|
||
return res.status(400).json({ ok:false, error: 'Die gelbe Schwelle muss unter der roten liegen.' });
|
||
}
|
||
|
||
db.exec('BEGIN');
|
||
try {
|
||
const stmt = db.prepare(`INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`);
|
||
writes.forEach(([k, v]) => stmt.run(k, v));
|
||
db.exec('COMMIT');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
res.json({ ok: true, settings: readAppSettings() });
|
||
} catch(err){
|
||
console.error('Einstellungen speichern fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// ---------- Feiertage ----------
|
||
|
||
// Gesetzliche Feiertage lassen sich vollstaendig BERECHNEN: Ein Teil liegt auf
|
||
// festen Kalendertagen, der Rest haengt am Osterdatum. Deshalb holt diese
|
||
// Anwendung sie nicht von einem Netzdienst — das haette eine Abhaengigkeit
|
||
// von fremder Verfuegbarkeit und einem Internetzugang des Servers bedeutet,
|
||
// den es im Buero-Netz nicht zwingend gibt. Berechnet ist das Ergebnis
|
||
// jederzeit gleich, funktioniert offline und braucht kein weiteres Paket
|
||
// (das Projekt kommt bewusst mit express und cors aus).
|
||
//
|
||
// Die errechneten Tage werden NICHT automatisch in die Tabelle geschrieben.
|
||
// Ein Administrator laesst sie sich vorschlagen und uebernimmt sie bewusst —
|
||
// Feiertage verschieben jeden gerechneten Terminplan, das soll nicht
|
||
// unbemerkt im Hintergrund passieren.
|
||
|
||
// GERMAN_STATES: die 16 Bundeslaender mit ihrem amtlichen Kuerzel.
|
||
const GERMAN_STATES = {
|
||
BW: 'Baden-Württemberg', BY: 'Bayern',
|
||
BE: 'Berlin', BB: 'Brandenburg',
|
||
HB: 'Bremen', HH: 'Hamburg',
|
||
HE: 'Hessen', MV: 'Mecklenburg-Vorpommern',
|
||
NI: 'Niedersachsen', NW: 'Nordrhein-Westfalen',
|
||
RP: 'Rheinland-Pfalz', SL: 'Saarland',
|
||
SN: 'Sachsen', ST: 'Sachsen-Anhalt',
|
||
SH: 'Schleswig-Holstein', TH: 'Thüringen',
|
||
};
|
||
|
||
// easterSunday: Ostersonntag eines Jahres nach der gregorianischen
|
||
// Osterformel (Gauss/Meeus). Alle beweglichen Feiertage haengen daran.
|
||
// Parameter: year (Zahl). Rueckgabe: Date (UTC-Mitternacht).
|
||
function easterSunday(year){
|
||
const a = year % 19;
|
||
const b = Math.floor(year / 100);
|
||
const c = year % 100;
|
||
const d = Math.floor(b / 4);
|
||
const e = b % 4;
|
||
const f = Math.floor((b + 8) / 25);
|
||
const g = Math.floor((b - f + 1) / 3);
|
||
const h = (19 * a + b - d - g + 15) % 30;
|
||
const i = Math.floor(c / 4);
|
||
const k = c % 4;
|
||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||
return new Date(Date.UTC(year, month - 1, day));
|
||
}
|
||
|
||
// isoFromDate / shiftDays: kleine Datums-Helfer fuer die Berechnung. Gerechnet
|
||
// wird durchgehend in UTC, damit keine Sommerzeit-Umstellung einen Tag
|
||
// verschiebt.
|
||
function isoFromDate(d){ return d.toISOString().slice(0, 10); }
|
||
function shiftDays(d, n){ return new Date(d.getTime() + n * 86400000); }
|
||
|
||
// bussUndBettag: Mittwoch vor dem 23. November (nur Sachsen).
|
||
// Parameter: year. Rueckgabe: Date.
|
||
function bussUndBettag(year){
|
||
let d = new Date(Date.UTC(year, 10, 22)); // 22.11.
|
||
while(d.getUTCDay() !== 3) d = shiftDays(d, -1);
|
||
return d;
|
||
}
|
||
|
||
// holidaysForYear: die gesetzlichen Feiertage eines Jahres in einem
|
||
// Bundesland. Nicht enthalten sind die gemeindeabhaengigen Faelle
|
||
// (Fronleichnam in Teilen von Sachsen und Thueringen, Mariä Himmelfahrt in
|
||
// ueberwiegend katholischen Gemeinden Bayerns) — die haengen vom Wohnort ab
|
||
// und lassen sich nicht aus dem Bundesland ableiten; sie koennen von Hand
|
||
// ergaenzt werden.
|
||
// Parameter: year (Zahl), state (Kuerzel aus GERMAN_STATES).
|
||
// Rueckgabe: Array von { date, name }, nach Datum sortiert.
|
||
function holidaysForYear(year, state){
|
||
const ostern = easterSunday(year);
|
||
const fest = (m, d) => isoFromDate(new Date(Date.UTC(year, m - 1, d)));
|
||
const ab = n => isoFromDate(shiftDays(ostern, n));
|
||
|
||
// Jeder Eintrag: [Datum, Bezeichnung, Bundeslaender oder null = bundesweit]
|
||
const alle = [
|
||
[fest(1, 1), 'Neujahr', null],
|
||
[fest(1, 6), 'Heilige Drei Könige', ['BW','BY','ST']],
|
||
[fest(3, 8), 'Internationaler Frauentag', ['BE','MV']],
|
||
[ab(-2), 'Karfreitag', null],
|
||
[ab(0), 'Ostersonntag', ['BB']],
|
||
[ab(1), 'Ostermontag', null],
|
||
[fest(5, 1), 'Tag der Arbeit', null],
|
||
[ab(39), 'Christi Himmelfahrt', null],
|
||
[ab(49), 'Pfingstsonntag', ['BB']],
|
||
[ab(50), 'Pfingstmontag', null],
|
||
[ab(60), 'Fronleichnam', ['BW','BY','HE','NW','RP','SL']],
|
||
[fest(8, 15), 'Mariä Himmelfahrt', ['SL']],
|
||
[fest(9, 20), 'Weltkindertag', ['TH']],
|
||
[fest(10, 3), 'Tag der Deutschen Einheit', null],
|
||
[fest(10, 31), 'Reformationstag', ['BB','HB','HH','MV','NI','SH','SN','ST','TH']],
|
||
[fest(11, 1), 'Allerheiligen', ['BW','BY','NW','RP','SL']],
|
||
[isoFromDate(bussUndBettag(year)), 'Buß- und Bettag', ['SN']],
|
||
[fest(12, 25), '1. Weihnachtstag', null],
|
||
[fest(12, 26), '2. Weihnachtstag', null],
|
||
];
|
||
|
||
return alle
|
||
.filter(([, , laender]) => laender === null || laender.includes(state))
|
||
.map(([date, name]) => ({ date, name }))
|
||
.sort((a, b) => a.date.localeCompare(b.date));
|
||
}
|
||
|
||
// Route: gesetzliche Feiertage eines Jahres vorschlagen. Auth (lesend, die
|
||
// Berechnung verraet nichts). Jeder Eintrag traegt zusaetzlich `vorhanden`,
|
||
// damit die Maske zeigen kann, was schon hinterlegt ist.
|
||
app.get('/holidays/suggest', requireAuth, (req, res) => {
|
||
try {
|
||
const year = Number(req.query.year);
|
||
const state = String(req.query.state || readAppSetting('feiertagBundesland'));
|
||
if(!Number.isInteger(year) || year < 1970 || year > 2200){
|
||
return res.status(400).json({ ok:false, error:'Jahr muss zwischen 1970 und 2200 liegen.' });
|
||
}
|
||
if(!GERMAN_STATES[state]){
|
||
return res.status(400).json({ ok:false, error:'Unbekanntes Bundesland: ' + state });
|
||
}
|
||
const vorhanden = new Set(db.prepare('SELECT date FROM holidays').all().map(r => r.date));
|
||
const holidays = holidaysForYear(year, state).map(h => ({
|
||
date: h.date, name: h.name, vorhanden: vorhanden.has(h.date),
|
||
}));
|
||
res.json({ ok:true, year, state, stateName: GERMAN_STATES[state], holidays, states: GERMAN_STATES });
|
||
} catch(err){
|
||
console.error('Feiertage berechnen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok:false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: berechnete Feiertage uebernehmen. Admin-only. Bereits hinterlegte
|
||
// Tage bleiben unveraendert — ein von Hand angepasster Name (etwa ein
|
||
// Brueckentag, den jemand auf denselben Tag gelegt hat) soll nicht
|
||
// ueberschrieben werden. Die Antwort meldet beides getrennt.
|
||
app.post('/holidays/import', requireRecht('a.feiertage'), (req, res) => {
|
||
try {
|
||
const year = Number((req.body || {}).year);
|
||
const state = String((req.body || {}).state || readAppSetting('feiertagBundesland'));
|
||
if(!Number.isInteger(year) || year < 1970 || year > 2200){
|
||
return res.status(400).json({ ok:false, error:'Jahr muss zwischen 1970 und 2200 liegen.' });
|
||
}
|
||
if(!GERMAN_STATES[state]){
|
||
return res.status(400).json({ ok:false, error:'Unbekanntes Bundesland: ' + state });
|
||
}
|
||
const liste = holidaysForYear(year, state);
|
||
const stmt = db.prepare('INSERT OR IGNORE INTO holidays (date, name) VALUES (?, ?)');
|
||
let angelegt = 0;
|
||
db.exec('BEGIN');
|
||
try {
|
||
liste.forEach(h => { angelegt += stmt.run(h.date, h.name).changes; });
|
||
db.exec('COMMIT');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
res.json({ ok:true, angelegt, uebersprungen: liste.length - angelegt, gesamt: liste.length });
|
||
} catch(err){
|
||
console.error('Feiertage übernehmen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok:false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
|
||
// Route: Feiertage lesen. Auth — das Frontend rechnet damit in Werktagen.
|
||
app.get('/holidays', requireAuthOderKiosk, (req, res) => {
|
||
try {
|
||
const rows = db.prepare('SELECT date, name FROM holidays ORDER BY date').all();
|
||
// Die Bundesländer kommen gleich mit: Der Server ist die eine Stelle, an
|
||
// der die Liste steht, und die Pflege-Maske braucht sie sofort — sonst
|
||
// hätte ihre Auswahl bis zum ersten Vorschlag nur einen Eintrag.
|
||
res.json({
|
||
ok: true,
|
||
holidays: rows.map(r => ({ date: r.date, name: r.name })),
|
||
states: GERMAN_STATES,
|
||
});
|
||
} catch(err){
|
||
console.error('Feiertage laden fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Feiertag anlegen oder umbenennen. Admin-only. Das Datum ist der
|
||
// Primärschlüssel, ein zweites Anlegen desselben Tages ersetzt also nur den
|
||
// Namen — das ist gewollt und erspart ein eigenes PATCH.
|
||
app.post('/holidays', requireRecht('a.feiertage'), (req, res) => {
|
||
try {
|
||
const { date, name } = req.body || {};
|
||
if(typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)){
|
||
return res.status(400).json({ ok:false, error:'Datum im Format JJJJ-MM-TT erforderlich.' });
|
||
}
|
||
const d = new Date(date + 'T00:00:00Z');
|
||
if(Number.isNaN(d.getTime()) || d.toISOString().slice(0,10) !== date){
|
||
return res.status(400).json({ ok:false, error:'Kein gültiges Datum.' });
|
||
}
|
||
const cleanName = String(name || '').trim().slice(0, 120);
|
||
db.prepare(`INSERT INTO holidays (date, name) VALUES (?, ?)
|
||
ON CONFLICT(date) DO UPDATE SET name = excluded.name`).run(date, cleanName);
|
||
res.json({ ok: true, holiday: { date, name: cleanName } });
|
||
} catch(err){
|
||
console.error('Feiertag anlegen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Feiertag löschen. Admin-only, idempotent.
|
||
app.delete('/holidays/:date', requireRecht('a.feiertage'), (req, res) => {
|
||
try {
|
||
db.prepare('DELETE FROM holidays WHERE date = ?').run(String(req.params.date));
|
||
res.json({ ok: true });
|
||
} catch(err){
|
||
console.error('Feiertag löschen fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
|
||
// readStundensatz: liest den firmenweiten Stundensatz (Vorgabe und Prüfung
|
||
// stehen in APP_SETTING_DEFS).
|
||
function readStundensatz(){
|
||
// Ein Schlüssel, eine Definition: der Stundensatz steht seit der
|
||
// Einführung von APP_SETTING_DEFS dort (mit derselben Vorgabe 135) und
|
||
// wird weiterhin über /honorar-config gepflegt, weil er fachlich zur
|
||
// Honorar-Maske gehört.
|
||
return readAppSetting('stundensatz');
|
||
}
|
||
|
||
// Route: Honorar-Konfiguration lesen — Anteile je Leistungsphase und
|
||
// Stundensatz. Auth (nicht admin-only): jeder angemeldete Nutzer darf einen
|
||
// Terminplan rechnen lassen, nur das Pflegen der Sätze ist Admin-Sache.
|
||
app.get('/honorar-config', requireAuthOderKiosk, (req, res) => {
|
||
try {
|
||
const rows = db.prepare('SELECT lph, percent FROM lph_honorar_anteile').all();
|
||
const anteile = {};
|
||
rows.forEach(r => { anteile[r.lph] = r.percent; });
|
||
res.json({ ok: true, anteile, stundensatz: readStundensatz() });
|
||
} catch(err){
|
||
console.error('Honorar-Konfiguration laden fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: Honorar-Konfiguration schreiben. Admin-only (firmenweite Vorgabe).
|
||
// `anteile` ist ein vollständiger Ersatz (delete + insert in einer
|
||
// Transaktion, gleiches Muster wie die Rollen in PATCH /admin/users/:id):
|
||
// eine im Objekt fehlende Leistungsphase gilt damit als "kein Anteil".
|
||
app.put('/honorar-config', requireRecht('a.honorar'), (req, res) => {
|
||
try {
|
||
const { anteile, stundensatz } = req.body || {};
|
||
if(anteile !== undefined && (typeof anteile !== 'object' || anteile === null)){
|
||
return res.status(400).json({ ok: false, error: 'anteile muss ein Objekt sein.' });
|
||
}
|
||
if(stundensatz !== undefined){
|
||
const v = Number(stundensatz);
|
||
if(!Number.isFinite(v) || v <= 0){
|
||
return res.status(400).json({ ok: false, error: 'Stundensatz muss größer als 0 sein.' });
|
||
}
|
||
}
|
||
// Vor dem Schreiben prüfen, damit eine einzelne unbrauchbare Zeile nicht
|
||
// die halbe Konfiguration überschreibt.
|
||
const clean = [];
|
||
if(anteile){
|
||
for(const [lph, pct] of Object.entries(anteile)){
|
||
if(!VALID_LPHS.has(lph)){
|
||
return res.status(400).json({ ok: false, error: 'Unbekannte Leistungsphase: ' + lph });
|
||
}
|
||
const v = Number(pct);
|
||
if(!Number.isFinite(v) || v < 0 || v > 100){
|
||
return res.status(400).json({ ok: false, error: 'Anteil für ' + lph + ' muss zwischen 0 und 100 liegen.' });
|
||
}
|
||
if(v > 0) clean.push([lph, v]);
|
||
}
|
||
}
|
||
|
||
db.exec('BEGIN');
|
||
try {
|
||
if(anteile){
|
||
db.prepare('DELETE FROM lph_honorar_anteile').run();
|
||
const ins = db.prepare('INSERT INTO lph_honorar_anteile (lph, percent) VALUES (?, ?)');
|
||
clean.forEach(([lph, v]) => ins.run(lph, v));
|
||
}
|
||
if(stundensatz !== undefined){
|
||
db.prepare(`INSERT INTO app_settings (key, value) VALUES ('stundensatz', ?)
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(Number(stundensatz)));
|
||
}
|
||
db.exec('COMMIT');
|
||
} catch(err){
|
||
db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
|
||
const rows = db.prepare('SELECT lph, percent FROM lph_honorar_anteile').all();
|
||
const out = {};
|
||
rows.forEach(r => { out[r.lph] = r.percent; });
|
||
res.json({ ok: true, anteile: out, stundensatz: readStundensatz() });
|
||
} catch(err){
|
||
console.error('Honorar-Konfiguration speichern fehlgeschlagen:', err);
|
||
res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
|
||
// Route: DB-Snapshot-Download. Erzeugt einen konsistenten Snapshot der
|
||
// SQLite-Datenbank (auch mit WAL) und stellt ihn als .db-Datei zum Download
|
||
// bereit (Backup für Archivierung/Wiederherstellung, z. B. per "DB Backup"-
|
||
// Button im Menü). Die temporäre Datei wird nach dem Senden gelöscht.
|
||
// NUR ADMINS: Der Snapshot enthält die komplette Datenbank und damit auch
|
||
// user_sessions.token im Klartext. Mit einem fremden Token könnte sich sonst
|
||
// jedes Mitglied als Administrator ausgeben (Rechteausweitung) — deshalb ist
|
||
// diese Route strenger abgesichert als der Rest der fachlichen Daten-Routen.
|
||
app.get('/backup', requireRecht('a.backup'), (req, res) => {
|
||
const tmp = path.join(os.tmpdir(), 'projektuebersicht_backup_' + Date.now() + '.db');
|
||
try {
|
||
createDbSnapshot(tmp);
|
||
// heuteIsoLokal() statt toISOString(): Letzteres rechnet nach UTC um und
|
||
// datierte das Backup zwischen 0 und 2 Uhr Ortszeit auf den Vortag.
|
||
const filename = 'projektuebersicht_backup_' + heuteIsoLokal() + '.db';
|
||
res.download(tmp, filename, () => fs.unlink(tmp, () => {}));
|
||
console.log('DB-Backup-Download:', filename);
|
||
} catch(err){
|
||
console.error('DB-Backup fehlgeschlagen:', err);
|
||
fs.unlink(tmp, () => {});
|
||
if(!res.headersSent) res.status(500).json({ ok: false, error: String(err) });
|
||
}
|
||
});
|
||
|
||
// Route: DB-Import (Backup-Wiederherstellung). Erwartet eine .db-Datei aus
|
||
// GET /backup bzw. "DB Backup" als rohen Binär-Body (application/octet-stream);
|
||
// validiert das Schema und ersetzt den gesamten Datenbestand. VOR dem Import
|
||
// wird der Zustand vor dem Import als frisches Backup gesichert, damit ein
|
||
// versehentlicher Restore rückgängig gemacht werden kann.
|
||
app.post('/import', requireRecht('a.import'), express.raw({ type: '*/*', limit: '50mb' }), (req, res) => {
|
||
const buf = req.body;
|
||
if(!Buffer.isBuffer(buf) || buf.length === 0){
|
||
return res.status(400).json({ ok: false, error: 'Keine Datei empfangen' });
|
||
}
|
||
const tmp = path.join(os.tmpdir(), 'projektuebersicht_import_' + Date.now() + '.db');
|
||
try {
|
||
fs.writeFileSync(tmp, buf);
|
||
try { pruefeBackupDatei(tmp); }
|
||
catch(err){ return res.status(400).json({ ok: false, error: err.message }); }
|
||
// Zustand VOR dem Restore sichern (danach wäre der Snapshot bereits der
|
||
// importierte Stand und die Wiederherstellung unmöglich).
|
||
runAutoBackup();
|
||
restoreDbFromBackup(tmp);
|
||
// Ältere Backups tragen Kommentare noch gewerkübergreifend in projects.
|
||
migrateKommentareJeGewerk();
|
||
kommentareVorsatzUmstellen();
|
||
// Ältere Backups kennen das Feld leistungsphase noch nicht.
|
||
phasenbalkenAusTitel();
|
||
console.log('Datenbank aus Backup wiederhergestellt (' + buf.length + ' Bytes)');
|
||
ereignisSenden({ typ: 'neu' }, req);
|
||
return res.json({ ok: true });
|
||
} catch(err){
|
||
console.error('Datenbank-Import fehlgeschlagen:', err);
|
||
if(!res.headersSent) res.status(500).json({ ok: false, error: String(err && err.message ? err.message : err) });
|
||
} finally {
|
||
fs.unlink(tmp, () => {});
|
||
}
|
||
});
|
||
|
||
// Route: Healthcheck-Endpunkt für Docker/Überwachung
|
||
app.get('/health', (req,res)=> res.json({
|
||
ok: true,
|
||
speicherMb: Math.round(process.memoryUsage().rss / 1048576),
|
||
laufzeitMin: Math.round(process.uptime() / 60)
|
||
}));
|
||
|
||
// Route: Liefert die Hauptseite App.html
|
||
app.get('/', (req,res)=>{
|
||
appSenden(req, res);
|
||
});
|
||
|
||
// Gültige Tab-Slugs für Deep-Link-Routen (muss zu TAB_SLUGS in js/globals.js passen)
|
||
// 'Projekte' bleibt bewusst gültig, obwohl der gleichnamige Bereich in der
|
||
// Projektliste aufgegangen ist: Weitergegebene Links sollen nicht mit 404
|
||
// enden. Die Oberfläche leitet den Abschnitt auf die Projektliste um
|
||
// (TAB_BY_SLUG in public/js/globals.js) und erzeugt ihn selbst nicht mehr.
|
||
const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Abwesenheiten','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']);
|
||
// Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen)
|
||
const PLAN_SLUG = 'Projektplan';
|
||
|
||
// Route: alte Direktlinks auf die entfallene Datei admin-users.html. Sie war
|
||
// bis zum Umbau eine eigene Seite mit nachgebauter Kopfzeile; die
|
||
// Benutzerverwaltung ist heute ein Abschnitt des Bereichs „Admin" auf der
|
||
// Hauptseite, damit die Seitenleiste stehen bleibt.
|
||
app.get('/admin-users.html', (req, res) => {
|
||
res.redirect(301, '/Admin#benutzer');
|
||
});
|
||
|
||
// Route: alte Direktlinks auf die entfallene Datei project-plan.html. Sie
|
||
// war bis zum Umbau ein eigenes Dokument; heute ist der Projektbereich der
|
||
// Bereich #plan der Hauptseite. Die Parameter (weekLabel, projNr, projName)
|
||
// bleiben erhalten, damit ein alter Link weiterhin dasselbe Projekt öffnet.
|
||
app.get('/project-plan.html', (req, res) => {
|
||
const params = new URLSearchParams(req.query);
|
||
const projName = params.get('projName') || '';
|
||
params.delete('projName');
|
||
const qs = params.toString();
|
||
res.redirect(301, '/' + PLAN_SLUG + '/' + encodeURIComponent(projName) + (qs ? '?' + qs : ''));
|
||
});
|
||
|
||
// Route: alte Direktlinks auf den früheren Dateinamen Test.html — ein nie
|
||
// umbenannter Platzhaltername aus der Frühphase des Projekts, inzwischen
|
||
// App.html. Anders als bei admin-users.html/project-plan.html gibt es kein
|
||
// anderes Ziel als "/" selbst: Test.html WAR die Hauptseite, nur unter einem
|
||
// unpassenden Namen.
|
||
app.get('/Test.html', (req, res) => {
|
||
res.redirect(301, '/');
|
||
});
|
||
|
||
// Route: Deep-Links je Tab, z. B. "/Auslastung" oder "/Projektübersicht"
|
||
// (seit Entfernung der Abteilungstrennung nur noch ein Pfad-Segment, vorher
|
||
// "/<Abteilung>/<Tab>"). Liefert wie "/" die Hauptseite; unbekannte Tabs
|
||
// werden abgelehnt (404), damit keine App.html auf beliebigen Pfaden
|
||
// ausgeliefert wird.
|
||
app.get('/:tab', (req, res) => {
|
||
const { tab } = req.params;
|
||
if(tab === PLAN_SLUG){
|
||
// Projektbereich eines Projekts, z. B. "/Projektplan?weekLabel=..." —
|
||
// seit dem Umbau kein eigenes Dokument mehr, sondern der Bereich #plan
|
||
// der Hauptseite (applyUrlRoute() in public/js/ui.js wertet die Adresse
|
||
// clientseitig aus).
|
||
return appSenden(req, res);
|
||
}
|
||
if(!VALID_TAB_SLUGS.has(tab)) return res.status(404).send('Nicht gefunden');
|
||
appSenden(req, res);
|
||
});
|
||
|
||
// Route: Projektbereich mit Projektname im Pfad, z. B.
|
||
// "/Projektplan/Gymnasium%20Hemer?weekLabel=..." – das Pfad-Segment dient nur
|
||
// der lesbaren Adresse, die Identifikation erfolgt über weekLabel/projNr.
|
||
app.get('/Projektplan/:projName', (req, res) => {
|
||
appSenden(req, res);
|
||
});
|
||
|
||
// Route: Weiterleitung alter, noch abteilungspräfixierter Projektplan-Links
|
||
// ("/HLS/Projektplan/Foo?weekLabel=...") auf das neue Schema, damit alte
|
||
// Lesezeichen nicht brechen. Muss VOR der generischen 2-Segment-Weiterleitung
|
||
// stehen (3 Segmente sind eindeutig).
|
||
app.get('/:legacyDept/Projektplan/:projName', (req, res) => {
|
||
if(!VALID_DEPARTMENTS.has(req.params.legacyDept)) return res.status(404).send('Nicht gefunden');
|
||
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
|
||
res.redirect(301, '/Projektplan/' + encodeURIComponent(req.params.projName) + qs);
|
||
});
|
||
|
||
// Route: Weiterleitung alter zweisegmentiger Deep-Links ("/ELT/Auslastung")
|
||
// auf das neue einsegmentige Schema. Muss NACH "/Projektplan/:projName"
|
||
// stehen, sonst würde z.B. "/Projektplan/Foo" hier fälschlich als
|
||
// Abteilung="Projektplan" interpretiert.
|
||
app.get('/:legacyDept/:tab', (req, res) => {
|
||
if(!VALID_DEPARTMENTS.has(req.params.legacyDept)) return res.status(404).send('Nicht gefunden');
|
||
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
|
||
res.redirect(301, '/' + encodeURIComponent(req.params.tab) + qs);
|
||
});
|
||
|
||
// Fehler aus Body-Parsern (z. B. Datei über dem Limit) als lesbares JSON statt
|
||
// der HTML-Fehlerseite von Express.
|
||
app.use((err, req, res, next) => {
|
||
if(res.headersSent) return next(err);
|
||
if(err && (err.type === 'entity.too.large' || err.status === 413)){
|
||
const mb = err.limit ? Math.round(err.limit / 1048576) : null;
|
||
return res.status(413).json({ ok: false, error: 'Datei zu groß' + (mb ? ' (höchstens ' + mb + ' MB).' : '.') });
|
||
}
|
||
console.error('Unbehandelter Fehler in', req.method, req.path, err);
|
||
res.status(err && err.status || 500).json({ ok: false, error: 'Interner Fehler' });
|
||
});
|
||
|
||
// Serverstart mit Fehlerbehandlung (z.B. Port bereits belegt)
|
||
const server = app.listen(PORT, () => {
|
||
console.log(`Projektübersicht server running on http://localhost:${PORT}`);
|
||
// Automatisches Datenbank-Backup beim Start (sessions/backups/)
|
||
runAutoBackup();
|
||
// Automatischer Sitzungswechsel: einmal jetzt (holt einen Wochenwechsel
|
||
// nach, der while der Server nicht lief passiert ist) und danach stündlich
|
||
// erneut — mehr als genug Auflösung für einen Wechsel, der genau einmal
|
||
// pro Woche eintritt. Muss NACH allen anderen Definitionen dieser Datei
|
||
// stehen (braucht u. a. APP_SETTING_DEFS/readAppSettings(), die erst
|
||
// weiter unten im Datei-Durchlauf definiert werden) — deshalb hier im
|
||
// app.listen()-Rückruf statt in der früheren Migrations-/Bootstrap-
|
||
// Reihenfolge weiter oben.
|
||
ensureCurrentWeekRollover();
|
||
// Konten, die nach dem Entstehen der aktuellen Sitzung angelegt wurden,
|
||
// in deren Teilnehmerkreis nachtragen (siehe kontenInAktuelleSitzung()).
|
||
kontenInAktuelleSitzung();
|
||
setInterval(ensureCurrentWeekRollover, 60 * 60 * 1000);
|
||
// Papierkorb gelöschter Projekte: Einträge nach 30 Tagen entfernen.
|
||
setInterval(purgeGeloeschteProjekte, 60 * 60 * 1000);
|
||
purgeBenachrichtigungen();
|
||
setInterval(purgeBenachrichtigungen, 60 * 60 * 1000);
|
||
// Betrieb auf dem Pi: Login-Sperrliste und WAL-Datei klein halten,
|
||
// Speicher und Hänger überwachen (siehe betriebsWaechterStarten()).
|
||
setInterval(betriebAufraeumen, 60 * 60 * 1000);
|
||
betriebsWaechterStarten();
|
||
});
|
||
|
||
// betriebAufraeumen: abgelaufene Login-Sperren entfernen (die Liste wuchs
|
||
// sonst mit jeder Fehleingabe) und die WAL-Datei zurück in die Datenbank
|
||
// schreiben, damit sie auf der SD-Karte nicht wächst.
|
||
function betriebAufraeumen(){
|
||
try {
|
||
const grenze = Date.now() - loginBlockMs();
|
||
loginAttempts.forEach((e, k) => { if(e.first < grenze) loginAttempts.delete(k); });
|
||
// Merkliste der last_seen_at-Schreibzeiten (attachUser) nicht endlos
|
||
// wachsen lassen: Einträge älter als eine Stunde braucht niemand mehr.
|
||
const stundeHer = Date.now() - 60 * 60 * 1000;
|
||
sitzungZuletztGeschrieben.forEach((t, k) => { if(t < stundeHer) sitzungZuletztGeschrieben.delete(k); });
|
||
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
||
} catch(err){ console.error('Aufräumen fehlgeschlagen:', err.message); }
|
||
}
|
||
|
||
// betriebsWaechterStarten: zwei Wächter für den unbeaufsichtigten Betrieb.
|
||
// (1) SPEICHER: Über SPEICHER_MAX_MB (Umgebungsvariable, im Docker-Container
|
||
// gesetzt; ohne sie aus) beendet sich der Server sauber mit Code 1 —
|
||
// Docker (restart: always) startet ihn mit frischem Speicher neu, bevor
|
||
// der Kernel den Container hart abschießt.
|
||
// (2) HÄNGER: Ein Worker-Thread prüft den Herzschlag des Haupt-Threads. Steht
|
||
// der Event-Loop länger als HAENGER_MAX_S (Vorgabe 90 s), beendet der
|
||
// Worker den Prozess hart. Docker startet "unhealthy" Container NICHT
|
||
// neu — ohne diesen Wächter bliebe ein hängender Server hängen.
|
||
function betriebsWaechterStarten(){
|
||
const maxMb = Number(process.env.SPEICHER_MAX_MB) || 0;
|
||
if(maxMb > 0){
|
||
setInterval(() => {
|
||
const mb = process.memoryUsage().rss / 1048576;
|
||
if(mb > maxMb){
|
||
console.error('Speicher-Wächter: ' + Math.round(mb) + ' MB > ' + maxMb + ' MB — Neustart.');
|
||
shutdown('Speicher-Wächter', 1);
|
||
}
|
||
}, 30 * 1000).unref();
|
||
}
|
||
const haengerS = Number(process.env.HAENGER_MAX_S) || 90;
|
||
const { Worker } = require('worker_threads');
|
||
const herz = new Float64Array(new SharedArrayBuffer(8));
|
||
herz[0] = Date.now();
|
||
setInterval(() => { herz[0] = Date.now(); }, 5000).unref();
|
||
const w = new Worker(`
|
||
const { workerData } = require('worker_threads');
|
||
const herz = new Float64Array(workerData.puffer);
|
||
setInterval(() => {
|
||
if(Date.now() - herz[0] > workerData.maxMs){
|
||
// console im Worker läuft über den (hängenden) Haupt-Thread — direkt schreiben.
|
||
require('fs').writeSync(2, 'Hänger-Wächter: Server reagiert seit ' + Math.round((Date.now() - herz[0]) / 1000) + ' s nicht — Neustart.\\n');
|
||
process.kill(process.pid, 'SIGKILL');
|
||
}
|
||
}, 5000);
|
||
`, { eval: true, workerData: { puffer: herz.buffer, maxMs: haengerS * 1000 }, resourceLimits: { maxOldGenerationSizeMb: 8 } });
|
||
w.unref();
|
||
w.on('error', err => console.error('Hänger-Wächter ausgefallen:', err.message));
|
||
}
|
||
|
||
// Unerwartete Fehler: protokollieren und mit Code 1 beenden — ein Prozess in
|
||
// unbekanntem Zustand soll nicht weiterlaufen, Docker startet ihn neu.
|
||
// Abgelehnte Promises betreffen dagegen fast immer nur eine Anfrage: nur
|
||
// protokollieren (Node beendete sonst den ganzen Server).
|
||
process.on('uncaughtException', (err) => {
|
||
console.error('Unerwarteter Fehler — Server wird neu gestartet:', err);
|
||
shutdown('uncaughtException', 1);
|
||
});
|
||
process.on('unhandledRejection', (grund) => {
|
||
console.error('Nicht abgefangene Promise-Ablehnung:', grund);
|
||
});
|
||
server.on('error', (err) => {
|
||
console.error('Server konnte nicht starten:', err.message);
|
||
if(err.code === 'EADDRINUSE'){
|
||
console.error(`Port ${PORT} ist bereits belegt. Bitte einen anderen PORT verwenden, z.B. PORT=3004 npm start`);
|
||
}
|
||
process.exit(1);
|
||
});
|
||
|
||
// Sauberes Beenden auf Strg+C/Cmd+C (SIGINT) oder `kill` (SIGTERM): schließt
|
||
// zuerst den HTTP-Server (keine neuen Verbindungen mehr, laufende werden zu
|
||
// Ende bedient) und danach die Datenbank (sauberer WAL-Checkpoint statt
|
||
// eines abrupten Abbruchs mitten im Schreiben). Ohne diesen Handler würde
|
||
// Node ebenfalls beenden — kein anderer Code hier überschreibt das
|
||
// Standardverhalten —, aber lautlos und ohne den Checkpoint.
|
||
let beendetBereits = false;
|
||
function shutdown(signal, code = 0){
|
||
if(beendetBereits) return;
|
||
beendetBereits = true;
|
||
console.log(`\n${signal} empfangen, Server wird beendet…`);
|
||
// Sicherheitsnetz: eine offene Keep-Alive-Verbindung kann server.close()
|
||
// unbegrenzt blockieren (bekannte Node-Eigenheit) — nach 3s notfalls hart
|
||
// beenden, damit der Prozess in jedem Fall nur bis zum Tastendruck läuft.
|
||
const notbremse = setTimeout(() => process.exit(code), 3000);
|
||
notbremse.unref();
|
||
// Offene Live-Verbindungen (GET /ereignisse) hielten server.close() sonst
|
||
// bis zur Notbremse auf.
|
||
if(typeof server.closeAllConnections === 'function') server.closeIdleConnections();
|
||
try { ereignisKunden.forEach(k => { try { k.res.end(); } catch(e){} }); } catch(e){}
|
||
server.close(() => {
|
||
try { db.close(); } catch(e){ /* bereits geschlossen */ }
|
||
console.log('Server beendet.');
|
||
process.exit(code);
|
||
});
|
||
}
|
||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|