/* * Express-Server der Projektübersicht: liefert die statischen Frontend-Dateien aus * public/ (App.html unter "/") und stellt den Frontend-Modulen unter public/js/ * eine JSON-API zum Laden und Speichern der Sitzungsdaten bereit. Die Daten liegen * in einer SQLite-Datenbank (node:sqlite, Node >= 22.13) unter sessions/projektuebersicht.db. * * Das Schema ist voll relational normalisiert: statt eines grossen JSON-Dokuments * pro Woche gibt es eigene Tabellen für Wochen-Metadaten, Wochen-Personen, Rollen, * Anwesenheit, Projekte, LPH-Werte, Projekt-Zuordnungen, Projektpläne, TODOs, * Meilensteine, Abhängigkeiten sowie die globalen Personen inkl. Rollen und * Abwesenheiten. Die HTTP-API bleibt davon unberührt: Das Frontend sendet und * erhält weiterhin komplette Wochen- und Personen-Objekte (JSON); der Server * verteilt sie beim Speichern auf die Tabellen und setzt sie beim Laden wieder * Zusammen (Serializer/Deserializer). * * Eine bestehende Datenbank aus dem alten Ein-Dokument-pro-Woche-Format wird * automatisch auf das relationale Schema umgestellt. * * Standardport ist 3003, überschreibbar über die Umgebungsvariable PORT. * Endpunkte: GET /, GET /health, GET /load-all, POST /save-week, * POST /delete-week, * GET /backup (SQLite-Snapshot als .db-Download; zusätzlich automatische Backups * nach sessions/backups/ beim Serverstart), POST /import (Backup-Wiederherstellung, * roher Binär-Body, schema-validiert). * Ungültige Abteilungen fallen auf ELT zurück. */ const express = require('express'); const fs = require('fs'); const path = require('path'); const os = require('os'); const zlib = require('zlib'); const crypto = require('crypto'); const { DatabaseSync } = require('node:sqlite'); // pdfjs-dist/mammoth: reine Textauszug-Bibliotheken für den Protokoll-Import // (POST /project-protokolle/import) — erste Abhängigkeiten außer // express/cors, siehe CLAUDE.md "### Protokolle". pdfjs-dist statt des // zunächst gewählten pdf-parse: dessen mitgelieferte, seit 2017 // eingefrorene pdf.js-Kopie liefert in diesem Prozess reproduzierbar // "bad XRef entry", sobald Node's eigenes http-Modul geladen ist (bereits // durch das bloße `require('http')` allein auslösbar, unabhängig vom // tatsächlichen PDF-Inhalt) — in einem Express-Server also immer, da // express selbst transitiv http lädt. Ein eigenständiges pdfjs-dist ist // nicht betroffen. // // @napi-rs/canvas: zunächst bewusst draußen gehalten (siehe Git-Historie // der .npmrc) — das Seiten-Rendering zu Bildern war hier nie gebraucht, // solange nur TEXT aus einem PDF gezogen wurde. Für den Plan Checker // (siehe "Plan Checker" weiter unten) ändert sich das: Maßketten und // Plankopf sind optische Merkmale einer Zeichnung, kein Fließtext, also // muss die Planseite als BILD an die KI gehen. Jetzt eine echte, direkte // Abhängigkeit (nicht mehr nur pdfjs-dists optionale) — die .npmrc mit // `omit=optional` ist deshalb entfallen, sie hätte sonst auch das für // @napi-rs/canvas selbst nötige, plattformspezifische Binärpaket (z. B. // @napi-rs/canvas-darwin-arm64) ausgeschlossen. const mammoth = require('mammoth'); // undici: NICHT für fetch() selbst (das liefert Node schon global) — nur für // die Agent-Klasse, um der KI-Erkennung gegen einen eigenen Server (siehe // fetchMitTimeout() weiter unten) ein höheres Verbindungslimit als Node/ // undicis eigenen Vorgabewert mitzugeben, siehe dort. const { Agent: UndiciAgent } = require('undici'); let pdfjsLibPromise = null; function ladePdfjs(){ if(!pdfjsLibPromise) pdfjsLibPromise = import('pdfjs-dist/legacy/build/pdf.mjs'); return pdfjsLibPromise; } async function extrahierePdfText(buf){ const pdfjsLib = await ladePdfjs(); const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf) }); try { const doc = await loadingTask.promise; let text = ''; for(let i = 1; i <= doc.numPages; i++){ const page = await doc.getPage(i); const content = await page.getTextContent(); // Textfragmente stehen ohne Zeilenumbrüche in content.items — erkennePunkteAusText() // braucht aber echte Zeilen (Punkt-Marker müssen am Zeilenanfang stehen). Ein // Umbruch wird eingefügt, sobald sich die Y-Koordinate (transform[5]) ändert, // exakt wie es das vormals genutzte pdf-parse intern selbst tat. let lastY = null; content.items.forEach(item => { if(lastY !== null && item.transform[5] !== lastY) text += '\n'; else if(lastY !== null) text += ' '; text += item.str; lastY = item.transform[5]; }); text += '\n\n'; } return text; } finally { loadingTask.destroy(); } } // ladeCanvas/renderPdfAlsBildsatz: fürs "Plan Checker"-Feature weiter unten // (POST /plan-checker-pruefen, aufgerufen aus pruefePlanMitKI()) — rendert // ausschließlich die ERSTE Seite eines PDFs zu einem Bildsatz (Übersicht + // hochaufgelöste Kachel-Ausschnitte, s. renderPdfAlsBildsatz() dort), für // BEIDE Anbieter gleich (siehe Projekthistorie bei pruefePlanMitClaude() // unten — ein einzelnes, von uns oder vom Anbieter selbst erzeugtes // Gesamtbild der Seite erwies sich für feine Maßzahlen/Plankopf-Text // wiederholt als zu grob). Ein Planblatt pro Prüfung ist der Normalfall; // mehrseitige PDFs mit mehreren Planblättern sind vorerst nicht vorgesehen. let canvasLibPromise = null; function ladeCanvas(){ if(!canvasLibPromise) canvasLibPromise = import('@napi-rs/canvas'); return canvasLibPromise; } // PDFJS_STANDARD_FONTS: Pfad zu den von pdfjs-dist mitgelieferten Standard- // Schriftdaten (Foxit-Ersatzschriften für Helvetica/Times/Symbol/...), mit // abschließendem Trenner wie von pdf.js intern per String-Konkatenation // erwartet (`${standardFontDataUrl}${dateiname}`). OHNE diesen Parameter // wirft pdf.js beim Rendern pro fehlender Schriftart die Warnung // "UnknownErrorException: Ensure that the standardFontDataUrl API parameter // is provided" und kann Zeichen nicht auf Glyphen abbilden — ein einfacher // Testtext (reine Helvetica-Buchstaben) rendert trotzdem noch halbwegs über // einen groben Ersatz, aber bei echten CAD-/Architektur-Export-PDFs // (Revit/AutoCAD, oft mit eigens eingebetteten oder nicht eingebetteten // Spezialschriften für Maßzahlen und Plankopf-Text) blieben genau diese // Textstellen dadurch leer oder unleserlich — die Maßketten-LINIEN kamen an // (reine Vektorgrafik, schriftunabhängig), die Zahlen und der Plankopf-Text // nicht. Nur für renderPdfAlsBildsatz() relevant: extrahierePdfText() // liest ausschließlich getTextContent() (die im PDF gespeicherten // Zeichencodes), ohne je eine Glyphe zu rendern, und ist von fehlenden // Schriftdaten nicht betroffen. const PDFJS_STANDARD_FONTS = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts') + path.sep; // PDFJS_WASM: Pfad zu den von pdfjs-dist mitgelieferten WASM-Modulen // (u. a. jbig2.wasm, openjpeg.wasm), im selben Muster wie PDFJS_STANDARD_FONTS // oben. OHNE diesen Parameter wirft pdf.js beim Laden eines WASM-Decoders // "UnknownErrorException: Ensure that the wasmUrl API parameter is provided" // und versucht dann, JBIG2-kodierte Bildinhalte (verbreitetes Kompressions- // format für eingebettete Scan-/Rasterbilder, z. B. ein eingescanntes Logo im // Plankopf) über einen Fallback-Pfad zu laden, der mangels `wasmUrl` zu einem // kaputten Modulnamen zusammengesetzt wird ("nulljbig2_nowasm_fallback.js") // und mit ERR_MODULE_NOT_FOUND scheitert — der betroffene Bildinhalt fehlt // dadurch im gerenderten PNG, genau wie zuvor die Ebenen/Schriften. const PDFJS_WASM = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'wasm') + path.sep; // PLAN_CHECK_KACHEL_*: Rastergröße und Überlappung für renderPdfAlsBildsatz() // unten. 2x2 (vier Kacheln) mit 12% Überlappung je Rand - genug // Sicherheitsrand, falls ein Feld (z. B. der Plankopf) genau auf einer // Kachelgrenze liegt, ohne die Zahl der pro Prüfung gesendeten Bilder // (= Kosten/Dauer je Anbieter-Anfrage) unnötig in die Höhe zu treiben. const PLAN_CHECK_KACHEL_SPALTEN = 2; const PLAN_CHECK_KACHEL_ZEILEN = 2; const PLAN_CHECK_KACHEL_UEBERLAPPUNG = 0.12; const PLAN_CHECK_UEBERSICHT_BREITE = 2000; const PLAN_CHECK_KACHEL_BREITE = 2200; // renderPdfAlsBildsatz: löst die vorherige renderPdfErstePageAlsPng() ab, die // die ganze Planseite als EIN einzelnes PNG rendert hat. Grund für die // Umstellung: Nach den vorherigen Fixes (Ebenen/Schrift/WASM, s.o.) meldete // Erik einen neuen, grundsätzlicheren Befund - der Plankopf wurde zwar jetzt // gefunden und der Plantitel korrekt gelesen, aber Projektname, // Planersteller, Maßstab, Datum und Plannummer blieben "in der vorliegenden // Auflösung" nicht sämtlich lesbar. Das bestätigt in letzter Konsequenz den // Verdacht, der schon zur vorherigen PDF-Direktübergabe an Claude geführt // hatte (s. historischer Kommentar bei pruefePlanMitClaude() weiter unten): // JEDER Anbieter skaliert ein übermitteltes Bild bzw. eine PDF-Seite intern // auf eine eigene Obergrenze herunter, BEVOR das Modell draufschaut (bei // Claude laut Dokumentation ca. 1568px lange Kante, bei OpenAI vergleichbar). // Ein Plankopf ist nur ein kleiner Ausschnitt eines großen Planblatts - seine // Feinschrift liegt nach dieser anbieterseitigen Herunterskalierung weit // unter Lesbarkeit, UNABHÄNGIG davon, wie hoch WIR die Ausgangsauflösung // wählen (mehr Pixel in der Quelle ändern nichts, wenn der Anbieter ohnehin // wieder auf dieselbe Obergrenze herunterrechnet). Claudes PDF-Direktübergabe // half laut Erik auch nicht durchgreifend genug - vermutlich, weil Anthropics // eigene Dokumenten-Verarbeitung intern denselben Weg geht (PDF-Seite zu // einem einzigen Bild, dann dieselbe Auflösungsgrenze). // // Der einzige verlässliche Ausweg: der KI ZUSÄTZLICH zur Übersicht mehrere, // überlappende Ausschnitte der Seite in höherer Pixel-Dichte mitgeben, sodass // Feinschrift gleich welcher Lage auf dem Blatt in mindestens einem Bild // unterhalb der anbieterseitigen Herunterskalierungs-Schwelle bleibt - OHNE // dafür die Lage des Plankopfs erkennen zu müssen (die variiert je // Büro-Vorlage: beim getesteten Plan oben rechts, DIN-Konvention wäre unten // rechts). Jede Kachel wird NICHT aus einem fertigen Raster-Bild // zugeschnitten (das brächte keine zusätzliche Schärfe, nur eine vergrößerte // Unschärfe), sondern pdf.js erneut mit einer höheren scale-Vorgabe gerendert // und per Context-Transform (ctx.translate) auf den gewünschten Ausschnitt // verschoben - der Ausschnitt entsteht also selbst aus dem Vektor-Original in // höherer Auflösung, nicht durch Hochskalieren eines bereits gerenderten // Bilds. Gilt jetzt für BEIDE Anbieter (Eriks ausdrückliche Entscheidung): // Claude bekommt dieselben gerenderten Bilder wie ChatGPT statt wieder der // rohen PDF, damit für beide dasselbe, selbst kontrollierte Verfahren gilt // statt für Claude erneut auf unbekanntes anbieterinternes Verhalten zu // vertrauen. // // Rückgabe: { uebersicht: Buffer, kacheln: Buffer[] } - je ein PNG-Buffer. async function renderPdfAlsBildsatz(buf){ const pdfjsLib = await ladePdfjs(); const { createCanvas } = await ladeCanvas(); const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf), standardFontDataUrl: PDFJS_STANDARD_FONTS, wasmUrl: PDFJS_WASM }); try { const doc = await loadingTask.promise; const page = await doc.getPage(1); const basisViewport = page.getViewport({ scale: 1 }); // Alle optionalen Ebenen (PDF Optional Content Groups) erzwungen // sichtbar schalten, BEVOR gerendert wird: CAD-/BIM-Exporte (AutoCAD, // Revit, ...) legen technische Pläne sehr häufig in Ebenen an (z. B. // "Architektur-Grundriss", "ELT-Planung", "Legende", "Plankopf") und // markieren einen Teil davon im PDF selbst als standardmäßig AUS - oft // bewusst so exportiert, damit jedes Gewerk seine eigene Ebene in // Acrobat selbst einschaltet. Ohne diese Ergänzung rendert pdf.js exakt // diesen Vorgabe-Zustand nach: bei einem echten Plan blieben dadurch nur // der Architektur-Grundriss übrig, ELT-Planung, Legende UND Plankopf // fehlten im gerenderten Bild komplett (von Erik direkt bestätigt, nach // Ansehen des gespeicherten Debug-PNGs) - die KI konnte diese Merkmale // also gar nicht als "nicht vorhanden" FALSCH einstufen, sie bekam sie // schlicht nie zu sehen. const ebenen = await doc.getOptionalContentConfig(); for(const [ebenenId] of ebenen){ ebenen.setVisibility(ebenenId, true); } // ausschnitt (optional): {x, y, breite, hoehe} in Geräte-Pixeln DIESES // scale-Werts. Ohne ausschnitt wird die ganze Seite gerendert. async function renderAusschnitt(scale, ausschnitt){ const viewport = page.getViewport({ scale }); const breite = ausschnitt ? Math.round(ausschnitt.breite) : Math.round(viewport.width); const hoehe = ausschnitt ? Math.round(ausschnitt.hoehe) : Math.round(viewport.height); const canvas = createCanvas(breite, hoehe); const ctx = canvas.getContext('2d'); if(ausschnitt) ctx.translate(-ausschnitt.x, -ausschnitt.y); await page.render({ canvasContext: ctx, viewport, optionalContentConfigPromise: Promise.resolve(ebenen) }).promise; return canvas.toBuffer('image/png'); } // Übersicht: die ganze Seite, moderate Auflösung - liefert der KI den // Gesamtkontext (Art/Lage der Zeichnung), aber keine verlässliche // Feinschrift. const uebersicht = await renderAusschnitt(PLAN_CHECK_UEBERSICHT_BREITE / basisViewport.width, null); // Kacheln: Raster mit Überlappung, JEDE Kachel mit eigener, hoher // Skalierung gerendert (nicht aus der Übersicht zugeschnitten, s. o.). const anteilBreite = (1 / PLAN_CHECK_KACHEL_SPALTEN) + PLAN_CHECK_KACHEL_UEBERLAPPUNG; const kachelScale = PLAN_CHECK_KACHEL_BREITE / (basisViewport.width * anteilBreite); const kachelViewport = page.getViewport({ scale: kachelScale }); const kacheln = []; for(let zeile = 0; zeile < PLAN_CHECK_KACHEL_ZEILEN; zeile++){ for(let spalte = 0; spalte < PLAN_CHECK_KACHEL_SPALTEN; spalte++){ const startAnteilX = Math.max(0, spalte / PLAN_CHECK_KACHEL_SPALTEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); const startAnteilY = Math.max(0, zeile / PLAN_CHECK_KACHEL_ZEILEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); const endAnteilX = Math.min(1, (spalte + 1) / PLAN_CHECK_KACHEL_SPALTEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); const endAnteilY = Math.min(1, (zeile + 1) / PLAN_CHECK_KACHEL_ZEILEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); kacheln.push(await renderAusschnitt(kachelScale, { x: startAnteilX * kachelViewport.width, y: startAnteilY * kachelViewport.height, breite: (endAnteilX - startAnteilX) * kachelViewport.width, hoehe: (endAnteilY - startAnteilY) * kachelViewport.height })); } } return { uebersicht, kacheln }; } finally { loadingTask.destroy(); } } const app = express(); // Wichtige Konstanten: Serverport (PORT-Env) und die gültigen Abteilungsordner const PORT = process.env.PORT || 3003; const VALID_DEPARTMENTS = new Set(['ELT','HLS','ARCH']); // SANDBOX_WEEK_LABEL: die eine, dauerhaft reservierte "Sitzung", in der alle // Planung-Terminpläne liegen (CLAUDE.md, "Planung — projektunabhängige // Terminplan-Sandbox"). Kein echtes Datum, keine echte Kalenderwoche — der // Name selbst ist der Marker, der dieses `weeks`-Label von jeder echten // Sitzung unterscheidet. MUSS mit der gleichnamigen Konstante in // public/js/globals.js übereinstimmen (kein gemeinsames Modulsystem zwischen // Server und Client in diesem Projekt, siehe "Frontend architecture"). const SANDBOX_WEEK_LABEL = '__sandbox__'; // Projekt-Zuordnungs-Rollen aus ROLES in public/js/globals.js (bewusst dupliziert, // kein Bundler vorhanden — NICHT zu verwechseln mit users.role, s. VALID_ROLES). const VALID_DISCIPLINE_ROLES = new Set(['Planung','Projektleitung','Bauüberwachung']); // VALID_ROLES: die Systemrollen eines Kontos (users.role, keine CHECK- // Constraint im Schema, daher hier die eine Stelle, die einen Wert // zurückweist). 'kiosk' ist das Wandtafel-Konto (siehe CLAUDE.md // "Wandtafel"): darf nur die lesenden Routen erreichen, die die Wandtafel // braucht (requireAuthOderKiosk weiter unten), überall sonst wie requireAuth // abgewiesen — und darf keine Fachrolle tragen (dort geprüft, wo roles // validiert wird). const VALID_ROLES = new Set(['admin', 'member', 'kiosk']); // VALID_AUFTRAGSSTATUS: der Weg von der Anfrage zum abgeschlossenen Auftrag // (projects.auftragsstatus). Die Reihenfolge ist die fachliche Abfolge und // steht genauso in AUFTRAGSSTATUS in public/js/auftrag.js — bewusst // dupliziert, es gibt keinen Bundler. Serverseitig geprüft wird das nicht // wegen der Oberfläche, sondern weil /save-week der einzige Schreibweg ist: // ein Tippfehler im Wert wäre sonst dauerhaft in der Datenbank und jede // Auswertung ("welche Projekte sind noch Angebot?") liefe daran vorbei. const VALID_AUFTRAGSSTATUS = new Set(['anfrage','angebot','beauftragt','abgeschlossen']); // DEFAULT_AUFTRAGSSTATUS: Vorgabe für Bestandsdaten und für unbelegte Felder. // 'beauftragt' und nicht 'anfrage', weil der gesamte Altbestand beauftragte // Arbeit ist — jeder andere Anfangswert hätte alle laufenden Projekte // rückwirkend zu Akquise erklärt. const DEFAULT_AUFTRAGSSTATUS = 'beauftragt'; // AUFTRAGSSTATUS_LABELS: lesbare Bezeichnungen für den CSV-Export. Der // Schlüssel selbst ('anfrage') steht in der Datenbank, in einer Tabelle für // Menschen hat er nichts zu suchen. const AUFTRAGSSTATUS_LABELS = { anfrage: 'Anfrage', angebot: 'Angebot', beauftragt: 'Beauftragt', abgeschlossen: 'Abgeschlossen' }; // normalizeAuftragsstatus: bringt einen eingehenden Wert auf einen gültigen // Schlüssel. Wird beim SCHREIBEN gebraucht (serializeWeek) — dort ist der Wert // bereits von der Route geprüft, leer oder fehlend darf er aber sein (alte // Clients, Demo-Daten) und wird dann zur Vorgabe. function normalizeAuftragsstatus(value){ const v = String(value || '').trim(); return VALID_AUFTRAGSSTATUS.has(v) ? v : DEFAULT_AUFTRAGSSTATUS; } // findInvalidAuftragsstatus: sucht in einer eingehenden Woche das erste Projekt // mit unbekanntem Auftragsstatus. Getrennt von normalizeAuftragsstatus(), weil // der Schreibweg BEIM SPEICHERN nicht stillschweigend zurechtbiegen darf: Ein // unbekannter Wert ist ein Fehler im Aufruf und wird mit 400 abgewiesen, // sonst käme aus der Oberfläche "Angebot" heraus und in der Datenbank stünde // "beauftragt". Ein fehlendes/leeres Feld ist dagegen erlaubt (alte Clients). // Rückgabe: { nr, name, wert } oder null, wenn alles gültig ist. function findInvalidAuftragsstatus(week){ const projects = Array.isArray(week && week.projects) ? week.projects : []; for(const p of projects){ const raw = p && p.auftragsstatus; if(raw === undefined || raw === null || raw === '') continue; const v = String(raw).trim(); if(!VALID_AUFTRAGSSTATUS.has(v)){ return { nr: String((p && p.nr) || ''), name: String((p && p.name) || ''), wert: v }; } } return null; } // Globale Middleware: CORS, JSON-Body-Limit (2 MB) und statisches Serving von public/ ohne Cache für Skripte/Styles app.use(require('cors')()); // Der JSON-Parser darf NICHT über die Routen mit rohem Binär-Body laufen: // Der Browser schickt beim Datei-Upload den Content-Type der Datei mit, und // bei einer .json-Datei würde express.json() den Datenstrom verbrauchen, bevor // express.raw() ihn bekommt ("Keine Datei empfangen"). 8 MB statt 2 MB, weil // eine Sitzung mit vielen Projekten und langen Kommentaren sonst irgendwann // still am 413 scheitert. const RAW_BODY_ROUTES = new Set(['/import', '/project-files', '/project-protokolle/import', '/plan-checker-pruefen', '/plan-todos-aus-protokoll/einlesen']); const jsonBodyParser = express.json({ limit: '8mb' }); app.use((req, res, next) => { if(req.method === 'POST' && RAW_BODY_ROUTES.has(req.path)) return next(); jsonBodyParser(req, res, next); }); // attachUser liest bei jedem Request das Session-Cookie und setzt req.user // (siehe Definition weiter unten im Abschnitt "Auth: Sessions, Middleware, // Endpunkte" — als function-Deklaration ist sie im gesamten Modul gehoistet). // Muss vor express.static laufen, damit sie garantiert für jeden Request greift. app.use(attachUser); app.use(express.static(path.join(__dirname, 'public'), { setHeaders: (res, path) => { if(path.endsWith('.js') || path.endsWith('.css') || path.endsWith('.html')){ res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); res.set('Pragma', 'no-cache'); res.set('Expires', '0'); } } })); // Datenverzeichnis sessions/ bei Bedarf anlegen (bind-mounted in Docker, enthält die DB) const SESSIONS_DIR = path.join(__dirname, 'sessions'); if(!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true }); // SQLite-Datenbank öffnen (wird angelegt, falls nicht vorhanden). Der Pfad ist // über die Umgebungsvariable DB_PATH übersteuerbar (für Tests/Backups), sonst // standardmäßig sessions/projektuebersicht.db. const DB_PATH = process.env.DB_PATH ? path.resolve(process.env.DB_PATH) : path.join(SESSIONS_DIR, 'projektuebersicht.db'); const db = new DatabaseSync(DB_PATH); db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA foreign_keys = ON;'); db.exec('PRAGMA busy_timeout = 5000;'); // 5 s warten statt SQLITE_BUSY-Fehlern bei parallelen Schreibern // ---------- Schema (voll relational normalisiert) ---------- // Der serialisierte Datenbestand einer Woche besteht aus folgenden Entitäten: // - weeks: Metadaten (id, label, date, locked) // - week_people / week_people_roles: personen-Kopien pro Woche // - attendance: Anwesenheit pro Woche/Person // - projects: Projekt-Metadaten pro Woche // - project_lph: LPH-Fortschrittswerte pro Projekt // - project_assign: Zuordnungen (Haupt/Optional) pro Projekt/Person/Rolle // - project_plan: Plan-Kopf (start, end, progress) pro Projekt // - plan_people: im Plan beteiligte Personen // - plan_todos / plan_todo_assignees / plan_todo_deps: TODO-Listen // - plan_milestones / plan_milestone_deps: Meilensteine // - globale Personen pro Abteilung kommen seit der Ablösung von // people/person_roles/person_absences aus users/user_roles/user_absences // (siehe loadGlobalPeople, migrateRetirePeopleTablesIntoUsers) db.exec(`CREATE TABLE IF NOT EXISTS weeks ( label TEXT NOT NULL, week_id TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '', locked INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT '', PRIMARY KEY (label) )`); db.exec(`CREATE TABLE IF NOT EXISTS week_people ( week_label TEXT NOT NULL, person_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', full_name TEXT NOT NULL DEFAULT '', array_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS week_people_roles ( week_label TEXT NOT NULL, person_id TEXT NOT NULL, role TEXT NOT NULL, role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, person_id, role) )`); db.exec(`CREATE TABLE IF NOT EXISTS attendance ( week_label TEXT NOT NULL, person_id TEXT NOT NULL, present INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS projects ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, nr TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT '', status_author TEXT NOT NULL DEFAULT '', status_at TEXT NOT NULL DEFAULT '', "group" TEXT NOT NULL DEFAULT '', array_order INTEGER NOT NULL DEFAULT 0, order_value INTEGER NOT NULL DEFAULT 0, -- besprochen: im Teammeeting abgehakt. Gehört zur SITZUNG, nicht zum -- Projekt: Jede Woche wird jedes Projekt neu durchgegangen, und der -- Haken beantwortet die Frage "hatten wir das schon?" nur für diese eine -- Sitzung. Deshalb steht er hier und nicht an einer wochenunabhängigen -- Stelle; beim Duplizieren einer Sitzung wird er zurückgesetzt. besprochen INTEGER NOT NULL DEFAULT 0, -- groesse: Umfang des Projekts als Zahl zwischen 0 und 1, gemessen am -- Referenzhonorar (app_settings.referenzHonorar). Sie tritt an die Stelle -- des Honorars, wenn keines hinterlegt ist, und ist damit die Grundlage -- der Auslastungsrechnung in Stunden. 0 = keine Angabe: Das Projekt ist -- dann nicht bewertbar und wird in der Auslastung als Lücke ausgewiesen, -- nicht stillschweigend als Null gezählt. groesse REAL NOT NULL DEFAULT 0, -- auftragsstatus: Wo das Projekt zwischen Anfrage und Abschluss steht -- ('anfrage'|'angebot'|'beauftragt'|'abgeschlossen', siehe -- VALID_AUFTRAGSSTATUS). Im Unterschied zu besprochen gehört der Wert zum -- PROJEKT und nicht zur Sitzung: Ein Angebot bleibt ein Angebot, bis es -- beauftragt wird, und wandert beim Duplizieren der Sitzung deshalb mit. -- Die Gruppe "In Anbahnung" deutete das bisher nur an; sie bleibt die -- Sortierung auf dem Board, sagt aber nichts darüber, ob schon ein Angebot -- draußen ist. auftragsstatus TEXT NOT NULL DEFAULT 'beauftragt', -- updated_at: Zeitstempel der letzten Speicherung DIESES Projekts. Bis zur -- Planung-Sandbox (CLAUDE.md, "Planung — projektunabhängige Terminplan- -- Sandbox") gab es nur eine Revision je WOCHE (weeks.updated_at) — genug, -- solange jede Sitzung nur wenige Projekte auf einmal hat. Die Sandbox legt -- aber mehrere, voneinander unabhängige Terminpläne in EINER reservierten -- Woche ab; ohne eine Zeitangabe je Projekt ließe sich "zuletzt bearbeitet" -- in der Planungsliste nicht je Plan unterscheiden. Für echte Projekte -- harmlos mitgeführt, aber ungenutzt. updated_at TEXT NOT NULL DEFAULT '', PRIMARY KEY (week_label, project_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_tags ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, tag TEXT NOT NULL, tag_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, project_id, tag) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_lph ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL, value TEXT NOT NULL DEFAULT '', PRIMARY KEY (week_label, project_id, lph) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_assign ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, person_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT '', state INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, project_id, person_id, role) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_aufwand ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL, role TEXT NOT NULL DEFAULT '', value REAL NOT NULL DEFAULT 0, PRIMARY KEY (week_label, project_id, lph, role) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_aktuelle_lp ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL, PRIMARY KEY (week_label, project_id, lph) )`); // project_beauftragte_lph: welche Leistungsphasen für dieses Projekt // BEAUFTRAGT sind — nicht zu verwechseln mit project_aktuelle_lp (die eine // gerade amtierende Phase) oder project_lph (Fortschritt in Prozent). // Grundlage für die Honorar-Terminplanung (siehe js/plan/plan-honorar.js): // das vereinbarte Honorar wird auf genau diese Phasen verteilt. db.exec(`CREATE TABLE IF NOT EXISTS project_beauftragte_lph ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL, PRIMARY KEY (week_label, project_id, lph) )`); // project_gewerke: welche Fachbereiche des Büros das Projekt bearbeiten // ('ELT'|'HLS'|'ARCH', dieselben Werte wie users.department). Ein Projekt // kann zu mehreren gehören — ein Schulanbau etwa zu ELT und HLS. // // Wozu: Die Auswahl der Projektbeteiligten wird darauf eingeschränkt. Ein // reines Elektro-Projekt soll nicht die ganze Belegschaft zur Auswahl // stellen; wer trotzdem jemanden aus einem anderen Gewerk braucht, holt ihn // ausdrücklich dazu (siehe den Wizard in js/projekt-wizard.js). // // Eigene Tabelle statt einer Spalte mit Trennzeichen — gleiche Bauart wie // project_beauftragte_lph, und damit dieselben Wege beim Serialisieren. db.exec(`CREATE TABLE IF NOT EXISTS project_gewerke ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, gewerk TEXT NOT NULL, PRIMARY KEY (week_label, project_id, gewerk) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_plan ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '', progress INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, project_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_people ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, person_id TEXT NOT NULL, array_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, project_id, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_todos ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '', done INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, show_in_gantt INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (week_label, project_id, todo_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL, person_id TEXT NOT NULL, PRIMARY KEY (week_label, project_id, todo_id, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_deps ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL, depends_on_id TEXT NOT NULL, PRIMARY KEY (week_label, project_id, todo_id, depends_on_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_milestones ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, milestone_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '', position TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (week_label, project_id, milestone_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, milestone_id TEXT NOT NULL, depends_on_id TEXT NOT NULL, PRIMARY KEY (week_label, project_id, milestone_id, depends_on_id) )`); // people / person_roles / person_absences (globale Personen pro Abteilung) sind // abgelöst worden (siehe migrateRetirePeopleTablesIntoUsers weiter unten) — // werden deshalb hier bewusst NICHT mehr unconditionally angelegt. Für Alt-DBs // im früheren Ein-Dokument-Format legt migrateLegacySchema() sie transient an, // migrateRetirePeopleTablesIntoUsers() überführt/droppt sie danach beim selben // Start. // ---------- User-Management, Sessions, Datei-Freigabe (Schema, additiv) ---------- db.exec(`CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', password_hash TEXT NOT NULL, password_salt TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', person_id TEXT NOT NULL DEFAULT '', active INTEGER NOT NULL DEFAULT 1, must_change_password INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' )`); db.exec(`CREATE INDEX IF NOT EXISTS idx_users_person_id ON users(person_id)`); db.exec(`CREATE TABLE IF NOT EXISTS user_sessions ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT '', last_seen_at TEXT NOT NULL DEFAULT '', expires_at TEXT NOT NULL DEFAULT '', FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE )`); // user_roles/user_absences: Rollen (Planung/Projektleitung/Bauüberwachung) und // Abwesenheiten je Benutzer — lösen die alten people/person_roles/ // person_absences-Tabellen vollständig ab (loadGlobalPeople liest nur noch // hieraus, migrateRetirePeopleTablesIntoUsers überführt/droppt die Altdaten). db.exec(`CREATE TABLE IF NOT EXISTS user_roles ( user_id INTEGER NOT NULL, role TEXT NOT NULL, role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (user_id, role), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE )`); // created_by: wer den Eintrag angelegt hat. Grundlage der Löschberechtigung — // eintragen darf jeder für jeden (die Projektleitung pflegt Urlaube ihres // Teams mit), aber entfernen nur die betroffene Person selbst, wer ihn // eingetragen hat, oder ein Administrator. 0 = Altbestand ohne Angabe. db.exec(`CREATE TABLE IF NOT EXISTS user_absences ( user_id INTEGER NOT NULL, id TEXT NOT NULL, start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '', type TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '', created_by INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (user_id, id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE )`); // Persönliche KI-Zugangsdaten für den Protokoll-Import (POST // /project-protokolle/import), siehe readUserAiKey()/writeUserAiKey() und // CLAUDE.md "### Protokolle": jedes Konto pflegt seinen eigenen Schlüssel + // optionale abweichende API-URL (z. B. ein Firmen-Proxy statt der // offiziellen Cloud-API) unter Einstellungen → Mein Konto — bewusst eine // eigene, kleine User-Kind-Tabelle statt Zeilen in app_settings, das // strukturell ein firmenweiter Singleton-Speicher ist. db.exec(`CREATE TABLE IF NOT EXISTS user_ai_keys ( user_id INTEGER NOT NULL, provider TEXT NOT NULL, api_key TEXT NOT NULL DEFAULT '', api_url TEXT NOT NULL DEFAULT '', PRIMARY KEY (user_id, provider), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE )`); db.exec(`CREATE TABLE IF NOT EXISTS project_files ( id TEXT PRIMARY KEY, project_nr TEXT NOT NULL, project_name TEXT NOT NULL DEFAULT '', original_filename TEXT NOT NULL, stored_name TEXT NOT NULL, content_type TEXT NOT NULL DEFAULT 'application/octet-stream', size_bytes INTEGER NOT NULL DEFAULT 0, uploaded_by_user_id INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' )`); db.exec(`CREATE INDEX IF NOT EXISTS idx_project_files_scope ON project_files(project_nr)`); db.exec(`CREATE TABLE IF NOT EXISTS project_file_reviewers ( file_id TEXT NOT NULL, user_id INTEGER NOT NULL, decision TEXT NOT NULL DEFAULT 'pending', decided_at TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '', PRIMARY KEY (file_id, user_id) )`); db.exec(`CREATE INDEX IF NOT EXISTS idx_pfr_user_pending ON project_file_reviewers(user_id, decision)`); db.exec(`CREATE TABLE IF NOT EXISTS project_file_comments ( id TEXT PRIMARY KEY, file_id TEXT NOT NULL, user_id INTEGER NOT NULL, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT '' )`); // Protokolle (Besprechungsprotokolle je Projekt) — wie project_files über // project_nr mit dem Projekt verknüpft, NICHT über week_label/project_id: // project_id wird bei jedem automatischen Sitzungswechsel // (ensureCurrentWeekRollover()) neu vergeben, ein Protokoll muss aber über // Sitzungen hinweg bestehen bleiben. week_label ist hier reine Information // (welche Sitzung lief beim Anlegen), keine Scoping-Spalte. db.exec(`CREATE TABLE IF NOT EXISTS project_protokolle ( id TEXT PRIMARY KEY, project_nr TEXT NOT NULL, project_name TEXT NOT NULL DEFAULT '', nummer INTEGER NOT NULL, datum TEXT NOT NULL DEFAULT '', betreff TEXT NOT NULL DEFAULT '', ort TEXT NOT NULL DEFAULT '', verfasser_user_id INTEGER NOT NULL, week_label TEXT NOT NULL DEFAULT '', quelle_dateiname TEXT NOT NULL DEFAULT '', quelle_stored_name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' )`); db.exec(`CREATE INDEX IF NOT EXISTS idx_protokolle_project ON project_protokolle(project_nr)`); // protokoll_teilnehmer: Freitext-Teilnehmerliste je Protokoll (Name + Firma), // unabhängig von Benutzerkonten — Baubesprechungen haben typischerweise // externe Teilnehmer (Bauherr, ausführende Firmen) ohne Login hier. db.exec(`CREATE TABLE IF NOT EXISTS protokoll_teilnehmer ( id TEXT PRIMARY KEY, protokoll_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', firma TEXT NOT NULL DEFAULT '', anwesend INTEGER NOT NULL DEFAULT 1, array_order INTEGER NOT NULL DEFAULT 0 )`); db.exec(`CREATE INDEX IF NOT EXISTS idx_protokoll_teilnehmer_protokoll ON protokoll_teilnehmer(protokoll_id)`); // protokoll_punkte: die eigentlichen Besprechungspunkte. stamm_id verbindet // alle fortgeschriebenen Kopien "desselben" Punkts über mehrere Protokolle // hinweg (siehe createProtokoll()) — nur daran hängt, ob ein Punkt als neu // gilt (neu=1), NICHT an nummer/gruppe: beides ist freier, jederzeit vom // Nutzer änderbarer Anzeige-Text (nummer z.B. "1", "1.1", "2.3"; gruppe z.B. // "ELT"/"HLS" — keine feste Liste, entsteht einfach durchs Eintragen). db.exec(`CREATE TABLE IF NOT EXISTS protokoll_punkte ( id TEXT PRIMARY KEY, protokoll_id TEXT NOT NULL, stamm_id TEXT NOT NULL, nummer TEXT NOT NULL DEFAULT '', gruppe TEXT NOT NULL DEFAULT '', neu INTEGER NOT NULL DEFAULT 0, thema TEXT NOT NULL DEFAULT '', text TEXT NOT NULL DEFAULT '', zustaendig TEXT NOT NULL DEFAULT '', termin TEXT NOT NULL DEFAULT '', erledigt INTEGER NOT NULL DEFAULT 0, erstellt_am TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'offen', array_order INTEGER NOT NULL DEFAULT 0 )`); db.exec(`CREATE INDEX IF NOT EXISTS idx_protokoll_punkte_protokoll ON protokoll_punkte(protokoll_id)`); // lph_templates: firmenweite Aufgaben-Vorlagen je Leistungsphase. Anders als // alle Tabellen darüber hängt diese an KEINER Sitzung und an keinem Projekt — // sie ist globale Konfiguration ("in LPH 5 machen wir immer diese Schritte"), // aus der auf der Projektplan-Seite per Klick TODOs erzeugt werden (siehe // js/plan/plan-templates.js). offset_days = Start relativ zum beim Einfügen // gewählten Datum (0 = am Starttag), duration_days = Dauer in Kalendertagen // (mindestens 1). Die erzeugten TODOs sind danach ganz normale plan_todos und // haben keinerlei Verbindung mehr zur Vorlage — spätere Änderungen an der // Vorlage wirken sich also NICHT auf bereits eingefügte Aufgaben aus (bewusst: // ein laufender Projektplan soll sich nicht rückwirkend ändern). db.exec(`CREATE TABLE IF NOT EXISTS lph_templates ( id TEXT PRIMARY KEY, lph TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', offset_days INTEGER NOT NULL DEFAULT 0, duration_days INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0 )`); db.exec('CREATE INDEX IF NOT EXISTS idx_lph_templates_lph ON lph_templates(lph)'); // plan_checker_merkmale: firmenweite Grundliste der Merkmale, gegen die der // Plan Checker (Seitenleisten-Bereich "Studenten") einen hochgeladenen Plan // prüft (z. B. "Maßketten vorhanden"). "prompt" ist die Prüfanweisung, die // an die KI geht — "titel" ist zugleich die Anzeige-Bezeichnung UND der // Schlüssel, unter dem die KI ihr Ergebnis zurückgibt. Anders als // lph_templates gibt es hier kein Gruppierungsfeld: die Liste gilt // unterschiedslos für jede Prüfung (plus ein optionales, NICHT // gespeichertes Zusatz-Merkmal nur für die jeweils eine Prüfung, siehe // POST /plan-checker-pruefen). db.exec(`CREATE TABLE IF NOT EXISTS plan_checker_merkmale ( id TEXT PRIMARY KEY, titel TEXT NOT NULL, prompt TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0 )`); // lph_honorar_anteile: firmenweite Honorar-Anteile je Leistungsphase in // Prozent (HOAI-Sätze). Bewusst NICHT als Konstante im Code, sondern // pflegbar — die Sätze hängen vom Leistungsbild ab (Technische Ausrüstung // nach Anlage 15 unterscheidet sich von der Objektplanung nach Anlage 10) // und ändern sich mit HOAI-Novellen. Startet leer und wird in der // Honorar-Maske auf der Projektplan-Seite von Administratoren befüllt. db.exec(`CREATE TABLE IF NOT EXISTS lph_honorar_anteile ( lph TEXT PRIMARY KEY, percent REAL NOT NULL DEFAULT 0 )`); // app_settings: firmenweite Einzelwerte als Schlüssel/Wert-Paare. Werte // stehen als TEXT und werden beim Lesen gewandelt; welche Schlüssel es gibt, // welchen Typ und welche Vorgabe sie haben, steht in APP_SETTING_DEFS weiter // unten — dort liegt auch die Prüfung. Eine Zeile existiert erst, wenn der // Wert einmal gesetzt wurde; sonst gilt die Vorgabe. Damit brauchen neue // Einstellungen weder eine eigene Tabelle noch eine Migration. db.exec(`CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '' )`); // holidays: firmenweite Feiertage und sonstige arbeitsfreie Tage. Bewusst // eine eigene Tabelle statt eines JSON-Werts in app_settings — es ist eine // Liste, die wächst, und sie soll sich einzeln pflegen lassen. Sie wirkt // überall dort, wo in WERKTAGEN gerechnet wird: workdaysBetween()/ // addWorkdays() in public/js/utils.js, also in der Terminplan-Berechnung aus // dem Honorar und in der Ressourcen-Auslastung des Projektbereichs. Die // Terminplanung über Abhängigkeiten (Auto-Schedule) rechnet weiterhin in // Kalendertagen — das war schon vorher so. db.exec(`CREATE TABLE IF NOT EXISTS holidays ( date TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '' )`); // ---------- Migration: altes Ein-Dokument-Format -> relationales Schema ---------- // hasLegacyWeeksTable: erkennt das alte Schema an der Daten-Spalte in `weeks`. function hasLegacyWeeksTable(){ const cols = db.prepare("SELECT name FROM pragma_table_info('weeks') WHERE name = 'data'").all(); return cols.length > 0; } // ---------- Serverseitige Bereinigung von Formatierungs-HTML ---------- // // Gegenstück zu sanitizeStatusHtml() (public/js/utils.js) für den Server. // Dieselbe Positivliste, dieselben Regeln — aber ohne DOM, deshalb ein // eigener, kleiner Tokenizer statt eines Parsers. // // Warum überhaupt serverseitig, wo der Client doch schon bereinigt: Weil die // Bereinigung im Client eine ANZEIGE-Maßnahme ist, keine Eingangskontrolle. // Wer eine gültige Anmeldung hat, kann die Routen direkt ansprechen und // beliebiges HTML in einen Kommentar oder Protokollpunkt schreiben. Bis // hierher wurde das unverändert gespeichert und erst beim Anzeigen entschärft // — jede Stelle, die das Feld künftig einmal ohne Bereinigung anfasst, wäre // damit sofort ein Einfallstor. Die Prüfung gehört an die Schreibstelle. // // Bewusst keine Abhängigkeit: Das Projekt kommt mit express/cors plus den // drei Protokoll-Import-Bibliotheken aus; ein Sanitizer-Paket dafür // aufzunehmen stünde in keinem Verhältnis zu diesen gut hundert Zeilen. const SICHERE_TAGS = new Set(['b','strong','i','em','u','p','br','div','span','font']); // Tags, deren INHALT mitverschwindet, statt ausgepackt zu werden: Bei //