Files
jobbi-bewerbung/server.js
T
thomasandClaude Opus 4.8 d86e40a6ff KI-Feinschliff, Volltextsuche über E-Mails, Prompt für die Antwort-Mail
Drei Erweiterungen, die sich server.js und lib/documents.js teilen und
deshalb zusammen liegen:

Jobsuche: Der Feinschliff (Zusatzbegriffe/Ausschluesse) laesst sich per KI
aus den eigenen Basis-Unterlagen vorschlagen. Der Rollen-Prompt ist wie die
uebrigen unter Vorlagen editierbar; gespeichert wird erst auf Klick.

Suche: Startseite und KI-Chat durchsuchen jetzt per SQLite-FTS5 auch die
E-Mail-Korrespondenz (Absender, Betreff, Text), Notizen und
Stellenbeschreibung - ein Name aus einer Mail findet die Bewerbung. Ranking
per bm25, Fundstellen mit Snippet, Feldfilter (firma:, von:), Phrasen,
Ausschluesse, Umlaut-Varianten (mueller/muller/mueller). Der Index wird von
SQL-Triggern gepflegt, weil Bewerbungen und Mails aus UI, REST-API, IMAP und
KI-Import geschrieben werden.

E-Mail: Vor dem Generieren laesst sich vorgeben, was in der Antwort stehen
soll; die Vorgabe geht als verbindliche Anweisung in den Prompt. Steht schon
ein Entwurf im Feld, ueberarbeitet die KI ihn, statt neu anzufangen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:56:41 +02:00

