Files
jobbi-bewerbung/server.js
T
thomasandClaude Opus 4.8 1aa902bdce Top-Navigation: neu strukturiert, mit Aktiv-Zustand und Dropdowns
Die Leiste war eine flache Reihe aus sieben gleichrangigen Pillen. Zwei
Probleme: sie zeigte nie an, wo man gerade ist (es gab schlicht keinen
Aktiv-Zustand), und auf dem Handy fielen nur die Beschriftungen weg - uebrig
blieben sieben unbeschriftete Icons.

Struktur folgt jetzt dem tatsaechlichen Ablauf (finden -> bewerben ->
antworten) statt der Reihenfolge, in der die Seiten entstanden sind:

  Bewerbungen | Jobangebote | Stellensuche v | Postfach | Vorlagen

"Stellensuche" buendelt als Dropdown, was Angebote erzeugt und filtert:
Suchprofil & Laeufe, Uebernommene Angebote, Blacklist - jeweils mit einer Zeile,
die erklaert, wozu der Punkt gut ist. Einstellungen, Benutzerverwaltung und
Abmelden wandern ins Konto-Menue rechts, wo Systemkram hingehoert. Die
taeglichen Wege (Bewerbungen, Jobangebote, Postfach) bleiben ein Klick weit weg.

Gestaltung: Die Leiste behaelt in beiden Themes ihr dunkles Navy - sie ist der
Rahmen des Werkzeugs und die einzige Flaeche, die sich beim Navigieren nie
aendert. Alles darin ist bewusst still (Slate auf Navy); der einzige Akzent ist
der aktive Punkt, markiert durch eine schmale leuchtende Schiene an der
Unterkante. Beim Hover deutet sich dieselbe Schiene gedaempft an.

Zaehler (ungelesene Post, offene Angebote) haengen jetzt an Klassen statt IDs,
weil sie in Leiste UND Mobilmenue erscheinen. Ein Controller bedient alle
Dropdowns: Klick oeffnet, andere schliessen, Escape und Klick daneben schliessen,
aria-expanded bleibt ehrlich. Fokus ist sichtbar, prefers-reduced-motion wird
respektiert, das Mobilmenue zeigt alle Punkte ausgeschrieben und gruppiert.

Der aktive Pfad kommt aus res.locals.pfad (Auth-Middleware). Die Navigation liegt
weiterhin in genau einem Partial - sie ist damit auf allen Seiten identisch;
nur die Login-Seite hat bewusst keine.

Geprueft mit echtem Browser (Screenshots): hell/dunkel, Dropdowns, Mobilmenue,
und der Aktiv-Zustand je Seite - Unterseiten wie /blacklist oder /jobsuche heben
korrekt "Stellensuche" hervor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:55:58 +02:00

