diff --git a/AGENTS.md b/AGENTS.md index 46d0fab..6370c16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/?weekLabel=…&projNr=…`, `findProjectForPlan` resolves them. Person IDs follow `personIdForName(name)` → `p_`. -- `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 diff --git a/README.md b/README.md index 0050747..8197947 100644 --- a/README.md +++ b/README.md @@ -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 //Projektplan(/)?` → 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. \ No newline at end of file diff --git a/public/Test.html b/public/Test.html index 9bc079c..771ce82 100644 --- a/public/Test.html +++ b/public/Test.html @@ -77,6 +77,15 @@ + + + + @@ -238,6 +247,9 @@ + + + \ No newline at end of file diff --git a/public/js/backup.js b/public/js/backup.js new file mode 100644 index 0000000..65ef7e1 --- /dev/null +++ b/public/js/backup.js @@ -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)); + } +} \ No newline at end of file diff --git a/public/js/board/board.js b/public/js/board/board.js index f8d0660..129f4a7 100644 --- a/public/js/board/board.js +++ b/public/js/board/board.js @@ -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 += ``; + body += ``; if(v!=""){ const pct = Math.min(100, parseFloat(v)||0); body += `
`; } body += ``; diff --git a/public/js/csv.js b/public/js/csv.js new file mode 100644 index 0000000..f2d7dce --- /dev/null +++ b/public/js/csv.js @@ -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)); + } +} \ No newline at end of file diff --git a/public/js/import.js b/public/js/import.js new file mode 100644 index 0000000..41fdb92 --- /dev/null +++ b/public/js/import.js @@ -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)); + } +} \ No newline at end of file diff --git a/public/js/people/people.js b/public/js/people/people.js index 321b74b..cec167c 100644 --- a/public/js/people/people.js +++ b/public/js/people/people.js @@ -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(); } \ No newline at end of file diff --git a/public/js/plan/plan-state.js b/public/js/plan/plan-state.js index c6e438f..b113434 100644 --- a/public/js/plan/plan-state.js +++ b/public/js/plan/plan-state.js @@ -176,19 +176,43 @@ 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)); - } + sendPeopleToServer(people).catch(err => console.error('Speichern Personen fehlgeschlagen:', err)); } // Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt) diff --git a/public/js/server.js b/public/js/server.js index 57a491e..e7281a3 100644 --- a/public/js/server.js +++ b/public/js/server.js @@ -34,14 +34,34 @@ function blobToDataUrl(blob){ } // sendWeekToServer: POSTet ein komplettes Wochen-Objekt (JSON) an -// /save-week. Parameter: week (Objekt). Rückgabe: Promise mit Server-Antwort. +// /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 diff --git a/public/js/store.js b/public/js/store.js index a5628a2..c077d03 100644 --- a/public/js/store.js +++ b/public/js/store.js @@ -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; diff --git a/public/js/tabs-auth.js b/public/js/tabs-auth.js index ba9b382..35407dc 100644 --- a/public/js/tabs-auth.js +++ b/public/js/tabs-auth.js @@ -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 { diff --git a/public/js/weeks.js b/public/js/weeks.js index f7db3a5..96c4987 100644 --- a/public/js/weeks.js +++ b/public/js/weeks.js @@ -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); }); } diff --git a/server.js b/server.js index 807f933..08fd241 100644 --- a/server.js +++ b/server.js @@ -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= 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 +// (
/

/

werden zu Zeilenumbrüchen, alle übrigen Tags entfernt). +function csvStatusText(html){ + if(!html) return ''; + return String(html) + .replace(//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'){ diff --git a/sessions/backups/projektuebersicht_2026-08-18T06-36-48-511.db b/sessions/backups/projektuebersicht_2026-08-18T06-36-48-511.db new file mode 100644 index 0000000..f7fff1e Binary files /dev/null and b/sessions/backups/projektuebersicht_2026-08-18T06-36-48-511.db differ diff --git a/sessions/backups/projektuebersicht_2026-08-18T06-41-14-950.db b/sessions/backups/projektuebersicht_2026-08-18T06-41-14-950.db new file mode 100644 index 0000000..7095fdc Binary files /dev/null and b/sessions/backups/projektuebersicht_2026-08-18T06-41-14-950.db differ diff --git a/sessions/backups/projektuebersicht_2026-08-18T06-41-14-963.db b/sessions/backups/projektuebersicht_2026-08-18T06-41-14-963.db new file mode 100644 index 0000000..8df9060 Binary files /dev/null and b/sessions/backups/projektuebersicht_2026-08-18T06-41-14-963.db differ diff --git a/sessions/backups/projektuebersicht_2026-08-18T06-43-13-525.db b/sessions/backups/projektuebersicht_2026-08-18T06-43-13-525.db new file mode 100644 index 0000000..7095fdc Binary files /dev/null and b/sessions/backups/projektuebersicht_2026-08-18T06-43-13-525.db differ diff --git a/sessions/backups/projektuebersicht_2026-08-18T06-47-01-551.db b/sessions/backups/projektuebersicht_2026-08-18T06-47-01-551.db new file mode 100644 index 0000000..7095fdc Binary files /dev/null and b/sessions/backups/projektuebersicht_2026-08-18T06-47-01-551.db differ diff --git a/sessions/backups/projektuebersicht_2026-08-18T07-09-35-889.db b/sessions/backups/projektuebersicht_2026-08-18T07-09-35-889.db new file mode 100644 index 0000000..7095fdc Binary files /dev/null and b/sessions/backups/projektuebersicht_2026-08-18T07-09-35-889.db differ diff --git a/sessions/backups/projektuebersicht_2026-08-18T07-10-17-549.db b/sessions/backups/projektuebersicht_2026-08-18T07-10-17-549.db new file mode 100644 index 0000000..e1aa595 Binary files /dev/null and b/sessions/backups/projektuebersicht_2026-08-18T07-10-17-549.db differ diff --git a/sessions/backups/projektuebersicht_2026-08-18T07-10-17-555.db b/sessions/backups/projektuebersicht_2026-08-18T07-10-17-555.db new file mode 100644 index 0000000..7095fdc Binary files /dev/null and b/sessions/backups/projektuebersicht_2026-08-18T07-10-17-555.db differ diff --git a/sessions/backups/projektuebersicht_2026-08-18T07-12-27-276.db b/sessions/backups/projektuebersicht_2026-08-18T07-12-27-276.db new file mode 100644 index 0000000..7095fdc Binary files /dev/null and b/sessions/backups/projektuebersicht_2026-08-18T07-12-27-276.db differ diff --git a/sessions/backups/projektuebersicht_2026-08-18T07-14-16-193.db b/sessions/backups/projektuebersicht_2026-08-18T07-14-16-193.db new file mode 100644 index 0000000..1358774 Binary files /dev/null and b/sessions/backups/projektuebersicht_2026-08-18T07-14-16-193.db differ