Merge: Stufe 1 — Server-Persistenz (SQLite, Sync, Backup)

This commit is contained in:
Kenearos 2026-07-07 17:08:14 +02:00
commit 76569b94d6
18 changed files with 500 additions and 17 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
.git
.planning
.superpowers
docs/superpowers

3
.gitignore vendored
View file

@ -45,3 +45,6 @@ dist/
build/
coverage/
*.egg-info/
# Lokale Dev-DB + Backups (Laufzeitdaten, nie committen)
data/

View file

@ -194,22 +194,36 @@ docker run -p 3000:3000 -e PORT=3000 dienstplan-pro
**Container:** `dienstplan-pro` on the `matrix_default` Docker network so the
`matrix-caddy-1` reverse proxy can resolve it by hostname.
The Dockerfile uses `serve` from npm to serve static files:
The Dockerfile runs an Express server (`server/index.js`) that serves the
static frontend **and** the `/api/state` persistence API, backed by SQLite:
```dockerfile
FROM node:20-alpine
RUN npm install -g serve
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
CMD serve -s . -l tcp://0.0.0.0:${PORT:-3000}
ENV PORT=3000
ENV DATA_DIR=/data
EXPOSE 3000
CMD ["node", "server/index.js"]
```
Caddy block in `/opt/matrix/Caddyfile`:
Caddy block in `/opt/matrix/Caddyfile` (app + `/api/*` behind Basic-Auth):
```
bonus.pixel-by-design.de {
basic_auth {
benad <BCRYPT_HASH>
}
reverse_proxy dienstplan-pro:3000
}
```
> **Wichtig:** Die SQLite-DB und die täglichen Backups liegen auf dem
> benannten Docker-Volume `dienstplan-data` (`/data` im Container, Backups
> unter `/data/backups/`). Niemals ohne dieses Volume deployen — sonst
> löscht `docker rm` beim nächsten Update alle Daten unwiderruflich. Die
> Domain ist komplett hinter Caddy Basic-Auth (gilt auch für `/api/*`).
**Update procedure** (when pushing new code):
```bash
ssh root@65.21.60.83
@ -218,7 +232,8 @@ git pull
docker build -t dienstplan-pro:latest .
docker stop dienstplan-pro && docker rm dienstplan-pro
docker run -d --name dienstplan-pro --network matrix_default \
--restart unless-stopped -e PORT=3000 dienstplan-pro:latest
--restart unless-stopped -e PORT=3000 -e DATA_DIR=/data \
-v dienstplan-data:/data dienstplan-pro:latest
```
Caddy reloads not needed unless the Caddyfile changes.

View file

@ -1,14 +1,16 @@
FROM node:20-alpine
FROM node:20-slim
# Install simple static file server
RUN npm install -g serve
# Create app directory
WORKDIR /app
# Copy all files
# Erst Manifeste kopieren → Layer-Cache für npm install
COPY package*.json ./
RUN npm install --omit=dev
# Rest der App
COPY . .
# Start server on the port defined by Railway ($PORT)
# If $PORT is not set, default to 3000
CMD serve -s . -l tcp://0.0.0.0:${PORT:-3000}
ENV PORT=3000
ENV DATA_DIR=/data
EXPOSE 3000
CMD ["node", "server/index.js"]

6
app.js
View file

@ -1230,7 +1230,11 @@ class DienstplanApp {
// Initialize app when DOM is ready
let app;
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
if (window.DataSync) {
try { await window.DataSync.boot(); }
catch (e) { console.error('Sync-Boot fehlgeschlagen, App laeuft lokal weiter:', e); }
}
app = new DienstplanApp();
window.app = app;
});

View file

@ -326,6 +326,7 @@
<script src="variants.js"></script>
<script src="calculator.js"></script>
<script src="storage.js"></script>
<script src="sync.js"></script>
<script src="app.js"></script>
<script src="image-import.js"></script>
</body>

14
package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "dienstplan-pro",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"scripts": {
"start": "node server/index.js",
"test": "node --test"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.21.0"
}
}

34
server/backup.js Normal file
View file

