/* * Express-Server der Projektübersicht: liefert die statischen Frontend-Dateien aus * public/ (Test.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 /save-people, * POST /delete-week. Ungültige Abteilungen fallen auf ELT zurück. */ const express = require('express'); const fs = require('fs'); const path = require('path'); const { DatabaseSync } = require('node:sqlite'); 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']); // Globale Middleware: CORS, JSON-Body-Limit (2 MB) und statisches Serving von public/ ohne Cache für Skripte/Styles app.use(require('cors')()); app.use(express.json({ limit: '2mb' })); 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;'); // ---------- 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 // - people / person_roles / person_absences: globale Personen pro Abteilung db.exec(`CREATE TABLE IF NOT EXISTS weeks ( department TEXT NOT NULL, label TEXT NOT NULL, week_id TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '', locked INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, label) )`); db.exec(`CREATE TABLE IF NOT EXISTS week_people ( department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', full_name TEXT NOT NULL DEFAULT '', array_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS week_people_roles ( department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL, role TEXT NOT NULL, role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id, role) )`); db.exec(`CREATE TABLE IF NOT EXISTS attendance ( department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL, present INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS projects ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, nr TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT '', "group" TEXT NOT NULL DEFAULT '', array_order INTEGER NOT NULL DEFAULT 0, order_value INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_lph ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL, value TEXT NOT NULL DEFAULT '', PRIMARY KEY (department, week_label, project_id, lph) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_assign ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, person_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT '', state INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id, person_id, role) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_aufwand ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL, role TEXT NOT NULL DEFAULT '', value REAL NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id, lph, role) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_aktuelle_lp ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL, PRIMARY KEY (department, week_label, project_id, lph) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_plan ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '', progress INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_people ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, person_id TEXT NOT NULL, array_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_todos ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '', done INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id, todo_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL, person_id TEXT NOT NULL, PRIMARY KEY (department, week_label, project_id, todo_id, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_deps ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL, depends_on_id TEXT NOT NULL, PRIMARY KEY (department, week_label, project_id, todo_id, depends_on_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_milestones ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, milestone_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '', position TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id, milestone_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, milestone_id TEXT NOT NULL, depends_on_id TEXT NOT NULL, PRIMARY KEY (department, week_label, project_id, milestone_id, depends_on_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS people ( department TEXT NOT NULL, person_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', full_name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS person_roles ( department TEXT NOT NULL, person_id TEXT NOT NULL, role TEXT NOT NULL, role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, person_id, role) )`); db.exec(`CREATE TABLE IF NOT EXISTS person_absences ( department TEXT NOT NULL, person_id TEXT NOT NULL, absence TEXT NOT NULL, PRIMARY KEY (department, person_id, absence) )`); // Erlaubt ausschliesslich die gültigen Abteilungen (ELT|HLS|ARCH, sonst ELT) function departmentKey(department){ return (typeof department === 'string' && VALID_DEPARTMENTS.has(department)) ? department : 'ELT'; } // ---------- 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; } // serializeWeek: zerlegt ein Wochen-Objekt (JSON vom Frontend) in die relationalen // Tabellen. Wird in einer Transaktion vom Aufrufer verwendet (deleteWeekData + // serializeWeek). Ersetzt alle Zeilen der Woche. function serializeWeek(department, week){ const weekLabel = String(week.label || ''); const upsertWeek = db.prepare(`INSERT INTO weeks (department, label, week_id, date, locked) VALUES (?, ?, ?, ?, ?) ON CONFLICT(department, label) DO UPDATE SET week_id = excluded.week_id, date = excluded.date, locked = excluded.locked`); upsertWeek.run(department, weekLabel, String(week.id || ''), String(week.date || ''), week.locked ? 1 : 0); const insWeekPerson = db.prepare(`INSERT INTO week_people (department, week_label, person_id, name, full_name, array_order) VALUES (?, ?, ?, ?, ?, ?)`); const insWeekPersonRole = db.prepare(`INSERT OR IGNORE INTO week_people_roles (department, week_label, person_id, role, role_order) VALUES (?, ?, ?, ?, ?)`); const insAttendance = db.prepare(`INSERT OR REPLACE INTO attendance (department, week_label, person_id, present) VALUES (?, ?, ?, ?)`); (Array.isArray(week.people) ? week.people : []).forEach((p, idx) => { insWeekPerson.run(department, weekLabel, String(p.id || ''), String(p.name || ''), String(p.fullName || ''), idx); (Array.isArray(p.role) ? p.role : []).forEach((r, ridx) => insWeekPersonRole.run(department, weekLabel, String(p.id || ''), String(r || ''), ridx)); }); const att = week.attendance || {}; Object.keys(att).forEach(pid => { insAttendance.run(department, weekLabel, pid, att[pid] ? 1 : 0); }); const insProject = db.prepare(`INSERT INTO projects (department, week_label, project_id, nr, name, status, "group", array_order, order_value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); const insLph = db.prepare(`INSERT OR REPLACE INTO project_lph (department, week_label, project_id, lph, value) VALUES (?, ?, ?, ?, ?)`); const insAssign = db.prepare(`INSERT OR REPLACE INTO project_assign (department, week_label, project_id, person_id, role, state) VALUES (?, ?, ?, ?, ?, ?)`); const insAufwand = db.prepare(`INSERT OR REPLACE INTO project_aufwand (department, week_label, project_id, lph, role, value) VALUES (?, ?, ?, ?, ?, ?)`); const insAktuelleLp = db.prepare(`INSERT OR IGNORE INTO project_aktuelle_lp (department, week_label, project_id, lph) VALUES (?, ?, ?, ?)`); const insPlan = db.prepare(`INSERT OR REPLACE INTO project_plan (department, week_label, project_id, start, end, progress) VALUES (?, ?, ?, ?, ?, ?)`); const insPlanPerson = db.prepare(`INSERT OR IGNORE INTO plan_people (department, week_label, project_id, person_id, array_order) VALUES (?, ?, ?, ?, ?)`); const insTodo = db.prepare(`INSERT INTO plan_todos (department, week_label, project_id, todo_id, title, start, end, done, progress, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); const insTodoAssignee = db.prepare(`INSERT OR IGNORE INTO plan_todo_assignees (department, week_label, project_id, todo_id, person_id) VALUES (?, ?, ?, ?, ?)`); const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (department, week_label, project_id, todo_id, depends_on_id) VALUES (?, ?, ?, ?, ?)`); const insMilestone = db.prepare(`INSERT INTO plan_milestones (department, week_label, project_id, milestone_id, name, date, position, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`); const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (department, week_label, project_id, milestone_id, depends_on_id) VALUES (?, ?, ?, ?, ?)`); (Array.isArray(week.projects) ? week.projects : []).forEach((p, idx) => { const projId = String(p.id || ''); insProject.run(department, weekLabel, projId, String(p.nr || ''), String(p.name || ''), String(p.status || ''), String(p.group || ''), idx, typeof p.order === 'number' ? p.order : idx); const lph = p.lph || {}; Object.keys(lph).forEach(l => insLph.run(department, weekLabel, projId, String(l || ''), String(lph[l] === undefined ? '' : lph[l]))); const assign = p.assign || {}; Object.keys(assign).forEach(key => { // Key-Format: "personId|Rolle" const sep = key.indexOf('|'); const pid = sep >= 0 ? key.slice(0, sep) : key; const role = sep >= 0 ? key.slice(sep + 1) : ''; insAssign.run(department, weekLabel, projId, pid, role, Number(assign[key]) || 0); }); const aufwand = p.aufwandProLP || {}; Object.keys(aufwand).forEach(lph => { const roles = aufwand[lph] || {}; Object.keys(roles).forEach(role => { const v = Number(roles[role]); if(!isNaN(v)) insAufwand.run(department, weekLabel, projId, String(lph || ''), String(role || ''), v); }); }); (Array.isArray(p.aktuelleLP) ? p.aktuelleLP : []).forEach(lph => { insAktuelleLp.run(department, weekLabel, projId, String(lph || '')); }); const plan = p.plan || null; if(plan){ insPlan.run(department, weekLabel, projId, String(plan.start || ''), String(plan.end || ''), Number(plan.progress) || 0); (Array.isArray(plan.people) ? plan.people : []).forEach((pid, pidx) => insPlanPerson.run(department, weekLabel, projId, String(pid || ''), pidx)); (Array.isArray(plan.todos) ? plan.todos : []).forEach((t, tidx) => { insTodo.run(department, weekLabel, projId, String(t.id || ''), String(t.title || ''), String(t.start || ''), String(t.end || ''), t.done ? 1 : 0, Number(t.progress) || 0, tidx); (Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => insTodoAssignee.run(department, weekLabel, projId, String(t.id || ''), String(pid || ''))); (Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(department, weekLabel, projId, String(t.id || ''), String(did || ''))); }); (Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => { insMilestone.run(department, weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx); (Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(department, weekLabel, projId, String(m.id || ''), String(did || ''))); }); } }); } // deleteWeekData: entfernt alle relationalen Zeilen einer Woche (Abteilung + Label) function deleteWeekData(department, weekLabel){ const tables = ['week_people_roles','week_people','attendance','project_lph','project_assign', 'project_aufwand','project_aktuelle_lp', 'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps', 'plan_milestones','plan_milestone_deps','projects']; tables.forEach(t => { db.prepare(`DELETE FROM ${t} WHERE department = ? AND week_label = ?`).run(department, weekLabel); }); // Die Wochen-Tabelle selbst nutzt `label` statt `week_label` db.prepare('DELETE FROM weeks WHERE department = ? AND label = ?').run(department, weekLabel); } // saveWeekRelational: speichert eine Woche vollständig relational (Transaktion) function saveWeekRelational(department, week){ const weekLabel = String(week.label || ''); const tx = db.exec('BEGIN'); try { deleteWeekData(department, weekLabel); serializeWeek(department, week); db.exec('COMMIT'); } catch(err){ db.exec('ROLLBACK'); throw err; } } // serializeGlobalPeople: zerlegt die globale Personenliste (JSON-Array) in die // Tabellen people/person_roles/person_absences. Ersetzt die gesamte Abteilungsliste. function serializeGlobalPeople(department, people){ const del = db.prepare(`DELETE FROM people WHERE department = ?`); const delRoles = db.prepare(`DELETE FROM person_roles WHERE department = ?`); const delAbsences = db.prepare(`DELETE FROM person_absences WHERE department = ?`); const tx = db.exec('BEGIN'); try { del.run(department); delRoles.run(department); delAbsences.run(department); const insPerson = db.prepare(`INSERT INTO people (department, person_id, name, full_name, email, sort_order) VALUES (?, ?, ?, ?, ?, ?)`); const insRole = db.prepare(`INSERT OR IGNORE INTO person_roles (department, person_id, role, role_order) VALUES (?, ?, ?, ?)`); const insAbsence = db.prepare(`INSERT OR IGNORE INTO person_absences (department, person_id, absence) VALUES (?, ?, ?)`); people.forEach((p, idx) => { insPerson.run(department, String(p.id || ''), String(p.name || ''), String(p.fullName || ''), String(p.email || ''), idx); (Array.isArray(p.role) ? p.role : []).forEach((r, ridx) => insRole.run(department, String(p.id || ''), String(r || ''), ridx)); (Array.isArray(p.absences) ? p.absences : []).forEach(a => insAbsence.run(department, String(p.id || ''), String(a || ''))); }); db.exec('COMMIT'); } catch(err){ db.exec('ROLLBACK'); throw err; } } // loadGlobalPeople: setzt die globale Personenliste einer Abteilung aus den // Tabellen people/person_roles/person_absences wieder zusammen. Rückgabe: Array. function loadGlobalPeople(department){ const people = db.prepare('SELECT * FROM people WHERE department = ? ORDER BY sort_order').all(department); const roles = db.prepare('SELECT * FROM person_roles WHERE department = ? ORDER BY role_order').all(department); const absences = db.prepare('SELECT * FROM person_absences WHERE department = ?').all(department); const rolesByPerson = {}; const absByPerson = {}; roles.forEach(r => { (rolesByPerson[r.person_id] = rolesByPerson[r.person_id] || []).push(r.role); }); absences.forEach(a => { (absByPerson[a.person_id] = absByPerson[a.person_id] || []).push(a.absence); }); return people.map(p => ({ id: p.person_id, name: p.name, fullName: p.full_name, email: p.email, role: rolesByPerson[p.person_id] || [], absences: absByPerson[p.person_id] || [], })); } // loadWeeks: setzt alle Wochen einer Abteilung aus den relationalen Tabellen // wieder zu kompletten Wochen-Objekten zusammen (Format wie vom Frontend erwartet). // Rückgabe: Array von Wochen-Objekten, sortiert nach Label. function loadWeeks(department){ const weeks = db.prepare('SELECT * FROM weeks WHERE department = ? ORDER BY label').all(department); const weekPeople = db.prepare('SELECT * FROM week_people WHERE department = ? ORDER BY array_order').all(department); const weekRoles = db.prepare('SELECT * FROM week_people_roles WHERE department = ? ORDER BY role_order').all(department); const attendance = db.prepare('SELECT * FROM attendance WHERE department = ?').all(department); const projects = db.prepare('SELECT * FROM projects WHERE department = ? ORDER BY array_order').all(department); const lph = db.prepare('SELECT * FROM project_lph WHERE department = ?').all(department); const assign = db.prepare('SELECT * FROM project_assign WHERE department = ?').all(department); const aufwand = db.prepare('SELECT * FROM project_aufwand WHERE department = ?').all(department); const aktuelleLp = db.prepare('SELECT * FROM project_aktuelle_lp WHERE department = ?').all(department); const plans = db.prepare('SELECT * FROM project_plan WHERE department = ?').all(department); const planPeople = db.prepare('SELECT * FROM plan_people WHERE department = ? ORDER BY array_order').all(department); const todos = db.prepare('SELECT * FROM plan_todos WHERE department = ? ORDER BY sort_order').all(department); const todoAssignees = db.prepare('SELECT * FROM plan_todo_assignees WHERE department = ?').all(department); const todoDeps = db.prepare('SELECT * FROM plan_todo_deps WHERE department = ?').all(department); const milestones = db.prepare('SELECT * FROM plan_milestones WHERE department = ? ORDER BY sort_order').all(department); const milestoneDeps = db.prepare('SELECT * FROM plan_milestone_deps WHERE department = ?').all(department); // Index-Karten für schnellen Zugriff const weekKey = (label) => department + '|' + label; const projKey = (label, pid) => department + '|' + label + '|' + pid; const todoKey = (label, pid, tid) => department + '|' + label + '|' + pid + '|' + tid; const msKey = (label, pid, mid) => department + '|' + label + '|' + pid + '|' + mid; const weekPeopleMap = {}; const weekRolesMap = {}; const attendanceMap = {}; weekPeople.forEach(r => { const k = weekKey(r.week_label); (weekPeopleMap[k] = weekPeopleMap[k] || [])[r.array_order] = { id: r.person_id, name: r.name, fullName: r.full_name }; }); weekRoles.forEach(r => { const k = weekKey(r.week_label); const arr = (weekRolesMap[k] = weekRolesMap[k] || {}); (arr[r.person_id] = arr[r.person_id] || []).push(r.role); }); attendance.forEach(r => { attendanceMap[weekKey(r.week_label) + '|' + r.person_id] = !!r.present; }); const projectsMap = {}; projects.forEach(r => { projectsMap[projKey(r.week_label, r.project_id)] = { id: r.project_id, nr: r.nr, name: r.name, status: r.status, group: r.group, lph: {}, assign: {}, order: r.order_value, plan: null, _arrayOrder: r.array_order, }; }); lph.forEach(r => { const p = projectsMap[projKey(r.week_label, r.project_id)]; if(p) p.lph[r.lph] = r.value; }); assign.forEach(r => { const p = projectsMap[projKey(r.week_label, r.project_id)]; if(p) p.assign[r.person_id + (r.role ? '|' + r.role : '')] = r.state; }); aufwand.forEach(r => { const p = projectsMap[projKey(r.week_label, r.project_id)]; if(p){ p.aufwandProLP = p.aufwandProLP || {}; (p.aufwandProLP[r.lph] = p.aufwandProLP[r.lph] || {})[r.role] = r.value; } }); aktuelleLp.forEach(r => { const p = projectsMap[projKey(r.week_label, r.project_id)]; if(p){ p.aktuelleLP = p.aktuelleLP || []; if(!p.aktuelleLP.includes(r.lph)) p.aktuelleLP.push(r.lph); } }); const planMap = {}; plans.forEach(r => { planMap[projKey(r.week_label, r.project_id)] = { start: r.start, end: r.end, progress: r.progress, people: [], todos: [], milestones: [] }; }); planPeople.forEach(r => { const plan = planMap[projKey(r.week_label, r.project_id)]; if(plan) plan.people.push(r.person_id); }); const todoMap = {}; const todoAssigneeMap = {}; const todoDepMap = {}; todos.forEach(r => { const k = todoKey(r.week_label, r.project_id, r.todo_id); todoMap[k] = { id: r.todo_id, title: r.title, start: r.start, end: r.end, done: !!r.done, dependsOn: [], assignees: [], progress: r.progress }; }); todoAssignees.forEach(r => { const t = todoMap[todoKey(r.week_label, r.project_id, r.todo_id)]; if(t) t.assignees.push(r.person_id); }); todoDeps.forEach(r => { const t = todoMap[todoKey(r.week_label, r.project_id, r.todo_id)]; if(t) t.dependsOn.push(r.depends_on_id); }); const msMap = {}; const msDepMap = {}; milestones.forEach(r => { const k = msKey(r.week_label, r.project_id, r.milestone_id); msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [] }; }); milestoneDeps.forEach(r => { const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)]; if(m) m.dependsOn.push(r.depends_on_id); }); // TODO- und Meilenstein-Objekte in die Projekt-Pläne einsortieren Object.keys(todoMap).forEach(k => { const parts = k.split('|'); const plan = planMap[department + '|' + parts[1] + '|' + parts[2]]; if(plan) plan.todos.push(todoMap[k]); }); Object.keys(msMap).forEach(k => { const parts = k.split('|'); const plan = planMap[department + '|' + parts[1] + '|' + parts[2]]; if(plan) plan.milestones.push(msMap[k]); }); // Wochen-Objekte zusammensetzen return weeks.map(w => { const wkey = weekKey(w.label); const people = (weekPeopleMap[wkey] || []).filter(Boolean).map(p => { const roles = (weekRolesMap[wkey] || {})[p.id] || []; const full = Object.assign({}, p); if(roles.length) full.role = roles; return full; }); const att = {}; (weekPeopleMap[wkey] || []).filter(Boolean).forEach(p => { const present = attendanceMap[wkey + '|' + p.id]; if(present !== undefined) att[p.id] = present; }); const weekProjects = Object.keys(projectsMap) .filter(k => k.startsWith(department + '|' + w.label + '|')) .map(k => { const p = projectsMap[k]; const plan = planMap[k]; if(plan) p.plan = plan; return p; }) .sort((a,b) => a._arrayOrder - b._arrayOrder) .map(({ _arrayOrder, ...rest }) => rest); return { id: w.week_id, label: w.label, date: w.date, people, projects: weekProjects, attendance: att, locked: !!w.locked, }; }); } // ---------- Migration: altes Ein-Dokument-Format -> relationales Schema ---------- // migrateLegacySchema: überführt eine bestehende Datenbank aus dem alten Format // (eine `weeks`-Tabelle mit JSON-`data`-Spalte sowie `people` mit JSON-`data`) // in das relationale Schema. Wird einmalig beim Start ausgeführt. function migrateLegacySchema(){ if(!hasLegacyWeeksTable()) return; console.log('Migriere altes Wochen-Dokument-Format in relationales Schema …'); // Alte Wochen-Dokumente lesen const legacyWeeks = db.prepare('SELECT department, label, data FROM weeks').all(); // Alte globale Personen-Dokumente lesen (eine JSON-Array-Zeile pro Abteilung) const legacyPeopleRows = []; const hasLegacyPeople = db.prepare("SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table' AND name='people' AND sql LIKE '%data%'").get().c > 0; if(hasLegacyPeople){ legacyPeopleRows.push(...db.prepare('SELECT department, data FROM people').all()); } const tx = db.exec('BEGIN'); try { // Alte Tabellen entfernen (Reihenfolge: erst Kind-Tabellen, dann Stamm) db.exec(`DROP TABLE IF EXISTS week_people_roles; DROP TABLE IF EXISTS week_people; DROP TABLE IF EXISTS attendance; DROP TABLE IF EXISTS project_lph; DROP TABLE IF EXISTS project_assign; DROP TABLE IF EXISTS project_aufwand; DROP TABLE IF EXISTS project_aktuelle_lp; DROP TABLE IF EXISTS project_plan; DROP TABLE IF EXISTS plan_people; DROP TABLE IF EXISTS plan_todos; DROP TABLE IF EXISTS plan_todo_assignees; DROP TABLE IF EXISTS plan_todo_deps; DROP TABLE IF EXISTS plan_milestones; DROP TABLE IF EXISTS plan_milestone_deps; DROP TABLE IF EXISTS projects; DROP TABLE IF EXISTS weeks; DROP TABLE IF EXISTS people; DROP TABLE IF EXISTS person_roles; DROP TABLE IF EXISTS person_absences;`); // Neue Tabellen anlegen (Schema-DDL oben ausführen) db.exec(`CREATE TABLE IF NOT EXISTS weeks ( department TEXT NOT NULL, label TEXT NOT NULL, week_id TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '', locked INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, label) )`); db.exec(`CREATE TABLE IF NOT EXISTS week_people ( department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', full_name TEXT NOT NULL DEFAULT '', array_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS week_people_roles ( department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL, role TEXT NOT NULL, role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id, role) )`); db.exec(`CREATE TABLE IF NOT EXISTS attendance ( department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL, present INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS projects ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, nr TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT '', "group" TEXT NOT NULL DEFAULT '', array_order INTEGER NOT NULL DEFAULT 0, order_value INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_lph ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL, value TEXT NOT NULL DEFAULT '', PRIMARY KEY (department, week_label, project_id, lph) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_assign ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, person_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT '', state INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id, person_id, role) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_aufwand ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL, role TEXT NOT NULL DEFAULT '', value REAL NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id, lph, role) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_aktuelle_lp ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, lph TEXT NOT NULL, PRIMARY KEY (department, week_label, project_id, lph) )`); db.exec(`CREATE TABLE IF NOT EXISTS project_plan ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '', progress INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_people ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, person_id TEXT NOT NULL, array_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_todos ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '', done INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id, todo_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL, person_id TEXT NOT NULL, PRIMARY KEY (department, week_label, project_id, todo_id, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_deps ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, todo_id TEXT NOT NULL, depends_on_id TEXT NOT NULL, PRIMARY KEY (department, week_label, project_id, todo_id, depends_on_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_milestones ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, milestone_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '', position TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id, milestone_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps ( department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL, milestone_id TEXT NOT NULL, depends_on_id TEXT NOT NULL, PRIMARY KEY (department, week_label, project_id, milestone_id, depends_on_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS people ( department TEXT NOT NULL, person_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', full_name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, person_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS person_roles ( department TEXT NOT NULL, person_id TEXT NOT NULL, role TEXT NOT NULL, role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, person_id, role) )`); db.exec(`CREATE TABLE IF NOT EXISTS person_absences ( department TEXT NOT NULL, person_id TEXT NOT NULL, absence TEXT NOT NULL, PRIMARY KEY (department, person_id, absence) )`); // Alte Dokumente einlesen und relational speichern. Die Tabellen wurden // gerade neu angelegt und sind leer; `serializeWeek` genügt (kein eigener // Transaktions-Wrapper, da die äußere BEGIN/COMMIT der Migration aktiv ist). legacyWeeks.forEach(row => { let week; try { week = JSON.parse(row.data); } catch(e){ console.error('Kaputte Woche bei Migration:', row.label, e.message); return; } if(!week || !week.label) return; serializeWeek(row.department, week); }); // Globale Personen aus dem alten Dokument-Format migrieren. serializeGlobalPeople // startet selbst eine Transaktion, daher werden die Zeilen hier direkt in die // neuen Tabellen eingefügt (serializeGlobalPeople ist in Transaktionen nicht // aufrufbar, weil BEGIN innerhalb BEGIN nicht erlaubt ist). legacyPeopleRows.forEach(row => { let people; try { people = JSON.parse(row.data); } catch(e){ console.error('Kaputte Personen-Liste bei Migration:', row.department, e.message); return; } if(!Array.isArray(people)) return; const insPerson = db.prepare(`INSERT INTO people (department, person_id, name, full_name, email, sort_order) VALUES (?, ?, ?, ?, ?, ?)`); const insRole = db.prepare(`INSERT OR IGNORE INTO person_roles (department, person_id, role, role_order) VALUES (?, ?, ?, ?)`); const insAbsence = db.prepare(`INSERT OR IGNORE INTO person_absences (department, person_id, absence) VALUES (?, ?, ?)`); people.forEach((p, idx) => { insPerson.run(row.department, String(p.id || ''), String(p.name || ''), String(p.fullName || ''), String(p.email || ''), idx); (Array.isArray(p.role) ? p.role : []).forEach((r, ridx) => insRole.run(row.department, String(p.id || ''), String(r || ''), ridx)); (Array.isArray(p.absences) ? p.absences : []).forEach(a => insAbsence.run(row.department, String(p.id || ''), String(a || ''))); }); }); db.exec('COMMIT'); console.log('Migration abgeschlossen (' + legacyWeeks.length + ' Wochen).'); } catch(err){ db.exec('ROLLBACK'); console.error('Migration fehlgeschlagen:', err.message); throw err; } } // Migration beim Serverstart ausführen migrateLegacySchema(); // Route: Speichert eine komplette Woche (als JSON-Objekt) für eine Abteilung app.post('/save-week', (req, res) => { const { department, week } = req.body || {}; if(!week || typeof week.label !== 'string' || !week.label) return res.status(400).json({ ok: false, error: 'Missing week or label' }); const dept = departmentKey(department); try { saveWeekRelational(dept, week); console.log('Saved week:', dept + ' / ' + week.label); return res.json({ ok: true }); } catch(err){ console.error('Save week error', err); return res.status(500).json({ ok: false, error: String(err) }); } }); // Route: Speichert die globale Personenliste einer Abteilung (als JSON-Array) app.post('/save-people', (req, res) => { const { department, people } = req.body || {}; if(!Array.isArray(people)) return res.status(400).json({ ok: false, error: 'Missing people array' }); const dept = departmentKey(department); try { serializeGlobalPeople(dept, people); console.log('Saved people:', dept, '(' + people.length + ' Personen)'); return res.json({ ok: true }); } catch(err){ console.error('Save people error', err); return res.status(500).json({ ok: false, error: String(err) }); } }); // Route: Löscht eine Woche einer Abteilung (fehlende Woche wird toleriert) app.post('/delete-week', (req, res) => { const { department, label } = req.body || {}; if(!label) return res.status(400).json({ ok: false, error: 'Missing label' }); const dept = departmentKey(department); try { const row = db.prepare('SELECT 1 FROM weeks WHERE department = ? AND label = ?').get(dept, String(label)); const existed = !!row; deleteWeekData(dept, String(label)); console.log('Deleted week:', dept + ' / ' + label, existed ? '' : '(nicht vorhanden)'); return res.json({ ok: true, deleted: existed }); } catch(err){ console.error('Delete week error', err); return res.status(500).json({ ok: false, error: String(err) }); } }); // Route: Lädt alle Wochen plus globale Personenliste einer Abteilung. // Liefert strukturierte JSON-Objekte (aus den relationalen Tabellen // zusammengesetzt); nach Label sortiert. app.get('/load-all', (req, res) => { try { const dept = departmentKey(req.query.department); const weeks = loadWeeks(dept); const people = loadGlobalPeople(dept); res.json({ ok: true, weeks, people }); } catch(err){ console.error('Load error', err); res.status(500).json({ ok: false, error: String(err) }); } }); // Route: Healthcheck-Endpunkt für Docker/Überwachung app.get('/health', (req,res)=> res.json({ok:true})); // Route: Liefert die Hauptseite Test.html app.get('/', (req,res)=>{ res.sendFile(path.join(__dirname, 'public', 'Test.html')); }); // Gültige Tab-Slugs für Deep-Link-Routen (muss zu TAB_SLUGS in js/globals.js passen) const VALID_TAB_SLUGS = new Set(['Projektübersicht','Auslastung','Projekte','Projektbeteiligte']); // Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen) const PLAN_SLUG = 'Projektplan'; // Route: Deep-Links je Abteilung + Tab, z. B. "/HLS/Auslastung" oder // "/ELT/Projektübersicht". Liefert wie "/" die Hauptseite; unbekannte // Abteilungen/Tabs werden abgelehnt (404), damit keine Test.html auf beliebigen // Pfaden ausgeliefert wird. app.get('/:department/:tab', (req, res) => { const { department, tab } = req.params; if(!VALID_DEPARTMENTS.has(department)) return res.status(404).send('Nicht gefunden'); if(tab === PLAN_SLUG){ // Projektplan-Seite (GANTT), z. B. "/HLS/Projektplan?weekLabel=..." return res.sendFile(path.join(__dirname, 'public', 'project-plan.html')); } if(!VALID_TAB_SLUGS.has(tab)) return res.status(404).send('Nicht gefunden'); res.sendFile(path.join(__dirname, 'public', 'Test.html')); }); // Route: Projektplan-Seite mit Projektname im Pfad, z. B. // "/HLS/Projektplan/Gymnasium%20Hemer?weekLabel=..." – das Pfad-Segment dient // nur der lesbaren Adresse, die Identifikation erfolgt über weekLabel/projNr. app.get('/:department/Projektplan/:projName', (req, res) => { if(!VALID_DEPARTMENTS.has(req.params.department)) return res.status(404).send('Nicht gefunden'); res.sendFile(path.join(__dirname, 'public', 'project-plan.html')); }); // Serverstart mit Fehlerbehandlung (z.B. Port bereits belegt) const server = app.listen(PORT, ()=> console.log(`Projektübersicht server running on http://localhost:${PORT}`)); server.on('error', (err) => { console.error('Server konnte nicht starten:', err.message); if(err.code === 'EADDRINUSE'){ console.error(`Port ${PORT} ist bereits belegt. Bitte einen anderen PORT verwenden, z.B. PORT=3004 npm start`); } process.exit(1); });