/* * 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. Das native @napi-rs/canvas (nur für Seiten-Rendering // zu Bildern, hier nie gebraucht) ist bei pdfjs-dist nur eine optionale // Abhängigkeit; die Projekt-.npmrc (`omit=optional`) verhindert, dass es // überhaupt installiert wird — keine native Kompilierung, kein Canvas. 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(); } } 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']); 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, 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)'); // 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 //