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 // (git-ignored) .env file into process.env without overwriting existing vars. (function loadEnv() { try { const envPath = path.join(__dirname, '.env'); if (!fs.existsSync(envPath)) return; for (const raw of fs.readFileSync(envPath, 'utf8').split('\n')) { const line = raw.trim(); if (!line || line.startsWith('#')) continue; const eq = line.indexOf('='); if (eq === -1) continue; const key = line.slice(0, eq).trim(); let val = line.slice(eq + 1).trim(); if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } if (key && !(key in process.env)) process.env[key] = val; } } catch (e) { console.warn('Konnte .env nicht laden:', e.message); } })(); const { generateApplicationDocuments, generateEmailReply, renderDesignVorschau, DOKUMENT_TYPEN, normalizeDokumente, } = require('./lib/documents'); const chat = require('./lib/chat'); const promptStore = require('./lib/prompts'); const designStore = require('./lib/design'); const mailer = require('./lib/mailer'); const { createExternalApi } = require('./lib/api'); const { buildOpenApiSpec } = require('./lib/openapi'); 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; // Shared option lists (used in multiple views) const ART_OPTIONS = [ 'E-Mail', 'Online-Portal', 'Indeed', 'StepStone', 'Firmenwebsite', 'Post', 'Initiativbewerbung', 'Arbeitsagentur', 'Sonstiges' ]; const STATUS_OPTIONS = [ 'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', 'Vorstellungsgespräch', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung' ]; // Base document types the user can provide as a foundation for AI tailoring const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges']; // Pick the application "source" (art) for a browser-captured job. Honour an // explicit value from the extension, otherwise infer it from the URL host so a // capture from any website is labelled sensibly. function deriveArt(url, provided) { if (provided && ART_OPTIONS.includes(provided)) return provided; const host = (String(url || '').match(/^https?:\/\/([^/]+)/i) || [, ''])[1].toLowerCase(); if (!host) return 'Sonstiges'; if (host.includes('indeed')) return 'Indeed'; if (host.includes('stepstone')) return 'StepStone'; if (host.includes('arbeitsagentur')) return 'Arbeitsagentur'; if (/(linkedin|xing|monster|stellenanzeigen|kimeta|glassdoor|jobware|meinestadt|jobs\.|karriere\.)/.test(host)) return 'Online-Portal'; return 'Firmenwebsite'; } // Middleware app.use(express.json({ limit: '2mb' })); app.use(express.urlencoded({ extended: true, limit: '2mb' })); app.use(express.static(path.join(__dirname, 'public'))); // Allow the browser extension (running on indeed.com) to call the import API. // Kept narrow: only the extension-facing endpoints need cross-origin access. app.use('/api/indeed-import', (req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'POST, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') return res.sendStatus(204); next(); }); // Set EJS as template engine 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)) { fs.mkdirSync(dataDir, { recursive: true }); } // Directory for generated attachment files (application documents) const anhaengeDir = path.join(dataDir, 'anhaenge'); if (!fs.existsSync(anhaengeDir)) { fs.mkdirSync(anhaengeDir, { recursive: true }); } // Directory for attachments received via IMAP (reply e-mails). const emailAnhaengeDir = path.join(dataDir, 'email_anhaenge'); 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. function serveInline(res, filePath, filename, mime) { const type = mime || { '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', }[path.extname(filename || filePath).toLowerCase()]; if (type) res.type(type); const safe = String(filename || 'datei').replace(/["\r\n]/g, ''); res.setHeader('Content-Disposition', `inline; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`); res.sendFile(filePath); } // --- E-mail display + reply helpers -------------------------------------- // A stored message is rendered as HTML when we have an HTML body (or the plain // body is actually HTML markup — some senders put HTML in the text/plain part). // Otherwise it is shown as plain text. HTML is displayed inside a sandboxed // iframe (see the views), so scripts never run. function looksLikeHtml(s) { return /<(?:!doctype|html|body|div|table|p|br|span|a|img|ul|ol|h[1-6])\b|<\/[a-z]/i.test(String(s || '')); } function emailDisplayHtml(e) { if (e.body_html && e.body_html.trim()) return e.body_html; if (e.body_text && looksLikeHtml(e.body_text)) return e.body_text; return null; } // Wrap raw e-mail HTML in a minimal document for the sandboxed iframe: a white // background, readable defaults, images constrained to the width and links that // open in a new tab. No scripts are enabled by the iframe sandbox. function buildEmailSrcdoc(html) { return '' + '' + '' + String(html || '') + ''; } // Plain-text version of a message body, used as the source for reply quoting. function emailPlainText(e) { const html = emailDisplayHtml(e); if (html && (!e.body_text || looksLikeHtml(e.body_text))) return mailer.htmlToText(html); return String(e.body_text || ''); } // Build a mail-client style quote of a received message: an attribution line // followed by the original body with every line prefixed by "> ". function buildReplyQuote(e) { const d = e.email_date ? new Date(e.email_date) : null; const when = d && !isNaN(d.getTime()) ? d.toLocaleString('de-DE') : ''; const who = String(e.from_addr || '').trim(); const src = emailPlainText(e).replace(/\r\n/g, '\n').replace(/\s+$/, ''); const quoted = src.split('\n').map((l) => '> ' + l).join('\n'); const attribution = who ? `Am ${when} schrieb ${who}:` : (when ? `Am ${when}:` : ''); return (attribution ? attribution + '\n\n' : '') + quoted; } // Attach display fields (and, for received mail, a reply quote) to each row. function decorateEmails(emails) { emails.forEach((e) => { const html = emailDisplayHtml(e); e.display_srcdoc = html ? buildEmailSrcdoc(html) : null; if (e.direction !== 'out') e.reply_quote = buildReplyQuote(e); }); } // Directory for static extra attachments (e.g. Zeugnisse) the user uploads once // and that are sent along with every generated application. const basisAnhaengeDir = path.join(dataDir, 'basis_anhaenge'); if (!fs.existsSync(basisAnhaengeDir)) { fs.mkdirSync(basisAnhaengeDir, { recursive: true }); } // Multipart upload for those static attachments (stored under the user's subdir) const uploadBasisAnhang = multer({ storage: multer.diskStorage({ 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}`); }, }), limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB }).single('datei'); // Directory for private attachments the user keeps alongside the internal // notes of an application. These are never exported into the PDF and never // sent with an application — they are for the user only. const interneAnhaengeDir = path.join(dataDir, 'interne_anhaenge'); if (!fs.existsSync(interneAnhaengeDir)) { fs.mkdirSync(interneAnhaengeDir, { recursive: true }); } const uploadInterneAnhang = multer({ storage: multer.diskStorage({ 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}`); }, }), limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB }).single('datei'); // Directory + uploader for the applicant's signature image (used in the letter) const signaturDir = path.join(dataDir, 'signatur'); if (!fs.existsSync(signaturDir)) { fs.mkdirSync(signaturDir, { recursive: true }); } const uploadSignatur = multer({ storage: multer.diskStorage({ 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}`); }, }), limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)), }).single('signatur'); // The single stored signature file, if any (in the current user's subdir). function currentSignaturFile() { try { 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; } } // Read the signature as a data URL + jsPDF format, for embedding in the letter. function loadSignatur() { const file = currentSignaturFile(); if (!file) return null; try { const ext = path.extname(file).toLowerCase(); const format = (ext === '.jpg' || ext === '.jpeg') ? 'JPEG' : 'PNG'; const mime = format === 'JPEG' ? 'image/jpeg' : 'image/png'; const b64 = fs.readFileSync(file).toString('base64'); return { dataUrl: `data:${mime};base64,${b64}`, format }; } catch (e) { return null; } } // Directory + uploader for the applicant's portrait photo (used in the CV) const fotoDir = path.join(dataDir, 'bewerberfoto'); if (!fs.existsSync(fotoDir)) { fs.mkdirSync(fotoDir, { recursive: true }); } const uploadFoto = multer({ storage: multer.diskStorage({ 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}`); }, }), limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)), }).single('foto'); // The single stored photo file, if any (in the current user's subdir). function currentFotoFile() { try { 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; } } // Read the photo as a data URL + jsPDF format, for embedding in the CV. function loadFoto() { const file = currentFotoFile(); if (!file) return null; try { const ext = path.extname(file).toLowerCase(); const format = (ext === '.jpg' || ext === '.jpeg') ? 'JPEG' : 'PNG'; const mime = format === 'JPEG' ? 'image/jpeg' : 'image/png'; const b64 = fs.readFileSync(file).toString('base64'); return { dataUrl: `data:${mime};base64,${b64}`, format }; } catch (e) { return null; } } // Database setup const dbPath = path.join(dataDir, 'bewerbungen.db'); const db = new sqlite3.Database(dbPath); // Sanitize input to prevent XSS function sanitizeInput(input) { if (typeof input !== 'string') return input; return input .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Promise wrapper for db operations function dbGet(sql, params = []) { return new Promise((resolve, reject) => { db.get(sql, params, (err, result) => { if (err) reject(err); else resolve(result); }); }); } function dbAll(sql, params = []) { return new Promise((resolve, reject) => { db.all(sql, params, (err, results) => { if (err) reject(err); else resolve(results); }); }); } function dbRun(sql, params = []) { return new Promise((resolve, reject) => { db.run(sql, params, function(err) { if (err) reject(err); else resolve({ lastID: this.lastID, changes: this.changes }); }); }); } // --------------------------------------------------------------------------- // 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) // --------------------------------------------------------------------------- // Insert one prepared blacklist entry (see lib/blacklist.buildManual/AutoEntry). async function insertBlacklistEntry(entry) { const cols = blacklist.COLUMNS; const placeholders = cols.map(() => '?').join(', '); const values = cols.map((c) => (entry[c] === undefined ? null : entry[c])); return dbRun( `INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${placeholders})`, [currentUserId(), ...values] ); } // Auto-blacklist an offer row so it can never be ingested again, then it is safe // 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 WHERE user_id = ?', [currentUserId()]); if (blacklist.matchBlacklist(rows, offer)) return; // already blocked await insertBlacklistEntry(blacklist.buildAutoEntry(offer, grund)); } // --------------------------------------------------------------------------- // Application calendar (CalDAV) helpers // --------------------------------------------------------------------------- // Upcoming appointments (not yet ended), newest first, for the dashboard widget. async function upcomingTermine(limit = 6) { const now = new Date().toISOString(); 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 t.user_id = ? AND COALESCE(t.ende, t.start) >= ? ORDER BY t.start ASC LIMIT ?`, [currentUserId(), now, limit] ); } // Reconcile our tracked appointments with the SOGo calendar: reflect remote // edits and drop entries deleted remotely. Cheap ctag check first. Best-effort. async function refreshCaldav() { if (!caldav.isConfigured()) return; const ctag = await caldav.getCtag().catch(() => null); if (ctag) { const prev = await getState('caldav_ctag'); if (prev && prev === ctag) return; } const from = new Date(Date.now() - 24 * 3600 * 1000); const to = new Date(Date.now() + 180 * 24 * 3600 * 1000); 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 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 = ? 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 = ? 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, currentUserId(), ] ); } } if (ctag) await setState('caldav_ctag', ctag); } // --------------------------------------------------------------------------- // E-Mail correspondence: IMAP polling, storing & matching incoming replies // --------------------------------------------------------------------------- async function getState(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 (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value', [currentUserId(), key, String(value)] ); } // The user's KI prompt overrides as { key: text }. Keys without a row keep the // default from lib/prompts.js. Read fresh on every generation so an edit takes // effect immediately, without a restart. async function loadPrompts() { try { 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); return {}; } } // The user's design overrides as { key: value }. Unset keys keep the defaults // from lib/design.js. Read fresh per generation, like the prompts. async function loadDesign() { try { 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); return {}; } } // The user's personal details (Persönliche Angaben). A user who has never saved // the form has no settings row at all — that is a valid state, not an error, so // this returns an empty object rather than undefined. Same contract as // loadPrompts()/loadDesign(): "no row" means "nothing set yet". Every consumer // (views, PDF generation, KI context) reads individual fields off the result, // so they all degrade to empty instead of crashing. async function loadSettings() { try { const row = await dbGet('SELECT * FROM settings WHERE user_id = ?', [currentUserId()]); return row || {}; } catch (e) { console.error('Konnte Persönliche Angaben nicht laden:', e.message); return {}; } } // Match an incoming message to an application: first via In-Reply-To/References // pointing at one of our sent messages, then by sender = a previous recipient. async function matchBewerbung(msg) { const refs = [] .concat((msg.inReplyTo || '').split(/\s+/)) .concat((msg.references || '').split(/\s+/)) .map((r) => r.replace(/[<>]/g, '').trim()) .filter(Boolean); for (const mid of refs) { const row = await dbGet( "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 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; } return null; } 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; try { const lastUid = Number(await getState('mail_last_uid')) || 0; const { messages, maxUid } = await mailer.fetchSince(lastUid); let stored = 0; 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 user_id = ? AND message_id = ?', [currentUserId(), m.messageId]); if (dup) continue; } const bewId = await matchBewerbung(m); const result = await dbRun( `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, ?)`, [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())] ); // Persist attachments to disk + link rows. for (const att of (m.attachments || [])) { 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(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++; } if (maxUid > lastUid) await setState('mail_last_uid', maxUid); return { fetched: stored }; } catch (err) { console.error('IMAP-Abruf fehlgeschlagen:', err.message); return { fetched: 0, error: err.message }; } finally { polling = false; } } // --------------------------------------------------------------------------- // Duplicate-application guard: spot an existing application for the same job so // the user doesn't accidentally apply twice. Matches on a normalised source URL // (strongest signal for imported postings) or an identical company + role. It // only warns — legitimate re-applications stay possible via a "force" flag. // --------------------------------------------------------------------------- function normText(s) { return String(s == null ? '' : s) .toLowerCase() .normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip diacritics (ä→a …) .replace(/[^a-z0-9]+/g, ' ') .trim(); } function normUrl(u) { const raw = String(u == null ? '' : u).trim(); if (!raw) return ''; try { const url = new URL(raw); const host = url.hostname.replace(/^www\./, '').toLowerCase(); // A job-identifying query param (Indeed jk/vjk, generic ids) pins the posting // regardless of tracking params or which path it was opened from. const idKeys = ['jk', 'vjk', 'jobkey', 'jobid', 'vacancyid', 'stellenangebotid', 'positionid', 'offerid', 'id']; let idPart = ''; for (const [k, v] of url.searchParams.entries()) { if (v && idKeys.includes(k.toLowerCase())) { idPart = k.toLowerCase() + '=' + v.toLowerCase(); break; } } const pathn = url.pathname.replace(/\/+$/, '').toLowerCase(); return idPart ? host + '|' + idPart : host + pathn; } catch (e) { return raw.toLowerCase().replace(/[?#].*$/, ''); } } // 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 WHERE user_id = ?', [currentUserId()]); const fUrl = normUrl(quelle_url); const fFirma = normText(firma); const fStelle = normText(stelle); const matches = []; for (const r of rows) { if (excludeId && Number(r.id) === Number(excludeId)) continue; let reason = null; if (fUrl && normUrl(r.quelle_url) === fUrl) reason = 'url'; else if (fFirma && fStelle && normText(r.firma) === fFirma && normText(r.stelle) === fStelle) reason = 'firma_stelle'; if (reason) matches.push({ id: r.id, datum: r.datum, firma: r.firma, stelle: r.stelle, ort: r.ort, status: r.status, reason }); } return matches; } // 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 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 = ? 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 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] || []; }); return applications; } // Run the AI document generation for one application (async, fire-and-forget). // Loads the base documents + user settings, asks the LLM to tailor them to the // job, writes the resulting PDFs to disk and links them as attachments. async function runGeneration(bewerbungId, options = {}) { try { 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 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 loadSettings(); const prompts = await loadPrompts(); const design = await loadDesign(); // Only the explicitly selected extra attachments are enclosed (default: none). const anlagenIds = Array.isArray(options.anlagenIds) ? options.anlagenIds.map(Number) : []; const selectedAnhaenge = basisAnhaenge.filter((a) => anlagenIds.includes(a.id)); // Which documents to produce; unset means both (the default everywhere). const dokumente = normalizeDokumente(options.dokumente); const { documents, email } = await generateApplicationDocuments({ job: { firma: bewerbung.firma, stelle: bewerbung.stelle, ort: bewerbung.ort, quelle_url: bewerbung.quelle_url, stellenbeschreibung: bewerbung.stellenbeschreibung, }, basisDokumente, settings, prompts, design, dokumente, // Names of the selected attachments so the cover letter (and the LLM) lists // exactly these under "Anlagen". zusatzAnlagen: selectedAnhaenge.map((a) => a.name || a.dateiname), // Free-text notes (company address, contact person, extra context) for the LLM. llmNotizen: bewerbung.llm_notizen || '', // Signature image placed under the closing salutation (instead of the typed name). signatur: loadSignatur(), // Applicant photo placed in the CV header (top-right), optional. bewerbungsfoto: loadFoto(), }); let seq = 0; const storeAnhang = async (name, filename, mime, buffer) => { const stored = `${bewerbungId}_${Date.now()}_${seq++}_${filename}`; fs.writeFileSync(path.join(userStorageDir(anhaengeDir), stored), buffer); await dbRun( 'INSERT INTO anhaenge (user_id, bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?, ?)', [U, bewerbungId, name, filename, mime, stored] ); }; // Generated (AI) documents for (const doc of documents) { await storeAnhang(doc.name, doc.filename, doc.mime, doc.buffer); } // Selected extra attachments (e.g. Zeugnisse) — copied as-is for (const ba of selectedAnhaenge) { 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 = ? 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 = ? AND user_id = ?", [String(error.message || 'Unbekannter Fehler'), bewerbungId, currentUserId()] ).catch(() => {}); } } // 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'); // 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)'); // 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 }); // 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)'); // 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 ) `); // 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 ) `); // 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 ) `); // 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 ) `); // 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 ) `); // 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)'); 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 ) `); // 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 ) `); // 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)'); // 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 ) `); // 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 ) `); // 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)'); // 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'); // Load configuration from the DB (migrates any still-present .env values // once). Must run before the boot checks below (mailer/caldav configured?) and // 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; 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 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 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); }); // Upcoming calendar appointments for the dashboard widget. const kommendeTermine = await upcomingTermine(6); // Get statistics 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 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 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 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 // application was created in an earlier month. const exportMonths = await dbAll(` SELECT DISTINCT strftime("%Y-%m", eff) as yearmonth, strftime("%m", eff) as month, strftime("%Y", eff) as year FROM ( SELECT COALESCE( (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, statistics: { total: totalCount ? totalCount.count : 0, byArt, byStatus }, availableMonths, exportMonths, currentFilter: { month, year }, kommendeTermine, caldavTz: caldav.TZ, artOptions: ART_OPTIONS, statusOptions: STATUS_OPTIONS, labelOptions: LABEL_OPTIONS }); } catch (error) { console.error('Error:', error); res.status(500).send('Serverfehler'); } }); // Get single application app.get('/api/bewerbungen/:id', async (req, res) => { try { const { id } = req.params; 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); res.status(500).json({ error: 'Serverfehler' }); } }); // Get settings app.get('/api/settings', async (req, res) => { try { const settings = await loadSettings(); res.json(settings); } catch (error) { console.error('Error getting settings:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // Save settings app.post('/api/settings', async (req, res) => { try { const { name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum } = req.body; const U = uid(); await dbRun( `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) ] ); res.json({ success: true }); } catch (error) { console.error('Error saving settings:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // ----- Indeed import (called by the browser extension) ----- app.post('/api/indeed-import', async (req, res) => { try { const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url, art } = req.body || {}; if (!firma || !stelle) { return res.status(400).json({ error: 'Firma und Stelle sind erforderlich.' }); } // Source of the capture: honour an explicit art, else infer from the URL. const quelle = deriveArt(quelle_url, art); // Duplicate guard: don't silently import the same posting twice. const forceImport = req.body.force === true || req.body.force === 'true'; if (!forceImport) { const dups = await findDuplicateApplications({ firma, stelle, quelle_url }); if (dups.length) { return res.status(409).json({ duplicate: true, matches: dups, error: 'Für diese Stelle existiert bereits eine Bewerbung.', }); } } const datum = new Date().toISOString().split('T')[0]; // Keep the extra details (location, salary, source) visible in the notes too. const notizParts = [ ort ? `Ort: ${ort}` : null, gehalt ? `Gehalt: ${gehalt}` : null, quelle_url ? `Quelle: ${quelle_url}` : null, ].filter(Boolean); const notizen = notizParts.join('\n'); // Stored raw: every view renders these through EJS `<%= %>` (auto-escaped), // so this is XSS-safe — and it keeps the text clean for the AI and the PDFs // (no HTML entities leaking into the generated documents). const result = await dbRun( `INSERT INTO bewerbungen (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 (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, // adds LLM notes if needed, and triggers generation on the application page. res.json({ success: true, id: result.lastID, url: `/bewerbung/${result.lastID}`, message: 'Bewerbung als Entwurf angelegt. Unterlagen können auf der Bewerbungsseite generiert werden.', }); } catch (error) { console.error('Error importing job:', error); res.status(500).json({ error: 'Serverfehler beim Import.' }); } }); // Poll generation status + current attachments for one application app.get('/api/bewerbungen/:id/generierung', async (req, res) => { try { const { id } = req.params; const bewerbung = await dbGet( '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 = ? AND user_id = ? ORDER BY id ASC', [id, uid()] ); res.json({ status: bewerbung.generierung_status, fehler: bewerbung.generierung_fehler, anhaenge, }); } catch (error) { console.error('Error fetching generation status:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // ----- Base documents (Basis-Unterlagen / Vorlagen) ----- app.get('/api/basis-dokumente', async (req, res) => { try { 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); res.status(500).json({ error: 'Serverfehler' }); } }); // Create application app.post('/api/bewerbungen', async (req, res) => { try { const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body; const labels = serializeLabels(req.body.labels); // Duplicate guard: warn before creating a second application for the same // company + role (the client re-submits with force=true to confirm). const force = req.body.force === true || req.body.force === 'true'; if (!force) { const dups = await findDuplicateApplications({ firma, stelle }); if (dups.length) { return res.status(409).json({ duplicate: true, matches: dups, error: 'Es gibt bereits eine Bewerbung für dieselbe Firma und Stelle.', }); } } const result = await dbRun( '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 (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 = ? AND user_id = ?', [result.lastID, uid()]); res.json({ success: true, application: newApplication }); } catch (error) { console.error('Error creating application:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // Update application app.put('/api/bewerbungen/:id', async (req, res) => { try { const { id } = req.params; const { datum, firma, stelle, art, status, notizen } = req.body; 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 = ? AND user_id = ?', [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), labels, id, uid()] ); const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]); res.json({ success: true, application: updatedApplication }); } catch (error) { console.error('Error updating application:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // Delete application app.delete('/api/bewerbungen/:id', async (req, res) => { try { const { id } = req.params; 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 = ? 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) { console.error('Error deleting application:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // Applications for PDF export (optionally filtered), including the status timeline 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 // Monatsexport listet eine Bewerbung im Monat ihres LETZTEN Status: Eine im // Juni gesendete Bewerbung, die im Juli zum Vorstellungsgespräch wird, // 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 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 = [U, U]; if (month && year) { 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}) AND strftime("%m", eff_datum) = ? ORDER BY eff_datum DESC`; params.push(month.padStart(2, '0')); } else if (year) { query = `SELECT * FROM (${base}) AND strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`; params.push(year); } const applications = await dbAll(query, params); await attachVerlauf(applications); // Internal notes must never reach the PDF/export applications.forEach((a) => { delete a.interne_notizen; }); res.json(applications); } catch (error) { console.error('Error exporting applications:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // ----- Dedicated edit page + status-timeline management ----- // Edit page for a single application app.get('/bewerbung/:id', async (req, res) => { try { const { id } = req.params; 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 = ? 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 = ? 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 = ? AND user_id = ? ORDER BY id ASC', [id, U] ); 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 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 = ? 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 = ? 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 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); }); emails.forEach((e) => { e.anhaenge = byEmail[e.id] || []; // Bare address for prefilling a reply's "To" (from "Name "). const m = String(e.from_addr || '').match(/<([^>]+)>/); e.from_addr_clean = m ? m[1] : String(e.from_addr || '').trim(); }); // 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 user_id = ? AND direction = 'in' AND seen = 0", [id, U]); } res.render('bewerbung', { application, verlauf, anhaenge, interneAnhaenge, emails, // Last choice of documents (defaults to both), for pre-ticking the form. dokumentAuswahl: normalizeDokumente( application.generierung_dokumente ? String(application.generierung_dokumente).split(',') : null ), mailConfigured: mailer.isConfigured(), mailFrom: mailer.isConfigured() ? mailer.fromField() : '', mailError: req.query.mailerror ? String(req.query.mailerror) : '', mailOk: req.query.mailok ? String(req.query.mailok) : '', basisCount: basisCountRow ? basisCountRow.count : 0, basisAnhaenge, termine, caldavConfigured: caldav.isConfigured(), caldavTz: caldav.TZ, terminVorschlag: req.query.vorschlag === 'vg' ? { datum: String(req.query.vdatum || '') } : null, terminOk: !!req.query.terminok, terminError: req.query.terminerror ? String(req.query.terminerror) : '', artOptions: ART_OPTIONS, statusOptions: STATUS_OPTIONS, labelOptions: LABEL_OPTIONS, hideSettings: true }); } catch (error) { console.error('Error loading edit page:', error); res.status(500).send('Serverfehler'); } }); // Update application core data (status is managed via the timeline) app.post('/bewerbung/:id', async (req, res) => { try { const { id } = req.params; const { datum, firma, stelle, art, notizen, interne_notizen } = req.body; 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 = ? AND user_id = ?', [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels, id, uid()] ); res.redirect('/bewerbung/' + id); } catch (error) { console.error('Error updating application:', error); res.status(500).send('Serverfehler'); } }); // Save the editable e-mail cover text (Begleit-E-Mail) after the user tweaks it. app.post('/bewerbung/:id/email', async (req, res) => { try { const { id } = req.params; 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 = ? AND user_id = ?', [String(req.body.email_betreff || ''), String(req.body.email_anschreiben || ''), id, uid()] ); res.redirect('/bewerbung/' + id + '#email'); } catch (error) { console.error('Error saving e-mail text:', error); res.status(500).send('Serverfehler'); } }); // Send an e-mail for an application (initial application or a reply). Sends // via authenticated submission, records it as an outgoing message and links // the chosen generated attachments. app.post('/bewerbung/:id/email/send', async (req, res) => { const { id } = req.params; const back = (frag) => '/bewerbung/' + id + (frag || '#korrespondenz'); try { 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 (Einstellungen).') + '#korrespondenz')); } const to = String(req.body.to || '').trim(); const subject = String(req.body.subject || '').trim(); const body = String(req.body.body || ''); if (!to || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to)) { return res.redirect(back('?mailerror=' + encodeURIComponent('Bitte eine gültige Empfänger-Adresse angeben.') + '#korrespondenz')); } // Selected generated attachments (checkbox values = anhaenge ids). let anhangIds = req.body.anhang || []; 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 = ? AND user_id = ?', [aid, id, U]); if (!a) continue; 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); } // Selected static attachments (Zeugnisse etc.), e.g. from a reply form. // 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 = ? AND user_id = ?', [bid, U]); if (!ba) continue; 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); } // 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 = ? 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(); } } const info = await mailer.sendMail({ to, subject, text: body, attachments, inReplyTo, references, }); const mid = String(info.messageId || '').replace(/[<>]/g, ''); const emailRow = await dbRun( `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, ?)`, [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(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 = ? AND user_id = ?', [to, id, U]); res.redirect(back('?mailok=' + encodeURIComponent('E-Mail an ' + to + ' gesendet.') + '#korrespondenz')); } catch (error) { console.error('Error sending e-mail:', error); res.redirect(back('?mailerror=' + encodeURIComponent('Versand fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')) + '#korrespondenz')); } }); // AI-draft a reply to a received e-mail. Returns JSON {betreff, text} that the // frontend drops into the reply form for the user to edit before sending. app.post('/bewerbung/:id/email/ai-reply', async (req, res) => { try { const { id } = req.params; 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 = ? AND user_id = ?', [req.body.email_id, id, uid()]); if (!orig) return res.status(404).json({ error: 'Nachricht nicht gefunden' }); const settings = await loadSettings(); const draft = await generateEmailReply({ incoming: { from: orig.from_addr, subject: orig.subject, text: emailPlainText(orig) }, job: { firma: bewerbung.firma, stelle: bewerbung.stelle }, settings, prompts: await loadPrompts(), hinweise: String(req.body.hinweise || ''), typ: String(req.body.typ || 'antwort'), }); // Include the quoted original so the reply reads like a mail-client thread. res.json({ ...draft, quote: buildReplyQuote(orig) }); } catch (error) { console.error('Error drafting AI reply:', error); res.status(500).json({ error: error.message || 'Serverfehler' }); } }); // Manually trigger an IMAP fetch of new replies, then return to the referring page. app.post('/email/fetch', async (req, res) => { const back = req.body.back || req.get('referer') || '/'; try { await pollInbox(); } catch (e) { /* errors are logged inside pollInbox */ } res.redirect(back); }); // 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 = ? AND user_id = ?', [req.params.id, uid()]); if (!a) return res.status(404).send('Anhang nicht gefunden'); 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); res.download(p, a.name || a.pfad); } catch (error) { console.error('Error downloading e-mail attachment:', error); res.status(500).send('Serverfehler'); } }); // --------------------------------------------------------------------------- // Postfach: incoming e-mails that landed in the inbox but could not be // matched to an application automatically. The user assigns them by hand. // --------------------------------------------------------------------------- // Page listing all e-mails with no bewerbung_id, plus every application as // assignment target. app.get('/postfach', async (req, res) => { try { const U = uid(); const emails = await dbAll( `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 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); }); emails.forEach((e) => { e.anhaenge = byEmail[e.id] || []; }); decorateEmails(emails); // 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 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 WHERE user_id = ? ORDER BY datum DESC, created_at DESC`, [U] ); res.render('postfach', { emails, bewerbungen, mailConfigured: mailer.isConfigured(), mailError: req.query.mailerror ? String(req.query.mailerror) : '', mailOk: req.query.mailok ? String(req.query.mailok) : '', hideSettings: true, }); } catch (error) { console.error('Error loading postfach:', error); res.status(500).send('Serverfehler'); } }); // Assign an unlinked e-mail to an existing application. app.post('/postfach/:emailId/zuweisen', async (req, res) => { try { const bewerbungId = Number(req.body.bewerbung_id); if (!bewerbungId) { 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 = ? 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 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); res.redirect('/postfach?mailerror=' + encodeURIComponent('Zuweisung fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler'))); } }); // Delete an e-mail from the Postfach (removes its stored attachment files too). 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 = ? AND user_id = ?', [emailId, uid()]); const attDir = userStorageDir(emailAnhaengeDir); for (const a of atts) { fs.promises.unlink(path.join(attDir, a.pfad)).catch(() => {}); } 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); res.redirect('/postfach?mailerror=' + encodeURIComponent('Löschen fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler'))); } }); // 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 user_id = ? AND bewerbung_id IS NULL', [uid()]); res.json({ count: row ? row.count : 0 }); } catch (error) { res.status(500).json({ count: 0 }); } }); // Unread received e-mails — drives the notification bell on every page. Returns // the total unread count plus the newest few as a ready-to-render list. Replies // auto-assigned to an application carry a link to that application; unassigned // mail links to the Postfach. app.get('/api/notifications', async (req, res) => { try { const cntRow = await dbGet( "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 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`, [uid()] ); const items = rows.map((e) => { const fromName = String(e.from_addr || '').replace(/<[^>]*>/, '').replace(/"/g, '').trim() || String(e.from_addr || '').trim(); const snippet = emailPlainText(e).replace(/\s+/g, ' ').trim().slice(0, 140); return { id: e.id, from: fromName || '(unbekannt)', subject: e.subject || '(kein Betreff)', snippet, date: e.email_date || null, bewerbung_id: e.bewerbung_id || null, kontext: e.bewerbung_id ? [e.firma, e.stelle].filter(Boolean).join(' · ') : '', url: e.bewerbung_id ? ('/bewerbung/' + e.bewerbung_id + '#korrespondenz') : '/postfach', }; }); res.json({ count: cntRow ? cntRow.count : 0, items }); } catch (error) { res.status(500).json({ count: 0, items: [] }); } }); // 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 user_id = ? AND direction = 'in' AND seen = 0", [uid()]); res.json({ ok: true }); } catch (error) { res.status(500).json({ ok: false }); } }); // Add a timeline entry (status change with date + comment) app.post('/bewerbung/:id/verlauf', async (req, res) => { try { const { id } = req.params; const { datum, status, kommentar } = req.body; if (datum && status && status.trim()) { await dbRun( 'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)', [uid(), id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')] ); await syncCurrentStatus(id); // Suggest a calendar entry when an interview was recorded (confirm + click). if (caldav.isConfigured() && /vorstellungsgespr/i.test(status)) { return res.redirect('/bewerbung/' + id + '?vorschlag=vg&vdatum=' + encodeURIComponent(datum) + '#termine'); } } res.redirect('/bewerbung/' + id); } catch (error) { console.error('Error adding timeline entry:', error); res.status(500).send('Serverfehler'); } }); // --- Application calendar appointments (mirrored to SOGo via CalDAV) --- app.post('/bewerbung/:id/termine', async (req, res) => { const { id } = req.params; try { 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'); } const b = req.body || {}; if (!b.datum) { return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Bitte ein Datum angeben.') + '#termine'); } const ganztags = b.ganztags === 'on' || b.ganztags === '1' || b.ganztags === 'true'; const typ = b.typ === 'vorstellungsgespraech' ? 'vorstellungsgespraech' : 'termin'; const titel = (b.titel || '').trim() || (typ === 'vorstellungsgespraech' ? 'Vorstellungsgespräch' : 'Termin'); const erinnerung = Math.max(0, parseInt(b.erinnerung_min, 10) || 0); let start, ende = null; if (ganztags) { const [y, mo, d] = String(b.datum).split('-').map(Number); start = caldav.wallToUtc(y, mo, d, 0, 0); } else { start = caldav.localInputToUtc(b.datum, b.von || '09:00'); if (b.bis) ende = caldav.localInputToUtc(b.datum, b.bis); } const created = await caldav.createEvent({ summary: titel, location: b.ort || '', description: b.notiz || '', start, end: ende, allDay: ganztags, alarmMin: erinnerung, }); await dbRun( `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] ); res.redirect('/bewerbung/' + id + '?terminok=1#termine'); } catch (error) { console.error('Error creating termin:', error); res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent(error.message || 'Termin konnte nicht angelegt werden.') + '#termine'); } }); 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 = ? 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 = ? AND user_id = ?', [tid, uid()]); } res.redirect('/bewerbung/' + id + '#termine'); } catch (error) { console.error('Error deleting termin:', error); res.redirect('/bewerbung/' + id + '#termine'); } }); // Update a timeline entry app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => { try { const { id, eintragId } = req.params; const { datum, status, kommentar } = req.body; if (datum && status && status.trim()) { await dbRun( '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); } res.redirect('/bewerbung/' + id); } catch (error) { console.error('Error updating timeline entry:', error); res.status(500).send('Serverfehler'); } }); // Delete a timeline entry 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 = ? AND user_id = ?', [eintragId, id, uid()]); await syncCurrentStatus(id); res.redirect('/bewerbung/' + id); } catch (error) { console.error('Error deleting timeline entry:', error); res.status(500).send('Serverfehler'); } }); // ----- Vorlagen (Basis-Unterlagen) management page ----- app.get('/vorlagen', async (req, res) => { try { 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 loadSettings(), prompts: promptStore.list(await loadPrompts()), designFelder: designStore.list(design), designAngepasst: designStore.isAngepasst(design), designFotoAn: designStore.settings(design).foto_anzeigen === '1', hasSignatur: Boolean(currentSignaturFile()), hasFoto: Boolean(currentFotoFile()), basisTypOptions: BASIS_TYP_OPTIONS, hasApiKey: Boolean(config.get('OLLAMA_API_KEY')), hideSettings: true, }); } catch (error) { console.error('Error loading vorlagen:', error); res.status(500).send('Serverfehler'); } }); // ----- Persönliche Angaben (under Vorlagen, formerly the index-page modal) ----- // Registered before /vorlagen/:id so the generic base-document handler doesn't // swallow this path. Stores name/address/contact data that flows into every // generated document (replacing the previous KI extraction from basis texts). app.post('/vorlagen/persoenlich', async (req, res) => { try { const { name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum } = req.body; await dbRun( `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) ] ); res.redirect('/vorlagen#persoenlich'); } catch (error) { console.error('Error saving personal data:', error); res.status(500).send('Serverfehler'); } }); // ----- Editable KI prompts ----- // Registered before /vorlagen/:id so the generic base-document handlers don't // swallow these paths. The prompt text is stored raw (no HTML escaping): it is // sent to the LLM, never rendered as markup — the views escape it on output. // Save an overridden prompt. Empty text = fall back to the default. app.post('/vorlagen/prompts/:key', async (req, res) => { try { const { key } = req.params; 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 user_id = ? AND key = ?', [uid(), key]); } else { await dbRun( `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'); } catch (error) { console.error('Error saving prompt:', error); res.status(500).send('Serverfehler'); } }); // 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 user_id = ? AND key = ?', [uid(), req.params.key]); res.redirect('/vorlagen#prompts'); } catch (error) { console.error('Error resetting prompt:', error); res.status(500).send('Serverfehler'); } }); // ----- Design of the generated PDFs ----- // Save the design choices. Values are validated by lib/design.js on read, and // a choice equal to the default is stored as a deletion, so the DB only ever // holds real deviations. app.post('/vorlagen/design', async (req, res) => { try { const gewaehlt = designStore.settings(req.body); // Compare against what the *chosen layout* ships with, not the global // defaults — otherwise picking "social" would persist its own pink/round // defaults as if the user had overridden them. `layout` itself keeps the // global default as its yardstick, so choosing a non-default layout is // stored (comparing it against itself would never save anything). const basis = { ...designStore.DEFAULTS, ...(designStore.LAYOUT_DEFAULTS[gewaehlt.layout] || {}), layout: designStore.DEFAULTS.layout, }; for (const [key, value] of Object.entries(gewaehlt)) { if (value === basis[key]) { await dbRun('DELETE FROM design WHERE user_id = ? AND key = ?', [uid(), key]); } else { await dbRun( `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] ); } } res.redirect('/vorlagen#design'); } catch (error) { console.error('Error saving design:', error); res.status(500).send('Serverfehler'); } }); // Back to the shipped design. app.post('/vorlagen/design/reset', async (req, res) => { try { await dbRun('DELETE FROM design WHERE user_id = ?', [uid()]); res.redirect('/vorlagen#design'); } catch (error) { console.error('Error resetting design:', error); res.status(500).send('Serverfehler'); } }); // Preview PDF (Anschreiben or Lebenslauf) with sample content — lets the user // see a design choice without spending an LLM run. Query params override the // saved design, so the form can preview a selection before it is saved. app.get('/vorlagen/design/vorschau/:doc.pdf', async (req, res) => { try { const welches = req.params.doc === 'lebenslauf' ? 'lebenslauf' : 'anschreiben'; const gespeichert = await loadDesign(); // Only known design keys from the query are honoured; design.settings() // drops anything invalid. const design = { ...gespeichert, ...req.query }; const settings = await loadSettings(); const pdfs = renderDesignVorschau({ settings, signatur: loadSignatur(), bewerbungsfoto: loadFoto(), design, }); res.type('application/pdf'); res.setHeader('Content-Disposition', `inline; filename="Vorschau_${welches}.pdf"`); res.send(pdfs[welches]); } catch (error) { console.error('Error rendering design preview:', error); res.status(500).send('Vorschau konnte nicht erzeugt werden'); } }); // Add a base document app.post('/vorlagen', async (req, res) => { try { const { typ, name, inhalt } = req.body; if (inhalt && inhalt.trim()) { await dbRun( 'INSERT INTO basis_dokumente (user_id, typ, name, inhalt) VALUES (?, ?, ?, ?)', [uid(), sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt] ); } res.redirect('/vorlagen'); } catch (error) { console.error('Error adding base document:', error); res.status(500).send('Serverfehler'); } }); // Update a base document app.post('/vorlagen/:id', async (req, res) => { try { const { id } = req.params; const { typ, name, inhalt } = req.body; await dbRun( '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) { console.error('Error updating base document:', error); res.status(500).send('Serverfehler'); } }); // Delete a base document app.post('/vorlagen/:id/delete', async (req, res) => { try { 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); res.status(500).send('Serverfehler'); } }); // ----- Static extra attachments (Zeugnisse etc.) ----- // Upload an attachment app.post('/anlagen', (req, res) => { uploadBasisAnhang(req, res, async (err) => { try { if (err) { console.error('Upload error:', err.message); return res.redirect('/vorlagen'); } if (req.file) { const original = req.file.originalname || req.file.filename; const name = (req.body.name && req.body.name.trim()) ? sanitizeInput(req.body.name.trim()) : sanitizeInput(original.replace(/\.[^.]+$/, '')); await dbRun( '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'); } catch (error) { console.error('Error saving attachment:', error); res.status(500).send('Serverfehler'); } }); }); // Download a static attachment app.get('/anlagen/:id/download', async (req, res) => { try { 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(userStorageDir(basisAnhaengeDir), a.pfad); if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden'); res.download(filePath, a.dateiname); } catch (error) { console.error('Error downloading attachment:', error); res.status(500).send('Serverfehler'); } }); // Delete a static attachment app.post('/anlagen/:id/delete', async (req, res) => { try { const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]); if (a) { 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) { console.error('Error deleting attachment:', error); res.status(500).send('Serverfehler'); } }); // ----- Signature (Unterschrift) ----- // Serve the current signature image (for the preview on the Vorlagen page) app.get('/unterschrift', (req, res) => { const file = currentSignaturFile(); if (!file) return res.status(404).send('Keine Unterschrift'); res.sendFile(file); }); // Upload / replace the signature app.post('/unterschrift', (req, res) => { uploadSignatur(req, res, (err) => { try { if (err) console.error('Signature upload error:', err.message); if (req.file) { // 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'); } catch (error) { console.error('Error saving signature:', error); res.status(500).send('Serverfehler'); } }); }); // Delete the signature app.post('/unterschrift/delete', (req, res) => { try { const dir = userStorageDir(signaturDir); fs.readdirSync(dir).forEach((f) => fs.promises.unlink(path.join(dir, f)).catch(() => {})); } catch (e) { /* ignore */ } res.redirect('/vorlagen'); }); // ----- Applicant photo (Bewerberfoto, used in the CV) ----- // Serve the current photo (for the preview on the Vorlagen page) app.get('/bewerbungsfoto', (req, res) => { const file = currentFotoFile(); if (!file) return res.status(404).send('Kein Bewerberfoto'); res.sendFile(file); }); // Upload / replace the photo app.post('/bewerbungsfoto', (req, res) => { uploadFoto(req, res, (err) => { try { if (err) console.error('Photo upload error:', err.message); if (req.file) { // 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'); } catch (error) { console.error('Error saving photo:', error); res.status(500).send('Serverfehler'); } }); }); // Delete the photo app.post('/bewerbungsfoto/delete', (req, res) => { try { const dir = userStorageDir(fotoDir); fs.readdirSync(dir).forEach((f) => fs.promises.unlink(path.join(dir, f)).catch(() => {})); } catch (e) { /* ignore */ } res.redirect('/vorlagen'); }); // Download a generated attachment app.get('/anhaenge/:id/download', async (req, res) => { try { 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(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); res.download(filePath, anhang.dateiname); } catch (error) { console.error('Error downloading attachment:', error); res.status(500).send('Serverfehler'); } }); // Delete a generated attachment 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 = ? AND user_id = ?', [anhangId, id, uid()]); if (anhang) { const filePath = path.join(userStorageDir(anhaengeDir), anhang.pfad); fs.promises.unlink(filePath).catch(() => {}); await dbRun('DELETE FROM anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]); } res.redirect('/bewerbung/' + id); } catch (error) { console.error('Error deleting attachment:', error); res.status(500).send('Serverfehler'); } }); // ----- Private attachments (internal notes) ----- // Upload a private attachment for an application's internal notes app.post('/bewerbung/:id/interne-anhaenge', (req, res) => { uploadInterneAnhang(req, res, async (err) => { try { const { id } = req.params; if (err) { console.error('Interne-Anhang upload error:', err.message); return res.redirect('/bewerbung/' + id); } if (req.file) { const original = req.file.originalname || req.file.filename; const name = (req.body.name && req.body.name.trim()) ? sanitizeInput(req.body.name.trim()) : sanitizeInput(original.replace(/\.[^.]+$/, '')); await dbRun( '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); } catch (error) { console.error('Error saving internal attachment:', error); res.status(500).send('Serverfehler'); } }); }); // Download a private attachment (inline=1 opens PDFs/images in the browser) 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 = ? AND user_id = ?', [anhangId, id, uid()]); if (!anhang) return res.status(404).send('Anhang nicht gefunden'); 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); } catch (error) { console.error('Error downloading internal attachment:', error); res.status(500).send('Serverfehler'); } }); // Delete a private attachment 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 = ? AND user_id = ?', [anhangId, id, uid()]); if (anhang) { 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) { console.error('Error deleting internal attachment:', error); res.status(500).send('Serverfehler'); } }); // Start (or re-run) the AI generation for an application. Saves the LLM notes // first, removes any previously generated attachments, then generates. app.post('/bewerbung/:id/generieren', async (req, res) => { try { const { id } = req.params; 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 = ? AND user_id = ?', [req.body.llm_notizen || '', id, U]); } 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(anhaengeUserDir, a.pfad)).catch(() => {}); } 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 = ? AND user_id = ?', [dokumente.join(','), id, U]); runGeneration(id, { anlagenIds, dokumente }); res.redirect('/bewerbung/' + id); } catch (error) { console.error('Error generating documents:', error); res.status(500).send('Serverfehler'); } }); // ----- Jobangebote page (list page) ----- // The offers shown here are ingested by third-party software via the // /api/v1/joboffers REST endpoint (POST). The page itself is read-only plus // two manual actions: turn an offer into an application draft, or delete it. // 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 user_id = ? AND status = 'offen'", [uid()]); res.json({ count: row ? row.count : 0 }); } catch (error) { res.status(500).json({ count: 0 }); } }); // Main list: only OPEN offers (still to be decided on). Taken-over offers // live on their own page (/jobangebote/uebernommen). app.get('/jobangebote', async (req, res) => { try { 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 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 user_id = ? AND status = 'uebernommen'", [uid()]); res.render('jobangebote', { jobangebote, uebernommenCount: uebernommenRow ? uebernommenRow.c : 0, artOptions: ART_OPTIONS, statusOptions: STATUS_OPTIONS, labelOptions: LABEL_OPTIONS, hideSettings: false, }); } catch (error) { console.error('Error listing job offers:', error); res.status(500).send('Serverfehler'); } }); // Taken-over offers: which offers were turned into applications. app.get('/jobangebote/uebernommen', async (req, res) => { try { 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 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 user_id = ? AND status = 'offen'", [uid()]); res.render('jobangebote_uebernommen', { uebernommen, offenCount: offenRow ? offenRow.c : 0, hideSettings: false, }); } catch (error) { console.error('Error listing taken-over offers:', error); res.status(500).send('Serverfehler'); } }); // Convert a job offer into a Bewerbung draft (mirrors the Indeed import flow: // creates a bewerbung with status "Entwurf", records the initial timeline entry, // and links the offer back to it). // Create a Bewerbung draft from an offer (or return the already-linked one). // The offer's FULL description is carried into `stellenbeschreibung`, which is // exactly the text handed to the LLM during generation. Returns the id. async function uebernehmeAngebot(angebot) { if (angebot.verknuepfte_bewerbung_id) return angebot.verknuepfte_bewerbung_id; const datum = new Date().toISOString().split('T')[0]; const notizParts = [ angebot.ort ? `Ort: ${angebot.ort}` : null, angebot.gehalt ? `Gehalt: ${angebot.gehalt}` : null, angebot.kontakt_email ? `Kontakt: ${angebot.kontakt_email}` : null, angebot.quelle_url ? `Quelle: ${angebot.quelle_url}` : null, angebot.quelle ? `Importiert via: ${angebot.quelle}` : null, ].filter(Boolean); // Employer address (street, house number, city) and contact person go into // the AI notes so the LLM can use them for the letter's Anschriftfeld and // salutation. let anschrift = (angebot.adresse || '').trim(); if (angebot.ort && !anschrift.toLowerCase().includes(String(angebot.ort).toLowerCase())) { anschrift = anschrift ? `${anschrift}, ${angebot.ort}` : String(angebot.ort); } const llmNotizen = [ anschrift ? `Anschrift des Arbeitgebers: ${anschrift}` : null, angebot.ansprechpartner ? `Ansprechpartner: ${angebot.ansprechpartner}` : null, ].filter(Boolean).join('\n'); const result = await dbRun( `INSERT INTO bewerbungen (user_id, datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, email_empfaenger, llm_notizen, labels, generierung_status) VALUES (?, ?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`, [ currentUserId(), datum, sanitizeInput(angebot.firma), sanitizeInput(angebot.stelle), sanitizeInput(angebot.art || deriveArt(angebot.quelle_url, null)), notizParts.join('\n'), angebot.ort || '', angebot.beschreibung || '', angebot.quelle_url || '', angebot.kontakt_email || '', llmNotizen, serializeLabels(angebot.labels), ] ); await dbRun( '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 = ? 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 = ? 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); } catch (error) { console.error('Error converting job offer:', error); res.status(500).send('Serverfehler'); } }); // Edit an offer's fields — mainly to paste/adjust the full job description // (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 = ? AND user_id = ?', [req.params.id, uid()]); if (!angebot) return res.status(404).send('Jobangebot nicht gefunden'); const b = req.body || {}; const quelleUrl = sanitizeInput(b.quelle_url || ''); await dbRun( `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 = ? AND user_id = ?`, [ sanitizeInput((b.firma || '').trim()) || angebot.firma, sanitizeInput((b.stelle || '').trim()) || angebot.stelle, sanitizeInput(b.ort || ''), sanitizeInput(b.adresse || ''), sanitizeInput(b.ansprechpartner || ''), sanitizeInput(b.gehalt || ''), sanitizeInput(b.beschreibung || ''), sanitizeInput(b.kontakt_email || ''), quelleUrl, sanitizeInput(b.anzeige_datum || ''), serializeLabels(b.labels), blacklist.normalizeUrl(b.quelle_url || '') || null, req.params.id, uid(), ] ); res.redirect('/jobangebote'); } catch (error) { console.error('Error editing job offer:', error); res.status(500).send('Serverfehler'); } }); // Delete a job offer. Deleting always blacklists it first, so the same offer // can never be ingested/listed again (the requirement: never appears twice, // 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 = ? AND user_id = ?', [req.params.id, uid()]); if (offer) { await autoBlacklistOffer(offer, 'Jobangebot gelöscht (Web-UI)'); await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); } res.redirect('/jobangebote'); } catch (error) { console.error('Error deleting job offer:', error); res.status(500).send('Serverfehler'); } }); // Blacklist management page — see what is blocked and remove entries. app.get('/blacklist', async (req, res) => { try { const eintraege = await dbAll( '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) { console.error('Error listing blacklist:', error); res.status(500).send('Serverfehler'); } }); // Add a manual blacklist entry (URL / domain / company / company+title). app.post('/blacklist', async (req, res) => { try { const entry = blacklist.buildManualEntry({ typ: req.body.typ, wert: sanitizeInput(req.body.wert || ''), firma: sanitizeInput(req.body.firma || ''), stelle: sanitizeInput(req.body.stelle || ''), ort: sanitizeInput(req.body.ort || ''), grund: sanitizeInput(req.body.grund || ''), }); if (entry) await insertBlacklistEntry(entry); res.redirect('/blacklist'); } catch (error) { console.error('Error adding blacklist entry:', error); res.status(500).send('Serverfehler'); } }); // 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 = ? AND user_id = ?', [req.params.id, uid()]); res.redirect('/blacklist'); } catch (error) { console.error('Error deleting blacklist entry:', error); res.status(500).send('Serverfehler'); } }); // ----- Einstellungen (früher .env — jetzt in der Datenbank) ----- // Alle Konfigurationswerte (Ollama, E-Mail, CalDAV, REST-API) liegen in der // app_state-Tabelle und sind hier editierbar. Speichern wirkt sofort, ein // Neustart ist nicht nötig (die Libs lesen per config.get() zur Laufzeit). app.get('/einstellungen', (req, res) => { res.render('settings', { felder: config.FIELDS, werte: config.getAll(), hideSettings: true, }); }); app.post('/einstellungen', async (req, res) => { try { const werte = {}; for (const sektion of config.FIELDS) { for (const f of sektion.items) { werte[f.key] = req.body[f.key] != null ? String(req.body[f.key]) : ''; } } await config.saveAll(werte); res.redirect('/einstellungen'); } catch (error) { console.error('Error saving settings:', error); res.status(500).send('Serverfehler'); } }); // ----- 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([ loadSettings(), dbAll( `SELECT inhalt FROM basis_dokumente 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(), ]); // Lightweight core context only: name, date and the user's profile (static, // small). All application/appointment data is fetched on demand via tools, // so the system prompt stays tiny regardless of how many bewerbungen exist. const profil = (profilRows.map((r) => (r.inhalt || '').trim()).join('\n\n---\n\n')).slice(0, 1800); const heute = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); return { heute, profil, prompts, settings }; } // Ollama tool definitions the assistant can call to look up application data. const CHAT_TOOLS = [ { type: 'function', function: { name: 'suche_bewerbungen', description: 'Durchsucht Bewerbungen nach Firmen- oder Stellenname (Teiltreffer). Nutze dies, wenn der Nutzer eine konkrete Firma/Stelle nennt oder fragt, was zu einer Firma bekannt ist. Liefert eine kompakte Trefferliste (id, firma, stelle, status, datum, ort) — hole Details mit bewerbung_detail.', parameters: { type: 'object', properties: { query: { type: 'string', description: 'Suchbegriff, z. B. Firmen- oder Stellenname (mind. 2 Zeichen)' }, }, required: ['query'], }, }, }, { type: 'function', function: { name: 'list_bewerbungen', description: 'Listet Bewerbungen auf, standardmäßig die jüngsten. Optional nach Status gefiltert. Für einen Überblick über alle laufenden/abgeschlossenen Bewerbungen.', parameters: { type: 'object', properties: { status: { type: 'string', description: 'Optional: nur Bewerbungen mit diesem Status (z. B. offen, absage, eingeladen)' }, limit: { type: 'integer', description: 'Max. Anzahl Treffer (Standard 20, max 40)' }, }, }, }, }, { type: 'function', function: { name: 'bewerbung_detail', description: 'Liefert volle Details zu einer Bewerbung: Stellenbeschreibung, Notizen, interne Notizen, Kontakt, Quell-URL und die letzten Korrespondenz-Betreffe. Setze die id aus suche_bewerbungen/list_bewerbungen voraus.', parameters: { type: 'object', properties: { id: { type: 'integer', description: 'Bewerbungs-ID' } }, required: ['id'], }, }, }, { type: 'function', function: { name: 'kommende_termine', description: 'Liefert die nächsten Termine (Gespräche, Fristen) mit Titel, Startzeit (UTC-ISO), verknüpfter Bewerbung.', parameters: { type: 'object', properties: {} }, }, }, ]; // Tool labels shown in the UI while a tool call is in flight. const CHAT_TOOL_LABELS = { suche_bewerbungen: 'Bewerbungen werden durchsucht…', list_bewerbungen: 'Bewerbungen werden geladen…', bewerbung_detail: 'Bewerbungsdetails werden geladen…', kommende_termine: 'Termine werden geladen…', }; // Execute one tool call against the database. Returns a JSON-serialisable // value that is fed back to the model as the tool result. async function executeChatTool(name, args) { const a = args || {}; if (name === 'suche_bewerbungen') { const q = String(a.query || '').trim(); if (q.length < 2) return { treffer: [], hinweis: 'Suchbegriff zu kurz' }; const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`; const rows = await dbAll( `SELECT id, firma, stelle, status, datum, ort FROM bewerbungen WHERE user_id = ? AND (firma LIKE ? ESCAPE '\\' OR stelle LIKE ? ESCAPE '\\') ORDER BY datum DESC, created_at DESC LIMIT 20`, [uid(), like, like] ); return { treffer: rows }; } if (name === 'list_bewerbungen') { 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 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') { const id = Number(a.id); if (!id) return { error: 'keine id' }; const row = await dbGet( `SELECT b.id, b.firma, b.stelle, b.status, b.datum, b.ort, b.notizen, b.interne_notizen, b.stellenbeschreibung, b.quelle_url, 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 AND j.user_id = b.user_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 = ? AND user_id = ? ORDER BY email_date DESC, created_at DESC LIMIT 8`, [id, uid()] ); return { id: row.id, firma: row.firma, stelle: row.stelle, status: row.status, datum: row.datum, ort: row.ort, quelle_url: row.quelle_url, kontakt_email: row.ja_kontakt || null, ansprechpartner: row.ja_ansprech || null, notizen: (row.notizen || '').trim(), interne_notizen: (row.interne_notizen || '').trim(), stellenbeschreibung: (row.stellenbeschreibung || row.ja_beschreibung || '').trim().slice(0, 1200), korrespondenz: em.map((e) => ({ direction: e.direction, subject: e.subject, von: e.from_addr, })), }; } if (name === 'kommende_termine') { const rows = await upcomingTermine(10); return { termine: rows.map((t) => ({ titel: t.titel, start: t.start, bewerbung_id: t.bewerbung_id, bewerbung: t.bewerbung_firma || null, })), }; } return { error: 'unbekanntes Werkzeug: ' + name }; } // Chat page: list threads + render the active thread (or a fresh empty one). app.get('/chat', async (req, res) => { 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 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 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', { threads, activeId, messages, hasApiKey: true, hideSettings: false, }); } catch (error) { console.error('Chat page error:', error); res.status(500).send('Serverfehler'); } }); // Create a new thread. Optional `titel` in the body. 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 (user_id, titel) VALUES (?, ?)', [uid(), titel]); res.json({ id: lastID, titel }); } catch (error) { console.error('Create thread error:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // 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 = ? AND user_id = ?', [Number(req.params.id), uid()]); res.json({ ok: true }); } catch (error) { console.error('Delete thread error:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // Rename a thread (e.g. auto-title from first message). 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 = ? AND user_id = ?', [titel, Number(req.params.id), uid()]); res.json({ ok: true }); } catch (error) { console.error('Rename thread error:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // Send a user message and stream the assistant reply via SSE. app.post('/chat/api/threads/:id/messages', async (req, res) => { if (!chat.isConfigured()) return res.status(503).json({ error: 'KI-Chat deaktiviert.' }); const threadId = Number(req.params.id); const userText = sanitizeInput((req.body.content || '').trim()); if (!userText) return res.status(400).json({ error: 'Leere Nachricht.' }); let thread; try { 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 (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 = ? 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 user_id = ? AND (titel IS NULL OR titel = "")', [t, threadId, uid()]); } } } catch (error) { console.error('Persist user message error:', error); return res.status(500).json({ error: 'Serverfehler' }); } let history; try { history = await dbAll( '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' }); } // SSE setup. Keep the connection alive; flush headers immediately. res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders && res.flushHeaders(); const send = (obj) => { res.write(`data: ${JSON.stringify(obj)}\n\n`); }; // AbortController so a closed client stops the upstream Ollama stream. const controller = new AbortController(); let aborted = false; req.on('close', () => { aborted = true; controller.abort(); }); // Trim very old history to bound token cost (keep the last 20 turns). const trimmed = history.slice(-40); const messages = trimmed.map((m) => ({ role: m.role, content: m.content })); let context; try { context = await gatherChatContext(); } catch (e) { context = {}; } const system = chat.buildContextPrompt(context); let assistantText = ''; try { assistantText = await chat.runChat({ system, messages, tools: CHAT_TOOLS, signal: controller.signal, onToken: (delta) => send({ type: 'token', content: delta }), onToolCall: (name) => send({ type: 'tool', name, label: CHAT_TOOL_LABELS[name] || name }), executeTool: executeChatTool, }); } catch (err) { if (aborted) { res.end(); return; } send({ type: 'error', message: err.message || 'KI-Fehler' }); res.end(); return; } // Persist the (possibly empty) assistant reply. const saved = assistantText || '(keine Antwort)'; try { const { lastID } = await dbRun( '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 = ? 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.' }); } res.end(); }); // ----- Third-party REST API (/api/v1) + OpenAPI/Swagger ----- // 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, dbAll, dbRun, sanitizeInput, attachVerlauf, findDuplicateApplications, syncCurrentStatus, runGeneration, anhaengeDir, emailAnhaengeDir, userStorageDir, apiToken, })); // Serve the OpenAPI document, with the real request host injected as server. app.get('/swagger.json', (req, res) => { const proto = req.get('x-forwarded-proto') || req.protocol; const host = req.get('host') || `localhost:${PORT}`; res.json(buildOpenApiSpec(`${proto}://${host}`)); }); // Swagger UI (loaded from CDN; consistent with the app's other CDN usage). app.get('/swagger', (req, res) => { const proto = req.get('x-forwarded-proto') || req.protocol; const host = req.get('host') || `localhost:${PORT}`; const specUrl = `${proto}://${host}/swagger.json`; res.type('text/html').send(` Bewerbungs-Tracker – API-Dokumentation
Bewerbungs-Tracker REST-API Drittanbieter-Schnittstelle v1 Authentifizierung: Header X-API-Key ← zur App
`); }); app.get('/api-docs', (req, res) => res.redirect(301, '/swagger')); // Start server app.listen(PORT, () => { console.log(`Server läuft auf http://localhost:${PORT}`); console.log('REST-API (/api/v1): pro Benutzer über X-API-Key (Token in den Einstellungen je Benutzer gesetzt) – Swagger unter /swagger'); }); // 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 */ } } } 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) => { res.status(404).send('Seite nicht gefunden'); }); }).catch((err) => { console.error('Failed to initialize database:', err); process.exit(1); }); // Close database on exit process.on('SIGINT', () => { db.close(); process.exit(); }); process.on('SIGTERM', () => { db.close(); process.exit(); });