4223 lines
178 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 crypto = require('crypto');
const dns = require('dns');
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, generateFeinschliff, renderDesignVorschau,
DOKUMENT_TYPEN, normalizeDokumente,
} = require('./lib/documents');
const chat = require('./lib/chat');
const promptStore = require('./lib/prompts');
const designStore = require('./lib/design');
const mailer = require('./lib/mailer');
const { createExternalApi } = require('./lib/api');
const { buildOpenApiSpec } = require('./lib/openapi');
const blacklist = require('./lib/blacklist');
const caldav = require('./lib/caldav');
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./lib/labels');
const config = require('./lib/config');
const { userContext, currentUser, currentUserId } = require('./lib/context');
const password = require('./lib/password');
const migrate = require('./lib/migrate-multiuser');
const { runMigration, importEnvIntoAdmin } = migrate;
const suchprofil = require('./lib/suchprofil');
const suche = require('./lib/suche');
const app = express();
const PORT = process.env.PORT || 3000;
// Shared option lists (used in multiple views)
const ART_OPTIONS = [
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone',
'Firmenwebsite', 'Post', 'Initiativbewerbung',
'Arbeitsagentur', 'Sonstiges'
];
const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung'
];
// Base document types the user can provide as a foundation for AI tailoring
const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges'];
// Pick the application "source" (art) for a browser-captured job. Honour an
// explicit value from the extension, otherwise infer it from the URL host so a
// capture from any website is labelled sensibly.
function deriveArt(url, provided) {
if (provided && ART_OPTIONS.includes(provided)) return provided;
const host = (String(url || '').match(/^https?:\/\/([^/]+)/i) || [, ''])[1].toLowerCase();
if (!host) return 'Sonstiges';
if (host.includes('indeed')) return 'Indeed';
if (host.includes('stepstone')) return 'StepStone';
if (host.includes('arbeitsagentur')) return 'Arbeitsagentur';
if (/(linkedin|xing|monster|stellenanzeigen|kimeta|glassdoor|jobware|meinestadt|jobs\.|karriere\.)/.test(host)) return 'Online-Portal';
return 'Firmenwebsite';
}
// Trust the Caddy reverse proxy (TLS terminator) so req.secure / req.ip reflect
// the real client connection — needed for the Secure cookie flag and rate limit.
app.set('trust proxy', 1);
// Don't advertise the framework version.
app.disable('x-powered-by');
// Express ships with helpful dev-mode error pages; in production it never leaks
// stack traces. We run behind Docker, so default to production unless overridden.
app.set('env', process.env.NODE_ENV || 'production');
// --- Security headers ----------------------------------------------------
// No helmet dependency: a small middleware sets the headers that matter. The
// CSP is intentionally permissive about *inline* scripts/styles (the views use a
// lot of inline <script>/<style>) but strict about everything else — it blocks
// external scripts, object/embed, framing of the app (clickjacking), base-uri
// hijacks and cross-origin forms/connects. Registered before express.static so
// static responses carry the headers too.
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'",
"style-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; '));
next();
});
// URL scheme allowlist for any value placed into an href/src in the views.
// Returns the URL unchanged for http(s)/mailto or a relative path, else '#' so a
// stored `javascript:` payload becomes an inert anchor (EJS escapes the result).
app.locals.safeUrl = function (url) {
const u = String(url == null ? '' : url).trim();
if (!u) return '';
// Protocol-relative URLs (//evil.com) would navigate off-site on click.
if (u.startsWith('//') || u.startsWith('\\/')) return '#';
// Anything that looks like scheme:url must be an allowed scheme.
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(u) && !/^(https?|mailto):/i.test(u)) return '#';
return u;
};
// Middleware
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
app.use(express.static(path.join(__dirname, 'public')));
// Allow the browser extension (running on indeed.com) to call the import API.
// Kept narrow: only the extension-facing endpoints need cross-origin access.
app.use('/api/indeed-import', (req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
// Set EJS as template engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// ---------------------------------------------------------------------------
// Auth: session cookie -> req.user + per-request user context
// ---------------------------------------------------------------------------
// Loads the session (if any) from the cookie and stores the resolved user on
// req.user / res.locals.user. The whole request then runs inside the
// AsyncLocalStorage user context, so the libs (config/mailer/caldav/chat) and
// the per-user query helpers below read the current user transparently.
app.use(async (req, res, next) => {
try {
const cookies = parseCookies(req.headers.cookie);
const token = cookies[SESSION_COOKIE];
const user = await loadSessionUser(token);
req.user = user;
req.sessionToken = token;
res.locals.user = user;
// impersonator is set when an admin is acting as another user; the header
// shows a banner and the "switch back" action uses it.
const impersonator = user && user.impersonator ? user.impersonator : null;
req.impersonator = impersonator;
res.locals.impersonator = impersonator;
// The header highlights the section you are in; it needs the current path.
res.locals.pfad = req.path;
// Warm this user's cfg rows before anything reads them: config.get() is
// synchronous and answers from the per-user cache, so a user whose rows were
// never loaded would silently read as "unconfigured". Until now the process
// .env fallback papered over that (it happened to hold the admin's values);
// with the fallback gone, the config must actually be loaded per request.
if (user) await config.ensureLoaded(user.id);
userContext.run(user, next);
} catch (e) {
console.error('Session-Laden fehlgeschlagen:', e.message);
req.user = null;
res.locals.user = null;
next();
}
});
// Paths that do not require an authenticated session. The external /api/v1 has
// its own X-API-Key auth; everything else below requireAuth needs a session.
const PUBLIC_PATHS = new Set(['/login', '/logout']);
function isPublicPath(p) {
if (PUBLIC_PATHS.has(p)) return true;
if (p === '/health' || p === '/swagger' || p === '/swagger.json' || p === '/api-docs') return true;
if (p.startsWith('/api/v1/')) return true; // own X-API-Key auth
return false;
}
// Require an authenticated user. Browser requests are redirected to /login; API
// requests get 401 JSON. Must be registered before any protected route.
function requireAuth(req, res, next) {
if (req.user) return next();
if (isPublicPath(req.path)) return next();
const wantsJson = (req.get('accept') || '').includes('application/json')
|| req.path.startsWith('/api/')
|| req.xhr;
if (wantsJson) return res.status(401).json({ error: 'Nicht angemeldet.' });
return res.redirect('/login');
}
// Simple in-memory login throttling keyed by client IP. After 8 failed attempts
// within 15 minutes the IP is locked out for 15 minutes, which blunts online
// password guessing. Cleared on the next successful login. Process restart wipes
// it (acceptable — it is a rate limiter, not a persistent record).
const loginFails = new Map();
const LOGIN_WINDOW = 15 * 60 * 1000;
const LOGIN_MAX = 8;
function clientIp(req) {
return String((req.ip || (req.socket && req.socket.remoteAddress) || '?'));
}
function loginGate(req) {
const rec = loginFails.get(clientIp(req));
return !rec || !rec.lock || rec.lock <= Date.now();
}
function loginFail(req) {
const ip = clientIp(req);
const now = Date.now();
let rec = loginFails.get(ip);
if (!rec || now - rec.since > LOGIN_WINDOW) rec = { since: now, count: 0, lock: 0 };
rec.count++;
if (rec.count >= LOGIN_MAX) rec.lock = now + LOGIN_WINDOW;
loginFails.set(ip, rec);
}
function loginOk(req) {
loginFails.delete(clientIp(req));
}
// Login page + form handler.
app.get('/login', (req, res) => {
if (req.user) return res.redirect('/');
res.render('login', { error: null, username: '' });
});
app.post('/login', async (req, res) => {
const { username, password: plain } = req.body || {};
if (!loginGate(req)) {
return res.status(429).render('login', { error: 'Zu viele Versuche. Bitte später erneut versuchen.', username: username || '' });
}
const user = await authenticate(username, plain);
if (!user) {
loginFail(req);
return res.status(401).render('login', { error: 'Benutzername oder Passwort falsch.', username: username || '' });
}
loginOk(req);
const token = await createSession(user.id);
setSessionCookie(res, token);
res.redirect('/');
});
app.post('/logout', async (req, res) => {
const cookies = parseCookies(req.headers.cookie);
await destroySession(cookies[SESSION_COOKIE]);
clearSessionCookie(res);
res.redirect('/login');
});
// `GET /logout` is convenient for the header link (no JS needed).
app.get('/logout', (req, res) => { clearSessionCookie(res); res.redirect('/login'); });
// Protect everything registered below this point.
app.use(requireAuth);
// Ensure data directory exists
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Directory for generated attachment files (application documents)
const anhaengeDir = path.join(dataDir, 'anhaenge');
if (!fs.existsSync(anhaengeDir)) {
fs.mkdirSync(anhaengeDir, { recursive: true });
}
// Directory for attachments received via IMAP (reply e-mails).
const emailAnhaengeDir = path.join(dataDir, 'email_anhaenge');
if (!fs.existsSync(emailAnhaengeDir)) {
fs.mkdirSync(emailAnhaengeDir, { recursive: true });
}
// Resolve a per-user storage directory (data/<base>/<userId>/), creating it on
// first use. Used for every attachment / signature / photo path so users' files
// are isolated on disk the same way their DB rows are. Reads the current user
// from the request/task context (lib/context.js).
function userStorageDir(base) {
const dir = path.join(base, String(currentUserId()));
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
// Same as userStorageDir but resolves the user id from req.user explicitly (used
// inside multer callbacks, where we prefer the request's user over the async
// context to stay robust against callback timing).
function userStorageDirForReq(base, req) {
const id = (req && req.user && req.user.id) || currentUserId();
const dir = path.join(base, String(id));
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
// Serve a stored file inline ONLY for a small allow-list of viewable types,
// decided by the file *extension* (never a client-/sender-supplied MIME). Every
// other type is forced to a download, so an uploaded .html / .svg / odd MIME can
// never render as an active document in the app origin (stored XSS).
const INLINE_TYPES = {
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
};
function serveInline(res, filePath, filename, mime) {
res.setHeader('X-Content-Type-Options', 'nosniff');
const ext = path.extname(filename || filePath).toLowerCase();
const type = INLINE_TYPES[ext] || INLINE_TYPES[path.extname(filePath).toLowerCase()];
const safe = safeFilename(filename).replace(/["\r\n]/g, '');
if (type) {
res.type(type);
res.setHeader('Content-Disposition',
`inline; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
return res.sendFile(filePath);
}
// Not a viewable type → download as an attachment, never inline.
res.setHeader('Content-Disposition',
`attachment; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
res.download(filePath, safe);
}
// --- 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 (stored under the user's subdir)
// Reject uploads whose extension is not on the allow-list. The on-disk name is
// already sanitised by the filename callback, but this stops a renamed .html /
// .svg from being stored at all (defense-in-depth against stored XSS via inline
// serving) and keeps the stored MIME trustworthy.
const ATTACHMENT_FILTER = (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const ok = /^\.(pdf|png|jpe?g|gif|webp|docx?|rtf|odt|ods|txt|csv|xlsx?)$/.test(ext);
cb(ok ? null : new Error('Dateityp nicht erlaubt'), ok);
};
const uploadBasisAnhang = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, userStorageDirForReq(basisAnhaengeDir, req)),
filename: (req, file, cb) => {
const safe = String(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
cb(null, `${Date.now()}_${safe}`);
},
}),
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
fileFilter: ATTACHMENT_FILTER,
}).single('datei');
// Directory for private attachments the user keeps alongside the internal
// notes of an application. These are never exported into the PDF and never
// sent with an application — they are for the user only.
const interneAnhaengeDir = path.join(dataDir, 'interne_anhaenge');
if (!fs.existsSync(interneAnhaengeDir)) {
fs.mkdirSync(interneAnhaengeDir, { recursive: true });
}
const uploadInterneAnhang = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, userStorageDirForReq(interneAnhaengeDir, req)),
filename: (req, file, cb) => {
const safe = String(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
cb(null, `${Date.now()}_${safe}`);
},
}),
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
fileFilter: ATTACHMENT_FILTER,
}).single('datei');
// Directory + uploader for the applicant's signature image (used in the letter)
const signaturDir = path.join(dataDir, 'signatur');
if (!fs.existsSync(signaturDir)) {
fs.mkdirSync(signaturDir, { recursive: true });
}
const uploadSignatur = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, userStorageDirForReq(signaturDir, req)),
// Derive the extension from the validated MIME, not the user-supplied name,
// so a "x.png"-named .html can never land on disk as signatur_*.html.
filename: (req, file, cb) => {
const ext = /jpe?g/i.test(file.mimetype) ? '.jpg' : '.png';
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)
&& /^\.(png|jpe?g)$/.test(path.extname(file.originalname).toLowerCase())),
}).single('signatur');
// The single stored signature file, if any (in the current user's subdir).
function currentSignaturFile() {
try {
const dir = userStorageDir(signaturDir);
const files = fs.readdirSync(dir).filter((f) => !f.startsWith('.'));
return files.length ? path.join(dir, files[0]) : null;
} catch (e) {
return null;
}
}
// Read the signature as a data URL + jsPDF format, for embedding in the letter.
function loadSignatur() {
const file = currentSignaturFile();
if (!file) return null;
try {
const ext = path.extname(file).toLowerCase();
const format = (ext === '.jpg' || ext === '.jpeg') ? 'JPEG' : 'PNG';
const mime = format === 'JPEG' ? 'image/jpeg' : 'image/png';
const b64 = fs.readFileSync(file).toString('base64');
return { dataUrl: `data:${mime};base64,${b64}`, format };
} catch (e) {
return null;
}
}
// Directory + uploader for the applicant's portrait photo (used in the CV)
const fotoDir = path.join(dataDir, 'bewerberfoto');
if (!fs.existsSync(fotoDir)) {
fs.mkdirSync(fotoDir, { recursive: true });
}
const uploadFoto = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, userStorageDirForReq(fotoDir, req)),
filename: (req, file, cb) => {
const ext = /jpe?g/i.test(file.mimetype) ? '.jpg' : '.png';
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)
&& /^\.(png|jpe?g)$/.test(path.extname(file.originalname).toLowerCase())),
}).single('foto');
// The single stored photo file, if any (in the current user's subdir).
function currentFotoFile() {
try {
const dir = userStorageDir(fotoDir);
const files = fs.readdirSync(dir).filter((f) => !f.startsWith('.'));
return files.length ? path.join(dir, files[0]) : null;
} catch (e) {
return null;
}
}
// Read the photo as a data URL + jsPDF format, for embedding in the CV.
function loadFoto() {
const file = currentFotoFile();
if (!file) return null;
try {
const ext = path.extname(file).toLowerCase();
const format = (ext === '.jpg' || ext === '.jpeg') ? 'JPEG' : 'PNG';
const mime = format === 'JPEG' ? 'image/jpeg' : 'image/png';
const b64 = fs.readFileSync(file).toString('base64');
return { dataUrl: `data:${mime};base64,${b64}`, format };
} catch (e) {
return null;
}
}
// Database setup
const dbPath = path.join(dataDir, 'bewerbungen.db');
const db = new sqlite3.Database(dbPath);
// Sanitize input to prevent XSS
function sanitizeInput(input) {
if (typeof input !== 'string') return input;
return input
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// --- File / path safety helpers -----------------------------------------
// Strip a stored/display filename down to a single safe segment: no path
// separators, no traversal, only [A-Za-z0-9._-]. multer already sanitises the
// on-disk name, but display names (basis_anhaenge.dateiname) are stored raw and
// were later re-used to build a path — which let a crafted name escape its dir.
function safeFilename(name) {
return String(name == null ? 'datei' : name)
.replace(/[^a-zA-Z0-9._-]/g, '_')
.replace(/^\.+/, '') // no leading dots
.slice(0, 200) || 'datei';
}
// Sanitise a *display* filename for the Content-Disposition header: keep
// unicode (umlauts etc.) but strip control chars / quotes / backslashes that
// could inject or break an HTTP header. Used for download names shown to the
// user — never for building an on-disk path (use safeFilename / containedPath).
function displayFilename(name) {
return String(name == null ? 'datei' : name)
.replace(/[\x00-\x1f"\r\n\\]/g, '_')
.replace(/^\.+/, '')
.slice(0, 200) || 'datei';
}
// Resolve a stored relative path inside a user's base directory and assert the
// result actually stays inside it (defense-in-depth against stored traversal).
// Returns the absolute path, or null if it would escape `base`.
function containedPath(base, rel) {
const root = path.resolve(base);
const abs = path.resolve(root, String(rel == null ? '' : rel));
if (abs !== root && !abs.startsWith(root + path.sep)) return null;
return abs;
}
// Only allow redirect targets that are same-origin relative paths, so a crafted
// `back=https://evil.com` cannot bounce the user to an attacker site.
function safeRedirect(target) {
const t = String(target == null ? '' : target).trim();
if (!t || t.startsWith('//') || t.startsWith('\\/')) return '/';
if (!/^[/?#]/.test(t)) return '/';
return t;
}
// --- SSRF guard for user-configured external hosts -----------------------
// Block addresses that would let a user point the server at cloud metadata /
// link-local services (the high-severity SSRF: stealing the instance's cloud
// credentials). Loopback and private ranges stay allowed on purpose: the app
// explicitly supports a local Ollama / CalDAV on the same host or LAN.
function isBlockedSsrfIp(ip) {
if (!ip) return true;
const m = ip.split('.');
if (m.length === 4 && m.every((x) => /^\d+$/.test(x))) {
const [a, b] = m.map(Number);
if (a === 0) return true; // 0.0.0.0/8
if (a === 169 && b === 254) return true; // link-local / cloud metadata
}
if (ip === '::' || ip === '0:0:0:0:0:0:0:0') return true;
if (/^fe80:/i.test(ip)) return true; // IPv6 link-local
return false;
}
function resolveHost(host) {
return new Promise((resolve) => {
dns.lookup(host, { all: true }, (err, addrs) => {
if (err || !addrs || !addrs.length) return resolve([]);
resolve(addrs.map((a) => a.address));
});
});
}
async function assertSafeUrl(raw, { requireTls = false } = {}) {
let u;
try { u = new URL(String(raw)); } catch { throw new Error('Ungültige URL'); }
if (!/^https?:$/.test(u.protocol)) throw new Error('Nur http/https erlaubt');
if (requireTls && u.protocol !== 'https:') throw new Error('HTTPS erforderlich');
if (isBlockedSsrfIp(u.hostname)) throw new Error('Interne Adresse nicht erlaubt');
for (const ip of await resolveHost(u.hostname)) {
if (isBlockedSsrfIp(ip)) throw new Error('Interne Adresse nicht erlaubt');
}
}
async function assertSafeHost(raw) {
const h = String(raw || '').split(':')[0].trim();
if (!h) throw new Error('Host fehlt');
if (isBlockedSsrfIp(h)) throw new Error('Interne Adresse nicht erlaubt');
for (const ip of await resolveHost(h)) {
if (isBlockedSsrfIp(ip)) throw new Error('Interne Adresse nicht erlaubt');
}
}
// Promise wrapper for db operations
function dbGet(sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
}
function dbAll(sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, results) => {
if (err) reject(err);
else resolve(results);
});
});
}
function dbRun(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err);
else resolve({ lastID: this.lastID, changes: this.changes });
});
});
}
// ---------------------------------------------------------------------------
// Multi-user: sessions, login, per-request user context
// ---------------------------------------------------------------------------
const SESSION_COOKIE = 'sid';
const SESSION_MAX_AGE = 30 * 24 * 3600; // 30 days, in seconds
// Minimal cookie parser (no cookie-parser dependency): { name: value }.
function parseCookies(header) {
const out = {};
if (!header) return out;
for (const part of String(header).split(';')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
const k = part.slice(0, eq).trim();
const v = part.slice(eq + 1).trim();
if (k) out[k] = decodeURIComponent(v);
}
return out;
}
// Create a session row for a user and return the opaque token to store in the cookie.
async function createSession(userId) {
const token = crypto.randomBytes(32).toString('hex');
await dbRun('INSERT INTO sessions (token, user_id) VALUES (?, ?)', [token, userId]);
return token;
}
async function destroySession(token) {
if (token) await dbRun('DELETE FROM sessions WHERE token = ?', [token]).catch(() => {});
}
// Resolve the user a session token belongs to, or null. Touches last_seen and
// enforces the server-side absolute session expiry: a cookie is only valid for
// SESSION_MAX_AGE after the last activity, regardless of the client-side
// maxAge (which a client can tamper with). Expired rows are deleted.
//
// When the session is an impersonation (an admin "logging in as" a user), the
// returned user is the *target*; `impersonator` carries the original admin so
// the app can show a banner and offer a "switch back" action. While
// impersonating, the effective user has no admin rights (see requireAdmin) —
// the admin is debugging the user's account, not escalating.
async function loadSessionUser(token) {
if (!token) return null;
const row = await dbGet(
`SELECT u.id AS id, u.username AS username, u.is_admin AS is_admin,
s.created_at AS created_at, s.last_seen AS last_seen,
s.impersonator_id AS impersonator_id,
i.username AS impersonator_username, i.is_admin AS impersonator_is_admin
FROM sessions s
JOIN users u ON u.id = s.user_id
LEFT JOIN users i ON i.id = s.impersonator_id
WHERE s.token = ?`,
[token]
);
if (!row) return null;
const stamp = row.last_seen || row.created_at; // 'YYYY-MM-DD HH:MM:SS' (UTC)
const last = new Date(stamp + 'Z');
if (isNaN(last.getTime()) || (Date.now() - last.getTime()) / 1000 > SESSION_MAX_AGE) {
await dbRun('DELETE FROM sessions WHERE token = ?', [token]).catch(() => {});
return null;
}
await dbRun('UPDATE sessions SET last_seen = CURRENT_TIMESTAMP WHERE token = ?', [token]).catch(() => {});
const user = { id: row.id, username: row.username, is_admin: !!row.is_admin };
if (row.impersonator_id) {
user.impersonator = { id: row.impersonator_id, username: row.impersonator_username, is_admin: !!row.impersonator_is_admin };
}
return user;
}
// Find a user by username + password (login check). Returns the user object or null.
// When the username is unknown we still run a dummy scrypt verify against a fixed
// hash, so a missing user takes the same time as a wrong password (no user
// enumeration via timing).
async function authenticate(username, plain) {
const row = await dbGet('SELECT id, username, password_hash, is_admin FROM users WHERE username = ?', [username || '']);
if (!row) {
await password.verify(plain || '', password.DUMMY_HASH);
return null;
}
if (!(await password.verify(plain || '', row.password_hash))) return null;
return { id: row.id, username: row.username, is_admin: !!row.is_admin };
}
// Set/clear the session cookie on a response. The Secure flag is set whenever
// the request arrived over TLS (Caddy terminates it; trust proxy lets us see
// that via req.secure), so the cookie is never leaked over plain HTTP.
function setSessionCookie(res, token) {
const secure = !!(res.req && res.req.secure);
res.cookie(SESSION_COOKIE, token, {
httpOnly: true, sameSite: 'lax', path: '/', maxAge: SESSION_MAX_AGE * 1000, secure,
});
}
function clearSessionCookie(res) {
res.clearCookie(SESSION_COOKIE, { path: '/' });
}
// ---------------------------------------------------------------------------
// Job-offer blacklist helpers (shared shape with lib/api.js)
// ---------------------------------------------------------------------------
// Insert one prepared blacklist entry (see lib/blacklist.buildManual/AutoEntry).
async function insertBlacklistEntry(entry) {
const cols = blacklist.COLUMNS;
const placeholders = cols.map(() => '?').join(', ');
const values = cols.map((c) => (entry[c] === undefined ? null : entry[c]));
return dbRun(
`INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${placeholders})`,
[currentUserId(), ...values]
);
}
// Auto-blacklist an offer row so it can never be ingested again, then it is safe
// to delete. Skips silently if the offer is already covered by an entry.
async function autoBlacklistOffer(offer, grund) {
if (!offer) return;
const rows = await dbAll('SELECT * FROM jobangebote_blacklist WHERE user_id = ?', [currentUserId()]);
if (blacklist.matchBlacklist(rows, offer)) return; // already blocked
await insertBlacklistEntry(blacklist.buildAutoEntry(offer, grund));
}
// ---------------------------------------------------------------------------
// Application calendar (CalDAV) helpers
// ---------------------------------------------------------------------------
// Upcoming appointments (not yet ended), newest first, for the dashboard widget.
async function upcomingTermine(limit = 6) {
const now = new Date().toISOString();
return dbAll(
`SELECT t.*, b.firma AS bewerbung_firma, b.stelle AS bewerbung_stelle
FROM termine t LEFT JOIN bewerbungen b ON b.id = t.bewerbung_id
WHERE t.user_id = ? AND COALESCE(t.ende, t.start) >= ?
ORDER BY t.start ASC LIMIT ?`,
[currentUserId(), now, limit]
);
}
// Reconcile our tracked appointments with the SOGo calendar: reflect remote
// edits and drop entries deleted remotely. Cheap ctag check first. Best-effort.
async function refreshCaldav() {
if (!caldav.isConfigured()) return;
const ctag = await caldav.getCtag().catch(() => null);
if (ctag) {
const prev = await getState('caldav_ctag');
if (prev && prev === ctag) return;
}
const from = new Date(Date.now() - 24 * 3600 * 1000);
const to = new Date(Date.now() + 180 * 24 * 3600 * 1000);
const remote = await caldav.listEvents({ from, to });
const byUid = new Map(remote.map((e) => [e.uid, e]));
const local = await dbAll(
'SELECT * FROM termine WHERE user_id = ? AND caldav_uid IS NOT NULL AND start >= ? AND start <= ?',
[currentUserId(), from.toISOString(), to.toISOString()]
);
for (const t of local) {
const r = byUid.get(t.caldav_uid);
if (!r) {
await dbRun('DELETE FROM termine WHERE id = ? AND user_id = ?', [t.id, currentUserId()]);
} else {
await dbRun(
`UPDATE termine SET titel = ?, ort = ?, notiz = ?, start = ?, ende = ?, ganztags = ?,
caldav_etag = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?`,
[
sanitizeInput(r.summary || t.titel), sanitizeInput(r.location || ''), sanitizeInput(r.description || ''),
(r.start || new Date(t.start)).toISOString(), r.end ? r.end.toISOString() : null,
r.allDay ? 1 : 0, r.etag || t.caldav_etag, t.id, currentUserId(),
]
);
}
}
if (ctag) await setState('caldav_ctag', ctag);
}
// ---------------------------------------------------------------------------
// E-Mail correspondence: IMAP polling, storing & matching incoming replies
// ---------------------------------------------------------------------------
async function getState(key) {
const row = await dbGet('SELECT value FROM app_state WHERE user_id = ? AND key = ?', [currentUserId(), key]);
return row ? row.value : null;
}
async function setState(key, value) {
await dbRun(
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
[currentUserId(), key, String(value)]
);
}
// The user's KI prompt overrides as { key: text }. Keys without a row keep the
// default from lib/prompts.js. Read fresh on every generation so an edit takes
// effect immediately, without a restart.
async function loadPrompts() {
try {
const rows = await dbAll('SELECT key, inhalt FROM prompts WHERE user_id = ?', [currentUserId()]);
return Object.fromEntries(rows.map((r) => [r.key, r.inhalt]));
} catch (e) {
console.error('Konnte Prompts nicht laden, nutze Standardtexte:', e.message);
return {};
}
}
// The user's design overrides as { key: value }. Unset keys keep the defaults
// from lib/design.js. Read fresh per generation, like the prompts.
async function loadDesign() {
try {
const rows = await dbAll('SELECT key, value FROM design WHERE user_id = ?', [currentUserId()]);
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
} catch (e) {
console.error('Konnte Design nicht laden, nutze Standardwerte:', e.message);
return {};
}
}
// The user's personal details (Persönliche Angaben). A user who has never saved
// the form has no settings row at all — that is a valid state, not an error, so
// this returns an empty object rather than undefined. Same contract as
// loadPrompts()/loadDesign(): "no row" means "nothing set yet". Every consumer
// (views, PDF generation, KI context) reads individual fields off the result,
// so they all degrade to empty instead of crashing.
async function loadSettings() {
try {
const row = await dbGet('SELECT * FROM settings WHERE user_id = ?', [currentUserId()]);
return row || {};
} catch (e) {
console.error('Konnte Persönliche Angaben nicht laden:', e.message);
return {};
}
}
// The user's job-search profile. Like the settings above, "no row" is a valid
// state and means "no search configured" — it reads as the defaults (inactive).
async function loadSuchprofil(userId) {
const row = await dbGet('SELECT * FROM suchprofil WHERE user_id = ?', [userId || currentUserId()]);
return suchprofil.fromRow(row);
}
async function saveSuchprofil(userId, profil) {
const r = suchprofil.toRow(profil);
await dbRun(
`INSERT INTO suchprofil (user_id, aktiv, modus, staedte, zusatz_begriffe, ausschluesse, zeitplan_tage, zeitplan_zeit, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
aktiv = excluded.aktiv, modus = excluded.modus, staedte = excluded.staedte,
zusatz_begriffe = excluded.zusatz_begriffe, ausschluesse = excluded.ausschluesse,
zeitplan_tage = excluded.zeitplan_tage, zeitplan_zeit = excluded.zeitplan_zeit,
updated_at = CURRENT_TIMESTAMP`,
[userId, r.aktiv, r.modus, r.staedte, r.zusatz_begriffe, r.ausschluesse, r.zeitplan_tage, r.zeitplan_zeit]
);
}
// Queue a search run for a user. The host-side runner executes it (this container
// has no `claude`/`ollama`). Refuses to pile up work: if a run is already waiting
// or in flight for that user, the existing one is returned instead of a second.
async function queueSuchlauf(userId, ausloeser) {
const offen = await dbGet(
"SELECT * FROM suchlaeufe WHERE user_id = ? AND status IN ('angefordert', 'laeuft') ORDER BY id DESC LIMIT 1",
[userId]
);
if (offen) return { lauf: offen, neu: false };
const res = await dbRun(
"INSERT INTO suchlaeufe (user_id, status, ausloeser) VALUES (?, 'angefordert', ?)",
[userId, ausloeser === 'zeitplan' ? 'zeitplan' : 'manuell']
);
const lauf = await dbGet('SELECT * FROM suchlaeufe WHERE id = ? AND user_id = ?', [res.lastID, userId]);
return { lauf, neu: true };
}
// Match an incoming message to an application: first via In-Reply-To/References
// pointing at one of our sent messages, then by sender = a previous recipient.
async function matchBewerbung(msg) {
const refs = []
.concat((msg.inReplyTo || '').split(/\s+/))
.concat((msg.references || '').split(/\s+/))
.map((r) => r.replace(/[<>]/g, '').trim())
.filter(Boolean);
for (const mid of refs) {
const row = await dbGet(
"SELECT bewerbung_id FROM emails WHERE user_id = ? AND direction = 'out' AND message_id = ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1",
[currentUserId(), mid]
);
if (row && row.bewerbung_id) return row.bewerbung_id;
}
if (msg.fromAddr) {
const row = await dbGet(
"SELECT bewerbung_id FROM emails WHERE user_id = ? AND direction = 'out' AND lower(to_addr) LIKE ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1",
[currentUserId(), '%' + msg.fromAddr.toLowerCase() + '%']
);
if (row && row.bewerbung_id) return row.bewerbung_id;
}
return null;
}
let polling = false;
// Fetch new mail from the IMAP inbox, persist unseen messages, link them to the
// matching application and save their attachments. Safe to call concurrently
// (guarded) — used both by the interval poller and the manual "fetch" button.
// Runs in the current user's context (background loop sets it per user).
async function pollInbox() {
if (!mailer.isConfigured() || polling) return { fetched: 0 };
polling = true;
try {
const lastUid = Number(await getState('mail_last_uid')) || 0;
const { messages, maxUid } = await mailer.fetchSince(lastUid);
let stored = 0;
for (const m of messages) {
// Skip if we already have this message (id or uid) — idempotent.
if (m.messageId) {
const dup = await dbGet('SELECT id FROM emails WHERE user_id = ? AND message_id = ?', [currentUserId(), m.messageId]);
if (dup) continue;
}
const bewId = await matchBewerbung(m);
const result = await dbRun(
`INSERT INTO emails (user_id, bewerbung_id, direction, message_id, in_reply_to, email_references,
from_addr, to_addr, subject, body_text, body_html, imap_uid, seen, email_date)
VALUES (?, ?, 'in', ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`,
[currentUserId(), bewId, m.messageId || null, m.inReplyTo || null, m.references || null,
m.fromName ? `${m.fromName} <${m.fromAddr}>` : m.fromAddr, m.toAddr || '',
m.subject || '', m.text || '', m.html || '', m.uid,
(m.date instanceof Date ? m.date.toISOString() : new Date().toISOString())]
);
// Persist attachments to disk + link rows.
for (const att of (m.attachments || [])) {
const safe = String(att.filename || 'anhang').replace(/[^a-zA-Z0-9äöüÄÖÜß._ -]/g, '_').slice(0, 80);
const storedName = `${result.lastID}_${Date.now()}_${safe}`;
try {
fs.writeFileSync(path.join(userStorageDir(emailAnhaengeDir), storedName), att.content);
await dbRun('INSERT INTO email_anhaenge (user_id, email_id, name, mime, pfad) VALUES (?, ?, ?, ?, ?)',
[currentUserId(), result.lastID, att.filename, att.contentType, storedName]);
} catch (e) { /* ignore a single bad attachment */ }
}
stored++;
}
if (maxUid > lastUid) await setState('mail_last_uid', maxUid);
return { fetched: stored };
} catch (err) {
console.error('IMAP-Abruf fehlgeschlagen:', err.message);
return { fetched: 0, error: err.message };
} finally {
polling = false;
}
}
// ---------------------------------------------------------------------------
// Duplicate-application guard: spot an existing application for the same job so
// the user doesn't accidentally apply twice. Matches on a normalised source URL
// (strongest signal for imported postings) or an identical company + role. It
// only warns — legitimate re-applications stay possible via a "force" flag.
// ---------------------------------------------------------------------------
function normText(s) {
return String(s == null ? '' : s)
.toLowerCase()
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip diacritics (ä→a …)
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function normUrl(u) {
const raw = String(u == null ? '' : u).trim();
if (!raw) return '';
try {
const url = new URL(raw);
const host = url.hostname.replace(/^www\./, '').toLowerCase();
// A job-identifying query param (Indeed jk/vjk, generic ids) pins the posting
// regardless of tracking params or which path it was opened from.
const idKeys = ['jk', 'vjk', 'jobkey', 'jobid', 'vacancyid', 'stellenangebotid', 'positionid', 'offerid', 'id'];
let idPart = '';
for (const [k, v] of url.searchParams.entries()) {
if (v && idKeys.includes(k.toLowerCase())) { idPart = k.toLowerCase() + '=' + v.toLowerCase(); break; }
}
const pathn = url.pathname.replace(/\/+$/, '').toLowerCase();
return idPart ? host + '|' + idPart : host + pathn;
} catch (e) {
return raw.toLowerCase().replace(/[?#].*$/, '');
}
}
// Existing applications that look like the same job as {firma, stelle, quelle_url}.
// `excludeId` skips a specific row (e.g. when re-checking during an edit).
async function findDuplicateApplications({ firma, stelle, quelle_url, excludeId }) {
const rows = await dbAll('SELECT id, datum, firma, stelle, ort, quelle_url, status FROM bewerbungen WHERE user_id = ?', [currentUserId()]);
const fUrl = normUrl(quelle_url);
const fFirma = normText(firma);
const fStelle = normText(stelle);
const matches = [];
for (const r of rows) {
if (excludeId && Number(r.id) === Number(excludeId)) continue;
let reason = null;
if (fUrl && normUrl(r.quelle_url) === fUrl) reason = 'url';
else if (fFirma && fStelle && normText(r.firma) === fFirma && normText(r.stelle) === fStelle) reason = 'firma_stelle';
if (reason) matches.push({ id: r.id, datum: r.datum, firma: r.firma, stelle: r.stelle, ort: r.ort, status: r.status, reason });
}
return matches;
}
// Recompute an application's current status from its latest timeline entry
async function syncCurrentStatus(bewerbungId) {
const latest = await dbGet(
'SELECT status FROM status_verlauf WHERE user_id = ? AND bewerbung_id = ? ORDER BY date(datum) DESC, id DESC LIMIT 1',
[currentUserId(), bewerbungId]
);
await dbRun(
'UPDATE bewerbungen SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
[latest ? latest.status : '', bewerbungId, currentUserId()]
);
}
// Attach the status timeline to each application (single query, grouped in JS)
async function attachVerlauf(applications) {
if (!applications.length) return applications;
const all = await dbAll('SELECT * FROM status_verlauf WHERE user_id = ? ORDER BY date(datum) ASC, id ASC', [currentUserId()]);
const byApp = {};
all.forEach((v) => { (byApp[v.bewerbung_id] = byApp[v.bewerbung_id] || []).push(v); });
applications.forEach((a) => { a.verlauf = byApp[a.id] || []; });
return applications;
}
// Run the AI document generation for one application (async, fire-and-forget).
// Loads the base documents + user settings, asks the LLM to tailor them to the
// job, writes the resulting PDFs to disk and links them as attachments.
async function runGeneration(bewerbungId, options = {}) {
try {
const U = currentUserId();
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [bewerbungId, U]);
if (!bewerbung) return;
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [U]);
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [U]);
const settings = await loadSettings();
const prompts = await loadPrompts();
const design = await loadDesign();
// Only the explicitly selected extra attachments are enclosed (default: none).
const anlagenIds = Array.isArray(options.anlagenIds) ? options.anlagenIds.map(Number) : [];
const selectedAnhaenge = basisAnhaenge.filter((a) => anlagenIds.includes(a.id));
// Which documents to produce; unset means both (the default everywhere).
const dokumente = normalizeDokumente(options.dokumente);
const { documents, email } = await generateApplicationDocuments({
job: {
firma: bewerbung.firma,
stelle: bewerbung.stelle,
ort: bewerbung.ort,
quelle_url: bewerbung.quelle_url,
stellenbeschreibung: bewerbung.stellenbeschreibung,
},
basisDokumente,
settings,
prompts,
design,
dokumente,
// Names of the selected attachments so the cover letter (and the LLM) lists
// exactly these under "Anlagen".
zusatzAnlagen: selectedAnhaenge.map((a) => a.name || a.dateiname),
// Free-text notes (company address, contact person, extra context) for the LLM.
llmNotizen: bewerbung.llm_notizen || '',
// Signature image placed under the closing salutation (instead of the typed name).
signatur: loadSignatur(),
// Applicant photo placed in the CV header (top-right), optional.
bewerbungsfoto: loadFoto(),
});
let seq = 0;
const storeAnhang = async (name, filename, mime, buffer) => {
// Sanitise the filename segment so a stored ba.dateiname like '../x' can
// never escape the user's anhaenge directory via the on-disk path.
const safe = safeFilename(filename);
const stored = `${bewerbungId}_${Date.now()}_${seq++}_${safe}`;
fs.writeFileSync(path.join(userStorageDir(anhaengeDir), stored), buffer);
await dbRun(
'INSERT INTO anhaenge (user_id, bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?, ?)',
[U, bewerbungId, name, safe, 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 = containedPath(userStorageDir(basisAnhaengeDir), ba.pfad);
if (!src || !fs.existsSync(src)) continue;
await storeAnhang(ba.name || ba.dateiname, ba.dateiname, ba.mime || 'application/octet-stream', fs.readFileSync(src));
}
await dbRun(
"UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, " +
"email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?",
[(email && email.betreff) || '', (email && email.text) || '', bewerbungId, U]
);
console.log(`Bewerbungsunterlagen für #${bewerbungId} generiert (${documents.length} Dokument(e)).`);
} catch (error) {
console.error(`Generierung für #${bewerbungId} fehlgeschlagen:`, error.message);
await dbRun(
"UPDATE bewerbungen SET generierung_status = 'fehler', generierung_fehler = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?",
[String(error.message || 'Unbekannter Fehler'), bewerbungId, currentUserId()]
).catch(() => {});
}
}
// Initialize database — multi-user schema.
//
// Creates every table with a `user_id` owner column (NOT NULL, FK -> users) and
// per-user PRIMARY KEY / UNIQUE constraints. Fresh installs get this schema
// directly; legacy single-user installs are upgraded by runMigration() (below),
// which adds the user_id columns, recreates the per-user PK tables, backfills
// all existing rows to the admin user and moves on-disk files into a per-user
// subdirectory. runMigration() is idempotent and also runs on fresh installs
// (where it only creates the admin user + default settings row).
async function initializeDatabase() {
const exec = (sql) => dbRun(sql);
db.run('PRAGMA foreign_keys = ON');
// Users + sessions (auth) ------------------------------------------------
await exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await exec(`
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
impersonator_id INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (impersonator_id) REFERENCES users(id) ON DELETE SET NULL
)
`);
await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)');
// Audit trail for admin actions that act on another user's account (today:
// impersonation start/stop). Persistent so a later review can reconstruct who
// acted as whom and when — a plain console.log would not survive a restart.
await exec(`
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER,
target_user_id INTEGER,
action TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL
)
`);
await exec('CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at)');
// Upgrade legacy single-user installs BEFORE the per-user CREATE/INDEX
// statements below: the migration adds the user_id column to every existing
// per-user table (ALTER) and recreates the PK/UNIQUE tables per-user, so the
// CREATE INDEX ... ON <table>(user_id) statements that follow find the column
// already present. On a fresh install the migration only creates the admin
// user + default settings row (none of the per-user tables exist yet, so every
// step guards itself with tableExists and is a no-op). Idempotent.
await runMigration({ db, dbAll, dbGet, dbRun });
// Applications (core data) ----------------------------------------------
await exec(`
CREATE TABLE IF NOT EXISTS bewerbungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
datum DATE NOT NULL,
firma TEXT NOT NULL,
stelle TEXT NOT NULL,
art TEXT,
status TEXT,
notizen TEXT,
interne_notizen TEXT,
ort TEXT,
stellenbeschreibung TEXT,
quelle_url TEXT,
generierung_status TEXT,
generierung_fehler TEXT,
email_betreff TEXT,
email_anschreiben TEXT,
llm_notizen TEXT,
labels TEXT,
generierung_dokumente TEXT,
email_empfaenger TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
await exec('CREATE INDEX IF NOT EXISTS idx_bewerbungen_user ON bewerbungen(user_id)');
// Chronological status changes, each with an optional comment
await exec(`
CREATE TABLE IF NOT EXISTS status_verlauf (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
bewerbung_id INTEGER NOT NULL,
datum DATE NOT NULL,
status TEXT NOT NULL,
kommentar TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
)
`);
// Base documents (Basis-Unterlagen) — the foundation the AI tailors from
await exec(`
CREATE TABLE IF NOT EXISTS basis_dokumente (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
typ TEXT,
name TEXT,
inhalt TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Static extra attachments (e.g. Zeugnisse) attached to every application
await exec(`
CREATE TABLE IF NOT EXISTS basis_anhaenge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT,
dateiname TEXT NOT NULL,
mime TEXT,
pfad TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Generated attachment files linked to an application
await exec(`
CREATE TABLE IF NOT EXISTS anhaenge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
bewerbung_id INTEGER NOT NULL,
name TEXT,
dateiname TEXT NOT NULL,
mime TEXT,
pfad TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
)
`);
// Private attachments linked to an application's internal notes.
await exec(`
CREATE TABLE IF NOT EXISTS interne_anhaenge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
bewerbung_id INTEGER NOT NULL,
name TEXT,
dateiname TEXT NOT NULL,
mime TEXT,
pfad TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
)
`);
// E-Mail correspondence (sent + received), linked to an application.
await exec(`
CREATE TABLE IF NOT EXISTS emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
bewerbung_id INTEGER,
direction TEXT NOT NULL,
message_id TEXT,
in_reply_to TEXT,
email_references TEXT,
from_addr TEXT,
to_addr TEXT,
subject TEXT,
body_text TEXT,
body_html TEXT,
attachments_json TEXT,
imap_uid INTEGER,
seen INTEGER DEFAULT 1,
email_date DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
)
`);
await exec('CREATE INDEX IF NOT EXISTS idx_emails_user ON emails(user_id)');
await exec(`
CREATE TABLE IF NOT EXISTS email_anhaenge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
email_id INTEGER NOT NULL,
name TEXT,
mime TEXT,
pfad TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (email_id) REFERENCES emails(id) ON DELETE CASCADE
)
`);
// Per-user key/value store (cfg: settings + per-user state like last IMAP UID).
await exec(`
CREATE TABLE IF NOT EXISTS app_state (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT,
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Job offers ingested via the third-party REST API. (user_id, quelle,
// external_id) identify an offer uniquely per user.
await exec(`
CREATE TABLE IF NOT EXISTS jobangebote (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
external_id TEXT,
quelle TEXT NOT NULL DEFAULT 'drittanbieter',
firma TEXT NOT NULL,
stelle TEXT NOT NULL,
ort TEXT,
adresse TEXT,
ansprechpartner TEXT,
gehalt TEXT,
beschreibung TEXT,
quelle_url TEXT,
art TEXT,
anzeige_datum DATE,
kontakt_email TEXT,
status TEXT NOT NULL DEFAULT 'offen',
verknuepfte_bewerbung_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
url_norm TEXT,
firma_slug TEXT,
labels TEXT,
UNIQUE (user_id, quelle, external_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
)
`);
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)');
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)');
// Blacklist of job offers that must never (re)appear in the list (per user).
await exec(`
CREATE TABLE IF NOT EXISTS jobangebote_blacklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
typ TEXT NOT NULL DEFAULT 'auto',
url_norm TEXT,
domain TEXT,
quelle TEXT,
external_id TEXT,
firma_norm TEXT,
firma_slug TEXT,
stelle_norm TEXT,
ort_norm TEXT,
firma TEXT,
stelle TEXT,
quelle_url TEXT,
grund TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Application calendar appointments, mirrored to the CalDAV calendar.
await exec(`
CREATE TABLE IF NOT EXISTS termine (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
bewerbung_id INTEGER,
typ TEXT NOT NULL DEFAULT 'termin',
titel TEXT NOT NULL,
ort TEXT,
notiz TEXT,
start TEXT NOT NULL,
ende TEXT,
ganztags INTEGER DEFAULT 0,
erinnerung_min INTEGER DEFAULT 60,
caldav_uid TEXT,
caldav_href TEXT,
caldav_etag TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
)
`);
// Conversational KI-Chat: threads and their messages (per user).
await exec(`
CREATE TABLE IF NOT EXISTS chat_threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
titel TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
await exec(`
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
thread_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (thread_id) REFERENCES chat_threads(id) ON DELETE CASCADE
)
`);
await exec('CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id)');
// Overridden KI system prompts (per user). Only edited prompts are stored.
await exec(`
CREATE TABLE IF NOT EXISTS prompts (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
inhalt TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Design choices for the generated PDFs (per user, same contract as prompts).
await exec(`
CREATE TABLE IF NOT EXISTS design (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Job-search profile (one row per user; a missing row = no search configured).
// The user sets *where* (Städte/Modus) and the guard rails; the roles and
// buzzwords are derived from their Lebenslauf at run time — see lib/suchprofil.
await exec(`
CREATE TABLE IF NOT EXISTS suchprofil (
user_id INTEGER PRIMARY KEY,
aktiv INTEGER NOT NULL DEFAULT 0,
modus TEXT NOT NULL DEFAULT 'regional',
staedte TEXT NOT NULL DEFAULT '[]',
zusatz_begriffe TEXT DEFAULT '',
ausschluesse TEXT DEFAULT '',
zeitplan_tage TEXT DEFAULT '1,2,3,4,5',
zeitplan_zeit TEXT DEFAULT '17:00',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Queue + history of search runs. The app only ever *enqueues* (status
// 'angefordert'); the host-side runner (bin/jobsuche-runner.sh) picks runs up,
// because `claude`/`ollama` exist on the host, not in this container.
await exec(`
CREATE TABLE IF NOT EXISTS suchlaeufe (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'angefordert',
ausloeser TEXT NOT NULL DEFAULT 'manuell',
angefordert_at DATETIME DEFAULT CURRENT_TIMESTAMP,
gestartet_at DATETIME,
beendet_at DATETIME,
neu INTEGER,
dubletten INTEGER,
verworfen INTEGER,
fehler TEXT,
log_datei TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
await exec('CREATE INDEX IF NOT EXISTS idx_suchlaeufe_user ON suchlaeufe(user_id, id DESC)');
await exec('CREATE INDEX IF NOT EXISTS idx_suchlaeufe_status ON suchlaeufe(status)');
// Personal details of the applicant (one row per user).
await exec(`
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
name TEXT,
adresse TEXT,
kundennummer TEXT,
ort TEXT,
webseite TEXT,
email TEXT,
telefon TEXT,
geburtsdatum TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Volltext-Index über Bewerbungen + E-Mail-Korrespondenz. Muss nach den beiden
// Quelltabellen entstehen, weil die Trigger auf ihnen sitzen; der Backfill
// läuft nur beim ersten Start (siehe lib/suche.js).
await suche.ensureSchema({ exec, dbGet });
}
// Initialize and start server
initializeDatabase().then(async () => {
console.log('Database initialized successfully');
// Load configuration from the DB. Must run before the boot checks below
// (mailer/caldav configured?) and before any route that reads config — values
// live in the DB now, not in the environment.
await config.init({ dbAll, dbRun });
// Import any config still supplied via the environment into the *admin's* rows
// (one-time, idempotent). Env config is the admin's: config.get() has no
// process.env fallback, precisely so that a newly created user does not
// inherit the admin's mailbox, calendar, Ollama key and API token. This runs
// here rather than inside runMigration() because on a fresh install app_state
// does not exist yet while the migration is running.
await importEnvIntoAdmin({ dbAll, dbGet });
// Current user's id — set by the auth middleware (lib/context.js). Guaranteed
// to be present inside any protected route or background-per-user task.
const uid = () => currentUserId();
// Resolve a per-user storage directory (data/<base>/<userId>/), creating it
// on first use. Used for every attachment / signature / photo path so users'
// files are isolated on disk the same way their DB rows are.
const userDir = userStorageDir;
// Routes
app.get('/', async (req, res) => {
try {
const { month, year } = req.query;
const U = uid();
let query = 'SELECT * FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC, created_at DESC';
const params = [U];
if (month && year) {
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC';
params.push(month.padStart(2, '0'), year);
} else if (year) {
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC';
params.push(year);
}
const applications = await dbAll(query, params);
await attachVerlauf(applications);
applications.forEach((a) => { a.labelsArr = parseLabels(a.labels); });
// Upcoming calendar appointments for the dashboard widget.
const kommendeTermine = await upcomingTermine(6);
// Get statistics
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [U]);
const byArt = await dbAll(`
SELECT art, COUNT(*) as count FROM bewerbungen
WHERE user_id = ? AND art IS NOT NULL AND art != ''
GROUP BY art ORDER BY count DESC
`, [U]);
const byStatus = await dbAll(`
SELECT status, COUNT(*) as count FROM bewerbungen
WHERE user_id = ? AND status IS NOT NULL AND status != ''
GROUP BY status ORDER BY count DESC
`, [U]);
// Get available months/years for filter
const availableMonths = await dbAll(`
SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth,
strftime("%m", datum) as month,
strftime("%Y", datum) as year
FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC
`, [U]);
// Months/years for the PDF export, keyed by the effective date (last status
// change) so a period like Juli 2026 is selectable even when the underlying
// application was created in an earlier month.
const exportMonths = await dbAll(`
SELECT DISTINCT strftime("%Y-%m", eff) as yearmonth,
strftime("%m", eff) as month,
strftime("%Y", eff) as year
FROM (
SELECT COALESCE(
(SELECT MAX(date(sv.datum)) FROM status_verlauf sv WHERE sv.bewerbung_id = b.id AND sv.user_id = ?),
date(b.datum)
) AS eff
FROM bewerbungen b
WHERE b.user_id = ?
)
ORDER BY yearmonth DESC
`, [U, U]);
res.render('index', {
applications,
statistics: {
total: totalCount ? totalCount.count : 0,
byArt,
byStatus
},
availableMonths,
exportMonths,
currentFilter: { month, year },
kommendeTermine,
caldavTz: caldav.TZ,
artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS,
labelOptions: LABEL_OPTIONS
});
} catch (error) {
console.error('Error:', error);
res.status(500).send('Serverfehler');
}
});
// Statistik — comprehensive, on its own page. The dashboard only carries a
// quick glance now; everything that needs room to breathe lives here.
app.get('/statistik', async (req, res) => {
try {
const U = uid();
const totalRow = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [U]);
const total = totalRow ? totalRow.count : 0;
const byArt = await dbAll(`
SELECT art, COUNT(*) as count FROM bewerbungen
WHERE user_id = ? AND art IS NOT NULL AND art != ''
GROUP BY art ORDER BY count DESC
`, [U]);
const statusRows = await dbAll(`
SELECT status, COUNT(*) as count FROM bewerbungen
WHERE user_id = ? AND status IS NOT NULL AND status != ''
GROUP BY status
`, [U]);
const statusMap = {};
statusRows.forEach((r) => { statusMap[r.status] = r.count; });
// Preserve the canonical STATUS_OPTIONS order so the bars read as a funnel
// top-to-bottom rather than a noisy rank-by-count.
const byStatus = STATUS_OPTIONS
.map((s) => ({ status: s, count: statusMap[s] || 0 }))
.filter((x) => x.count > 0);
const byOrt = await dbAll(`
SELECT ort, COUNT(*) as count FROM bewerbungen
WHERE user_id = ? AND ort IS NOT NULL AND ort != ''
GROUP BY ort ORDER BY count DESC LIMIT 10
`, [U]);
// Monthly trend (oldest first so the chart reads left-to-right).
const byMonth = await dbAll(`
SELECT strftime('%Y-%m', datum) as ym, COUNT(*) as count
FROM bewerbungen WHERE user_id = ? AND datum IS NOT NULL
GROUP BY ym ORDER BY ym ASC
`, [U]);
// How many applications ever reached each status — drives the funnel.
const verlaufStatus = await dbAll(`
SELECT status, COUNT(DISTINCT bewerbung_id) as count
FROM status_verlauf WHERE user_id = ? GROUP BY status
`, [U]);
const verlaufMap = {};
verlaufStatus.forEach((r) => { verlaufMap[r.status] = r.count; });
const ever = (s) => verlaufMap[s] || 0;
const cur = (s) => statusMap[s] || 0;
const entwuerfe = cur('Entwurf');
const gesendet = total - entwuerfe;
const gespraech = cur('Vorstellungsgespräch');
const eingestellt = cur('Einstellung');
const absagen = cur('Absage') + cur('Absage von meiner Seite');
// Any reply at all — everything past "Gesendet" except "Keine Rückmeldung".
const rueckStatus = [
'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert',
'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Absage', 'Absage von meiner Seite', 'Einstellung'
];
const antworten = rueckStatus.reduce((a, s) => a + cur(s), 0);
const erfolgsquote = gesendet > 0 ? Math.round((eingestellt / gesendet) * 100) : 0;
const antwortquote = gesendet > 0 ? Math.round((antworten / gesendet) * 100) : 0;
const avgProMonat = byMonth.length > 0 ? Math.round((total / byMonth.length) * 10) / 10 : 0;
// Funnel: distinct applications that ever reached the stage. Einstellung
// implies a Gespräch happened, so count those toward the Gespräch stage too.
const funnel = [
{ label: 'Bewerbungen gesamt', count: total },
{ label: 'Versendet', count: ever('Gesendet') || gesendet },
{ label: 'Eingangsbestätigung', count: ever('Eingangsbestätigung') },
{ label: 'Vorstellungsgespräch', count: ever('Vorstellungsgespräch') + ever('Einstellung') },
{ label: 'Einstellung', count: ever('Einstellung') }
];
// Recent activity: latest status changes with the application they belong to.
const aktivitaet = await dbAll(`
SELECT sv.datum, sv.status, sv.kommentar, b.id as bewerbung_id,
b.firma, b.stelle
FROM status_verlauf sv
LEFT JOIN bewerbungen b ON b.id = sv.bewerbung_id
WHERE sv.user_id = ?
ORDER BY datetime(sv.created_at) DESC, sv.id DESC
LIMIT 12
`, [U]);
res.render('statistik', {
total,
byArt,
byStatus,
byOrt,
byMonth,
kpis: {
gesendet,
gespraech,
eingestellt,
absagen,
antworten,
erfolgsquote,
antwortquote,
avgProMonat,
monateAktiv: byMonth.length
},
funnel,
aktivitaet,
statusOptions: STATUS_OPTIONS
});
} catch (error) {
console.error('Error loading statistik:', error);
res.status(500).send('Serverfehler');
}
});
// Get single application
app.get('/api/bewerbungen/:id', async (req, res) => {
try {
const { id } = req.params;
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
if (!application) {
return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
}
res.json(application);
} catch (error) {
console.error('Error getting application:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Get settings
app.get('/api/settings', async (req, res) => {
try {
const settings = await loadSettings();
res.json(settings);
} catch (error) {
console.error('Error getting settings:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Save settings
app.post('/api/settings', async (req, res) => {
try {
const { name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum } = req.body;
const U = uid();
await dbRun(
`INSERT INTO settings (user_id, name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
name = excluded.name, adresse = excluded.adresse, kundennummer = excluded.kundennummer,
email = excluded.email, telefon = excluded.telefon, ort = excluded.ort,
webseite = excluded.webseite, geburtsdatum = excluded.geburtsdatum`,
[
U,
sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer),
sanitizeInput(email), sanitizeInput(telefon), sanitizeInput(ort),
sanitizeInput(webseite), sanitizeInput(geburtsdatum)
]
);
res.json({ success: true });
} catch (error) {
console.error('Error saving settings:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// ----- Indeed import (called by the browser extension) -----
app.post('/api/indeed-import', async (req, res) => {
try {
const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url, art } = req.body || {};
if (!firma || !stelle) {
return res.status(400).json({ error: 'Firma und Stelle sind erforderlich.' });
}
// Source of the capture: honour an explicit art, else infer from the URL.
const quelle = deriveArt(quelle_url, art);
// Duplicate guard: don't silently import the same posting twice.
const forceImport = req.body.force === true || req.body.force === 'true';
if (!forceImport) {
const dups = await findDuplicateApplications({ firma, stelle, quelle_url });
if (dups.length) {
return res.status(409).json({
duplicate: true,
matches: dups,
error: 'Für diese Stelle existiert bereits eine Bewerbung.',
});
}
}
const datum = new Date().toISOString().split('T')[0];
// Keep the extra details (location, salary, source) visible in the notes too.
const notizParts = [
ort ? `Ort: ${ort}` : null,
gehalt ? `Gehalt: ${gehalt}` : null,
quelle_url ? `Quelle: ${quelle_url}` : null,
].filter(Boolean);
const notizen = notizParts.join('\n');
// Stored raw: every view renders these through EJS `<%= %>` (auto-escaped),
// so this is XSS-safe — and it keeps the text clean for the AI and the PDFs
// (no HTML entities leaking into the generated documents).
const result = await dbRun(
`INSERT INTO bewerbungen
(user_id, datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
VALUES (?, ?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
[uid(), datum, firma, stelle, quelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
);
// Record the initial "Entwurf" status in the timeline
await dbRun(
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
[uid(), result.lastID, datum, 'Entwurf', `Automatisch aus dem Browser importiert (${quelle})`]
);
// Note: generation is NOT started automatically — the user reviews the draft,
// adds LLM notes if needed, and triggers generation on the application page.
res.json({
success: true,
id: result.lastID,
url: `/bewerbung/${result.lastID}`,
message: 'Bewerbung als Entwurf angelegt. Unterlagen können auf der Bewerbungsseite generiert werden.',
});
} catch (error) {
console.error('Error importing job:', error);
res.status(500).json({ error: 'Serverfehler beim Import.' });
}
});
// Volltextsuche für die Startseite: Bewerbungen samt ihrer E-Mail-Korrespondenz
// (Absender, Betreff, Text), Notizen und Stellenbeschreibung. Liefert je Treffer
// die Fundstellen mit Snippet — die Marker darin ersetzt der Client durch <mark>.
app.get('/api/suche', async (req, res) => {
try {
const q = String(req.query.q || '');
if (q.trim().length < 2) return res.json({ q, treffer: [] });
const treffer = await suche.suche(dbAll, uid(), q);
res.json({ q, treffer });
} catch (error) {
console.error('Search error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Poll generation status + current attachments for one application
app.get('/api/bewerbungen/:id/generierung', async (req, res) => {
try {
const { id } = req.params;
const bewerbung = await dbGet(
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]
);
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const anhaenge = await dbAll(
'SELECT id, name, dateiname, mime FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC', [id, uid()]
);
res.json({
status: bewerbung.generierung_status,
fehler: bewerbung.generierung_fehler,
anhaenge,
});
} catch (error) {
console.error('Error fetching generation status:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// ----- Base documents (Basis-Unterlagen / Vorlagen) -----
app.get('/api/basis-dokumente', async (req, res) => {
try {
const docs = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [uid()]);
res.json(docs);
} catch (error) {
console.error('Error listing base documents:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Create application
app.post('/api/bewerbungen', async (req, res) => {
try {
const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body;
const labels = serializeLabels(req.body.labels);
// Duplicate guard: warn before creating a second application for the same
// company + role (the client re-submits with force=true to confirm).
const force = req.body.force === true || req.body.force === 'true';
if (!force) {
const dups = await findDuplicateApplications({ firma, stelle });
if (dups.length) {
return res.status(409).json({
duplicate: true,
matches: dups,
error: 'Es gibt bereits eine Bewerbung für dieselbe Firma und Stelle.',
});
}
}
const result = await dbRun(
'INSERT INTO bewerbungen (user_id, datum, firma, stelle, art, status, notizen, interne_notizen, labels) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[uid(), datum, sanitizeInput(firma), sanitizeInput(stelle),
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels]
);
// Record the initial status as the first timeline entry
if (status && status.trim()) {
await dbRun(
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
[uid(), result.lastID, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
);
}
const newApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
res.json({ success: true, application: newApplication });
} catch (error) {
console.error('Error creating application:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Update application
app.put('/api/bewerbungen/:id', async (req, res) => {
try {
const { id } = req.params;
const { datum, firma, stelle, art, status, notizen } = req.body;
const existing = await dbGet('SELECT labels FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
const labels = Object.prototype.hasOwnProperty.call(req.body, 'labels')
? serializeLabels(req.body.labels)
: (existing ? existing.labels : '[]');
await dbRun(
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
[datum, sanitizeInput(firma), sanitizeInput(stelle),
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), labels, id, uid()]
);
const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
res.json({ success: true, application: updatedApplication });
} catch (error) {
console.error('Error updating application:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Delete application
app.delete('/api/bewerbungen/:id', async (req, res) => {
try {
const { id } = req.params;
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
// Remove private attachments belonging to the internal notes.
const interne = await dbAll('SELECT pfad FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
const intDir = userStorageDir(interneAnhaengeDir);
interne.forEach((a) => {
const fp = containedPath(intDir, a.pfad);
if (fp) fs.promises.unlink(fp).catch(() => {});
});
await dbRun('DELETE FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
await dbRun('DELETE FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
res.json({ success: true });
} catch (error) {
console.error('Error deleting application:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Applications for PDF export (optionally filtered), including the status timeline
app.get('/api/export', async (req, res) => {
try {
const { month, year } = req.query;
const U = uid();
// Effektives Datum einer Bewerbung = Datum ihrer letzten Statusänderung
// (fällt auf das Bewerbungsdatum zurück, wenn es keinen Verlauf gibt). Der
// Monatsexport listet eine Bewerbung im Monat ihres LETZTEN Status: Eine im
// Juni gesendete Bewerbung, die im Juli zum Vorstellungsgespräch wird,
// erscheint dadurch im Export für Juli.
const base = `
SELECT b.*, COALESCE(
(SELECT MAX(date(sv.datum)) FROM status_verlauf sv WHERE sv.bewerbung_id = b.id AND sv.user_id = ?),
date(b.datum)
) AS eff_datum
FROM bewerbungen b
WHERE b.user_id = ?
`;
let query = `SELECT * FROM (${base}) ORDER BY eff_datum DESC`;
const params = [U, U];
if (month && year) {
query = `SELECT * FROM (${base}) 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 U = uid();
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, U]);
if (!application) return res.status(404).send('Bewerbung nicht gefunden');
application.labelsArr = parseLabels(application.labels);
const verlauf = await dbAll(
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ? ORDER BY date(datum) ASC, id ASC',
[id, U]
);
const anhaenge = await dbAll(
'SELECT id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
[id, U]
);
// Private attachments belonging to the internal notes — never exported/sent.
const interneAnhaenge = await dbAll(
'SELECT id, name, dateiname, mime, created_at FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
[id, U]
);
const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente WHERE user_id = ?', [U]);
// Available static attachments (Zeugnisse etc.) to optionally enclose.
const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [U]);
// Calendar appointments for this application (mirrored to SOGo).
const termine = await dbAll('SELECT * FROM termine WHERE bewerbung_id = ? AND user_id = ? ORDER BY start ASC', [id, U]);
// E-Mail correspondence (sent + received), oldest first, with attachments.
const emails = await dbAll(
'SELECT * FROM emails WHERE bewerbung_id = ? AND user_id = ? ORDER BY datetime(email_date) ASC, id ASC',
[id, U]
);
if (emails.length) {
const eIds = emails.map((e) => e.id);
const atts = await dbAll(
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE user_id = ? AND email_id IN (${eIds.map(() => '?').join(',')})`,
[U, ...eIds]
);
const byEmail = {};
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
emails.forEach((e) => {
e.anhaenge = byEmail[e.id] || [];
// Bare address for prefilling a reply's "To" (from "Name <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 user_id = ? AND direction = 'in' AND seen = 0", [id, U]);
}
res.render('bewerbung', {
application,
verlauf,
anhaenge,
interneAnhaenge,
emails,
// Last choice of documents (defaults to both), for pre-ticking the form.
dokumentAuswahl: normalizeDokumente(
application.generierung_dokumente ? String(application.generierung_dokumente).split(',') : null
),
mailConfigured: mailer.isConfigured(),
mailFrom: mailer.isConfigured() ? mailer.fromField() : '',
mailError: req.query.mailerror ? String(req.query.mailerror) : '',
mailOk: req.query.mailok ? String(req.query.mailok) : '',
basisCount: basisCountRow ? basisCountRow.count : 0,
basisAnhaenge,
termine,
caldavConfigured: caldav.isConfigured(),
caldavTz: caldav.TZ,
terminVorschlag: req.query.vorschlag === 'vg' ? { datum: String(req.query.vdatum || '') } : null,
terminOk: !!req.query.terminok,
terminError: req.query.terminerror ? String(req.query.terminerror) : '',
artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS,
labelOptions: LABEL_OPTIONS,
hideSettings: true
});
} catch (error) {
console.error('Error loading edit page:', error);
res.status(500).send('Serverfehler');
}
});
// Update application core data (status is managed via the timeline)
app.post('/bewerbung/:id', async (req, res) => {
try {
const { id } = req.params;
const { datum, firma, stelle, art, notizen, interne_notizen } = req.body;
const labels = serializeLabels(req.body.labels);
await dbRun(
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
[datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels, id, uid()]
);
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error updating application:', error);
res.status(500).send('Serverfehler');
}
});
// Save the editable e-mail cover text (Begleit-E-Mail) after the user tweaks it.
app.post('/bewerbung/:id/email', async (req, res) => {
try {
const { id } = req.params;
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
// Store raw; the value is HTML-escaped on render (EJS <%= %>), matching how
// the generated e-mail is stored. Sanitising here would double-escape.
await dbRun(
'UPDATE bewerbungen SET email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
[String(req.body.email_betreff || ''), String(req.body.email_anschreiben || ''), id, uid()]
);
res.redirect('/bewerbung/' + id + '#email');
} catch (error) {
console.error('Error saving e-mail text:', error);
res.status(500).send('Serverfehler');
}
});
// Send an e-mail for an application (initial application or a reply). Sends
// via authenticated submission, records it as an outgoing message and links
// the chosen generated attachments.
app.post('/bewerbung/:id/email/send', async (req, res) => {
const { id } = req.params;
const back = (frag) => '/bewerbung/' + id + (frag || '#korrespondenz');
try {
const U = uid();
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, U]);
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
if (!mailer.isConfigured()) {
return res.redirect(back('?mailerror=' + encodeURIComponent('E-Mail ist nicht konfiguriert (Einstellungen).') + '#korrespondenz'));
}
const to = String(req.body.to || '').trim();
const subject = String(req.body.subject || '').trim();
const body = String(req.body.body || '');
if (!to || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to)) {
return res.redirect(back('?mailerror=' + encodeURIComponent('Bitte eine gültige Empfänger-Adresse angeben.') + '#korrespondenz'));
}
// Selected generated attachments (checkbox values = anhaenge ids).
let anhangIds = req.body.anhang || [];
if (!Array.isArray(anhangIds)) anhangIds = [anhangIds];
const attachments = [];
const attNames = [];
const anhaengeUserDir = userStorageDir(anhaengeDir);
for (const aid of anhangIds) {
const a = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [aid, id, U]);
if (!a) continue;
const p = containedPath(anhaengeUserDir, a.pfad);
if (!p || !fs.existsSync(p)) continue;
attachments.push({ filename: a.dateiname, path: p, contentType: a.mime || undefined });
attNames.push(a.dateiname);
}
// Selected static attachments (Zeugnisse etc.), e.g. from a reply form.
// Default: none selected.
let basisAnlageIds = req.body.basis_anlage || [];
if (!Array.isArray(basisAnlageIds)) basisAnlageIds = [basisAnlageIds];
const basisUserDir = userStorageDir(basisAnhaengeDir);
for (const bid of basisAnlageIds) {
const ba = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [bid, U]);
if (!ba) continue;
const p = containedPath(basisUserDir, ba.pfad);
if (!p || !fs.existsSync(p)) continue;
attachments.push({ filename: ba.dateiname, path: p, contentType: ba.mime || undefined });
attNames.push(ba.dateiname);
}
// Threading headers when this is a reply to a stored message.
let inReplyTo = null, references = null;
if (req.body.reply_to) {
const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [req.body.reply_to, id, U]);
if (orig && orig.message_id) {
inReplyTo = '<' + orig.message_id + '>';
references = ((orig.email_references ? orig.email_references + ' ' : '') + inReplyTo).trim();
}
}
const info = await mailer.sendMail({
to, subject, text: body, attachments,
inReplyTo, references,
});
const mid = String(info.messageId || '').replace(/[<>]/g, '');
const emailRow = await dbRun(
`INSERT INTO emails (user_id, bewerbung_id, direction, message_id, in_reply_to, email_references,
from_addr, to_addr, subject, body_text, attachments_json, seen, email_date)
VALUES (?, ?, 'out', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
[U, id, mid, inReplyTo ? inReplyTo.replace(/[<>]/g, '') : null, references ? references.replace(/[<>]/g, '') : null,
mailer.fromAddress(), to, subject, body, JSON.stringify(attNames), new Date().toISOString()]
);
// Keep a copy of each sent attachment linked to the outgoing message so it
// is shown (and can be reopened) in the correspondence view afterwards.
const emailAttDir = userStorageDir(emailAnhaengeDir);
for (const att of attachments) {
const safe = String(att.filename || 'anhang').replace(/[^a-zA-Z0-9äöüÄÖÜß._ -]/g, '_').slice(0, 80);
const storedName = `${emailRow.lastID}_${Date.now()}_${safe}`;
try {
fs.copyFileSync(att.path, path.join(emailAttDir, storedName));
await dbRun('INSERT INTO email_anhaenge (user_id, email_id, name, mime, pfad) VALUES (?, ?, ?, ?, ?)',
[U, emailRow.lastID, att.filename, att.contentType || null, storedName]);
} catch (e) { /* ignore a single bad attachment */ }
}
await dbRun('UPDATE bewerbungen SET email_empfaenger = ? WHERE id = ? AND user_id = ?', [to, id, U]);
res.redirect(back('?mailok=' + encodeURIComponent('E-Mail an ' + to + ' gesendet.') + '#korrespondenz'));
} catch (error) {
console.error('Error sending e-mail:', error);
res.redirect(back('?mailerror=' + encodeURIComponent('Versand fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')) + '#korrespondenz'));
}
});
// AI-draft a reply to a received e-mail. Returns JSON {betreff, text} that the
// frontend drops into the reply form for the user to edit before sending.
app.post('/bewerbung/:id/email/ai-reply', async (req, res) => {
try {
const { id } = req.params;
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [req.body.email_id, id, uid()]);
if (!orig) return res.status(404).json({ error: 'Nachricht nicht gefunden' });
const settings = await loadSettings();
const draft = await generateEmailReply({
incoming: { from: orig.from_addr, subject: orig.subject, text: emailPlainText(orig) },
job: { firma: bewerbung.firma, stelle: bewerbung.stelle },
settings,
prompts: await loadPrompts(),
// Was der Nutzer über dem Antwortfeld vorgegeben hat, plus (beim zweiten
// Klick) der Entwurf, der gerade im Feld steht — dann überarbeitet die KI
// ihn, statt neu anzufangen.
hinweise: String(req.body.hinweise || ''),
entwurf: String(req.body.entwurf || ''),
typ: String(req.body.typ || 'antwort'),
});
// Include the quoted original so the reply reads like a mail-client thread.
res.json({ ...draft, quote: buildReplyQuote(orig) });
} catch (error) {
console.error('Error drafting AI reply:', error);
res.status(500).json({ error: error.message || 'Serverfehler' });
}
});
// Manually trigger an IMAP fetch of new replies, then return to the referring page.
app.post('/email/fetch', async (req, res) => {
const back = safeRedirect(req.body.back || req.get('referer') || '/');
try {
await pollInbox();
} catch (e) { /* errors are logged inside pollInbox */ }
res.redirect(back);
});
// Download an attachment that arrived with a received e-mail.
app.get('/email-anhaenge/:id/download', async (req, res) => {
try {
const a = await dbGet('SELECT * FROM email_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
if (!a) return res.status(404).send('Anhang nicht gefunden');
const p = containedPath(userStorageDir(emailAnhaengeDir), a.pfad);
if (!p || !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, displayFilename(a.name || a.pfad));
} catch (error) {
console.error('Error downloading e-mail attachment:', error);
res.status(500).send('Serverfehler');
}
});
// ---------------------------------------------------------------------------
// Postfach: incoming e-mails that landed in the inbox but could not be
// matched to an application automatically. The user assigns them by hand.
// ---------------------------------------------------------------------------
// Page listing all e-mails with no bewerbung_id, plus every application as
// assignment target.
app.get('/postfach', async (req, res) => {
try {
const U = uid();
const emails = await dbAll(
`SELECT * FROM emails WHERE user_id = ? AND bewerbung_id IS NULL ORDER BY datetime(email_date) DESC, id DESC`,
[U]
);
if (emails.length) {
const eIds = emails.map((e) => e.id);
const atts = await dbAll(
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE user_id = ? AND email_id IN (${eIds.map(() => '?').join(',')})`,
[U, ...eIds]
);
const byEmail = {};
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
emails.forEach((e) => { e.anhaenge = byEmail[e.id] || []; });
decorateEmails(emails);
// These are read now that they are shown — clears them from the bell.
// (The `seen` values above are captured pre-update, so the "Neu" badge
// still renders on this view.)
await dbRun("UPDATE emails SET seen = 1 WHERE user_id = ? AND bewerbung_id IS NULL AND direction = 'in' AND seen = 0", [U]);
}
// Applications the user can assign an e-mail to (newest first).
const bewerbungen = await dbAll(
`SELECT id, firma, stelle, ort, datum FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC, created_at DESC`,
[U]
);
res.render('postfach', {
emails,
bewerbungen,
mailConfigured: mailer.isConfigured(),
mailError: req.query.mailerror ? String(req.query.mailerror) : '',
mailOk: req.query.mailok ? String(req.query.mailok) : '',
hideSettings: true,
});
} catch (error) {
console.error('Error loading postfach:', error);
res.status(500).send('Serverfehler');
}
});
// Assign an unlinked e-mail to an existing application.
app.post('/postfach/:emailId/zuweisen', async (req, res) => {
try {
const bewerbungId = Number(req.body.bewerbung_id);
if (!bewerbungId) {
return res.redirect('/postfach?mailerror=' + encodeURIComponent('Bitte eine Bewerbung auswählen.'));
}
// Make sure the target application exists and the e-mail is still unlinked.
const app = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [bewerbungId, uid()]);
if (!app) {
return res.redirect('/postfach?mailerror=' + encodeURIComponent('Ausgewählte Bewerbung existiert nicht.'));
}
await dbRun('UPDATE emails SET bewerbung_id = ? WHERE id = ? AND user_id = ? AND bewerbung_id IS NULL', [bewerbungId, req.params.emailId, uid()]);
res.redirect('/postfach?mailok=' + encodeURIComponent('E-Mail wurde der Bewerbung zugewiesen.'));
} catch (error) {
console.error('Error assigning e-mail:', error);
res.redirect('/postfach?mailerror=' + encodeURIComponent('Zuweisung fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')));
}
});
// Delete an e-mail from the Postfach (removes its stored attachment files too).
app.post('/postfach/:emailId/delete', async (req, res) => {
try {
const emailId = req.params.emailId;
const atts = await dbAll('SELECT pfad FROM email_anhaenge WHERE email_id = ? AND user_id = ?', [emailId, uid()]);
const attDir = userStorageDir(emailAnhaengeDir);
for (const a of atts) {
const fp = containedPath(attDir, a.pfad);
if (fp) fs.promises.unlink(fp).catch(() => {});
}
await dbRun('DELETE FROM email_anhaenge WHERE email_id = ? AND user_id = ?', [emailId, uid()]);
await dbRun('DELETE FROM emails WHERE id = ? AND user_id = ?', [emailId, uid()]);
res.redirect('/postfach?mailok=' + encodeURIComponent('E-Mail wurde gelöscht.'));
} catch (error) {
console.error('Error deleting e-mail:', error);
res.redirect('/postfach?mailerror=' + encodeURIComponent('Löschen fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')));
}
});
// Count of unlinked e-mails — drives the "Postfach" header badge on every page.
app.get('/api/emails/unassigned-count', async (req, res) => {
try {
const row = await dbGet('SELECT COUNT(*) as count FROM emails WHERE user_id = ? AND bewerbung_id IS NULL', [uid()]);
res.json({ count: row ? row.count : 0 });
} catch (error) {
res.status(500).json({ count: 0 });
}
});
// Unread received e-mails — drives the notification bell on every page. Returns
// the total unread count plus the newest few as a ready-to-render list. Replies
// auto-assigned to an application carry a link to that application; unassigned
// mail links to the Postfach.
app.get('/api/notifications', async (req, res) => {
try {
const cntRow = await dbGet(
"SELECT COUNT(*) AS count FROM emails WHERE user_id = ? AND direction = 'in' AND seen = 0",
[uid()]
);
const rows = await dbAll(
`SELECT e.id, e.from_addr, e.subject, e.body_text, e.body_html, e.email_date, e.bewerbung_id,
b.firma, b.stelle
FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id AND b.user_id = e.user_id
WHERE e.user_id = ? AND e.direction = 'in' AND e.seen = 0
ORDER BY datetime(e.email_date) DESC, e.id DESC
LIMIT 30`,
[uid()]
);
const items = rows.map((e) => {
const fromName = String(e.from_addr || '').replace(/<[^>]*>/, '').replace(/"/g, '').trim()
|| String(e.from_addr || '').trim();
const snippet = emailPlainText(e).replace(/\s+/g, ' ').trim().slice(0, 140);
return {
id: e.id,
from: fromName || '(unbekannt)',
subject: e.subject || '(kein Betreff)',
snippet,
date: e.email_date || null,
bewerbung_id: e.bewerbung_id || null,
kontext: e.bewerbung_id ? [e.firma, e.stelle].filter(Boolean).join(' · ') : '',
url: e.bewerbung_id ? ('/bewerbung/' + e.bewerbung_id + '#korrespondenz') : '/postfach',
};
});
res.json({ count: cntRow ? cntRow.count : 0, items });
} catch (error) {
res.status(500).json({ count: 0, items: [] });
}
});
// Mark every received e-mail as read (clears the notification bell).
app.post('/api/emails/mark-all-read', async (req, res) => {
try {
await dbRun("UPDATE emails SET seen = 1 WHERE user_id = ? AND direction = 'in' AND seen = 0", [uid()]);
res.json({ ok: true });
} catch (error) {
res.status(500).json({ ok: false });
}
});
// Add a timeline entry (status change with date + comment)
app.post('/bewerbung/:id/verlauf', async (req, res) => {
try {
const { id } = req.params;
const { datum, status, kommentar } = req.body;
if (datum && status && status.trim()) {
await dbRun(
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
[uid(), id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
);
await syncCurrentStatus(id);
// Suggest a calendar entry when an interview was recorded (confirm + click).
if (caldav.isConfigured() && /vorstellungsgespr/i.test(status)) {
return res.redirect('/bewerbung/' + id + '?vorschlag=vg&vdatum=' + encodeURIComponent(datum) + '#termine');
}
}
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error adding timeline entry:', error);
res.status(500).send('Serverfehler');
}
});
// --- Application calendar appointments (mirrored to SOGo via CalDAV) ---
app.post('/bewerbung/:id/termine', async (req, res) => {
const { id } = req.params;
try {
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
if (!caldav.isConfigured()) {
return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Kalender ist nicht konfiguriert.') + '#termine');
}
const b = req.body || {};
if (!b.datum) {
return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Bitte ein Datum angeben.') + '#termine');
}
const ganztags = b.ganztags === 'on' || b.ganztags === '1' || b.ganztags === 'true';
const typ = b.typ === 'vorstellungsgespraech' ? 'vorstellungsgespraech' : 'termin';
const titel = (b.titel || '').trim() || (typ === 'vorstellungsgespraech' ? 'Vorstellungsgespräch' : 'Termin');
const erinnerung = Math.max(0, parseInt(b.erinnerung_min, 10) || 0);
let start, ende = null;
if (ganztags) {
const [y, mo, d] = String(b.datum).split('-').map(Number);
start = caldav.wallToUtc(y, mo, d, 0, 0);
} else {
start = caldav.localInputToUtc(b.datum, b.von || '09:00');
if (b.bis) ende = caldav.localInputToUtc(b.datum, b.bis);
}
const created = await caldav.createEvent({
summary: titel, location: b.ort || '', description: b.notiz || '',
start, end: ende, allDay: ganztags, alarmMin: erinnerung,
});
await dbRun(
`INSERT INTO termine (user_id, bewerbung_id, typ, titel, ort, notiz, start, ende, ganztags, erinnerung_min, caldav_uid, caldav_href, caldav_etag)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[uid(), id, typ, sanitizeInput(titel), sanitizeInput(b.ort || ''), sanitizeInput(b.notiz || ''),
start.toISOString(), ende ? ende.toISOString() : null, ganztags ? 1 : 0, erinnerung,
created.uid, created.href, created.etag]
);
res.redirect('/bewerbung/' + id + '?terminok=1#termine');
} catch (error) {
console.error('Error creating termin:', error);
res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent(error.message || 'Termin konnte nicht angelegt werden.') + '#termine');
}
});
app.post('/bewerbung/:id/termine/:tid/delete', async (req, res) => {
const { id, tid } = req.params;
try {
const t = await dbGet('SELECT * FROM termine WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [tid, id, uid()]);
if (t) {
await caldav.deleteEvent({ href: t.caldav_href, etag: t.caldav_etag }).catch((e) => console.warn('CalDAV delete:', e.message));
await dbRun('DELETE FROM termine WHERE id = ? AND user_id = ?', [tid, uid()]);
}
res.redirect('/bewerbung/' + id + '#termine');
} catch (error) {
console.error('Error deleting termin:', error);
res.redirect('/bewerbung/' + id + '#termine');
}
});
// Update a timeline entry
app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => {
try {
const { id, eintragId } = req.params;
const { datum, status, kommentar } = req.body;
if (datum && status && status.trim()) {
await dbRun(
'UPDATE status_verlauf SET datum = ?, status = ?, kommentar = ? WHERE id = ? AND bewerbung_id = ? AND user_id = ?',
[datum, sanitizeInput(status), sanitizeInput(kommentar || ''), eintragId, id, uid()]
);
await syncCurrentStatus(id);
}
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error updating timeline entry:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a timeline entry
app.post('/bewerbung/:id/verlauf/:eintragId/delete', async (req, res) => {
try {
const { id, eintragId } = req.params;
await dbRun('DELETE FROM status_verlauf WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [eintragId, id, uid()]);
await syncCurrentStatus(id);
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error deleting timeline entry:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Vorlagen (Basis-Unterlagen) management page -----
app.get('/vorlagen', async (req, res) => {
try {
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [uid()]);
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [uid()]);
const design = await loadDesign();
res.render('vorlagen', {
basisDokumente,
basisAnhaenge,
settings: await loadSettings(),
prompts: promptStore.list(await loadPrompts()),
designFelder: designStore.list(design),
designAngepasst: designStore.isAngepasst(design),
designFotoAn: designStore.settings(design).foto_anzeigen === '1',
hasSignatur: Boolean(currentSignaturFile()),
hasFoto: Boolean(currentFotoFile()),
basisTypOptions: BASIS_TYP_OPTIONS,
hasApiKey: Boolean(config.get('OLLAMA_API_KEY')),
hideSettings: true,
});
} catch (error) {
console.error('Error loading vorlagen:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Persönliche Angaben (under Vorlagen, formerly the index-page modal) -----
// Registered before /vorlagen/:id so the generic base-document handler doesn't
// swallow this path. Stores name/address/contact data that flows into every
// generated document (replacing the previous KI extraction from basis texts).
app.post('/vorlagen/persoenlich', async (req, res) => {
try {
const { name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum } = req.body;
await dbRun(
`INSERT INTO settings (user_id, name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
name = excluded.name, adresse = excluded.adresse, kundennummer = excluded.kundennummer,
email = excluded.email, telefon = excluded.telefon, ort = excluded.ort,
webseite = excluded.webseite, geburtsdatum = excluded.geburtsdatum`,
[
uid(),
sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer),
sanitizeInput(email), sanitizeInput(telefon), sanitizeInput(ort),
sanitizeInput(webseite), sanitizeInput(geburtsdatum)
]
);
res.redirect('/vorlagen#persoenlich');
} catch (error) {
console.error('Error saving personal data:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Editable KI prompts -----
// Registered before /vorlagen/:id so the generic base-document handlers don't
// swallow these paths. The prompt text is stored raw (no HTML escaping): it is
// sent to the LLM, never rendered as markup — the views escape it on output.
// Save an overridden prompt. Empty text = fall back to the default.
app.post('/vorlagen/prompts/:key', async (req, res) => {
try {
const { key } = req.params;
if (!promptStore.isKnownKey(key)) return res.status(404).send('Unbekannter Prompt');
const inhalt = String(req.body.inhalt || '').trim();
if (!inhalt || inhalt === promptStore.defaultText(key).trim()) {
await dbRun('DELETE FROM prompts WHERE user_id = ? AND key = ?', [uid(), key]);
} else {
await dbRun(
`INSERT INTO prompts (user_id, key, inhalt, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, key) DO UPDATE SET inhalt = excluded.inhalt, updated_at = CURRENT_TIMESTAMP`,
[uid(), key, inhalt]
);
}
res.redirect('/vorlagen#prompts');
} catch (error) {
console.error('Error saving prompt:', error);
res.status(500).send('Serverfehler');
}
});
// Restore the default text of a prompt by dropping the override.
app.post('/vorlagen/prompts/:key/reset', async (req, res) => {
try {
await dbRun('DELETE FROM prompts WHERE user_id = ? AND key = ?', [uid(), req.params.key]);
res.redirect('/vorlagen#prompts');
} catch (error) {
console.error('Error resetting prompt:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Design of the generated PDFs -----
// Save the design choices. Values are validated by lib/design.js on read, and
// a choice equal to the default is stored as a deletion, so the DB only ever
// holds real deviations.
app.post('/vorlagen/design', async (req, res) => {
try {
const gewaehlt = designStore.settings(req.body);
// Compare against what the *chosen layout* ships with, not the global
// defaults — otherwise picking "social" would persist its own pink/round
// defaults as if the user had overridden them. `layout` itself keeps the
// global default as its yardstick, so choosing a non-default layout is
// stored (comparing it against itself would never save anything).
const basis = {
...designStore.DEFAULTS,
...(designStore.LAYOUT_DEFAULTS[gewaehlt.layout] || {}),
layout: designStore.DEFAULTS.layout,
};
for (const [key, value] of Object.entries(gewaehlt)) {
if (value === basis[key]) {
await dbRun('DELETE FROM design WHERE user_id = ? AND key = ?', [uid(), key]);
} else {
await dbRun(
`INSERT INTO design (user_id, key, value, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
[uid(), key, value]
);
}
}
res.redirect('/vorlagen#design');
} catch (error) {
console.error('Error saving design:', error);
res.status(500).send('Serverfehler');
}
});
// Back to the shipped design.
app.post('/vorlagen/design/reset', async (req, res) => {
try {
await dbRun('DELETE FROM design WHERE user_id = ?', [uid()]);
res.redirect('/vorlagen#design');
} catch (error) {
console.error('Error resetting design:', error);
res.status(500).send('Serverfehler');
}
});
// Preview PDF (Anschreiben or Lebenslauf) with sample content — lets the user
// see a design choice without spending an LLM run. Query params override the
// saved design, so the form can preview a selection before it is saved.
app.get('/vorlagen/design/vorschau/:doc.pdf', async (req, res) => {
try {
const welches = req.params.doc === 'lebenslauf' ? 'lebenslauf' : 'anschreiben';
const gespeichert = await loadDesign();
// Only known design keys from the query are honoured; design.settings()
// drops anything invalid.
const design = { ...gespeichert, ...req.query };
const settings = await loadSettings();
const pdfs = renderDesignVorschau({
settings,
signatur: loadSignatur(),
bewerbungsfoto: loadFoto(),
design,
});
res.type('application/pdf');
res.setHeader('Content-Disposition', `inline; filename="Vorschau_${welches}.pdf"`);
res.send(pdfs[welches]);
} catch (error) {
console.error('Error rendering design preview:', error);
res.status(500).send('Vorschau konnte nicht erzeugt werden');
}
});
// Add a base document
app.post('/vorlagen', async (req, res) => {
try {
const { typ, name, inhalt } = req.body;
if (inhalt && inhalt.trim()) {
await dbRun(
'INSERT INTO basis_dokumente (user_id, typ, name, inhalt) VALUES (?, ?, ?, ?)',
[uid(), sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt]
);
}
res.redirect('/vorlagen');
} catch (error) {
console.error('Error adding base document:', error);
res.status(500).send('Serverfehler');
}
});
// Update a base document
app.post('/vorlagen/:id', async (req, res) => {
try {
const { id } = req.params;
const { typ, name, inhalt } = req.body;
await dbRun(
'UPDATE basis_dokumente SET typ = ?, name = ?, inhalt = ? WHERE id = ? AND user_id = ?',
[sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt || '', id, uid()]
);
res.redirect('/vorlagen');
} catch (error) {
console.error('Error updating base document:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a base document
app.post('/vorlagen/:id/delete', async (req, res) => {
try {
await dbRun('DELETE FROM basis_dokumente WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
res.redirect('/vorlagen');
} catch (error) {
console.error('Error deleting base document:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Static extra attachments (Zeugnisse etc.) -----
// Upload an attachment
app.post('/anlagen', (req, res) => {
uploadBasisAnhang(req, res, async (err) => {
try {
if (err) {
console.error('Upload error:', err.message);
return res.redirect('/vorlagen');
}
if (req.file) {
const original = req.file.originalname || req.file.filename;
const name = (req.body.name && req.body.name.trim())
? sanitizeInput(req.body.name.trim())
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
await dbRun(
'INSERT INTO basis_anhaenge (user_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
[uid(), name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
);
}
res.redirect('/vorlagen');
} catch (error) {
console.error('Error saving attachment:', error);
res.status(500).send('Serverfehler');
}
});
});
// Download a static attachment
app.get('/anlagen/:id/download', async (req, res) => {
try {
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
if (!a) return res.status(404).send('Anlage nicht gefunden');
const filePath = containedPath(userStorageDir(basisAnhaengeDir), a.pfad);
if (!filePath || !fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
res.download(filePath, displayFilename(a.dateiname));
} catch (error) {
console.error('Error downloading attachment:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a static attachment
app.post('/anlagen/:id/delete', async (req, res) => {
try {
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
if (a) {
const fp = containedPath(userStorageDir(basisAnhaengeDir), a.pfad);
if (fp) fs.promises.unlink(fp).catch(() => {});
await dbRun('DELETE FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
}
res.redirect('/vorlagen');
} catch (error) {
console.error('Error deleting attachment:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Signature (Unterschrift) -----
// Serve the current signature image (for the preview on the Vorlagen page)
app.get('/unterschrift', (req, res) => {
const file = currentSignaturFile();
if (!file) return res.status(404).send('Keine Unterschrift');
res.sendFile(file);
});
// Upload / replace the signature
app.post('/unterschrift', (req, res) => {
uploadSignatur(req, res, (err) => {
try {
if (err) console.error('Signature upload error:', err.message);
if (req.file) {
// keep only the newly uploaded file (in the user's subdir)
const dir = userStorageDir(signaturDir);
fs.readdirSync(dir).forEach((f) => {
if (f !== req.file.filename) fs.promises.unlink(path.join(dir, f)).catch(() => {});
});
}
res.redirect('/vorlagen');
} catch (error) {
console.error('Error saving signature:', error);
res.status(500).send('Serverfehler');
}
});
});
// Delete the signature
app.post('/unterschrift/delete', (req, res) => {
try {
const dir = userStorageDir(signaturDir);
fs.readdirSync(dir).forEach((f) => fs.promises.unlink(path.join(dir, f)).catch(() => {}));
} catch (e) { /* ignore */ }
res.redirect('/vorlagen');
});
// ----- Applicant photo (Bewerberfoto, used in the CV) -----
// Serve the current photo (for the preview on the Vorlagen page)
app.get('/bewerbungsfoto', (req, res) => {
const file = currentFotoFile();
if (!file) return res.status(404).send('Kein Bewerberfoto');
res.sendFile(file);
});
// Upload / replace the photo
app.post('/bewerbungsfoto', (req, res) => {
uploadFoto(req, res, (err) => {
try {
if (err) console.error('Photo upload error:', err.message);
if (req.file) {
// keep only the newly uploaded file (in the user's subdir)
const dir = userStorageDir(fotoDir);
fs.readdirSync(dir).forEach((f) => {
if (f !== req.file.filename) fs.promises.unlink(path.join(dir, f)).catch(() => {});
});
}
res.redirect('/vorlagen');
} catch (error) {
console.error('Error saving photo:', error);
res.status(500).send('Serverfehler');
}
});
});
// Delete the photo
app.post('/bewerbungsfoto/delete', (req, res) => {
try {
const dir = userStorageDir(fotoDir);
fs.readdirSync(dir).forEach((f) => fs.promises.unlink(path.join(dir, f)).catch(() => {}));
} catch (e) { /* ignore */ }
res.redirect('/vorlagen');
});
// Download a generated attachment
app.get('/anhaenge/:id/download', async (req, res) => {
try {
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
if (!anhang) return res.status(404).send('Anhang nicht gefunden');
const filePath = containedPath(userStorageDir(anhaengeDir), anhang.pfad);
if (!filePath || !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, displayFilename(anhang.dateiname));
} catch (error) {
console.error('Error downloading attachment:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a generated attachment
app.post('/bewerbung/:id/anhaenge/:anhangId/delete', async (req, res) => {
try {
const { id, anhangId } = req.params;
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
if (anhang) {
const filePath = containedPath(userStorageDir(anhaengeDir), anhang.pfad);
if (filePath) fs.promises.unlink(filePath).catch(() => {});
await dbRun('DELETE FROM anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]);
}
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error deleting attachment:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Private attachments (internal notes) -----
// Upload a private attachment for an application's internal notes
app.post('/bewerbung/:id/interne-anhaenge', (req, res) => {
uploadInterneAnhang(req, res, async (err) => {
try {
const { id } = req.params;
if (err) {
console.error('Interne-Anhang upload error:', err.message);
return res.redirect('/bewerbung/' + id);
}
if (req.file) {
const original = req.file.originalname || req.file.filename;
const name = (req.body.name && req.body.name.trim())
? sanitizeInput(req.body.name.trim())
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
await dbRun(
'INSERT INTO interne_anhaenge (user_id, bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?, ?)',
[uid(), id, name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
);
}
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error saving internal attachment:', error);
res.status(500).send('Serverfehler');
}
});
});
// Download a private attachment (inline=1 opens PDFs/images in the browser)
app.get('/bewerbung/:id/interne-anhaenge/:anhangId/download', async (req, res) => {
try {
const { id, anhangId } = req.params;
const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
if (!anhang) return res.status(404).send('Anhang nicht gefunden');
const filePath = containedPath(userStorageDir(interneAnhaengeDir), anhang.pfad);
if (!filePath || !fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
if (req.query.inline === '1') return serveInline(res, filePath, anhang.dateiname, anhang.mime);
res.download(filePath, displayFilename(anhang.dateiname));
} catch (error) {
console.error('Error downloading internal attachment:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a private attachment
app.post('/bewerbung/:id/interne-anhaenge/:anhangId/delete', async (req, res) => {
try {
const { id, anhangId } = req.params;
const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
if (anhang) {
const fp = containedPath(userStorageDir(interneAnhaengeDir), anhang.pfad);
if (fp) fs.promises.unlink(fp).catch(() => {});
await dbRun('DELETE FROM interne_anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]);
}
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error deleting internal attachment:', error);
res.status(500).send('Serverfehler');
}
});
// Start (or re-run) the AI generation for an application. Saves the LLM notes
// first, removes any previously generated attachments, then generates.
app.post('/bewerbung/:id/generieren', async (req, res) => {
try {
const { id } = req.params;
const U = uid();
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [id, U]);
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
// Persist the LLM notes the user provided (used as context during generation)
if (typeof req.body.llm_notizen !== 'undefined') {
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ? AND user_id = ?', [req.body.llm_notizen || '', id, U]);
}
const anhaengeUserDir = userStorageDir(anhaengeDir);
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, U]);
for (const a of alte) {
const fp = containedPath(anhaengeUserDir, a.pfad);
if (fp) fs.promises.unlink(fp).catch(() => {});
}
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, U]);
await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ? AND user_id = ?", [id, U]);
// Selected extra attachments (checkbox values); none by default.
const anlagenIds = [].concat(req.body.anlage || []).map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n));
// Which documents to generate; nothing ticked = both (the default).
const dokumente = normalizeDokumente(req.body.dokument);
await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ? AND user_id = ?', [dokumente.join(','), id, U]);
runGeneration(id, { anlagenIds, dokumente });
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error generating documents:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Jobangebote page (list page) -----
// The offers shown here are ingested by third-party software via the
// /api/v1/joboffers REST endpoint (POST). The page itself is read-only plus
// two manual actions: turn an offer into an application draft, or delete it.
// Count of open offers, consumed by the header badge (no auth — just a number).
app.get('/jobangebote/anzahl-offen', async (req, res) => {
try {
const row = await dbGet("SELECT COUNT(*) as count FROM jobangebote WHERE user_id = ? AND status = 'offen'", [uid()]);
res.json({ count: row ? row.count : 0 });
} catch (error) {
res.status(500).json({ count: 0 });
}
});
// Main list: only OPEN offers (still to be decided on). Taken-over offers
// live on their own page (/jobangebote/uebernommen).
app.get('/jobangebote', async (req, res) => {
try {
const jobangebote = await dbAll(
`SELECT j.*, b.datum AS bewerbung_datum
FROM jobangebote j
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id
WHERE j.user_id = ? AND j.status = 'offen'
ORDER BY j.created_at DESC, j.id DESC`,
[uid()]
);
jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
const uebernommenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE user_id = ? AND status = 'uebernommen'", [uid()]);
res.render('jobangebote', {
jobangebote,
uebernommenCount: uebernommenRow ? uebernommenRow.c : 0,
artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS,
labelOptions: LABEL_OPTIONS,
hideSettings: false,
});
} catch (error) {
console.error('Error listing job offers:', error);
res.status(500).send('Serverfehler');
}
});
// Taken-over offers: which offers were turned into applications.
app.get('/jobangebote/uebernommen', async (req, res) => {
try {
const uebernommen = await dbAll(
`SELECT j.*, b.datum AS bewerbung_datum, b.status AS bewerbung_status
FROM jobangebote j
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id
WHERE j.user_id = ? AND j.status = 'uebernommen'
ORDER BY j.updated_at DESC, j.id DESC`,
[uid()]
);
uebernommen.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
const offenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE user_id = ? AND status = 'offen'", [uid()]);
res.render('jobangebote_uebernommen', {
uebernommen,
offenCount: offenRow ? offenRow.c : 0,
hideSettings: false,
});
} catch (error) {
console.error('Error listing taken-over offers:', error);
res.status(500).send('Serverfehler');
}
});
// Convert a job offer into a Bewerbung draft (mirrors the Indeed import flow:
// creates a bewerbung with status "Entwurf", records the initial timeline entry,
// and links the offer back to it).
// Create a Bewerbung draft from an offer (or return the already-linked one).
// The offer's FULL description is carried into `stellenbeschreibung`, which is
// exactly the text handed to the LLM during generation. Returns the id.
async function uebernehmeAngebot(angebot) {
if (angebot.verknuepfte_bewerbung_id) return angebot.verknuepfte_bewerbung_id;
const datum = new Date().toISOString().split('T')[0];
const notizParts = [
angebot.ort ? `Ort: ${angebot.ort}` : null,
angebot.gehalt ? `Gehalt: ${angebot.gehalt}` : null,
angebot.kontakt_email ? `Kontakt: ${angebot.kontakt_email}` : null,
angebot.quelle_url ? `Quelle: ${angebot.quelle_url}` : null,
angebot.quelle ? `Importiert via: ${angebot.quelle}` : null,
].filter(Boolean);
// Employer address (street, house number, city) and contact person go into
// the AI notes so the LLM can use them for the letter's Anschriftfeld and
// salutation.
let anschrift = (angebot.adresse || '').trim();
if (angebot.ort && !anschrift.toLowerCase().includes(String(angebot.ort).toLowerCase())) {
anschrift = anschrift ? `${anschrift}, ${angebot.ort}` : String(angebot.ort);
}
const llmNotizen = [
anschrift ? `Anschrift des Arbeitgebers: ${anschrift}` : null,
angebot.ansprechpartner ? `Ansprechpartner: ${angebot.ansprechpartner}` : null,
].filter(Boolean).join('\n');
const result = await dbRun(
`INSERT INTO bewerbungen
(user_id, datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung,
quelle_url, email_empfaenger, llm_notizen, labels, generierung_status)
VALUES (?, ?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
[
currentUserId(),
datum,
sanitizeInput(angebot.firma),
sanitizeInput(angebot.stelle),
sanitizeInput(angebot.art || deriveArt(angebot.quelle_url, null)),
notizParts.join('\n'),
angebot.ort || '',
angebot.beschreibung || '',
angebot.quelle_url || '',
angebot.kontakt_email || '',
llmNotizen,
serializeLabels(angebot.labels),
]
);
await dbRun(
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
[currentUserId(), result.lastID, datum, 'Entwurf', `Automatisch aus Jobangebot übernommen (${angebot.quelle || 'drittanbieter'})`]
);
await dbRun(
'UPDATE jobangebote SET verknuepfte_bewerbung_id = ?, status = "uebernommen", updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
[result.lastID, angebot.id, currentUserId()]
);
return result.lastID;
}
app.post('/jobangebote/:id/uebernehmen', async (req, res) => {
try {
const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
if (!angebot) return res.status(404).send('Jobangebot nicht gefunden');
const bewerbungId = await uebernehmeAngebot(angebot);
res.redirect('/bewerbung/' + bewerbungId);
} catch (error) {
console.error('Error converting job offer:', error);
res.status(500).send('Serverfehler');
}
});
// Edit an offer's fields — mainly to paste/adjust the full job description
// (any length) before turning it into an application.
app.post('/jobangebote/:id/bearbeiten', async (req, res) => {
try {
const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
if (!angebot) return res.status(404).send('Jobangebot nicht gefunden');
const b = req.body || {};
const quelleUrl = sanitizeInput(b.quelle_url || '');
await dbRun(
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
beschreibung = ?, kontakt_email = ?, quelle_url = ?, anzeige_datum = ?,
labels = ?, url_norm = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?`,
[
sanitizeInput((b.firma || '').trim()) || angebot.firma,
sanitizeInput((b.stelle || '').trim()) || angebot.stelle,
sanitizeInput(b.ort || ''),
sanitizeInput(b.adresse || ''),
sanitizeInput(b.ansprechpartner || ''),
sanitizeInput(b.gehalt || ''),
sanitizeInput(b.beschreibung || ''),
sanitizeInput(b.kontakt_email || ''),
quelleUrl,
sanitizeInput(b.anzeige_datum || ''),
serializeLabels(b.labels),
blacklist.normalizeUrl(b.quelle_url || '') || null,
req.params.id,
uid(),
]
);
res.redirect('/jobangebote');
} catch (error) {
console.error('Error editing job offer:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a job offer. Deleting always blacklists it first, so the same offer
// can never be ingested/listed again (the requirement: never appears twice,
// even after deletion). The entry can be removed later on /blacklist.
app.post('/jobangebote/:id/delete', async (req, res) => {
try {
const offer = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
if (offer) {
await autoBlacklistOffer(offer, 'Jobangebot gelöscht (Web-UI)');
await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
}
res.redirect('/jobangebote');
} catch (error) {
console.error('Error deleting job offer:', error);
res.status(500).send('Serverfehler');
}
});
// Blacklist management page — see what is blocked and remove entries.
app.get('/blacklist', async (req, res) => {
try {
const eintraege = await dbAll(
'SELECT * FROM jobangebote_blacklist WHERE user_id = ? ORDER BY created_at DESC, id DESC',
[uid()]
);
res.render('blacklist', { eintraege, blacklistTypen: blacklist.TYPES, hideSettings: false });
} catch (error) {
console.error('Error listing blacklist:', error);
res.status(500).send('Serverfehler');
}
});
// Add a manual blacklist entry (URL / domain / company / company+title).
app.post('/blacklist', async (req, res) => {
try {
const entry = blacklist.buildManualEntry({
typ: req.body.typ,
wert: sanitizeInput(req.body.wert || ''),
firma: sanitizeInput(req.body.firma || ''),
stelle: sanitizeInput(req.body.stelle || ''),
ort: sanitizeInput(req.body.ort || ''),
grund: sanitizeInput(req.body.grund || ''),
});
if (entry) await insertBlacklistEntry(entry);
res.redirect('/blacklist');
} catch (error) {
console.error('Error adding blacklist entry:', error);
res.status(500).send('Serverfehler');
}
});
// Remove a blacklist entry (offer may then be ingested again).
app.post('/blacklist/:id/delete', async (req, res) => {
try {
await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
res.redirect('/blacklist');
} catch (error) {
console.error('Error deleting blacklist entry:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Einstellungen (früher .env — jetzt in der Datenbank) -----
// Alle Konfigurationswerte (Ollama, E-Mail, CalDAV, REST-API) liegen in der
// app_state-Tabelle und sind hier editierbar. Speichern wirkt sofort, ein
// Neustart ist nicht nötig (die Libs lesen per config.get() zur Laufzeit).
app.get('/einstellungen', (req, res) => {
res.render('settings', {
felder: config.FIELDS,
werte: config.getAll(),
hideSettings: true,
});
});
app.post('/einstellungen', async (req, res) => {
try {
const werte = {};
for (const sektion of config.FIELDS) {
for (const f of sektion.items) {
werte[f.key] = req.body[f.key] != null ? String(req.body[f.key]) : '';
}
}
// The API token identifies the user to /api/v1, so it must not collide with
// another user's: the auth lookup would otherwise be ambiguous (it now fails
// closed, which would lock *both* users out). Reject the save instead.
const token = (werte.API_TOKEN || '').trim();
if (token) {
const fremd = await dbGet(
`SELECT u.username FROM app_state a JOIN users u ON u.id = a.user_id
WHERE a.key = ? AND a.value = ? AND a.user_id != ?`,
[config.PREFIX + 'API_TOKEN', token, uid()]
);
if (fremd) {
return res.status(409).send('Dieser API-Token ist bereits von einem anderen Benutzer belegt. Bitte „Neu generieren“ verwenden.');
}
}
// SSRF guard: the user can configure external hosts the server will call
// (Ollama, CalDAV, mail). Validate them against a metadata/link-local
// blocklist so a user cannot point the server at a cloud metadata endpoint
// to exfiltrate instance credentials. Localhost / LAN stays allowed on
// purpose (the app supports a local Ollama or on-prem CalDAV).
try {
if ((werte.OLLAMA_HOST || '').trim()) await assertSafeUrl(werte.OLLAMA_HOST.trim());
if ((werte.CALDAV_URL || '').trim()) await assertSafeUrl(werte.CALDAV_URL.trim());
if ((werte.MAIL_HOST || '').trim()) await assertSafeHost(werte.MAIL_HOST.trim());
} catch (vErr) {
return res.status(400).send('Einstellung abgelehnt: ' + (vErr.message || 'ungültiger Host'));
}
await config.saveAll(werte);
res.redirect('/einstellungen');
} catch (error) {
console.error('Error saving settings:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Jobsuche: Suchprofil + Suchläufe -----
// The profile steers *where* to search; the roles/technologies come from the
// user's own Lebenslauf at run time. Runs are only queued here — the host-side
// runner executes them (see lib/suchprofil for why).
//
// An admin may manage another user's profile via ?user=<id> / the hidden
// `ziel_user` field; everyone else is always confined to their own.
function resolveZielUser(req) {
const roh = req.query.user != null ? req.query.user : (req.body || {}).ziel_user;
const id = Number(roh);
if (!roh || Number.isNaN(id) || id === Number(req.user.id)) return { id: Number(req.user.id), fremd: false };
if (!req.user.is_admin) return null; // nur Admins dürfen fremde Profile sehen/ändern
return { id, fremd: true };
}
app.get('/jobsuche', async (req, res) => {
try {
const ziel = resolveZielUser(req);
if (!ziel) return res.status(403).send('Zugriff verweigert nur für Administratoren.');
const zielUser = await dbGet('SELECT id, username FROM users WHERE id = ?', [ziel.id]);
if (!zielUser) return res.status(404).send('Benutzer nicht gefunden.');
const profil = await loadSuchprofil(ziel.id);
const laeufe = await dbAll(
'SELECT * FROM suchlaeufe WHERE user_id = ? ORDER BY id DESC LIMIT 10',
[ziel.id]
);
// Warn up front instead of letting a run fail in the background: without a
// Lebenslauf there is no profile to derive roles from, and without an Ollama
// key the run has no model to talk to (both are per-user now).
const lebenslauf = await dbGet(
"SELECT id FROM basis_dokumente WHERE user_id = ? AND typ = 'Lebenslauf' AND inhalt IS NOT NULL AND inhalt != '' LIMIT 1",
[ziel.id]
);
// Der KI-Feinschliff liest ALLE Basis-Unterlagen, nicht nur den Lebenslauf —
// ein Kurzprofil allein reicht ihm schon.
const unterlagen = await dbGet(
"SELECT id FROM basis_dokumente WHERE user_id = ? AND inhalt IS NOT NULL AND inhalt != '' LIMIT 1",
[ziel.id]
);
const ollamaKey = await dbGet(
"SELECT value FROM app_state WHERE user_id = ? AND key = 'cfg:OLLAMA_API_KEY' AND value != ''",
[ziel.id]
);
res.render('jobsuche', {
profil,
laeufe,
zielUser,
fremd: ziel.fremd,
modi: suchprofil.MODI,
wochentage: suchprofil.WOCHENTAGE,
maxStaedte: suchprofil.MAX_STAEDTE,
hatLebenslauf: Boolean(lebenslauf),
hatUnterlagen: Boolean(unterlagen),
hatOllamaKey: Boolean(ollamaKey),
fehler: req.query.fehler ? String(req.query.fehler) : null,
hinweis: req.query.hinweis ? String(req.query.hinweis) : null,
hideSettings: false,
});
} catch (error) {
console.error('Error loading jobsuche:', error);
res.status(500).send('Serverfehler');
}
});
app.post('/jobsuche', async (req, res) => {
try {
const ziel = resolveZielUser(req);
if (!ziel) return res.status(403).send('Zugriff verweigert nur für Administratoren.');
const suffix = ziel.fremd ? `?user=${ziel.id}` : '';
const profil = suchprofil.fromForm(req.body, sanitizeInput);
const fehler = suchprofil.validate(profil);
if (fehler.length) {
return res.redirect(`/jobsuche${suffix}${suffix ? '&' : '?'}fehler=${encodeURIComponent(fehler.join(' '))}`);
}
await saveSuchprofil(ziel.id, profil);
res.redirect(`/jobsuche${suffix}${suffix ? '&' : '?'}hinweis=${encodeURIComponent('Suchprofil gespeichert.')}`);
} catch (error) {
console.error('Error saving suchprofil:', error);
res.status(500).send('Serverfehler');
}
});
// KI-Vorschlag für den Feinschliff (Zusatzbegriffe / Ausschlüsse), abgeleitet
// aus den Basis-Unterlagen des Zielbenutzers. Der Vorschlag wird nur
// zurückgegeben, nicht gespeichert — der Nutzer prüft ihn im Formular und
// speichert selbst. Anders als der Suchlauf läuft das direkt hier: es ist ein
// einzelner Ollama-Aufruf, kein Web-Rechercheauftrag für den Host-Runner.
app.post('/jobsuche/feinschliff', async (req, res) => {
try {
const ziel = resolveZielUser(req);
if (!ziel) return res.status(403).json({ error: 'Zugriff verweigert nur für Administratoren.' });
const unterlagen = await dbAll(
`SELECT typ, name, inhalt FROM basis_dokumente
WHERE user_id = ? AND inhalt IS NOT NULL AND inhalt != ''
ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 WHEN 'Profil/Kurzprofil' THEN 1 WHEN 'Anschreiben' THEN 2 ELSE 3 END, id ASC`,
[ziel.id]
);
if (!unterlagen.length) {
return res.status(400).json({
error: 'Keine Basis-Unterlagen hinterlegt lege zuerst unter „Vorlagen“ einen Lebenslauf an.',
});
}
// Modus/Städte kommen aus dem Formular (ggf. ungespeichert), der Rest ist
// für den Vorschlag irrelevant.
const profil = suchprofil.fromForm({ ...req.body, aktiv: '0' }, sanitizeInput);
const text = unterlagen
.map((d) => `## ${d.typ}${d.name ? ` ${d.name}` : ''}\n${String(d.inhalt).trim()}`)
.join('\n\n');
const vorschlag = await generateFeinschliff({
unterlagen: text,
profil,
prompts: await loadPrompts(),
});
res.json(vorschlag);
} catch (error) {
console.error('Error generating Feinschliff:', error);
res.status(500).json({ error: error.message || 'Serverfehler' });
}
});
// "Jetzt suchen": queue a run. Picked up by the host runner within a few minutes.
app.post('/jobsuche/start', async (req, res) => {
try {
const ziel = resolveZielUser(req);
if (!ziel) return res.status(403).send('Zugriff verweigert nur für Administratoren.');
const suffix = ziel.fremd ? `?user=${ziel.id}` : '';
const sep = suffix ? '&' : '?';
const profil = await loadSuchprofil(ziel.id);
const fehler = suchprofil.validate(profil);
if (fehler.length) {
return res.redirect(`/jobsuche${suffix}${sep}fehler=${encodeURIComponent(fehler.join(' '))}`);
}
const { neu } = await queueSuchlauf(ziel.id, 'manuell');
const msg = neu
? 'Suchlauf angefordert er startet innerhalb weniger Minuten.'
: 'Es läuft bereits ein Suchlauf es wurde kein zweiter gestartet.';
res.redirect(`/jobsuche${suffix}${sep}hinweis=${encodeURIComponent(msg)}`);
} catch (error) {
console.error('Error queueing suchlauf:', error);
res.status(500).send('Serverfehler');
}
});
// Poll target for the page (shows a run moving from angefordert -> läuft -> fertig).
app.get('/jobsuche/laeufe', async (req, res) => {
try {
const ziel = resolveZielUser(req);
if (!ziel) return res.status(403).json({ error: 'Zugriff verweigert' });
const laeufe = await dbAll(
'SELECT * FROM suchlaeufe WHERE user_id = ? ORDER BY id DESC LIMIT 10',
[ziel.id]
);
res.json(laeufe);
} catch (error) {
console.error('Error listing suchlaeufe:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// ----- Admin: Benutzerverwaltung (nur für Admins) -----
// Admins legen neue Benutzer an, setzen Passwörter zurück und löschen
// Benutzer. Beim Löschen eines Benutzers löscht die DB per ON DELETE CASCADE
// alle seine Daten (Bewerbungen, E-Mails, Termine, Chat, Dateien liegen in
// data/<dir>/<userId>/ und müssen separat entfernt werden — siehe unten).
function requireAdmin(req, res, next) {
// Admin rights are suspended while impersonating another user: the admin is
// reviewing that user's account, not exercising admin powers. This also
// keeps the impersonated session from reaching admin endpoints at all.
if (req.user && req.user.is_admin && !req.impersonator) return next();
if (req.path.startsWith('/admin/') || req.xhr || (req.get('accept') || '').includes('application/json')) {
return res.status(403).send('Zugriff verweigert nur für Administratoren.');
}
res.status(403).send('Zugriff verweigert nur für Administratoren.');
}
app.get('/admin', requireAdmin, async (req, res) => {
try {
const users = await dbAll(
'SELECT id, username, is_admin, created_at, (SELECT COUNT(*) FROM bewerbungen b WHERE b.user_id = users.id) AS anzahl_bewerbungen FROM users ORDER BY is_admin DESC, id ASC'
);
res.render('admin', { users, currentUserId: uid(), hideSettings: false });
} catch (error) {
console.error('Admin list error:', error);
res.status(500).send('Serverfehler');
}
});
// Neuen Benutzer anlegen.
app.post('/admin/users', requireAdmin, async (req, res) => {
try {
const username = sanitizeInput((req.body.username || '').trim());
const plain = req.body.password || '';
const isAdmin = req.body.is_admin === '1' || req.body.is_admin === 'on';
if (!username || !plain) return res.status(400).send('Benutzername und Passwort erforderlich.');
if (username.length > 64) return res.status(400).send('Benutzername zu lang.');
const dup = await dbGet('SELECT id FROM users WHERE username = ?', [username]);
if (dup) return res.status(409).send('Benutzername bereits vergeben.');
await dbRun('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)',
[username, password.hash(plain), isAdmin ? 1 : 0]);
res.redirect('/admin');
} catch (error) {
console.error('Admin create user error:', error);
res.status(500).send('Serverfehler');
}
});
// Passwort zurücksetzen.
app.post('/admin/users/:id/reset-password', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
const plain = req.body.password || '';
if (!plain) return res.status(400).send('Passwort erforderlich.');
await dbRun('UPDATE users SET password_hash = ? WHERE id = ?', [password.hash(plain), id]);
await dbRun('DELETE FROM sessions WHERE user_id = ?', [id]); // alle Sessions des Benutzers ungültigen
res.redirect('/admin');
} catch (error) {
console.error('Admin reset password error:', error);
res.status(500).send('Serverfehler');
}
});
// Benutzer löschen (mit allen Daten + Dateien). Der letzte Admin darf nicht
// gelöscht werden, sonst sperrt man sich selbst aus.
app.post('/admin/users/:id/delete', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
if (id === Number(req.user.id)) return res.status(400).send('Man kann sich nicht selbst löschen.');
const target = await dbGet('SELECT is_admin FROM users WHERE id = ?', [id]);
if (!target) return res.status(404).send('Benutzer nicht gefunden.');
if (target.is_admin) {
const adminCount = await dbGet('SELECT COUNT(*) as count FROM users WHERE is_admin = 1');
if (adminCount && adminCount.count <= 1) return res.status(400).send('Der letzte Admin darf nicht gelöscht werden.');
}
// Dateien des Benutzers auf der Festplatte entfernen (die DB-Zeilen löscht
// ON DELETE CASCADE).
for (const dir of migrate.STORAGE_DIRS) {
const base = path.join(dataDir, dir, String(id));
if (fs.existsSync(base)) fs.rmSync(base, { recursive: true, force: true });
}
await dbRun('DELETE FROM users WHERE id = ?', [id]);
res.redirect('/admin');
} catch (error) {
console.error('Admin delete user error:', error);
res.status(500).send('Serverfehler');
}
});
// --- Impersonation: admin "logs in as" a user --------------------------
// Best practice (Django/Flask-impersonate style): the admin's own session
// keeps its cookie token, but its user_id switches to the target and the
// original admin is preserved in sessions.impersonator_id. Everything the app
// does (DB queries via uid(), per-user config, file dirs) then runs as the
// target — the admin sees exactly the user's account. Admin rights are
// suspended while impersonating (requireAdmin), and a banner + "switch back"
// action are always one click away. Every start/stop is recorded in audit_log.
async function auditLog(actorId, targetId, action) {
await dbRun(
'INSERT INTO audit_log (actor_user_id, target_user_id, action) VALUES (?, ?, ?)',
[actorId || null, targetId || null, action]
).catch((e) => console.error('audit_log write failed:', e.message));
}
// Start impersonating a user. Admin-only, and never while already
// impersonating (no nesting — switch back first).
app.post('/admin/users/:id/impersonate', requireAdmin, async (req, res) => {
try {
const targetId = Number(req.params.id);
if (!targetId || targetId === Number(req.user.id)) {
return res.status(400).send('Man kann sich nicht selbst imitieren.');
}
const target = await dbGet('SELECT id, username FROM users WHERE id = ?', [targetId]);
if (!target) return res.status(404).send('Benutzer nicht gefunden.');
const token = req.sessionToken;
if (!token) return res.status(400).send('Keine Sitzung.');
await dbRun(
'UPDATE sessions SET user_id = ?, impersonator_id = ?, last_seen = CURRENT_TIMESTAMP WHERE token = ?',
[target.id, req.user.id, token]
);
await auditLog(req.user.id, target.id, `impersonate_start → ${target.username}`);
console.log(`Impersonation: Admin #${req.user.id} (${req.user.username}) → User #${target.id} (${target.username})`);
res.redirect('/');
} catch (error) {
console.error('Admin impersonate error:', error);
res.status(500).send('Serverfehler');
}
});
// Switch back to the original admin. This is intentionally NOT requireAdmin:
// it runs while impersonating, when req.user is the target (non-admin). The
// guard is the session's impersonator_id — only an actual impersonation can
// stop one, so a normal user session (impersonator_id NULL) cannot use it.
app.post('/admin/impersonate/stop', async (req, res) => {
try {
const token = req.sessionToken;
if (!token) return res.redirect('/');
const row = await dbGet(
'SELECT user_id, impersonator_id FROM sessions WHERE token = ?',
[token]
);
if (!row || !row.impersonator_id) {
return res.status(403).send('Keine aktive Impersonation.');
}
const adminId = row.impersonator_id;
const targetId = row.user_id;
await dbRun(
'UPDATE sessions SET user_id = ?, impersonator_id = NULL, last_seen = CURRENT_TIMESTAMP WHERE token = ?',
[adminId, token]
);
await auditLog(adminId, targetId, 'impersonate_stop');
console.log(`Impersonation: Admin #${adminId} switched back from User #${targetId}`);
res.redirect('/admin');
} catch (error) {
console.error('Admin impersonate stop error:', error);
res.status(500).send('Serverfehler');
}
});
// Recent impersonation audit entries for the admin dashboard.
app.get('/admin/audit/impersonations', requireAdmin, async (req, res) => {
try {
const rows = await dbAll(
`SELECT a.action, a.created_at,
au.username AS actor, tu.username AS target
FROM audit_log a
LEFT JOIN users au ON au.id = a.actor_user_id
LEFT JOIN users tu ON tu.id = a.target_user_id
WHERE a.action LIKE 'impersonate_%'
ORDER BY a.created_at DESC
LIMIT 25`
);
res.json({ items: rows });
} catch (error) {
console.error('Audit list error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// ----- Conversational KI-Chat (Ollama, streaming) -----
// Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the
// assistant answer is streamed back via Server-Sent Events.
async function gatherChatContext() {
const [settings, profilRows, prompts] = await Promise.all([
loadSettings(),
dbAll(
`SELECT inhalt FROM basis_dokumente
WHERE user_id = ? AND typ IN ('Lebenslauf', 'Profil/Kurzprofil') AND inhalt IS NOT NULL AND inhalt != ''
ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END`,
[uid()]
),
loadPrompts(),
]);
// Lightweight core context only: name, date and the user's profile (static,
// small). All application/appointment data is fetched on demand via tools,
// so the system prompt stays tiny regardless of how many bewerbungen exist.
const profil = (profilRows.map((r) => (r.inhalt || '').trim()).join('\n\n---\n\n')).slice(0, 1800);
const heute = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
return { heute, profil, prompts, settings };
}
// Ollama tool definitions the assistant can call to look up application data.
const CHAT_TOOLS = [
{
type: 'function',
function: {
name: 'suche_bewerbungen',
description: 'Volltextsuche über Bewerbungen UND ihre E-Mail-Korrespondenz: Firma, Stelle, Notizen, Stellenbeschreibung, Absender/Empfänger, Betreff und Mailtext. Nutze dies auch, wenn der Nutzer nur einen Namen einer Ansprechpartnerin, einen Betreff oder eine Formulierung aus einer E-Mail nennt. Liefert je Treffer die Bewerbung samt Fundstelle (wo der Begriff steht) — hole Details mit bewerbung_detail.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Suchbegriff (mind. 2 Zeichen). Mehrere Wörter werden UND-verknüpft; "in Anführungszeichen" sucht die Wortfolge, feld:wert grenzt ein (firma:, stelle:, von:, betreff:), -wort schließt aus.' },
},
required: ['query'],
},
},
},
{
type: 'function',
function: {
name: 'list_bewerbungen',
description: 'Listet Bewerbungen auf, standardmäßig die jüngsten. Optional nach Status gefiltert. Für einen Überblick über alle laufenden/abgeschlossenen Bewerbungen.',
parameters: {
type: 'object',
properties: {
status: { type: 'string', description: 'Optional: nur Bewerbungen mit diesem Status (z. B. offen, absage, eingeladen)' },
limit: { type: 'integer', description: 'Max. Anzahl Treffer (Standard 20, max 40)' },
},
},
},
},
{
type: 'function',
function: {
name: 'bewerbung_detail',
description: 'Liefert volle Details zu einer Bewerbung: Stellenbeschreibung, Notizen, interne Notizen, Kontakt, Quell-URL und die letzten Korrespondenz-Betreffe. Setze die id aus suche_bewerbungen/list_bewerbungen voraus.',
parameters: {
type: 'object',
properties: { id: { type: 'integer', description: 'Bewerbungs-ID' } },
required: ['id'],
},
},
},
{
type: 'function',
function: {
name: 'kommende_termine',
description: 'Liefert die nächsten Termine (Gespräche, Fristen) mit Titel, Startzeit (UTC-ISO), verknüpfter Bewerbung.',
parameters: { type: 'object', properties: {} },
},
},
];
// Tool labels shown in the UI while a tool call is in flight.
const CHAT_TOOL_LABELS = {
suche_bewerbungen: 'Bewerbungen werden durchsucht…',
list_bewerbungen: 'Bewerbungen werden geladen…',
bewerbung_detail: 'Bewerbungsdetails werden geladen…',
kommende_termine: 'Termine werden geladen…',
};
// Execute one tool call against the database. Returns a JSON-serialisable
// value that is fed back to the model as the tool result.
async function executeChatTool(name, args) {
const a = args || {};
if (name === 'suche_bewerbungen') {
const q = String(a.query || '').trim();
if (q.length < 2) return { treffer: [], hinweis: 'Suchbegriff zu kurz' };
// Dieselbe Volltextsuche wie auf der Startseite (lib/suche.js) — der Assistent
// findet eine Bewerbung dadurch auch über einen Namen aus der Korrespondenz.
const treffer = await suche.suche(dbAll, uid(), q, 20);
return {
treffer: treffer.map((t) => ({
id: t.bewerbung_id,
...(t.bewerbung || {}),
// Wo der Begriff steht, ohne Markierungszeichen — die versteht das Modell nicht.
fundstellen: t.fundstellen.map((f) => ({
wo: f.quelle === 'email'
? `E-Mail${f.email && f.email.betreff ? ` „${f.email.betreff}"` : ''}${f.email && f.email.von ? ` von ${f.email.von}` : ''}`
: f.feld_label,
auszug: suche.ohneMarker(f.snippet),
})),
})),
};
}
if (name === 'list_bewerbungen') {
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 40);
const status = String(a.status || '').trim();
const sql = `SELECT id, firma, stelle, status, datum, ort FROM bewerbungen
WHERE user_id = ?${status ? ' AND status = ?' : ''} ORDER BY datum DESC, created_at DESC LIMIT ?`;
const rows = await dbAll(sql, status ? [uid(), status, limit] : [uid(), limit]);
return { bewerbungen: rows };
}
if (name === 'bewerbung_detail') {
const id = Number(a.id);
if (!id) return { error: 'keine id' };
const row = await dbGet(
`SELECT b.id, b.firma, b.stelle, b.status, b.datum, b.ort, b.notizen,
b.interne_notizen, b.stellenbeschreibung, b.quelle_url,
j.kontakt_email AS ja_kontakt, j.ansprechpartner AS ja_ansprech, j.beschreibung AS ja_beschreibung
FROM bewerbungen b
LEFT JOIN jobangebote j ON j.verknuepfte_bewerbung_id = b.id AND j.user_id = b.user_id
WHERE b.id = ? AND b.user_id = ?`,
[id, uid()]
);
if (!row) return { error: 'nicht gefunden' };
const em = await dbAll(
`SELECT direction, subject, from_addr FROM emails WHERE bewerbung_id = ? AND user_id = ?
ORDER BY email_date DESC, created_at DESC LIMIT 8`,
[id, uid()]
);
return {
id: row.id, firma: row.firma, stelle: row.stelle, status: row.status, datum: row.datum, ort: row.ort,
quelle_url: row.quelle_url,
kontakt_email: row.ja_kontakt || null,
ansprechpartner: row.ja_ansprech || null,
notizen: (row.notizen || '').trim(),
interne_notizen: (row.interne_notizen || '').trim(),
stellenbeschreibung: (row.stellenbeschreibung || row.ja_beschreibung || '').trim().slice(0, 1200),
korrespondenz: em.map((e) => ({
direction: e.direction, subject: e.subject, von: e.from_addr,
})),
};
}
if (name === 'kommende_termine') {
const rows = await upcomingTermine(10);
return {
termine: rows.map((t) => ({
titel: t.titel, start: t.start,
bewerbung_id: t.bewerbung_id,
bewerbung: t.bewerbung_firma || null,
})),
};
}
return { error: 'unbekanntes Werkzeug: ' + name };
}
// Chat page: list threads + render the active thread (or a fresh empty one).
app.get('/chat', async (req, res) => {
if (!chat.isConfigured()) return res.status(503).send('KI-Chat deaktiviert kein Ollama-API-Schlüssel konfiguriert (unter „Einstellungen“ eintragen).');
try {
const threads = await dbAll(
'SELECT id, titel, updated_at FROM chat_threads WHERE user_id = ? ORDER BY updated_at DESC',
[uid()]
);
const activeId = req.query.thread ? Number(req.query.thread) : (threads[0] && threads[0].id);
let messages = [];
if (activeId) {
messages = await dbAll(
`SELECT m.id, m.role, m.content, m.created_at FROM chat_messages m
JOIN chat_threads t ON t.id = m.thread_id
WHERE m.thread_id = ? AND t.user_id = ? ORDER BY m.id ASC`,
[activeId, uid()]
);
}
res.render('chat', {
threads, activeId, messages,
hasApiKey: true, hideSettings: false,
});
} catch (error) {
console.error('Chat page error:', error);
res.status(500).send('Serverfehler');
}
});
// Create a new thread. Optional `titel` in the body.
app.post('/chat/api/threads', async (req, res) => {
try {
const titel = sanitizeInput((req.body.titel || '').trim()).slice(0, 120) || null;
const { lastID } = await dbRun('INSERT INTO chat_threads (user_id, titel) VALUES (?, ?)', [uid(), titel]);
res.json({ id: lastID, titel });
} catch (error) {
console.error('Create thread error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Delete a thread (cascades to its messages).
app.delete('/chat/api/threads/:id', async (req, res) => {
try {
await dbRun('DELETE FROM chat_threads WHERE id = ? AND user_id = ?', [Number(req.params.id), uid()]);
res.json({ ok: true });
} catch (error) {
console.error('Delete thread error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Rename a thread (e.g. auto-title from first message).
app.patch('/chat/api/threads/:id', async (req, res) => {
try {
const titel = sanitizeInput((req.body.titel || '').trim()).slice(0, 120);
await dbRun('UPDATE chat_threads SET titel = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
[titel, Number(req.params.id), uid()]);
res.json({ ok: true });
} catch (error) {
console.error('Rename thread error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Send a user message and stream the assistant reply via SSE.
app.post('/chat/api/threads/:id/messages', async (req, res) => {
if (!chat.isConfigured()) return res.status(503).json({ error: 'KI-Chat deaktiviert.' });
const threadId = Number(req.params.id);
const userText = sanitizeInput((req.body.content || '').trim());
if (!userText) return res.status(400).json({ error: 'Leere Nachricht.' });
let thread;
try {
thread = await dbGet('SELECT id, titel FROM chat_threads WHERE id = ? AND user_id = ?', [threadId, uid()]);
} catch (e) { /* fall through */ }
if (!thread) return res.status(404).json({ error: 'Thread nicht gefunden.' });
// Persist the user message, then load the full prior history for context.
try {
await dbRun('INSERT INTO chat_messages (user_id, thread_id, role, content) VALUES (?, ?, ?, ?)',
[uid(), threadId, 'user', userText]);
await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [threadId, uid()]);
// Auto-title the thread from the first user message, if untitled.
if (!thread.titel) {
const first = await dbGet('SELECT content FROM chat_messages WHERE thread_id = ? AND user_id = ? ORDER BY id ASC LIMIT 1', [threadId, uid()]);
if (first) {
const t = first.content.slice(0, 60).replace(/\s+/g, ' ').trim();
if (t) await dbRun('UPDATE chat_threads SET titel = ? WHERE id = ? AND user_id = ? AND (titel IS NULL OR titel = "")', [t, threadId, uid()]);
}
}
} catch (error) {
console.error('Persist user message error:', error);
return res.status(500).json({ error: 'Serverfehler' });
}
let history;
try {
history = await dbAll(
'SELECT role, content FROM chat_messages WHERE thread_id = ? AND user_id = ? ORDER BY id ASC',
[threadId, uid()]
);
} catch (error) {
return res.status(500).json({ error: 'Serverfehler' });
}
// SSE setup. Keep the connection alive; flush headers immediately.
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders && res.flushHeaders();
const send = (obj) => {
res.write(`data: ${JSON.stringify(obj)}\n\n`);
};
// AbortController so a closed client stops the upstream Ollama stream.
const controller = new AbortController();
let aborted = false;
req.on('close', () => { aborted = true; controller.abort(); });
// Trim very old history to bound token cost (keep the last 20 turns).
const trimmed = history.slice(-40);
const messages = trimmed.map((m) => ({ role: m.role, content: m.content }));
let context;
try { context = await gatherChatContext(); }
catch (e) { context = {}; }
const system = chat.buildContextPrompt(context);
let assistantText = '';
try {
assistantText = await chat.runChat({
system,
messages,
tools: CHAT_TOOLS,
signal: controller.signal,
onToken: (delta) => send({ type: 'token', content: delta }),
onToolCall: (name) => send({ type: 'tool', name, label: CHAT_TOOL_LABELS[name] || name }),
executeTool: executeChatTool,
});
} catch (err) {
if (aborted) { res.end(); return; }
send({ type: 'error', message: err.message || 'KI-Fehler' });
res.end();
return;
}
// Persist the (possibly empty) assistant reply.
const saved = assistantText || '(keine Antwort)';
try {
const { lastID } = await dbRun(
'INSERT INTO chat_messages (user_id, thread_id, role, content) VALUES (?, ?, ?, ?)',
[uid(), threadId, 'assistant', saved]
);
await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [threadId, uid()]);
const threadRow = await dbGet('SELECT titel FROM chat_threads WHERE id = ? AND user_id = ?', [threadId, uid()]);
send({ type: 'done', messageId: lastID, content: saved, titel: threadRow && threadRow.titel });
} catch (error) {
send({ type: 'error', message: 'Antwort konnte nicht gespeichert werden.' });
}
res.end();
});
// ----- Third-party REST API (/api/v1) + OpenAPI/Swagger -----
// Per-user API key: each user may set their own API_TOKEN on /einstellungen.
// The X-API-Key header resolves to the owning user, and every request then
// operates only on that user's data (see lib/api.js).
const apiToken = () => config.get('API_TOKEN') || '';
app.use('/api/v1', createExternalApi({
dbGet,
dbAll,
dbRun,
sanitizeInput,
attachVerlauf,
findDuplicateApplications,
syncCurrentStatus,
runGeneration,
anhaengeDir,
emailAnhaengeDir,
userStorageDir,
apiToken,
}));
// Serve the OpenAPI document, with the real request host injected as server.
app.get('/swagger.json', (req, res) => {
const proto = req.get('x-forwarded-proto') || req.protocol;
const host = req.get('host') || `localhost:${PORT}`;
res.json(buildOpenApiSpec(`${proto}://${host}`));
});
// Swagger UI (loaded from CDN; consistent with the app's other CDN usage).
app.get('/swagger', (req, res) => {
const proto = req.get('x-forwarded-proto') || req.protocol;
const host = req.get('host') || `localhost:${PORT}`;
const specUrl = `${proto}://${host}/swagger.json`;
res.type('text/html').send(`<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NextJobs 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>NextJobs 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}`);
console.log('REST-API (/api/v1): pro Benutzer über X-API-Key (Token in den Einstellungen je Benutzer gesetzt) Swagger unter /swagger');
});
// Background loops are per-user: every user owns their own mail/calendar
// config, so polling iterates all users and runs each user's poll inside
// that user's context (config.get() then resolves to the user's own values).
async function forEachUser(fn) {
const users = await dbAll('SELECT id, username, is_admin FROM users ORDER BY id');
for (const u of users) {
try { await config.ensureLoaded(u.id); } catch (e) { continue; }
try { await userContext.run(u, fn); } catch (e) { /* errors logged inside fn */ }
}
}
async function pollInboxAllUsers() {
await forEachUser(async () => {
try { await pollInbox(); } catch (e) { /* logged inside pollInbox */ }
});
}
async function refreshCaldavAllUsers() {
await forEachUser(async () => {
try { await refreshCaldav(); } catch (e) { /* logged inside refreshCaldav */ }
});
}
// E-Mail: verify SMTP on startup (per-user, but verification only needs the
// first configured user to confirm reachability) and poll every user's IMAP
// inbox for replies on the configured interval.
const pollMs = Math.max(60000, Number(config.get('MAIL_POLL_MS')) || 180000);
setTimeout(() => { pollInboxAllUsers().catch(() => {}); }, 8000); // initial fetch after boot
setInterval(() => { pollInboxAllUsers().catch(() => {}); }, pollMs); // periodic fetch
// Calendar: reconcile every user's appointments with their CalDAV calendar.
const calPoll = Math.max(60000, Number(config.get('CALDAV_POLL_MS')) || 300000);
setTimeout(() => { refreshCaldavAllUsers().catch(() => {}); }, 10000); // initial sync after boot
setInterval(() => { refreshCaldavAllUsers().catch(() => {}); }, calPoll); // periodic reconcile
// Global error handler: never leak internals. API/AJAX callers get JSON, everyone
// else gets a plain 500. Logged server-side with the stack for debugging.
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
if (res.headersSent) return next(err);
console.error('Unhandled error:', err);
const wantsJson = (req.get('accept') || '').includes('application/json') || req.path.startsWith('/api/') || req.xhr;
if (wantsJson) return res.status(500).json({ error: 'Serverfehler' });
res.status(500).send('Serverfehler');
});
// 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();
});