DB Import und Export sowie Backup und kleinere Fehlerbeseitigung

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

No files matched your search

+61 -10
View File
@@ -42,6 +42,9 @@ user-facing text.
│ │ ├── showmode.js # Show-Mode (Präsentationsmodus) │ │ ├── showmode.js # Show-Mode (Präsentationsmodus)
│ │ ├── tabs-auth.js # showTab, confirmPassword (SHA256-Passwortschutz) │ │ ├── tabs-auth.js # showTab, confirmPassword (SHA256-Passwortschutz)
│ │ ├── pdf.js # PDF-Export (pdfmake, A3 quer) │ │ ├── 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) │ │ └── init.js # renderAll/initUiState + Seiteneinstieg (nur Test.html)
│ │ ├── board/ # tab-specific: Projektboard │ │ ├── board/ # tab-specific: Projektboard
│ │ │ └── board.js # Projektboard-Rendering, Status-Editor, Anwesenheit, Zuordnung │ │ │ └── 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. (`/js/...`, `/styles.css`) so they resolve on nested routes.
- `GET /load-all?department=` → `{weeks:[weekObj], people:[personObj]}` with the - `GET /load-all?department=` → `{weeks:[weekObj], people:[personObj]}` with the
global people list in `people`; weeks are full JSON week objects (id, label, global people list in `people`; weeks are full JSON week objects (id, label,
date, locked, people, attendance, projects), sorted by label. date, locked, updatedAt, people, attendance, projects), sorted by label.
- `POST /save-week {department, week}` → upserts one week (JSON object, keyed by - `POST /save-week {department, week, baseUpdatedAt}` → upserts one week (JSON
department + label). Used by `sendWeekToServer()`. 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 - `POST /save-people {department, people}` → replaces the department's global
people list (JSON array). people list (JSON array).
- `POST /delete-week {department, label}` → deletes the week; missing label - `POST /delete-week {department, label}` → deletes the week; missing label
yields 400, missing week tolerates with `deleted:false`. 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 hard-coded as `ELT|HLS|ARCH` in `server.js`
(`VALID_DEPARTMENTS`); anything else silently falls back to `ELT`. Same set in (`VALID_DEPARTMENTS`); anything else silently falls back to `ELT`. Same set in
`DEPARTMENTS` in `js/globals.js`. `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`, - Data lives in `sessions/projektuebersicht.db` (`node:sqlite` `DatabaseSync`,
WAL mode, `PRAGMA foreign_keys = ON`), created automatically. The schema is WAL mode, `PRAGMA foreign_keys = ON`), created automatically. The schema is
**fully relational** (18 tables) instead of one big JSON document per week: **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` - `week_people` / `week_people_roles` — person copies per week (`array_order`
keeps the list order, `role_order` the role array order) keeps the list order, `role_order` the role array order)
- `attendance` — présence per week/person - `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()` tables inside a transaction, `loadWeeks()` / `loadGlobalPeople()`
(re)assemble them on read. `saveWeekRelational()` deletes the week's rows (re)assemble them on read. `saveWeekRelational()` deletes the week's rows
first (`deleteWeekData()`, note `weeks` uses `label`, all child tables 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): - Migration on every start (idempotent):
`migrateLegacySchema()` converts a DB from the old single-JSON-document format `migrateLegacySchema()` converts a DB from the old single-JSON-document format
(detected via a `data` column in `weeks`): it reads the documents, drops all (detected via a `data` column in `weeks`): it reads the documents, drops all
tables, recreates the relational schema and inserts everything in one 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 - The DB file sits in `sessions/`, so the existing Docker bind-mount
`./sessions:/app/sessions` keeps it persistent. The path is overridable via `./sessions:/app/sessions` keeps it persistent. The path is overridable via
the `DB_PATH` environment variable (used for tests), production default is 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=…`, `openProjectPlan()` builds `/HLS/Projektplan/<ProjName>?weekLabel=…&projNr=…`,
`findProjectForPlan` resolves them. Person IDs follow `findProjectForPlan` resolves them. Person IDs follow
`personIdForName(name)` → `p_<lowercased alnum>`. `personIdForName(name)` → `p_<lowercased alnum>`.
- `save()` debounces 500 ms then `syncAllToServer()` writes **all** weeks (via - `save()` debounces 500 ms then `syncAllToServer()` writes **only dirty weeks**
`sendWeekToServer()`) plus the people list (via `sendPeopleToServer()`) — not (`dirtyWeeks` – track `markWeekDirty()`) via `sendWeekToServer()`, plus the
just the changed week. Exception: `js/plan/plan-state.js` `savePlan()` bypasses people list `markPeopleDirty()` via `sendPeopleToServer()` if marked dirty.
`save()` and writes only the current week directly via `sendWeekToServer()`. 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 - `switchDepartment()` cancels a pending save debounce before switching, and
`load()` discards stale `/load-all` responses after a department switch `load()` discards stale `/load-all` responses after a department switch
(frozen `requestedDepartment`), so late responses can never render or persist (frozen `requestedDepartment`), so late responses can never render or persist
+40 -34
View File
@@ -1,12 +1,12 @@
# Projektübersicht — README # Projektübersicht — README
## Übersicht ## Ü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 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-Server in `server.js` - einem einfachen Node.js-/Express-Server in `server.js`
- einem Datenspeicher in einer SQLite-Datenbank (`node:sqlite`) unter `sessions/projektuebersicht.db` - einem Datenspeicher in einer SQLite-Datenbank (`node:sqlite`, benötigt Node ≥ 22.13) unter `sessions/projektuebersicht.db`
- einem Docker-Setup unter `Docker/` - einem Docker-Setup (`Docker/Dockerfile` + `docker-compose.yml` im Projektstamm)
## Datei- und Komponentenübersicht ## Datei- und Komponentenübersicht
@@ -14,15 +14,19 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
- `server.js` - `server.js`
- Node.js/Express-Server - Node.js/Express-Server
- dient als API für das Frontend - 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: - implementiert Endpunkte:
- `GET /` → liefert `Test.html` - `GET /` → liefert `Test.html`
- `GET /load-all?department=…` → lädt alle Sitzungen plus globale Personenliste einer Abteilung - `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 /save-people {department, people}` → ersetzt die globale Personenliste einer Abteilung
- `POST /delete-week {department, label}` → löscht eine Sitzung - `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 - 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 - beendet sich mit aussagekräftiger Fehlermeldung, wenn der Port belegt ist
- `package.json` - `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`) - wird für reproduzierbare Docker-Builds verwendet (`npm ci`)
- `public/js/` (Module) - `public/js/` (Module)
- Die Client-Logik ist in kleine klassische Script-Dateien aufgeteilt (alle Funktionen global auf `window`): - Die Client-Logik ist in kleine klassische Script-Dateien aufgeteilt (alle Funktionen global auf `window`, kein Bundler):
`globals.js`, `utils.js`, `ui.js`, `date-picker.js`, `server.js`, `store.js`, `weeks.js`, - 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`
`board.js`, `people.js`, `projects.js`, `load.js`, `gantt.js`, `showmode.js`, `tabs-auth.js`, `pdf.js`, - tab-spezifisch in eigenen Ordnern: `board/board.js` (Projektboard), `people/people.js` (Projektbeteiligte), `projects/projects.js` (Projekte), `load/load.js` (Auslastung)
`init.js` sowie die Plan-Module unter `js/plan/` (`plan-state`, `plan-build-gantt`, `plan-gantt`, - Planungs-Module unter `js/plan/` (`plan-state`, `plan-build-gantt`, `plan-gantt`, `plan-drag`, `plan-modals`, `plan-schedule`)
`plan-drag`, `plan-modals`, `plan-schedule`).
- rendert das Projektboard, die Projekt- und Personenverwaltung sowie die Auslastungsansicht - 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 - 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`) - 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 - sortiert Sitzungen nach Kalenderwoche, nicht alphabetisch
- sperrt historische Sitzungen automatisch (bis auf die chronologisch letzte) - sperrt historische Sitzungen automatisch (bis auf die chronologisch letzte)
- Personen sind global pro Abteilung verwaltet; historische Sitzungen behalten ihre lokalen Personenkopien - 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` - `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`) - 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` - `project-plan.html`
- die Planungsansicht (GANTT/TODOs/Meilensteine) pro Projekt - die Planungsansicht (GANTT/TODOs/Meilensteine) pro Projekt
- öffnet sich in einem neuen Tab über `openProjectPlan()` (`js/gantt.js`) - ö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` - `styles.css`
- Layout und Styling der Oberfläche - Layout und Styling der Oberfläche
### Docker-bezogene Dateien ### Docker-bezogene Dateien
- `Docker/Dockerfile` - `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 `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 - legt sicherheitshalber `/app/sessions` an
- läuft als nicht-root-User `node` - läuft als nicht-root-User `node`
- enthält einen `HEALTHCHECK` auf `/health` - enthält einen `HEALTHCHECK` auf `/health`
- startet `node server.js` - startet `node server.js`
- `docker-compose.yml` - `docker-compose.yml` (im Projektstamm)
- definiert den Service `projektuebersicht` - 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` - mappt Port `3003:3003`
- bindet das Host-Verzeichnis `./sessions` in den Container nach `/app/sessions` - bindet das Host-Verzeichnis `./sessions` in den Container nach `/app/sessions`
- setzt `restart: unless-stopped` - setzt `restart: unless-stopped`
- definiert einen `healthcheck` - definiert einen `healthcheck`
- `.dockerignore` - `.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 ### Sonstige Dateien
- `dein_bild.ico` - `dein_bild.ico`
@@ -88,25 +93,25 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
- `AGENTS.md` - `AGENTS.md`
- Projektnotizen / Dokumentation, aber nicht Teil des Anwendungslaufs - Projektnotizen / Dokumentation, aber nicht Teil des Anwendungslaufs
## Speicherorte und Datenfluss ## Persistenz und Datenfluss
### Speicherung ### Speicherung
- Alle persistierten Daten werden in einer SQLite-Datenbank (`node:sqlite`, `DatabaseSync`, WAL-Modus) unter `sessions/projektuebersicht.db` gespeichert. - Alle persistierten Daten werden in einer SQLite-Datenbank (`node:sqlite`, `DatabaseSync`, WAL-Modus, `PRAGMA foreign_keys = ON`) unter `sessions/projektuebersicht.db` gespeichert.
- Innerhalb von `sessions/` liegen ausschließlich die SQLite-Datenbankdateien (`projektuebersicht.db` + WAL/SHM). - 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? ### Server-API
- Der Server wird in `server.js` erstellt. - Der Server wird in `server.js` erstellt und startet auf Port `3003` oder dem Wert von `process.env.PORT`.
- `server.js` startet einen Express-Server 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).
- Das Frontend ruft diesen Server über `window.location.origin` an.
### Wo werden die Daten gespeichert? ### Wo werden die Daten gespeichert?
- Das Frontend schreibt über den Server in die SQLite-Datenbank. - 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. - 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 schreibt die relationalen Tabellen in `sessions/projektuebersicht.db` (eine Transaktion pro Sitzung, siehe `serializeWeek()`/`loadWeeks()` in `server.js`). - 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 ## Betrieb
### Lokal mit Node.js ### Lokal mit Node.js (Node ≥ 22.13)
```bash ```bash
npm install npm install
npm start npm start
@@ -122,7 +127,7 @@ PORT=3004 npm start
Im Projektstamm ausführen: Im Projektstamm ausführen:
```bash ```bash
docker compose -f Docker/docker-compose.yml up --build docker compose up --build
``` ```
Danach ist die Anwendung erreichbar unter: Danach ist die Anwendung erreichbar unter:
@@ -133,7 +138,8 @@ http://localhost:3003
## Wichtige Hinweise ## Wichtige Hinweise
- Der Server benötigt Lese-/Schreibzugriff auf `sessions/` (für die SQLite-Datenbank und das Docker-Bind-Mount). - 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. - `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). - 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="exportPDF()">PDF Export</button>
<button class="secondary" onclick="startShowMode()">Show Mode</button> <button class="secondary" onclick="startShowMode()">Show Mode</button>
</div> </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>
</div> </div>
@@ -238,6 +247,9 @@
<script src="/js/showmode.js?v=5"></script> <script src="/js/showmode.js?v=5"></script>
<script src="/js/tabs-auth.js?v=5"></script> <script src="/js/tabs-auth.js?v=5"></script>
<script src="/js/pdf.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> <script src="/js/init.js?v=5"></script>
</body> </body>
</html> </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 v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
const aktiv = l === aktLp; const aktiv = l === aktLp;
const inputAttr = locked ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`; 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); if(v!=""){ const pct = Math.min(100, parseFloat(v)||0);
body += `<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>`; } body += `<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>`; }
body += `</td>`; 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('newPersonFullName').value='';
document.getElementById('newPersonEmail').value=''; document.getElementById('newPersonEmail').value='';
document.querySelectorAll('input[name="newPersonRoles"]').forEach(el=>el.checked=false); 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 // 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); 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 // editPerson: ändert Name/Vollname/Rolle sowohl global als auch in der
@@ -132,7 +132,7 @@ function editPerson(id,field,val){
delete w.attendance[id]; delete w.attendance[id];
} }
} }
save(); renderPeople(); renderBoard(); save({people:true}); renderPeople(); renderBoard();
return; return;
} }
@@ -146,7 +146,7 @@ function editPerson(id,field,val){
const localPerson = (w.people || []).find(p => p.id === id); const localPerson = (w.people || []).find(p => p.id === id);
if(localPerson) localPerson[field] = val; 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 // 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(globalPerson) updateRoles(globalPerson);
if(localPerson) updateRoles(localPerson); 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 // 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]); }); newPeople.forEach(p => { if(byId[p.id]) newWeekPeople.push(byId[p.id]); });
if(newWeekPeople.length === w.people.length) w.people = newWeekPeople; 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 = []; if(!Array.isArray(person.absences)) person.absences = [];
person.absences.push({ id: uid(), start, end, type, note }); person.absences.push({ id: uid(), start, end, type, note });
person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start)); person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
save(); save({week:false, people:true});
renderPeople(); renderPeople();
if(currentTab === 'gantt') renderGantt(); if(currentTab === 'gantt') renderGantt();
} }
@@ -361,7 +361,7 @@ function deleteAbsence(personId, absenceId){
const person = people.find(p => p.id === personId); const person = people.find(p => p.id === personId);
if(!person || !person.absences) return; if(!person || !person.absences) return;
person.absences = person.absences.filter(a => a.id !== absenceId); person.absences = person.absences.filter(a => a.id !== absenceId);
save(); save({week:false, people:true});
renderPeople(); renderPeople();
if(currentTab === 'gantt') renderGantt(); if(currentTab === 'gantt') renderGantt();
} }
+30 -6
View File
@@ -176,19 +176,43 @@ window.PlanApp = {
}).join(''); }).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(){ function savePlan(){
if(!app.week || !app.project) return; 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 // 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(){ function saveGlobalPeople(){
const people = db.globalPeople[app.department] || []; 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) // Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt)
+23 -3
View File
@@ -34,14 +34,34 @@ function blobToDataUrl(blob){
} }
// sendWeekToServer: POSTet ein komplettes Wochen-Objekt (JSON) an // 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){ function sendWeekToServer(week){
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar')); if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
return fetch(baseUrl() + '/save-week',{ return fetch(baseUrl() + '/save-week',{
method:'POST', method:'POST',
headers:{'Content-Type':'application/json; charset=utf-8'}, headers:{'Content-Type':'application/json; charset=utf-8'},
body:JSON.stringify({week, department:currentDepartment}) body:JSON.stringify({
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }); 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 // 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 // `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. // ist über `saveSyncTimer` (in `js/globals.js` deklariert) um 500 ms debounced.
// save: synchronisiert die aktuellen Daten zum Server. // save: plant einen (debounced) Sync. Standardmäßig wird die aktuell gewählte
// Jede Sitzung wird als eigenes JSON-Dokument gespeichert. Personen und Rollen // Woche als geändert markiert, da sich die meisten Bearbeitungen auf die
// werden in der Sitzung selbst abgelegt. // aktuelle Sitzung beziehen. Optionen:
function save(){ // { 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(); 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 // scheduleSessionSync: plant den Speichervorgang für alle Wochen und Personen
// über einen 500-ms-Timer (Debounce). Bei erneutem Aufruf wird ein laufender // über einen 500-ms-Timer (Debounce). Bei erneutem Aufruf wird ein laufender
// Timer zurückgesetzt, damit aufeinanderfolgende Änderungen in einem Sync enden. // Timer zurückgesetzt, damit aufeinanderfolgende Änderungen in einem Sync enden.
@@ -27,30 +47,133 @@ function scheduleSessionSync(){
saveSyncTimer = setTimeout(() => { syncAllToServer(); saveSyncTimer = null; }, 500); saveSyncTimer = setTimeout(() => { syncAllToServer(); saveSyncTimer = null; }, 500);
} }
// syncAllToServer: schreibt die gesamte Datenbank als JSON-Dokumente zum Server. // syncAllToServer: schreibt nur die seit dem letzten Sync geänderten Wochen
// Zuerst wird die globale Personenliste (`save-people`) gespeichert, danach jede // plus – falls markiert – die globale Personenliste zum Server. Konflikte
// Sitzung einzeln über `sendWeekToServer()`. // (409) auf einer Woche werden über handleWeekConflict() gemeldet. Fehlerhaft
// Gibt nichts zurück; Fehler werden nur per console.error protokolliert. // gespeicherte Wochen bleiben dirty, damit der nächste Sync sie erneut
// versucht; nur erfolgreich gespeicherte werden aus dem Dirty-Satz entfernt.
function syncAllToServer(){ function syncAllToServer(){
// Zuerst globale Personenliste speichern if(syncInFlight){ syncQueued = true; return; }
saveGlobalPeopleToServer(); syncInFlight = true;
// Dann jede Sitzung speichern
db.weeks.forEach(w => { // Zuerst globale Personenliste speichern, falls geändert
sendWeekToServer(w) const jobs = [];
.then(res => { if(res && res.ok) console.log('Sitzung gespeichert:', w.label); }) if(dirtyPeople) jobs.push(saveGlobalPeopleToServer());
.catch(err => { console.error('Sitzung konnte nicht gespeichert werden:', w.label, err.message || err); });
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 // saveGlobalPeopleToServer: speichert die globale Personenliste der aktuellen
// Abteilung über `sendPeopleToServer()` (JSON-Array). // Abteilung über `sendPeopleToServer()` (JSON-Array). Auch eine leere Liste
// Leere Personenlisten werden übersprungen (kein Schreibvorgang). // 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(){ function saveGlobalPeopleToServer(){
const people = db.globalPeople[currentDepartment] || []; const people = db.globalPeople[currentDepartment] || [];
if(people.length === 0) return; return sendPeopleToServer(people)
sendPeopleToServer(people) .then(res => {
.then(res => { if(res && res.ok) console.log('Globale Personenliste gespeichert'); }) if(res && res.ok){
.catch(err => { console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err); }); 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 // 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 // Sync nicht nach dem Reset mit dem neuen currentDepartment feuert und die
// Daten der falschen Abteilung schreibt. // Daten der falschen Abteilung schreibt.
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; } if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
dirtyWeeks.clear();
dirtyPeople = false;
currentDepartment = dept; currentDepartment = dept;
saveUiState(); saveUiState();
updateRouteUrl(dept, currentTab); updateRouteUrl(dept, currentTab);
@@ -89,6 +214,11 @@ function switchDepartment(dept){
// die Ansicht. Kein Parameter, keine Rückgabe; bei Fehlern oder fehlenden // die Ansicht. Kein Parameter, keine Rückgabe; bei Fehlern oder fehlenden
// Daten werden Beispieldaten über `seed()` geladen. // Daten werden Beispieldaten über `seed()` geladen.
function load(){ 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){ if(!window.fetch){
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.'); alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
seed(); return; seed(); return;
+1
View File
@@ -59,6 +59,7 @@ function confirmPassword(){
if(action?.type==='tab') showTab(action.tab, true); if(action?.type==='tab') showTab(action.tab, true);
else if(action?.type==='deleteWeek') deleteWeek(true); else if(action?.type==='deleteWeek') deleteWeek(true);
else if(action?.type==='unlockWeek') unlockCurrentWeek(true); else if(action?.type==='unlockWeek') unlockCurrentWeek(true);
else if(action?.type==='importDb') importDb(true);
}, 100); }, 100);
document.getElementById('passwordModal').style.display='none'; document.getElementById('passwordModal').style.display='none';
} else { } else {
+9 -3
View File
@@ -31,9 +31,11 @@ function updateWeekDate(){
// Kein Parameter; Rückgabe: das gefundene Woche-Objekt oder undefined. // Kein Parameter; Rückgabe: das gefundene Woche-Objekt oder undefined.
function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); } function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); }
// switchWeek: setzt die aktuell gewählte Wochen-ID (`id`), speichert und // switchWeek: setzt die aktuell gewählte Wochen-ID (`id`) und rendert die
// rendert die Ansicht neu. Keine Rückgabe. // Ansicht neu. Es werden keine Daten geändert, daher wird nicht gespeichert
function switchWeek(id){ db.currentWeek = id; save(); renderAll(); } // (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 // newWeek: erzeugt über zwei prompt-Dialoge eine neue leere Sitzung (Bezeichnung
// und Datum). Die neue Woche übernimmt die globale Personenliste der Abteilung, // 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 sorted = [...db.weeks].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
const latestId = sorted[sorted.length - 1].id; const latestId = sorted[sorted.length - 1].id;
db.weeks.forEach(w => { db.weeks.forEach(w => {
const wasLocked = !!w.locked;
w.locked = (w.id !== latestId); 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. * Standardport ist 3003, überschreibbar über die Umgebungsvariable PORT.
* Endpunkte: GET /, GET /health, GET /load-all, POST /save-week, POST /save-people, * 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 express = require('express');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os');
const zlib = require('zlib');
const { DatabaseSync } = require('node:sqlite'); const { DatabaseSync } = require('node:sqlite');
const app = express(); 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); const db = new DatabaseSync(DB_PATH);
db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA foreign_keys = ON;'); 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) ---------- // ---------- Schema (voll relational normalisiert) ----------
@@ -76,6 +83,7 @@ db.exec(`CREATE TABLE IF NOT EXISTS weeks (
week_id TEXT NOT NULL DEFAULT '', week_id TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '',
locked INTEGER NOT NULL DEFAULT 0, locked INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT '',
PRIMARY KEY (department, label) PRIMARY KEY (department, label)
)`); )`);
db.exec(`CREATE TABLE IF NOT EXISTS week_people ( db.exec(`CREATE TABLE IF NOT EXISTS week_people (
@@ -253,9 +261,10 @@ function hasLegacyWeeksTable(){
// serializeWeek). Ersetzt alle Zeilen der Woche. // serializeWeek). Ersetzt alle Zeilen der Woche.
function serializeWeek(department, week){ function serializeWeek(department, week){
const weekLabel = String(week.label || ''); const weekLabel = String(week.label || '');
const upsertWeek = db.prepare(`INSERT INTO weeks (department, label, week_id, date, locked) VALUES (?, ?, ?, ?, ?) const nowIso = new Date().toISOString(); // Revision: wird bei jedem Speichern aktualisiert
ON CONFLICT(department, label) DO UPDATE SET week_id = excluded.week_id, date = excluded.date, locked = excluded.locked`); const upsertWeek = db.prepare(`INSERT INTO weeks (department, label, week_id, date, locked, updated_at) VALUES (?, ?, ?, ?, ?, ?)
upsertWeek.run(department, weekLabel, String(week.id || ''), String(week.date || ''), week.locked ? 1 : 0); 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) const insWeekPerson = db.prepare(`INSERT INTO week_people (department, week_label, person_id, name, full_name, array_order)
VALUES (?, ?, ?, ?, ?, ?)`); 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) // 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); 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){ function saveWeekRelational(department, week){
const weekLabel = String(week.label || ''); const weekLabel = String(week.label || '');
const tx = db.exec('BEGIN'); const tx = db.exec('BEGIN');
try { try {
deleteWeekData(department, weekLabel); deleteWeekData(department, weekLabel);
serializeWeek(department, week); const updatedAt = serializeWeek(department, week);
db.exec('COMMIT'); db.exec('COMMIT');
return updatedAt;
} catch(err){ } catch(err){
db.exec('ROLLBACK'); db.exec('ROLLBACK');
throw err; throw err;
@@ -551,10 +566,463 @@ function loadWeeks(department){
return { return {
id: w.week_id, label: w.label, date: w.date, id: w.week_id, label: w.label, date: w.date,
people, projects: weekProjects, attendance: att, locked: !!w.locked, 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 ---------- // ---------- Migration: altes Ein-Dokument-Format -> relationales Schema ----------
// migrateLegacySchema: überführt eine bestehende Datenbank aus dem alten Format // migrateLegacySchema: überführt eine bestehende Datenbank aus dem alten Format
@@ -589,6 +1057,7 @@ function migrateLegacySchema(){
db.exec(`CREATE TABLE IF NOT EXISTS weeks ( db.exec(`CREATE TABLE IF NOT EXISTS weeks (
department TEXT NOT NULL, label TEXT NOT NULL, week_id TEXT NOT NULL DEFAULT '', 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, date TEXT NOT NULL DEFAULT '', locked INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT '',
PRIMARY KEY (department, label) PRIMARY KEY (department, label)
)`); )`);
db.exec(`CREATE TABLE IF NOT EXISTS week_people ( 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 // Migration beim Serverstart ausführen
migrateLegacySchema(); migrateLegacySchema();
migrateWeeksUpdatedAt();
// Route: Speichert eine komplette Woche (als JSON-Objekt) für eine Abteilung // Route: Speichert eine komplette Woche (als JSON-Objekt) für eine Abteilung
app.post('/save-week', (req, res) => { 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' }); if(!week || typeof week.label !== 'string' || !week.label) return res.status(400).json({ ok: false, error: 'Missing week or label' });
const dept = departmentKey(department); const dept = departmentKey(department);
try { 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); console.log('Saved week:', dept + ' / ' + week.label);
return res.json({ ok: true }); return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
} catch(err){ } catch(err){
console.error('Save week error', err); console.error('Save week error', err);
return res.status(500).json({ ok: false, error: String(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 // Route: Healthcheck-Endpunkt für Docker/Überwachung
app.get('/health', (req,res)=> res.json({ok:true})); 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) // 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) => { server.on('error', (err) => {
console.error('Server konnte nicht starten:', err.message); console.error('Server konnte nicht starten:', err.message);
if(err.code === 'EADDRINUSE'){ if(err.code === 'EADDRINUSE'){