@ -0,0 +1,34 @@
const path = require('path');
const fs = require('fs');
const { db, DATA_DIR } = require('./db');
const BACKUP_DIR = path.join(DATA_DIR, 'backups');
const KEEP = 14;
async function runBackup(now) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
const stamp = now.replace(/[:.]/g, '-');
const dest = path.join(BACKUP_DIR, `dienstplan-${stamp}.db`);
await db.backup(dest); // Online-Backup-API von SQLite — konsistent, ohne cp-Risiko
prune();
return dest;
}
function prune() {
const files = fs.readdirSync(BACKUP_DIR)
.filter(f => f.startsWith('dienstplan-') && f.endsWith('.db'))
.sort(); // ISO-abgeleiteter Stamp → lexikografisch == chronologisch
const excess = files.length - KEEP;
for (let i = 0; i < excess; i++) {
fs.unlinkSync(path.join(BACKUP_DIR, files[i]));
}
}
function scheduleBackups() {
const tick = () => runBackup(new Date().toISOString())
.catch(e => console.error('Backup fehlgeschlagen:', e));
tick();
setInterval(tick, 24 * 60 * 60 * 1000);
}
module.exports = { runBackup, scheduleBackups, BACKUP_DIR };

17
server/backup.test.js Normal file
View file

@ -0,0 +1,17 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-backup-'));
const { putDoc } = require('./db');
const { runBackup, BACKUP_DIR } = require('./backup');
test('runBackup erzeugt eine nicht-leere DB-Datei', async () => {
putDoc('employees', ['Max'], '2026-07-07T10:00:00.000Z');
const dest = await runBackup('2026-07-07T10:00:00.000Z');
assert.ok(fs.existsSync(dest), 'Backup-Datei existiert');
assert.ok(fs.statSync(dest).size > 0, 'Backup ist nicht leer');
assert.ok(dest.startsWith(BACKUP_DIR), 'Backup liegt im BACKUP_DIR');
});

51
server/db.js Normal file
View file

@ -0,0 +1,51 @@
const path = require('path');
const fs = require('fs');
const Database = require('better-sqlite3');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
fs.mkdirSync(DATA_DIR, { recursive: true });
const DB_PATH = path.join(DATA_DIR, 'dienstplan.db');
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.exec(`
CREATE TABLE IF NOT EXISTS documents (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
value TEXT NOT NULL,
replaced_at TEXT NOT NULL
);
`);
function getDoc(key) {
const row = db.prepare('SELECT value, updated_at FROM documents WHERE key = ?').get(key);
if (!row) return null;
return { value: JSON.parse(row.value), updatedAt: row.updated_at };
}
// ponytail: history wächst unbegrenzt. Bei Single-User/kleinen Docs jahrelang egal.
// Pruning (z.B. > 500 Einträge pro key löschen) nachrüsten, falls es je wächst.
function putDoc(key, value, now) {
const json = JSON.stringify(value);
const tx = db.transaction(() => {
const existing = db.prepare('SELECT value FROM documents WHERE key = ?').get(key);
if (existing) {
db.prepare('INSERT INTO history (key, value, replaced_at) VALUES (?, ?, ?)')
.run(key, existing.value, now);
}
db.prepare(`
INSERT INTO documents (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`).run(key, json, now);
});
tx();
}
module.exports = { db, getDoc, putDoc, DB_PATH, DATA_DIR };

29
server/db.test.js Normal file
View file

@ -0,0 +1,29 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
// DATA_DIR auf Temp umbiegen, BEVOR db.js geladen wird
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-db-'));
const { getDoc, putDoc } = require('./db');
test('putDoc/getDoc Roundtrip', () => {
putDoc('employees', ['Max', 'Anna'], '2026-07-07T10:00:00.000Z');
const doc = getDoc('employees');
assert.deepStrictEqual(doc.value, ['Max', 'Anna']);
assert.strictEqual(doc.updatedAt, '2026-07-07T10:00:00.000Z');
});
test('getDoc für unbekannten Key liefert null', () => {
assert.strictEqual(getDoc('gibtsnicht'), null);
});
test('putDoc snapshottet den alten Wert in history', () => {
const { db } = require('./db');
putDoc('duties', { v: 1 }, '2026-07-07T10:00:00.000Z');
putDoc('duties', { v: 2 }, '2026-07-07T11:00:00.000Z');
const rows = db.prepare("SELECT value FROM history WHERE key = 'duties' ORDER BY id").all();
assert.strictEqual(rows.length, 1);
assert.deepStrictEqual(JSON.parse(rows[0].value), { v: 1 });
});

