const express = require('express'); const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const fs = require('fs'); 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 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', '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')); // 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 }); } // 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 const uploadBasisAnhang = multer({ storage: multer.diskStorage({ destination: (req, file, cb) => cb(null, basisAnhaengeDir), 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, interneAnhaengeDir), 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, signaturDir), 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. function currentSignaturFile() { try { const files = fs.readdirSync(signaturDir).filter((f) => !f.startsWith('.')); return files.length ? path.join(signaturDir, 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, fotoDir), 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. function currentFotoFile() { try { const files = fs.readdirSync(fotoDir).filter((f) => !f.startsWith('.')); return files.length ? path.join(fotoDir, 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 }); }); }); } // --------------------------------------------------------------------------- // 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 (${cols.join(', ')}) VALUES (${placeholders})`, 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'); 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 COALESCE(t.ende, t.start) >= ? ORDER BY t.start ASC LIMIT ?`, [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 caldav_uid IS NOT NULL AND start >= ? AND start <= ?', [from.toISOString(), to.toISOString()] ); for (const t of local) { const r = byUid.get(t.caldav_uid); if (!r) { await dbRun('DELETE FROM termine WHERE id = ?', [t.id]); } else { await dbRun( `UPDATE termine SET titel = ?, ort = ?, notiz = ?, start = ?, ende = ?, ganztags = ?, caldav_etag = ?, updated_at = CURRENT_TIMESTAMP WHERE 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, ] ); } } 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 key = ?', [key]); return row ? row.value : null; } async function setState(key, value) { await dbRun( 'INSERT INTO app_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, String(value)] ); } // 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'); 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'); return Object.fromEntries(rows.map((r) => [r.key, r.value])); } catch (e) { console.error('Konnte Design nicht laden, nutze Standardwerte:', 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 direction = 'out' AND message_id = ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1", [mid] ); if (row && row.bewerbung_id) return row.bewerbung_id; } if (msg.fromAddr) { const row = await dbGet( "SELECT bewerbung_id FROM emails WHERE direction = 'out' AND lower(to_addr) LIKE ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1", ['%' + msg.fromAddr.toLowerCase() + '%'] ); 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. 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 message_id = ?', [m.messageId]); if (dup) continue; } const bewId = await matchBewerbung(m); const result = await dbRun( `INSERT INTO emails (bewerbung_id, direction, message_id, in_reply_to, email_references, from_addr, to_addr, subject, body_text, body_html, imap_uid, seen, email_date) VALUES (?, 'in', ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`, [bewId, m.messageId || null, m.inReplyTo || null, m.references || null, 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(emailAnhaengeDir, storedName), att.content); await dbRun('INSERT INTO email_anhaenge (email_id, name, mime, pfad) VALUES (?, ?, ?, ?)', [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'); 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 bewerbung_id = ? ORDER BY date(datum) DESC, id DESC LIMIT 1', [bewerbungId] ); await dbRun( 'UPDATE bewerbungen SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [latest ? latest.status : '', bewerbungId] ); } // Attach the status timeline to each application (single query, grouped in JS) async function attachVerlauf(applications) { if (!applications.length) return applications; const all = await dbAll('SELECT * FROM status_verlauf ORDER BY date(datum) ASC, id ASC'); const 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 bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [bewerbungId]); if (!bewerbung) return; const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC'); const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC'); const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); const 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(anhaengeDir, stored), buffer); await dbRun( 'INSERT INTO anhaenge (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)', [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(basisAnhaengeDir, ba.pfad); if (!fs.existsSync(src)) continue; await storeAnhang(ba.name || ba.dateiname, ba.dateiname, ba.mime || 'application/octet-stream', fs.readFileSync(src)); } await dbRun( "UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, " + "email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", [(email && email.betreff) || '', (email && email.text) || '', bewerbungId] ); console.log(`Bewerbungsunterlagen für #${bewerbungId} generiert (${documents.length} Dokument(e)).`); } catch (error) { console.error(`Generierung für #${bewerbungId} fehlgeschlagen:`, error.message); await dbRun( "UPDATE bewerbungen SET generierung_status = 'fehler', generierung_fehler = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", [String(error.message || 'Unbekannter Fehler'), bewerbungId] ).catch(() => {}); } } // Initialize database - create tables and default settings in one operation function initializeDatabase() { return new Promise((resolve, reject) => { db.serialize(() => { db.run('PRAGMA foreign_keys = ON'); // Create tables db.run(` CREATE TABLE IF NOT EXISTS bewerbungen ( id INTEGER PRIMARY KEY AUTOINCREMENT, datum DATE NOT NULL, firma TEXT NOT NULL, stelle TEXT NOT NULL, art TEXT, status TEXT, notizen TEXT, interne_notizen TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `, (err) => { if (err) return reject(err); // Migration: add columns to pre-existing databases (ignore "duplicate column") db.run('ALTER TABLE bewerbungen ADD COLUMN interne_notizen TEXT', () => { db.run('ALTER TABLE bewerbungen ADD COLUMN ort TEXT', () => { db.run('ALTER TABLE bewerbungen ADD COLUMN stellenbeschreibung TEXT', () => { db.run('ALTER TABLE bewerbungen ADD COLUMN quelle_url TEXT', () => { db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_status TEXT', () => { db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_fehler TEXT', () => { db.run('ALTER TABLE bewerbungen ADD COLUMN email_betreff TEXT', () => { db.run('ALTER TABLE bewerbungen ADD COLUMN email_anschreiben TEXT', () => { db.run('ALTER TABLE bewerbungen ADD COLUMN llm_notizen TEXT', () => { // Chronological status changes, each with an optional comment db.run(` CREATE TABLE IF NOT EXISTS status_verlauf ( id INTEGER PRIMARY KEY AUTOINCREMENT, bewerbung_id INTEGER NOT NULL, datum DATE NOT NULL, status TEXT NOT NULL, kommentar TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE ) `, (err) => { if (err) return reject(err); // Base documents (Basis-Unterlagen) — the foundation the AI tailors from db.run(` CREATE TABLE IF NOT EXISTS basis_dokumente ( id INTEGER PRIMARY KEY AUTOINCREMENT, typ TEXT, name TEXT, inhalt TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `, (err) => { if (err) return reject(err); // Static extra attachments (e.g. Zeugnisse) attached to every application db.run(` CREATE TABLE IF NOT EXISTS basis_anhaenge ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, dateiname TEXT NOT NULL, mime TEXT, pfad TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `, (err) => { if (err) return reject(err); // Generated attachment files linked to an application db.run(` CREATE TABLE IF NOT EXISTS anhaenge ( id INTEGER PRIMARY KEY AUTOINCREMENT, bewerbung_id INTEGER NOT NULL, name TEXT, dateiname TEXT NOT NULL, mime TEXT, pfad TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE ) `, (err) => { if (err) return reject(err); // Private attachments linked to an application's internal notes. // Not exported, not sent — for the user only. db.run(` CREATE TABLE IF NOT EXISTS interne_anhaenge ( id INTEGER PRIMARY KEY AUTOINCREMENT, bewerbung_id INTEGER NOT NULL, name TEXT, dateiname TEXT NOT NULL, mime TEXT, pfad TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE ) `, (err) => { if (err) return reject(err); // E-Mail correspondence (sent + received), linked to an application. // Serialized mode guarantees these run after the tables above exist. db.run(` CREATE TABLE IF NOT EXISTS emails ( id INTEGER PRIMARY KEY AUTOINCREMENT, bewerbung_id INTEGER, direction TEXT NOT NULL, message_id TEXT, in_reply_to TEXT, email_references TEXT, from_addr TEXT, to_addr TEXT, subject TEXT, body_text TEXT, body_html TEXT, attachments_json TEXT, imap_uid INTEGER, seen INTEGER DEFAULT 1, email_date DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE ) `); db.run(` CREATE TABLE IF NOT EXISTS email_anhaenge ( id INTEGER PRIMARY KEY AUTOINCREMENT, email_id INTEGER NOT NULL, name TEXT, mime TEXT, pfad TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (email_id) REFERENCES emails(id) ON DELETE CASCADE ) `); // Small key/value store (e.g. last processed IMAP UID). db.run(` CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value TEXT ) `); // Job offers ingested via the third-party REST API (/api/v1/joboffers). // `quelle` + `external_id` identify an offer from one source uniquely, // so re-sending the same offer updates it instead of creating a copy. db.run(` CREATE TABLE IF NOT EXISTS jobangebote ( id INTEGER PRIMARY KEY AUTOINCREMENT, external_id TEXT, quelle TEXT NOT NULL DEFAULT 'drittanbieter', firma TEXT NOT NULL, stelle TEXT NOT NULL, ort TEXT, adresse TEXT, ansprechpartner TEXT, gehalt TEXT, beschreibung TEXT, quelle_url TEXT, art TEXT, anzeige_datum DATE, kontakt_email TEXT, status TEXT NOT NULL DEFAULT 'offen', verknuepfte_bewerbung_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE (quelle, external_id), FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL ) `); // Migration: labels (JSON array of work-location labels) on both Stellen tables. db.run('ALTER TABLE bewerbungen ADD COLUMN labels TEXT', () => {}); // Which documents the last generation run produced ("anschreiben,lebenslauf"). // Remembered so the form comes back pre-ticked with the user's last choice. db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_dokumente TEXT', () => {}); db.run('ALTER TABLE jobangebote ADD COLUMN labels TEXT', () => {}); // Migration: add columns to pre-existing jobangebote tables. db.run('ALTER TABLE jobangebote ADD COLUMN anzeige_datum DATE', () => {}); db.run('ALTER TABLE jobangebote ADD COLUMN kontakt_email TEXT', () => {}); db.run('ALTER TABLE jobangebote ADD COLUMN adresse TEXT', () => {}); db.run('ALTER TABLE jobangebote ADD COLUMN ansprechpartner TEXT', () => {}); // Normalized URL for URL-based de-duplication of offers (see lib/blacklist). db.run('ALTER TABLE jobangebote ADD COLUMN url_norm TEXT', () => { // Backfill url_norm for rows ingested before this column existed. db.all('SELECT id, quelle_url FROM jobangebote WHERE url_norm IS NULL AND quelle_url IS NOT NULL AND quelle_url != ""', (err, rows) => { if (err || !rows) return; rows.forEach((r) => { const norm = blacklist.normalizeUrl(r.quelle_url); if (norm) db.run('UPDATE jobangebote SET url_norm = ? WHERE id = ?', [norm, r.id], () => {}); }); }); }); db.run('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)', () => {}); // Company slug carried on the offer itself (supplied by the indexing // client, else derived from firma). Used for blacklist matching and // "same company" grouping. Backfill derives it from firma. db.run('ALTER TABLE jobangebote ADD COLUMN firma_slug TEXT', () => { db.all('SELECT id, firma FROM jobangebote WHERE firma_slug IS NULL AND firma IS NOT NULL AND firma != ""', (err, rows) => { if (err || !rows) return; rows.forEach((r) => { const slug = blacklist.firmaSlug(r.firma); if (slug) db.run('UPDATE jobangebote SET firma_slug = ? WHERE id = ?', [slug, r.id], () => {}); }); }); }); db.run('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)', () => {}); // Blacklist of job offers that must never (re)appear in the list. A // deleted offer is auto-blacklisted; manual entries can block a URL, // a whole domain, a company, or a specific company+title posting. db.run(` CREATE TABLE IF NOT EXISTS jobangebote_blacklist ( id INTEGER PRIMARY KEY AUTOINCREMENT, typ TEXT NOT NULL DEFAULT 'auto', url_norm TEXT, domain TEXT, quelle TEXT, external_id TEXT, firma_norm TEXT, firma_slug TEXT, stelle_norm TEXT, ort_norm TEXT, firma TEXT, stelle TEXT, quelle_url TEXT, grund TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `, () => {}); // Add + backfill firma_slug on pre-existing installs. The slug is a // legal-form/umlaut-robust company key that the blacklist now matches // on; backfill from the stored original company name so old company // entries start blocking reliably too. db.run('ALTER TABLE jobangebote_blacklist ADD COLUMN firma_slug TEXT', () => { db.all( `SELECT id, firma, firma_norm FROM jobangebote_blacklist WHERE firma_slug IS NULL AND (firma IS NOT NULL OR firma_norm IS NOT NULL)`, (err, rows) => { if (err || !rows) return; for (const r of rows) { const slug = blacklist.firmaSlug(r.firma || r.firma_norm || ''); if (slug) { db.run('UPDATE jobangebote_blacklist SET firma_slug = ? WHERE id = ?', [slug, r.id], () => {}); } } } ); }); // Remember the last recipient address per application (prefill). db.run('ALTER TABLE bewerbungen ADD COLUMN email_empfaenger TEXT', () => {}); // Application calendar appointments, mirrored to the SOGo CalDAV // calendar (see lib/caldav). Times are stored as UTC ISO strings. db.run(` CREATE TABLE IF NOT EXISTS termine ( id INTEGER PRIMARY KEY AUTOINCREMENT, bewerbung_id INTEGER, typ TEXT NOT NULL DEFAULT 'termin', titel TEXT NOT NULL, ort TEXT, notiz TEXT, start TEXT NOT NULL, ende TEXT, ganztags INTEGER DEFAULT 0, erinnerung_min INTEGER DEFAULT 60, caldav_uid TEXT, caldav_href TEXT, caldav_etag TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL ) `, () => {}); // Conversational KI-Chat: threads and their messages. The assistant // answer is streamed from Ollama (see lib/chat.js) and persisted here. db.run(` CREATE TABLE IF NOT EXISTS chat_threads ( id INTEGER PRIMARY KEY AUTOINCREMENT, titel TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); db.run(` CREATE TABLE IF NOT EXISTS chat_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (thread_id) REFERENCES chat_threads(id) ON DELETE CASCADE ) `, () => {}); db.run('CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id)', () => {}); // Overridden KI system prompts. Only prompts the user actually edited // are stored; everything else falls back to the defaults in // lib/prompts.js, so "Zurücksetzen" is a plain DELETE. db.run(` CREATE TABLE IF NOT EXISTS prompts ( key TEXT PRIMARY KEY, inhalt TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `, () => {}); // Design choices for the generated PDFs (accent colour, sidebar tint, // photo shape, font size). Same contract as `prompts`: only deviations // are stored, and lib/design.js validates every value on read, so a // stale row can never produce a broken document. db.run(` CREATE TABLE IF NOT EXISTS design ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `, () => {}); db.run(` CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY CHECK (id = 1), name TEXT, adresse TEXT, kundennummer TEXT, email TEXT, telefon TEXT, ort TEXT, webseite TEXT, geburtsdatum TEXT ) `, (err) => { if (err) return reject(err); // Migration: persönliche Kontaktfelder nachträglich anlegen, falls die // Tabelle aus einer älteren Version stammt (CREATE TABLE IF NOT EXISTS // ergänzt fehlende Spalten nicht). Fehler "duplicate column" ignorieren. const kontaktSpalten = ['email', 'telefon', 'ort', 'webseite', 'geburtsdatum']; kontaktSpalten.forEach((spalte) => { db.run(`ALTER TABLE settings ADD COLUMN ${spalte} TEXT`, () => {}); }); // Insert default settings if not exists db.get('SELECT COUNT(*) as count FROM settings WHERE id = 1', (err, result) => { if (err) return reject(err); if (result && result.count === 0) { db.run( 'INSERT INTO settings (id, name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)', ['Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', '', '', '', '', '', '', ''], (err) => { if (err) return reject(err); resolve(); } ); } else { resolve(); } }); }); }); }); }); }); }); }); }); }); }); }); }); }); }); }); }); }); }); } // Initialize and start server initializeDatabase().then(() => { console.log('Database initialized successfully'); // Routes app.get('/', async (req, res) => { try { const { month, year } = req.query; let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC, created_at DESC'; const params = []; if (month && year) { query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC'; params.push(month.padStart(2, '0'), year); } else if (year) { query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC'; 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'); const byArt = await dbAll(` SELECT art, COUNT(*) as count FROM bewerbungen WHERE art IS NOT NULL AND art != '' GROUP BY art ORDER BY count DESC `); const byStatus = await dbAll(` SELECT status, COUNT(*) as count FROM bewerbungen WHERE status IS NOT NULL AND status != '' GROUP BY status ORDER BY count DESC `); // Get available months/years for filter const availableMonths = await dbAll(` SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth, strftime("%m", datum) as month, strftime("%Y", datum) as year FROM bewerbungen ORDER BY datum DESC `); // 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), date(b.datum) ) AS eff FROM bewerbungen b ) ORDER BY yearmonth DESC `); 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 = ?', [id]); 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 dbGet('SELECT * FROM settings WHERE id = 1'); 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; await dbRun( 'UPDATE settings SET name = ?, adresse = ?, kundennummer = ?, email = ?, telefon = ?, ort = ?, webseite = ?, geburtsdatum = ? WHERE id = 1', [ 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 (datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status) VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`, [datum, firma, stelle, quelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || ''] ); // Record the initial "Entwurf" status in the timeline await dbRun( 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', [result.lastID, datum, 'Entwurf', `Automatisch aus dem Browser importiert (${quelle})`] ); // 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 = ?', [id] ); if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); const anhaenge = await dbAll( 'SELECT id, name, dateiname, mime FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', [id] ); 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 ORDER BY id ASC'); 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 (datum, firma, stelle, art, status, notizen, interne_notizen, labels) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels] ); // Record the initial status as the first timeline entry if (status && status.trim()) { await dbRun( 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', [result.lastID, datum, sanitizeInput(status), sanitizeInput(kommentar || '')] ); } const newApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]); 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 = ?', [id]); 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 = ?', [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), labels, id] ); const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); 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 = ?', [id]); // Remove private attachments belonging to the internal notes. const interne = await dbAll('SELECT pfad FROM interne_anhaenge WHERE bewerbung_id = ?', [id]); interne.forEach((a) => fs.promises.unlink(path.join(interneAnhaengeDir, a.pfad)).catch(() => {})); await dbRun('DELETE FROM interne_anhaenge WHERE bewerbung_id = ?', [id]); await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]); 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; // 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), date(b.datum) ) AS eff_datum FROM bewerbungen b `; let query = `SELECT * FROM (${base}) ORDER BY eff_datum DESC`; const params = []; if (month && year) { query = `SELECT * FROM (${base}) WHERE strftime("%m", eff_datum) = ? AND strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`; params.push(month.padStart(2, '0'), year); } else if (month) { // A month without a year must still restrict the export to that month — // never fall through to exporting every application. query = `SELECT * FROM (${base}) WHERE strftime("%m", eff_datum) = ? ORDER BY eff_datum DESC`; params.push(month.padStart(2, '0')); } else if (year) { query = `SELECT * FROM (${base}) WHERE 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 application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); if (!application) return res.status(404).send('Bewerbung nicht gefunden'); application.labelsArr = parseLabels(application.labels); const verlauf = await dbAll( 'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC', [id] ); const anhaenge = await dbAll( 'SELECT id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', [id] ); // Private attachments belonging to the internal notes — never exported/sent. const interneAnhaenge = await dbAll( 'SELECT id, name, dateiname, mime, created_at FROM interne_anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', [id] ); const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente'); // Available static attachments (Zeugnisse etc.) to optionally enclose. const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge ORDER BY id ASC'); // Calendar appointments for this application (mirrored to SOGo). const termine = await dbAll('SELECT * FROM termine WHERE bewerbung_id = ? ORDER BY start ASC', [id]); // E-Mail correspondence (sent + received), oldest first, with attachments. const emails = await dbAll( 'SELECT * FROM emails WHERE bewerbung_id = ? ORDER BY datetime(email_date) ASC, id ASC', [id] ); if (emails.length) { const eIds = emails.map((e) => e.id); const atts = await dbAll( `SELECT id, email_id, name, mime FROM email_anhaenge WHERE email_id IN (${eIds.map(() => '?').join(',')})`, eIds ); 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 direction = 'in' AND seen = 0", [id]); } 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 = ?', [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels, id] ); 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 = ?', [id]); if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden'); // Store raw; the value is HTML-escaped on render (EJS <%= %>), matching how // the generated e-mail is stored. Sanitising here would double-escape. await dbRun( 'UPDATE bewerbungen SET email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [String(req.body.email_betreff || ''), String(req.body.email_anschreiben || ''), id] ); 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 bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden'); if (!mailer.isConfigured()) { return res.redirect(back('?mailerror=' + encodeURIComponent('E-Mail ist nicht konfiguriert (.env).') + '#korrespondenz')); } 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 = []; for (const aid of anhangIds) { const a = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?', [aid, id]); if (!a) continue; const p = path.join(anhaengeDir, 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]; for (const bid of basisAnlageIds) { const ba = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [bid]); if (!ba) continue; const p = path.join(basisAnhaengeDir, 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 = ?', [req.body.reply_to, id]); 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 (bewerbung_id, direction, message_id, in_reply_to, email_references, from_addr, to_addr, subject, body_text, attachments_json, seen, email_date) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`, [id, mid, inReplyTo ? inReplyTo.replace(/[<>]/g, '') : null, references ? references.replace(/[<>]/g, '') : null, 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. for (const att of attachments) { const safe = String(att.filename || 'anhang').replace(/[^a-zA-Z0-9äöüÄÖÜß._ -]/g, '_').slice(0, 80); const storedName = `${emailRow.lastID}_${Date.now()}_${safe}`; try { fs.copyFileSync(att.path, path.join(emailAnhaengeDir, storedName)); await dbRun('INSERT INTO email_anhaenge (email_id, name, mime, pfad) VALUES (?, ?, ?, ?)', [emailRow.lastID, att.filename, att.contentType || null, storedName]); } catch (e) { /* ignore a single bad attachment */ } } await dbRun('UPDATE bewerbungen SET email_empfaenger = ? WHERE id = ?', [to, id]); 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 = ?', [id]); if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ?', [req.body.email_id, id]); if (!orig) return res.status(404).json({ error: 'Nachricht nicht gefunden' }); const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); 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 = ?', [req.params.id]); if (!a) return res.status(404).send('Anhang nicht gefunden'); const p = path.join(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 emails = await dbAll( `SELECT * FROM emails WHERE bewerbung_id IS NULL ORDER BY datetime(email_date) DESC, id DESC` ); if (emails.length) { const eIds = emails.map((e) => e.id); const atts = await dbAll( `SELECT id, email_id, name, mime FROM email_anhaenge WHERE email_id IN (${eIds.map(() => '?').join(',')})`, eIds ); 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 bewerbung_id IS NULL AND direction = 'in' AND seen = 0"); } // Applications the user can assign an e-mail to (newest first). const bewerbungen = await dbAll( `SELECT id, firma, stelle, ort, datum FROM bewerbungen ORDER BY datum DESC, created_at DESC` ); 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 = ?', [bewerbungId]); if (!app) { return res.redirect('/postfach?mailerror=' + encodeURIComponent('Ausgewählte Bewerbung existiert nicht.')); } await dbRun('UPDATE emails SET bewerbung_id = ? WHERE id = ? AND bewerbung_id IS NULL', [bewerbungId, req.params.emailId]); 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 = ?', [emailId]); for (const a of atts) { fs.promises.unlink(path.join(emailAnhaengeDir, a.pfad)).catch(() => {}); } await dbRun('DELETE FROM email_anhaenge WHERE email_id = ?', [emailId]); await dbRun('DELETE FROM emails WHERE id = ?', [emailId]); 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 bewerbung_id IS NULL'); 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 direction = 'in' AND seen = 0" ); const rows = await dbAll( `SELECT e.id, e.from_addr, e.subject, e.body_text, e.body_html, e.email_date, e.bewerbung_id, b.firma, b.stelle FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id WHERE e.direction = 'in' AND e.seen = 0 ORDER BY datetime(e.email_date) DESC, e.id DESC LIMIT 30` ); 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 direction = 'in' AND seen = 0"); 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 (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', [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 = ?', [id]); 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 (bewerbung_id, typ, titel, ort, notiz, start, ende, ganztags, erinnerung_min, caldav_uid, caldav_href, caldav_etag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, typ, sanitizeInput(titel), sanitizeInput(b.ort || ''), sanitizeInput(b.notiz || ''), 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 = ?', [tid, id]); if (t) { await caldav.deleteEvent({ href: t.caldav_href, etag: t.caldav_etag }).catch((e) => console.warn('CalDAV delete:', e.message)); await dbRun('DELETE FROM termine WHERE id = ?', [tid]); } 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 = ?', [datum, sanitizeInput(status), sanitizeInput(kommentar || ''), eintragId, id] ); 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 = ?', [eintragId, id]); 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 ORDER BY id ASC'); const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC'); const design = await loadDesign(); res.render('vorlagen', { basisDokumente, basisAnhaenge, settings: await dbGet('SELECT * FROM settings WHERE id = 1'), 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(process.env.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( 'UPDATE settings SET name = ?, adresse = ?, kundennummer = ?, email = ?, telefon = ?, ort = ?, webseite = ?, geburtsdatum = ? WHERE id = 1', [ 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 key = ?', [key]); } else { await dbRun( `INSERT INTO prompts (key, inhalt, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET inhalt = excluded.inhalt, updated_at = CURRENT_TIMESTAMP`, [key, inhalt] ); } 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 key = ?', [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 key = ?', [key]); } else { await dbRun( `INSERT INTO design (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`, [key, value] ); } } 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'); 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 dbGet('SELECT * FROM settings WHERE id = 1'); 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 (typ, name, inhalt) VALUES (?, ?, ?)', [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 = ?', [sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt || '', id] ); 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 = ?', [req.params.id]); 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 (name, dateiname, mime, pfad) VALUES (?, ?, ?, ?)', [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 = ?', [req.params.id]); if (!a) return res.status(404).send('Anlage nicht gefunden'); const filePath = path.join(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 = ?', [req.params.id]); if (a) { fs.promises.unlink(path.join(basisAnhaengeDir, a.pfad)).catch(() => {}); await dbRun('DELETE FROM basis_anhaenge WHERE id = ?', [req.params.id]); } 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 fs.readdirSync(signaturDir).forEach((f) => { if (f !== req.file.filename) fs.promises.unlink(path.join(signaturDir, 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 { fs.readdirSync(signaturDir).forEach((f) => fs.promises.unlink(path.join(signaturDir, 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 fs.readdirSync(fotoDir).forEach((f) => { if (f !== req.file.filename) fs.promises.unlink(path.join(fotoDir, 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 { fs.readdirSync(fotoDir).forEach((f) => fs.promises.unlink(path.join(fotoDir, 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 = ?', [req.params.id]); if (!anhang) return res.status(404).send('Anhang nicht gefunden'); const filePath = path.join(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 = ?', [anhangId, id]); if (anhang) { const filePath = path.join(anhaengeDir, anhang.pfad); fs.promises.unlink(filePath).catch(() => {}); await dbRun('DELETE FROM anhaenge WHERE id = ?', [anhangId]); } 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 (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)', [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 = ?', [anhangId, id]); if (!anhang) return res.status(404).send('Anhang nicht gefunden'); const filePath = path.join(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 = ?', [anhangId, id]); if (anhang) { fs.promises.unlink(path.join(interneAnhaengeDir, anhang.pfad)).catch(() => {}); await dbRun('DELETE FROM interne_anhaenge WHERE id = ?', [anhangId]); } 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 bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]); if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden'); // Persist the LLM notes the user provided (used as context during generation) if (typeof req.body.llm_notizen !== 'undefined') { await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ?', [req.body.llm_notizen || '', id]); } const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]); for (const a of alte) { fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {}); } await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]); await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?", [id]); // Selected extra attachments (checkbox values); none by default. const anlagenIds = [].concat(req.body.anlage || []).map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n)); // Which documents to generate; nothing ticked = both (the default). const dokumente = normalizeDokumente(req.body.dokument); await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ?', [dokumente.join(','), id]); 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 status = 'offen'"); 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 WHERE j.status = 'offen' ORDER BY j.created_at DESC, j.id DESC` ); jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); }); const uebernommenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE status = 'uebernommen'"); 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 WHERE j.status = 'uebernommen' ORDER BY j.updated_at DESC, j.id DESC` ); uebernommen.forEach((j) => { j.labelsArr = parseLabels(j.labels); }); const offenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE status = 'offen'"); 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 (datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, email_empfaenger, llm_notizen, labels, generierung_status) VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`, [ 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 (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', [result.lastID, datum, 'Entwurf', `Automatisch aus Jobangebot übernommen (${angebot.quelle || 'drittanbieter'})`] ); await dbRun( 'UPDATE jobangebote SET verknuepfte_bewerbung_id = ?, status = "uebernommen", updated_at = CURRENT_TIMESTAMP WHERE id = ?', [result.lastID, angebot.id] ); return result.lastID; } app.post('/jobangebote/:id/uebernehmen', async (req, res) => { try { const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]); 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 = ?', [req.params.id]); 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 = ?`, [ 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, ] ); 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 = ?', [req.params.id]); if (offer) { await autoBlacklistOffer(offer, 'Jobangebot gelöscht (Web-UI)'); await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]); } 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 ORDER BY created_at DESC, id DESC' ); 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 = ?', [req.params.id]); res.redirect('/blacklist'); } catch (error) { console.error('Error deleting blacklist entry:', error); res.status(500).send('Serverfehler'); } }); // ----- Conversational KI-Chat (Ollama, streaming) ----- // Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the // assistant answer is streamed back via Server-Sent Events. async function gatherChatContext() { const [settings, profilRows, prompts] = await Promise.all([ dbGet('SELECT name FROM settings WHERE id = 1'), dbAll( `SELECT inhalt FROM basis_dokumente WHERE typ IN ('Lebenslauf', 'Profil/Kurzprofil') AND inhalt IS NOT NULL AND inhalt != '' ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END` ), 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: 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 firma LIKE ? ESCAPE '\\' OR stelle LIKE ? ESCAPE '\\' ORDER BY datum DESC, created_at DESC LIMIT 20`, [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 ${status ? 'WHERE status = ?' : ''} ORDER BY datum DESC, created_at DESC LIMIT ?`; const rows = await dbAll(sql, status ? [status, limit] : [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 WHERE b.id = ?`, [id] ); if (!row) return { error: 'nicht gefunden' }; const em = await dbAll( `SELECT direction, subject, from_addr FROM emails WHERE bewerbung_id = ? ORDER BY email_date DESC, created_at DESC LIMIT 8`, [id] ); 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 – OLLAMA_API_KEY fehlt.'); try { const threads = await dbAll( 'SELECT id, titel, updated_at FROM chat_threads ORDER BY updated_at DESC' ); const activeId = req.query.thread ? Number(req.query.thread) : (threads[0] && threads[0].id); let messages = []; if (activeId) { messages = await dbAll( 'SELECT id, role, content, created_at FROM chat_messages WHERE thread_id = ? ORDER BY id ASC', [activeId] ); } 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 (titel) VALUES (?)', [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 = ?', [Number(req.params.id)]); 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 = ?', [titel, Number(req.params.id)]); 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 = ?', [threadId]); } catch (e) { /* fall through */ } if (!thread) return res.status(404).json({ error: 'Thread nicht gefunden.' }); // Persist the user message, then load the full prior history for context. try { await dbRun('INSERT INTO chat_messages (thread_id, role, content) VALUES (?, ?, ?)', [threadId, 'user', userText]); await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [threadId]); // Auto-title the thread from the first user message, if untitled. if (!thread.titel) { const first = await dbGet('SELECT content FROM chat_messages WHERE thread_id = ? ORDER BY id ASC LIMIT 1', [threadId]); if (first) { const t = first.content.slice(0, 60).replace(/\s+/g, ' ').trim(); if (t) await dbRun('UPDATE chat_threads SET titel = ? WHERE id = ? AND (titel IS NULL OR titel = "")', [t, threadId]); } } } 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 = ? ORDER BY id ASC', [threadId] ); } 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 (thread_id, role, content) VALUES (?, ?, ?)', [threadId, 'assistant', saved] ); await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [threadId]); const threadRow = await dbGet('SELECT titel FROM chat_threads WHERE id = ?', [threadId]); 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 ----- // API key for third-party software. When unset, the API responds 503 on // every endpoint except /health — it never silently exposes data. const apiToken = process.env.API_TOKEN || ''; app.use('/api/v1', createExternalApi({ dbGet, dbAll, dbRun, sanitizeInput, attachVerlauf, findDuplicateApplications, syncCurrentStatus, runGeneration, anhaengeDir, emailAnhaengeDir, 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}`); if (apiToken) console.log('REST-API (/api/v1) aktiv – Swagger unter /swagger'); else console.log('REST-API deaktiviert – API_TOKEN fehlt (Swagger unter /swagger weiterhin verfügbar)'); }); // E-Mail: verify SMTP on startup and poll the IMAP inbox for replies. if (mailer.isConfigured()) { mailer.verify() .then(() => console.log(`E-Mail aktiv: Versand über ${mailer.config().host} als ${mailer.fromAddress()}`)) .catch((e) => console.warn('E-Mail SMTP-Verbindung nicht verifizierbar:', e.message)); const pollMs = Math.max(60000, Number(process.env.MAIL_POLL_MS) || 180000); setTimeout(() => { pollInbox().catch(() => {}); }, 8000); // initial fetch after boot setInterval(() => { pollInbox().catch(() => {}); }, pollMs); // periodic fetch } else { console.log('E-Mail nicht konfiguriert (MAIL_HOST/MAIL_USER/MAIL_PASSWORD fehlen) - Versand/Empfang deaktiviert.'); } // Calendar: reconcile our appointments with the SOGo CalDAV calendar. if (caldav.isConfigured()) { console.log(`Kalender aktiv: CalDAV ${caldav.collectionUrl()}`); const calPoll = Math.max(60000, Number(process.env.CALDAV_POLL_MS) || 300000); setTimeout(() => { refreshCaldav().catch(() => {}); }, 10000); // initial sync after boot setInterval(() => { refreshCaldav().catch(() => {}); }, calPoll); // periodic reconcile } else { console.log('Kalender nicht konfiguriert (CALDAV_URL fehlt) - Kalender-Funktionen deaktiviert.'); } // 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(); });