DB Import und Export sowie Backup und kleinere Fehlerbeseitigung

This commit is contained in:
erik committed 2026-08-18 09:16:59 +02:00
1 parent 91cc898ec7
commit 1c176ea621
24 files changed
+1081 -96

No files matched your search

+597 -10
View File
@@ -18,11 +18,17 @@
*
* 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.
* POST /delete-week, GET /export-csv (ZIP mit CSV je logischer Entität je Abteilung),
* 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 { DatabaseSync } = require('node:sqlite');
const app = express();
@@ -54,6 +60,7 @@ const DB_PATH = process.env.DB_PATH ? path.resolve(process.env.DB_PATH) : path.j
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) ----------
@@ -76,6 +83,7 @@ db.exec(`CREATE TABLE IF NOT EXISTS weeks (
week_id TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL DEFAULT '',
locked INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT '',
PRIMARY KEY (department, label)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS week_people (
@@ -253,9 +261,10 @@ function hasLegacyWeeksTable(){
// 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 nowIso = new Date().toISOString(); // Revision: wird bei jedem Speichern aktualisiert
const upsertWeek = db.prepare(`INSERT INTO weeks (department, label, week_id, date, locked, updated_at) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(department, label) DO UPDATE SET week_id = excluded.week_id, date = excluded.date, locked = excluded.locked, updated_at = excluded.updated_at`);
upsertWeek.run(department, weekLabel, String(week.id || ''), String(week.date || ''), week.locked ? 1 : 0, nowIso);
const insWeekPerson = db.prepare(`INSERT INTO week_people (department, week_label, person_id, name, full_name, array_order)
VALUES (?, ?, ?, ?, ?, ?)`);
@@ -339,6 +348,8 @@ function serializeWeek(department, week){
});
}
});
// Rückgabe der Revision für den Aufrufer (saveWeekRelational -> Route)
return nowIso;
}
// deleteWeekData: entfernt alle relationalen Zeilen einer Woche (Abteilung + Label)
@@ -354,14 +365,18 @@ function deleteWeekData(department, weekLabel){
db.prepare('DELETE FROM weeks WHERE department = ? AND label = ?').run(department, weekLabel);
}
// saveWeekRelational: speichert eine Woche vollständig relational (Transaktion)
// saveWeekRelational: speichert eine Woche vollständig relational (Transaktion).
// Rückgabe: die neu gesetzte Revision `updated_at` (ISO-Zeitstempel), damit der
// Client seine lokale Basis aktualisieren kann (sonst würde er beim nächsten
// Speichern einen falschen Konflikt mit sich selbst melden).
function saveWeekRelational(department, week){
const weekLabel = String(week.label || '');
const tx = db.exec('BEGIN');
try {
deleteWeekData(department, weekLabel);
serializeWeek(department, week);
const updatedAt = serializeWeek(department, week);
db.exec('COMMIT');
return updatedAt;
} catch(err){
db.exec('ROLLBACK');
throw err;
@@ -551,10 +566,463 @@ function loadWeeks(department){
return {
id: w.week_id, label: w.label, date: w.date,
people, projects: weekProjects, attendance: att, locked: !!w.locked,
updatedAt: w.updated_at || '',
};
});
}
// ---------- CSV-/ZIP-Export ----------
//
// GET /export-csv?department=<ELT|HLS|ARCH> liefert einen ZIP, der pro logischer
// Entität eine übersichtliche CSV-Datei enthält (Wochen, Personen, Projekte,
// Zuordnungen, Leistungsphasen, Pläne/TODOs/Meilensteine). Zielgruppe ist vor
// allem Excel; deshalb sind die CSVs UTF-8 mit BOM, CRLF-Zeilenenden und
// Semikolon als Trenner (MS-Excel-de standardmäßig). Das Trennzeichen lässt
// sich über das Query-Parameter `sep` übersteuern (z. B. `?sep=,`).
// CSV_TABLE: CRC32-Prüftabelle für den ZIP-Build (ZIP verlangt pro Datei eine
// CRC32-Summe; zlib.crc32 existiert erst ab neueren Node-Versionen, daher hier
// manuell).
const CSV_TABLE = (() => {
const t = new Uint32Array(256);
for(let n = 0; n < 256; n++){
let c = n;
for(let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c;
}
return t;
})();
function crc32(buf){
let c = 0xFFFFFFFF;
for(let i = 0; i < buf.length; i++) c = CSV_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
// dosDateTime: kodiert einen Zeitpunkt als DOS-Datum/-Uhrzeit (FAT-Format des
// ZIP-Headers). Rückgabe: {time, date}.
function dosDateTime(d){
const time = (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2);
const date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
return { time, date };
}
// buildZip: packt mehrere Dateien ohne externe Bibliothek in ein ZIP-Archiv
// (Deflate-Kompression, UTF-8-Dateinamen). Parameter: files = Array von
// {name, data}, wobei data Buffer oder String ist. Rückgabe: Buffer.
function buildZip(files){
const localParts = [];
const centralParts = [];
let offset = 0;
files.forEach(f => {
const nameBuf = Buffer.from(f.name, 'utf8');
const raw = Buffer.isBuffer(f.data) ? f.data : Buffer.from(f.data, 'utf8');
const compressed = zlib.deflateRawSync(raw);
const crc = crc32(raw);
const dt = dosDateTime(new Date());
const csize = compressed.length;
const usize = raw.length;
const nlen = nameBuf.length;
// Lokaler Datei-Header (30 Byte) + Name + Daten
const lh = Buffer.alloc(30);
lh.writeUInt32LE(0x04034b50, 0);
lh.writeUInt16LE(20, 4); // Version benötigt
lh.writeUInt16LE(0x0800, 6); // Flag: UTF-8-Namen
lh.writeUInt16LE(8, 8); // Methode: Deflate
lh.writeUInt16LE(dt.time, 10);
lh.writeUInt16LE(dt.date, 12);
lh.writeUInt32LE(crc, 14);
lh.writeUInt32LE(csize, 18);
lh.writeUInt32LE(usize, 22);
lh.writeUInt16LE(nlen, 26);
lh.writeUInt16LE(0, 28); // Extra-Länge
const local = Buffer.concat([lh, nameBuf, compressed]);
localParts.push(local);
// Zentralverzeichnis-Eintrag (46 Byte) + Name
const ch = Buffer.alloc(46);
ch.writeUInt32LE(0x02014b50, 0);
ch.writeUInt16LE(20, 4); // Version erstellt
ch.writeUInt16LE(20, 6); // Version benötigt
ch.writeUInt16LE(0x0800, 8);
ch.writeUInt16LE(8, 10);
ch.writeUInt16LE(dt.time, 12);
ch.writeUInt16LE(dt.date, 14);
ch.writeUInt32LE(crc, 16);
ch.writeUInt32LE(csize, 20);
ch.writeUInt32LE(usize, 24);
ch.writeUInt16LE(nlen, 28);
ch.writeUInt16LE(0, 30); // Extra
ch.writeUInt16LE(0, 32); // Kommentar
ch.writeUInt16LE(0, 34); // Datenträger
ch.writeUInt16LE(0, 36); // Interne Attribute
ch.writeUInt32LE(0, 38); // Externe Attribute
ch.writeUInt32LE(offset, 42); // Offset des lokalen Headers
centralParts.push(Buffer.concat([ch, nameBuf]));
offset += local.length;
});
const centralBuf = Buffer.concat(centralParts);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4); // Datenträger
end.writeUInt16LE(0, 6); // Datenträger mit CD
end.writeUInt16LE(files.length, 8); // Einträge auf Datenträger
end.writeUInt16LE(files.length, 10); // Einträge gesamt
end.writeUInt32LE(centralBuf.length, 12);
end.writeUInt32LE(offset, 16);
end.writeUInt16LE(0, 20); // Kommentarlänge
return Buffer.concat([...localParts, centralBuf, end]);
}
// CSV_SEP: Trennzeichen der Export-CSVs. Standard Semikolon, damit die Dateien
// in der deutschen Excel-Version direkt sauber öffnen; über `?sep=` übersteuerbar.
let CSV_SEP = ';';
// csvCell: escaped eine Zelle für CSV (Anführungszeichen verdoppeln, Zellen
// mit Trenner/Quote/Zeilenumbruch in Anführungszeichen setzen).
function csvCell(v){
const s = v === undefined || v === null ? '' : String(v);
if(s.includes(CSV_SEP) || s.includes('"') || s.includes('\n') || s.includes('\r')){
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
// csvFile: erzeugt aus Spaltenüberschriften (headers) und Zeilen (rows, je
// Array von Werten) eine fertige CSV-Datei-Beschreibung {name, data} für den
// ZIP-Build. Daten werden mit BOM + CRLF (Excel-kompatibel) erzeugt.
// name: Dateiname inkl. Unterordner (z. B. "ELT/projekte.csv").
function csvFile(name, headers, rows){
const lines = [csvRow(headers)];
rows.forEach(r => lines.push(csvRow(r)));
return { name, data: '\uFEFF' + lines.join('') };
}
// csvRow: eine CSV-Zeile als String (inkl. Zeilenende CRLF).
function csvRow(values){
return (values || []).map(csvCell).join(CSV_SEP) + '\r\n';
}
// csvStatusText: reduziert ein Status-HTML-Feld auf reinen Text für CSV
// (<br>/<p>/<div> werden zu Zeilenumbrüchen, alle übrigen Tags entfernt).
function csvStatusText(html){
if(!html) return '';
return String(html)
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div)>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\u00A0/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
// personNameById: Namen der Wochen-Personen nachschlagen (Kurzname bevorzugt).
function weekPersonName(week, id){
const p = (week.people || []).find(e => e.id === id);
return p ? (p.name || p.fullName || id) : id;
}
// buildExportCsvs: erzeugt alle CSV-Dateien für eine Abteilung auf Basis der
// auch fürs Frontend verwendeten Deserialisierer (loadWeeks/loadGlobalPeople).
// Rückgabe: Array von {name, data} für buildZip.
function buildExportCsvs(department){
const weeks = loadWeeks(department);
const people = loadGlobalPeople(department);
const dir = department + '/';
const files = [];
// 1) Wochen
files.push(csvFile(dir + 'wochen.csv',
['Abteilung', 'Woche (KW)', 'ID', 'Datum', 'Gesperrt'],
weeks.map(w => [department, w.label || '', String(w.id || ''), w.date || '', w.locked ? 'Ja' : 'Nein'])));
// 2) Globale Personen
files.push(csvFile(dir + 'personen.csv',
['Abteilung', 'Kürzel', 'Voller Name', 'E-Mail', 'Rollen', 'Abwesenheiten'],
people.map(p => [
department, p.name || '', p.fullName || '', p.email || '',
Array.isArray(p.role) ? p.role.join(', ') : '',
Array.isArray(p.absences) ? p.absences.join(', ') : '',
])));
// 3) Sitzungsteilnehmer inkl. Anwesenheit
const teilnehmer = [];
weeks.forEach(w => {
(w.people || []).forEach(p => {
const att = w.attendance || {};
teilnehmer.push([
w.label || '', p.name || '', p.fullName || '',
Array.isArray(p.role) ? p.role.join(', ') : '',
att[p.id] === undefined ? '' : (att[p.id] ? 'Ja' : 'Nein'),
]);
});
});
files.push(csvFile(dir + 'sitzungsteilnehmer.csv',
['Woche (KW)', 'Kürzel', 'Voller Name', 'Rollen', 'Anwesend'],
teilnehmer));
// 4) Projekte
files.push(csvFile(dir + 'projekte.csv',
['Woche (KW)', 'Projekt-Nr.', 'Bezeichnung', 'Gruppe', 'Reihenfolge', 'Status'],
weeks.flatMap(w => (w.projects || []).map(p => [
w.label || '', p.nr || '', p.name || '', p.group || '',
typeof p.order === 'number' ? p.order : '', csvStatusText(p.status),
]))));
// 5) Projekt-Zuordnungen (Haupt/Optional je Person/Rolle)
const zuordnungen = [];
weeks.forEach(w => {
(w.projects || []).forEach(p => {
Object.keys(p.assign || {}).forEach(aKey => {
const sep = aKey.indexOf('|');
const pid = sep >= 0 ? aKey.slice(0, sep) : aKey;
const role = sep >= 0 ? aKey.slice(sep + 1) : '';
const state = Number(p.assign[aKey]) || 0;
zuordnungen.push([
w.label || '', p.nr || '', p.name || '', weekPersonName(w, pid), role,
state === 1 ? 'Haupt' : state === 2 ? 'Optional' : String(state),
]);
});
});
});
files.push(csvFile(dir + 'zuordnungen.csv',
['Woche (KW)', 'Projekt-Nr.', 'Bezeichnung', 'Kürzel', 'Rolle', 'Art'],
zuordnungen));
// 6) Leistungsphasen (Fortschrittswerte je Projekt/LPH)
const lphRows = [];
weeks.forEach(w => {
(w.projects || []).forEach(p => {
(p.lph || {}) && Object.keys(p.lph || {}).forEach(l =>
lphRows.push([w.label || '', p.nr || '', p.name || '', l, p.lph[l]]));
});
});
files.push(csvFile(dir + 'leistungsphasen.csv',
['Woche (KW)', 'Projekt-Nr.', 'Bezeichnung', 'Leistungsphase', 'Wert'],
lphRows));
// 7) Aktuelle Leistungsphasen je Projekt
const aktLpRows = [];
weeks.forEach(w => {
(w.projects || []).forEach(p => {
(Array.isArray(p.aktuelleLP) ? p.aktuelleLP : []).forEach(l =>
aktLpRows.push([w.label || '', p.nr || '', p.name || '', l]));
});
});
files.push(csvFile(dir + 'aktuelle-leistungsphasen.csv',
['Woche (KW)', 'Projekt-Nr.', 'Bezeichnung', 'Leistungsphase'],
aktLpRows));
// 8) Aufwand je Leistungsphase/Rolle (Gewichtungsfaktoren)
const aufwandRows = [];
weeks.forEach(w => {
(w.projects || []).forEach(p => {
(p.aufwandProLP || {}) && Object.keys(p.aufwandProLP || {}).forEach(l => {
Object.keys(p.aufwandProLP[l] || {}).forEach(role =>
aufwandRows.push([w.label || '', p.nr || '', p.name || '', l, role, p.aufwandProLP[l][role]]));
});
});
});
files.push(csvFile(dir + 'aufwand-je-leistungsphase.csv',
['Woche (KW)', 'Projekt-Nr.', 'Bezeichnung', 'Leistungsphase', 'Rolle', 'Faktor'],
aufwandRows));
// 9) Projektpläne (GANTT-Kopf)
const planRows = [];
weeks.forEach(w => {
(w.projects || []).forEach(p => {
const plan = p.plan;
if(!plan) return;
planRows.push([
w.label || '', p.nr || '', p.name || '', plan.start || '', plan.end || '',
Number(plan.progress) || 0,
(plan.people || []).map(pid => weekPersonName(w, pid)).join(', '),
]);
});
});
files.push(csvFile(dir + 'projektplaene.csv',
['Woche (KW)', 'Projekt-Nr.', 'Bezeichnung', 'Start', 'Ende', 'Fortschritt (%)', 'Beteiligte'],
planRows));
// 10) TODOs der Projektpläne (Zuständige und Abhängigkeiten als lesbare Namen)
const todoRows = [];
weeks.forEach(w => {
(w.projects || []).forEach(p => {
const plan = p.plan;
if(!plan || !Array.isArray(plan.todos)) return;
const titleById = {};
(plan.todos || []).forEach(t => { titleById[t.id] = t.title; });
(plan.milestones || []).forEach(m => { titleById[m.id] = m.name; });
(plan.todos || []).forEach(t => {
todoRows.push([
w.label || '', p.nr || '', p.name || '', t.title || '', t.start || '',
t.end || '', t.done ? 'Ja' : 'Nein', Number(t.progress) || 0,
(t.assignees || []).map(pid => weekPersonName(w, pid)).join(', '),
(t.dependsOn || []).map(did => titleById[did] || did).join(', '),
]);
});
});
});
files.push(csvFile(dir + 'todos.csv',
['Woche (KW)', 'Projekt-Nr.', 'Bezeichnung', 'TODO', 'Start', 'Ende', 'Erledigt', 'Fortschritt (%)', 'Zuständige', 'Hängt ab von'],
todoRows));
// 11) Meilensteine der Projektpläne
const msRows = [];
weeks.forEach(w => {
(w.projects || []).forEach(p => {
const plan = p.plan;
if(!plan || !Array.isArray(plan.milestones)) return;
const titleById = {};
(plan.todos || []).forEach(t => { titleById[t.id] = t.title; });
(plan.milestones || []).forEach(m => { titleById[m.id] = m.name; });
(plan.milestones || []).forEach(m => {
msRows.push([
w.label || '', p.nr || '', p.name || '', m.name || '', m.date || '',
m.position || '',
(m.dependsOn || []).map(did => titleById[did] || did).join(', '),
]);
});
});
});
files.push(csvFile(dir + 'meilensteine.csv',
['Woche (KW)', 'Projekt-Nr.', 'Bezeichnung', 'Meilenstein', 'Datum', 'Position', 'Hängt ab von'],
msRows));
return files;
}
// ---------- Datenbank-Backup (Ergänzung zum CSV-Export) ----------
//
// Zwei Wege: (1) Beim Serverstart wird automatisch eine Kopie der SQLite-DB
// als zeitgestempelte Datei in sessions/backups/ abgelegt (nur die letzten
// MAX_BACKUPS werden behalten). (2) GET /backup liefert einen konsistenten
// Snapshot der DB als herunterladbare .db-Datei (z. B. über den Button
// "DB Backup" im Menü der Hauptseite). Beide nutzen `VACUUM INTO`, das auch
// bei aktivem WAL-Mode eine konsistente Einzeldatei erzeugt (WAL-Inhalte
// werden einbezogen).
// BACKUP_DIR: Zielordner der automatischen Backups (sessions/backups, Docker-
// bind-mounted → bleibt persistent).
const BACKUP_DIR = path.join(SESSIONS_DIR, 'backups');
// MAX_BACKUPS: Anzahl der automatisch behaltenen Backups (Älteste werden verworfen)
const MAX_BACKUPS = 10;
// createDbSnapshot: erzeugt einen konsistenten Snapshot der SQLite-DB in die
// angegebene Zieldatei (VACUUM INTO; funktioniert auch bei WAL-Mode).
function createDbSnapshot(targetPath){
const quoted = String(targetPath).replace(/'/g, "''");
db.exec(`VACUUM INTO '${quoted}'`);
}
// runAutoBackup: legt ein zeitgestempeltes Backup in BACKUP_DIR ab und
// verwirft ältere, sobald MAX_BACKUPS überschritten ist. Fehler werden nur
// geloggt (Backup darf den Serverstart nicht verhindern).
function runAutoBackup(){
try {
if(!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
// Zeitstempel mit Millisekunden, damit auch zwei Backups innerhalb einer
// Sekunde (z. B. direkt nach dem Import) eindeutige Dateinamen erhalten.
const base = 'projektuebersicht_' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23);
let file = path.join(BACKUP_DIR, base + '.db');
let suffix = 1;
while(fs.existsSync(file)) file = path.join(BACKUP_DIR, base + '-' + (suffix++) + '.db');
createDbSnapshot(file);
console.log('Datenbank-Backup erstellt:', file);
// Nur die letzten MAX_BACKUPS behalten
const backups = fs.readdirSync(BACKUP_DIR)
.filter(f => /^projektuebersicht_.+\.db$/.test(f))
.map(f => ({ name: f, mtime: fs.statSync(path.join(BACKUP_DIR, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
backups.slice(MAX_BACKUPS).forEach(b => {
fs.unlinkSync(path.join(BACKUP_DIR, b.name));
});
} catch(err){
console.error('Automatisches Backup fehlgeschlagen:', err.message);
}
}
// ---------- Datenbank-Import (Wiederherstellung aus Backup) ----------
//
// POST /import nimmt eine mit "DB Backup" erzeugte .db-Datei als rohen
// Binär-Body entgegen, validiert das Schema und ersetzt den gesamten
// Datenbestand der laufenden Datenbank durch den Inhalt der Backup-Datei.
// Das Aufspielen erfolgt per ATTACH + DELETE/INSERT-SELECT in einer
// Transaktion: Die Live-Verbindung bleibt stabil, bei Fehlern wird
// zurückgerollt. Vor dem Import wird der Zustand vor dem Import als frisches
// Backup gesichert (runAutoBackup), damit er rückgängig gemacht werden kann.
// IMPORT_TABLES: alle Tabellen des relationalen Schemas; Grundlage für
// Validierung, Löschvorgang und Datentransfer. Die Reihenfolge ist ohne
// Bedeutung, da das Schema keine Foreign-Key-Constraints deklariert.
const IMPORT_TABLES = ['weeks','week_people','week_people_roles','attendance','projects',
'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','people','person_roles','person_absences'];
// dbTableColumns: liefert die Spaltennamen einer Tabelle (funktioniert gegen
// jede DatabaseSync-Instanz, auch read-only Backups) via pragma_table_info.
function dbTableColumns(conn, table){
return conn.prepare('SELECT name FROM pragma_table_info(?)').all(table).map(r => r.name);
}
// IMPORT_OPTIONAL_COLS: Spalten, die in älteren Backups fehlen dürfen (sie
// werden beim Import mit dem Default-Wert der Live-Tabelle neu belegt).
const IMPORT_OPTIONAL_COLS = ['updated_at'];
// restoreDbFromBackup: validiert die Backup-Datei (muss das bekannte Schema
// mit identischen Spalten wie die Live-DB besitzen) und ersetzt den
// Datenbestand der Live-DB durch den Snapshot. Parameter: backupPath (Pfad).
// Wirft bei ungültigem Schema oder Transferfehler (Transaktion wird gerollt).
function restoreDbFromBackup(backupPath){
const backupConn = new DatabaseSync(backupPath, { readOnly: true });
try {
// 1) Schema-Validierung: Tabelle muss existieren und jede Spalte der
// Live-DB (außer den als optional markierten) enthalten. Zusätzliche
// Spalten im Backup (z. B. aus einer neueren Version) werden ignoriert.
for(const t of IMPORT_TABLES){
const liveCols = dbTableColumns(db, t);
const backCols = dbTableColumns(backupConn, t);
if(liveCols.length === 0) throw new Error('Live-Schema defekt (Tabelle fehlt): ' + t);
if(backCols.length === 0) throw new Error('Ungültiges Backup (Tabelle fehlt): ' + t);
const missing = liveCols.filter(c => !backCols.includes(c) && !IMPORT_OPTIONAL_COLS.includes(c));
if(missing.length > 0) throw new Error('Backup-Schema passt nicht zur Live-DB (Tabelle ' + t + ', fehlt: ' + missing.join(', ') + ')');
}
// 2) Datentransfer: Backup anbinden, Bestand löschen, Snapshot übernehmen.
db.exec(`ATTACH DATABASE '${String(backupPath).replace(/'/g, "''")}' AS import_backup`);
db.exec('BEGIN');
try {
IMPORT_TABLES.forEach(t => db.exec('DELETE FROM ' + t));
for(const t of IMPORT_TABLES){
const liveCols = dbTableColumns(db, t);
const backCols = dbTableColumns(backupConn, t);
// Nur Spalten übertragen, die BEIDE Schemas enthalten (alte Backups
// ohne updated_at bleiben so importierbar).
const common = liveCols.filter(c => backCols.includes(c));
// Anführungszeichen um jede Spalte: `projects` enthält die
// SQL-Schlüsselwort-Spalte "group", die sonst einen Syntaxfehler wirft.
const quoted = common.map(c => '"' + c + '"').join(', ');
const marks = common.map(() => '?').join(', ');
const rows = backupConn.prepare('SELECT ' + quoted + ' FROM ' + t).all();
const ins = db.prepare('INSERT INTO ' + t + ' (' + quoted + ') VALUES (' + marks + ')');
rows.forEach(r => ins.run(...common.map(c => r[c])));
}
db.exec('COMMIT');
} catch(err){
db.exec('ROLLBACK');
throw err;
} finally {
db.exec('DETACH DATABASE import_backup');
}
} finally {
backupConn.close();
}
}
// ---------- Migration: altes Ein-Dokument-Format -> relationales Schema ----------
// migrateLegacySchema: überführt eine bestehende Datenbank aus dem alten Format
@@ -589,6 +1057,7 @@ function migrateLegacySchema(){
db.exec(`CREATE TABLE IF NOT EXISTS weeks (
department TEXT NOT NULL, label TEXT NOT NULL, week_id TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL DEFAULT '', locked INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT '',
PRIMARY KEY (department, label)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS week_people (
@@ -717,18 +1186,64 @@ function migrateLegacySchema(){
}
}
// migrateWeeksUpdatedAt: ergänzt die `updated_at`-Spalte in bestehenden
// relationalen Datenbanken (idempotent; neue DBs haben die Spalte bereits).
function migrateWeeksUpdatedAt(){
const cols = db.prepare("SELECT name FROM pragma_table_info('weeks')").all().map(r => r.name);
if(!cols.includes('updated_at')){
db.exec("ALTER TABLE weeks ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''");
console.log('Wochen-Tabelle um updated_at ergänzt.');
}
}
// Migration beim Serverstart ausführen
migrateLegacySchema();
migrateWeeksUpdatedAt();
// Route: Speichert eine komplette Woche (als JSON-Objekt) für eine Abteilung
app.post('/save-week', (req, res) => {
const { department, week } = req.body || {};
const { department, week, baseUpdatedAt } = 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);
// Revisions-/Konflikt-Check: Mit `baseUpdatedAt` meldet der Client die
// Version, auf der seine lokalen Daten beruhen. Ist die Woche auf dem
// Server zwischenzeitlich von jemand anderem gespeichert worden, wird
// NICHT überschrieben, sondern ein Konflikt gemeldet (HTTP 409).
// Wichtig: Auch eine leere Basis (updated_at='' direkt nach der Migration)
// wird geprüft – nur Clients OHNE baseUpdatedAt-Feld (alte Versionen)
// überspringen die Prüfung. Der Check läuft synchron vor dem Schreiben,
// daher gibt es im Prozess keine Race-Condition zwischen Prüfung und
// Transaktion.
if(typeof baseUpdatedAt === 'string'){
const row = db.prepare('SELECT updated_at FROM weeks WHERE department = ? AND label = ?').get(dept, String(week.label));
if(row){
if(baseUpdatedAt !== row.updated_at){
return res.status(409).json({
ok: false,
conflict: true,
error: 'Die Sitzung "' + week.label + '" wurde zwischenzeitlich von einer anderen Person geändert.',
updatedAt: row.updated_at
});
}
} else if(baseUpdatedAt !== ''){
// Die Woche existiert am Server nicht mehr (vermutlich gelöscht). Ein
// Save mit nicht-leerer, also älterer Revision darf eine gelöschte
// Sitzung nicht wiederbeleben; nur neue Wochen (leere/fehlende Basis)
// dürfen angelegt werden.
return res.status(409).json({
ok: false,
conflict: true,
error: 'Die Sitzung "' + week.label + '" wurde zwischenzeitlich gelöscht.',
updatedAt: ''
});
}
}
// `updatedAt` zurückgeben, damit der Client seine lokale Revisions-Basis
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
const updatedAt = saveWeekRelational(dept, week);
console.log('Saved week:', dept + ' / ' + week.label);
return res.json({ ok: true });
return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
} catch(err){
console.error('Save week error', err);
return res.status(500).json({ ok: false, error: String(err) });
@@ -782,6 +1297,74 @@ app.get('/load-all', (req, res) => {
}
});
// Route: CSV-Export. Erzeugt einen ZIP mit einer übersichtlichen CSV je
// logischer Entität (Wochen, Personen, Sitzungsteilnehmer, Projekte,
// Zuordnungen, Leistungsphasen, Pläne/TODOs/Meilensteine) der angegebenen
// Abteilung. Das Trennzeichen kann über `?sep=` gesetzt werden (Standard: ';').
app.get('/export-csv', (req, res) => {
try {
const dept = departmentKey(req.query.department);
const sep = String(req.query.sep || ';');
CSV_SEP = (sep.length === 1) ? sep : ';';
const files = buildExportCsvs(dept);
const zip = buildZip(files);
const filename = 'Projektuebersicht_' + dept + '_' + new Date().toISOString().slice(0,10) + '.zip';
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="' + filename + '"');
res.setHeader('Content-Length', String(zip.length));
res.send(zip);
console.log('CSV-Export:', dept, '(' + files.length + ' Dateien, ' + zip.length + ' Bytes)');
} catch(err){
console.error('CSV-Export fehlgeschlagen:', err);
if(!res.headersSent) res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: DB-Snapshot-Download. Erzeugt einen konsistenten Snapshot der
// SQLite-Datenbank (auch mit WAL) und stellt ihn als .db-Datei zum Download
// bereit (Backup für Archivierung/Wiederherstellung, z. B. per "DB Backup"-
// Button im Menü). Die temporäre Datei wird nach dem Senden gelöscht.
app.get('/backup', (req, res) => {
const tmp = path.join(os.tmpdir(), 'projektuebersicht_backup_' + Date.now() + '.db');
try {
createDbSnapshot(tmp);
const filename = 'projektuebersicht_backup_' + new Date().toISOString().slice(0, 10) + '.db';
res.download(tmp, filename, () => fs.unlink(tmp, () => {}));
console.log('DB-Backup-Download:', filename);
} catch(err){
console.error('DB-Backup fehlgeschlagen:', err);
fs.unlink(tmp, () => {});
if(!res.headersSent) res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: DB-Import (Backup-Wiederherstellung). Erwartet eine .db-Datei aus
// GET /backup bzw. "DB Backup" als rohen Binär-Body (application/octet-stream);
// validiert das Schema und ersetzt den gesamten Datenbestand. VOR dem Import
// wird der Zustand vor dem Import als frisches Backup gesichert, damit ein
// versehentlicher Restore rückgängig gemacht werden kann.
app.post('/import', express.raw({ type: '*/*', limit: '100mb' }), (req, res) => {
const buf = req.body;
if(!Buffer.isBuffer(buf) || buf.length === 0){
return res.status(400).json({ ok: false, error: 'Keine Datei empfangen' });
}
const tmp = path.join(os.tmpdir(), 'projektuebersicht_import_' + Date.now() + '.db');
try {
fs.writeFileSync(tmp, buf);
// Zustand VOR dem Restore sichern (danach wäre der Snapshot bereits der
// importierte Stand und die Wiederherstellung unmöglich).
runAutoBackup();
restoreDbFromBackup(tmp);
console.log('Datenbank aus Backup wiederhergestellt (' + buf.length + ' Bytes)');
return res.json({ ok: true });
} catch(err){
console.error('Datenbank-Import fehlgeschlagen:', err);
if(!res.headersSent) res.status(500).json({ ok: false, error: String(err && err.message ? err.message : err) });
} finally {
fs.unlink(tmp, () => {});
}
});
// Route: Healthcheck-Endpunkt für Docker/Überwachung
app.get('/health', (req,res)=> res.json({ok:true}));
@@ -819,7 +1402,11 @@ app.get('/:department/Projektplan/:projName', (req, res) => {
});
// Serverstart mit Fehlerbehandlung (z.B. Port bereits belegt)
const server = app.listen(PORT, ()=> console.log(`Projektübersicht server running on http://localhost:${PORT}`));
const server = app.listen(PORT, () => {
console.log(`Projektübersicht server running on http://localhost:${PORT}`);
// Automatisches Datenbank-Backup beim Start (session/backups/, letzte MAX_BACKUPS)
runAutoBackup();
});
server.on('error', (err) => {
console.error('Server konnte nicht starten:', err.message);
if(err.code === 'EADDRINUSE'){