From 0371aa85a5ab85329aca7e9dc191612fd433a219 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Mon, 13 Jul 2026 21:57:50 +0200 Subject: [PATCH] Multi-User-Plattform: jeder Benutzer hat eigene, isolierte Daten - Auth via Session-Cookie + Login-Seite (scrypt, lib/password.js, sessions-Tabelle) - AsyncLocalStorage (lib/context.js) propagiert aktuellen Benutzer durch alle Libs - user_id auf allen Datentabellen (FK->users ON DELETE CASCADE), per-user PK/UNIQUE (app_state, settings, prompts, design, jobangebote) und per-user Dateispeicher (data///) - Alle Queries in server.js + lib/api.js nach user_id scope-iert - Pro-Benutzer-Konfiguration (Ollama/Mail/CalDAV/API-Token) in app_state, Live gelesen via config.get(); Hintergrund-Loops (IMAP/CalDAV) iterieren alle Benutzer - REST-API /api/v1: X-API-Key loest den Token zu einem Benutzer auf, Anfragen operieren nur auf dessen Daten - Admin-Panel /admin: Benutzer anlegen, Passwort zuruecksetzen, loeschen (mit Daten) - Idempotente Migration (lib/migrate-multiuser.js + scripts/migrate-to-multiuser.js): bestehende Daten werden dem Benutzer admin:admin zugeordnet Co-Authored-By: Claude --- lib/api.js | 253 ++--- lib/config.js | 129 ++- lib/context.js | 32 + lib/migrate-multiuser.js | 311 ++++++ lib/password.js | 34 + scripts/migrate-to-multiuser.js | 41 + server.js | 1614 ++++++++++++++++++------------- views/admin.ejs | 111 +++ views/login.ejs | 47 + views/partials/header.ejs | 27 + 10 files changed, 1772 insertions(+), 827 deletions(-) create mode 100644 lib/context.js create mode 100644 lib/migrate-multiuser.js create mode 100644 lib/password.js create mode 100644 scripts/migrate-to-multiuser.js create mode 100644 views/admin.ejs create mode 100644 views/login.ejs diff --git a/lib/api.js b/lib/api.js index bbe4671..0b2df71 100644 --- a/lib/api.js +++ b/lib/api.js @@ -1,10 +1,11 @@ // Third-party REST API (v1) for the Bewerbungs-Tracker. // // Mounted under /api/v1 in server.js. All endpoints except /health require an -// API key (API_TOKEN, editable via /einstellungen) sent in the X-API-Key -// header. Reuses the server's existing DB helpers, sanitizer, duplicate guard, -// generation runner and attachment directories so behaviour stays consistent -// with the web UI. +// API key sent in the X-API-Key header. The key is per-user: it is the +// API_TOKEN the user saved on /einstellungen. The auth step resolves the key to +// the owning user, then runs every request inside that user's context +// (lib/context.js) so all queries + file paths are scoped to that user — the +// third-party software only ever sees and creates the user's own data. const express = require('express'); const path = require('path'); @@ -12,6 +13,9 @@ const fs = require('fs'); const blacklist = require('./blacklist'); const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels'); const { normalizeDokumente } = require('./documents'); +const { userContext, currentUserId } = require('./context'); + +const CONFIG_PREFIX = 'cfg:'; // Replace the stored labels JSON string with a real array on outgoing rows. function withLabels(row) { @@ -37,35 +41,53 @@ function createExternalApi(deps) { runGeneration, anhaengeDir, emailAnhaengeDir, - apiToken, + userStorageDir, } = deps; const router = express.Router(); - // `apiToken` may be a string (static) or a function () => string (dynamic, - // read from the DB on each request so an edit on /einstellungen takes effect - // without a restart). - const resolveToken = () => (typeof apiToken === 'function' ? apiToken() : apiToken); + // The current user's id, resolved from the per-request context set below. + const uid = () => currentUserId(); // --- API key auth -------------------------------------------------- - // /health is public so monitoring tools can probe availability; everything - // else returns 401 when the header is missing/wrong or the token isn't set. - router.use((req, res, next) => { + // /health is public so monitoring tools can probe availability. Every other + // endpoint resolves the X-API-Key to the user who owns that token (stored in + // app_state as cfg:API_TOKEN), then wraps the rest of the request in that + // user's context so all queries/files are scoped to them. A missing or + // unknown key yields 401. + router.use(async (req, res, next) => { if (req.path === '/health') return next(); - const token = resolveToken(); - if (!token) { - return res.status(503).json({ error: 'API-Token nicht konfiguriert (in den Einstellungen setzen).' }); - } const provided = req.get('X-API-Key'); - if (!provided || provided !== token) { + if (!provided) { return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' }); } - next(); + try { + // Resolve the token to its owning user (the user whose cfg:API_TOKEN + // matches). Two users could in principle share a value — we take the + // first match, which is fine since the data the caller then sees is that + // one user's only. + const user = await dbGet( + `SELECT u.id, u.username, u.is_admin + FROM app_state a JOIN users u ON u.id = a.user_id + WHERE a.key = ? AND a.value = ? LIMIT 1`, + [CONFIG_PREFIX + 'API_TOKEN', provided] + ); + if (!user) { + return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' }); + } + req.user = user; + // Run the remainder of the request inside this user's context so + // currentUserId() / config.get() / the scoped helpers all resolve here. + userContext.run(user, next); + } catch (err) { + console.error('API auth error:', err); + res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' }); + } }); // --- helpers ------------------------------------------------------- async function getApplication(id) { - return dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); + return dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]); } // --- System -------------------------------------------------------- @@ -80,8 +102,8 @@ function createExternalApi(deps) { const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500); const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0); - const where = []; - const params = []; + const where = ['user_id = ?']; + const params = [uid()]; if (label && LABEL_OPTIONS.includes(label)) { // labels is a JSON array string; match the quoted label token. where.push('labels LIKE ?'); @@ -109,7 +131,7 @@ function createExternalApi(deps) { params.push(term, term); } - const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + const clause = `WHERE ${where.join(' AND ')}`; const applications = await dbAll( `SELECT * FROM bewerbungen ${clause} ORDER BY datum DESC, created_at DESC LIMIT ? OFFSET ?`, [...params, limit, offset] @@ -146,10 +168,11 @@ function createExternalApi(deps) { const result = await dbRun( `INSERT INTO bewerbungen - (datum, firma, stelle, art, status, notizen, interne_notizen, ort, + (user_id, datum, firma, stelle, art, status, notizen, interne_notizen, ort, stellenbeschreibung, quelle_url, llm_notizen, labels, generierung_status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`, [ + uid(), datum, sanitizeInput(firma), sanitizeInput(stelle), @@ -167,12 +190,12 @@ function createExternalApi(deps) { if (b.status && String(b.status).trim()) { await dbRun( - 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', - [result.lastID, datum, sanitizeInput(b.status), sanitizeInput(b.kommentar || '')] + 'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)', + [uid(), result.lastID, datum, sanitizeInput(b.status), sanitizeInput(b.kommentar || '')] ); } - const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]); + const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [result.lastID, uid()]); res.json({ success: true, application: withLabels(application) }); } catch (error) { console.error('API create application error:', error); @@ -208,7 +231,7 @@ function createExternalApi(deps) { datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, interne_notizen = ?, ort = ?, stellenbeschreibung = ?, quelle_url = ?, llm_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ?`, + WHERE id = ? AND user_id = ?`, [ b.datum, sanitizeInput(b.firma), @@ -225,10 +248,11 @@ function createExternalApi(deps) { ? serializeLabels(b.labels) : (existing.labels || '[]'), id, + uid(), ] ); - const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); + const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]); res.json({ success: true, application: withLabels(application) }); } catch (error) { console.error('API update application error:', error); @@ -242,8 +266,8 @@ function createExternalApi(deps) { const existing = await getApplication(id); if (!existing) return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); - await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]); - await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]); + await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]); + await dbRun('DELETE FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]); res.json({ success: true }); } catch (error) { console.error('API delete application error:', error); @@ -257,8 +281,8 @@ function createExternalApi(deps) { const { id } = req.params; if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); const verlauf = await dbAll( - 'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC', - [id] + 'SELECT * FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ? ORDER BY date(datum) ASC, id ASC', + [id, uid()] ); res.json(verlauf); } catch (error) { @@ -279,12 +303,12 @@ function createExternalApi(deps) { const day = datum || new Date().toISOString().split('T')[0]; const result = await dbRun( - 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', - [id, day, status, sanitizeInput(kommentar || '')] + 'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)', + [uid(), id, day, status, sanitizeInput(kommentar || '')] ); await syncCurrentStatus(id); - const entry = await dbGet('SELECT * FROM status_verlauf WHERE id = ?', [result.lastID]); + const entry = await dbGet('SELECT * FROM status_verlauf WHERE id = ? AND user_id = ?', [result.lastID, uid()]); res.json(entry); } catch (error) { console.error('API add timeline error:', error); @@ -296,12 +320,12 @@ function createExternalApi(deps) { try { const { id, eintragId } = req.params; const entry = await dbGet( - 'SELECT id FROM status_verlauf WHERE id = ? AND bewerbung_id = ?', - [eintragId, id] + 'SELECT id FROM status_verlauf WHERE id = ? AND bewerbung_id = ? AND user_id = ?', + [eintragId, id, uid()] ); if (!entry) return res.status(404).json({ error: 'Verlaufseintrag nicht gefunden' }); - await dbRun('DELETE FROM status_verlauf WHERE id = ?', [eintragId]); + await dbRun('DELETE FROM status_verlauf WHERE id = ? AND user_id = ?', [eintragId, uid()]); await syncCurrentStatus(id); res.json({ success: true }); } catch (error) { @@ -316,8 +340,8 @@ function createExternalApi(deps) { const { id } = req.params; if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); const anhaenge = await dbAll( - 'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', - [id] + 'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC', + [id, uid()] ); res.json(anhaenge); } catch (error) { @@ -330,12 +354,12 @@ function createExternalApi(deps) { try { const { id, attachmentId } = req.params; const anhang = await dbGet( - 'SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?', - [attachmentId, id] + 'SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', + [attachmentId, id, uid()] ); if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' }); - const file = path.join(anhaengeDir, anhang.pfad); + const file = path.join(userStorageDir(anhaengeDir), anhang.pfad); if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' }); res.download(file, anhang.dateiname || anhang.name || path.basename(anhang.pfad)); @@ -352,14 +376,14 @@ function createExternalApi(deps) { if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); const emails = await dbAll( - 'SELECT * FROM emails WHERE bewerbung_id = ? ORDER BY datetime(email_date) ASC, id ASC', - [id] + 'SELECT * FROM emails WHERE bewerbung_id = ? AND user_id = ? ORDER BY datetime(email_date) ASC, id ASC', + [id, uid()] ); if (emails.length) { const eIds = emails.map((e) => e.id); const atts = await dbAll( - `SELECT id, email_id, name, mime FROM email_anhaenge WHERE email_id IN (${eIds.map(() => '?').join(',')})`, - eIds + `SELECT id, email_id, name, mime FROM email_anhaenge WHERE user_id = ? AND email_id IN (${eIds.map(() => '?').join(',')})`, + [uid(), ...eIds] ); const byEmail = {}; atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); }); @@ -376,12 +400,12 @@ function createExternalApi(deps) { try { const { emailId, attachmentId } = req.params; const anhang = await dbGet( - 'SELECT * FROM email_anhaenge WHERE id = ? AND email_id = ?', - [attachmentId, emailId] + 'SELECT * FROM email_anhaenge WHERE id = ? AND email_id = ? AND user_id = ?', + [attachmentId, emailId, uid()] ); if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' }); - const file = path.join(emailAnhaengeDir, anhang.pfad); + const file = path.join(userStorageDir(emailAnhaengeDir), anhang.pfad); if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' }); res.download(file, anhang.name || path.basename(anhang.pfad)); @@ -399,18 +423,19 @@ function createExternalApi(deps) { if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); if (typeof (req.body || {}).llm_notizen !== 'undefined') { - await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ?', [req.body.llm_notizen || '', id]); + await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ? AND user_id = ?', [req.body.llm_notizen || '', id, uid()]); } // Drop existing generated attachments + their files before re-generating. - const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]); + const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]); + const dir = userStorageDir(anhaengeDir); for (const a of alte) { - fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {}); + fs.promises.unlink(path.join(dir, a.pfad)).catch(() => {}); } - await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]); + await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]); await dbRun( - "UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?", - [id] + "UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ? AND user_id = ?", + [id, uid()] ); // Optional: IDs of static attachments (basis_anhaenge) to enclose; none @@ -421,7 +446,7 @@ function createExternalApi(deps) { // Optional: which documents to produce (["anschreiben"], ["lebenslauf"] or // both). Omitted / empty means both. const dokumente = normalizeDokumente((req.body || {}).dokumente); - await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ?', [dokumente.join(','), id]); + await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ? AND user_id = ?', [dokumente.join(','), id, uid()]); runGeneration(id, { anlagenIds, dokumente }); res.status(202).json({ success: true, dokumente }); } catch (error) { @@ -434,14 +459,14 @@ function createExternalApi(deps) { try { const { id } = req.params; const bewerbung = await dbGet( - 'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?', - [id] + 'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ? AND user_id = ?', + [id, uid()] ); if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); const anhaenge = await dbAll( - 'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', - [id] + 'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC', + [id, uid()] ); res.json({ status: bewerbung.generierung_status, fehler: bewerbung.generierung_fehler, anhaenge }); } catch (error) { @@ -453,7 +478,7 @@ function createExternalApi(deps) { // --- Settings ------------------------------------------------------ router.get('/settings', async (req, res) => { try { - const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); + const settings = await dbGet('SELECT * FROM settings WHERE user_id = ?', [uid()]); res.json(settings); } catch (error) { console.error('API get settings error:', error); @@ -465,8 +490,8 @@ function createExternalApi(deps) { try { const { name, adresse, kundennummer } = req.body || {}; await dbRun( - 'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1', - [sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)] + 'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE user_id = ?', + [sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer), uid()] ); res.json({ success: true }); } catch (error) { @@ -478,17 +503,19 @@ function createExternalApi(deps) { // --- Statistics & export ------------------------------------------- router.get('/statistics', async (req, res) => { try { - const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen'); - const byArt = await dbAll(` - SELECT art, COUNT(*) as count FROM bewerbungen - WHERE art IS NOT NULL AND art != '' - GROUP BY art ORDER BY count DESC - `); - const byStatus = await dbAll(` - SELECT status, COUNT(*) as count FROM bewerbungen - WHERE status IS NOT NULL AND status != '' - GROUP BY status ORDER BY count DESC - `); + const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [uid()]); + const byArt = await dbAll( + `SELECT art, COUNT(*) as count FROM bewerbungen + WHERE user_id = ? AND art IS NOT NULL AND art != '' + GROUP BY art ORDER BY count DESC`, + [uid()] + ); + const byStatus = await dbAll( + `SELECT status, COUNT(*) as count FROM bewerbungen + WHERE user_id = ? AND status IS NOT NULL AND status != '' + GROUP BY status ORDER BY count DESC`, + [uid()] + ); res.json({ total: totalCount ? totalCount.count : 0, byArt, @@ -503,16 +530,16 @@ function createExternalApi(deps) { router.get('/export', async (req, res) => { try { const { month, year } = req.query; - let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC'; - const params = []; + let query = 'SELECT * FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC'; + const params = [uid()]; if (month && year) { - query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC'; + query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC'; params.push(String(month).padStart(2, '0'), String(year)); } else if (month) { - query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? ORDER BY datum DESC'; + query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%m", datum) = ? ORDER BY datum DESC'; params.push(String(month).padStart(2, '0')); } else if (year) { - query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC'; + query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC'; params.push(String(year)); } @@ -530,7 +557,7 @@ function createExternalApi(deps) { // --- Templates ----------------------------------------------------- router.get('/templates', async (req, res) => { try { - const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC'); + const docs = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [uid()]); res.json(docs); } catch (error) { console.error('API list templates error:', error); @@ -539,12 +566,14 @@ function createExternalApi(deps) { }); // --- Job offers (ingested by third-party software) ----------------- - // POST upserts by (quelle, external_id): re-sending the same offer updates - // it instead of creating a duplicate. `quelle` defaults to "drittanbieter". + // POST upserts by (user_id, quelle, external_id): re-sending the same offer + // updates it instead of creating a duplicate. `quelle` defaults to + // "drittanbieter". router.get('/joboffers', async (req, res) => { try { const rows = await dbAll( - `SELECT * FROM jobangebote ORDER BY created_at DESC, id DESC` + 'SELECT * FROM jobangebote WHERE user_id = ? ORDER BY created_at DESC, id DESC', + [uid()] ); rows.forEach(withLabels); res.json(rows); @@ -560,7 +589,8 @@ function createExternalApi(deps) { router.get('/joboffers/blacklist', async (req, res) => { try { const rows = await dbAll( - 'SELECT * FROM jobangebote_blacklist ORDER BY created_at DESC, id DESC' + 'SELECT * FROM jobangebote_blacklist WHERE user_id = ? ORDER BY created_at DESC, id DESC', + [uid()] ); res.json(rows); } catch (error) { @@ -587,10 +617,10 @@ function createExternalApi(deps) { } const cols = blacklist.COLUMNS; const result = await dbRun( - `INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`, - cols.map((c) => (entry[c] === undefined ? null : entry[c])) + `INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${cols.map(() => '?').join(', ')})`, + [uid(), ...cols.map((c) => (entry[c] === undefined ? null : entry[c]))] ); - const row = await dbGet('SELECT * FROM jobangebote_blacklist WHERE id = ?', [result.lastID]); + const row = await dbGet('SELECT * FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [result.lastID, uid()]); res.status(201).json({ success: true, entry: row }); } catch (error) { console.error('API create blacklist entry error:', error); @@ -600,9 +630,9 @@ function createExternalApi(deps) { router.delete('/joboffers/blacklist/:id', async (req, res) => { try { - const row = await dbGet('SELECT id FROM jobangebote_blacklist WHERE id = ?', [req.params.id]); + const row = await dbGet('SELECT id FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [req.params.id, uid()]); if (!row) return res.status(404).json({ error: 'Blacklist-Eintrag nicht gefunden' }); - await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ?', [req.params.id]); + await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [req.params.id, uid()]); res.json({ success: true }); } catch (error) { console.error('API delete blacklist entry error:', error); @@ -612,7 +642,7 @@ function createExternalApi(deps) { router.get('/joboffers/:id', async (req, res) => { try { - const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]); + const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' }); res.json(withLabels(row)); } catch (error) { @@ -629,7 +659,7 @@ function createExternalApi(deps) { } // Reject blacklisted offers outright — they must never reappear. - const blacklistRows = await dbAll('SELECT * FROM jobangebote_blacklist'); + const blacklistRows = await dbAll('SELECT * FROM jobangebote_blacklist WHERE user_id = ?', [uid()]); const blocked = blacklist.matchBlacklist(blacklistRows, b); if (blocked) { return res.status(409).json({ @@ -669,19 +699,20 @@ function createExternalApi(deps) { firmaSlug, ]; - // De-dup: prefer a (quelle, external_id) match, else the same normalized - // URL — so the same posting never lands twice, even with a new id. + // De-dup: prefer a (user_id, quelle, external_id) match, else the same + // normalized URL — so the same posting never lands twice, even with a + // new id. let existing = null; if (externalId) { existing = await dbGet( - 'SELECT id FROM jobangebote WHERE quelle = ? AND external_id = ?', - [quelle, externalId] + 'SELECT id FROM jobangebote WHERE user_id = ? AND quelle = ? AND external_id = ?', + [uid(), quelle, externalId] ); } if (!existing && urlNorm) { existing = await dbGet( - 'SELECT id FROM jobangebote WHERE url_norm = ? ORDER BY id ASC LIMIT 1', - [urlNorm] + 'SELECT id FROM jobangebote WHERE user_id = ? AND url_norm = ? ORDER BY id ASC LIMIT 1', + [uid(), urlNorm] ); } @@ -689,20 +720,20 @@ function createExternalApi(deps) { await dbRun( `UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?, beschreibung = ?, quelle_url = ?, art = ?, anzeige_datum = ?, kontakt_email = ?, status = ?, - labels = ?, url_norm = ?, firma_slug = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - [...fields, existing.id] + labels = ?, url_norm = ?, firma_slug = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?`, + [...fields, existing.id, uid()] ); - const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [existing.id]); + const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [existing.id, uid()]); return res.json({ success: true, action: 'updated', joboffer: withLabels(row) }); } const result = await dbRun( `INSERT INTO jobangebote - (external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, labels, url_norm, firma_slug) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [externalId, quelle, ...fields] + (user_id, external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, labels, url_norm, firma_slug) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uid(), externalId, quelle, ...fields] ); - const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [result.lastID]); + const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [result.lastID, uid()]); res.status(201).json({ success: true, action: 'created', joboffer: withLabels(row) }); } catch (error) { console.error('API create job offer error:', error); @@ -714,24 +745,24 @@ function createExternalApi(deps) { // be ingested again; pass ?blacklist=false to hard-delete without blocking. router.delete('/joboffers/:id', async (req, res) => { try { - const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]); + const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' }); const skipBlacklist = req.query.blacklist === 'false' || req.query.blacklist === '0'; let blacklisted = false; if (!skipBlacklist) { - const rows = await dbAll('SELECT * FROM jobangebote_blacklist'); + const rows = await dbAll('SELECT * FROM jobangebote_blacklist WHERE user_id = ?', [uid()]); if (!blacklist.matchBlacklist(rows, row)) { const cols = blacklist.COLUMNS; const entry = blacklist.buildAutoEntry(row, 'Jobangebot gelöscht (REST-API)'); await dbRun( - `INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`, - cols.map((c) => (entry[c] === undefined ? null : entry[c])) + `INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${cols.map(() => '?').join(', ')})`, + [uid(), ...cols.map((c) => (entry[c] === undefined ? null : entry[c]))] ); } blacklisted = true; } - await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]); + await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); res.json({ success: true, blacklisted }); } catch (error) { console.error('API delete job offer error:', error); diff --git a/lib/config.js b/lib/config.js index 94ba478..53505ad 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,14 +1,19 @@ -// Centralized configuration store. +// Centralized, per-user configuration store. // -// Replaces the .env file: all settings live in the SQLite app_state table -// (rows prefixed "cfg:") and are editable via the /einstellungen page. The -// libs read values through config.get() / config.ollama() at *call* time, so -// an edit in the UI takes effect immediately — no restart, no .env file. +// Multi-user: every setting (Ollama, E-Mail, CalDAV, API-Token) is owned by a +// user. The values live in the SQLite app_state table (key prefixed "cfg:"), +// now keyed by (user_id, key). The /einstellungen page edits the *current* user's +// values; the libs (mailer, caldav, chat, documents) read through config.get() / +// config.ollama() at call time, picking up the current user from the per-request +// context (lib/context.js). An edit therefore takes effect immediately, scoped +// to the user who made it — no restart, no .env file. // // On first start of an install that previously used .env, init() migrates any -// still-present env value into the DB once, so existing config is not lost. -// After that the database is the single source of truth; process.env is only a -// fallback for keys that were never saved (and for the one-time migration). +// still-present env value into the *admin* user's config once. After that the +// database is the single source of truth; process.env is only a fallback for keys +// that were never saved. + +const { currentUser } = require('./context'); const DEFAULTS = { // Ollama Cloud (KI text generation + chat). @@ -49,7 +54,7 @@ const FIELDS = [ }, { titel: 'E-Mail (SMTP-Versand + IMAP-Empfang)', - beschreibung: 'Versand läuft über den eigenen Mailserver (DKIM/SPF/DMARC-Alignment). Ohne Host/Benutzer/Passwort ist der E-Mail-Teil deaktiviert.', + beschreibung: 'Versand läuft über den eigenen Mailserver (DKIM/SPF/DMARC-Alignment). Ohne Host/Benutzer/Passwort ist der E-Mail-Teil deaktiviert. Pro Benutzer eigenes Postfach.', items: [ { key: 'MAIL_HOST', label: 'SMTP/IMAP Host' }, { key: 'MAIL_SMTP_PORT', label: 'SMTP-Port', help: '587 = STARTTLS, 465 = implicit TLS' }, @@ -73,7 +78,7 @@ const FIELDS = [ }, { titel: 'REST-API für Drittanbietersoftware', - beschreibung: 'Ist ein Token gesetzt, ist /api/v1 aktiv und erwartet den Wert im Header „X-API-Key“. Ohne Token antwortet die API (bis auf /health) mit 503. Swagger unter /swagger.', + beschreibung: 'Ist ein Token gesetzt, ist /api/v1 für diesen Benutzer aktiv und erwartet den Wert im Header „X-API-Key“. Anfragen operieren auf den Daten dieses Benutzers. Ohne Token antwortet die API (bis auf /health) mit 503. Swagger unter /swagger.', items: [ { key: 'API_TOKEN', label: 'API-Token (X-API-Key)', secret: true, help: 'Leer = API deaktiviert' }, ], @@ -81,7 +86,12 @@ const FIELDS = [ ]; const PREFIX = 'cfg:'; -const cache = Object.create(null); // key -> string (only keys present in the DB) + +// Per-user cache: Map>. A user is loaded lazily on +// first access (ensureLoaded) and stays cached for the process lifetime. Edits +// via saveAll() update the cache in place so subsequent reads are consistent. +const cache = new Map(); // userId -> { key: value } +const loaded = new Set(); // userIds whose cfg rows have been read from the DB let dbAllFn = null; let dbRunFn = null; @@ -90,22 +100,43 @@ function envOrDefault(key) { return e && e.length ? e : DEFAULTS[key]; } -// Synchronous read. Falls back to process.env (pre-migration / never saved) -// then to the built-in default. After init() the DB value is cached and wins. +// Load one user's cfg rows from the DB into the cache. No-op if already loaded. +async function ensureLoaded(userId) { + if (!userId || loaded.has(userId)) return; + const rows = await dbAllFn('SELECT key, value FROM app_state WHERE user_id = ?', [userId]); + const obj = Object.create(null); + for (const r of rows) obj[r.key.slice(PREFIX.length)] = r.value; + cache.set(userId, obj); + loaded.add(userId); +} + +// Drop the cached rows for a user so the next read reloads from the DB. Used +// after a direct DB write outside saveAll() (e.g. the one-time env migration). +function invalidate(userId) { + loaded.delete(userId); + cache.delete(userId); +} + +// Synchronous read for the current user. Falls back to process.env (pre-migration +// / never saved) then to the built-in default. Outside a request context (boot) +// only the env/default fallback applies — callers that need a specific user must +// run inside the user context (see lib/context.js). function get(key) { - const v = cache[key]; - if (v !== undefined) return v; + const u = currentUser(); + const userObj = u ? cache.get(u.id) : null; + if (userObj && userObj[key] !== undefined) return userObj[key]; return envOrDefault(key); } -// All keys with their effective values, keyed by name — used by the settings UI. +// All keys with their effective values for the current user — used by the +// settings UI. Must be called within a request context. function getAll() { const out = {}; for (const key of Object.keys(DEFAULTS)) out[key] = get(key); return out; } -// Ollama bundle (shared by lib/documents.js + lib/chat.js). +// Ollama bundle (shared by lib/documents.js + lib/chat.js), for the current user. function ollama() { return { host: (get('OLLAMA_HOST') || 'https://ollama.com').replace(/\/+$/, ''), @@ -115,49 +146,49 @@ function ollama() { }; } -async function load() { - if (!dbAllFn) return; - const rows = await dbAllFn('SELECT key, value FROM app_state WHERE key LIKE ?', [PREFIX + '%']); - for (const r of rows) cache[r.key.slice(PREFIX.length)] = r.value; -} - -// Wire up DB helpers, load the cached rows, then one-time-migrate any env -// value that isn't yet in the DB (so an existing .env install keeps its config). +// Wire up DB helpers. The one-time .env migration now runs per-admin at boot +// from server.js (it needs the admin user id); this init only stores the fns. async function init({ dbAll, dbRun }) { dbAllFn = dbAll; dbRunFn = dbRun; - await load(); - const toMigrate = []; - for (const key of Object.keys(DEFAULTS)) { - if (cache[key] === undefined) { - const e = process.env[key]; - if (e && e.length) toMigrate.push([key, e]); - } - } - if (toMigrate.length) { - for (const [k, v] of toMigrate) { - await dbRunFn( - 'INSERT INTO app_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', - [PREFIX + k, v] - ); - cache[k] = v; - } - console.log(`Konfiguration aus .env in die Datenbank migriert (${toMigrate.length} Werte) — .env wird nicht mehr benötigt.`); - } } -// Persist every key (writes all rows, including empty strings, so a cleared -// field is stored as empty and no longer falls back to env/default). +// Persist every key for the current user (writes all rows, including empty +// strings, so a cleared field is stored as empty and no longer falls back to +// env/default). Must be called within a request context. async function saveAll(values) { if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.'); + const u = currentUser(); + if (!u) throw new Error('Kein Benutzerkontext für Konfigurationsspeicherung.'); + const obj = cache.get(u.id) || Object.create(null); for (const key of Object.keys(DEFAULTS)) { const v = values && values[key] != null ? String(values[key]) : ''; await dbRunFn( - 'INSERT INTO app_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', - [PREFIX + key, v] + 'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value', + [u.id, PREFIX + key, v] ); - cache[key] = v; + obj[key] = v; + } + cache.set(u.id, obj); + loaded.add(u.id); +} + +// Write a single key for an explicit user (used by the one-time env migration, +// which runs outside a request context). Updates the cache if loaded. +async function setForUser(userId, key, value) { + if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.'); + await dbRunFn( + 'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value', + [userId, PREFIX + key, String(value)] + ); + if (loaded.has(userId)) { + const obj = cache.get(userId) || Object.create(null); + obj[key] = String(value); + cache.set(userId, obj); } } -module.exports = { DEFAULTS, FIELDS, get, getAll, ollama, init, load, saveAll }; \ No newline at end of file +module.exports = { + DEFAULTS, FIELDS, PREFIX, + get, getAll, ollama, init, saveAll, setForUser, ensureLoaded, invalidate, +}; \ No newline at end of file diff --git a/lib/context.js b/lib/context.js new file mode 100644 index 0000000..635f2bd --- /dev/null +++ b/lib/context.js @@ -0,0 +1,32 @@ +// Per-request user context. +// +// The web UI authenticates a user via a signed session cookie (see the +// /login flow + auth middleware in server.js). The middleware stores the +// resolved user object in an AsyncLocalStorage, so any code running during the +// request — including the libs (config, mailer, caldav, chat) called deep in +// the stack — can read the *current user* without threading it through every +// function signature. Background jobs (IMAP poll, CalDAV sync) set the same +// context per user while iterating, so each user's config/data is used in turn. +// +// This is the keystone of the multi-tenant split: config.get() reads the current +// user's cfg rows, and the per-user query helpers below filter every SELECT/INSERT +// by the current user, guaranteeing isolation between users. + +const { AsyncLocalStorage } = require('async_hooks'); + +const userContext = new AsyncLocalStorage(); + +// The current user object ({ id, username, is_admin }) or null outside a request +// (e.g. during boot). Anything that needs the user id must call this and decide +// how to behave when it is absent. +function currentUser() { + return userContext.getStore() || null; +} + +// Convenience: the current user's id, or null when no user is set. +function currentUserId() { + const u = currentUser(); + return u ? u.id : null; +} + +module.exports = { userContext, currentUser, currentUserId }; \ No newline at end of file diff --git a/lib/migrate-multiuser.js b/lib/migrate-multiuser.js new file mode 100644 index 0000000..a5d22b5 --- /dev/null +++ b/lib/migrate-multiuser.js @@ -0,0 +1,311 @@ +// Idempotent migration from the single-user schema to the multi-user schema. +// +// Used both at server boot (server.js calls runMigration after opening the DB) +// and by the standalone scripts/migrate-to-multiuser.js. Safe to run repeatedly: +// every step guards itself with "already done?" checks. +// +// What it does, in order: +// 1. Create the `users` + `sessions` tables. +// 2. Ensure an `admin` user exists (password "admin", scrypt hash). Record its id. +// 3. Add a `user_id` column to every per-user table (ALTER ADD COLUMN, nullable +// for upgraded installs — fresh installs create it NOT NULL directly) and +// backfill every existing row to the admin id. +// 4. Recreate the tables whose PRIMARY KEY / UNIQUE must include user_id so the +// constraint becomes per-user: app_state, settings, prompts, design, +// jobangebote. Old rows are copied to the admin user. +// 5. Move on-disk attachment files into a per-user subdirectory for the admin. +// 6. One-time migration of any still-present .env values into the admin's cfg. +// +// After this, the app boots against a fully multi-user schema and all pre-existing +// data is owned by the admin user. + +const fs = require('fs'); +const path = require('path'); +const password = require('./password'); +const config = require('./config'); + +// Tables that need a `user_id` column added (legacy upgrades). Fresh installs +// create these with user_id NOT NULL directly in initializeDatabase(). +const USER_TABLES = [ + 'bewerbungen', 'status_verlauf', 'anhaenge', 'interne_anhaenge', + 'emails', 'email_anhaenge', 'basis_dokumente', 'basis_anhaenge', + 'termine', 'chat_threads', 'chat_messages', + 'jobangebote_blacklist', +]; +// jobangebote is NOT in USER_TABLES: its user_id column + per-user UNIQUE +// constraint are installed by the recreate in step 4 (which needs user_id to be +// absent so the rebuild fires). Adding it here first would make step 4's +// !hasColumn guard short-circuit and leave the legacy single-user UNIQUE in +// place, breaking per-user isolation on ingest. + +const ADMIN_USERNAME = 'admin'; +const ADMIN_DEFAULT_PASSWORD = 'admin'; + +async function runMigration({ db, dbAll, dbGet, dbRun }) { + // Wire the config store to the same DB so config.setForUser works during the + // one-time .env migration below. + await config.init({ dbAll, dbRun }); + const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve())); + + const tableExists = async (name) => { + const row = await dbGet("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [name]); + return !!row; + }; + + const columnsOf = async (table) => { + const rows = await dbAll(`PRAGMA table_info(${table})`); + return rows.map((r) => r.name); + }; + + const hasColumn = async (table, col) => (await columnsOf(table)).includes(col); + + // 1. users + sessions ------------------------------------------------- + await exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await exec(` + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)'); + + // 2. Ensure admin user (idempotent) ----------------------------------- + let admin = await dbGet('SELECT id, password_hash FROM users WHERE username = ?', [ADMIN_USERNAME]); + if (!admin) { + const hash = password.hash(ADMIN_DEFAULT_PASSWORD); + const res = await dbRun( + 'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)', + [ADMIN_USERNAME, hash] + ); + admin = { id: res.lastID }; + console.log(`Multi-User-Migration: Admin-Benutzer „${ADMIN_USERNAME}“ angelegt (Passwort „${ADMIN_DEFAULT_PASSWORD}“). Bitte nach erstem Login ändern.`); + } + const adminId = admin.id; + + // 3. Add user_id column + backfill ------------------------------------ + for (const tbl of USER_TABLES) { + if (!(await tableExists(tbl))) continue; // table may not exist yet on a partial install + if (!(await hasColumn(tbl, 'user_id'))) { + await exec(`ALTER TABLE ${tbl} ADD COLUMN user_id INTEGER`); + } + // Backfill every row that is not yet assigned (NULL) to the admin. + await dbRun(`UPDATE ${tbl} SET user_id = ? WHERE user_id IS NULL`, [adminId]); + } + + // 4. Recreate tables whose PK/UNIQUE must be per-user ----------------- + // app_state: PK(key) -> PK(user_id, key) + if (await tableExists('app_state')) { + if (!(await hasColumn('app_state', 'user_id'))) { + await recreate(db, dbAll, dbRun, 'app_state', + `CREATE TABLE app_state ( + user_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT, + PRIMARY KEY (user_id, key), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `INSERT INTO app_state (user_id, key, value) SELECT ?, key, value FROM app_state_old`, + [adminId] + ); + } else { + await dbRun('UPDATE app_state SET user_id = ? WHERE user_id IS NULL', [adminId]); + } + } + + // prompts: PK(key) -> PK(user_id, key) + if (await tableExists('prompts') && !(await hasColumn('prompts', 'user_id'))) { + await recreate(db, dbAll, dbRun, 'prompts', + `CREATE TABLE prompts ( + user_id INTEGER NOT NULL, + key TEXT NOT NULL, + inhalt TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, key), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `INSERT INTO prompts (user_id, key, inhalt, updated_at) SELECT ?, key, inhalt, updated_at FROM prompts_old`, + [adminId] + ); + } else if (await tableExists('prompts')) { + await dbRun('UPDATE prompts SET user_id = ? WHERE user_id IS NULL', [adminId]); + } + + // design: PK(key) -> PK(user_id, key) + if (await tableExists('design') && !(await hasColumn('design', 'user_id'))) { + await recreate(db, dbAll, dbRun, 'design', + `CREATE TABLE design ( + user_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, key), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `INSERT INTO design (user_id, key, value, updated_at) SELECT ?, key, value, updated_at FROM design_old`, + [adminId] + ); + } else if (await tableExists('design')) { + await dbRun('UPDATE design SET user_id = ? WHERE user_id IS NULL', [adminId]); + } + + // settings: single row CHECK(id=1) -> per-user row (user_id UNIQUE) + if (await tableExists('settings') && !(await hasColumn('settings', 'user_id'))) { + await recreate(db, dbAll, dbRun, 'settings', + `CREATE TABLE settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE, + name TEXT, + adresse TEXT, + kundennummer TEXT, + ort TEXT, + webseite TEXT, + email TEXT, + telefon TEXT, + geburtsdatum TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum) + SELECT ?, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum FROM settings_old`, + [adminId] + ); + } else if (await tableExists('settings')) { + await dbRun('UPDATE settings SET user_id = ? WHERE user_id IS NULL', [adminId]); + } + + // jobangebote: UNIQUE(quelle, external_id) -> UNIQUE(user_id, quelle, external_id) + if (await tableExists('jobangebote') && !(await hasColumn('jobangebote', 'user_id'))) { + // user_id was added in step 3 already; this branch only recreates for the + // per-user UNIQUE constraint. Rebuild preserving all columns. + const cols = (await columnsOf('jobangebote')).filter((c) => c !== 'user_id'); + const colList = cols.join(', '); + await recreate(db, dbAll, dbRun, 'jobangebote', + `CREATE TABLE jobangebote ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + external_id TEXT, + quelle TEXT NOT NULL DEFAULT 'drittanbieter', + firma TEXT NOT NULL, + stelle TEXT NOT NULL, + ort TEXT, + adresse TEXT, + ansprechpartner TEXT, + gehalt TEXT, + beschreibung TEXT, + quelle_url TEXT, + art TEXT, + anzeige_datum DATE, + kontakt_email TEXT, + status TEXT NOT NULL DEFAULT 'offen', + verknuepfte_bewerbung_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + url_norm TEXT, + firma_slug TEXT, + labels TEXT, + UNIQUE (user_id, quelle, external_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL + )`, + `INSERT INTO jobangebote (user_id, ${colList}) SELECT ?, ${colList} FROM jobangebote_old`, + [adminId] + ); + await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)'); + await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)'); + } + + // Ensure a settings row exists for the admin (idempotent). On a fresh install + // the settings table does not exist yet at this point (it is created by the + // server's CREATE TABLE IF NOT EXISTS right after the migration), so skip — + // the row is upserted on the admin's first /vorlagen save anyway. + if (await tableExists('settings')) { + const srow = await dbGet('SELECT id FROM settings WHERE user_id = ?', [adminId]); + if (!srow) { + await dbRun( + `INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum) + VALUES (?, 'Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', '', '', '', '', '', '')`, + [adminId] + ); + } + } + + // 5. Move on-disk files into a per-user subdirectory for the admin ----- + moveFilesIntoUserSubdir(adminId); + + // 6. One-time .env -> admin cfg migration ------------------------------ + await migrateEnvForAdmin(dbAll, adminId); + + return { adminId }; +} + +// Rename `table` to `table_old`, create the new table from `newSchemaSql`, +// copy rows via `copySql` (with `copyParams`), then drop `table_old`. +async function recreate(db, dbAll, dbRun, table, newSchemaSql, copySql, copyParams) { + const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve())); + await exec(`ALTER TABLE ${table} RENAME TO ${table}_old`); + await exec(newSchemaSql); + await dbRun(copySql, copyParams || []); + await exec(`DROP TABLE ${table}_old`); +} + +// Move every file in each per-user storage directory into a `/` subdir. +// Idempotent: if the subdir already contains files (already migrated), leave the +// top-level files alone (they would be a re-run left-overs) — but on first run the +// top level holds the legacy flat files, which we move in. +const STORAGE_DIRS = ['anhaenge', 'basis_anhaenge', 'interne_anhaenge', 'email_anhaenge', 'signatur', 'bewerberfoto']; +function moveFilesIntoUserSubdir(userId) { + const dataDir = path.join(__dirname, '..', 'data'); + for (const dir of STORAGE_DIRS) { + const base = path.join(dataDir, dir); + if (!fs.existsSync(base)) continue; + const userDir = path.join(base, String(userId)); + if (!fs.existsSync(userDir)) fs.mkdirSync(userDir, { recursive: true }); + for (const name of fs.readdirSync(base)) { + if (name === String(userId)) continue; + const src = path.join(base, name); + if (!fs.statSync(src).isFile()) continue; // skip subdirectories + const dst = path.join(userDir, name); + if (!fs.existsSync(dst)) fs.renameSync(src, dst); + } + } +} + +// Move any still-present .env value for a DEFAULT key that the admin hasn't got +// stored yet into the admin's cfg rows. Idempotent: only fills keys that are not +// yet present for the admin. +async function migrateEnvForAdmin(dbAll, adminId) { + // On a fresh install app_state does not exist yet at this point (created by + // the server right after the migration), so there is nothing to read from or + // write to — skip. The .env values, if any, then fall back via config.get(). + try { + await dbAll('SELECT 1 FROM app_state LIMIT 1'); + } catch (e) { + return; + } + const storedRows = await dbAll('SELECT key FROM app_state WHERE user_id = ?', [adminId]); + const stored = new Set(storedRows.map((r) => r.key.slice(config.PREFIX.length))); + const migrated = []; + for (const key of Object.keys(config.DEFAULTS)) { + if (stored.has(key)) continue; // already in the DB, never overwrite from .env + const envVal = process.env[key]; + if (envVal && envVal.length) { + await config.setForUser(adminId, key, envVal); + migrated.push(key); + } + } + if (migrated.length) { + console.log(`Multi-User-Migration: ${migrated.length} Konfigurationswerte aus .env in den Admin-Benutzer migriert — .env wird nicht mehr benötigt.`); + } +} + +module.exports = { runMigration, moveFilesIntoUserSubdir, STORAGE_DIRS, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD }; \ No newline at end of file diff --git a/lib/password.js b/lib/password.js new file mode 100644 index 0000000..2087910 --- /dev/null +++ b/lib/password.js @@ -0,0 +1,34 @@ +// Password hashing using Node's built-in scrypt + a per-hash random salt. +// +// No external dependency (bcrypt would need a native build step). scrypt is +// memory-hard and well suited for interactive logins. Hash format: +// ":" (salt is 16 bytes, hash is 64 bytes) + +const crypto = require('crypto'); + +const KEYLEN = 64; + +function hash(password) { + const salt = crypto.randomBytes(16).toString('hex'); + const out = crypto.scryptSync(password, salt, KEYLEN).toString('hex'); + return `${salt}:${out}`; +} + +function verify(password, stored) { + if (typeof stored !== 'string' || !stored.includes(':')) return false; + const idx = stored.indexOf(':'); + const salt = stored.slice(0, idx); + const expected = stored.slice(idx + 1); + if (!salt || !expected) return false; + let computed; + try { + computed = crypto.scryptSync(password, salt, KEYLEN).toString('hex'); + } catch (e) { + return false; + } + if (computed.length !== expected.length) return false; + // Constant-time compare to avoid timing side channels. + return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected)); +} + +module.exports = { hash, verify }; \ No newline at end of file diff --git a/scripts/migrate-to-multiuser.js b/scripts/migrate-to-multiuser.js new file mode 100644 index 0000000..50481fb --- /dev/null +++ b/scripts/migrate-to-multiuser.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +// Standalone one-shot migration from the single-user schema to the multi-user +// schema. Idempotent (safe to run repeatedly). Run it once on an existing +// single-user install before (or alongside) the first boot of the multi-user +// server — the server runs the same migration at boot too, so this script is +// mainly a convenience for migrating without starting the server. +// +// node scripts/migrate-to-multiuser.js [path/to/bewerbungen.db] +// +// All existing data is assigned to the admin user (admin:admin). Change the +// admin password after first login. + +const path = require('path'); +const sqlite3 = require('sqlite3').verbose(); +const { runMigration, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD } = require('../lib/migrate-multiuser'); + +const dbPath = process.argv[2] + ? path.resolve(process.argv[2]) + : path.join(__dirname, '..', 'data', 'bewerbungen.db'); + +const db = new sqlite3.Database(dbPath); + +// Promise wrappers matching the server's helpers. +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))); +const dbRun = (sql, params = []) => new Promise((resolve, reject) => db.run(sql, params, function (err) { err ? reject(err) : resolve({ lastID: this.lastID, changes: this.changes }); })); + +(async () => { + try { + console.log(`Multi-User-Migration startet für Datenbank: ${dbPath}`); + const { adminId } = await runMigration({ db, dbAll, dbGet, dbRun }); + console.log(`Multi-User-Migration abgeschlossen. Alle Daten gehören jetzt dem Benutzer „${ADMIN_USERNAME}“ (id ${adminId}).`); + console.log(`Bitte nach dem ersten Login das Passwort „${ADMIN_DEFAULT_PASSWORD}“ ändern.`); + db.close(); + process.exit(0); + } catch (err) { + console.error('Multi-User-Migration fehlgeschlagen:', err); + db.close(); + process.exit(1); + } +})(); \ No newline at end of file diff --git a/server.js b/server.js index baf330f..0816949 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,7 @@ const express = require('express'); const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const fs = require('fs'); +const crypto = require('crypto'); const multer = require('multer'); // Minimal, dependency-free .env loader: load KEY=VALUE lines from a local @@ -41,6 +42,10 @@ const blacklist = require('./lib/blacklist'); const caldav = require('./lib/caldav'); const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./lib/labels'); const config = require('./lib/config'); +const { userContext, currentUser, currentUserId } = require('./lib/context'); +const password = require('./lib/password'); +const migrate = require('./lib/migrate-multiuser'); +const { runMigration } = migrate; const app = express(); const PORT = process.env.PORT || 3000; @@ -92,6 +97,79 @@ app.use('/api/indeed-import', (req, res, next) => { app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); +// --------------------------------------------------------------------------- +// Auth: session cookie -> req.user + per-request user context +// --------------------------------------------------------------------------- +// Loads the session (if any) from the cookie and stores the resolved user on +// req.user / res.locals.user. The whole request then runs inside the +// AsyncLocalStorage user context, so the libs (config/mailer/caldav/chat) and +// the per-user query helpers below read the current user transparently. +app.use(async (req, res, next) => { + try { + const cookies = parseCookies(req.headers.cookie); + const user = await loadSessionUser(cookies[SESSION_COOKIE]); + req.user = user; + res.locals.user = user; + userContext.run(user, next); + } catch (e) { + console.error('Session-Laden fehlgeschlagen:', e.message); + req.user = null; + res.locals.user = null; + next(); + } +}); + +// Paths that do not require an authenticated session. The external /api/v1 has +// its own X-API-Key auth; everything else below requireAuth needs a session. +const PUBLIC_PATHS = new Set(['/login', '/logout']); +function isPublicPath(p) { + if (PUBLIC_PATHS.has(p)) return true; + if (p === '/health' || p === '/swagger' || p === '/swagger.json' || p === '/api-docs') return true; + if (p.startsWith('/api/v1/')) return true; // own X-API-Key auth + return false; +} + +// Require an authenticated user. Browser requests are redirected to /login; API +// requests get 401 JSON. Must be registered before any protected route. +function requireAuth(req, res, next) { + if (req.user) return next(); + if (isPublicPath(req.path)) return next(); + const wantsJson = (req.get('accept') || '').includes('application/json') + || req.path.startsWith('/api/') + || req.xhr; + if (wantsJson) return res.status(401).json({ error: 'Nicht angemeldet.' }); + return res.redirect('/login'); +} + +// Login page + form handler. +app.get('/login', (req, res) => { + if (req.user) return res.redirect('/'); + res.render('login', { error: null, username: '' }); +}); + +app.post('/login', async (req, res) => { + const { username, password: plain } = req.body || {}; + const user = await authenticate(username, plain); + if (!user) { + return res.status(401).render('login', { error: 'Benutzername oder Passwort falsch.', username: username || '' }); + } + const token = await createSession(user.id); + setSessionCookie(res, token); + res.redirect('/'); +}); + +app.post('/logout', async (req, res) => { + const cookies = parseCookies(req.headers.cookie); + await destroySession(cookies[SESSION_COOKIE]); + clearSessionCookie(res); + res.redirect('/login'); +}); +// `GET /logout` is convenient for the header link (no JS needed). +app.get('/logout', (req, res) => { clearSessionCookie(res); res.redirect('/login'); }); + +// Protect everything registered below this point. +app.use(requireAuth); + // Ensure data directory exists const dataDir = path.join(__dirname, 'data'); if (!fs.existsSync(dataDir)) { @@ -110,6 +188,26 @@ if (!fs.existsSync(emailAnhaengeDir)) { fs.mkdirSync(emailAnhaengeDir, { recursive: true }); } +// Resolve a per-user storage directory (data///), creating it on +// first use. Used for every attachment / signature / photo path so users' files +// are isolated on disk the same way their DB rows are. Reads the current user +// from the request/task context (lib/context.js). +function userStorageDir(base) { + const dir = path.join(base, String(currentUserId())); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +// Same as userStorageDir but resolves the user id from req.user explicitly (used +// inside multer callbacks, where we prefer the request's user over the async +// context to stay robust against callback timing). +function userStorageDirForReq(base, req) { + const id = (req && req.user && req.user.id) || currentUserId(); + const dir = path.join(base, String(id)); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + return dir; +} + // Serve a stored file with `Content-Disposition: inline` so the browser opens it // (PDF/image) in a tab instead of downloading. Falls back to the file extension // when no MIME type is stored. @@ -184,10 +282,10 @@ if (!fs.existsSync(basisAnhaengeDir)) { fs.mkdirSync(basisAnhaengeDir, { recursive: true }); } -// Multipart upload for those static attachments +// Multipart upload for those static attachments (stored under the user's subdir) const uploadBasisAnhang = multer({ storage: multer.diskStorage({ - destination: (req, file, cb) => cb(null, basisAnhaengeDir), + destination: (req, file, cb) => cb(null, userStorageDirForReq(basisAnhaengeDir, req)), filename: (req, file, cb) => { const safe = String(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_'); cb(null, `${Date.now()}_${safe}`); @@ -205,7 +303,7 @@ if (!fs.existsSync(interneAnhaengeDir)) { } const uploadInterneAnhang = multer({ storage: multer.diskStorage({ - destination: (req, file, cb) => cb(null, interneAnhaengeDir), + destination: (req, file, cb) => cb(null, userStorageDirForReq(interneAnhaengeDir, req)), filename: (req, file, cb) => { const safe = String(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_'); cb(null, `${Date.now()}_${safe}`); @@ -221,7 +319,7 @@ if (!fs.existsSync(signaturDir)) { } const uploadSignatur = multer({ storage: multer.diskStorage({ - destination: (req, file, cb) => cb(null, signaturDir), + destination: (req, file, cb) => cb(null, userStorageDirForReq(signaturDir, req)), filename: (req, file, cb) => { const ext = (path.extname(file.originalname) || '.png').toLowerCase(); cb(null, `signatur_${Date.now()}${ext}`); @@ -231,11 +329,12 @@ const uploadSignatur = multer({ fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)), }).single('signatur'); -// The single stored signature file, if any. +// The single stored signature file, if any (in the current user's subdir). function currentSignaturFile() { try { - const files = fs.readdirSync(signaturDir).filter((f) => !f.startsWith('.')); - return files.length ? path.join(signaturDir, files[0]) : null; + const dir = userStorageDir(signaturDir); + const files = fs.readdirSync(dir).filter((f) => !f.startsWith('.')); + return files.length ? path.join(dir, files[0]) : null; } catch (e) { return null; } @@ -263,7 +362,7 @@ if (!fs.existsSync(fotoDir)) { } const uploadFoto = multer({ storage: multer.diskStorage({ - destination: (req, file, cb) => cb(null, fotoDir), + destination: (req, file, cb) => cb(null, userStorageDirForReq(fotoDir, req)), filename: (req, file, cb) => { const ext = (path.extname(file.originalname) || '.png').toLowerCase(); cb(null, `foto_${Date.now()}${ext}`); @@ -273,11 +372,12 @@ const uploadFoto = multer({ fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)), }).single('foto'); -// The single stored photo file, if any. +// The single stored photo file, if any (in the current user's subdir). function currentFotoFile() { try { - const files = fs.readdirSync(fotoDir).filter((f) => !f.startsWith('.')); - return files.length ? path.join(fotoDir, files[0]) : null; + const dir = userStorageDir(fotoDir); + const files = fs.readdirSync(dir).filter((f) => !f.startsWith('.')); + return files.length ? path.join(dir, files[0]) : null; } catch (e) { return null; } @@ -340,6 +440,70 @@ function dbRun(sql, params = []) { }); } +// --------------------------------------------------------------------------- +// Multi-user: sessions, login, per-request user context +// --------------------------------------------------------------------------- + +const SESSION_COOKIE = 'sid'; +const SESSION_MAX_AGE = 30 * 24 * 3600; // 30 days, in seconds + +// Minimal cookie parser (no cookie-parser dependency): { name: value }. +function parseCookies(header) { + const out = {}; + if (!header) return out; + for (const part of String(header).split(';')) { + const eq = part.indexOf('='); + if (eq === -1) continue; + const k = part.slice(0, eq).trim(); + const v = part.slice(eq + 1).trim(); + if (k) out[k] = decodeURIComponent(v); + } + return out; +} + +// Create a session row for a user and return the opaque token to store in the cookie. +async function createSession(userId) { + const token = crypto.randomBytes(32).toString('hex'); + await dbRun('INSERT INTO sessions (token, user_id) VALUES (?, ?)', [token, userId]); + return token; +} + +async function destroySession(token) { + if (token) await dbRun('DELETE FROM sessions WHERE token = ?', [token]).catch(() => {}); +} + +// Resolve the user a session token belongs to, or null. Touches last_seen. +async function loadSessionUser(token) { + if (!token) return null; + const row = await dbGet( + `SELECT u.id AS id, u.username AS username, u.is_admin AS is_admin + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token = ?`, + [token] + ); + if (!row) return null; + await dbRun('UPDATE sessions SET last_seen = CURRENT_TIMESTAMP WHERE token = ?', [token]).catch(() => {}); + return { id: row.id, username: row.username, is_admin: !!row.is_admin }; +} + +// Find a user by username + password (login check). Returns the user object or null. +async function authenticate(username, plain) { + const row = await dbGet('SELECT id, username, password_hash, is_admin FROM users WHERE username = ?', [username || '']); + if (!row) return null; + if (!password.verify(plain || '', row.password_hash)) return null; + return { id: row.id, username: row.username, is_admin: !!row.is_admin }; +} + +// Set/clear the session cookie on a response. +function setSessionCookie(res, token) { + res.cookie(SESSION_COOKIE, token, { + httpOnly: true, sameSite: 'lax', path: '/', maxAge: SESSION_MAX_AGE * 1000, + }); +} +function clearSessionCookie(res) { + res.clearCookie(SESSION_COOKIE, { path: '/' }); +} + // --------------------------------------------------------------------------- // Job-offer blacklist helpers (shared shape with lib/api.js) // --------------------------------------------------------------------------- @@ -350,8 +514,8 @@ async function insertBlacklistEntry(entry) { const placeholders = cols.map(() => '?').join(', '); const values = cols.map((c) => (entry[c] === undefined ? null : entry[c])); return dbRun( - `INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${placeholders})`, - values + `INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${placeholders})`, + [currentUserId(), ...values] ); } @@ -359,7 +523,7 @@ async function insertBlacklistEntry(entry) { // to delete. Skips silently if the offer is already covered by an entry. async function autoBlacklistOffer(offer, grund) { if (!offer) return; - const rows = await dbAll('SELECT * FROM jobangebote_blacklist'); + const rows = await dbAll('SELECT * FROM jobangebote_blacklist WHERE user_id = ?', [currentUserId()]); if (blacklist.matchBlacklist(rows, offer)) return; // already blocked await insertBlacklistEntry(blacklist.buildAutoEntry(offer, grund)); } @@ -374,9 +538,9 @@ async function upcomingTermine(limit = 6) { return dbAll( `SELECT t.*, b.firma AS bewerbung_firma, b.stelle AS bewerbung_stelle FROM termine t LEFT JOIN bewerbungen b ON b.id = t.bewerbung_id - WHERE COALESCE(t.ende, t.start) >= ? + WHERE t.user_id = ? AND COALESCE(t.ende, t.start) >= ? ORDER BY t.start ASC LIMIT ?`, - [now, limit] + [currentUserId(), now, limit] ); } @@ -394,21 +558,21 @@ async function refreshCaldav() { const remote = await caldav.listEvents({ from, to }); const byUid = new Map(remote.map((e) => [e.uid, e])); const local = await dbAll( - 'SELECT * FROM termine WHERE caldav_uid IS NOT NULL AND start >= ? AND start <= ?', - [from.toISOString(), to.toISOString()] + 'SELECT * FROM termine WHERE user_id = ? AND caldav_uid IS NOT NULL AND start >= ? AND start <= ?', + [currentUserId(), from.toISOString(), to.toISOString()] ); for (const t of local) { const r = byUid.get(t.caldav_uid); if (!r) { - await dbRun('DELETE FROM termine WHERE id = ?', [t.id]); + await dbRun('DELETE FROM termine WHERE id = ? AND user_id = ?', [t.id, currentUserId()]); } else { await dbRun( `UPDATE termine SET titel = ?, ort = ?, notiz = ?, start = ?, ende = ?, ganztags = ?, - caldav_etag = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + caldav_etag = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?`, [ sanitizeInput(r.summary || t.titel), sanitizeInput(r.location || ''), sanitizeInput(r.description || ''), (r.start || new Date(t.start)).toISOString(), r.end ? r.end.toISOString() : null, - r.allDay ? 1 : 0, r.etag || t.caldav_etag, t.id, + r.allDay ? 1 : 0, r.etag || t.caldav_etag, t.id, currentUserId(), ] ); } @@ -421,13 +585,13 @@ async function refreshCaldav() { // --------------------------------------------------------------------------- async function getState(key) { - const row = await dbGet('SELECT value FROM app_state WHERE key = ?', [key]); + const row = await dbGet('SELECT value FROM app_state WHERE user_id = ? AND key = ?', [currentUserId(), key]); return row ? row.value : null; } async function setState(key, value) { await dbRun( - 'INSERT INTO app_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', - [key, String(value)] + 'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value', + [currentUserId(), key, String(value)] ); } @@ -436,7 +600,7 @@ async function setState(key, value) { // effect immediately, without a restart. async function loadPrompts() { try { - const rows = await dbAll('SELECT key, inhalt FROM prompts'); + const rows = await dbAll('SELECT key, inhalt FROM prompts WHERE user_id = ?', [currentUserId()]); return Object.fromEntries(rows.map((r) => [r.key, r.inhalt])); } catch (e) { console.error('Konnte Prompts nicht laden, nutze Standardtexte:', e.message); @@ -448,7 +612,7 @@ async function loadPrompts() { // from lib/design.js. Read fresh per generation, like the prompts. async function loadDesign() { try { - const rows = await dbAll('SELECT key, value FROM design'); + const rows = await dbAll('SELECT key, value FROM design WHERE user_id = ?', [currentUserId()]); return Object.fromEntries(rows.map((r) => [r.key, r.value])); } catch (e) { console.error('Konnte Design nicht laden, nutze Standardwerte:', e.message); @@ -466,15 +630,15 @@ async function matchBewerbung(msg) { .filter(Boolean); for (const mid of refs) { const row = await dbGet( - "SELECT bewerbung_id FROM emails WHERE direction = 'out' AND message_id = ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1", - [mid] + "SELECT bewerbung_id FROM emails WHERE user_id = ? AND direction = 'out' AND message_id = ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1", + [currentUserId(), mid] ); if (row && row.bewerbung_id) return row.bewerbung_id; } if (msg.fromAddr) { const row = await dbGet( - "SELECT bewerbung_id FROM emails WHERE direction = 'out' AND lower(to_addr) LIKE ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1", - ['%' + msg.fromAddr.toLowerCase() + '%'] + "SELECT bewerbung_id FROM emails WHERE user_id = ? AND direction = 'out' AND lower(to_addr) LIKE ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1", + [currentUserId(), '%' + msg.fromAddr.toLowerCase() + '%'] ); if (row && row.bewerbung_id) return row.bewerbung_id; } @@ -485,6 +649,7 @@ let polling = false; // Fetch new mail from the IMAP inbox, persist unseen messages, link them to the // matching application and save their attachments. Safe to call concurrently // (guarded) — used both by the interval poller and the manual "fetch" button. +// Runs in the current user's context (background loop sets it per user). async function pollInbox() { if (!mailer.isConfigured() || polling) return { fetched: 0 }; polling = true; @@ -495,15 +660,15 @@ async function pollInbox() { for (const m of messages) { // Skip if we already have this message (id or uid) — idempotent. if (m.messageId) { - const dup = await dbGet('SELECT id FROM emails WHERE message_id = ?', [m.messageId]); + const dup = await dbGet('SELECT id FROM emails WHERE user_id = ? AND message_id = ?', [currentUserId(), m.messageId]); if (dup) continue; } const bewId = await matchBewerbung(m); const result = await dbRun( - `INSERT INTO emails (bewerbung_id, direction, message_id, in_reply_to, email_references, + `INSERT INTO emails (user_id, bewerbung_id, direction, message_id, in_reply_to, email_references, from_addr, to_addr, subject, body_text, body_html, imap_uid, seen, email_date) - VALUES (?, 'in', ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`, - [bewId, m.messageId || null, m.inReplyTo || null, m.references || null, + VALUES (?, ?, 'in', ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`, + [currentUserId(), bewId, m.messageId || null, m.inReplyTo || null, m.references || null, m.fromName ? `${m.fromName} <${m.fromAddr}>` : m.fromAddr, m.toAddr || '', m.subject || '', m.text || '', m.html || '', m.uid, (m.date instanceof Date ? m.date.toISOString() : new Date().toISOString())] @@ -513,9 +678,9 @@ async function pollInbox() { const safe = String(att.filename || 'anhang').replace(/[^a-zA-Z0-9äöüÄÖÜß._ -]/g, '_').slice(0, 80); const storedName = `${result.lastID}_${Date.now()}_${safe}`; try { - fs.writeFileSync(path.join(emailAnhaengeDir, storedName), att.content); - await dbRun('INSERT INTO email_anhaenge (email_id, name, mime, pfad) VALUES (?, ?, ?, ?)', - [result.lastID, att.filename, att.contentType, storedName]); + fs.writeFileSync(path.join(userStorageDir(emailAnhaengeDir), storedName), att.content); + await dbRun('INSERT INTO email_anhaenge (user_id, email_id, name, mime, pfad) VALUES (?, ?, ?, ?, ?)', + [currentUserId(), result.lastID, att.filename, att.contentType, storedName]); } catch (e) { /* ignore a single bad attachment */ } } stored++; @@ -567,7 +732,7 @@ function normUrl(u) { // Existing applications that look like the same job as {firma, stelle, quelle_url}. // `excludeId` skips a specific row (e.g. when re-checking during an edit). async function findDuplicateApplications({ firma, stelle, quelle_url, excludeId }) { - const rows = await dbAll('SELECT id, datum, firma, stelle, ort, quelle_url, status FROM bewerbungen'); + const rows = await dbAll('SELECT id, datum, firma, stelle, ort, quelle_url, status FROM bewerbungen WHERE user_id = ?', [currentUserId()]); const fUrl = normUrl(quelle_url); const fFirma = normText(firma); const fStelle = normText(stelle); @@ -585,19 +750,19 @@ async function findDuplicateApplications({ firma, stelle, quelle_url, excludeId // Recompute an application's current status from its latest timeline entry async function syncCurrentStatus(bewerbungId) { const latest = await dbGet( - 'SELECT status FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) DESC, id DESC LIMIT 1', - [bewerbungId] + 'SELECT status FROM status_verlauf WHERE user_id = ? AND bewerbung_id = ? ORDER BY date(datum) DESC, id DESC LIMIT 1', + [currentUserId(), bewerbungId] ); await dbRun( - 'UPDATE bewerbungen SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', - [latest ? latest.status : '', bewerbungId] + 'UPDATE bewerbungen SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', + [latest ? latest.status : '', bewerbungId, currentUserId()] ); } // Attach the status timeline to each application (single query, grouped in JS) async function attachVerlauf(applications) { if (!applications.length) return applications; - const all = await dbAll('SELECT * FROM status_verlauf ORDER BY date(datum) ASC, id ASC'); + const all = await dbAll('SELECT * FROM status_verlauf WHERE user_id = ? ORDER BY date(datum) ASC, id ASC', [currentUserId()]); const byApp = {}; all.forEach((v) => { (byApp[v.bewerbung_id] = byApp[v.bewerbung_id] || []).push(v); }); applications.forEach((a) => { a.verlauf = byApp[a.id] || []; }); @@ -609,11 +774,12 @@ async function attachVerlauf(applications) { // job, writes the resulting PDFs to disk and links them as attachments. async function runGeneration(bewerbungId, options = {}) { try { - const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [bewerbungId]); + const U = currentUserId(); + const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [bewerbungId, U]); if (!bewerbung) return; - const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC'); - const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC'); - const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); + const basisDokumente = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [U]); + const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [U]); + const settings = await dbGet('SELECT * FROM settings WHERE user_id = ?', [U]); const prompts = await loadPrompts(); const design = await loadDesign(); @@ -651,10 +817,10 @@ async function runGeneration(bewerbungId, options = {}) { let seq = 0; const storeAnhang = async (name, filename, mime, buffer) => { const stored = `${bewerbungId}_${Date.now()}_${seq++}_${filename}`; - fs.writeFileSync(path.join(anhaengeDir, stored), buffer); + fs.writeFileSync(path.join(userStorageDir(anhaengeDir), stored), buffer); await dbRun( - 'INSERT INTO anhaenge (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)', - [bewerbungId, name, filename, mime, stored] + 'INSERT INTO anhaenge (user_id, bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?, ?)', + [U, bewerbungId, name, filename, mime, stored] ); }; @@ -665,406 +831,371 @@ async function runGeneration(bewerbungId, options = {}) { // Selected extra attachments (e.g. Zeugnisse) — copied as-is for (const ba of selectedAnhaenge) { - const src = path.join(basisAnhaengeDir, ba.pfad); + const src = path.join(userStorageDir(basisAnhaengeDir), ba.pfad); if (!fs.existsSync(src)) continue; await storeAnhang(ba.name || ba.dateiname, ba.dateiname, ba.mime || 'application/octet-stream', fs.readFileSync(src)); } await dbRun( "UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, " + - "email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", - [(email && email.betreff) || '', (email && email.text) || '', bewerbungId] + "email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?", + [(email && email.betreff) || '', (email && email.text) || '', bewerbungId, U] ); console.log(`Bewerbungsunterlagen für #${bewerbungId} generiert (${documents.length} Dokument(e)).`); } catch (error) { console.error(`Generierung für #${bewerbungId} fehlgeschlagen:`, error.message); await dbRun( - "UPDATE bewerbungen SET generierung_status = 'fehler', generierung_fehler = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", - [String(error.message || 'Unbekannter Fehler'), bewerbungId] + "UPDATE bewerbungen SET generierung_status = 'fehler', generierung_fehler = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?", + [String(error.message || 'Unbekannter Fehler'), bewerbungId, currentUserId()] ).catch(() => {}); } } -// Initialize database - create tables and default settings in one operation -function initializeDatabase() { - return new Promise((resolve, reject) => { - db.serialize(() => { - db.run('PRAGMA foreign_keys = ON'); +// Initialize database — multi-user schema. +// +// Creates every table with a `user_id` owner column (NOT NULL, FK -> users) and +// per-user PRIMARY KEY / UNIQUE constraints. Fresh installs get this schema +// directly; legacy single-user installs are upgraded by runMigration() (below), +// which adds the user_id columns, recreates the per-user PK tables, backfills +// all existing rows to the admin user and moves on-disk files into a per-user +// subdirectory. runMigration() is idempotent and also runs on fresh installs +// (where it only creates the admin user + default settings row). +async function initializeDatabase() { + const exec = (sql) => dbRun(sql); + db.run('PRAGMA foreign_keys = ON'); - // Create tables - db.run(` - CREATE TABLE IF NOT EXISTS bewerbungen ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - datum DATE NOT NULL, - firma TEXT NOT NULL, - stelle TEXT NOT NULL, - art TEXT, - status TEXT, - notizen TEXT, - interne_notizen TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `, (err) => { - if (err) return reject(err); + // Users + sessions (auth) ------------------------------------------------ + await exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await exec(` + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)'); - // Migration: add columns to pre-existing databases (ignore "duplicate column") - db.run('ALTER TABLE bewerbungen ADD COLUMN interne_notizen TEXT', () => { - db.run('ALTER TABLE bewerbungen ADD COLUMN ort TEXT', () => { - db.run('ALTER TABLE bewerbungen ADD COLUMN stellenbeschreibung TEXT', () => { - db.run('ALTER TABLE bewerbungen ADD COLUMN quelle_url TEXT', () => { - db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_status TEXT', () => { - db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_fehler TEXT', () => { - db.run('ALTER TABLE bewerbungen ADD COLUMN email_betreff TEXT', () => { - db.run('ALTER TABLE bewerbungen ADD COLUMN email_anschreiben TEXT', () => { - db.run('ALTER TABLE bewerbungen ADD COLUMN llm_notizen TEXT', () => { + // Upgrade legacy single-user installs BEFORE the per-user CREATE/INDEX + // statements below: the migration adds the user_id column to every existing + // per-user table (ALTER) and recreates the PK/UNIQUE tables per-user, so the + // CREATE INDEX ... ON (user_id) statements that follow find the column + // already present. On a fresh install the migration only creates the admin + // user + default settings row (none of the per-user tables exist yet, so every + // step guards itself with tableExists and is a no-op). Idempotent. + await runMigration({ db, dbAll, dbGet, dbRun }); - // Chronological status changes, each with an optional comment - db.run(` - CREATE TABLE IF NOT EXISTS status_verlauf ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bewerbung_id INTEGER NOT NULL, - datum DATE NOT NULL, - status TEXT NOT NULL, - kommentar TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE - ) - `, (err) => { - if (err) return reject(err); + // Applications (core data) ---------------------------------------------- + await exec(` + CREATE TABLE IF NOT EXISTS bewerbungen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + datum DATE NOT NULL, + firma TEXT NOT NULL, + stelle TEXT NOT NULL, + art TEXT, + status TEXT, + notizen TEXT, + interne_notizen TEXT, + ort TEXT, + stellenbeschreibung TEXT, + quelle_url TEXT, + generierung_status TEXT, + generierung_fehler TEXT, + email_betreff TEXT, + email_anschreiben TEXT, + llm_notizen TEXT, + labels TEXT, + generierung_dokumente TEXT, + email_empfaenger TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + await exec('CREATE INDEX IF NOT EXISTS idx_bewerbungen_user ON bewerbungen(user_id)'); - // Base documents (Basis-Unterlagen) — the foundation the AI tailors from - db.run(` - CREATE TABLE IF NOT EXISTS basis_dokumente ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - typ TEXT, - name TEXT, - inhalt TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `, (err) => { - if (err) return reject(err); + // Chronological status changes, each with an optional comment + await exec(` + CREATE TABLE IF NOT EXISTS status_verlauf ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + bewerbung_id INTEGER NOT NULL, + datum DATE NOT NULL, + status TEXT NOT NULL, + kommentar TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE + ) + `); - // Static extra attachments (e.g. Zeugnisse) attached to every application - db.run(` - CREATE TABLE IF NOT EXISTS basis_anhaenge ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT, - dateiname TEXT NOT NULL, - mime TEXT, - pfad TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `, (err) => { - if (err) return reject(err); + // Base documents (Basis-Unterlagen) — the foundation the AI tailors from + await exec(` + CREATE TABLE IF NOT EXISTS basis_dokumente ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + typ TEXT, + name TEXT, + inhalt TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); - // Generated attachment files linked to an application - db.run(` - CREATE TABLE IF NOT EXISTS anhaenge ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bewerbung_id INTEGER NOT NULL, - name TEXT, - dateiname TEXT NOT NULL, - mime TEXT, - pfad TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE - ) - `, (err) => { - if (err) return reject(err); + // Static extra attachments (e.g. Zeugnisse) attached to every application + await exec(` + CREATE TABLE IF NOT EXISTS basis_anhaenge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT, + dateiname TEXT NOT NULL, + mime TEXT, + pfad TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); - // Private attachments linked to an application's internal notes. - // Not exported, not sent — for the user only. - db.run(` - CREATE TABLE IF NOT EXISTS interne_anhaenge ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bewerbung_id INTEGER NOT NULL, - name TEXT, - dateiname TEXT NOT NULL, - mime TEXT, - pfad TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE - ) - `, (err) => { - if (err) return reject(err); + // Generated attachment files linked to an application + await exec(` + CREATE TABLE IF NOT EXISTS anhaenge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + bewerbung_id INTEGER NOT NULL, + name TEXT, + dateiname TEXT NOT NULL, + mime TEXT, + pfad TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE + ) + `); - // E-Mail correspondence (sent + received), linked to an application. - // Serialized mode guarantees these run after the tables above exist. - db.run(` - CREATE TABLE IF NOT EXISTS emails ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bewerbung_id INTEGER, - direction TEXT NOT NULL, - message_id TEXT, - in_reply_to TEXT, - email_references TEXT, - from_addr TEXT, - to_addr TEXT, - subject TEXT, - body_text TEXT, - body_html TEXT, - attachments_json TEXT, - imap_uid INTEGER, - seen INTEGER DEFAULT 1, - email_date DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE - ) - `); - db.run(` - CREATE TABLE IF NOT EXISTS email_anhaenge ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email_id INTEGER NOT NULL, - name TEXT, - mime TEXT, - pfad TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (email_id) REFERENCES emails(id) ON DELETE CASCADE - ) - `); - // Small key/value store (e.g. last processed IMAP UID). - db.run(` - CREATE TABLE IF NOT EXISTS app_state ( - key TEXT PRIMARY KEY, - value TEXT - ) - `); - // Job offers ingested via the third-party REST API (/api/v1/joboffers). - // `quelle` + `external_id` identify an offer from one source uniquely, - // so re-sending the same offer updates it instead of creating a copy. - db.run(` - CREATE TABLE IF NOT EXISTS jobangebote ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - external_id TEXT, - quelle TEXT NOT NULL DEFAULT 'drittanbieter', - firma TEXT NOT NULL, - stelle TEXT NOT NULL, - ort TEXT, - adresse TEXT, - ansprechpartner TEXT, - gehalt TEXT, - beschreibung TEXT, - quelle_url TEXT, - art TEXT, - anzeige_datum DATE, - kontakt_email TEXT, - status TEXT NOT NULL DEFAULT 'offen', - verknuepfte_bewerbung_id INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE (quelle, external_id), - FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL - ) - `); - // Migration: labels (JSON array of work-location labels) on both Stellen tables. - db.run('ALTER TABLE bewerbungen ADD COLUMN labels TEXT', () => {}); - // Which documents the last generation run produced ("anschreiben,lebenslauf"). - // Remembered so the form comes back pre-ticked with the user's last choice. - db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_dokumente TEXT', () => {}); - db.run('ALTER TABLE jobangebote ADD COLUMN labels TEXT', () => {}); - // Migration: add columns to pre-existing jobangebote tables. - db.run('ALTER TABLE jobangebote ADD COLUMN anzeige_datum DATE', () => {}); - db.run('ALTER TABLE jobangebote ADD COLUMN kontakt_email TEXT', () => {}); - db.run('ALTER TABLE jobangebote ADD COLUMN adresse TEXT', () => {}); - db.run('ALTER TABLE jobangebote ADD COLUMN ansprechpartner TEXT', () => {}); - // Normalized URL for URL-based de-duplication of offers (see lib/blacklist). - db.run('ALTER TABLE jobangebote ADD COLUMN url_norm TEXT', () => { - // Backfill url_norm for rows ingested before this column existed. - db.all('SELECT id, quelle_url FROM jobangebote WHERE url_norm IS NULL AND quelle_url IS NOT NULL AND quelle_url != ""', (err, rows) => { - if (err || !rows) return; - rows.forEach((r) => { - const norm = blacklist.normalizeUrl(r.quelle_url); - if (norm) db.run('UPDATE jobangebote SET url_norm = ? WHERE id = ?', [norm, r.id], () => {}); - }); - }); - }); - db.run('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)', () => {}); - // Company slug carried on the offer itself (supplied by the indexing - // client, else derived from firma). Used for blacklist matching and - // "same company" grouping. Backfill derives it from firma. - db.run('ALTER TABLE jobangebote ADD COLUMN firma_slug TEXT', () => { - db.all('SELECT id, firma FROM jobangebote WHERE firma_slug IS NULL AND firma IS NOT NULL AND firma != ""', (err, rows) => { - if (err || !rows) return; - rows.forEach((r) => { - const slug = blacklist.firmaSlug(r.firma); - if (slug) db.run('UPDATE jobangebote SET firma_slug = ? WHERE id = ?', [slug, r.id], () => {}); - }); - }); - }); - db.run('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)', () => {}); - // Blacklist of job offers that must never (re)appear in the list. A - // deleted offer is auto-blacklisted; manual entries can block a URL, - // a whole domain, a company, or a specific company+title posting. - db.run(` - CREATE TABLE IF NOT EXISTS jobangebote_blacklist ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - typ TEXT NOT NULL DEFAULT 'auto', - url_norm TEXT, - domain TEXT, - quelle TEXT, - external_id TEXT, - firma_norm TEXT, - firma_slug TEXT, - stelle_norm TEXT, - ort_norm TEXT, - firma TEXT, - stelle TEXT, - quelle_url TEXT, - grund TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `, () => {}); - // Add + backfill firma_slug on pre-existing installs. The slug is a - // legal-form/umlaut-robust company key that the blacklist now matches - // on; backfill from the stored original company name so old company - // entries start blocking reliably too. - db.run('ALTER TABLE jobangebote_blacklist ADD COLUMN firma_slug TEXT', () => { - db.all( - `SELECT id, firma, firma_norm FROM jobangebote_blacklist - WHERE firma_slug IS NULL AND (firma IS NOT NULL OR firma_norm IS NOT NULL)`, - (err, rows) => { - if (err || !rows) return; - for (const r of rows) { - const slug = blacklist.firmaSlug(r.firma || r.firma_norm || ''); - if (slug) { - db.run('UPDATE jobangebote_blacklist SET firma_slug = ? WHERE id = ?', [slug, r.id], () => {}); - } - } - } - ); - }); - // Remember the last recipient address per application (prefill). - db.run('ALTER TABLE bewerbungen ADD COLUMN email_empfaenger TEXT', () => {}); + // Private attachments linked to an application's internal notes. + await exec(` + CREATE TABLE IF NOT EXISTS interne_anhaenge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + bewerbung_id INTEGER NOT NULL, + name TEXT, + dateiname TEXT NOT NULL, + mime TEXT, + pfad TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE + ) + `); - // Application calendar appointments, mirrored to the SOGo CalDAV - // calendar (see lib/caldav). Times are stored as UTC ISO strings. - db.run(` - CREATE TABLE IF NOT EXISTS termine ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bewerbung_id INTEGER, - typ TEXT NOT NULL DEFAULT 'termin', - titel TEXT NOT NULL, - ort TEXT, - notiz TEXT, - start TEXT NOT NULL, - ende TEXT, - ganztags INTEGER DEFAULT 0, - erinnerung_min INTEGER DEFAULT 60, - caldav_uid TEXT, - caldav_href TEXT, - caldav_etag TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL - ) - `, () => {}); + // E-Mail correspondence (sent + received), linked to an application. + await exec(` + CREATE TABLE IF NOT EXISTS emails ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + bewerbung_id INTEGER, + direction TEXT NOT NULL, + message_id TEXT, + in_reply_to TEXT, + email_references TEXT, + from_addr TEXT, + to_addr TEXT, + subject TEXT, + body_text TEXT, + body_html TEXT, + attachments_json TEXT, + imap_uid INTEGER, + seen INTEGER DEFAULT 1, + email_date DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE + ) + `); + await exec('CREATE INDEX IF NOT EXISTS idx_emails_user ON emails(user_id)'); - // Conversational KI-Chat: threads and their messages. The assistant - // answer is streamed from Ollama (see lib/chat.js) and persisted here. - db.run(` - CREATE TABLE IF NOT EXISTS chat_threads ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - titel TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - db.run(` - CREATE TABLE IF NOT EXISTS chat_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - thread_id INTEGER NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (thread_id) REFERENCES chat_threads(id) ON DELETE CASCADE - ) - `, () => {}); - db.run('CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id)', () => {}); + await exec(` + CREATE TABLE IF NOT EXISTS email_anhaenge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + email_id INTEGER NOT NULL, + name TEXT, + mime TEXT, + pfad TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (email_id) REFERENCES emails(id) ON DELETE CASCADE + ) + `); - // Overridden KI system prompts. Only prompts the user actually edited - // are stored; everything else falls back to the defaults in - // lib/prompts.js, so "Zurücksetzen" is a plain DELETE. - db.run(` - CREATE TABLE IF NOT EXISTS prompts ( - key TEXT PRIMARY KEY, - inhalt TEXT NOT NULL, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `, () => {}); + // Per-user key/value store (cfg: settings + per-user state like last IMAP UID). + await exec(` + CREATE TABLE IF NOT EXISTS app_state ( + user_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT, + PRIMARY KEY (user_id, key), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); - // Design choices for the generated PDFs (accent colour, sidebar tint, - // photo shape, font size). Same contract as `prompts`: only deviations - // are stored, and lib/design.js validates every value on read, so a - // stale row can never produce a broken document. - db.run(` - CREATE TABLE IF NOT EXISTS design ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `, () => {}); + // Job offers ingested via the third-party REST API. (user_id, quelle, + // external_id) identify an offer uniquely per user. + await exec(` + CREATE TABLE IF NOT EXISTS jobangebote ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + external_id TEXT, + quelle TEXT NOT NULL DEFAULT 'drittanbieter', + firma TEXT NOT NULL, + stelle TEXT NOT NULL, + ort TEXT, + adresse TEXT, + ansprechpartner TEXT, + gehalt TEXT, + beschreibung TEXT, + quelle_url TEXT, + art TEXT, + anzeige_datum DATE, + kontakt_email TEXT, + status TEXT NOT NULL DEFAULT 'offen', + verknuepfte_bewerbung_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + url_norm TEXT, + firma_slug TEXT, + labels TEXT, + UNIQUE (user_id, quelle, external_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL + ) + `); + await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)'); + await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)'); - db.run(` - CREATE TABLE IF NOT EXISTS settings ( - id INTEGER PRIMARY KEY CHECK (id = 1), - name TEXT, - adresse TEXT, - kundennummer TEXT, - email TEXT, - telefon TEXT, - ort TEXT, - webseite TEXT, - geburtsdatum TEXT - ) - `, (err) => { - if (err) return reject(err); + // Blacklist of job offers that must never (re)appear in the list (per user). + await exec(` + CREATE TABLE IF NOT EXISTS jobangebote_blacklist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + typ TEXT NOT NULL DEFAULT 'auto', + url_norm TEXT, + domain TEXT, + quelle TEXT, + external_id TEXT, + firma_norm TEXT, + firma_slug TEXT, + stelle_norm TEXT, + ort_norm TEXT, + firma TEXT, + stelle TEXT, + quelle_url TEXT, + grund TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); - // Migration: persönliche Kontaktfelder nachträglich anlegen, falls die - // Tabelle aus einer älteren Version stammt (CREATE TABLE IF NOT EXISTS - // ergänzt fehlende Spalten nicht). Fehler "duplicate column" ignorieren. - const kontaktSpalten = ['email', 'telefon', 'ort', 'webseite', 'geburtsdatum']; - kontaktSpalten.forEach((spalte) => { - db.run(`ALTER TABLE settings ADD COLUMN ${spalte} TEXT`, () => {}); - }); + // Application calendar appointments, mirrored to the CalDAV calendar. + await exec(` + CREATE TABLE IF NOT EXISTS termine ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + bewerbung_id INTEGER, + typ TEXT NOT NULL DEFAULT 'termin', + titel TEXT NOT NULL, + ort TEXT, + notiz TEXT, + start TEXT NOT NULL, + ende TEXT, + ganztags INTEGER DEFAULT 0, + erinnerung_min INTEGER DEFAULT 60, + caldav_uid TEXT, + caldav_href TEXT, + caldav_etag TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL + ) + `); - // Insert default settings if not exists - db.get('SELECT COUNT(*) as count FROM settings WHERE id = 1', (err, result) => { - if (err) return reject(err); + // Conversational KI-Chat: threads and their messages (per user). + await exec(` + CREATE TABLE IF NOT EXISTS chat_threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + titel TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + await exec(` + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + thread_id INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (thread_id) REFERENCES chat_threads(id) ON DELETE CASCADE + ) + `); + await exec('CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id)'); - if (result && result.count === 0) { - db.run( - 'INSERT INTO settings (id, name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)', - ['Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', '', '', '', '', '', '', ''], - (err) => { - if (err) return reject(err); - resolve(); - } - ); - } else { - resolve(); - } - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); + // Overridden KI system prompts (per user). Only edited prompts are stored. + await exec(` + CREATE TABLE IF NOT EXISTS prompts ( + user_id INTEGER NOT NULL, + key TEXT NOT NULL, + inhalt TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, key), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + // Design choices for the generated PDFs (per user, same contract as prompts). + await exec(` + CREATE TABLE IF NOT EXISTS design ( + user_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, key), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + // Personal details of the applicant (one row per user). + await exec(` + CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE, + name TEXT, + adresse TEXT, + kundennummer TEXT, + ort TEXT, + webseite TEXT, + email TEXT, + telefon TEXT, + geburtsdatum TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); } - // Initialize and start server initializeDatabase().then(async () => { console.log('Database initialized successfully'); @@ -1074,22 +1205,31 @@ initializeDatabase().then(async () => { // before any route that reads config — values live in the DB now, not in .env. await config.init({ dbAll, dbRun }); + // Current user's id — set by the auth middleware (lib/context.js). Guaranteed + // to be present inside any protected route or background-per-user task. + const uid = () => currentUserId(); + + // Resolve a per-user storage directory (data///), creating it + // on first use. Used for every attachment / signature / photo path so users' + // files are isolated on disk the same way their DB rows are. + const userDir = userStorageDir; + // Routes app.get('/', async (req, res) => { try { const { month, year } = req.query; - - let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC, created_at DESC'; - const params = []; - + const U = uid(); + + let query = 'SELECT * FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC, created_at DESC'; + const params = [U]; if (month && year) { - query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC'; + query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC'; params.push(month.padStart(2, '0'), year); } else if (year) { - query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC'; + query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC'; params.push(year); } - + const applications = await dbAll(query, params); await attachVerlauf(applications); applications.forEach((a) => { a.labelsArr = parseLabels(a.labels); }); @@ -1097,25 +1237,25 @@ initializeDatabase().then(async () => { const kommendeTermine = await upcomingTermine(6); // Get statistics - const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen'); + const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [U]); const byArt = await dbAll(` - SELECT art, COUNT(*) as count FROM bewerbungen - WHERE art IS NOT NULL AND art != '' + SELECT art, COUNT(*) as count FROM bewerbungen + WHERE user_id = ? AND art IS NOT NULL AND art != '' GROUP BY art ORDER BY count DESC - `); + `, [U]); const byStatus = await dbAll(` - SELECT status, COUNT(*) as count FROM bewerbungen - WHERE status IS NOT NULL AND status != '' + SELECT status, COUNT(*) as count FROM bewerbungen + WHERE user_id = ? AND status IS NOT NULL AND status != '' GROUP BY status ORDER BY count DESC - `); - + `, [U]); + // Get available months/years for filter const availableMonths = await dbAll(` SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth, strftime("%m", datum) as month, strftime("%Y", datum) as year - FROM bewerbungen ORDER BY datum DESC - `); + FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC + `, [U]); // Months/years for the PDF export, keyed by the effective date (last status // change) so a period like Juli 2026 is selectable even when the underlying @@ -1126,13 +1266,14 @@ initializeDatabase().then(async () => { strftime("%Y", eff) as year FROM ( SELECT COALESCE( - (SELECT MAX(date(sv.datum)) FROM status_verlauf sv WHERE sv.bewerbung_id = b.id), + (SELECT MAX(date(sv.datum)) FROM status_verlauf sv WHERE sv.bewerbung_id = b.id AND sv.user_id = ?), date(b.datum) ) AS eff FROM bewerbungen b + WHERE b.user_id = ? ) ORDER BY yearmonth DESC - `); + `, [U, U]); res.render('index', { applications, @@ -1160,12 +1301,12 @@ initializeDatabase().then(async () => { app.get('/api/bewerbungen/:id', async (req, res) => { try { const { id } = req.params; - const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); - + const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]); + if (!application) { return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); } - + res.json(application); } catch (error) { console.error('Error getting application:', error); @@ -1176,8 +1317,8 @@ initializeDatabase().then(async () => { // Get settings app.get('/api/settings', async (req, res) => { try { - const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); - res.json(settings); + const settings = await dbGet('SELECT * FROM settings WHERE user_id = ?', [uid()]); + res.json(settings || {}); } catch (error) { console.error('Error getting settings:', error); res.status(500).json({ error: 'Serverfehler' }); @@ -1188,10 +1329,17 @@ initializeDatabase().then(async () => { app.post('/api/settings', async (req, res) => { try { const { name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum } = req.body; + const U = uid(); await dbRun( - 'UPDATE settings SET name = ?, adresse = ?, kundennummer = ?, email = ?, telefon = ?, ort = ?, webseite = ?, geburtsdatum = ? WHERE id = 1', + `INSERT INTO settings (user_id, name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + name = excluded.name, adresse = excluded.adresse, kundennummer = excluded.kundennummer, + email = excluded.email, telefon = excluded.telefon, ort = excluded.ort, + webseite = excluded.webseite, geburtsdatum = excluded.geburtsdatum`, [ + U, sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer), sanitizeInput(email), sanitizeInput(telefon), sanitizeInput(ort), sanitizeInput(webseite), sanitizeInput(geburtsdatum) @@ -1244,15 +1392,15 @@ initializeDatabase().then(async () => { // (no HTML entities leaking into the generated documents). const result = await dbRun( `INSERT INTO bewerbungen - (datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status) - VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`, - [datum, firma, stelle, quelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || ''] + (user_id, datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status) + VALUES (?, ?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`, + [uid(), datum, firma, stelle, quelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || ''] ); // Record the initial "Entwurf" status in the timeline await dbRun( - 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', - [result.lastID, datum, 'Entwurf', `Automatisch aus dem Browser importiert (${quelle})`] + 'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)', + [uid(), result.lastID, datum, 'Entwurf', `Automatisch aus dem Browser importiert (${quelle})`] ); // Note: generation is NOT started automatically — the user reviews the draft, @@ -1274,11 +1422,11 @@ initializeDatabase().then(async () => { try { const { id } = req.params; const bewerbung = await dbGet( - 'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?', [id] + 'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()] ); if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); const anhaenge = await dbAll( - 'SELECT id, name, dateiname, mime FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', [id] + 'SELECT id, name, dateiname, mime FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC', [id, uid()] ); res.json({ status: bewerbung.generierung_status, @@ -1294,7 +1442,7 @@ initializeDatabase().then(async () => { // ----- Base documents (Basis-Unterlagen / Vorlagen) ----- app.get('/api/basis-dokumente', async (req, res) => { try { - const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC'); + const docs = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [uid()]); res.json(docs); } catch (error) { console.error('Error listing base documents:', error); @@ -1323,20 +1471,20 @@ initializeDatabase().then(async () => { } const result = await dbRun( - 'INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, interne_notizen, labels) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - [datum, sanitizeInput(firma), sanitizeInput(stelle), + 'INSERT INTO bewerbungen (user_id, datum, firma, stelle, art, status, notizen, interne_notizen, labels) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [uid(), datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels] ); // Record the initial status as the first timeline entry if (status && status.trim()) { await dbRun( - 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', - [result.lastID, datum, sanitizeInput(status), sanitizeInput(kommentar || '')] + 'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)', + [uid(), result.lastID, datum, sanitizeInput(status), sanitizeInput(kommentar || '')] ); } - const newApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]); + const newApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [result.lastID, uid()]); res.json({ success: true, application: newApplication }); } catch (error) { @@ -1350,18 +1498,18 @@ initializeDatabase().then(async () => { try { const { id } = req.params; const { datum, firma, stelle, art, status, notizen } = req.body; - const existing = await dbGet('SELECT labels FROM bewerbungen WHERE id = ?', [id]); + const existing = await dbGet('SELECT labels FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]); const labels = Object.prototype.hasOwnProperty.call(req.body, 'labels') ? serializeLabels(req.body.labels) : (existing ? existing.labels : '[]'); await dbRun( - 'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + 'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [datum, sanitizeInput(firma), sanitizeInput(stelle), - sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), labels, id] + sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), labels, id, uid()] ); - - const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); + + const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]); res.json({ success: true, application: updatedApplication }); } catch (error) { @@ -1374,12 +1522,13 @@ initializeDatabase().then(async () => { app.delete('/api/bewerbungen/:id', async (req, res) => { try { const { id } = req.params; - await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]); + await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]); // Remove private attachments belonging to the internal notes. - const interne = await dbAll('SELECT pfad FROM interne_anhaenge WHERE bewerbung_id = ?', [id]); - interne.forEach((a) => fs.promises.unlink(path.join(interneAnhaengeDir, a.pfad)).catch(() => {})); - await dbRun('DELETE FROM interne_anhaenge WHERE bewerbung_id = ?', [id]); - await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]); + const interne = await dbAll('SELECT pfad FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]); + const intDir = userStorageDir(interneAnhaengeDir); + interne.forEach((a) => fs.promises.unlink(path.join(intDir, a.pfad)).catch(() => {})); + await dbRun('DELETE FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]); + await dbRun('DELETE FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]); res.json({ success: true }); } catch (error) { @@ -1392,6 +1541,7 @@ initializeDatabase().then(async () => { app.get('/api/export', async (req, res) => { try { const { month, year } = req.query; + const U = uid(); // Effektives Datum einer Bewerbung = Datum ihrer letzten Statusänderung // (fällt auf das Bewerbungsdatum zurück, wenn es keinen Verlauf gibt). Der @@ -1400,24 +1550,25 @@ initializeDatabase().then(async () => { // erscheint dadurch im Export für Juli. const base = ` SELECT b.*, COALESCE( - (SELECT MAX(date(sv.datum)) FROM status_verlauf sv WHERE sv.bewerbung_id = b.id), + (SELECT MAX(date(sv.datum)) FROM status_verlauf sv WHERE sv.bewerbung_id = b.id AND sv.user_id = ?), date(b.datum) ) AS eff_datum FROM bewerbungen b + WHERE b.user_id = ? `; let query = `SELECT * FROM (${base}) ORDER BY eff_datum DESC`; - const params = []; + const params = [U, U]; if (month && year) { - query = `SELECT * FROM (${base}) WHERE strftime("%m", eff_datum) = ? AND strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`; + query = `SELECT * FROM (${base}) AND strftime("%m", eff_datum) = ? AND strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`; params.push(month.padStart(2, '0'), year); } else if (month) { // A month without a year must still restrict the export to that month — // never fall through to exporting every application. - query = `SELECT * FROM (${base}) WHERE strftime("%m", eff_datum) = ? ORDER BY eff_datum DESC`; + query = `SELECT * FROM (${base}) AND strftime("%m", eff_datum) = ? ORDER BY eff_datum DESC`; params.push(month.padStart(2, '0')); } else if (year) { - query = `SELECT * FROM (${base}) WHERE strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`; + query = `SELECT * FROM (${base}) AND strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`; params.push(year); } @@ -1439,40 +1590,41 @@ initializeDatabase().then(async () => { app.get('/bewerbung/:id', async (req, res) => { try { const { id } = req.params; - const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); + const U = uid(); + const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, U]); if (!application) return res.status(404).send('Bewerbung nicht gefunden'); application.labelsArr = parseLabels(application.labels); const verlauf = await dbAll( - 'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC', - [id] + 'SELECT * FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ? ORDER BY date(datum) ASC, id ASC', + [id, U] ); const anhaenge = await dbAll( - 'SELECT id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', - [id] + 'SELECT id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC', + [id, U] ); // Private attachments belonging to the internal notes — never exported/sent. const interneAnhaenge = await dbAll( - 'SELECT id, name, dateiname, mime, created_at FROM interne_anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', - [id] + 'SELECT id, name, dateiname, mime, created_at FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC', + [id, U] ); - const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente'); + const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente WHERE user_id = ?', [U]); // Available static attachments (Zeugnisse etc.) to optionally enclose. - const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge ORDER BY id ASC'); + const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [U]); // Calendar appointments for this application (mirrored to SOGo). - const termine = await dbAll('SELECT * FROM termine WHERE bewerbung_id = ? ORDER BY start ASC', [id]); + const termine = await dbAll('SELECT * FROM termine WHERE bewerbung_id = ? AND user_id = ? ORDER BY start ASC', [id, U]); // E-Mail correspondence (sent + received), oldest first, with attachments. const emails = await dbAll( - 'SELECT * FROM emails WHERE bewerbung_id = ? ORDER BY datetime(email_date) ASC, id ASC', - [id] + 'SELECT * FROM emails WHERE bewerbung_id = ? AND user_id = ? ORDER BY datetime(email_date) ASC, id ASC', + [id, U] ); if (emails.length) { const eIds = emails.map((e) => e.id); const atts = await dbAll( - `SELECT id, email_id, name, mime FROM email_anhaenge WHERE email_id IN (${eIds.map(() => '?').join(',')})`, - eIds + `SELECT id, email_id, name, mime FROM email_anhaenge WHERE user_id = ? AND email_id IN (${eIds.map(() => '?').join(',')})`, + [U, ...eIds] ); const byEmail = {}; atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); }); @@ -1485,7 +1637,7 @@ initializeDatabase().then(async () => { // HTML rendering (sandboxed iframe) + a quote of each received message. decorateEmails(emails); // Mark received messages as read now that they are shown. - await dbRun("UPDATE emails SET seen = 1 WHERE bewerbung_id = ? AND direction = 'in' AND seen = 0", [id]); + await dbRun("UPDATE emails SET seen = 1 WHERE bewerbung_id = ? AND user_id = ? AND direction = 'in' AND seen = 0", [id, U]); } res.render('bewerbung', { @@ -1529,8 +1681,8 @@ initializeDatabase().then(async () => { const labels = serializeLabels(req.body.labels); await dbRun( - 'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', - [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels, id] + 'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', + [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels, id, uid()] ); res.redirect('/bewerbung/' + id); @@ -1544,14 +1696,14 @@ initializeDatabase().then(async () => { app.post('/bewerbung/:id/email', async (req, res) => { try { const { id } = req.params; - const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]); + const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]); if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden'); // Store raw; the value is HTML-escaped on render (EJS <%= %>), matching how // the generated e-mail is stored. Sanitising here would double-escape. await dbRun( - 'UPDATE bewerbungen SET email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', - [String(req.body.email_betreff || ''), String(req.body.email_anschreiben || ''), id] + 'UPDATE bewerbungen SET email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', + [String(req.body.email_betreff || ''), String(req.body.email_anschreiben || ''), id, uid()] ); res.redirect('/bewerbung/' + id + '#email'); } catch (error) { @@ -1567,10 +1719,11 @@ initializeDatabase().then(async () => { const { id } = req.params; const back = (frag) => '/bewerbung/' + id + (frag || '#korrespondenz'); try { - const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); + const U = uid(); + const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, U]); if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden'); if (!mailer.isConfigured()) { - return res.redirect(back('?mailerror=' + encodeURIComponent('E-Mail ist nicht konfiguriert (.env).') + '#korrespondenz')); + return res.redirect(back('?mailerror=' + encodeURIComponent('E-Mail ist nicht konfiguriert (Einstellungen).') + '#korrespondenz')); } const to = String(req.body.to || '').trim(); @@ -1585,10 +1738,11 @@ initializeDatabase().then(async () => { if (!Array.isArray(anhangIds)) anhangIds = [anhangIds]; const attachments = []; const attNames = []; + const anhaengeUserDir = userStorageDir(anhaengeDir); for (const aid of anhangIds) { - const a = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?', [aid, id]); + const a = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [aid, id, U]); if (!a) continue; - const p = path.join(anhaengeDir, a.pfad); + const p = path.join(anhaengeUserDir, a.pfad); if (!fs.existsSync(p)) continue; attachments.push({ filename: a.dateiname, path: p, contentType: a.mime || undefined }); attNames.push(a.dateiname); @@ -1598,10 +1752,11 @@ initializeDatabase().then(async () => { // Default: none selected. let basisAnlageIds = req.body.basis_anlage || []; if (!Array.isArray(basisAnlageIds)) basisAnlageIds = [basisAnlageIds]; + const basisUserDir = userStorageDir(basisAnhaengeDir); for (const bid of basisAnlageIds) { - const ba = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [bid]); + const ba = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [bid, U]); if (!ba) continue; - const p = path.join(basisAnhaengeDir, ba.pfad); + const p = path.join(basisUserDir, ba.pfad); if (!fs.existsSync(p)) continue; attachments.push({ filename: ba.dateiname, path: p, contentType: ba.mime || undefined }); attNames.push(ba.dateiname); @@ -1610,7 +1765,7 @@ initializeDatabase().then(async () => { // Threading headers when this is a reply to a stored message. let inReplyTo = null, references = null; if (req.body.reply_to) { - const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ?', [req.body.reply_to, id]); + const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [req.body.reply_to, id, U]); if (orig && orig.message_id) { inReplyTo = '<' + orig.message_id + '>'; references = ((orig.email_references ? orig.email_references + ' ' : '') + inReplyTo).trim(); @@ -1624,26 +1779,27 @@ initializeDatabase().then(async () => { const mid = String(info.messageId || '').replace(/[<>]/g, ''); const emailRow = await dbRun( - `INSERT INTO emails (bewerbung_id, direction, message_id, in_reply_to, email_references, + `INSERT INTO emails (user_id, bewerbung_id, direction, message_id, in_reply_to, email_references, from_addr, to_addr, subject, body_text, attachments_json, seen, email_date) - VALUES (?, 'out', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`, - [id, mid, inReplyTo ? inReplyTo.replace(/[<>]/g, '') : null, references ? references.replace(/[<>]/g, '') : null, + VALUES (?, ?, 'out', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`, + [U, id, mid, inReplyTo ? inReplyTo.replace(/[<>]/g, '') : null, references ? references.replace(/[<>]/g, '') : null, mailer.fromAddress(), to, subject, body, JSON.stringify(attNames), new Date().toISOString()] ); // Keep a copy of each sent attachment linked to the outgoing message so it // is shown (and can be reopened) in the correspondence view afterwards. + const emailAttDir = userStorageDir(emailAnhaengeDir); for (const att of attachments) { const safe = String(att.filename || 'anhang').replace(/[^a-zA-Z0-9äöüÄÖÜß._ -]/g, '_').slice(0, 80); const storedName = `${emailRow.lastID}_${Date.now()}_${safe}`; try { - fs.copyFileSync(att.path, path.join(emailAnhaengeDir, storedName)); - await dbRun('INSERT INTO email_anhaenge (email_id, name, mime, pfad) VALUES (?, ?, ?, ?)', - [emailRow.lastID, att.filename, att.contentType || null, storedName]); + fs.copyFileSync(att.path, path.join(emailAttDir, storedName)); + await dbRun('INSERT INTO email_anhaenge (user_id, email_id, name, mime, pfad) VALUES (?, ?, ?, ?, ?)', + [U, emailRow.lastID, att.filename, att.contentType || null, storedName]); } catch (e) { /* ignore a single bad attachment */ } } - await dbRun('UPDATE bewerbungen SET email_empfaenger = ? WHERE id = ?', [to, id]); + await dbRun('UPDATE bewerbungen SET email_empfaenger = ? WHERE id = ? AND user_id = ?', [to, id, U]); res.redirect(back('?mailok=' + encodeURIComponent('E-Mail an ' + to + ' gesendet.') + '#korrespondenz')); } catch (error) { @@ -1657,11 +1813,11 @@ initializeDatabase().then(async () => { app.post('/bewerbung/:id/email/ai-reply', async (req, res) => { try { const { id } = req.params; - const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); + const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]); if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); - const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ?', [req.body.email_id, id]); + const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [req.body.email_id, id, uid()]); if (!orig) return res.status(404).json({ error: 'Nachricht nicht gefunden' }); - const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); + const settings = await dbGet('SELECT * FROM settings WHERE user_id = ?', [uid()]); const draft = await generateEmailReply({ incoming: { from: orig.from_addr, subject: orig.subject, text: emailPlainText(orig) }, @@ -1691,9 +1847,9 @@ initializeDatabase().then(async () => { // Download an attachment that arrived with a received e-mail. app.get('/email-anhaenge/:id/download', async (req, res) => { try { - const a = await dbGet('SELECT * FROM email_anhaenge WHERE id = ?', [req.params.id]); + const a = await dbGet('SELECT * FROM email_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]); if (!a) return res.status(404).send('Anhang nicht gefunden'); - const p = path.join(emailAnhaengeDir, a.pfad); + const p = path.join(userStorageDir(emailAnhaengeDir), a.pfad); if (!fs.existsSync(p)) return res.status(404).send('Datei nicht gefunden'); // inline=1 opens viewable files (PDF/image) in the browser tab instead of forcing a download. if (req.query.inline === '1') return serveInline(res, p, a.name || a.pfad, a.mime); @@ -1713,14 +1869,16 @@ initializeDatabase().then(async () => { // assignment target. app.get('/postfach', async (req, res) => { try { + const U = uid(); const emails = await dbAll( - `SELECT * FROM emails WHERE bewerbung_id IS NULL ORDER BY datetime(email_date) DESC, id DESC` + `SELECT * FROM emails WHERE user_id = ? AND bewerbung_id IS NULL ORDER BY datetime(email_date) DESC, id DESC`, + [U] ); if (emails.length) { const eIds = emails.map((e) => e.id); const atts = await dbAll( - `SELECT id, email_id, name, mime FROM email_anhaenge WHERE email_id IN (${eIds.map(() => '?').join(',')})`, - eIds + `SELECT id, email_id, name, mime FROM email_anhaenge WHERE user_id = ? AND email_id IN (${eIds.map(() => '?').join(',')})`, + [U, ...eIds] ); const byEmail = {}; atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); }); @@ -1729,11 +1887,12 @@ initializeDatabase().then(async () => { // These are read now that they are shown — clears them from the bell. // (The `seen` values above are captured pre-update, so the "Neu" badge // still renders on this view.) - await dbRun("UPDATE emails SET seen = 1 WHERE bewerbung_id IS NULL AND direction = 'in' AND seen = 0"); + await dbRun("UPDATE emails SET seen = 1 WHERE user_id = ? AND bewerbung_id IS NULL AND direction = 'in' AND seen = 0", [U]); } // Applications the user can assign an e-mail to (newest first). const bewerbungen = await dbAll( - `SELECT id, firma, stelle, ort, datum FROM bewerbungen ORDER BY datum DESC, created_at DESC` + `SELECT id, firma, stelle, ort, datum FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC, created_at DESC`, + [U] ); res.render('postfach', { emails, @@ -1757,11 +1916,11 @@ initializeDatabase().then(async () => { return res.redirect('/postfach?mailerror=' + encodeURIComponent('Bitte eine Bewerbung auswählen.')); } // Make sure the target application exists and the e-mail is still unlinked. - const app = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [bewerbungId]); + const app = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [bewerbungId, uid()]); if (!app) { return res.redirect('/postfach?mailerror=' + encodeURIComponent('Ausgewählte Bewerbung existiert nicht.')); } - await dbRun('UPDATE emails SET bewerbung_id = ? WHERE id = ? AND bewerbung_id IS NULL', [bewerbungId, req.params.emailId]); + await dbRun('UPDATE emails SET bewerbung_id = ? WHERE id = ? AND user_id = ? AND bewerbung_id IS NULL', [bewerbungId, req.params.emailId, uid()]); res.redirect('/postfach?mailok=' + encodeURIComponent('E-Mail wurde der Bewerbung zugewiesen.')); } catch (error) { console.error('Error assigning e-mail:', error); @@ -1773,12 +1932,13 @@ initializeDatabase().then(async () => { app.post('/postfach/:emailId/delete', async (req, res) => { try { const emailId = req.params.emailId; - const atts = await dbAll('SELECT pfad FROM email_anhaenge WHERE email_id = ?', [emailId]); + const atts = await dbAll('SELECT pfad FROM email_anhaenge WHERE email_id = ? AND user_id = ?', [emailId, uid()]); + const attDir = userStorageDir(emailAnhaengeDir); for (const a of atts) { - fs.promises.unlink(path.join(emailAnhaengeDir, a.pfad)).catch(() => {}); + fs.promises.unlink(path.join(attDir, a.pfad)).catch(() => {}); } - await dbRun('DELETE FROM email_anhaenge WHERE email_id = ?', [emailId]); - await dbRun('DELETE FROM emails WHERE id = ?', [emailId]); + await dbRun('DELETE FROM email_anhaenge WHERE email_id = ? AND user_id = ?', [emailId, uid()]); + await dbRun('DELETE FROM emails WHERE id = ? AND user_id = ?', [emailId, uid()]); res.redirect('/postfach?mailok=' + encodeURIComponent('E-Mail wurde gelöscht.')); } catch (error) { console.error('Error deleting e-mail:', error); @@ -1789,7 +1949,7 @@ initializeDatabase().then(async () => { // Count of unlinked e-mails — drives the "Postfach" header badge on every page. app.get('/api/emails/unassigned-count', async (req, res) => { try { - const row = await dbGet('SELECT COUNT(*) as count FROM emails WHERE bewerbung_id IS NULL'); + const row = await dbGet('SELECT COUNT(*) as count FROM emails WHERE user_id = ? AND bewerbung_id IS NULL', [uid()]); res.json({ count: row ? row.count : 0 }); } catch (error) { res.status(500).json({ count: 0 }); @@ -1803,15 +1963,17 @@ initializeDatabase().then(async () => { app.get('/api/notifications', async (req, res) => { try { const cntRow = await dbGet( - "SELECT COUNT(*) AS count FROM emails WHERE direction = 'in' AND seen = 0" + "SELECT COUNT(*) AS count FROM emails WHERE user_id = ? AND direction = 'in' AND seen = 0", + [uid()] ); const rows = await dbAll( `SELECT e.id, e.from_addr, e.subject, e.body_text, e.body_html, e.email_date, e.bewerbung_id, b.firma, b.stelle - FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id - WHERE e.direction = 'in' AND e.seen = 0 + FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id AND b.user_id = e.user_id + WHERE e.user_id = ? AND e.direction = 'in' AND e.seen = 0 ORDER BY datetime(e.email_date) DESC, e.id DESC - LIMIT 30` + LIMIT 30`, + [uid()] ); const items = rows.map((e) => { const fromName = String(e.from_addr || '').replace(/<[^>]*>/, '').replace(/"/g, '').trim() @@ -1837,7 +1999,7 @@ initializeDatabase().then(async () => { // Mark every received e-mail as read (clears the notification bell). app.post('/api/emails/mark-all-read', async (req, res) => { try { - await dbRun("UPDATE emails SET seen = 1 WHERE direction = 'in' AND seen = 0"); + await dbRun("UPDATE emails SET seen = 1 WHERE user_id = ? AND direction = 'in' AND seen = 0", [uid()]); res.json({ ok: true }); } catch (error) { res.status(500).json({ ok: false }); @@ -1852,8 +2014,8 @@ initializeDatabase().then(async () => { if (datum && status && status.trim()) { await dbRun( - 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', - [id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')] + 'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)', + [uid(), id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')] ); await syncCurrentStatus(id); @@ -1874,7 +2036,7 @@ initializeDatabase().then(async () => { app.post('/bewerbung/:id/termine', async (req, res) => { const { id } = req.params; try { - const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]); + const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]); if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden'); if (!caldav.isConfigured()) { return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Kalender ist nicht konfiguriert.') + '#termine'); @@ -1903,9 +2065,9 @@ initializeDatabase().then(async () => { }); await dbRun( - `INSERT INTO termine (bewerbung_id, typ, titel, ort, notiz, start, ende, ganztags, erinnerung_min, caldav_uid, caldav_href, caldav_etag) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [id, typ, sanitizeInput(titel), sanitizeInput(b.ort || ''), sanitizeInput(b.notiz || ''), + `INSERT INTO termine (user_id, bewerbung_id, typ, titel, ort, notiz, start, ende, ganztags, erinnerung_min, caldav_uid, caldav_href, caldav_etag) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uid(), id, typ, sanitizeInput(titel), sanitizeInput(b.ort || ''), sanitizeInput(b.notiz || ''), start.toISOString(), ende ? ende.toISOString() : null, ganztags ? 1 : 0, erinnerung, created.uid, created.href, created.etag] ); @@ -1919,10 +2081,10 @@ initializeDatabase().then(async () => { app.post('/bewerbung/:id/termine/:tid/delete', async (req, res) => { const { id, tid } = req.params; try { - const t = await dbGet('SELECT * FROM termine WHERE id = ? AND bewerbung_id = ?', [tid, id]); + const t = await dbGet('SELECT * FROM termine WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [tid, id, uid()]); if (t) { await caldav.deleteEvent({ href: t.caldav_href, etag: t.caldav_etag }).catch((e) => console.warn('CalDAV delete:', e.message)); - await dbRun('DELETE FROM termine WHERE id = ?', [tid]); + await dbRun('DELETE FROM termine WHERE id = ? AND user_id = ?', [tid, uid()]); } res.redirect('/bewerbung/' + id + '#termine'); } catch (error) { @@ -1939,8 +2101,8 @@ initializeDatabase().then(async () => { if (datum && status && status.trim()) { await dbRun( - 'UPDATE status_verlauf SET datum = ?, status = ?, kommentar = ? WHERE id = ? AND bewerbung_id = ?', - [datum, sanitizeInput(status), sanitizeInput(kommentar || ''), eintragId, id] + 'UPDATE status_verlauf SET datum = ?, status = ?, kommentar = ? WHERE id = ? AND bewerbung_id = ? AND user_id = ?', + [datum, sanitizeInput(status), sanitizeInput(kommentar || ''), eintragId, id, uid()] ); await syncCurrentStatus(id); } @@ -1956,7 +2118,7 @@ initializeDatabase().then(async () => { app.post('/bewerbung/:id/verlauf/:eintragId/delete', async (req, res) => { try { const { id, eintragId } = req.params; - await dbRun('DELETE FROM status_verlauf WHERE id = ? AND bewerbung_id = ?', [eintragId, id]); + await dbRun('DELETE FROM status_verlauf WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [eintragId, id, uid()]); await syncCurrentStatus(id); res.redirect('/bewerbung/' + id); } catch (error) { @@ -1968,13 +2130,13 @@ initializeDatabase().then(async () => { // ----- Vorlagen (Basis-Unterlagen) management page ----- app.get('/vorlagen', async (req, res) => { try { - const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC'); - const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC'); + const basisDokumente = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [uid()]); + const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [uid()]); const design = await loadDesign(); res.render('vorlagen', { basisDokumente, basisAnhaenge, - settings: await dbGet('SELECT * FROM settings WHERE id = 1'), + settings: await dbGet('SELECT * FROM settings WHERE user_id = ?', [uid()]), prompts: promptStore.list(await loadPrompts()), designFelder: designStore.list(design), designAngepasst: designStore.isAngepasst(design), @@ -1999,8 +2161,14 @@ initializeDatabase().then(async () => { try { const { name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum } = req.body; await dbRun( - 'UPDATE settings SET name = ?, adresse = ?, kundennummer = ?, email = ?, telefon = ?, ort = ?, webseite = ?, geburtsdatum = ? WHERE id = 1', + `INSERT INTO settings (user_id, name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + name = excluded.name, adresse = excluded.adresse, kundennummer = excluded.kundennummer, + email = excluded.email, telefon = excluded.telefon, ort = excluded.ort, + webseite = excluded.webseite, geburtsdatum = excluded.geburtsdatum`, [ + uid(), sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer), sanitizeInput(email), sanitizeInput(telefon), sanitizeInput(ort), sanitizeInput(webseite), sanitizeInput(geburtsdatum) @@ -2025,12 +2193,12 @@ initializeDatabase().then(async () => { if (!promptStore.isKnownKey(key)) return res.status(404).send('Unbekannter Prompt'); const inhalt = String(req.body.inhalt || '').trim(); if (!inhalt || inhalt === promptStore.defaultText(key).trim()) { - await dbRun('DELETE FROM prompts WHERE key = ?', [key]); + await dbRun('DELETE FROM prompts WHERE user_id = ? AND key = ?', [uid(), key]); } else { await dbRun( - `INSERT INTO prompts (key, inhalt, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(key) DO UPDATE SET inhalt = excluded.inhalt, updated_at = CURRENT_TIMESTAMP`, - [key, inhalt] + `INSERT INTO prompts (user_id, key, inhalt, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id, key) DO UPDATE SET inhalt = excluded.inhalt, updated_at = CURRENT_TIMESTAMP`, + [uid(), key, inhalt] ); } res.redirect('/vorlagen#prompts'); @@ -2043,7 +2211,7 @@ initializeDatabase().then(async () => { // Restore the default text of a prompt by dropping the override. app.post('/vorlagen/prompts/:key/reset', async (req, res) => { try { - await dbRun('DELETE FROM prompts WHERE key = ?', [req.params.key]); + await dbRun('DELETE FROM prompts WHERE user_id = ? AND key = ?', [uid(), req.params.key]); res.redirect('/vorlagen#prompts'); } catch (error) { console.error('Error resetting prompt:', error); @@ -2071,12 +2239,12 @@ initializeDatabase().then(async () => { }; for (const [key, value] of Object.entries(gewaehlt)) { if (value === basis[key]) { - await dbRun('DELETE FROM design WHERE key = ?', [key]); + await dbRun('DELETE FROM design WHERE user_id = ? AND key = ?', [uid(), key]); } else { await dbRun( - `INSERT INTO design (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`, - [key, value] + `INSERT INTO design (user_id, key, value, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`, + [uid(), key, value] ); } } @@ -2090,7 +2258,7 @@ initializeDatabase().then(async () => { // Back to the shipped design. app.post('/vorlagen/design/reset', async (req, res) => { try { - await dbRun('DELETE FROM design'); + await dbRun('DELETE FROM design WHERE user_id = ?', [uid()]); res.redirect('/vorlagen#design'); } catch (error) { console.error('Error resetting design:', error); @@ -2108,7 +2276,7 @@ initializeDatabase().then(async () => { // Only known design keys from the query are honoured; design.settings() // drops anything invalid. const design = { ...gespeichert, ...req.query }; - const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); + const settings = await dbGet('SELECT * FROM settings WHERE user_id = ?', [uid()]); const pdfs = renderDesignVorschau({ settings, signatur: loadSignatur(), @@ -2130,8 +2298,8 @@ initializeDatabase().then(async () => { const { typ, name, inhalt } = req.body; if (inhalt && inhalt.trim()) { await dbRun( - 'INSERT INTO basis_dokumente (typ, name, inhalt) VALUES (?, ?, ?)', - [sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt] + 'INSERT INTO basis_dokumente (user_id, typ, name, inhalt) VALUES (?, ?, ?, ?)', + [uid(), sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt] ); } res.redirect('/vorlagen'); @@ -2147,8 +2315,8 @@ initializeDatabase().then(async () => { const { id } = req.params; const { typ, name, inhalt } = req.body; await dbRun( - 'UPDATE basis_dokumente SET typ = ?, name = ?, inhalt = ? WHERE id = ?', - [sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt || '', id] + 'UPDATE basis_dokumente SET typ = ?, name = ?, inhalt = ? WHERE id = ? AND user_id = ?', + [sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt || '', id, uid()] ); res.redirect('/vorlagen'); } catch (error) { @@ -2160,7 +2328,7 @@ initializeDatabase().then(async () => { // Delete a base document app.post('/vorlagen/:id/delete', async (req, res) => { try { - await dbRun('DELETE FROM basis_dokumente WHERE id = ?', [req.params.id]); + await dbRun('DELETE FROM basis_dokumente WHERE id = ? AND user_id = ?', [req.params.id, uid()]); res.redirect('/vorlagen'); } catch (error) { console.error('Error deleting base document:', error); @@ -2184,8 +2352,8 @@ initializeDatabase().then(async () => { ? sanitizeInput(req.body.name.trim()) : sanitizeInput(original.replace(/\.[^.]+$/, '')); await dbRun( - 'INSERT INTO basis_anhaenge (name, dateiname, mime, pfad) VALUES (?, ?, ?, ?)', - [name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename] + 'INSERT INTO basis_anhaenge (user_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)', + [uid(), name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename] ); } res.redirect('/vorlagen'); @@ -2199,9 +2367,9 @@ initializeDatabase().then(async () => { // Download a static attachment app.get('/anlagen/:id/download', async (req, res) => { try { - const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [req.params.id]); + const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]); if (!a) return res.status(404).send('Anlage nicht gefunden'); - const filePath = path.join(basisAnhaengeDir, a.pfad); + const filePath = path.join(userStorageDir(basisAnhaengeDir), a.pfad); if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden'); res.download(filePath, a.dateiname); } catch (error) { @@ -2213,10 +2381,10 @@ initializeDatabase().then(async () => { // Delete a static attachment app.post('/anlagen/:id/delete', async (req, res) => { try { - const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [req.params.id]); + const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]); if (a) { - fs.promises.unlink(path.join(basisAnhaengeDir, a.pfad)).catch(() => {}); - await dbRun('DELETE FROM basis_anhaenge WHERE id = ?', [req.params.id]); + fs.promises.unlink(path.join(userStorageDir(basisAnhaengeDir), a.pfad)).catch(() => {}); + await dbRun('DELETE FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]); } res.redirect('/vorlagen'); } catch (error) { @@ -2240,9 +2408,10 @@ initializeDatabase().then(async () => { try { if (err) console.error('Signature upload error:', err.message); if (req.file) { - // keep only the newly uploaded file - fs.readdirSync(signaturDir).forEach((f) => { - if (f !== req.file.filename) fs.promises.unlink(path.join(signaturDir, f)).catch(() => {}); + // keep only the newly uploaded file (in the user's subdir) + const dir = userStorageDir(signaturDir); + fs.readdirSync(dir).forEach((f) => { + if (f !== req.file.filename) fs.promises.unlink(path.join(dir, f)).catch(() => {}); }); } res.redirect('/vorlagen'); @@ -2256,7 +2425,8 @@ initializeDatabase().then(async () => { // Delete the signature app.post('/unterschrift/delete', (req, res) => { try { - fs.readdirSync(signaturDir).forEach((f) => fs.promises.unlink(path.join(signaturDir, f)).catch(() => {})); + const dir = userStorageDir(signaturDir); + fs.readdirSync(dir).forEach((f) => fs.promises.unlink(path.join(dir, f)).catch(() => {})); } catch (e) { /* ignore */ } res.redirect('/vorlagen'); }); @@ -2276,9 +2446,10 @@ initializeDatabase().then(async () => { try { if (err) console.error('Photo upload error:', err.message); if (req.file) { - // keep only the newly uploaded file - fs.readdirSync(fotoDir).forEach((f) => { - if (f !== req.file.filename) fs.promises.unlink(path.join(fotoDir, f)).catch(() => {}); + // keep only the newly uploaded file (in the user's subdir) + const dir = userStorageDir(fotoDir); + fs.readdirSync(dir).forEach((f) => { + if (f !== req.file.filename) fs.promises.unlink(path.join(dir, f)).catch(() => {}); }); } res.redirect('/vorlagen'); @@ -2292,7 +2463,8 @@ initializeDatabase().then(async () => { // Delete the photo app.post('/bewerbungsfoto/delete', (req, res) => { try { - fs.readdirSync(fotoDir).forEach((f) => fs.promises.unlink(path.join(fotoDir, f)).catch(() => {})); + const dir = userStorageDir(fotoDir); + fs.readdirSync(dir).forEach((f) => fs.promises.unlink(path.join(dir, f)).catch(() => {})); } catch (e) { /* ignore */ } res.redirect('/vorlagen'); }); @@ -2300,9 +2472,9 @@ initializeDatabase().then(async () => { // Download a generated attachment app.get('/anhaenge/:id/download', async (req, res) => { try { - const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ?', [req.params.id]); + const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]); if (!anhang) return res.status(404).send('Anhang nicht gefunden'); - const filePath = path.join(anhaengeDir, anhang.pfad); + const filePath = path.join(userStorageDir(anhaengeDir), anhang.pfad); if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden'); // inline=1 opens viewable files (PDF/image) in the browser tab instead of forcing a download. if (req.query.inline === '1') return serveInline(res, filePath, anhang.dateiname, anhang.mime); @@ -2317,11 +2489,11 @@ initializeDatabase().then(async () => { app.post('/bewerbung/:id/anhaenge/:anhangId/delete', async (req, res) => { try { const { id, anhangId } = req.params; - const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?', [anhangId, id]); + const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]); if (anhang) { - const filePath = path.join(anhaengeDir, anhang.pfad); + const filePath = path.join(userStorageDir(anhaengeDir), anhang.pfad); fs.promises.unlink(filePath).catch(() => {}); - await dbRun('DELETE FROM anhaenge WHERE id = ?', [anhangId]); + await dbRun('DELETE FROM anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]); } res.redirect('/bewerbung/' + id); } catch (error) { @@ -2347,8 +2519,8 @@ initializeDatabase().then(async () => { ? sanitizeInput(req.body.name.trim()) : sanitizeInput(original.replace(/\.[^.]+$/, '')); await dbRun( - 'INSERT INTO interne_anhaenge (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)', - [id, name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename] + 'INSERT INTO interne_anhaenge (user_id, bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?, ?)', + [uid(), id, name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename] ); } res.redirect('/bewerbung/' + id); @@ -2363,9 +2535,9 @@ initializeDatabase().then(async () => { app.get('/bewerbung/:id/interne-anhaenge/:anhangId/download', async (req, res) => { try { const { id, anhangId } = req.params; - const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ?', [anhangId, id]); + const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]); if (!anhang) return res.status(404).send('Anhang nicht gefunden'); - const filePath = path.join(interneAnhaengeDir, anhang.pfad); + const filePath = path.join(userStorageDir(interneAnhaengeDir), anhang.pfad); if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden'); if (req.query.inline === '1') return serveInline(res, filePath, anhang.dateiname, anhang.mime); res.download(filePath, anhang.dateiname); @@ -2379,10 +2551,10 @@ initializeDatabase().then(async () => { app.post('/bewerbung/:id/interne-anhaenge/:anhangId/delete', async (req, res) => { try { const { id, anhangId } = req.params; - const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ?', [anhangId, id]); + const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]); if (anhang) { - fs.promises.unlink(path.join(interneAnhaengeDir, anhang.pfad)).catch(() => {}); - await dbRun('DELETE FROM interne_anhaenge WHERE id = ?', [anhangId]); + fs.promises.unlink(path.join(userStorageDir(interneAnhaengeDir), anhang.pfad)).catch(() => {}); + await dbRun('DELETE FROM interne_anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]); } res.redirect('/bewerbung/' + id); } catch (error) { @@ -2396,26 +2568,28 @@ initializeDatabase().then(async () => { app.post('/bewerbung/:id/generieren', async (req, res) => { try { const { id } = req.params; - const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]); + const U = uid(); + const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [id, U]); if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden'); // Persist the LLM notes the user provided (used as context during generation) if (typeof req.body.llm_notizen !== 'undefined') { - await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ?', [req.body.llm_notizen || '', id]); + await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ? AND user_id = ?', [req.body.llm_notizen || '', id, U]); } - const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]); + const anhaengeUserDir = userStorageDir(anhaengeDir); + const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, U]); for (const a of alte) { - fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {}); + fs.promises.unlink(path.join(anhaengeUserDir, a.pfad)).catch(() => {}); } - await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]); - await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?", [id]); + await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, U]); + await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ? AND user_id = ?", [id, U]); // Selected extra attachments (checkbox values); none by default. const anlagenIds = [].concat(req.body.anlage || []).map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n)); // Which documents to generate; nothing ticked = both (the default). const dokumente = normalizeDokumente(req.body.dokument); - await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ?', [dokumente.join(','), id]); + await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ? AND user_id = ?', [dokumente.join(','), id, U]); runGeneration(id, { anlagenIds, dokumente }); res.redirect('/bewerbung/' + id); } catch (error) { @@ -2432,7 +2606,7 @@ initializeDatabase().then(async () => { // Count of open offers, consumed by the header badge (no auth — just a number). app.get('/jobangebote/anzahl-offen', async (req, res) => { try { - const row = await dbGet("SELECT COUNT(*) as count FROM jobangebote WHERE status = 'offen'"); + const row = await dbGet("SELECT COUNT(*) as count FROM jobangebote WHERE user_id = ? AND status = 'offen'", [uid()]); res.json({ count: row ? row.count : 0 }); } catch (error) { res.status(500).json({ count: 0 }); @@ -2446,12 +2620,13 @@ initializeDatabase().then(async () => { const jobangebote = await dbAll( `SELECT j.*, b.datum AS bewerbung_datum FROM jobangebote j - LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id - WHERE j.status = 'offen' - ORDER BY j.created_at DESC, j.id DESC` + LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id + WHERE j.user_id = ? AND j.status = 'offen' + ORDER BY j.created_at DESC, j.id DESC`, + [uid()] ); jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); }); - const uebernommenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE status = 'uebernommen'"); + const uebernommenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE user_id = ? AND status = 'uebernommen'", [uid()]); res.render('jobangebote', { jobangebote, uebernommenCount: uebernommenRow ? uebernommenRow.c : 0, @@ -2472,12 +2647,13 @@ initializeDatabase().then(async () => { const uebernommen = await dbAll( `SELECT j.*, b.datum AS bewerbung_datum, b.status AS bewerbung_status FROM jobangebote j - LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id - WHERE j.status = 'uebernommen' - ORDER BY j.updated_at DESC, j.id DESC` + LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id + WHERE j.user_id = ? AND j.status = 'uebernommen' + ORDER BY j.updated_at DESC, j.id DESC`, + [uid()] ); uebernommen.forEach((j) => { j.labelsArr = parseLabels(j.labels); }); - const offenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE status = 'offen'"); + const offenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE user_id = ? AND status = 'offen'", [uid()]); res.render('jobangebote_uebernommen', { uebernommen, offenCount: offenRow ? offenRow.c : 0, @@ -2521,10 +2697,11 @@ initializeDatabase().then(async () => { const result = await dbRun( `INSERT INTO bewerbungen - (datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, + (user_id, datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, email_empfaenger, llm_notizen, labels, generierung_status) - VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`, + VALUES (?, ?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`, [ + currentUserId(), datum, sanitizeInput(angebot.firma), sanitizeInput(angebot.stelle), @@ -2540,19 +2717,19 @@ initializeDatabase().then(async () => { ); await dbRun( - 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', - [result.lastID, datum, 'Entwurf', `Automatisch aus Jobangebot übernommen (${angebot.quelle || 'drittanbieter'})`] + 'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)', + [currentUserId(), result.lastID, datum, 'Entwurf', `Automatisch aus Jobangebot übernommen (${angebot.quelle || 'drittanbieter'})`] ); await dbRun( - 'UPDATE jobangebote SET verknuepfte_bewerbung_id = ?, status = "uebernommen", updated_at = CURRENT_TIMESTAMP WHERE id = ?', - [result.lastID, angebot.id] + 'UPDATE jobangebote SET verknuepfte_bewerbung_id = ?, status = "uebernommen", updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', + [result.lastID, angebot.id, currentUserId()] ); return result.lastID; } app.post('/jobangebote/:id/uebernehmen', async (req, res) => { try { - const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]); + const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); if (!angebot) return res.status(404).send('Jobangebot nicht gefunden'); const bewerbungId = await uebernehmeAngebot(angebot); res.redirect('/bewerbung/' + bewerbungId); @@ -2566,7 +2743,7 @@ initializeDatabase().then(async () => { // (any length) before turning it into an application. app.post('/jobangebote/:id/bearbeiten', async (req, res) => { try { - const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]); + const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); if (!angebot) return res.status(404).send('Jobangebot nicht gefunden'); const b = req.body || {}; @@ -2575,7 +2752,7 @@ initializeDatabase().then(async () => { `UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?, beschreibung = ?, kontakt_email = ?, quelle_url = ?, anzeige_datum = ?, labels = ?, url_norm = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ?`, + WHERE id = ? AND user_id = ?`, [ sanitizeInput((b.firma || '').trim()) || angebot.firma, sanitizeInput((b.stelle || '').trim()) || angebot.stelle, @@ -2590,6 +2767,7 @@ initializeDatabase().then(async () => { serializeLabels(b.labels), blacklist.normalizeUrl(b.quelle_url || '') || null, req.params.id, + uid(), ] ); res.redirect('/jobangebote'); @@ -2604,10 +2782,10 @@ initializeDatabase().then(async () => { // even after deletion). The entry can be removed later on /blacklist. app.post('/jobangebote/:id/delete', async (req, res) => { try { - const offer = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]); + const offer = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); if (offer) { await autoBlacklistOffer(offer, 'Jobangebot gelöscht (Web-UI)'); - await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]); + await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); } res.redirect('/jobangebote'); } catch (error) { @@ -2620,7 +2798,8 @@ initializeDatabase().then(async () => { app.get('/blacklist', async (req, res) => { try { const eintraege = await dbAll( - 'SELECT * FROM jobangebote_blacklist ORDER BY created_at DESC, id DESC' + 'SELECT * FROM jobangebote_blacklist WHERE user_id = ? ORDER BY created_at DESC, id DESC', + [uid()] ); res.render('blacklist', { eintraege, blacklistTypen: blacklist.TYPES, hideSettings: false }); } catch (error) { @@ -2651,7 +2830,7 @@ initializeDatabase().then(async () => { // Remove a blacklist entry (offer may then be ingested again). app.post('/blacklist/:id/delete', async (req, res) => { try { - await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ?', [req.params.id]); + await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [req.params.id, uid()]); res.redirect('/blacklist'); } catch (error) { console.error('Error deleting blacklist entry:', error); @@ -2687,16 +2866,102 @@ initializeDatabase().then(async () => { } }); + // ----- Admin: Benutzerverwaltung (nur für Admins) ----- + // Admins legen neue Benutzer an, setzen Passwörter zurück und löschen + // Benutzer. Beim Löschen eines Benutzers löscht die DB per ON DELETE CASCADE + // alle seine Daten (Bewerbungen, E-Mails, Termine, Chat, Dateien liegen in + // data/// und müssen separat entfernt werden — siehe unten). + function requireAdmin(req, res, next) { + if (req.user && req.user.is_admin) return next(); + if (req.path.startsWith('/admin/') || req.xhr || (req.get('accept') || '').includes('application/json')) { + return res.status(403).send('Zugriff verweigert – nur für Administratoren.'); + } + res.status(403).send('Zugriff verweigert – nur für Administratoren.'); + } + + app.get('/admin', requireAdmin, async (req, res) => { + try { + const users = await dbAll( + 'SELECT id, username, is_admin, created_at, (SELECT COUNT(*) FROM bewerbungen b WHERE b.user_id = users.id) AS anzahl_bewerbungen FROM users ORDER BY is_admin DESC, id ASC' + ); + res.render('admin', { users, currentUserId: uid(), hideSettings: false }); + } catch (error) { + console.error('Admin list error:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Neuen Benutzer anlegen. + app.post('/admin/users', requireAdmin, async (req, res) => { + try { + const username = sanitizeInput((req.body.username || '').trim()); + const plain = req.body.password || ''; + const isAdmin = req.body.is_admin === '1' || req.body.is_admin === 'on'; + if (!username || !plain) return res.status(400).send('Benutzername und Passwort erforderlich.'); + if (username.length > 64) return res.status(400).send('Benutzername zu lang.'); + const dup = await dbGet('SELECT id FROM users WHERE username = ?', [username]); + if (dup) return res.status(409).send('Benutzername bereits vergeben.'); + await dbRun('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)', + [username, password.hash(plain), isAdmin ? 1 : 0]); + res.redirect('/admin'); + } catch (error) { + console.error('Admin create user error:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Passwort zurücksetzen. + app.post('/admin/users/:id/reset-password', requireAdmin, async (req, res) => { + try { + const id = Number(req.params.id); + const plain = req.body.password || ''; + if (!plain) return res.status(400).send('Passwort erforderlich.'); + await dbRun('UPDATE users SET password_hash = ? WHERE id = ?', [password.hash(plain), id]); + await dbRun('DELETE FROM sessions WHERE user_id = ?', [id]); // alle Sessions des Benutzers ungültigen + res.redirect('/admin'); + } catch (error) { + console.error('Admin reset password error:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Benutzer löschen (mit allen Daten + Dateien). Der letzte Admin darf nicht + // gelöscht werden, sonst sperrt man sich selbst aus. + app.post('/admin/users/:id/delete', requireAdmin, async (req, res) => { + try { + const id = Number(req.params.id); + if (id === Number(req.user.id)) return res.status(400).send('Man kann sich nicht selbst löschen.'); + const target = await dbGet('SELECT is_admin FROM users WHERE id = ?', [id]); + if (!target) return res.status(404).send('Benutzer nicht gefunden.'); + if (target.is_admin) { + const adminCount = await dbGet('SELECT COUNT(*) as count FROM users WHERE is_admin = 1'); + if (adminCount && adminCount.count <= 1) return res.status(400).send('Der letzte Admin darf nicht gelöscht werden.'); + } + // Dateien des Benutzers auf der Festplatte entfernen (die DB-Zeilen löscht + // ON DELETE CASCADE). + for (const dir of migrate.STORAGE_DIRS) { + const base = path.join(dataDir, dir, String(id)); + if (fs.existsSync(base)) fs.rmSync(base, { recursive: true, force: true }); + } + await dbRun('DELETE FROM users WHERE id = ?', [id]); + res.redirect('/admin'); + } catch (error) { + console.error('Admin delete user error:', error); + res.status(500).send('Serverfehler'); + } + }); + // ----- Conversational KI-Chat (Ollama, streaming) ----- // Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the // assistant answer is streamed back via Server-Sent Events. async function gatherChatContext() { const [settings, profilRows, prompts] = await Promise.all([ - dbGet('SELECT name FROM settings WHERE id = 1'), + dbGet('SELECT name FROM settings WHERE user_id = ?', [uid()]), dbAll( `SELECT inhalt FROM basis_dokumente - WHERE typ IN ('Lebenslauf', 'Profil/Kurzprofil') AND inhalt IS NOT NULL AND inhalt != '' - ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END` + WHERE user_id = ? AND typ IN ('Lebenslauf', 'Profil/Kurzprofil') AND inhalt IS NOT NULL AND inhalt != '' + ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END`, + [uid()] ), loadPrompts(), ]); @@ -2778,9 +3043,9 @@ initializeDatabase().then(async () => { const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`; const rows = await dbAll( `SELECT id, firma, stelle, status, datum, ort FROM bewerbungen - WHERE firma LIKE ? ESCAPE '\\' OR stelle LIKE ? ESCAPE '\\' + WHERE user_id = ? AND (firma LIKE ? ESCAPE '\\' OR stelle LIKE ? ESCAPE '\\') ORDER BY datum DESC, created_at DESC LIMIT 20`, - [like, like] + [uid(), like, like] ); return { treffer: rows }; } @@ -2788,8 +3053,8 @@ initializeDatabase().then(async () => { const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 40); const status = String(a.status || '').trim(); const sql = `SELECT id, firma, stelle, status, datum, ort FROM bewerbungen - ${status ? 'WHERE status = ?' : ''} ORDER BY datum DESC, created_at DESC LIMIT ?`; - const rows = await dbAll(sql, status ? [status, limit] : [limit]); + WHERE user_id = ?${status ? ' AND status = ?' : ''} ORDER BY datum DESC, created_at DESC LIMIT ?`; + const rows = await dbAll(sql, status ? [uid(), status, limit] : [uid(), limit]); return { bewerbungen: rows }; } if (name === 'bewerbung_detail') { @@ -2801,14 +3066,14 @@ initializeDatabase().then(async () => { j.kontakt_email AS ja_kontakt, j.ansprechpartner AS ja_ansprech, j.beschreibung AS ja_beschreibung FROM bewerbungen b LEFT JOIN jobangebote j ON j.verknuepfte_bewerbung_id = b.id - WHERE b.id = ?`, - [id] + WHERE b.id = ? AND b.user_id = ?`, + [id, uid()] ); if (!row) return { error: 'nicht gefunden' }; const em = await dbAll( - `SELECT direction, subject, from_addr FROM emails WHERE bewerbung_id = ? + `SELECT direction, subject, from_addr FROM emails WHERE bewerbung_id = ? AND user_id = ? ORDER BY email_date DESC, created_at DESC LIMIT 8`, - [id] + [id, uid()] ); return { id: row.id, firma: row.firma, stelle: row.stelle, status: row.status, datum: row.datum, ort: row.ort, @@ -2841,14 +3106,17 @@ initializeDatabase().then(async () => { if (!chat.isConfigured()) return res.status(503).send('KI-Chat deaktiviert – kein Ollama-API-Schlüssel konfiguriert (unter „Einstellungen“ eintragen).'); try { const threads = await dbAll( - 'SELECT id, titel, updated_at FROM chat_threads ORDER BY updated_at DESC' + 'SELECT id, titel, updated_at FROM chat_threads WHERE user_id = ? ORDER BY updated_at DESC', + [uid()] ); const activeId = req.query.thread ? Number(req.query.thread) : (threads[0] && threads[0].id); let messages = []; if (activeId) { messages = await dbAll( - 'SELECT id, role, content, created_at FROM chat_messages WHERE thread_id = ? ORDER BY id ASC', - [activeId] + `SELECT m.id, m.role, m.content, m.created_at FROM chat_messages m + JOIN chat_threads t ON t.id = m.thread_id + WHERE m.thread_id = ? AND t.user_id = ? ORDER BY m.id ASC`, + [activeId, uid()] ); } res.render('chat', { @@ -2865,7 +3133,7 @@ initializeDatabase().then(async () => { app.post('/chat/api/threads', async (req, res) => { try { const titel = sanitizeInput((req.body.titel || '').trim()).slice(0, 120) || null; - const { lastID } = await dbRun('INSERT INTO chat_threads (titel) VALUES (?)', [titel]); + const { lastID } = await dbRun('INSERT INTO chat_threads (user_id, titel) VALUES (?, ?)', [uid(), titel]); res.json({ id: lastID, titel }); } catch (error) { console.error('Create thread error:', error); @@ -2876,7 +3144,7 @@ initializeDatabase().then(async () => { // Delete a thread (cascades to its messages). app.delete('/chat/api/threads/:id', async (req, res) => { try { - await dbRun('DELETE FROM chat_threads WHERE id = ?', [Number(req.params.id)]); + await dbRun('DELETE FROM chat_threads WHERE id = ? AND user_id = ?', [Number(req.params.id), uid()]); res.json({ ok: true }); } catch (error) { console.error('Delete thread error:', error); @@ -2888,8 +3156,8 @@ initializeDatabase().then(async () => { app.patch('/chat/api/threads/:id', async (req, res) => { try { const titel = sanitizeInput((req.body.titel || '').trim()).slice(0, 120); - await dbRun('UPDATE chat_threads SET titel = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', - [titel, Number(req.params.id)]); + await dbRun('UPDATE chat_threads SET titel = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', + [titel, Number(req.params.id), uid()]); res.json({ ok: true }); } catch (error) { console.error('Rename thread error:', error); @@ -2906,21 +3174,21 @@ initializeDatabase().then(async () => { let thread; try { - thread = await dbGet('SELECT id, titel FROM chat_threads WHERE id = ?', [threadId]); + thread = await dbGet('SELECT id, titel FROM chat_threads WHERE id = ? AND user_id = ?', [threadId, uid()]); } catch (e) { /* fall through */ } if (!thread) return res.status(404).json({ error: 'Thread nicht gefunden.' }); // Persist the user message, then load the full prior history for context. try { - await dbRun('INSERT INTO chat_messages (thread_id, role, content) VALUES (?, ?, ?)', - [threadId, 'user', userText]); - await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [threadId]); + await dbRun('INSERT INTO chat_messages (user_id, thread_id, role, content) VALUES (?, ?, ?, ?)', + [uid(), threadId, 'user', userText]); + await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [threadId, uid()]); // Auto-title the thread from the first user message, if untitled. if (!thread.titel) { - const first = await dbGet('SELECT content FROM chat_messages WHERE thread_id = ? ORDER BY id ASC LIMIT 1', [threadId]); + const first = await dbGet('SELECT content FROM chat_messages WHERE thread_id = ? AND user_id = ? ORDER BY id ASC LIMIT 1', [threadId, uid()]); if (first) { const t = first.content.slice(0, 60).replace(/\s+/g, ' ').trim(); - if (t) await dbRun('UPDATE chat_threads SET titel = ? WHERE id = ? AND (titel IS NULL OR titel = "")', [t, threadId]); + if (t) await dbRun('UPDATE chat_threads SET titel = ? WHERE id = ? AND user_id = ? AND (titel IS NULL OR titel = "")', [t, threadId, uid()]); } } } catch (error) { @@ -2931,8 +3199,8 @@ initializeDatabase().then(async () => { let history; try { history = await dbAll( - 'SELECT role, content FROM chat_messages WHERE thread_id = ? ORDER BY id ASC', - [threadId] + 'SELECT role, content FROM chat_messages WHERE thread_id = ? AND user_id = ? ORDER BY id ASC', + [threadId, uid()] ); } catch (error) { return res.status(500).json({ error: 'Serverfehler' }); @@ -2985,11 +3253,11 @@ initializeDatabase().then(async () => { const saved = assistantText || '(keine Antwort)'; try { const { lastID } = await dbRun( - 'INSERT INTO chat_messages (thread_id, role, content) VALUES (?, ?, ?)', - [threadId, 'assistant', saved] + 'INSERT INTO chat_messages (user_id, thread_id, role, content) VALUES (?, ?, ?, ?)', + [uid(), threadId, 'assistant', saved] ); - await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [threadId]); - const threadRow = await dbGet('SELECT titel FROM chat_threads WHERE id = ?', [threadId]); + await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [threadId, uid()]); + const threadRow = await dbGet('SELECT titel FROM chat_threads WHERE id = ? AND user_id = ?', [threadId, uid()]); send({ type: 'done', messageId: lastID, content: saved, titel: threadRow && threadRow.titel }); } catch (error) { send({ type: 'error', message: 'Antwort konnte nicht gespeichert werden.' }); @@ -2998,9 +3266,9 @@ initializeDatabase().then(async () => { }); // ----- Third-party REST API (/api/v1) + OpenAPI/Swagger ----- - // API key for third-party software. When unset, the API responds 503 on - // every endpoint except /health — it never silently exposes data. Read from - // the DB via config so an edit on /einstellungen is picked up live. + // Per-user API key: each user may set their own API_TOKEN on /einstellungen. + // The X-API-Key header resolves to the owning user, and every request then + // operates only on that user's data (see lib/api.js). const apiToken = () => config.get('API_TOKEN') || ''; app.use('/api/v1', createExternalApi({ dbGet, @@ -3013,6 +3281,7 @@ initializeDatabase().then(async () => { runGeneration, anhaengeDir, emailAnhaengeDir, + userStorageDir, apiToken, })); @@ -3071,31 +3340,42 @@ initializeDatabase().then(async () => { // Start server app.listen(PORT, () => { console.log(`Server läuft auf http://localhost:${PORT}`); - if (apiToken()) console.log('REST-API (/api/v1) aktiv – Swagger unter /swagger'); - else console.log('REST-API deaktiviert – API_TOKEN fehlt (Swagger unter /swagger weiterhin verfügbar)'); + console.log('REST-API (/api/v1): pro Benutzer über X-API-Key (Token in den Einstellungen je Benutzer gesetzt) – Swagger unter /swagger'); }); - // E-Mail: verify SMTP on startup and poll the IMAP inbox for replies. - if (mailer.isConfigured()) { - mailer.verify() - .then(() => console.log(`E-Mail aktiv: Versand über ${mailer.config().host} als ${mailer.fromAddress()}`)) - .catch((e) => console.warn('E-Mail SMTP-Verbindung nicht verifizierbar:', e.message)); - const pollMs = Math.max(60000, Number(config.get('MAIL_POLL_MS')) || 180000); - setTimeout(() => { pollInbox().catch(() => {}); }, 8000); // initial fetch after boot - setInterval(() => { pollInbox().catch(() => {}); }, pollMs); // periodic fetch - } else { - console.log('E-Mail nicht konfiguriert (MAIL_HOST/MAIL_USER/MAIL_PASSWORD fehlen) - Versand/Empfang deaktiviert.'); + // Background loops are per-user: every user owns their own mail/calendar + // config, so polling iterates all users and runs each user's poll inside + // that user's context (config.get() then resolves to the user's own values). + async function forEachUser(fn) { + const users = await dbAll('SELECT id, username, is_admin FROM users ORDER BY id'); + for (const u of users) { + try { await config.ensureLoaded(u.id); } catch (e) { continue; } + try { await userContext.run(u, fn); } catch (e) { /* errors logged inside fn */ } + } } - // Calendar: reconcile our appointments with the SOGo CalDAV calendar. - if (caldav.isConfigured()) { - console.log(`Kalender aktiv: CalDAV ${caldav.collectionUrl()}`); - const calPoll = Math.max(60000, Number(config.get('CALDAV_POLL_MS')) || 300000); - setTimeout(() => { refreshCaldav().catch(() => {}); }, 10000); // initial sync after boot - setInterval(() => { refreshCaldav().catch(() => {}); }, calPoll); // periodic reconcile - } else { - console.log('Kalender nicht konfiguriert (CALDAV_URL fehlt) - Kalender-Funktionen deaktiviert.'); + async function pollInboxAllUsers() { + await forEachUser(async () => { + try { await pollInbox(); } catch (e) { /* logged inside pollInbox */ } + }); } + async function refreshCaldavAllUsers() { + await forEachUser(async () => { + try { await refreshCaldav(); } catch (e) { /* logged inside refreshCaldav */ } + }); + } + + // E-Mail: verify SMTP on startup (per-user, but verification only needs the + // first configured user to confirm reachability) and poll every user's IMAP + // inbox for replies on the configured interval. + const pollMs = Math.max(60000, Number(config.get('MAIL_POLL_MS')) || 180000); + setTimeout(() => { pollInboxAllUsers().catch(() => {}); }, 8000); // initial fetch after boot + setInterval(() => { pollInboxAllUsers().catch(() => {}); }, pollMs); // periodic fetch + + // Calendar: reconcile every user's appointments with their CalDAV calendar. + const calPoll = Math.max(60000, Number(config.get('CALDAV_POLL_MS')) || 300000); + setTimeout(() => { refreshCaldavAllUsers().catch(() => {}); }, 10000); // initial sync after boot + setInterval(() => { refreshCaldavAllUsers().catch(() => {}); }, calPoll); // periodic reconcile // Handle 404 app.use((req, res) => { diff --git a/views/admin.ejs b/views/admin.ejs new file mode 100644 index 0000000..1463e8d --- /dev/null +++ b/views/admin.ejs @@ -0,0 +1,111 @@ + + + + <%- include('partials/head') %> + + + <%- include('partials/header') %> + +
+ + + + + Zurück zur Übersicht + + +

Benutzerverwaltung

+

Benutzer anlegen, Passwörter zurücksetzen und Benutzer inkl. aller ihrer Daten löschen.

+ + +
+

Neuen Benutzer anlegen

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+

Bestehende Benutzer

+
+
+ + + + + + + + + + + <% users.forEach(function(u) { %> + + + + + + + + + + + <% }); %> + +
BenutzernameRolleBewerbungenErstelltAktionen
+ <%= u.username %> + <% if (u.id === currentUserId) { %>(du)<% } %> + + <% if (u.is_admin) { %> + Admin + <% } else { %> + Benutzer + <% } %> + <%= u.anzahl_bewerbungen %><%= u.created_at %> + + <% if (u.id !== currentUserId) { %> +
+ +
+ <% } %> +
+ + + + + \ No newline at end of file diff --git a/views/login.ejs b/views/login.ejs new file mode 100644 index 0000000..6d2a139 --- /dev/null +++ b/views/login.ejs @@ -0,0 +1,47 @@ + + + + <%- include('partials/head') %> + + + +
+
+
+ + + +

Bewerbungs-Tracker

+

Bitte anmelden

+
+ + <% if (error) { %> +
+ <%= error %> +
+ <% } %> + +
+
+ + +
+
+ + +
+ +
+
+

Multi-User-Plattform

+
+ + \ No newline at end of file diff --git a/views/partials/header.ejs b/views/partials/header.ejs index f186220..3a4c121 100644 --- a/views/partials/header.ejs +++ b/views/partials/header.ejs @@ -63,6 +63,18 @@ + <% if (typeof user !== 'undefined' && user && user.is_admin) { %> + + + + + + + + <% } %> +
+ + <% if (typeof user !== 'undefined' && user) { %> + +
+ + + + + + + +
+ <% } %>