Stand der laufenden Anwendung von 192.168.1.5:/opt/timer-app (systemd-Dienst timer-app, Port 3003). Zeiterfassung mit Kostenberechnung: Node/Express, SQLite, Anmeldung ausschliesslich ueber Authentik (OIDC, PKCE, RP-Logout) - der frueher vorhandene Passwort-Login ist entfernt. Nicht im Repo, bewusst: - backend/.env und die .env-Sicherung: enthalten Session-Secret und die Authentik-Zugangsdaten. Vorlage ist backend/.env.example. - data/: die Datenbank mit den echten Zeiterfassungen. - node_modules/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
617 lines
24 KiB
JavaScript
617 lines
24 KiB
JavaScript
/**
|
||
* Timer App - Backend
|
||
*
|
||
* Zeiterfassung mit Kostenberechnung fuer Ladengeschaefte.
|
||
*
|
||
* Kernprinzip: Timer leben SERVERSEITIG in der Datenbank, nicht im Browser.
|
||
* Dadurch ueberleben sie Reload, Browserwechsel, Tablet-Neustart und sind
|
||
* auf mehreren Geraeten gleichzeitig sichtbar.
|
||
*/
|
||
|
||
const express = require('express');
|
||
const session = require('express-session');
|
||
const rateLimit = require('express-rate-limit');
|
||
const sqlite3 = require('sqlite3').verbose();
|
||
const bcrypt = require('bcrypt');
|
||
const path = require('path');
|
||
require('dotenv').config();
|
||
const oidc = require('./oidc');
|
||
|
||
const app = express();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Konfiguration
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const PORT = parseInt(process.env.PORT) || 3003;
|
||
const HOST = process.env.HOST || '127.0.0.1';
|
||
const TRUST_PROXY = process.env.TRUST_PROXY || '1';
|
||
|
||
// COOKIE_SECURE=true verlangt HTTPS. Ohne HTTPS wird dann KEIN Cookie gesetzt
|
||
// und der Login scheitert stumm -> siehe DEPLOY.md, Abschnitt "Stolpersteine".
|
||
const COOKIE_SECURE = String(process.env.COOKIE_SECURE || 'true') === 'true';
|
||
|
||
const SESSION_SECRET = process.env.SESSION_SECRET;
|
||
const PASSWORD_HASH = process.env.PASSWORD_HASH;
|
||
const PASSWORD_PLAIN = process.env.PASSWORD;
|
||
|
||
const LOGIN_MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS) || 20;
|
||
const LOGIN_WINDOW_MIN = parseInt(process.env.LOGIN_WINDOW_MIN) || 15;
|
||
|
||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data/timer.db');
|
||
|
||
// --- Startpruefungen: lieber sofort und laut scheitern als still falsch laufen
|
||
|
||
if (!SESSION_SECRET) {
|
||
console.error('FATAL: SESSION_SECRET ist nicht gesetzt. Siehe .env.example.');
|
||
process.exit(1);
|
||
}
|
||
|
||
// Login ist ueber Authentik (OIDC) ODER Passwort moeglich. Nur wenn KEINES
|
||
// von beiden konfiguriert ist, gibt es keinen Anmeldeweg -> laut abbrechen.
|
||
if (!oidc.isConfigured() && !PASSWORD_HASH && !PASSWORD_PLAIN) {
|
||
console.error('FATAL: Kein Anmeldeweg konfiguriert — weder Authentik (OIDC_*) noch PASSWORD_HASH/PASSWORD.');
|
||
process.exit(1);
|
||
}
|
||
|
||
// Passwort-Login ist entfernt; passwordHash wird nur noch definiert, falls in der
|
||
// .env (Altbestand) ein Wert steht — schadet nicht, wird aber nicht mehr genutzt.
|
||
let passwordHash = PASSWORD_HASH;
|
||
if (!passwordHash && PASSWORD_PLAIN) {
|
||
passwordHash = bcrypt.hashSync(PASSWORD_PLAIN, 12);
|
||
}
|
||
|
||
app.set('trust proxy', TRUST_PROXY === 'false' ? false : (isNaN(parseInt(TRUST_PROXY)) ? TRUST_PROXY : parseInt(TRUST_PROXY)));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Datenbank
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const db = new sqlite3.Database(DB_PATH, (err) => {
|
||
if (err) {
|
||
console.error('FATAL: Datenbank nicht oeffenbar:', DB_PATH, err.message);
|
||
process.exit(1);
|
||
}
|
||
console.log('Datenbank verbunden:', DB_PATH);
|
||
initDatabase();
|
||
});
|
||
|
||
const dbRun = (sql, params = []) => new Promise((resolve, reject) => {
|
||
db.run(sql, params, function (err) { err ? reject(err) : resolve(this); });
|
||
});
|
||
const dbGet = (sql, params = []) => new Promise((resolve, reject) => {
|
||
db.get(sql, params, (err, row) => err ? reject(err) : resolve(row));
|
||
});
|
||
const dbAll = (sql, params = []) => new Promise((resolve, reject) => {
|
||
db.all(sql, params, (err, rows) => err ? reject(err) : resolve(rows));
|
||
});
|
||
|
||
async function initDatabase() {
|
||
try {
|
||
await dbRun('PRAGMA journal_mode = WAL');
|
||
|
||
await dbRun(`CREATE TABLE IF NOT EXISTS sessions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT NOT NULL,
|
||
start_time INTEGER NOT NULL,
|
||
end_time INTEGER,
|
||
duration INTEGER,
|
||
amount REAL,
|
||
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||
)`);
|
||
|
||
// Laufende Timer. Ein Datensatz pro Platz/Kunde.
|
||
await dbRun(`CREATE TABLE IF NOT EXISTS active_timers (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
customer_name TEXT NOT NULL DEFAULT '',
|
||
started_at INTEGER,
|
||
accumulated_ms INTEGER NOT NULL DEFAULT 0,
|
||
is_running INTEGER NOT NULL DEFAULT 1,
|
||
created_at INTEGER NOT NULL
|
||
)`);
|
||
|
||
await dbRun(`CREATE TABLE IF NOT EXISTS settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL
|
||
)`);
|
||
|
||
// Migration: Spalten fuer Kundenname und geltenden Tarif nachruesten.
|
||
const cols = await dbAll(`PRAGMA table_info(sessions)`);
|
||
const names = cols.map(c => c.name);
|
||
if (!names.includes('customer_name')) {
|
||
await dbRun(`ALTER TABLE sessions ADD COLUMN customer_name TEXT DEFAULT ''`);
|
||
console.log('Migration: sessions.customer_name ergaenzt');
|
||
}
|
||
if (!names.includes('rate_amount')) {
|
||
await dbRun(`ALTER TABLE sessions ADD COLUMN rate_amount REAL`);
|
||
console.log('Migration: sessions.rate_amount ergaenzt');
|
||
}
|
||
if (!names.includes('rate_interval')) {
|
||
await dbRun(`ALTER TABLE sessions ADD COLUMN rate_interval INTEGER`);
|
||
console.log('Migration: sessions.rate_interval ergaenzt');
|
||
}
|
||
|
||
await dbRun(`CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at DESC)`);
|
||
|
||
// Standardtarif einmalig setzen (aus .env uebernommen, falls vorhanden)
|
||
const existing = await dbGet(`SELECT value FROM settings WHERE key = 'rate_amount'`);
|
||
if (!existing) {
|
||
const envAmount = parseInt(process.env.RATE_PER_10MIN) || 10;
|
||
await dbRun(`INSERT INTO settings (key, value) VALUES ('rate_amount', ?)`, [String(envAmount)]);
|
||
await dbRun(`INSERT INTO settings (key, value) VALUES ('rate_interval', '10')`);
|
||
console.log(`Standardtarif gesetzt: ${envAmount} EUR pro 10 Minuten`);
|
||
}
|
||
|
||
console.log('Datenbank bereit');
|
||
} catch (err) {
|
||
console.error('FATAL: Datenbank-Initialisierung fehlgeschlagen:', err.message);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tarif & Berechnung (bewusst NUR serverseitig - der Client rechnet nur fuer
|
||
// die Anzeige mit, der verbindliche Betrag entsteht hier)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function getRate() {
|
||
const rows = await dbAll(`SELECT key, value FROM settings WHERE key IN ('rate_amount','rate_interval')`);
|
||
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||
return {
|
||
rateAmount: parseFloat(map.rate_amount) || 10,
|
||
rateInterval: parseInt(map.rate_interval) || 10
|
||
};
|
||
}
|
||
|
||
/** Jede angefangene Einheit wird voll berechnet. */
|
||
function calcAmount(ms, rateAmount, rateInterval) {
|
||
if (!ms || ms <= 0) return 0;
|
||
const minutes = Math.ceil(ms / 60000);
|
||
const units = Math.ceil(minutes / rateInterval);
|
||
return Math.round(units * rateAmount * 100) / 100;
|
||
}
|
||
|
||
/** Vergangene Zeit eines Timers - laufend oder pausiert. */
|
||
function elapsedMs(timer) {
|
||
const base = timer.accumulated_ms || 0;
|
||
if (!timer.is_running || !timer.started_at) return base;
|
||
return base + (Date.now() - timer.started_at);
|
||
}
|
||
|
||
async function decorate(timer, rate) {
|
||
const ms = elapsedMs(timer);
|
||
return {
|
||
id: timer.id,
|
||
customerName: timer.customer_name || '',
|
||
isRunning: !!timer.is_running,
|
||
elapsedMs: ms,
|
||
amount: calcAmount(ms, rate.rateAmount, rate.rateInterval),
|
||
startedAt: timer.started_at,
|
||
createdAt: timer.created_at,
|
||
serverNow: Date.now()
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sitzungsspeicher
|
||
//
|
||
// Bewusst selbst gebaut statt per Zusatzpaket: der Standardspeicher von
|
||
// express-session liegt im Arbeitsspeicher: nach jedem Neustart des Dienstes
|
||
// muessten sich alle Geraete neu anmelden. Da die Timer den Neustart
|
||
// ueberleben, waere das ein Bruch im Bedienablauf.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class SqliteSessionStore extends session.Store {
|
||
constructor() {
|
||
super();
|
||
db.run(`CREATE TABLE IF NOT EXISTS user_sessions (
|
||
sid TEXT PRIMARY KEY,
|
||
expires INTEGER NOT NULL,
|
||
data TEXT NOT NULL
|
||
)`, () => {
|
||
db.run(`CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires)`);
|
||
});
|
||
|
||
// Abgelaufene Sitzungen stuendlich entfernen
|
||
this.cleanupTimer = setInterval(() => {
|
||
db.run(`DELETE FROM user_sessions WHERE expires < ?`, [Date.now()]);
|
||
}, 60 * 60 * 1000);
|
||
this.cleanupTimer.unref();
|
||
}
|
||
|
||
_expiry(sess) {
|
||
const ms = sess && sess.cookie && sess.cookie.maxAge;
|
||
return Date.now() + (typeof ms === 'number' ? ms : 24 * 60 * 60 * 1000);
|
||
}
|
||
|
||
get(sid, cb) {
|
||
db.get(`SELECT data, expires FROM user_sessions WHERE sid = ?`, [sid], (err, row) => {
|
||
if (err) return cb(err);
|
||
if (!row) return cb(null, null);
|
||
if (row.expires < Date.now()) {
|
||
return db.run(`DELETE FROM user_sessions WHERE sid = ?`, [sid], () => cb(null, null));
|
||
}
|
||
try {
|
||
cb(null, JSON.parse(row.data));
|
||
} catch (e) {
|
||
cb(null, null);
|
||
}
|
||
});
|
||
}
|
||
|
||
set(sid, sess, cb = () => {}) {
|
||
let data;
|
||
try {
|
||
data = JSON.stringify(sess);
|
||
} catch (e) {
|
||
return cb(e);
|
||
}
|
||
db.run(`INSERT INTO user_sessions (sid, expires, data) VALUES (?, ?, ?)
|
||
ON CONFLICT(sid) DO UPDATE SET expires = excluded.expires, data = excluded.data`,
|
||
[sid, this._expiry(sess), data], cb);
|
||
}
|
||
|
||
touch(sid, sess, cb = () => {}) {
|
||
db.run(`UPDATE user_sessions SET expires = ? WHERE sid = ?`, [this._expiry(sess), sid], cb);
|
||
}
|
||
|
||
destroy(sid, cb = () => {}) {
|
||
db.run(`DELETE FROM user_sessions WHERE sid = ?`, [sid], cb);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Middleware
|
||
// ---------------------------------------------------------------------------
|
||
|
||
app.use(express.json());
|
||
app.use(express.urlencoded({ extended: true }));
|
||
|
||
app.use(session({
|
||
store: new SqliteSessionStore(),
|
||
secret: SESSION_SECRET,
|
||
resave: false,
|
||
saveUninitialized: false,
|
||
rolling: true, // aktive Nutzung verlaengert die Anmeldung
|
||
cookie: {
|
||
maxAge: 24 * 60 * 60 * 1000,
|
||
httpOnly: true,
|
||
sameSite: 'lax',
|
||
secure: COOKIE_SECURE
|
||
}
|
||
}));
|
||
|
||
app.use(express.static(path.join(__dirname, '../frontend')));
|
||
|
||
function requireAuth(req, res, next) {
|
||
if (req.session.authenticated) return next();
|
||
res.status(401).json({ error: 'Nicht angemeldet' });
|
||
}
|
||
|
||
// Fehler aus async-Handlern sauber weiterreichen
|
||
const wrap = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Auth
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Anmeldung ausschliesslich ueber Authentik (SSO). Der fruehere Passwort-Login
|
||
// (POST /api/login) wurde bewusst entfernt.
|
||
|
||
app.get('/api/auth/status', (req, res) => {
|
||
res.json({
|
||
authenticated: !!req.session.authenticated,
|
||
user: req.session.user || null, // bei Authentik-Login gesetzt, bei Passwort-Login null
|
||
oidcConfigured: oidc.isReady() // steuert den "Anmelden mit Authentik"-Button
|
||
});
|
||
});
|
||
|
||
// --- Authentik (OpenID Connect) -------------------------------------------
|
||
|
||
// Fuers Frontend: ist der Authentik-Login verfuegbar?
|
||
app.get('/auth/config', (req, res) => {
|
||
res.json({ oidcConfigured: oidc.isReady() });
|
||
});
|
||
|
||
// Start des Authentik-Logins -> Weiterleitung zu Authentik
|
||
app.get('/auth/login', wrap(async (req, res) => {
|
||
if (!oidc.isReady()) {
|
||
return res.status(503).send('Authentik ist nicht konfiguriert.');
|
||
}
|
||
const { url, codeVerifier, state } = await oidc.buildLoginUrl();
|
||
req.session.oidcVerifier = codeVerifier;
|
||
req.session.oidcState = state;
|
||
req.session.save(err => {
|
||
if (err) return res.status(500).send('Session-Fehler');
|
||
res.redirect(url);
|
||
});
|
||
}));
|
||
|
||
// Rueckkehr von Authentik (Code -> Tokens -> Sitzung)
|
||
app.get('/auth/callback', wrap(async (req, res) => {
|
||
const { oidcVerifier, oidcState } = req.session;
|
||
if (!oidcVerifier || !oidcState) {
|
||
return res.redirect('/?error=session');
|
||
}
|
||
try {
|
||
const currentUrl = new URL(req.originalUrl, oidc.REDIRECT_URI);
|
||
const { user, idToken } = await oidc.handleCallback(currentUrl, oidcVerifier, oidcState);
|
||
req.session.regenerate(err => {
|
||
if (err) return res.redirect('/?error=session');
|
||
req.session.authenticated = true;
|
||
req.session.userId = req.sessionID;
|
||
req.session.user = { username: user.username, displayName: user.displayName, email: user.email };
|
||
req.session.idToken = idToken; // fuer den Single-Logout
|
||
req.session.save(e => res.redirect(e ? '/?error=session' : '/'));
|
||
});
|
||
} catch (err) {
|
||
console.error('[auth] Callback-Fehler:', err.message || err);
|
||
res.redirect('/?error=oidc');
|
||
}
|
||
}));
|
||
|
||
// Logout per GET (Browser navigiert hierher): lokal beenden und – falls per
|
||
// Authentik angemeldet – auch die SSO-Sitzung mitbeenden (RP-Initiated Logout).
|
||
app.get('/auth/logout', (req, res) => {
|
||
if (req.get('sec-fetch-site') === 'cross-site') {
|
||
return res.status(403).send('Abmeldung nur aus der Anwendung heraus.');
|
||
}
|
||
const idToken = req.session.idToken;
|
||
const logoutUrl = idToken ? oidc.buildLogoutUrl(idToken) : null;
|
||
req.session.destroy(() => {
|
||
res.clearCookie('connect.sid');
|
||
res.redirect(logoutUrl || '/');
|
||
});
|
||
});
|
||
|
||
// Passwort-Logout (Fallback, wird per fetch aufgerufen)
|
||
app.post('/api/logout', (req, res) => {
|
||
req.session.destroy(() => res.json({ success: true }));
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Laufende Timer
|
||
// ---------------------------------------------------------------------------
|
||
|
||
app.get('/api/timers', requireAuth, wrap(async (req, res) => {
|
||
const rate = await getRate();
|
||
const rows = await dbAll(`SELECT * FROM active_timers ORDER BY created_at ASC`);
|
||
const timers = await Promise.all(rows.map(t => decorate(t, rate)));
|
||
res.json({ timers, rate, serverNow: Date.now() });
|
||
}));
|
||
|
||
app.post('/api/timers', requireAuth, wrap(async (req, res) => {
|
||
const name = String((req.body && req.body.customerName) || '').trim().slice(0, 80);
|
||
const now = Date.now();
|
||
const result = await dbRun(
|
||
`INSERT INTO active_timers (customer_name, started_at, accumulated_ms, is_running, created_at)
|
||
VALUES (?, ?, 0, 1, ?)`,
|
||
[name, now, now]
|
||
);
|
||
const rate = await getRate();
|
||
const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [result.lastID]);
|
||
res.json({ timer: await decorate(row, rate) });
|
||
}));
|
||
|
||
app.post('/api/timers/:id/pause', requireAuth, wrap(async (req, res) => {
|
||
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
|
||
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
|
||
if (!t.is_running) return res.json({ timer: await decorate(t, await getRate()) });
|
||
|
||
await dbRun(
|
||
`UPDATE active_timers SET accumulated_ms = ?, is_running = 0, started_at = NULL WHERE id = ?`,
|
||
[elapsedMs(t), t.id]
|
||
);
|
||
const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [t.id]);
|
||
res.json({ timer: await decorate(row, await getRate()) });
|
||
}));
|
||
|
||
app.post('/api/timers/:id/resume', requireAuth, wrap(async (req, res) => {
|
||
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
|
||
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
|
||
if (t.is_running) return res.json({ timer: await decorate(t, await getRate()) });
|
||
|
||
await dbRun(`UPDATE active_timers SET started_at = ?, is_running = 1 WHERE id = ?`, [Date.now(), t.id]);
|
||
const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [t.id]);
|
||
res.json({ timer: await decorate(row, await getRate()) });
|
||
}));
|
||
|
||
app.patch('/api/timers/:id', requireAuth, wrap(async (req, res) => {
|
||
const name = String((req.body && req.body.customerName) || '').trim().slice(0, 80);
|
||
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
|
||
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
|
||
await dbRun(`UPDATE active_timers SET customer_name = ? WHERE id = ?`, [name, t.id]);
|
||
const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [t.id]);
|
||
res.json({ timer: await decorate(row, await getRate()) });
|
||
}));
|
||
|
||
/** Beenden: schreibt die Session in die Historie und entfernt den Timer. */
|
||
app.post('/api/timers/:id/stop', requireAuth, wrap(async (req, res) => {
|
||
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
|
||
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
|
||
|
||
const rate = await getRate();
|
||
const duration = elapsedMs(t);
|
||
const amount = calcAmount(duration, rate.rateAmount, rate.rateInterval);
|
||
const endTime = Date.now();
|
||
|
||
if (duration <= 0) {
|
||
await dbRun(`DELETE FROM active_timers WHERE id = ?`, [t.id]);
|
||
return res.json({ saved: false, reason: 'Keine Zeit erfasst - nichts gespeichert.' });
|
||
}
|
||
|
||
const result = await dbRun(
|
||
`INSERT INTO sessions (session_id, start_time, end_time, duration, amount, customer_name, rate_amount, rate_interval, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
[req.session.userId || 'unknown', t.created_at, endTime, duration, amount,
|
||
t.customer_name || '', rate.rateAmount, rate.rateInterval, Math.floor(endTime / 1000)]
|
||
);
|
||
await dbRun(`DELETE FROM active_timers WHERE id = ?`, [t.id]);
|
||
|
||
res.json({ saved: true, id: result.lastID, duration, amount });
|
||
}));
|
||
|
||
/** Verwerfen: Timer weg, nichts in der Historie. */
|
||
app.delete('/api/timers/:id', requireAuth, wrap(async (req, res) => {
|
||
await dbRun(`DELETE FROM active_timers WHERE id = ?`, [req.params.id]);
|
||
res.json({ success: true });
|
||
}));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Einstellungen (serverseitig - gelten fuer ALLE Geraete)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
app.get('/api/settings', requireAuth, wrap(async (req, res) => {
|
||
res.json(await getRate());
|
||
}));
|
||
|
||
app.put('/api/settings', requireAuth, wrap(async (req, res) => {
|
||
const amount = parseFloat(req.body && req.body.rateAmount);
|
||
const interval = parseInt(req.body && req.body.rateInterval);
|
||
|
||
if (!isFinite(amount) || amount <= 0 || amount > 10000) {
|
||
return res.status(400).json({ error: 'Betrag muss zwischen 0 und 10000 liegen.' });
|
||
}
|
||
if (!Number.isInteger(interval) || interval < 1 || interval > 600) {
|
||
return res.status(400).json({ error: 'Intervall muss zwischen 1 und 600 Minuten liegen.' });
|
||
}
|
||
|
||
await dbRun(`INSERT INTO settings (key,value) VALUES ('rate_amount',?)
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(amount)]);
|
||
await dbRun(`INSERT INTO settings (key,value) VALUES ('rate_interval',?)
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(interval)]);
|
||
|
||
res.json(await getRate());
|
||
}));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Historie
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Baut die WHERE-Klausel fuer den Datumsfilter (Serverzeitzone). */
|
||
function dateFilter(query) {
|
||
const clauses = [];
|
||
const params = [];
|
||
if (query.from) {
|
||
clauses.push(`date(created_at, 'unixepoch', 'localtime') >= date(?)`);
|
||
params.push(String(query.from));
|
||
}
|
||
if (query.to) {
|
||
clauses.push(`date(created_at, 'unixepoch', 'localtime') <= date(?)`);
|
||
params.push(String(query.to));
|
||
}
|
||
if (query.q) {
|
||
clauses.push(`customer_name LIKE ?`);
|
||
params.push('%' + String(query.q) + '%');
|
||
}
|
||
return { where: clauses.length ? 'WHERE ' + clauses.join(' AND ') : '', params };
|
||
}
|
||
|
||
app.get('/api/sessions', requireAuth, wrap(async (req, res) => {
|
||
const limit = Math.min(parseInt(req.query.limit) || 50, 500);
|
||
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
||
const { where, params } = dateFilter(req.query);
|
||
|
||
const rows = await dbAll(
|
||
`SELECT * FROM sessions ${where} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
|
||
[...params, limit, offset]
|
||
);
|
||
// Summen ueber den GESAMTEN Filter, nicht nur ueber die angezeigte Seite
|
||
const totals = await dbGet(
|
||
`SELECT COUNT(*) AS count, COALESCE(SUM(duration),0) AS duration, COALESCE(SUM(amount),0) AS amount
|
||
FROM sessions ${where}`,
|
||
params
|
||
);
|
||
|
||
res.json({ sessions: rows, totals, limit, offset });
|
||
}));
|
||
|
||
app.delete('/api/sessions/:id', requireAuth, wrap(async (req, res) => {
|
||
const result = await dbRun(`DELETE FROM sessions WHERE id = ?`, [req.params.id]);
|
||
if (result.changes === 0) return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||
res.json({ success: true });
|
||
}));
|
||
|
||
/** CSV fuer Excel: Semikolon, UTF-8-BOM, deutsches Dezimalkomma. */
|
||
app.get('/api/sessions/export.csv', requireAuth, wrap(async (req, res) => {
|
||
const { where, params } = dateFilter(req.query);
|
||
const rows = await dbAll(`SELECT * FROM sessions ${where} ORDER BY created_at DESC, id DESC`, params);
|
||
|
||
const esc = v => {
|
||
const s = String(v == null ? '' : v);
|
||
return /[";\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||
};
|
||
const fmtTs = ms => {
|
||
if (!ms) return '';
|
||
const d = new Date(Number(ms));
|
||
const p = n => String(n).padStart(2, '0');
|
||
return `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||
};
|
||
const fmtDur = ms => {
|
||
const s = Math.floor((ms || 0) / 1000);
|
||
const p = n => String(n).padStart(2, '0');
|
||
return `${p(Math.floor(s / 3600))}:${p(Math.floor((s % 3600) / 60))}:${p(s % 60)}`;
|
||
};
|
||
const fmtNum = n => String(Number(n || 0).toFixed(2)).replace('.', ',');
|
||
|
||
const header = ['ID', 'Kunde', 'Beginn', 'Ende', 'Dauer', 'Dauer (Minuten)', 'Betrag (EUR)', 'Tarif'];
|
||
const lines = [header.join(';')];
|
||
|
||
for (const r of rows) {
|
||
lines.push([
|
||
r.id,
|
||
esc(r.customer_name || ''),
|
||
fmtTs(r.start_time),
|
||
fmtTs(r.end_time),
|
||
fmtDur(r.duration),
|
||
fmtNum((r.duration || 0) / 60000),
|
||
fmtNum(r.amount),
|
||
r.rate_amount ? `${fmtNum(r.rate_amount)} EUR / ${r.rate_interval} Min` : ''
|
||
].join(';'));
|
||
}
|
||
|
||
const stamp = new Date().toISOString().slice(0, 10);
|
||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||
res.setHeader('Content-Disposition', `attachment; filename="zeiterfassung-${stamp}.csv"`);
|
||
res.send('' + lines.join('\r\n') + '\r\n');
|
||
}));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Health & Fehlerbehandlung
|
||
// ---------------------------------------------------------------------------
|
||
|
||
app.get('/health', wrap(async (req, res) => {
|
||
const rate = await getRate();
|
||
const active = await dbGet(`SELECT COUNT(*) AS c FROM active_timers`);
|
||
res.json({
|
||
status: 'ok',
|
||
rateAmount: rate.rateAmount,
|
||
rateInterval: rate.rateInterval,
|
||
activeTimers: active.c,
|
||
uptimeSeconds: Math.floor(process.uptime())
|
||
});
|
||
}));
|
||
|
||
app.use((err, req, res, next) => {
|
||
console.error('Unerwarteter Fehler:', err);
|
||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Authentik-Discovery anstossen (nicht-blockierend; loggt das Ergebnis selbst)
|
||
oidc.init();
|
||
|
||
const server = app.listen(PORT, HOST, () => {
|
||
console.log(`Timer App laeuft auf http://${HOST}:${PORT}`);
|
||
console.log(`Cookie secure: ${COOKIE_SECURE} (bei false ist HTTP erlaubt)`);
|
||
});
|
||
|
||
function shutdown(signal) {
|
||
console.log(`${signal} empfangen - fahre herunter.`);
|
||
server.close(() => db.close(() => process.exit(0)));
|
||
setTimeout(() => process.exit(0), 5000).unref();
|
||
}
|
||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||
process.on('SIGINT', () => shutdown('SIGINT'));
|