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
+1080 -95

No files matched your search

+61 -10
View File
@@ -42,6 +42,9 @@ user-facing text.
│ │ ├── showmode.js # Show-Mode (Präsentationsmodus)
│ │ ├── tabs-auth.js # showTab, confirmPassword (SHA256-Passwortschutz)
│ │ ├── pdf.js # PDF-Export (pdfmake, A3 quer)
│ │ ├── csv.js # exportCSV (lädt ZIP von GET /export-csv)
│ │ ├── backup.js # downloadBackup (lädt SQLite-Snapshot von GET /backup)
│ │ ├── import.js # importDb (Backup-Wiederherstellung über POST /import, passwortgeschützt)
│ │ └── init.js # renderAll/initUiState + Seiteneinstieg (nur Test.html)
│ │ ├── board/ # tab-specific: Projektboard
│ │ │ └── board.js # Projektboard-Rendering, Status-Editor, Anwesenheit, Zuordnung
@@ -94,13 +97,44 @@ docker compose up --build # Docker variant (compose file is at repo root)
(`/js/...`, `/styles.css`) so they resolve on nested routes.
- `GET /load-all?department=` → `{weeks:[weekObj], people:[personObj]}` with the
global people list in `people`; weeks are full JSON week objects (id, label,
date, locked, people, attendance, projects), sorted by label.
- `POST /save-week {department, week}` → upserts one week (JSON object, keyed by
department + label). Used by `sendWeekToServer()`.
date, locked, updatedAt, people, attendance, projects), sorted by label.
- `POST /save-week {department, week, baseUpdatedAt}` → upserts one week (JSON
object, keyed by department + label). Used by `sendWeekToServer()`. The client
sends its local revision in `baseUpdatedAt` (the `updatedAt` value from
`load-all`); the server compares it to `weeks.updated_at` before writing and
answers `409 {ok:false, conflict:true, error, updatedAt}` if someone else
saved the week in the meantime. A save with a **non-empty** base for a week
the server no longer knows is also rejected with 409 ("wurde gelöscht") so a
deleted week can't silently resurrect; only new weeks (empty/missing base)
pass. On success it returns `{ok:true, updatedAt}` (the new revision), which
the client stores as the new sync base. Clients WITHOUT `baseUpdatedAt` (old
versions) always pass. The check runs synchronously in-process before the
write transaction (no race between check and write).
- `POST /save-people {department, people}` → replaces the department's global
people list (JSON array).
- `POST /delete-week {department, label}` → deletes the week; missing label
yields 400, missing week tolerates with `deleted:false`.
- `GET /export-csv?department=` → ZIP with one readable CSV per logical entity
(wochen, personen, sitzungsteilnehmer, projekte, zuordnungen,
leistungsphasen, aktuelle-leistungsphasen, aufwand-je-leistungsphase,
projektplaene, todos, meilensteine) for that department. CSV: UTF-8 with BOM,
CRLF, `;` separator by default (overridable via `?sep=`; German Excel opens
it directly). ZIP is built in pure Node (`zlib.deflateRawSync`, manual CRC32,
no external library). Used by `exportCSV()` in `js/csv.js`.
- `GET /backup` → consistent SQLite snapshot (`VACUUM INTO`, WAL-safe) as a
`.db` file download (temp file in `os.tmpdir()`, deleted after send). Used by
`downloadBackup()` in `js/backup.js`. Additionally, on server startup the
server writes a timestamped copy to `sessions/backups/` (also via
`VACUUM INTO`) and keeps only the `MAX_BACKUPS` newest ones (default 10).
- `POST /import` → restores the whole database from a `.db` backup file sent as
a raw binary body (`express.raw`, limit 100 MB). Validates the schema
(`pragma_table_info` on every table, exact column match vs. live DB), then
swaps all data via `ATTACH DATABASE …` + DELETE/INSERT-SELECT inside one
transaction (rollback on error). Immediately BEFORE the restore the server
saves a fresh auto-backup of the current state via `runAutoBackup()` (so a
restore can be undone), then performs the swap. Used by `importDb()` in
`js/import.js`, which is password-gated (`requestedAction` type `'importDb'`
in `js/tabs-auth.js`) like week deletion.
- Valid departments hard-coded as `ELT|HLS|ARCH` in `server.js`
(`VALID_DEPARTMENTS`); anything else silently falls back to `ELT`. Same set in
`DEPARTMENTS` in `js/globals.js`.
@@ -112,7 +146,9 @@ docker compose up --build # Docker variant (compose file is at repo root)
- Data lives in `sessions/projektuebersicht.db` (`node:sqlite` `DatabaseSync`,
WAL mode, `PRAGMA foreign_keys = ON`), created automatically. The schema is
**fully relational** (18 tables) instead of one big JSON document per week:
- `weeks` (department, label, week_id, date, locked; PK `(department,label)`)
- `weeks` (department, label, week_id, date, locked, updated_at; PK
`(department,label)`) — `updated_at` is the revision timestamp for the
single-user conflict handling (see `/save-week` above)
- `week_people` / `week_people_roles` — person copies per week (`array_order`
keeps the list order, `role_order` the role array order)
- `attendance` — présence per week/person
@@ -129,12 +165,16 @@ docker compose up --build # Docker variant (compose file is at repo root)
tables inside a transaction, `loadWeeks()` / `loadGlobalPeople()`
(re)assemble them on read. `saveWeekRelational()` deletes the week's rows
first (`deleteWeekData()`, note `weeks` uses `label`, all child tables
`week_label`).
`week_label`) and returns the new `updated_at` revision written by
`serializeWeek()`.
- Migration on every start (idempotent):
`migrateLegacySchema()` converts a DB from the old single-JSON-document format
(detected via a `data` column in `weeks`): it reads the documents, drops all
tables, recreates the relational schema and inserts everything in one
transaction.
transaction. Afterwards `migrateWeeksUpdatedAt()` adds the `updated_at`
column to `weeks` (idempotent, `ALTER TABLE … ADD COLUMN`).
Note: legacy rows keep `updated_at = ''`, so the first client save with an
empty `baseUpdatedAt` matches and only then a real revision is written.
- The DB file sits in `sessions/`, so the existing Docker bind-mount
`./sessions:/app/sessions` keeps it persistent. The path is overridable via
the `DB_PATH` environment variable (used for tests), production default is
@@ -166,10 +206,21 @@ docker compose up --build # Docker variant (compose file is at repo root)
`openProjectPlan()` builds `/HLS/Projektplan/<ProjName>?weekLabel=…&projNr=…`,
`findProjectForPlan` resolves them. Person IDs follow
`personIdForName(name)` → `p_<lowercased alnum>`.
- `save()` debounces 500 ms then `syncAllToServer()` writes **all** weeks (via
`sendWeekToServer()`) plus the people list (via `sendPeopleToServer()`) — not
just the changed week. Exception: `js/plan/plan-state.js` `savePlan()` bypasses
`save()` and writes only the current week directly via `sendWeekToServer()`.
- `save()` debounces 500 ms then `syncAllToServer()` writes **only dirty weeks**
(`dirtyWeeks` – track `markWeekDirty()`) via `sendWeekToServer()`, plus the
people list `markPeopleDirty()` via `sendPeopleToServer()` if marked dirty.
An in-flight guard (`syncInFlight`/`syncQueued`) prevents overlapping syncs
from double-POSTing the same week with the same revision (which would look
like a spurious 409). On success the server-provided `updatedAt` revision is
adopted as the new sync base; weeks whose save failed (network errors) stay
dirty and are retried. A `409` conflict response drops the local changes for
that week and `handleWeekConflict()` reloads **only that week** from the
server (`reloadWeekFromServer()`), leaving all other dirty weeks and the
people list untouched. Exception: `js/plan/plan-state.js` `savePlan()`
bypasses `save()` and writes only the current week directly via
`sendWeekToServer()` (with the same in-flight/trailing-save guard), adopting
the returned `updatedAt` and showing a toast on conflict. `switchWeek()` (in
`js/weeks.js`) does NOT trigger a save – only actual edits do.
- `switchDepartment()` cancels a pending save debounce before switching, and
`load()` discards stale `/load-all` responses after a department switch
(frozen `requestedDepartment`), so late responses can never render or persist
+40 -34
View File
@@ -1,12 +1,12 @@
# Projektübersicht — README
## Übersicht
Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrotechnischer Projekte und Sitzungsübersichten. Die Anwendung besteht aus:
Dieses Projekt ist eine lokale Weboberfläche zur Verwaltung von TGA-Projekten (Elektro, HLS, Architektur) über wöchentliche Sitzungen. Die Anwendung besteht aus:
- einem Frontend in `Test.html`, `styles.css` und den JS-Modulen unter `public/js/`
- einem einfachen Node.js-Server in `server.js`
- einem Datenspeicher in einer SQLite-Datenbank (`node:sqlite`) unter `sessions/projektuebersicht.db`
- einem Docker-Setup unter `Docker/`
- einem Frontend aus zwei HTML-Einstiegen (`Test.html` für die Hauptansicht mit Tabs, `project-plan.html` für die Projektplanung/GANTT-Ansicht) plus `styles.css` und den JS-Modulen unter `public/js/`
- einem einfachen Node.js-/Express-Server in `server.js`
- einem Datenspeicher in einer SQLite-Datenbank (`node:sqlite`, benötigt Node ≥ 22.13) unter `sessions/projektuebersicht.db`
- einem Docker-Setup (`Docker/Dockerfile` + `docker-compose.yml` im Projektstamm)
## Datei- und Komponentenübersicht
@@ -14,15 +14,19 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
- `server.js`
- Node.js/Express-Server
- dient als API für das Frontend
- stellt statische Dateien (`Test.html`, die Module unter `js/`, `styles.css`, etc.) bereit
- stellt statische Dateien (`Test.html`, `project-plan.html`, die Module unter `js/`, `styles.css`, etc.) bereit; statische Assets werden mit `Cache-Control: no-store` ausgeliefert
- implementiert Endpunkte:
- `GET /` → liefert `Test.html`
- `GET /load-all?department=…` → lädt alle Sitzungen plus globale Personenliste einer Abteilung
- `POST /save-week {department, week}` → speichert/aktualisiert eine Sitzung
- `POST /save-week {department, week, baseUpdatedAt}` → speichert/aktualisiert eine Sitzung; mit `baseUpdatedAt` (die vom Client gehaltene Revision = `updatedAt` aus `load-all`) erkennt der Server veraltete Speicherungen und antwortet dann mit HTTP 409 `{ok:false, conflict:true, …}`; auch gelöschte Sitzungen werden mit nicht-leerer Basis nicht wiederbelebt (409). Erfolgreiche Saves antworten `{ok:true, updatedAt}` (neue Revision)
- `POST /save-people {department, people}` → ersetzt die globale Personenliste einer Abteilung
- `POST /delete-week {department, label}` → löscht eine Sitzung
- `GET /health` → Gesundheitscheck
- `GET /health` → Gesundheitscheck (`{ok:true}`)
- Deep Links je Abteilung + Tab, z. B. `GET /HLS/Auslastung` oder `GET /ELT/Projektübersicht` → liefert `Test.html`; gültige Tab-Slugs sind `Projektübersicht | Auslastung | Projekte | Projektbeteiligte`, alles andere ergibt 404
- `GET /<Dept>/Projektplan(/<ProjName>)?` → liefert `project-plan.html` (GANTT-Ansicht); die Identifikation erfolgt über die Query-Parameter `weekLabel` und `projNr`, der Projektname ist nur ein lesbares Pfad-Segment
- validiert Abteilungen gegen `ELT | HLS | ARCH` (unbekannte fallen stillschweigend auf `ELT` zurück)
- legt das `sessions`-Verzeichnis beim Start an, falls es nicht existiert
- JSON-Body-Limit von 2 MB
- beendet sich mit aussagekräftiger Fehlermeldung, wenn der Port belegt ist
- `package.json`
@@ -33,11 +37,10 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
- wird für reproduzierbare Docker-Builds verwendet (`npm ci`)
- `public/js/` (Module)
- Die Client-Logik ist in kleine klassische Script-Dateien aufgeteilt (alle Funktionen global auf `window`):
`globals.js`, `utils.js`, `ui.js`, `date-picker.js`, `server.js`, `store.js`, `weeks.js`,
`board.js`, `people.js`, `projects.js`, `load.js`, `gantt.js`, `showmode.js`, `tabs-auth.js`, `pdf.js`,
`init.js` sowie die Plan-Module unter `js/plan/` (`plan-state`, `plan-build-gantt`, `plan-gantt`,
`plan-drag`, `plan-modals`, `plan-schedule`).
- Die Client-Logik ist in kleine klassische Script-Dateien aufgeteilt (alle Funktionen global auf `window`, kein Bundler):
- gemeinsame Helfer: `globals.js`, `utils.js`, `ui.js`, `date-picker.js`, `server.js`, `store.js`, `weeks.js`, `gantt.js`, `showmode.js`, `tabs-auth.js`, `pdf.js`, `init.js`
- tab-spezifisch in eigenen Ordnern: `board/board.js` (Projektboard), `people/people.js` (Projektbeteiligte), `projects/projects.js` (Projekte), `load/load.js` (Auslastung)
- Planungs-Module unter `js/plan/` (`plan-state`, `plan-build-gantt`, `plan-gantt`, `plan-drag`, `plan-modals`, `plan-schedule`)
- rendert das Projektboard, die Projekt- und Personenverwaltung sowie die Auslastungsansicht
- lädt Daten über den Server und speichert Änderungen als JSON-Objekte in die SQLite-Datenbank zurück
- verwendet `currentDepartment` zur Trennung von Abteilungen (`ELT`, `HLS`, `ARCH`)
@@ -46,40 +49,42 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
- sortiert Sitzungen nach Kalenderwoche, nicht alphabetisch
- sperrt historische Sitzungen automatisch (bis auf die chronologisch letzte)
- Personen sind global pro Abteilung verwaltet; historische Sitzungen behalten ihre lokalen Personenkopien
- speichert keine Daten in `localStorage` (dort liegt nur UI-Zustand wie Abteilung, Theme und Header-Collapse)
- `Test.html`
- die Benutzeroberfläche der Anwendung
- die Haupt-Benutzeroberfläche der Anwendung (Tabs: Projektboard, Auslastung, Projekte, Projektbeteiligte)
- lädt `styles.css` und die JS-Module aus `js/` in fester Reihenfolge (siehe `AGENTS.md`)
- enthält die Abteilungsauswahl, Sitzungsnavigation, Tab-Navigation und das Passwort-Modal
- enthält die Abteilungsauswahl, Sitzungsnavigation, Tab-Navigation, das Passwort-Modal sowie PDF-Export
- lädt CryptoJS (Passwortprüfung) und pdfmake (PDF-Export) von `cdnjs.cloudflare.com` — diese Funktionen benötigen daher Internetzugang
- `project-plan.html`
- die Planungsansicht (GANTT/TODOs/Meilensteine) pro Projekt
- öffnet sich in einem neuen Tab über `openProjectPlan()` (`js/gantt.js`)
- lädt nur die gemeinsamen Helfermodule plus `js/plan/*`
- lädt nur die gemeinsamen Helfermodule plus `js/plan/*` und window-übergreifend abgestimmte Pfade
- `styles.css`
- Layout und Styling der Oberfläche
### Docker-bezogene Dateien
- `Docker/Dockerfile`
- Basis-Image: `node:20-alpine`
- Basis-Image: `node:22-alpine` (nötig, da `node:sqlite` erst ab Node 22.13 ohne Flag stabil verfügbar ist)
- kopiert `package.json` und `package-lock.json`, installiert Produktionsabhängigkeiten mit `npm ci`
- kopiert den Rest des Projekts in `/app`
- kopiert `server.js` und `public/` nach `/app`
- legt sicherheitshalber `/app/sessions` an
- läuft als nicht-root-User `node`
- enthält einen `HEALTHCHECK` auf `/health`
- startet `node server.js`
- `docker-compose.yml`
- `docker-compose.yml` (im Projektstamm)
- definiert den Service `projektuebersicht`
- baut das Image aus dem Projektstamm mit `Docker/Dockerfile`
- baut das Image mit Build-Kontext `.` und `Docker/Dockerfile`
- mappt Port `3003:3003`
- bindet das Host-Verzeichnis `./sessions` in den Container nach `/app/sessions`
- setzt `restart: unless-stopped`
- definiert einen `healthcheck`
- `.dockerignore`
- schließt unnötige Dateien aus dem Docker-Build aus: `Docker/`, `node_modules/`, `npm-debug.log`, `.git`, `.gitignore`
- schließt unnötige Dateien aus dem Docker-Build aus: `node_modules/`, `npm-debug.log`, `.git`, `.gitignore`, `Docker/*` (außer `Docker/Dockerfile`)
### Sonstige Dateien
- `dein_bild.ico`
@@ -88,25 +93,25 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
- `AGENTS.md`
- Projektnotizen / Dokumentation, aber nicht Teil des Anwendungslaufs
## Speicherorte und Datenfluss
## Persistenz und Datenfluss
### Speicherung
- Alle persistierten Daten werden in einer SQLite-Datenbank (`node:sqlite`, `DatabaseSync`, WAL-Modus) unter `sessions/projektuebersicht.db` gespeichert.
- Innerhalb von `sessions/` liegen ausschließlich die SQLite-Datenbankdateien (`projektuebersicht.db` + WAL/SHM).
- Alle persistierten Daten werden in einer SQLite-Datenbank (`node:sqlite`, `DatabaseSync`, WAL-Modus, `PRAGMA foreign_keys = ON`) unter `sessions/projektuebersicht.db` gespeichert.
- Das Schema ist vollständig relational (19 Tabellen): `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`). Projekt-/Personen-IDs bleiben dadurch über Neustarts stabil.
- Der Datenbankpfad ist über die Umgebungsvariable `DB_PATH` übersteuerbar (für Tests/Backups), Standard ist `sessions/projektuebersicht.db`.
### Wo wird der Server erstellt?
- Der Server wird in `server.js` erstellt.
- `server.js` startet einen Express-Server auf Port `3003` oder dem Wert von `process.env.PORT`.
- Das Frontend ruft diesen Server über `window.location.origin` an.
### Server-API
- Der Server wird in `server.js` erstellt und startet auf Port `3003` oder dem Wert von `process.env.PORT`.
- Das Frontend ruft den Server über `window.location.origin` an (Fallback `http://localhost:3003` bei `file://`-Öffnung).
### Wo werden die Daten gespeichert?
- Das Frontend schreibt über den Server in die SQLite-Datenbank.
- In `js/store.js` ruft die Anwendung beim Speichern `/save-week` (Sitzungen) bzw. `/save-people` (globale Personen) auf.
- Der Server schreibt die relationalen Tabellen in `sessions/projektuebersicht.db` (eine Transaktion pro Sitzung, siehe `serializeWeek()`/`loadWeeks()` in `server.js`).
- In `js/store.js` speichert die Anwendung nur **geänderte** Sitzungen (Dirty-Tracking über `markWeekDirty()`/`markPeopleDirty()`) via `/save-week` (Sitzungen) bzw. `/save-people` (globale Personen); ein In-Flight-Guard verhindert Doppel-Speicherungen. Jede Sitzung führt einen Revisions-Zeitstempel (`updated_at`, übertragen als `updatedAt`); speichert ein Client mit veralteter Revision (`baseUpdatedAt`), antwortet der Server mit HTTP 409 und der Client lädt nur diese eine Sitzung neu (andere ungespeicherte Änderungen bleiben erhalten).
- Der Server zerlegt die JSON-Objekte in die relationalen Tabellen (`serializeWeek()`/`loadWeeks()`) in einer Transaktion pro Sitzung. Beim Start migriert `migrateLegacySchema()` idempotent ein altes Single-JSON-Dokument-Schema in das relationale Schema; anschließend ergänzt `migrateWeeksUpdatedAt()` die `updated_at`-Spalte der `weeks`-Tabelle (idempotent). `PRAGMA busy_timeout = 5000` verhindert Fehler bei parallelen Schreibzugriffen.
## Betrieb
### Lokal mit Node.js
### Lokal mit Node.js (Node ≥ 22.13)
```bash
npm install
npm start
@@ -122,7 +127,7 @@ PORT=3004 npm start
Im Projektstamm ausführen:
```bash
docker compose -f Docker/docker-compose.yml up --build
docker compose up --build
```
Danach ist die Anwendung erreichbar unter:
@@ -133,7 +138,8 @@ http://localhost:3003
## Wichtige Hinweise
- Der Server benötigt Lese-/Schreibzugriff auf `sessions/` (für die SQLite-Datenbank und das Docker-Bind-Mount).
- Die Docker-Compose-Konfiguration mountet `./sessions` in den Container. Achte darauf, dass der relative Pfad im Kontext korrekt ist, wenn du `docker compose` aus einem anderen Verzeichnis startest.
- Die Docker-Compose-Konfiguration liegt im Projektstamm und mountet `./sessions` in den Container. Achte darauf, dass der relative Pfad im Kontext korrekt ist, wenn du `docker compose` aus einem anderen Verzeichnis startest.
- `js/store.js` und `js/server.js` arbeiten mit Abteilungsparametern und speichern Daten abteilungsweise getrennt.
- Die Passwortfunktion ist nur ein einfacher Client-Mechanismus im Frontend und kein echter Sicherheitsmechanismus.
- Die Passwortfunktion (Tabs „Projekte“/„Projektbeteiligte“, Sitzungslöschung, Entsperren) ist nur ein einfacher Client-Mechanismus im Frontend (SHA256-Vergleich) und kein echter Sicherheitsmechanismus.
- Globale Personen werden pro Abteilung in der Datenbank verwaltet. Wird eine Person global gelöscht, bleibt sie in bereits existierenden Sitzungen sichtbar (Wochen-Personenkopien).
- CryptoJS und pdfmake werden von `cdnjs.cloudflare.com` geladen — PDF-Export und Passwortprüfung funktionieren ohne Internet nicht.
+12
View File
@@ -77,6 +77,15 @@
<button class="secondary" onclick="exportPDF()">PDF Export</button>
<button class="secondary" onclick="startShowMode()">Show Mode</button>
</div>
<hr class="menu-divider">
<div class="menu-section">
<div class="menu-label">Backup</div>
<button class="secondary" onclick="exportCSV()">CSV Export</button>
<button class="secondary" onclick="downloadBackup()">DB Backup</button>
<button class="secondary" onclick="importDb()">DB Import</button>
</div>
</div>
</div>
@@ -238,6 +247,9 @@
<script src="/js/showmode.js?v=5"></script>
<script src="/js/tabs-auth.js?v=5"></script>
<script src="/js/pdf.js?v=5"></script>
<script src="/js/csv.js?v=1"></script>
<script src="/js/backup.js?v=1"></script>
<script src="/js/import.js?v=1"></script>
<script src="/js/init.js?v=5"></script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
// -----------------------------
// Projektübersicht — Datenbank-Backup (Download)
// -----------------------------
// Stellt downloadBackup() bereit: lädt einen konsistenten Snapshot der
// SQLite-Datenbank über GET /backup als .db-Datei herunter (VACUUM INTO,
// WAL-sicher). Hängt im Seitenmenü von Test.html am Button "DB Backup" und
// nutzt baseUrl() aus js/server.js. Ergänzt das automatische Backup, das der
// Server beim Start in sessions/backups/ ablegt.
// downloadBackup: fordert den DB-Snapshot an und startet den Download.
// Kein Parameter, keine Rückgabe; Fehler werden über ein Alert gemeldet.
async function downloadBackup(){
try {
const res = await fetch(baseUrl() + '/backup');
if(!res.ok){
let msg = 'Server HTTP ' + res.status;
try {
const json = await res.json();
if(json && json.error) msg = json.error;
} catch(e){ /* Antwort war kein JSON */ }
throw new Error(msg);
}
const blob = await res.blob();
const filename = 'projektuebersicht_backup_'
+ new Date().toISOString().slice(0, 10) + '.db';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch(err){
console.error('DB-Backup fehlgeschlagen:', err);
alert('DB-Backup fehlgeschlagen: ' + (err.message || err));
}
}
+1 -1
View File
@@ -146,7 +146,7 @@ function renderBoard(){
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
const aktiv = l === aktLp;
const inputAttr = locked ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
body += `<td class="lph-cell ${locked ? 'locked-cell' : ''}${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Aktive Leistungsphase"' : ''}><input type="text" value="${v}" ${inputAttr} placeholder="-">`;
body += `<td class="lph-cell ${locked ? 'locked-cell' : ''}${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Aktive Leistungsphase"' : ''}><input type="text" value="${esc(v)}" ${inputAttr} placeholder="-">`;
if(v!=""){ const pct = Math.min(100, parseFloat(v)||0);
body += `<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>`; }
body += `</td>`;
+42
View File
@@ -0,0 +1,42 @@
// -----------------------------
// Projektübersicht — CSV/ZIP-Export
// -----------------------------
// Stellt exportCSV() bereit: lädt den ZIP mit den übersichtlichen CSV-Dateien
// der aktuellen Abteilung über GET /export-csv und stößt den Download an.
// Hängt im Seitenmenü von Test.html am Button "CSV Export" und nutzt baseUrl()
// aus js/server.js sowie currentDepartment aus js/globals.js. Der ZIP enthält
// pro logischer Entität eine CSV (Wochen, Personen, Projekte, …), erzeugt vom
// Server (siehe server.js im Projekt-Root).
// exportCSV: fordert den CSV-Export der aktuellen Abteilung an und lädt ihn
// als ZIP-Datei herunter. Kein Parameter, keine Rückgabe; Fehler werden über
// ein Alert gemeldet.
async function exportCSV(){
try {
const dept = currentDepartment || 'ELT';
const res = await fetch(baseUrl() + '/export-csv?department=' + encodeURIComponent(dept));
if(!res.ok){
let msg = 'Server HTTP ' + res.status;
try {
const json = await res.json();
if(json && json.error) msg = json.error;
} catch(e){ /* Antwort war kein JSON */ }
throw new Error(msg);
}
const blob = await res.blob();
const filename = 'Projektuebersicht_' + dept + '_'
+ new Date().toISOString().slice(0, 10) + '.zip';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch(err){
console.error('CSV-Export fehlgeschlagen:', err);
alert('CSV-Export fehlgeschlagen: ' + (err.message || err));
}
}
+67
View File
@@ -0,0 +1,67 @@
// -----------------------------
// Projektübersicht — Datenbank-Import (Backup-Wiederherstellung)
// -----------------------------
// Stellt importDb() bereit: wählt über einen Datei-Dialog eine mit "DB Backup"
// erzeugte .db-Datei aus, lädt sie als rohen Binär-Body an POST /import und
// lädt nach erfolgreicher Wiederherstellung die Seite neu. Da der Import den
// kompletten Datenbestand ersetzt, ist er passwortgeschützt (gleiche Logik wie
// Wochen-Löschung über requestedAction/isAuthenticated, siehe js/tabs-auth.js
// und js/weeks.js) und wird zusätzlich im Browser per Bestätigungsdialog
// abgesichert.
// importDb: öffnet bei Bedarf das Passwort-Modal (requestedAction 'importDb')
// und startet danach den Datei-Dialog für das Backup. Parameter `skipAuth=true`
// überspringt die Passwortabfrage (wird von confirmPassword() genutzt).
// Keine Rückgabe.
function importDb(skipAuth = false){
if(!skipAuth && !isAuthenticated){
requestedAction = { type: 'importDb' };
document.getElementById('passwordModal').style.display = 'flex';
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
return;
}
if(!confirm('Datenbank-Backup wirklich wiederherstellen?\n\nAlle aktuellen Daten werden ersetzt.')){
requestedAction = null;
return;
}
const input = document.createElement('input');
input.type = 'file';
input.accept = '.db,application/octet-stream,application/x-sqlite3';
input.onchange = () => {
const file = input.files && input.files[0];
if(!file){
requestedAction = null;
return;
}
uploadBackup(file);
};
input.click();
requestedAction = null;
}
// uploadBackup: lädt die gewählte .db-Datei als rohen Binär-Body an
// POST /import und informiert über das Ergebnis. Bei Erfolg wird die Seite neu
// geladen, damit das Frontend den wiederhergestellten Datenbestand zeigt.
async function uploadBackup(file){
try {
const res = await fetch(baseUrl() + '/import', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: file
});
if(!res.ok){
let msg = 'Server HTTP ' + res.status;
try {
const json = await res.json();
if(json && json.error) msg = json.error;
} catch(e){ /* Antwort war kein JSON */ }
throw new Error(msg);
}
alert('Datenbank-Backup erfolgreich wiederhergestellt.\nDie Seite wird neu geladen.');
window.location.reload();
} catch(err){
console.error('DB-Import fehlgeschlagen:', err);
alert('DB-Import fehlgeschlagen: ' + (err.message || err));
}
}
+8 -8
View File
@@ -45,7 +45,7 @@ function addPerson(){
document.getElementById('newPersonFullName').value='';
document.getElementById('newPersonEmail').value='';
document.querySelectorAll('input[name="newPersonRoles"]').forEach(el=>el.checked=false);
save(); renderPeople(); renderBoard();
save({people:true}); renderPeople(); renderBoard();
}
// delPerson: entfernt eine Person aus der globalen Liste. Bereits in
@@ -76,7 +76,7 @@ function delPerson(id){
w.people = (w.people || []).filter(p=>p.id!==id);
}
save(); renderPeople(); renderBoard();
save({people:true}); renderPeople(); renderBoard();
}
// editPerson: ändert Name/Vollname/Rolle sowohl global als auch in der
@@ -132,7 +132,7 @@ function editPerson(id,field,val){
delete w.attendance[id];
}
}
save(); renderPeople(); renderBoard();
save({people:true}); renderPeople(); renderBoard();
return;
}
@@ -146,7 +146,7 @@ function editPerson(id,field,val){
const localPerson = (w.people || []).find(p => p.id === id);
if(localPerson) localPerson[field] = val;
}
save(); renderPeople(); renderBoard();
save({people:true}); renderPeople(); renderBoard();
}
// togglePersonRole: fügt eine Rolle (`role`) zur Person `id` hinzu bzw. entfernt
@@ -172,7 +172,7 @@ function togglePersonRole(id, role, checked){
if(globalPerson) updateRoles(globalPerson);
if(localPerson) updateRoles(localPerson);
save(); renderPeople(); renderBoard();
save({people:true}); renderPeople(); renderBoard();
}
// renderPeople: baut die HTML-Liste der Personen für den Personen-Tab auf
@@ -324,7 +324,7 @@ function reorderPeople(){
newPeople.forEach(p => { if(byId[p.id]) newWeekPeople.push(byId[p.id]); });
if(newWeekPeople.length === w.people.length) w.people = newWeekPeople;
}
save();
save({people:true});
}
}
@@ -348,7 +348,7 @@ function addAbsence(personId){
if(!Array.isArray(person.absences)) person.absences = [];
person.absences.push({ id: uid(), start, end, type, note });
person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
save();
save({week:false, people:true});
renderPeople();
if(currentTab === 'gantt') renderGantt();
}
@@ -361,7 +361,7 @@ function deleteAbsence(personId, absenceId){
const person = people.find(p => p.id === personId);
if(!person || !person.absences) return;
person.absences = person.absences.filter(a => a.id !== absenceId);
save();
save({week:false, people:true});
renderPeople();
if(currentTab === 'gantt') renderGantt();
}
+29 -5
View File
@@ -176,20 +176,44 @@ window.PlanApp = {
}).join('');
}
// Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben
// Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben.
// In-Flight-Schutz: läuft bereits ein Save, wird kein zweiter POST mit
// identischer Revisions-Basis abgeschickt (der würde fälschlich als 409-
// Konflikt gemeldet). Stattdessen wird ein Folgesave eingereiht, der nach
// Abschluss des laufenden Saves mit der dann frischen Revision nachzieht.
let savePlanRunning = false;
let savePlanQueued = false;
function savePlan(){
if(!app.week || !app.project) return;
sendWeekToServer(app.week).catch(err => console.error('Speichern fehlgeschlagen:', err));
if(savePlanRunning){ savePlanQueued = true; return; }
savePlanRunning = true;
sendWeekToServer(app.week)
.then(res => {
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
// nicht fälschlich als Konflikt gemeldet werden.
if(res && res.updatedAt) app.week.updatedAt = res.updatedAt;
})
.catch(err => {
if(err && err.conflict){
showToast('Konflikt: Die Sitzung wurde zwischenzeitlich geändert. Bitte Seite neu laden.');
console.error('Speichern fehlgeschlagen (Konflikt):', err.message || err);
} else {
console.error('Speichern fehlgeschlagen:', err);
}
})
.finally(() => {
savePlanRunning = false;
if(savePlanQueued){ savePlanQueued = false; savePlan(); }
});
}
// saveGlobalPeople: schreibt die globale Personenliste der Abteilung als
// JSON-Array auf den Server, aber nur wenn sie nicht leer ist.
// JSON-Array auf den Server. Auch eine leere Liste wird gespeichert, damit
// das Löschen der letzten Person erhalten bleibt.
function saveGlobalPeople(){
const people = db.globalPeople[app.department] || [];
if(people.length > 0){
sendPeopleToServer(people).catch(err => console.error('Speichern Personen fehlgeschlagen:', err));
}
}
// Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt)
function findGlobalPerson(personId){
+23 -3
View File
@@ -34,14 +34,34 @@ function blobToDataUrl(blob){
}
// sendWeekToServer: POSTet ein komplettes Wochen-Objekt (JSON) an
// <baseUrl>/save-week. Parameter: week (Objekt). Rückgabe: Promise mit Server-Antwort.
// <baseUrl>/save-week. Parameter: week (Objekt). Rückgabe: Promise mit
// Server-Antwort. `baseUpdatedAt` übermittelt die Revision, auf der die
// lokalen Daten beruhen; meldet der Server 409 (conflict), wird ein Error
// mit `err.conflict = true` und `err.updatedAt` geworfen, damit der Aufrufer
// den Konflikt anzeigen kann.
function sendWeekToServer(week){
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
return fetch(baseUrl() + '/save-week',{
method:'POST',
headers:{'Content-Type':'application/json; charset=utf-8'},
body:JSON.stringify({week, department:currentDepartment})
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
body:JSON.stringify({
week,
department:currentDepartment,
baseUpdatedAt:(week && week.updatedAt) ? week.updatedAt : undefined
})
}).then(r=>{
return r.json().then(json => {
if(r.ok && json) return json;
if(json && json.conflict){
const err = new Error(json.error || 'Konflikt: Sitzung wurde zwischenzeitlich geändert');
err.conflict = true;
err.label = (week && week.label) || '';
err.updatedAt = json.updatedAt;
throw err;
}
throw new Error('Server HTTP '+r.status);
});
});
}
// sendPeopleToServer: POSTet die globale Personenliste einer Abteilung als
+151 -21
View File
@@ -12,13 +12,33 @@
// `js/board/board.js`, `js/people/people.js`, `js/projects/projects.js` und `js/load/load.js`; das Speichern
// ist über `saveSyncTimer` (in `js/globals.js` deklariert) um 500 ms debounced.
// save: synchronisiert die aktuellen Daten zum Server.
// Jede Sitzung wird als eigenes JSON-Dokument gespeichert. Personen und Rollen
// werden in der Sitzung selbst abgelegt.
function save(){
// save: plant einen (debounced) Sync. Standardmäßig wird die aktuell gewählte
// Woche als geändert markiert, da sich die meisten Bearbeitungen auf die
// aktuelle Sitzung beziehen. Optionen:
// { week:false } – aktuelle Woche NICHT markieren (z. B. reine Personen-Aktionen)
// { people:true } – zusätzlich die globale Personenliste als geändert markieren
function save(opts = {}){
if(opts.week !== false){
const w = currentWeek();
if(w) dirtyWeeks.add(w.label);
}
if(opts.people) dirtyPeople = true;
scheduleSessionSync();
}
// Dirty-Tracking: merkt sich, welche Wochen bzw. ob die globale Personenliste
// seit dem letzten Sync lokal geändert wurden. Gespeichert wird nur das, was
// sich wirklich geändert hat (statt wie früher immer alle Wochen).
let dirtyWeeks = new Set();
let dirtyPeople = false;
// In-Flight-Schutz für syncAllToServer: läuft bereits ein Sync, wird kein
// zweiter gestartet (sonst senden zwei Syncs dieselbe Woche mit gleicher
// Revisions-Basis und der zweite bekäme einen scheinbaren 409-Konflikt).
// Stattdessen wird ein Nachlauf-Sync eingereiht und nach Abschluss ausgeführt.
let syncInFlight = false;
let syncQueued = false;
// scheduleSessionSync: plant den Speichervorgang für alle Wochen und Personen
// über einen 500-ms-Timer (Debounce). Bei erneutem Aufruf wird ein laufender
// Timer zurückgesetzt, damit aufeinanderfolgende Änderungen in einem Sync enden.
@@ -27,30 +47,133 @@ function scheduleSessionSync(){
saveSyncTimer = setTimeout(() => { syncAllToServer(); saveSyncTimer = null; }, 500);
}
// syncAllToServer: schreibt die gesamte Datenbank als JSON-Dokumente zum Server.
// Zuerst wird die globale Personenliste (`save-people`) gespeichert, danach jede
// Sitzung einzeln über `sendWeekToServer()`.
// Gibt nichts zurück; Fehler werden nur per console.error protokolliert.
// syncAllToServer: schreibt nur die seit dem letzten Sync geänderten Wochen
// plus – falls markiert – die globale Personenliste zum Server. Konflikte
// (409) auf einer Woche werden über handleWeekConflict() gemeldet. Fehlerhaft
// gespeicherte Wochen bleiben dirty, damit der nächste Sync sie erneut
// versucht; nur erfolgreich gespeicherte werden aus dem Dirty-Satz entfernt.
function syncAllToServer(){
// Zuerst globale Personenliste speichern
saveGlobalPeopleToServer();
// Dann jede Sitzung speichern
db.weeks.forEach(w => {
sendWeekToServer(w)
.then(res => { if(res && res.ok) console.log('Sitzung gespeichert:', w.label); })
.catch(err => { console.error('Sitzung konnte nicht gespeichert werden:', w.label, err.message || err); });
if(syncInFlight){ syncQueued = true; return; }
syncInFlight = true;
// Zuerst globale Personenliste speichern, falls geändert
const jobs = [];
if(dirtyPeople) jobs.push(saveGlobalPeopleToServer());
const weekLabels = Array.from(dirtyWeeks);
weekLabels.forEach(label => {
const w = db.weeks.find(x => x.label === label);
if(!w){ dirtyWeeks.delete(label); return; }
jobs.push(sendWeekToServer(w)
.then(res => {
if(res && res.ok){
console.log('Sitzung gespeichert:', w.label);
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
// nicht fälschlich als Konflikt gemeldet werden.
if(res.updatedAt) w.updatedAt = res.updatedAt;
dirtyWeeks.delete(label);
} else {
console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler');
}
})
.catch(err => {
if(err && err.conflict){
dirtyWeeks.delete(label);
handleWeekConflict(w, err);
} else {
console.error('Sitzung konnte nicht gespeichert werden:', w.label, err.message || err);
// Label bleibt dirty -> der nächste Sync versucht es erneut.
}
}));
});
Promise.all(jobs).then(() => {
syncInFlight = false;
if(syncQueued){ syncQueued = false; syncAllToServer(); }
});
}
// markWeekDirty: markiert eine bestimmte Woche (label) als geändert – für
// Aktionen, die Wochen AUFßER der aktuellen betreffen (z. B. Auto-Lock).
function markWeekDirty(label){
if(typeof label === 'string' && label) dirtyWeeks.add(label);
}
// markPeopleDirty: markiert die globale Personenliste als geändert.
function markPeopleDirty(){
dirtyPeople = true;
}
// handleWeekConflict: wird aufgerufen, wenn der Server eine zwischenzeitlich
// geänderte Woche meldet (409). Nur diese eine Woche wird vom Server neu
// geladen; andere lokal geänderte Wochen (dirtyWeeks) und die Personenliste
// bleiben unangetastet und gehen nicht verloren.
function handleWeekConflict(week, err){
const label = (week && week.label) || (err && err.label) || '';
alert('Achtung: ' + label + ' wurde zwischenzeitlich von einer anderen Person '
+ 'geändert. Ihre Änderungen an dieser Sitzung wurden nicht gespeichert; '
+ 'die aktuellen Daten dieser Sitzung werden geladen.');
if(label) reloadWeekFromServer(label);
else load();
}
// reloadWeekFromServer: lädt die aktuelle Abteilung neu und ersetzt NUR die
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
// syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten.
function reloadWeekFromServer(label){
const requestedDepartment = currentDepartment;
fetch(baseUrl() + '/load-all' + departmentParam())
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
if(currentDepartment !== requestedDepartment) return;
const fresh = (Array.isArray(data.weeks) ? data.weeks : [])
.find(w => w && w.label === label);
const idx = db.weeks.findIndex(w => w.label === label);
if(idx >= 0){
if(fresh){
// Felder absichern (wie in load()), damit keine kaputten Daten crashen
if(!Array.isArray(fresh.people)) fresh.people = [];
if(!Array.isArray(fresh.projects)) fresh.projects = [];
if(!fresh.attendance) fresh.attendance = {};
db.weeks[idx] = fresh;
} else {
db.weeks.splice(idx, 1);
}
// Wurde die aktuelle Woche entfernt, auf die letzte verbleibende wechseln
if(!db.weeks.some(w => w.id === db.currentWeek)){
db.currentWeek = db.weeks.length ? db.weeks[db.weeks.length - 1].id : null;
}
}
renderAll();
showTab(currentTab);
})
.catch(err => {
console.error('Neu laden der Sitzung fehlgeschlagen:', err);
load();
});
}
// saveGlobalPeopleToServer: speichert die globale Personenliste der aktuellen
// Abteilung über `sendPeopleToServer()` (JSON-Array).
// Leere Personenlisten werden übersprungen (kein Schreibvorgang).
// Abteilung über `sendPeopleToServer()` (JSON-Array). Auch eine leere Liste
// wird gespeichert, damit das Löschen der letzten Person erhalten bleibt. Bei
// Erfolg wird der Dirty-Status für Personen zurückgesetzt, bei Fehler bleibt
// er erhalten (Retry beim nächsten Sync). Rückgabe: Promise.
function saveGlobalPeopleToServer(){
const people = db.globalPeople[currentDepartment] || [];
if(people.length === 0) return;
sendPeopleToServer(people)
.then(res => { if(res && res.ok) console.log('Globale Personenliste gespeichert'); })
.catch(err => { console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err); });
return sendPeopleToServer(people)
.then(res => {
if(res && res.ok){
dirtyPeople = false;
console.log('Globale Personenliste gespeichert');
} else {
console.error('Globale Personenliste konnte nicht gespeichert werden:', res && res.error ? res.error : 'unbekannter Fehler');
}
})
.catch(err => {
console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err);
// dirtyPeople bleibt true -> der nächste Sync versucht es erneut.
});
}
// switchDepartment: wechselt die aktive Abteilung (z.B. "ELT") und lädt deren
@@ -63,6 +186,8 @@ function switchDepartment(dept){
// Sync nicht nach dem Reset mit dem neuen currentDepartment feuert und die
// Daten der falschen Abteilung schreibt.
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
dirtyWeeks.clear();
dirtyPeople = false;
currentDepartment = dept;
saveUiState();
updateRouteUrl(dept, currentTab);
@@ -89,6 +214,11 @@ function switchDepartment(dept){
// die Ansicht. Kein Parameter, keine Rückgabe; bei Fehlern oder fehlenden
// Daten werden Beispieldaten über `seed()` geladen.
function load(){
// Nach einem (Neu-)Load sind die lokalen Daten serverseitig aktuell:
// laufenden Debounce abbrechen und Dirty-Zustand verwerfen.
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
dirtyWeeks.clear();
dirtyPeople = false;
if(!window.fetch){
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
seed(); return;
+1
View File
@@ -59,6 +59,7 @@ function confirmPassword(){
if(action?.type==='tab') showTab(action.tab, true);
else if(action?.type==='deleteWeek') deleteWeek(true);
else if(action?.type==='unlockWeek') unlockCurrentWeek(true);
else if(action?.type==='importDb') importDb(true);
}, 100);
document.getElementById('passwordModal').style.display='none';
} else {
+9 -3
View File
@@ -31,9 +31,11 @@ function updateWeekDate(){
// Kein Parameter; Rückgabe: das gefundene Woche-Objekt oder undefined.
function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); }
// switchWeek: setzt die aktuell gewählte Wochen-ID (`id`), speichert und
// rendert die Ansicht neu. Keine Rückgabe.
function switchWeek(id){ db.currentWeek = id; save(); renderAll(); }
// switchWeek: setzt die aktuell gewählte Wochen-ID (`id`) und rendert die
// Ansicht neu. Es werden keine Daten geändert, daher wird nicht gespeichert
// (eine save() würde die Woche nur als dirty markieren und unnötig senden).
// Keine Rückgabe.
function switchWeek(id){ db.currentWeek = id; renderAll(); }
// newWeek: erzeugt über zwei prompt-Dialoge eine neue leere Sitzung (Bezeichnung
// und Datum). Die neue Woche übernimmt die globale Personenliste der Abteilung,
@@ -82,7 +84,11 @@ function applyAutoLock(){
const sorted = [...db.weeks].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
const latestId = sorted[sorted.length - 1].id;
db.weeks.forEach(w => {
const wasLocked = !!w.locked;
w.locked = (w.id !== latestId);
// Nur tatsächlich geänderte Lock-Zustände übertragen (beim bloßen Laden
// sind die Werte identisch und es wird nichts gesendet).
if(wasLocked !== w.locked) markWeekDirty(w.label);
});
}
+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'){