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 } = require('./lib/documents'); const mailer = require('./lib/mailer'); const { createExternalApi } = require('./lib/api'); const { buildOpenApiSpec } = require('./lib/openapi'); 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', '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 }); } // 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 + 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 }); }); }); } // --------------------------------------------------------------------------- // 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)] ); } // 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) { 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 { documents, email } = await generateApplicationDocuments({ job: { firma: bewerbung.firma, stelle: bewerbung.stelle, ort: bewerbung.ort, quelle_url: bewerbung.quelle_url, stellenbeschreibung: bewerbung.stellenbeschreibung, }, basisDokumente, settings, // Names of the static attachments so the cover letter can list them under "Anlagen". zusatzAnlagen: basisAnhaenge.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); } // Static extra attachments (e.g. Zeugnisse) — copied as-is for (const ba of basisAnhaenge) { 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); // 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, gehalt TEXT, beschreibung TEXT, quelle_url TEXT, art 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 ) `); // Remember the last recipient address per application (prefill). db.run('ALTER TABLE bewerbungen ADD COLUMN email_empfaenger TEXT', () => {}); db.run(` CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY CHECK (id = 1), name TEXT, adresse TEXT, kundennummer TEXT ) `, (err) => { if (err) return reject(err); // 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) 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); const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); // 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 `); res.render('index', { applications, settings, statistics: { total: totalCount ? totalCount.count : 0, byArt, byStatus }, availableMonths, currentFilter: { month, year }, artOptions: ART_OPTIONS, statusOptions: STATUS_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 } = req.body; await dbRun( 'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1', [sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)] ); 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; // 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) VALUES (?, ?, ?, ?, ?, ?, ?)', [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen)] ); // 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; await dbRun( 'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), 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]); 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; let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC'; const params = []; if (month && year) { query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY 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 bewerbungen WHERE strftime("%m", datum) = ? ORDER BY datum DESC'; params.push(month.padStart(2, '0')); } else if (year) { query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY 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'); 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] ); const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente'); // 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(); }); // 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, emails, 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, artOptions: ART_OPTIONS, statusOptions: STATUS_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; await dbRun( 'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), 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); } // 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, ''); 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()] ); 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: orig.body_text }, job: { firma: bewerbung.firma, stelle: bewerbung.stelle }, settings, hinweise: String(req.body.hinweise || ''), }); res.json(draft); } 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'); 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] || []; }); } // 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'))); } }); // Count of unlinked e-mails — drives the 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 }); } }); // 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); } res.redirect('/bewerbung/' + id); } catch (error) { console.error('Error adding timeline entry:', error); res.status(500).send('Serverfehler'); } }); // 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'); res.render('vorlagen', { basisDokumente, basisAnhaenge, 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'); } }); // 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'); 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'); } }); // 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]); runGeneration(id); 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 }); } }); 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 ORDER BY j.created_at DESC, j.id DESC` ); res.render('jobangebote', { jobangebote, artOptions: ART_OPTIONS, statusOptions: STATUS_OPTIONS, hideSettings: false, }); } catch (error) { console.error('Error listing job 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). app.post('/jobangebote/:id/uebernehmen', async (req, res) => { try { const { id } = req.params; const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [id]); if (!angebot) return res.status(404).send('Jobangebot nicht gefunden'); if (angebot.verknuepfte_bewerbung_id) { return res.redirect('/bewerbung/' + 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.quelle_url ? `Quelle: ${angebot.quelle_url}` : null, angebot.quelle ? `Importiert via: ${angebot.quelle}` : null, ].filter(Boolean); const result = await dbRun( `INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, 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 || '', ] ); 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, id] ); res.redirect('/bewerbung/' + result.lastID); } catch (error) { console.error('Error converting job offer:', error); res.status(500).send('Serverfehler'); } }); // Delete a job offer (cascades nothing — verknuepfte_bewerbung_id is SET NULL). app.post('/jobangebote/:id/delete', async (req, res) => { try { 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'); } }); // ----- 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.'); } // 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(); });