46
server/index.js Normal file
View file

@ -0,0 +1,46 @@
const path = require('path');
const express = require('express');
const { getDoc, putDoc } = require('./db');
const { scheduleBackups } = require('./backup');
const app = express();
app.use(express.json({ limit: '5mb' }));
const KEYS = ['employees', 'duties', 'vacation'];
const EMPTY = { employees: [], duties: {}, vacation: {} };
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
app.get('/api/state', (req, res) => {
const state = { ...EMPTY, updatedAt: null };
for (const key of KEYS) {
const doc = getDoc(key);
if (doc) {
state[key] = doc.value;
if (!state.updatedAt || doc.updatedAt > state.updatedAt) state.updatedAt = doc.updatedAt;
}
}
res.json(state);
});
app.put('/api/state', (req, res) => {
const body = req.body || {};
const now = new Date().toISOString();
for (const key of KEYS) {
if (body[key] !== undefined) putDoc(key, body[key], now);
}
res.json({ status: 'ok', updatedAt: now });
});
// Statisches Frontend (Repo-Root). Dotfiles werden per Default nicht ausgeliefert.
app.use(express.static(path.join(__dirname, '..')));
if (require.main === module) {
const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => {
console.log(`Dienstplan-Pro auf :${PORT}`);
scheduleBackups();
});
}
module.exports = app;

16
server/index.test.js Normal file
View file

@ -0,0 +1,16 @@
const { test } = require('node:test');
const assert = require('node:assert');
const app = require('./index');
test('GET /api/health liefert ok', async () => {
const server = app.listen(0);
const { port } = server.address();
try {
const res = await fetch(`http://127.0.0.1:${port}/api/health`);
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.status, 'ok');
} finally {
server.close();
}
});

42
server/state.test.js Normal file
View file

@ -0,0 +1,42 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-state-'));
const app = require('./index');
async function req(port, method, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`http://127.0.0.1:${port}/api/state`, opts);
return { status: res.status, body: await res.json() };
}
test('leerer Server liefert leeren State', async () => {
const server = app.listen(0);
const { port } = server.address();
try {
const { status, body } = await req(port, 'GET');
assert.strictEqual(status, 200);
assert.deepStrictEqual(body.employees, []);
assert.deepStrictEqual(body.duties, {});
assert.deepStrictEqual(body.vacation, {});
assert.strictEqual(body.updatedAt, null);
} finally { server.close(); }
});
test('PUT dann GET Roundtrip', async () => {
const server = app.listen(0);
const { port } = server.address();
try {
const put = await req(port, 'PUT', { employees: ['Max'], duties: { Max: { '2026-07': [] } } });
assert.strictEqual(put.status, 200);
assert.strictEqual(put.body.status, 'ok');
const get = await req(port, 'GET');
assert.deepStrictEqual(get.body.employees, ['Max']);
assert.deepStrictEqual(get.body.duties, { Max: { '2026-07': [] } });
assert.strictEqual(typeof get.body.updatedAt, 'string');
} finally { server.close(); }
});

View file

