Files
jobbi-bewerbung/server.js
T
thomasandClaude 97b48f9841 Einstellungen-Seite: .env-Werte in DB (app_state), keine .env mehr
Neue /einstellungen-Seite (Zahnrad im Header) mit allen bisherigen .env-Werten
(Ollama, E-Mail, CalDAV, REST-API), gespeichert in SQLite (app_state, cfg:-Prefix).
Libs lesen per lib/config.js zur Laufzeit statt beim Start -> Aenderungen
wirken sofort, kein Neustart. Bestehende .env wird beim ersten Start einmalig
migriert.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 20:57:31 +02:00

3119 lines
128 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 config = require('./lib/config');
const app = express();
const PORT = process.env.PORT || 3000;
// Shared option lists (used in multiple views)
const ART_OPTIONS = [
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone',
'Firmenwebsite', 'Post', 'Initiativbewerbung',
'Arbeitsagentur', 'Sonstiges'
];
const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung'
];
// Base document types the user can provide as a foundation for AI tailoring
const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges'];
// Pick the application "source" (art) for a browser-captured job. Honour an
// explicit value from the extension, otherwise infer it from the URL host so a
// capture from any website is labelled sensibly.
function deriveArt(url, provided) {
if (provided && ART_OPTIONS.includes(provided)) return provided;
const host = (String(url || '').match(/^https?:\/\/([^/]+)/i) || [, ''])[1].toLowerCase();
if (!host) return 'Sonstiges';
if (host.includes('indeed')) return 'Indeed';
if (host.includes('stepstone')) return 'StepStone';
if (host.includes('arbeitsagentur')) return 'Arbeitsagentur';
if (/(linkedin|xing|monster|stellenanzeigen|kimeta|glassdoor|jobware|meinestadt|jobs\.|karriere\.)/.test(host)) return 'Online-Portal';
return 'Firmenwebsite';
}
// Middleware
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
app.use(express.static(path.join(__dirname, 'public')));
// Allow the browser extension (running on indeed.com) to call the import API.
// Kept narrow: only the extension-facing endpoints need cross-origin access.
app.use('/api/indeed-import', (req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
// Set EJS as template engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// 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 '<!doctype html><html><head><meta charset="utf-8">' +
'<meta name="referrer" content="no-referrer"><base target="_blank">' +
'<style>html,body{margin:0;padding:10px;background:#fff;color:#111;' +
'font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;' +
'word-break:break-word;overflow-wrap:anywhere}' +
'img{max-width:100%!important;height:auto}table{max-width:100%!important}' +
'a{color:#2563eb}*{max-width:100%}</style></head><body>' +
String(html || '') + '</body></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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 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(async () => {
console.log('Database initialized successfully');
// Load configuration from the DB (migrates any still-present .env values
// once). Must run before the boot checks below (mailer/caldav configured?) and
// before any route that reads config — values live in the DB now, not in .env.
await config.init({ dbAll, dbRun });
// 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 <addr>").
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(config.get('OLLAMA_API_KEY')),
hideSettings: true,
});
} catch (error) {
console.error('Error loading vorlagen:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Persönliche Angaben (under Vorlagen, formerly the index-page modal) -----
// Registered before /vorlagen/:id so the generic base-document handler doesn't
// swallow this path. Stores name/address/contact data that flows into every
// generated document (replacing the previous KI extraction from basis texts).
app.post('/vorlagen/persoenlich', async (req, res) => {
try {
const { name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum } = req.body;
await dbRun(
'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');
}
});
// ----- Einstellungen (früher .env — jetzt in der Datenbank) -----
// Alle Konfigurationswerte (Ollama, E-Mail, CalDAV, REST-API) liegen in der
// app_state-Tabelle und sind hier editierbar. Speichern wirkt sofort, ein
// Neustart ist nicht nötig (die Libs lesen per config.get() zur Laufzeit).
app.get('/einstellungen', (req, res) => {
res.render('settings', {
felder: config.FIELDS,
werte: config.getAll(),
hideSettings: true,
});
});
app.post('/einstellungen', async (req, res) => {
try {
const werte = {};
for (const sektion of config.FIELDS) {
for (const f of sektion.items) {
werte[f.key] = req.body[f.key] != null ? String(req.body[f.key]) : '';
}
}
await config.saveAll(werte);
res.redirect('/einstellungen');
} catch (error) {
console.error('Error saving settings:', error);
res.status(500).send('Serverfehler');
}
});
// ----- 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 kein Ollama-API-Schlüssel konfiguriert (unter „Einstellungen“ eintragen).');
try {
const threads = await dbAll(
'SELECT id, titel, updated_at FROM chat_threads ORDER BY updated_at DESC'
);
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. Read from
// the DB via config so an edit on /einstellungen is picked up live.
const apiToken = () => config.get('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(`<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bewerbungs-Tracker API-Dokumentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *::before, *::after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
.topbar { display:flex; align-items:center; gap:12px; padding:10px 16px;
background:#1f2937; color:#fff; font-family:system-ui,sans-serif; }
.topbar a { color:#93c5fd; text-decoration:none; font-weight:600; }
</style>
</head>
<body>
<div class="topbar">
<strong>Bewerbungs-Tracker REST-API</strong>
<span style="opacity:.7">Drittanbieter-Schnittstelle v1</span>
<span style="margin-left:auto">Authentifizierung: Header <code>X-API-Key</code></span>
<a href="/">← zur App</a>
</div>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" charset="UTF-8"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: ${JSON.stringify(specUrl)},
dom_id: '#swagger-ui',
deepLinking: true,
persistAuthorization: true,
});
};
</script>
</body>
</html>`);
});
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(config.get('MAIL_POLL_MS')) || 180000);
setTimeout(() => { pollInbox().catch(() => {}); }, 8000); // initial fetch after boot
setInterval(() => { pollInbox().catch(() => {}); }, pollMs); // periodic fetch
} else {
console.log('E-Mail nicht konfiguriert (MAIL_HOST/MAIL_USER/MAIL_PASSWORD fehlen) - Versand/Empfang deaktiviert.');
}
// Calendar: reconcile our appointments with the SOGo CalDAV calendar.
if (caldav.isConfigured()) {
console.log(`Kalender aktiv: CalDAV ${caldav.collectionUrl()}`);
const calPoll = Math.max(60000, Number(config.get('CALDAV_POLL_MS')) || 300000);
setTimeout(() => { refreshCaldav().catch(() => {}); }, 10000); // initial sync after boot
setInterval(() => { refreshCaldav().catch(() => {}); }, calPoll); // periodic reconcile
} else {
console.log('Kalender nicht konfiguriert (CALDAV_URL fehlt) - Kalender-Funktionen deaktiviert.');
}
// 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();
});