/* * 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 }. 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 //