@ -45,6 +45,7 @@ class DataStorage {
throw new TypeError('employees muss ein Array sein');
}
localStorage.setItem(this.STORAGE_KEY_EMPLOYEES, JSON.stringify(employees));
this._notifyChange();
} catch (e) {
console.error('Fehler beim Speichern der Mitarbeiter-Daten:', e);
throw e;
@ -117,6 +118,7 @@ class DataStorage {
throw new TypeError('duties muss ein gültiges Objekt sein');
}
localStorage.setItem(this.STORAGE_KEY_DUTIES, JSON.stringify(duties));
this._notifyChange();
} catch (e) {
console.error('Fehler beim Speichern der Dienst-Daten:', e);
throw e;
@ -285,6 +287,7 @@ class DataStorage {
if (!map[employeeName]) map[employeeName] = {};
map[employeeName][yearMonth] = Boolean(value);
localStorage.setItem(this.STORAGE_KEY_VACATION, JSON.stringify(map));
this._notifyChange();
} catch (e) {
console.error('Fehler beim Speichern des Urlaubsmodus:', e);
throw e;
@ -319,6 +322,16 @@ class DataStorage {
return map;
}
/**
* Signalisiert dem Sync-Layer eine Aenderung (falls vorhanden).
* Wird unterdrueckt, waehrend DataSync gerade Server-Daten uebernimmt.
*/
_notifyChange() {
if (typeof window !== 'undefined' && window.DataSync && !window.DataSync._applying) {
window.DataSync.push();
}
}
/**
* Clear all data
*/
@ -326,6 +339,7 @@ class DataStorage {
localStorage.removeItem(this.STORAGE_KEY_EMPLOYEES);
localStorage.removeItem(this.STORAGE_KEY_DUTIES);
localStorage.removeItem(this.STORAGE_KEY_VACATION);
this._notifyChange();
}
/**
@ -363,6 +377,7 @@ class DataStorage {
if (data.vacation && typeof data.vacation === 'object') {
localStorage.setItem(this.STORAGE_KEY_VACATION, JSON.stringify(data.vacation));
}
this._notifyChange();
return true;
} catch (e) {
console.error('Import failed:', e);

3
sw.js
View file

@ -1,4 +1,4 @@
const CACHE_NAME = 'dienstplan-pro-v4';
const CACHE_NAME = 'dienstplan-pro-v5';
const ASSETS = [
'./',
'./index.html',
@ -8,6 +8,7 @@ const ASSETS = [
'./variants.js',
'./holidays.js',
'./storage.js',
'./sync.js',
'./image-import.js'
];

109
sync.js Normal file
View file

@ -0,0 +1,109 @@
/**
* DataSync hält LocalStorage (synchrone Working-Copy) und Server-DB
* (dauerhafte Wahrheit + Backup + Historie) synchron.
* ponytail: last-write-wins auf ganzen Dokumenten. Single-User genügt.
*/
const DataSync = {
KEY_EMPLOYEES: 'dienstplan_employees',
KEY_DUTIES: 'dienstplan_duties',
KEY_VACATION: 'dienstplan_vacation',
KEY_PENDING: 'dienstplan_sync_pending',
_applying: false,
_timer: null,
online: false,
_local() {
const parse = (k, fb) => {
try { const v = JSON.parse(localStorage.getItem(k)); return v ?? fb; }
catch { return fb; }
};
return {
employees: parse(this.KEY_EMPLOYEES, []),
duties: parse(this.KEY_DUTIES, {}),
vacation: parse(this.KEY_VACATION, {}),
};
},
_isEmpty(s) {
return (!s.employees || s.employees.length === 0)
&& (!s.duties || Object.keys(s.duties).length === 0)
&& (!s.vacation || Object.keys(s.vacation).length === 0);
},
// Reine Entscheidung — in Node unit-getestet.
decideSync(local, server, pending) {
if (server === null) return 'offline';
if (pending) return 'push-local';
if (this._isEmpty(server) && !this._isEmpty(local)) return 'push-local';
return 'adopt-server';
},
_applyServer(server) {
this._applying = true;
try {
localStorage.setItem(this.KEY_EMPLOYEES, JSON.stringify(server.employees ?? []));
localStorage.setItem(this.KEY_DUTIES, JSON.stringify(server.duties ?? {}));
localStorage.setItem(this.KEY_VACATION, JSON.stringify(server.vacation ?? {}));
} finally {
this._applying = false;
}
},
async boot() {
let server = null;
try {
const res = await fetch('/api/state', { cache: 'no-store' });
if (res.ok) server = await res.json();
} catch { /* offline */ }
const action = this.decideSync(this._local(), server, localStorage.getItem(this.KEY_PENDING) === '1');
if (action === 'offline') { this.online = false; this._renderStatus(); return; }
this.online = true;
if (action === 'adopt-server') this._applyServer(server);
else await this._flush(); // push-local: Erstmigration oder pending Offline-Aenderungen
this._renderStatus();
},
push() {
if (this._applying) return;
this._dirty = (this._dirty || 0) + 1;
localStorage.setItem(this.KEY_PENDING, '1');
clearTimeout(this._timer);
this._timer = setTimeout(() => this._flush(), 500);
},
async _flush() {
const gen = this._dirty; // ponytail: Generationszaehler gegen Verlust-Race bei ueberlappenden Flushes
try {
const res = await fetch('/api/state', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this._local()),
});
if (!res.ok) throw new Error('HTTP ' + res.status);
if (this._dirty === gen) localStorage.removeItem(this.KEY_PENDING); // nur wenn kein neuerer push() lief
this.online = true;
} catch (e) {
console.error('Sync fehlgeschlagen, Daten bleiben lokal:', e);
this.online = false; // pending bleibt gesetzt → naechster boot() pusht
}
this._renderStatus();
},
_renderStatus() {
if (typeof document === 'undefined' || !document.body) return;
let el = document.getElementById('sync-status');
if (!el) {
el = document.createElement('div');
el.id = 'sync-status';
el.style.cssText = 'position:fixed;bottom:8px;right:8px;font-size:12px;'
+ 'padding:4px 8px;border-radius:6px;z-index:9999;opacity:.85;color:#fff';
document.body.appendChild(el);
}
el.textContent = this.online ? '● Synchronisiert' : '● Offline (nur lokal)';
el.style.background = this.online ? '#1e7e34' : '#856404';
},
};
if (typeof module !== 'undefined' && module.exports) module.exports = DataSync;
if (typeof window !== 'undefined') window.DataSync = DataSync;

