Files
jobbi-bewerbung/server.js
T
thomasandClaude ead70efa49 KI-Chat: interaktiver Bewerbungs-Assistent (Ollama-Streaming)
Eigenes Chat-Interface mit SSE-Streaming gegen das hinterlegte Ollama-Modell,
gegroundet in den Bewerbungs-/E-Mail-/Termindaten des Nutzers.

- lib/chat.js: streamChat (Ollama stream:true, NDJSON-Token) + buildContextPrompt
- chat_threads/chat_messages Tabellen (CASCADE, Index)
- Routen: GET /chat, Thread-CRUD, POST /messages (SSE, AbortController)
- views/chat.ejs + public/js/chat.js + Floating-Button im Footer
- hasApiKey-Gating (503 ohne OLLAMA_API_KEY)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-07 14:07:38 +00:00

2627 lines
107 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 } = require('./lib/documents');
const chat = require('./lib/chat');
const mailer = require('./lib/mailer');
const { createExternalApi } = require('./lib/api');
const { buildOpenApiSpec } = require('./lib/openapi');
const blacklist = require('./lib/blacklist');
const caldav = require('./lib/caldav');
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./lib/labels');
const app = express();
const PORT = process.env.PORT || 3000;
// Shared option lists (used in multiple views)
const ART_OPTIONS = [
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone',
'Firmenwebsite', 'Post', 'Initiativbewerbung',
'Arbeitsagentur', 'Sonstiges'
];
const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Absage', 'Einstellung', 'Keine Rückmeldung'
];
// Base document types the user can provide as a foundation for AI tailoring
const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges'];
// Pick the application "source" (art) for a browser-captured job. Honour an
// explicit value from the extension, otherwise infer it from the URL host so a
// capture from any website is labelled sensibly.
function deriveArt(url, provided) {
if (provided && ART_OPTIONS.includes(provided)) return provided;
const host = (String(url || '').match(/^https?:\/\/([^/]+)/i) || [, ''])[1].toLowerCase();
if (!host) return 'Sonstiges';
if (host.includes('indeed')) return 'Indeed';
if (host.includes('stepstone')) return 'StepStone';
if (host.includes('arbeitsagentur')) return 'Arbeitsagentur';
if (/(linkedin|xing|monster|stellenanzeigen|kimeta|glassdoor|jobware|meinestadt|jobs\.|karriere\.)/.test(host)) return 'Online-Portal';
return 'Firmenwebsite';
}
// Middleware
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
app.use(express.static(path.join(__dirname, 'public')));
// Allow the browser extension (running on indeed.com) to call the import API.
// Kept narrow: only the extension-facing endpoints need cross-origin access.
app.use('/api/indeed-import', (req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
// Set EJS as template engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Ensure data directory exists
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Directory for generated attachment files (application documents)
const anhaengeDir = path.join(dataDir, 'anhaenge');
if (!fs.existsSync(anhaengeDir)) {
fs.mkdirSync(anhaengeDir, { recursive: true });
}
// Directory for attachments received via IMAP (reply e-mails).
const emailAnhaengeDir = path.join(dataDir, 'email_anhaenge');
if (!fs.existsSync(emailAnhaengeDir)) {
fs.mkdirSync(emailAnhaengeDir, { recursive: true });
}
// Serve a stored file with `Content-Disposition: inline` so the browser opens it
// (PDF/image) in a tab instead of downloading. Falls back to the file extension
// when no MIME type is stored.
function serveInline(res, filePath, filename, mime) {
const type = mime || {
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
}[path.extname(filename || filePath).toLowerCase()];
if (type) res.type(type);
const safe = String(filename || 'datei').replace(/["\r\n]/g, '');
res.setHeader('Content-Disposition',
`inline; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
res.sendFile(filePath);
}
// --- E-mail display + reply helpers --------------------------------------
// A stored message is rendered as HTML when we have an HTML body (or the plain
// body is actually HTML markup — some senders put HTML in the text/plain part).
// Otherwise it is shown as plain text. HTML is displayed inside a sandboxed
// iframe (see the views), so scripts never run.
function looksLikeHtml(s) {
return /<(?:!doctype|html|body|div|table|p|br|span|a|img|ul|ol|h[1-6])\b|<\/[a-z]/i.test(String(s || ''));
}
function emailDisplayHtml(e) {
if (e.body_html && e.body_html.trim()) return e.body_html;
if (e.body_text && looksLikeHtml(e.body_text)) return e.body_text;
return null;
}
// Wrap raw e-mail HTML in a minimal document for the sandboxed iframe: a white
// background, readable defaults, images constrained to the width and links that
// open in a new tab. No scripts are enabled by the iframe sandbox.
function buildEmailSrcdoc(html) {
return '<!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 + 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)]
);
}
// 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');
// 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));
const { documents, email } = await generateApplicationDocuments({
job: {
firma: bewerbung.firma,
stelle: bewerbung.stelle,
ort: bewerbung.ort,
quelle_url: bewerbung.quelle_url,
stellenbeschreibung: bewerbung.stellenbeschreibung,
},
basisDokumente,
settings,
// Names of the 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);
// 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', () => {});
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)', () => {});
db.run(`
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT,
adresse TEXT,
kundennummer TEXT
)
`, (err) => {
if (err) return reject(err);
// Insert default settings if not exists
db.get('SELECT COUNT(*) as count FROM settings WHERE id = 1', (err, result) => {
if (err) return reject(err);
if (result && result.count === 0) {
db.run(
'INSERT INTO settings (id, name, adresse, kundennummer) VALUES (1, ?, ?, ?)',
['Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', ''],
(err) => {
if (err) return reject(err);
resolve();
}
);
} else {
resolve();
}
});
});
});
});
});
});
});
});
});
});
});
});
});
});
});
});
});
});
}
// Initialize and start server
initializeDatabase().then(() => {
console.log('Database initialized successfully');
// Routes
app.get('/', async (req, res) => {
try {
const { month, year } = req.query;
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC, created_at DESC';
const params = [];
if (month && year) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC';
params.push(month.padStart(2, '0'), year);
} else if (year) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC';
params.push(year);
}
const applications = await dbAll(query, params);
await attachVerlauf(applications);
applications.forEach((a) => { a.labelsArr = parseLabels(a.labels); });
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
// 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,
settings,
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 } = req.body;
await dbRun(
'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1',
[sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)]
);
res.json({ success: true });
} catch (error) {
console.error('Error saving settings:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// ----- Indeed import (called by the browser extension) -----
app.post('/api/indeed-import', async (req, res) => {
try {
const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url, art } = req.body || {};
if (!firma || !stelle) {
return res.status(400).json({ error: 'Firma und Stelle sind erforderlich.' });
}
// Source of the capture: honour an explicit art, else infer from the URL.
const quelle = deriveArt(quelle_url, art);
// Duplicate guard: don't silently import the same posting twice.
const forceImport = req.body.force === true || req.body.force === 'true';
if (!forceImport) {
const dups = await findDuplicateApplications({ firma, stelle, quelle_url });
if (dups.length) {
return res.status(409).json({
duplicate: true,
matches: dups,
error: 'Für diese Stelle existiert bereits eine Bewerbung.',
});
}
}
const datum = new Date().toISOString().split('T')[0];
// Keep the extra details (location, salary, source) visible in the notes too.
const notizParts = [
ort ? `Ort: ${ort}` : null,
gehalt ? `Gehalt: ${gehalt}` : null,
quelle_url ? `Quelle: ${quelle_url}` : null,
].filter(Boolean);
const notizen = notizParts.join('\n');
// Stored raw: every view renders these through EJS `<%= %>` (auto-escaped),
// so this is XSS-safe — and it keeps the text clean for the AI and the PDFs
// (no HTML entities leaking into the generated documents).
const result = await dbRun(
`INSERT INTO bewerbungen
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
[datum, firma, stelle, quelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
);
// Record the initial "Entwurf" status in the timeline
await dbRun(
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
[result.lastID, datum, 'Entwurf', `Automatisch aus dem Browser importiert (${quelle})`]
);
// Note: generation is NOT started automatically — the user reviews the draft,
// adds LLM notes if needed, and triggers generation on the application page.
res.json({
success: true,
id: result.lastID,
url: `/bewerbung/${result.lastID}`,
message: 'Bewerbung als Entwurf angelegt. Unterlagen können auf der Bewerbungsseite generiert werden.',
});
} catch (error) {
console.error('Error importing job:', error);
res.status(500).json({ error: 'Serverfehler beim Import.' });
}
});
// Poll generation status + current attachments for one application
app.get('/api/bewerbungen/:id/generierung', async (req, res) => {
try {
const { id } = req.params;
const bewerbung = await dbGet(
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?', [id]
);
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const anhaenge = await dbAll(
'SELECT id, name, dateiname, mime FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', [id]
);
res.json({
status: bewerbung.generierung_status,
fehler: bewerbung.generierung_fehler,
anhaenge,
});
} catch (error) {
console.error('Error fetching generation status:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// ----- Base documents (Basis-Unterlagen / Vorlagen) -----
app.get('/api/basis-dokumente', async (req, res) => {
try {
const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
res.json(docs);
} catch (error) {
console.error('Error listing base documents:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Create application
app.post('/api/bewerbungen', async (req, res) => {
try {
const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body;
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]);
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]
);
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,
emails,
mailConfigured: mailer.isConfigured(),
mailFrom: mailer.isConfigured() ? mailer.fromField() : '',
mailError: req.query.mailerror ? String(req.query.mailerror) : '',
mailOk: req.query.mailok ? String(req.query.mailok) : '',
basisCount: basisCountRow ? basisCountRow.count : 0,
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,
hinweise: String(req.body.hinweise || ''),
});
// 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');
res.render('vorlagen', {
basisDokumente,
basisAnhaenge,
hasSignatur: Boolean(currentSignaturFile()),
hasFoto: Boolean(currentFotoFile()),
basisTypOptions: BASIS_TYP_OPTIONS,
hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
hideSettings: true,
});
} catch (error) {
console.error('Error loading vorlagen:', error);
res.status(500).send('Serverfehler');
}
});
// Add a base document
app.post('/vorlagen', async (req, res) => {
try {
const { typ, name, inhalt } = req.body;
if (inhalt && inhalt.trim()) {
await dbRun(
'INSERT INTO basis_dokumente (typ, name, inhalt) VALUES (?, ?, ?)',
[sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt]
);
}
res.redirect('/vorlagen');
} catch (error) {
console.error('Error adding base document:', error);
res.status(500).send('Serverfehler');
}
});
// Update a base document
app.post('/vorlagen/:id', async (req, res) => {
try {
const { id } = req.params;
const { typ, name, inhalt } = req.body;
await dbRun(
'UPDATE basis_dokumente SET typ = ?, name = ?, inhalt = ? WHERE id = ?',
[sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt || '', id]
);
res.redirect('/vorlagen');
} catch (error) {
console.error('Error updating base document:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a base document
app.post('/vorlagen/:id/delete', async (req, res) => {
try {
await dbRun('DELETE FROM basis_dokumente WHERE id = ?', [req.params.id]);
res.redirect('/vorlagen');
} catch (error) {
console.error('Error deleting base document:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Static extra attachments (Zeugnisse etc.) -----
// Upload an attachment
app.post('/anlagen', (req, res) => {
uploadBasisAnhang(req, res, async (err) => {
try {
if (err) {
console.error('Upload error:', err.message);
return res.redirect('/vorlagen');
}
if (req.file) {
const original = req.file.originalname || req.file.filename;
const name = (req.body.name && req.body.name.trim())
? sanitizeInput(req.body.name.trim())
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
await dbRun(
'INSERT INTO basis_anhaenge (name, dateiname, mime, pfad) VALUES (?, ?, ?, ?)',
[name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
);
}
res.redirect('/vorlagen');
} catch (error) {
console.error('Error saving attachment:', error);
res.status(500).send('Serverfehler');
}
});
});
// Download a static attachment
app.get('/anlagen/:id/download', async (req, res) => {
try {
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [req.params.id]);
if (!a) return res.status(404).send('Anlage nicht gefunden');
const filePath = path.join(basisAnhaengeDir, a.pfad);
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
res.download(filePath, a.dateiname);
} catch (error) {
console.error('Error downloading attachment:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a static attachment
app.post('/anlagen/:id/delete', async (req, res) => {
try {
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [req.params.id]);
if (a) {
fs.promises.unlink(path.join(basisAnhaengeDir, a.pfad)).catch(() => {});
await dbRun('DELETE FROM basis_anhaenge WHERE id = ?', [req.params.id]);
}
res.redirect('/vorlagen');
} catch (error) {
console.error('Error deleting attachment:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Signature (Unterschrift) -----
// Serve the current signature image (for the preview on the Vorlagen page)
app.get('/unterschrift', (req, res) => {
const file = currentSignaturFile();
if (!file) return res.status(404).send('Keine Unterschrift');
res.sendFile(file);
});
// Upload / replace the signature
app.post('/unterschrift', (req, res) => {
uploadSignatur(req, res, (err) => {
try {
if (err) console.error('Signature upload error:', err.message);
if (req.file) {
// keep only the newly uploaded file
fs.readdirSync(signaturDir).forEach((f) => {
if (f !== req.file.filename) fs.promises.unlink(path.join(signaturDir, f)).catch(() => {});
});
}
res.redirect('/vorlagen');
} catch (error) {
console.error('Error saving signature:', error);
res.status(500).send('Serverfehler');
}
});
});
// Delete the signature
app.post('/unterschrift/delete', (req, res) => {
try {
fs.readdirSync(signaturDir).forEach((f) => fs.promises.unlink(path.join(signaturDir, f)).catch(() => {}));
} catch (e) { /* ignore */ }
res.redirect('/vorlagen');
});
// ----- Applicant photo (Bewerberfoto, used in the CV) -----
// Serve the current photo (for the preview on the Vorlagen page)
app.get('/bewerbungsfoto', (req, res) => {
const file = currentFotoFile();
if (!file) return res.status(404).send('Kein Bewerberfoto');
res.sendFile(file);
});
// Upload / replace the photo
app.post('/bewerbungsfoto', (req, res) => {
uploadFoto(req, res, (err) => {
try {
if (err) console.error('Photo upload error:', err.message);
if (req.file) {
// keep only the newly uploaded file
fs.readdirSync(fotoDir).forEach((f) => {
if (f !== req.file.filename) fs.promises.unlink(path.join(fotoDir, f)).catch(() => {});
});
}
res.redirect('/vorlagen');
} catch (error) {
console.error('Error saving photo:', error);
res.status(500).send('Serverfehler');
}
});
});
// Delete the photo
app.post('/bewerbungsfoto/delete', (req, res) => {
try {
fs.readdirSync(fotoDir).forEach((f) => fs.promises.unlink(path.join(fotoDir, f)).catch(() => {}));
} catch (e) { /* ignore */ }
res.redirect('/vorlagen');
});
// Download a generated attachment
app.get('/anhaenge/:id/download', async (req, res) => {
try {
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ?', [req.params.id]);
if (!anhang) return res.status(404).send('Anhang nicht gefunden');
const filePath = path.join(anhaengeDir, anhang.pfad);
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
// 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');
}
});
// 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));
runGeneration(id, { anlagenIds });
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error generating documents:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Jobangebote page (list page) -----
// The offers shown here are ingested by third-party software via the
// /api/v1/joboffers REST endpoint (POST). The page itself is read-only plus
// two manual actions: turn an offer into an application draft, or delete it.
// Count of open offers, consumed by the header badge (no auth — just a number).
app.get('/jobangebote/anzahl-offen', async (req, res) => {
try {
const row = await dbGet("SELECT COUNT(*) as count FROM jobangebote WHERE status = 'offen'");
res.json({ count: row ? row.count : 0 });
} catch (error) {
res.status(500).json({ count: 0 });
}
});
// Main list: only OPEN offers (still to be decided on). Taken-over offers
// live on their own page (/jobangebote/uebernommen).
app.get('/jobangebote', async (req, res) => {
try {
const jobangebote = await dbAll(
`SELECT j.*, b.datum AS bewerbung_datum
FROM jobangebote j
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id
WHERE j.status = 'offen'
ORDER BY j.created_at DESC, j.id DESC`
);
jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
const uebernommenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE status = 'uebernommen'");
res.render('jobangebote', {
jobangebote,
uebernommenCount: uebernommenRow ? uebernommenRow.c : 0,
artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS,
labelOptions: LABEL_OPTIONS,
hideSettings: false,
});
} catch (error) {
console.error('Error listing job offers:', error);
res.status(500).send('Serverfehler');
}
});
// Taken-over offers: which offers were turned into applications.
app.get('/jobangebote/uebernommen', async (req, res) => {
try {
const uebernommen = await dbAll(
`SELECT j.*, b.datum AS bewerbung_datum, b.status AS bewerbung_status
FROM jobangebote j
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id
WHERE j.status = 'uebernommen'
ORDER BY j.updated_at DESC, j.id DESC`
);
uebernommen.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
const offenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE status = 'offen'");
res.render('jobangebote_uebernommen', {
uebernommen,
offenCount: offenRow ? offenRow.c : 0,
hideSettings: false,
});
} catch (error) {
console.error('Error listing taken-over offers:', error);
res.status(500).send('Serverfehler');
}
});
// Convert a job offer into a Bewerbung draft (mirrors the Indeed import flow:
// creates a bewerbung with status "Entwurf", records the initial timeline entry,
// and links the offer back to it).
// Create a Bewerbung draft from an offer (or return the already-linked one).
// The offer's FULL description is carried into `stellenbeschreibung`, which is
// exactly the text handed to the LLM during generation. Returns the id.
async function uebernehmeAngebot(angebot) {
if (angebot.verknuepfte_bewerbung_id) return angebot.verknuepfte_bewerbung_id;
const datum = new Date().toISOString().split('T')[0];
const notizParts = [
angebot.ort ? `Ort: ${angebot.ort}` : null,
angebot.gehalt ? `Gehalt: ${angebot.gehalt}` : null,
angebot.kontakt_email ? `Kontakt: ${angebot.kontakt_email}` : null,
angebot.quelle_url ? `Quelle: ${angebot.quelle_url}` : null,
angebot.quelle ? `Importiert via: ${angebot.quelle}` : null,
].filter(Boolean);
// Employer address (street, house number, city) and contact person go into
// the AI notes so the LLM can use them for the letter's Anschriftfeld and
// salutation.
let anschrift = (angebot.adresse || '').trim();
if (angebot.ort && !anschrift.toLowerCase().includes(String(angebot.ort).toLowerCase())) {
anschrift = anschrift ? `${anschrift}, ${angebot.ort}` : String(angebot.ort);
}
const llmNotizen = [
anschrift ? `Anschrift des Arbeitgebers: ${anschrift}` : null,
angebot.ansprechpartner ? `Ansprechpartner: ${angebot.ansprechpartner}` : null,
].filter(Boolean).join('\n');
const result = await dbRun(
`INSERT INTO bewerbungen
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung,
quelle_url, email_empfaenger, llm_notizen, labels, generierung_status)
VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
[
datum,
sanitizeInput(angebot.firma),
sanitizeInput(angebot.stelle),
sanitizeInput(angebot.art || deriveArt(angebot.quelle_url, null)),
notizParts.join('\n'),
angebot.ort || '',
angebot.beschreibung || '',
angebot.quelle_url || '',
angebot.kontakt_email || '',
llmNotizen,
serializeLabels(angebot.labels),
]
);
await dbRun(
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
[result.lastID, datum, 'Entwurf', `Automatisch aus Jobangebot übernommen (${angebot.quelle || 'drittanbieter'})`]
);
await dbRun(
'UPDATE jobangebote SET verknuepfte_bewerbung_id = ?, status = "uebernommen", updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[result.lastID, angebot.id]
);
return result.lastID;
}
app.post('/jobangebote/:id/uebernehmen', async (req, res) => {
try {
const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
if (!angebot) return res.status(404).send('Jobangebot nicht gefunden');
const bewerbungId = await uebernehmeAngebot(angebot);
res.redirect('/bewerbung/' + bewerbungId);
} catch (error) {
console.error('Error converting job offer:', error);
res.status(500).send('Serverfehler');
}
});
// Edit an offer's fields — mainly to paste/adjust the full job description
// (any length) before turning it into an application.
app.post('/jobangebote/:id/bearbeiten', async (req, res) => {
try {
const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
if (!angebot) return res.status(404).send('Jobangebot nicht gefunden');
const b = req.body || {};
const quelleUrl = sanitizeInput(b.quelle_url || '');
await dbRun(
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
beschreibung = ?, kontakt_email = ?, quelle_url = ?, anzeige_datum = ?,
labels = ?, url_norm = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
sanitizeInput((b.firma || '').trim()) || angebot.firma,
sanitizeInput((b.stelle || '').trim()) || angebot.stelle,
sanitizeInput(b.ort || ''),
sanitizeInput(b.adresse || ''),
sanitizeInput(b.ansprechpartner || ''),
sanitizeInput(b.gehalt || ''),
sanitizeInput(b.beschreibung || ''),
sanitizeInput(b.kontakt_email || ''),
quelleUrl,
sanitizeInput(b.anzeige_datum || ''),
serializeLabels(b.labels),
blacklist.normalizeUrl(b.quelle_url || '') || null,
req.params.id,
]
);
res.redirect('/jobangebote');
} catch (error) {
console.error('Error editing job offer:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a job offer. Deleting always blacklists it first, so the same offer
// can never be ingested/listed again (the requirement: never appears twice,
// even after deletion). The entry can be removed later on /blacklist.
app.post('/jobangebote/:id/delete', async (req, res) => {
try {
const offer = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
if (offer) {
await autoBlacklistOffer(offer, 'Jobangebot gelöscht (Web-UI)');
await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]);
}
res.redirect('/jobangebote');
} catch (error) {
console.error('Error deleting job offer:', error);
res.status(500).send('Serverfehler');
}
});
// Blacklist management page — see what is blocked and remove entries.
app.get('/blacklist', async (req, res) => {
try {
const eintraege = await dbAll(
'SELECT * FROM jobangebote_blacklist ORDER BY created_at DESC, id DESC'
);
res.render('blacklist', { eintraege, blacklistTypen: blacklist.TYPES, hideSettings: false });
} catch (error) {
console.error('Error listing blacklist:', error);
res.status(500).send('Serverfehler');
}
});
// Add a manual blacklist entry (URL / domain / company / company+title).
app.post('/blacklist', async (req, res) => {
try {
const entry = blacklist.buildManualEntry({
typ: req.body.typ,
wert: sanitizeInput(req.body.wert || ''),
firma: sanitizeInput(req.body.firma || ''),
stelle: sanitizeInput(req.body.stelle || ''),
ort: sanitizeInput(req.body.ort || ''),
grund: sanitizeInput(req.body.grund || ''),
});
if (entry) await insertBlacklistEntry(entry);
res.redirect('/blacklist');
} catch (error) {
console.error('Error adding blacklist entry:', error);
res.status(500).send('Serverfehler');
}
});
// Remove a blacklist entry (offer may then be ingested again).
app.post('/blacklist/:id/delete', async (req, res) => {
try {
await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ?', [req.params.id]);
res.redirect('/blacklist');
} catch (error) {
console.error('Error deleting blacklist entry:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Conversational KI-Chat (Ollama, streaming) -----
// Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the
// assistant answer is streamed back via Server-Sent Events.
async function gatherChatContext() {
const [settings, applications, recentEmails, upcoming] = await Promise.all([
dbGet('SELECT name FROM settings WHERE id = 1'),
dbAll(`SELECT firma, stelle, status, datum, notizen FROM bewerbungen
ORDER BY datum DESC, created_at DESC LIMIT 25`),
dbAll(`SELECT e.subject, e.from_addr AS from, b.firma AS bewerbung
FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id
WHERE e.direction = 'received'
ORDER BY e.email_date DESC, e.created_at DESC LIMIT 12`),
upcomingTermine(8),
]);
return {
settings: settings || {},
applications: applications.map((a) => ({
firma: a.firma, stelle: a.stelle, status: a.status,
datum: a.datum, notizen: (a.notizen || '').slice(0, 240),
})),
recentEmails: recentEmails.map((e) => ({
subject: e.subject, from: e.from_addr, bewerbung: e.bewerbung,
})),
upcomingTermine: upcoming.map((t) => ({
titel: t.titel, start: t.start, bewerbung: t.bewerbung_firma,
})),
};
}
// Chat page: list threads + render the active thread (or a fresh empty one).
app.get('/chat', async (req, res) => {
if (!chat.isConfigured()) return res.status(503).send('KI-Chat deaktiviert OLLAMA_API_KEY fehlt.');
try {
const threads = await dbAll(
'SELECT id, titel, updated_at FROM chat_threads ORDER BY updated_at DESC'
);
const activeId = req.query.thread ? Number(req.query.thread) : (threads[0] && threads[0].id);
let messages = [];
if (activeId) {
messages = await dbAll(
'SELECT id, role, content, created_at FROM chat_messages WHERE thread_id = ? ORDER BY id ASC',
[activeId]
);
}
res.render('chat', {
threads, activeId, messages,
hasApiKey: true, hideSettings: false,
});
} catch (error) {
console.error('Chat page error:', error);
res.status(500).send('Serverfehler');
}
});
// Create a new thread. Optional `titel` in the body.
app.post('/chat/api/threads', async (req, res) => {
try {
const titel = sanitizeInput((req.body.titel || '').trim()).slice(0, 120) || null;
const { lastID } = await dbRun('INSERT INTO chat_threads (titel) VALUES (?)', [titel]);
res.json({ id: lastID, titel });
} catch (error) {
console.error('Create thread error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Delete a thread (cascades to its messages).
app.delete('/chat/api/threads/:id', async (req, res) => {
try {
await dbRun('DELETE FROM chat_threads WHERE id = ?', [Number(req.params.id)]);
res.json({ ok: true });
} catch (error) {
console.error('Delete thread error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Rename a thread (e.g. auto-title from first message).
app.patch('/chat/api/threads/:id', async (req, res) => {
try {
const titel = sanitizeInput((req.body.titel || '').trim()).slice(0, 120);
await dbRun('UPDATE chat_threads SET titel = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[titel, Number(req.params.id)]);
res.json({ ok: true });
} catch (error) {
console.error('Rename thread error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Send a user message and stream the assistant reply via SSE.
app.post('/chat/api/threads/:id/messages', async (req, res) => {
if (!chat.isConfigured()) return res.status(503).json({ error: 'KI-Chat deaktiviert.' });
const threadId = Number(req.params.id);
const userText = sanitizeInput((req.body.content || '').trim());
if (!userText) return res.status(400).json({ error: 'Leere Nachricht.' });
let thread;
try {
thread = await dbGet('SELECT id, titel FROM chat_threads WHERE id = ?', [threadId]);
} catch (e) { /* fall through */ }
if (!thread) return res.status(404).json({ error: 'Thread nicht gefunden.' });
// Persist the user message, then load the full prior history for context.
try {
await dbRun('INSERT INTO chat_messages (thread_id, role, content) VALUES (?, ?, ?)',
[threadId, 'user', userText]);
await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [threadId]);
// Auto-title the thread from the first user message, if untitled.
if (!thread.titel) {
const first = await dbGet('SELECT content FROM chat_messages WHERE thread_id = ? ORDER BY id ASC LIMIT 1', [threadId]);
if (first) {
const t = first.content.slice(0, 60).replace(/\s+/g, ' ').trim();
if (t) await dbRun('UPDATE chat_threads SET titel = ? WHERE id = ? AND (titel IS NULL OR titel = "")', [t, threadId]);
}
}
} catch (error) {
console.error('Persist user message error:', error);
return res.status(500).json({ error: 'Serverfehler' });
}
let history;
try {
history = await dbAll(
'SELECT role, content FROM chat_messages WHERE thread_id = ? ORDER BY id ASC',
[threadId]
);
} catch (error) {
return res.status(500).json({ error: 'Serverfehler' });
}
// SSE setup. Keep the connection alive; flush headers immediately.
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders && res.flushHeaders();
const send = (obj) => {
res.write(`data: ${JSON.stringify(obj)}\n\n`);
};
// AbortController so a closed client stops the upstream Ollama stream.
const controller = new AbortController();
let aborted = false;
req.on('close', () => { aborted = true; controller.abort(); });
// Trim very old history to bound token cost (keep the last 20 turns).
const trimmed = history.slice(-40);
const messages = trimmed.map((m) => ({ role: m.role, content: m.content }));
let context;
try { context = await gatherChatContext(); }
catch (e) { context = {}; }
const system = chat.buildContextPrompt(context);
let assistantText = '';
try {
assistantText = await chat.streamChat({
system,
messages,
signal: controller.signal,
onToken: (delta) => send({ type: 'token', content: delta }),
});
} 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]);
send({ type: 'done', messageId: lastID, content: saved });
} catch (error) {
send({ type: 'error', message: 'Antwort konnte nicht gespeichert werden.' });
}
res.end();
});
// ----- Third-party REST API (/api/v1) + OpenAPI/Swagger -----
// API key for third-party software. When unset, the API responds 503 on
// every endpoint except /health — it never silently exposes data.
const apiToken = process.env.API_TOKEN || '';
app.use('/api/v1', createExternalApi({
dbGet,
dbAll,
dbRun,
sanitizeInput,
attachVerlauf,
findDuplicateApplications,
syncCurrentStatus,
runGeneration,
anhaengeDir,
emailAnhaengeDir,
apiToken,
}));
// Serve the OpenAPI document, with the real request host injected as server.
app.get('/swagger.json', (req, res) => {
const proto = req.get('x-forwarded-proto') || req.protocol;
const host = req.get('host') || `localhost:${PORT}`;
res.json(buildOpenApiSpec(`${proto}://${host}`));
});
// Swagger UI (loaded from CDN; consistent with the app's other CDN usage).
app.get('/swagger', (req, res) => {
const proto = req.get('x-forwarded-proto') || req.protocol;
const host = req.get('host') || `localhost:${PORT}`;
const specUrl = `${proto}://${host}/swagger.json`;
res.type('text/html').send(`<!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(process.env.MAIL_POLL_MS) || 180000);
setTimeout(() => { pollInbox().catch(() => {}); }, 8000); // initial fetch after boot
setInterval(() => { pollInbox().catch(() => {}); }, pollMs); // periodic fetch
} else {
console.log('E-Mail nicht konfiguriert (MAIL_HOST/MAIL_USER/MAIL_PASSWORD fehlen) - Versand/Empfang deaktiviert.');
}
// Calendar: reconcile our appointments with the SOGo CalDAV calendar.
if (caldav.isConfigured()) {
console.log(`Kalender aktiv: CalDAV ${caldav.collectionUrl()}`);
const calPoll = Math.max(60000, Number(process.env.CALDAV_POLL_MS) || 300000);
setTimeout(() => { refreshCaldav().catch(() => {}); }, 10000); // initial sync after boot
setInterval(() => { refreshCaldav().catch(() => {}); }, calPoll); // periodic reconcile
} else {
console.log('Kalender nicht konfiguriert (CALDAV_URL fehlt) - Kalender-Funktionen deaktiviert.');
}
// Handle 404
app.use((req, res) => {
res.status(404).send('Seite nicht gefunden');
});
}).catch((err) => {
console.error('Failed to initialize database:', err);
process.exit(1);
});
// Close database on exit
process.on('SIGINT', () => {
db.close();
process.exit();
});
process.on('SIGTERM', () => {
db.close();
process.exit();
});