DB Import und Export sowie Backup und kleinere Fehlerbeseitigung
This commit is contained in:
1 parent
91cc898ec7
commit
1c176ea621
24 files changed
+1081
-96
No files matched your search
@@ -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'){
|
||||
|
||||
Reference in new issue
Block a user