3645 lines
152 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 multer = require('multer');
// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
// (git-ignored) .env file into process.env without overwriting existing vars.
(function loadEnv() {
try {
const envPath = path.join(__dirname, '.env');
if (!fs.existsSync(envPath)) return;
for (const raw of fs.readFileSync(envPath, 'utf8').split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let val = line.slice(eq + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (key && !(key in process.env)) process.env[key] = val;
}
} catch (e) {
console.warn('Konnte .env nicht laden:', e.message);
}
})();
const {
generateApplicationDocuments, generateEmailReply, renderDesignVorschau,
DOKUMENT_TYPEN, normalizeDokumente,
} = require('./lib/documents');
const chat = require('./lib/chat');
const promptStore = require('./lib/prompts');
const designStore = require('./lib/design');
const mailer = require('./lib/mailer');
const { createExternalApi } = require('./lib/api');
const { buildOpenApiSpec } = require('./lib/openapi');
const blacklist = require('./lib/blacklist');
const caldav = require('./lib/caldav');
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./lib/labels');
const config = require('./lib/config');
const { 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 app = express();
const PORT = process.env.PORT || 3000;
// Shared option lists (used in multiple views)
const ART_OPTIONS = [
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone',
'Firmenwebsite', 'Post', 'Initiativbewerbung',
'Arbeitsagentur', 'Sonstiges'
];
const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung'
];
// Base document types the user can provide as a foundation for AI tailoring
const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges'];
// Pick the application "source" (art) for a browser-captured job. Honour an
// explicit value from the extension, otherwise infer it from the URL host so a
// capture from any website is labelled sensibly.
function deriveArt(url, provided) {
if (provided && ART_OPTIONS.includes(provided)) return provided;
const host = (String(url || '').match(/^https?:\/\/([^/]+)/i) || [, ''])[1].toLowerCase();
if (!host) return 'Sonstiges';
if (host.includes('indeed')) return 'Indeed';
if (host.includes('stepstone')) return 'StepStone';
if (host.includes('arbeitsagentur')) return 'Arbeitsagentur';
if (/(linkedin|xing|monster|stellenanzeigen|kimeta|glassdoor|jobware|meinestadt|jobs\.|karriere\.)/.test(host)) return 'Online-Portal';
return 'Firmenwebsite';
}
// Middleware
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
app.use(express.static(path.join(__dirname, 'public')));
// Allow the browser extension (running on indeed.com) to call the import API.
// Kept narrow: only the extension-facing endpoints need cross-origin access.
app.use('/api/indeed-import', (req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
// Set EJS as template engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// ---------------------------------------------------------------------------
// 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 user = await loadSessionUser(cookies[SESSION_COOKIE]);
req.user = user;
res.locals.user = user;
// 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');
}
// 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 || {};
const user = await authenticate(username, plain);
if (!user) {
return res.status(401).render('login', { error: 'Benutzername oder Passwort falsch.', username: username || '' });
}
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 with `Content-Disposition: inline` so the browser opens it
// (PDF/image) in a tab instead of downloading. Falls back to the file extension
// when no MIME type is stored.
function serveInline(res, filePath, filename, mime) {
const type = mime || {
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
}[path.extname(filename || filePath).toLowerCase()];
if (type) res.type(type);
const safe = String(filename || 'datei').replace(/["\r\n]/g, '');
res.setHeader('Content-Disposition',
`inline; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
res.sendFile(filePath);
}
// --- E-mail display + reply helpers --------------------------------------
// A stored message is rendered as HTML when we have an HTML body (or the plain
// body is actually HTML markup — some senders put HTML in the text/plain part).
// Otherwise it is shown as plain text. HTML is displayed inside a sandboxed
// iframe (see the views), so scripts never run.
function looksLikeHtml(s) {
return /<(?:!doctype|html|body|div|table|p|br|span|a|img|ul|ol|h[1-6])\b|<\/[a-z]/i.test(String(s || ''));
}
function emailDisplayHtml(e) {
if (e.body_html && e.body_html.trim()) return e.body_html;
if (e.body_text && looksLikeHtml(e.body_text)) return e.body_text;
return null;
}
// Wrap raw e-mail HTML in a minimal document for the sandboxed iframe: a white
// background, readable defaults, images constrained to the width and links that
// open in a new tab. No scripts are enabled by the iframe sandbox.
function buildEmailSrcdoc(html) {
return '<!doctype html><html><head><meta charset="utf-8">' +
'<meta name="referrer" content="no-referrer"><base target="_blank">' +
'<style>html,body{margin:0;padding:10px;background:#fff;color:#111;' +
'font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;' +
'word-break:break-word;overflow-wrap:anywhere}' +
'img{max-width:100%!important;height:auto}table{max-width:100%!important}' +
'a{color:#2563eb}*{max-width:100%}</style></head><body>' +
String(html || '') + '</body></html>';
}
// Plain-text version of a message body, used as the source for reply quoting.
function emailPlainText(e) {
const html = emailDisplayHtml(e);
if (html && (!e.body_text || looksLikeHtml(e.body_text))) return mailer.htmlToText(html);
return String(e.body_text || '');
}
// Build a mail-client style quote of a received message: an attribution line
// followed by the original body with every line prefixed by "> ".
function buildReplyQuote(e) {
const d = e.email_date ? new Date(e.email_date) : null;
const when = d && !isNaN(d.getTime()) ? d.toLocaleString('de-DE') : '';
const who = String(e.from_addr || '').trim();
const src = emailPlainText(e).replace(/\r\n/g, '\n').replace(/\s+$/, '');
const quoted = src.split('\n').map((l) => '> ' + l).join('\n');
const attribution = who ? `Am ${when} schrieb ${who}:` : (when ? `Am ${when}:` : '');
return (attribution ? attribution + '\n\n' : '') + quoted;
}
// Attach display fields (and, for received mail, a reply quote) to each row.
function decorateEmails(emails) {
emails.forEach((e) => {
const html = emailDisplayHtml(e);
e.display_srcdoc = html ? buildEmailSrcdoc(html) : null;
if (e.direction !== 'out') e.reply_quote = buildReplyQuote(e);
});
}
// Directory for static extra attachments (e.g. Zeugnisse) the user uploads once
// and that are sent along with every generated application.
const basisAnhaengeDir = path.join(dataDir, 'basis_anhaenge');
if (!fs.existsSync(basisAnhaengeDir)) {
fs.mkdirSync(basisAnhaengeDir, { recursive: true });
}
// Multipart upload for those static attachments (stored under the user's subdir)
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
}).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
}).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)),
filename: (req, file, cb) => {
const ext = (path.extname(file.originalname) || '.png').toLowerCase();
cb(null, `signatur_${Date.now()}${ext}`);
},
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)),
}).single('signatur');
// The single stored signature file, if any (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 = (path.extname(file.originalname) || '.png').toLowerCase();
cb(null, `foto_${Date.now()}${ext}`);
},
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)),
}).single('foto');
// The single stored photo file, if any (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;');
}
// 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.
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
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ?`,
[token]
);
if (!row) return null;
await dbRun('UPDATE sessions SET last_seen = CURRENT_TIMESTAMP WHERE token = ?', [token]).catch(() => {});
return { id: row.id, username: row.username, is_admin: !!row.is_admin };
}
// Find a user by username + password (login check). Returns the user object or null.
async function authenticate(username, plain) {
const row = await dbGet('SELECT id, username, password_hash, is_admin FROM users WHERE username = ?', [username || '']);
if (!row) return null;
if (!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.
function setSessionCookie(res, token) {
res.cookie(SESSION_COOKIE, token, {
httpOnly: true, sameSite: 'lax', path: '/', maxAge: SESSION_MAX_AGE * 1000,
});
}
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) => {
const stored = `${bewerbungId}_${Date.now()}_${seq++}_${filename}`;
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, filename, mime, stored]
);
};
// Generated (AI) documents
for (const doc of documents) {
await storeAnhang(doc.name, doc.filename, doc.mime, doc.buffer);
}
// Selected extra attachments (e.g. Zeugnisse) — copied as-is
for (const ba of selectedAnhaenge) {
const src = path.join(userStorageDir(basisAnhaengeDir), ba.pfad);
if (!fs.existsSync(src)) continue;
await storeAnhang(ba.name || ba.dateiname, ba.dateiname, ba.mime || 'application/octet-stream', fs.readFileSync(src));
}
await dbRun(
"UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, " +
"email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? 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,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)');
// 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
)
`);
}
// 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');
}
});
// 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.' });
}
});
// 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) => fs.promises.unlink(path.join(intDir, a.pfad)).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}) AND 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}) AND strftime("%m", eff_datum) = ? ORDER BY eff_datum DESC`;
params.push(month.padStart(2, '0'));
} else if (year) {
query = `SELECT * FROM (${base}) AND 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 = path.join(anhaengeUserDir, a.pfad);
if (!fs.existsSync(p)) continue;
attachments.push({ filename: a.dateiname, path: p, contentType: a.mime || undefined });
attNames.push(a.dateiname);
}
// Selected static attachments (Zeugnisse etc.), e.g. from a reply form.
// Default: none selected.
let basisAnlageIds = req.body.basis_anlage || [];
if (!Array.isArray(basisAnlageIds)) basisAnlageIds = [basisAnlageIds];
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 = path.join(basisUserDir, ba.pfad);
if (!fs.existsSync(p)) continue;
attachments.push({ filename: ba.dateiname, path: p, contentType: ba.mime || undefined });
attNames.push(ba.dateiname);
}
// Threading headers when this is a reply to a stored message.
let inReplyTo = null, references = null;
if (req.body.reply_to) {
const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ? 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(),
hinweise: String(req.body.hinweise || ''),
typ: String(req.body.typ || 'antwort'),
});
// Include the quoted original so the reply reads like a mail-client thread.
res.json({ ...draft, quote: buildReplyQuote(orig) });
} catch (error) {
console.error('Error drafting AI reply:', error);
res.status(500).json({ error: error.message || 'Serverfehler' });
}
});
// Manually trigger an IMAP fetch of new replies, then return to the referring page.
app.post('/email/fetch', async (req, res) => {
const back = req.body.back || req.get('referer') || '/';
try {
await pollInbox();
} catch (e) { /* errors are logged inside pollInbox */ }
res.redirect(back);
});
// Download an attachment that arrived with a received e-mail.
app.get('/email-anhaenge/:id/download', async (req, res) => {
try {
const a = await dbGet('SELECT * FROM email_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
if (!a) return res.status(404).send('Anhang nicht gefunden');
const p = path.join(userStorageDir(emailAnhaengeDir), a.pfad);
if (!fs.existsSync(p)) return res.status(404).send('Datei nicht gefunden');
// inline=1 opens viewable files (PDF/image) in the browser tab instead of forcing a download.
if (req.query.inline === '1') return serveInline(res, p, a.name || a.pfad, a.mime);
res.download(p, a.name || a.pfad);
} catch (error) {
console.error('Error downloading e-mail attachment:', error);
res.status(500).send('Serverfehler');
}
});
// ---------------------------------------------------------------------------
// Postfach: incoming e-mails that landed in the inbox but could not be
// matched to an application automatically. The user assigns them by hand.
// ---------------------------------------------------------------------------
// Page listing all e-mails with no bewerbung_id, plus every application as
// assignment target.
app.get('/postfach', async (req, res) => {
try {
const 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) {
fs.promises.unlink(path.join(attDir, a.pfad)).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 = path.join(userStorageDir(basisAnhaengeDir), a.pfad);
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
res.download(filePath, a.dateiname);
} catch (error) {
console.error('Error downloading attachment:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a static attachment
app.post('/anlagen/:id/delete', async (req, res) => {
try {
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
if (a) {
fs.promises.unlink(path.join(userStorageDir(basisAnhaengeDir), a.pfad)).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 = path.join(userStorageDir(anhaengeDir), anhang.pfad);
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
// inline=1 opens viewable files (PDF/image) in the browser tab instead of forcing a download.
if (req.query.inline === '1') return serveInline(res, filePath, anhang.dateiname, anhang.mime);
res.download(filePath, anhang.dateiname);
} catch (error) {
console.error('Error downloading attachment:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a generated attachment
app.post('/bewerbung/:id/anhaenge/:anhangId/delete', async (req, res) => {
try {
const { id, anhangId } = req.params;
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
if (anhang) {
const filePath = path.join(userStorageDir(anhaengeDir), anhang.pfad);
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 = path.join(userStorageDir(interneAnhaengeDir), anhang.pfad);
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
if (req.query.inline === '1') return serveInline(res, filePath, anhang.dateiname, anhang.mime);
res.download(filePath, anhang.dateiname);
} catch (error) {
console.error('Error downloading internal attachment:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a private attachment
app.post('/bewerbung/:id/interne-anhaenge/:anhangId/delete', async (req, res) => {
try {
const { id, anhangId } = req.params;
const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
if (anhang) {
fs.promises.unlink(path.join(userStorageDir(interneAnhaengeDir), anhang.pfad)).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) {
fs.promises.unlink(path.join(anhaengeUserDir, a.pfad)).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.');
}
}
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]
);
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),
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');
}
});
// "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) {
if (req.user && req.user.is_admin) 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');
}
});
// ----- 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: 'Durchsucht Bewerbungen nach Firmen- oder Stellenname (Teiltreffer). Nutze dies, wenn der Nutzer eine konkrete Firma/Stelle nennt oder fragt, was zu einer Firma bekannt ist. Liefert eine kompakte Trefferliste (id, firma, stelle, status, datum, ort) — hole Details mit bewerbung_detail.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Suchbegriff, z. B. Firmen- oder Stellenname (mind. 2 Zeichen)' },
},
required: ['query'],
},
},
},
{
type: 'function',
function: {
name: 'list_bewerbungen',
description: 'Listet Bewerbungen auf, standardmäßig die jüngsten. Optional nach Status gefiltert. Für einen Überblick über alle laufenden/abgeschlossenen Bewerbungen.',
parameters: {
type: 'object',
properties: {
status: { type: 'string', description: 'Optional: nur Bewerbungen mit diesem Status (z. B. offen, absage, eingeladen)' },
limit: { type: 'integer', description: 'Max. Anzahl Treffer (Standard 20, max 40)' },
},
},
},
},
{
type: 'function',
function: {
name: 'bewerbung_detail',
description: 'Liefert volle Details zu einer Bewerbung: Stellenbeschreibung, Notizen, interne Notizen, Kontakt, Quell-URL und die letzten Korrespondenz-Betreffe. Setze die id aus suche_bewerbungen/list_bewerbungen voraus.',
parameters: {
type: 'object',
properties: { id: { type: 'integer', description: 'Bewerbungs-ID' } },
required: ['id'],
},
},
},
{
type: 'function',
function: {
name: 'kommende_termine',
description: 'Liefert die nächsten Termine (Gespräche, Fristen) mit Titel, Startzeit (UTC-ISO), verknüpfter Bewerbung.',
parameters: { type: 'object', properties: {} },
},
},
];
// Tool labels shown in the UI while a tool call is in flight.
const CHAT_TOOL_LABELS = {
suche_bewerbungen: 'Bewerbungen werden durchsucht…',
list_bewerbungen: 'Bewerbungen werden geladen…',
bewerbung_detail: 'Bewerbungsdetails werden geladen…',
kommende_termine: 'Termine werden geladen…',
};
// Execute one tool call against the database. Returns a JSON-serialisable
// value that is fed back to the model as the tool result.
async function executeChatTool(name, args) {
const a = args || {};
if (name === 'suche_bewerbungen') {
const q = String(a.query || '').trim();
if (q.length < 2) return { treffer: [], hinweis: 'Suchbegriff zu kurz' };
const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`;
const rows = await dbAll(
`SELECT id, firma, stelle, status, datum, ort FROM bewerbungen
WHERE user_id = ? AND (firma LIKE ? ESCAPE '\\' OR stelle LIKE ? ESCAPE '\\')
ORDER BY datum DESC, created_at DESC LIMIT 20`,
[uid(), like, like]
);
return { treffer: rows };
}
if (name === 'list_bewerbungen') {
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 40);
const status = String(a.status || '').trim();
const sql = `SELECT id, firma, stelle, status, datum, ort FROM bewerbungen
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>Bewerbungs-Tracker API-Dokumentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *::before, *::after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
.topbar { display:flex; align-items:center; gap:12px; padding:10px 16px;
background:#1f2937; color:#fff; font-family:system-ui,sans-serif; }
.topbar a { color:#93c5fd; text-decoration:none; font-weight:600; }
</style>
</head>
<body>
<div class="topbar">
<strong>Bewerbungs-Tracker REST-API</strong>
<span style="opacity:.7">Drittanbieter-Schnittstelle v1</span>
<span style="margin-left:auto">Authentifizierung: Header <code>X-API-Key</code></span>
<a href="/">← zur App</a>
</div>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" charset="UTF-8"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: ${JSON.stringify(specUrl)},
dom_id: '#swagger-ui',
deepLinking: true,
persistAuthorization: true,
});
};
</script>
</body>
</html>`);
});
app.get('/api-docs', (req, res) => res.redirect(301, '/swagger'));
// Start server
app.listen(PORT, () => {
console.log(`Server läuft auf http://localhost:${PORT}`);
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
// 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();
});