79
sync.test.js Normal file
View file

@ -0,0 +1,79 @@
const { test } = require('node:test');
const assert = require('node:assert');
const DataSync = require('./sync');
const empty = { employees: [], duties: {}, vacation: {} };
const full = { employees: ['Max'], duties: { Max: {} }, vacation: {} };
test('Server unerreichbar → offline', () => {
assert.strictEqual(DataSync.decideSync(full, null, false), 'offline');
});
test('pending gesetzt → push-local (Offline-Aenderungen gewinnen)', () => {
assert.strictEqual(DataSync.decideSync(full, full, true), 'push-local');
});
test('Server leer, lokal voll → push-local (Erstmigration)', () => {
assert.strictEqual(DataSync.decideSync(full, empty, false), 'push-local');
});
test('Server hat Daten → adopt-server', () => {
assert.strictEqual(DataSync.decideSync(empty, full, false), 'adopt-server');
});
test('beide leer → adopt-server (nichts zu tun)', () => {
assert.strictEqual(DataSync.decideSync(empty, empty, false), 'adopt-server');
});
// --- localStorage/fetch Stubs fuer _flush()-Race-Tests ---
function makeLocalStorage() {
const map = new Map();
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => map.set(k, String(v)),
removeItem: (k) => map.delete(k),
};
}
test('einzelner push() + erfolgreicher flush → pending wird geleert', async () => {
global.localStorage = makeLocalStorage();
global.fetch = async () => ({ ok: true, json: async () => ({}) });
DataSync._dirty = 0;
DataSync._applying = false;
DataSync.push();
clearTimeout(DataSync._timer); // manueller Flush statt Debounce
assert.strictEqual(localStorage.getItem(DataSync.KEY_PENDING), '1');
await DataSync._flush();
assert.strictEqual(localStorage.getItem(DataSync.KEY_PENDING), null);
delete global.localStorage;
delete global.fetch;
});
test('push() waehrend laufendem flush() → pending bleibt gesetzt (Verlust-Race verhindert)', async () => {
global.localStorage = makeLocalStorage();
DataSync._dirty = 0;
DataSync._applying = false;
let resolveFetch;
global.fetch = () => new Promise((resolve) => { resolveFetch = resolve; });
DataSync.push(); // dirty=1, pending='1'
clearTimeout(DataSync._timer);
const flushPromise = DataSync._flush(); // gen erfasst als 1, fetch haengt in flight
DataSync.push(); // B: dirty=2, pending erneut '1' waehrend flush#1 noch laeuft
clearTimeout(DataSync._timer);
resolveFetch({ ok: true, json: async () => ({}) }); // flush#1 kommt zurueck
await flushPromise;
assert.strictEqual(localStorage.getItem(DataSync.KEY_PENDING), '1');
delete global.localStorage;
delete global.fetch;
});