const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const dns = require('dns');
const multer = require('multer');

// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
// (git-ignored) .env file into process.env without overwriting existing vars.
(function loadEnv() {
  try {
    const envPath = path.join(__dirname, '.env');
    if (!fs.existsSync(envPath)) return;
    for (const raw of fs.readFileSync(envPath, 'utf8').split('\n')) {
      const line = raw.trim();
      if (!line || line.startsWith('#')) continue;
      const eq = line.indexOf('=');
      if (eq === -1) continue;
      const key = line.slice(0, eq).trim();
      let val = line.slice(eq + 1).trim();
      if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
        val = val.slice(1, -1);
      }
      if (key && !(key in process.env)) process.env[key] = val;
    }
  } catch (e) {
    console.warn('Konnte .env nicht laden:', e.message);
  }
})();

const {
  generateApplicationDocuments, generateEmailReply, generateFeinschliff, renderDesignVorschau,
  DOKUMENT_TYPEN, normalizeDokumente, standardDokumente,
} = require('./lib/documents');
const chat = require('./lib/chat');
const websuche = require('./lib/websuche');
const promptStore = require('./lib/prompts');
const designStore = require('./lib/design');
const mailer = require('./lib/mailer');
const { createExternalApi } = require('./lib/api');
const { buildOpenApiSpec } = require('./lib/openapi');
const blacklist = require('./lib/blacklist');
const caldav = require('./lib/caldav');
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./lib/labels');
const config = require('./lib/config');
const { userContext, currentUser, currentUserId } = require('./lib/context');
const password = require('./lib/password');
const migrate = require('./lib/migrate-multiuser');
const { runMigration, importEnvIntoAdmin } = migrate;
const suchprofil = require('./lib/suchprofil');
const suche = require('./lib/suche');

const app = express();
const PORT = process.env.PORT || 3000;

// Shared option lists (used in multiple views)
const ART_OPTIONS = [
  'E-Mail', 'Online-Portal', 'Indeed', 'StepStone',
  'Firmenwebsite', 'Post', 'Initiativbewerbung',
  'Arbeitsagentur', 'Sonstiges'
];
const STATUS_OPTIONS = [
  'Entwurf', 'Gesendet', 'Eingangsbestätigung',
  'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
  'Vorstellungsgespräch', 'Vertragsverhandlung',
  'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung'
];
// Base document types the user can provide as a foundation for AI tailoring
const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges'];

// Pick the application "source" (art) for a browser-captured job. Honour an
// explicit value from the extension, otherwise infer it from the URL host so a
// capture from any website is labelled sensibly.
function deriveArt(url, provided) {
  if (provided && ART_OPTIONS.includes(provided)) return provided;
  const host = (String(url || '').match(/^https?:\/\/([^/]+)/i) || [, ''])[1].toLowerCase();
  if (!host) return 'Sonstiges';
  if (host.includes('indeed')) return 'Indeed';
  if (host.includes('stepstone')) return 'StepStone';
  if (host.includes('arbeitsagentur')) return 'Arbeitsagentur';
  if (/(linkedin|xing|monster|stellenanzeigen|kimeta|glassdoor|jobware|meinestadt|jobs\.|karriere\.)/.test(host)) return 'Online-Portal';
  return 'Firmenwebsite';
}

// Trust the Caddy reverse proxy (TLS terminator) so req.secure / req.ip reflect
// the real client connection — needed for the Secure cookie flag and rate limit.
app.set('trust proxy', 1);
// Don't advertise the framework version.
app.disable('x-powered-by');
// Express ships with helpful dev-mode error pages; in production it never leaks
// stack traces. We run behind Docker, so default to production unless overridden.
app.set('env', process.env.NODE_ENV || 'production');

// --- Security headers ----------------------------------------------------
// No helmet dependency: a small middleware sets the headers that matter. The
// CSP is intentionally permissive about *inline* scripts/styles (the views use a
// lot of inline <script>/<style>) but strict about everything else — it blocks
// external scripts, object/embed, framing of the app (clickjacking), base-uri
// hijacks and cross-origin forms/connects. Registered before express.static so
// static responses carry the headers too.
app.use((req, res, next) => {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'SAMEORIGIN');
  res.setHeader('Referrer-Policy', 'no-referrer');
  res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
  res.setHeader('Content-Security-Policy', [
    "default-src 'self'",
    "script-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'",
    "style-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'",
    "img-src 'self' data: blob: https:",
    "font-src 'self' data:",
    "connect-src 'self'",
    "frame-ancestors 'self'",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
  ].join('; '));
  next();
});

// URL scheme allowlist for any value placed into an href/src in the views.
// Returns the URL unchanged for http(s)/mailto or a relative path, else '#' so a
// stored `javascript:` payload becomes an inert anchor (EJS escapes the result).
app.locals.safeUrl = function (url) {
  const u = String(url == null ? '' : url).trim();
  if (!u) return '';
  // Protocol-relative URLs (//evil.com) would navigate off-site on click.
  if (u.startsWith('//') || u.startsWith('\\/')) return '#';
  // Anything that looks like scheme:url must be an allowed scheme.
  if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(u) && !/^(https?|mailto):/i.test(u)) return '#';
  return u;
};

// Middleware
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
// Statische Dateien. JS/CSS bekommen "no-cache": der Browser darf sie behalten,
// muss aber jedes Mal per ETag nachfragen, ob sie noch aktuell sind (304, kein
// erneuter Download). Ohne diesen Header setzt der Browser die Lebensdauer selbst
// fest — nach einem Deploy lief die Oberfläche dann noch tagelang mit dem alten
// chat.js weiter, obwohl der Server längst die neue Datei auslieferte.
app.use(express.static(path.join(__dirname, 'public'), {
  setHeaders: (res, datei) => {
    if (/\.(js|css)$/i.test(datei)) res.setHeader('Cache-Control', 'no-cache');
  },
}));

// Allow the browser extension (running on indeed.com) to call the import API.
// Kept narrow: only the extension-facing endpoints need cross-origin access.
app.use('/api/indeed-import', (req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
  res.header('Access-Control-Allow-Headers', 'Content-Type');
  if (req.method === 'OPTIONS') return res.sendStatus(204);
  next();
});

// Set EJS as template engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

// ---------------------------------------------------------------------------
//  Auth: session cookie -> req.user + per-request user context
// ---------------------------------------------------------------------------
// Loads the session (if any) from the cookie and stores the resolved user on
// req.user / res.locals.user. The whole request then runs inside the
// AsyncLocalStorage user context, so the libs (config/mailer/caldav/chat) and
// the per-user query helpers below read the current user transparently.
app.use(async (req, res, next) => {
  try {
    const cookies = parseCookies(req.headers.cookie);
    const token = cookies[SESSION_COOKIE];
    const user = await loadSessionUser(token);
    req.user = user;
    req.sessionToken = token;
    res.locals.user = user;
    // impersonator is set when an admin is acting as another user; the header
    // shows a banner and the "switch back" action uses it.
    const impersonator = user && user.impersonator ? user.impersonator : null;
    req.impersonator = impersonator;
    res.locals.impersonator = impersonator;
    // The header highlights the section you are in; it needs the current path.
    res.locals.pfad = req.path;
    // Warm this user's cfg rows before anything reads them: config.get() is
    // synchronous and answers from the per-user cache, so a user whose rows were
    // never loaded would silently read as "unconfigured". Until now the process
    // .env fallback papered over that (it happened to hold the admin's values);
    // with the fallback gone, the config must actually be loaded per request.
    if (user) await config.ensureLoaded(user.id);
    userContext.run(user, next);
  } catch (e) {
    console.error('Session-Laden fehlgeschlagen:', e.message);
    req.user = null;
    res.locals.user = null;
    next();
  }
});

// Paths that do not require an authenticated session. The external /api/v1 has
// its own X-API-Key auth; everything else below requireAuth needs a session.
const PUBLIC_PATHS = new Set(['/login', '/logout']);
function isPublicPath(p) {
  if (PUBLIC_PATHS.has(p)) return true;
  if (p === '/health' || p === '/swagger' || p === '/swagger.json' || p === '/api-docs') return true;
  if (p.startsWith('/api/v1/')) return true; // own X-API-Key auth
  return false;
}

// Require an authenticated user. Browser requests are redirected to /login; API
// requests get 401 JSON. Must be registered before any protected route.
function requireAuth(req, res, next) {
  if (req.user) return next();
  if (isPublicPath(req.path)) return next();
  const wantsJson = (req.get('accept') || '').includes('application/json')
    || req.path.startsWith('/api/')
    || req.xhr;
  if (wantsJson) return res.status(401).json({ error: 'Nicht angemeldet.' });
  return res.redirect('/login');
}

// Simple in-memory login throttling keyed by client IP. After 8 failed attempts
// within 15 minutes the IP is locked out for 15 minutes, which blunts online
// password guessing. Cleared on the next successful login. Process restart wipes
// it (acceptable — it is a rate limiter, not a persistent record).
const loginFails = new Map();
const LOGIN_WINDOW = 15 * 60 * 1000;
const LOGIN_MAX = 8;
function clientIp(req) {
  return String((req.ip || (req.socket && req.socket.remoteAddress) || '?'));
}
function loginGate(req) {
  const rec = loginFails.get(clientIp(req));
  return !rec || !rec.lock || rec.lock <= Date.now();
}
function loginFail(req) {
  const ip = clientIp(req);
  const now = Date.now();
  let rec = loginFails.get(ip);
  if (!rec || now - rec.since > LOGIN_WINDOW) rec = { since: now, count: 0, lock: 0 };
  rec.count++;
  if (rec.count >= LOGIN_MAX) rec.lock = now + LOGIN_WINDOW;
  loginFails.set(ip, rec);
}
function loginOk(req) {
  loginFails.delete(clientIp(req));
}

// Login page + form handler.
app.get('/login', (req, res) => {
  if (req.user) return res.redirect('/');
  res.render('login', { error: null, username: '', dauerhaft: true });
});

app.post('/login', async (req, res) => {
  const { username, password: plain } = req.body || {};
  // Bei einem Fehlversuch die Wahl des Nutzers behalten, statt sie zurückzusetzen.
  const gewaehlt = (req.body || {}).dauerhaft === '1';
  if (!loginGate(req)) {
    return res.status(429).render('login', {
      error: 'Zu viele Versuche. Bitte später erneut versuchen.', username: username || '', dauerhaft: gewaehlt,
    });
  }
  const user = await authenticate(username, plain);
  if (!user) {
    loginFail(req);
    return res.status(401).render('login', {
      error: 'Benutzername oder Passwort falsch.', username: username || '', dauerhaft: gewaehlt,
    });
  }
  loginOk(req);
  // Checkbox ist standardmäßig gesetzt; abgewählt sendet der Browser das Feld gar nicht.
  const token = await createSession(user.id, gewaehlt);
  setSessionCookie(res, token, gewaehlt);
  res.redirect('/');
});

app.post('/logout', async (req, res) => {
  const cookies = parseCookies(req.headers.cookie);
  await destroySession(cookies[SESSION_COOKIE]);
  clearSessionCookie(res);
  res.redirect('/login');
});
// `GET /logout` is convenient for the header link (no JS needed).
app.get('/logout', (req, res) => { clearSessionCookie(res); res.redirect('/login'); });

// Protect everything registered below this point.
app.use(requireAuth);

// Ensure data directory exists
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) {
  fs.mkdirSync(dataDir, { recursive: true });
}

// Directory for generated attachment files (application documents)
const anhaengeDir = path.join(dataDir, 'anhaenge');
if (!fs.existsSync(anhaengeDir)) {
  fs.mkdirSync(anhaengeDir, { recursive: true });
}

// Directory for attachments received via IMAP (reply e-mails).
const emailAnhaengeDir = path.join(dataDir, 'email_anhaenge');
if (!fs.existsSync(emailAnhaengeDir)) {
  fs.mkdirSync(emailAnhaengeDir, { recursive: true });
}

// Resolve a per-user storage directory (data/<base>/<userId>/), creating it on
// first use. Used for every attachment / signature / photo path so users' files
// are isolated on disk the same way their DB rows are. Reads the current user
// from the request/task context (lib/context.js).
function userStorageDir(base) {
  const dir = path.join(base, String(currentUserId()));
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  return dir;
}

// Same as userStorageDir but resolves the user id from req.user explicitly (used
// inside multer callbacks, where we prefer the request's user over the async
// context to stay robust against callback timing).
function userStorageDirForReq(base, req) {
  const id = (req && req.user && req.user.id) || currentUserId();
  const dir = path.join(base, String(id));
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  return dir;
}

// Serve a stored file inline ONLY for a small allow-list of viewable types,
// decided by the file *extension* (never a client-/sender-supplied MIME). Every
// other type is forced to a download, so an uploaded .html / .svg / odd MIME can
// never render as an active document in the app origin (stored XSS).
const INLINE_TYPES = {
  '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
};
function serveInline(res, filePath, filename, mime) {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  const ext = path.extname(filename || filePath).toLowerCase();
  const type = INLINE_TYPES[ext] || INLINE_TYPES[path.extname(filePath).toLowerCase()];
  const safe = safeFilename(filename).replace(/["\r\n]/g, '');
  if (type) {
    res.type(type);
    res.setHeader('Content-Disposition',
      `inline; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
    return res.sendFile(filePath);
  }
  // Not a viewable type → download as an attachment, never inline.
  res.setHeader('Content-Disposition',
    `attachment; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
  res.download(filePath, safe);
}

// --- E-mail display + reply helpers --------------------------------------
// A stored message is rendered as HTML when we have an HTML body (or the plain
// body is actually HTML markup — some senders put HTML in the text/plain part).
// Otherwise it is shown as plain text. HTML is displayed inside a sandboxed
// iframe (see the views), so scripts never run.
function looksLikeHtml(s) {
  return /<(?:!doctype|html|body|div|table|p|br|span|a|img|ul|ol|h[1-6])\b|<\/[a-z]/i.test(String(s || ''));
}
function emailDisplayHtml(e) {
  if (e.body_html && e.body_html.trim()) return e.body_html;
  if (e.body_text && looksLikeHtml(e.body_text)) return e.body_text;
  return null;
}
// Wrap raw e-mail HTML in a minimal document for the sandboxed iframe: a white
// background, readable defaults, images constrained to the width and links that
// open in a new tab. No scripts are enabled by the iframe sandbox.
function buildEmailSrcdoc(html) {
  return '<!doctype html><html><head><meta charset="utf-8">' +
    '<meta name="referrer" content="no-referrer"><base target="_blank">' +
    '<style>html,body{margin:0;padding:10px;background:#fff;color:#111;' +
    'font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;' +
    'word-break:break-word;overflow-wrap:anywhere}' +
    'img{max-width:100%!important;height:auto}table{max-width:100%!important}' +
    'a{color:#2563eb}*{max-width:100%}</style></head><body>' +
    String(html || '') + '</body></html>';
}
// Plain-text version of a message body, used as the source for reply quoting.
function emailPlainText(e) {
  const html = emailDisplayHtml(e);
  if (html && (!e.body_text || looksLikeHtml(e.body_text))) return mailer.htmlToText(html);
  return String(e.body_text || '');
}
// Build a mail-client style quote of a received message: an attribution line
// followed by the original body with every line prefixed by "> ".
function buildReplyQuote(e) {
  const d = e.email_date ? new Date(e.email_date) : null;
  const when = d && !isNaN(d.getTime()) ? d.toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : '';
  const who = String(e.from_addr || '').trim();
  const src = emailPlainText(e).replace(/\r\n/g, '\n').replace(/\s+$/, '');
  const quoted = src.split('\n').map((l) => '> ' + l).join('\n');
  const attribution = who ? `Am ${when} schrieb ${who}:` : (when ? `Am ${when}:` : '');
  return (attribution ? attribution + '\n\n' : '') + quoted;
}
// Attach display fields (and, for received mail, a reply quote) to each row.
function decorateEmails(emails) {
  emails.forEach((e) => {
    const html = emailDisplayHtml(e);
    e.display_srcdoc = html ? buildEmailSrcdoc(html) : null;
    if (e.direction !== 'out') e.reply_quote = buildReplyQuote(e);
  });
}

// Directory for static extra attachments (e.g. Zeugnisse) the user uploads once
// and that are sent along with every generated application.
const basisAnhaengeDir = path.join(dataDir, 'basis_anhaenge');
if (!fs.existsSync(basisAnhaengeDir)) {
  fs.mkdirSync(basisAnhaengeDir, { recursive: true });
}

// Multipart upload for those static attachments (stored under the user's subdir)
// Reject uploads whose extension is not on the allow-list. The on-disk name is
// already sanitised by the filename callback, but this stops a renamed .html /
// .svg from being stored at all (defense-in-depth against stored XSS via inline
// serving) and keeps the stored MIME trustworthy.
const ATTACHMENT_FILTER = (req, file, cb) => {
  const ext = path.extname(file.originalname).toLowerCase();
  const ok = /^\.(pdf|png|jpe?g|gif|webp|docx?|rtf|odt|ods|txt|csv|xlsx?)$/.test(ext);
  cb(ok ? null : dateitypFehler(), ok);
};

// Upload size limits. The numbers live here (not inline in the multer configs)
// because the message shown to the user quotes them — a limit that says one
// thing and enforces another is worse than no message at all.
const ANHANG_MAX_MB = 30;
const BILD_MAX_MB = 5;

function dateitypFehler() {
  const err = new Error('Dateityp nicht erlaubt');
  err.code = 'DATEITYP';
  return err;
}

// Multer reports a rejected upload through its callback's `err`: a blown size
// limit, a blocked file type, a failed write. Those used to be logged to the
// server console and otherwise swallowed — the browser was redirected back to a
// page that simply did not list the file, which to the user looks exactly like
// "it uploaded but it isn't shown". Turn the error into a sentence they can act
// on. Returns null when the upload was fine.
function uploadFehlerText(err, maxMb) {
  if (!err) return null;
  if (err.code === 'LIMIT_FILE_SIZE') {
    return `Die Datei ist zu groß. Erlaubt sind maximal ${maxMb} MB — bitte komprimiere sie (z. B. den Scan als PDF verkleinern) und lade sie erneut hoch.`;
  }
  if (err.code === 'DATEITYP') {
    return 'Dieser Dateityp ist nicht erlaubt. Möglich sind PDF, Bilder (PNG, JPG, GIF, WebP), Word, ODT, RTF, Text, CSV und Excel.';
  }
  if (err.code === 'BILDTYP') {
    return 'Für dieses Bild sind nur PNG- oder JPG-Dateien möglich.';
  }
  return `Die Datei konnte nicht hochgeladen werden: ${err.message || 'unbekannter Fehler'}`;
}

// Bilder (Unterschrift, Bewerbungsfoto): nur PNG/JPG, und der Dateiname muss zur
// MIME-Art passen. Wie oben mit einem Fehler ablehnen statt still zu verwerfen.
const BILD_FILTER = (req, file, cb) => {
  const ok = /^image\/(png|jpe?g)$/.test(file.mimetype)
    && /^\.(png|jpe?g)$/.test(path.extname(file.originalname).toLowerCase());
  cb(ok ? null : bildtypFehler(), ok);
};

function bildtypFehler() {
  const err = new Error('Nur PNG oder JPG');
  err.code = 'BILDTYP';
  return err;
}

// Ein Ziel plus Fehlermeldung als Query-Parameter — dasselbe Muster wie auf der
// Jobsuche-Seite (?fehler=…), das die Views bereits als Banner rendern.
function mitFehler(pfad, text) {
  return `${pfad}${pfad.includes('?') ? '&' : '?'}fehler=${encodeURIComponent(text)}`;
}

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: ANHANG_MAX_MB * 1024 * 1024 },
  fileFilter: ATTACHMENT_FILTER,
}).single('datei');

// Directory for private attachments the user keeps alongside the internal
// notes of an application. These are never exported into the PDF and never
// sent with an application — they are for the user only.
const interneAnhaengeDir = path.join(dataDir, 'interne_anhaenge');
if (!fs.existsSync(interneAnhaengeDir)) {
  fs.mkdirSync(interneAnhaengeDir, { recursive: true });
}
const uploadInterneAnhang = multer({
  storage: multer.diskStorage({
    destination: (req, file, cb) => cb(null, userStorageDirForReq(interneAnhaengeDir, req)),
    filename: (req, file, cb) => {
      const safe = String(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
      cb(null, `${Date.now()}_${safe}`);
    },
  }),
  limits: { fileSize: ANHANG_MAX_MB * 1024 * 1024 },
  fileFilter: ATTACHMENT_FILTER,
}).single('datei');

// Directory + uploader for the applicant's signature image (used in the letter)
const signaturDir = path.join(dataDir, 'signatur');
if (!fs.existsSync(signaturDir)) {
  fs.mkdirSync(signaturDir, { recursive: true });
}
const uploadSignatur = multer({
  storage: multer.diskStorage({
    destination: (req, file, cb) => cb(null, userStorageDirForReq(signaturDir, req)),
    // Derive the extension from the validated MIME, not the user-supplied name,
    // so a "x.png"-named .html can never land on disk as signatur_*.html.
    filename: (req, file, cb) => {
      const ext = /jpe?g/i.test(file.mimetype) ? '.jpg' : '.png';
      cb(null, `signatur_${Date.now()}${ext}`);
    },
  }),
  limits: { fileSize: BILD_MAX_MB * 1024 * 1024 },
  fileFilter: BILD_FILTER,
}).single('signatur');

// The single stored signature file, if any (in the current user's subdir).
function currentSignaturFile() {
  try {
    const dir = userStorageDir(signaturDir);
    const files = fs.readdirSync(dir).filter((f) => !f.startsWith('.'));
    return files.length ? path.join(dir, files[0]) : null;
  } catch (e) {
    return null;
  }
}

// Read the signature as a data URL + jsPDF format, for embedding in the letter.
function loadSignatur() {
  const file = currentSignaturFile();
  if (!file) return null;
  try {
    const ext = path.extname(file).toLowerCase();
    const format = (ext === '.jpg' || ext === '.jpeg') ? 'JPEG' : 'PNG';
    const mime = format === 'JPEG' ? 'image/jpeg' : 'image/png';
    const b64 = fs.readFileSync(file).toString('base64');
    return { dataUrl: `data:${mime};base64,${b64}`, format };
  } catch (e) {
    return null;
  }
}

// Directory + uploader for the applicant's portrait photo (used in the CV)
const fotoDir = path.join(dataDir, 'bewerberfoto');
if (!fs.existsSync(fotoDir)) {
  fs.mkdirSync(fotoDir, { recursive: true });
}
const uploadFoto = multer({
  storage: multer.diskStorage({
    destination: (req, file, cb) => cb(null, userStorageDirForReq(fotoDir, req)),
    filename: (req, file, cb) => {
      const ext = /jpe?g/i.test(file.mimetype) ? '.jpg' : '.png';
      cb(null, `foto_${Date.now()}${ext}`);
    },
  }),
  limits: { fileSize: BILD_MAX_MB * 1024 * 1024 },
  fileFilter: BILD_FILTER,
}).single('foto');

// The single stored photo file, if any (in the current user's subdir).
function currentFotoFile() {
  try {
    const dir = userStorageDir(fotoDir);
    const files = fs.readdirSync(dir).filter((f) => !f.startsWith('.'));
    return files.length ? path.join(dir, files[0]) : null;
  } catch (e) {
    return null;
  }
}

// Read the photo as a data URL + jsPDF format, for embedding in the CV.
function loadFoto() {
  const file = currentFotoFile();
  if (!file) return null;
  try {
    const ext = path.extname(file).toLowerCase();
    const format = (ext === '.jpg' || ext === '.jpeg') ? 'JPEG' : 'PNG';
    const mime = format === 'JPEG' ? 'image/jpeg' : 'image/png';
    const b64 = fs.readFileSync(file).toString('base64');
    return { dataUrl: `data:${mime};base64,${b64}`, format };
  } catch (e) {
    return null;
  }
}

// Database setup
const dbPath = path.join(dataDir, 'bewerbungen.db');
const db = new sqlite3.Database(dbPath);

// Sanitize input to prevent XSS
function sanitizeInput(input) {
  if (typeof input !== 'string') return input;
  return input
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

// --- File / path safety helpers -----------------------------------------
// Strip a stored/display filename down to a single safe segment: no path
// separators, no traversal, only [A-Za-z0-9._-]. multer already sanitises the
// on-disk name, but display names (basis_anhaenge.dateiname) are stored raw and
// were later re-used to build a path — which let a crafted name escape its dir.
function safeFilename(name) {
  return String(name == null ? 'datei' : name)
    .replace(/[^a-zA-Z0-9._-]/g, '_')
    .replace(/^\.+/, '')      // no leading dots
    .slice(0, 200) || 'datei';
}

// Sanitise a *display* filename for the Content-Disposition header: keep
// unicode (umlauts etc.) but strip control chars / quotes / backslashes that
// could inject or break an HTTP header. Used for download names shown to the
// user — never for building an on-disk path (use safeFilename / containedPath).
function displayFilename(name) {
  return String(name == null ? 'datei' : name)
    .replace(/[\x00-\x1f"\r\n\\]/g, '_')
    .replace(/^\.+/, '')
    .slice(0, 200) || 'datei';
}

// Resolve a stored relative path inside a user's base directory and assert the
// result actually stays inside it (defense-in-depth against stored traversal).
// Returns the absolute path, or null if it would escape `base`.
function containedPath(base, rel) {
  const root = path.resolve(base);
  const abs = path.resolve(root, String(rel == null ? '' : rel));
  if (abs !== root && !abs.startsWith(root + path.sep)) return null;
  return abs;
}

// Only allow redirect targets that are same-origin relative paths, so a crafted
// `back=https://evil.com` cannot bounce the user to an attacker site.
function safeRedirect(target) {
  const t = String(target == null ? '' : target).trim();
  if (!t || t.startsWith('//') || t.startsWith('\\/')) return '/';
  if (!/^[/?#]/.test(t)) return '/';
  return t;
}

// --- SSRF guard for user-configured external hosts -----------------------
// Block addresses that would let a user point the server at cloud metadata /
// link-local services (the high-severity SSRF: stealing the instance's cloud
// credentials). Loopback and private ranges stay allowed on purpose: the app
// explicitly supports a local Ollama / CalDAV on the same host or LAN.
function isBlockedSsrfIp(ip) {
  if (!ip) return true;
  const m = ip.split('.');
  if (m.length === 4 && m.every((x) => /^\d+$/.test(x))) {
    const [a, b] = m.map(Number);
    if (a === 0) return true;                 // 0.0.0.0/8
    if (a === 169 && b === 254) return true;   // link-local / cloud metadata
  }
  if (ip === '::' || ip === '0:0:0:0:0:0:0:0') return true;
  if (/^fe80:/i.test(ip)) return true;         // IPv6 link-local
  return false;
}
function resolveHost(host) {
  return new Promise((resolve) => {
    dns.lookup(host, { all: true }, (err, addrs) => {
      if (err || !addrs || !addrs.length) return resolve([]);
      resolve(addrs.map((a) => a.address));
    });
  });
}
async function assertSafeUrl(raw, { requireTls = false } = {}) {
  let u;
  try { u = new URL(String(raw)); } catch { throw new Error('Ungültige URL'); }
  if (!/^https?:$/.test(u.protocol)) throw new Error('Nur http/https erlaubt');
  if (requireTls && u.protocol !== 'https:') throw new Error('HTTPS erforderlich');
  if (isBlockedSsrfIp(u.hostname)) throw new Error('Interne Adresse nicht erlaubt');
  for (const ip of await resolveHost(u.hostname)) {
    if (isBlockedSsrfIp(ip)) throw new Error('Interne Adresse nicht erlaubt');
  }
}
async function assertSafeHost(raw) {
  const h = String(raw || '').split(':')[0].trim();
  if (!h) throw new Error('Host fehlt');
  if (isBlockedSsrfIp(h)) throw new Error('Interne Adresse nicht erlaubt');
  for (const ip of await resolveHost(h)) {
    if (isBlockedSsrfIp(ip)) throw new Error('Interne Adresse nicht erlaubt');
  }
}

// Promise wrapper for db operations
function dbGet(sql, params = []) {
  return new Promise((resolve, reject) => {
    db.get(sql, params, (err, result) => {
      if (err) reject(err);
      else resolve(result);
    });
  });
}

function dbAll(sql, params = []) {
  return new Promise((resolve, reject) => {
    db.all(sql, params, (err, results) => {
      if (err) reject(err);
      else resolve(results);
    });
  });
}

function dbRun(sql, params = []) {
  return new Promise((resolve, reject) => {
    db.run(sql, params, function(err) {
      if (err) reject(err);
      else resolve({ lastID: this.lastID, changes: this.changes });
    });
  });
}

// ---------------------------------------------------------------------------
//  Multi-user: sessions, login, per-request user context
// ---------------------------------------------------------------------------

const SESSION_COOKIE = 'sid';
// Zwei Lebensdauern, je nachdem ob der Nutzer beim Login "Angemeldet bleiben"
// angehakt hat (Standard):
//   dauerhaft  -> Cookie mit Ablaufdatum, Session lebt 30 Tage ab letzter Aktivität
//   nicht      -> Session-Cookie (weg beim Schließen des Browsers), serverseitig
//                 zusätzlich nach 12 Stunden Inaktivität ungültig
// Die serverseitige Frist ist die verbindliche: ein Client kann sein Cookie
// manipulieren, die Zeile in `sessions` nicht.
const SESSION_MAX_AGE = 30 * 24 * 3600; // 30 Tage, in Sekunden
const SESSION_MAX_AGE_KURZ = 12 * 3600; // 12 Stunden, in Sekunden

// 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.
// `dauerhaft` merkt sich die Wahl aus der Login-Maske und entscheidet später über
// die Ablauffrist (siehe loadSessionUser).
async function createSession(userId, dauerhaft = true) {
  const token = crypto.randomBytes(32).toString('hex');
  await dbRun('INSERT INTO sessions (token, user_id, dauerhaft) VALUES (?, ?, ?)', [token, userId, dauerhaft ? 1 : 0]);
  return token;
}

async function destroySession(token) {
  if (token) await dbRun('DELETE FROM sessions WHERE token = ?', [token]).catch(() => {});
}

// Resolve the user a session token belongs to, or null. Touches last_seen and
// enforces the server-side absolute session expiry: a cookie is only valid for
// SESSION_MAX_AGE after the last activity, regardless of the client-side
// maxAge (which a client can tamper with). Expired rows are deleted.
//
// When the session is an impersonation (an admin "logging in as" a user), the
// returned user is the *target*; `impersonator` carries the original admin so
// the app can show a banner and offer a "switch back" action. While
// impersonating, the effective user has no admin rights (see requireAdmin) —
// the admin is debugging the user's account, not escalating.
async function loadSessionUser(token) {
  if (!token) return null;
  const row = await dbGet(
    `SELECT u.id AS id, u.username AS username, u.is_admin AS is_admin,
            s.created_at AS created_at, s.last_seen AS last_seen,
            s.dauerhaft AS dauerhaft,
            s.impersonator_id AS impersonator_id,
            i.username AS impersonator_username, i.is_admin AS impersonator_is_admin
       FROM sessions s
       JOIN users u ON u.id = s.user_id
       LEFT JOIN users i ON i.id = s.impersonator_id
      WHERE s.token = ?`,
    [token]
  );
  if (!row) return null;
  const stamp = row.last_seen || row.created_at;     // 'YYYY-MM-DD HH:MM:SS' (UTC)
  const last = new Date(stamp + 'Z');
  const frist = row.dauerhaft ? SESSION_MAX_AGE : SESSION_MAX_AGE_KURZ;
  if (isNaN(last.getTime()) || (Date.now() - last.getTime()) / 1000 > frist) {
    await dbRun('DELETE FROM sessions WHERE token = ?', [token]).catch(() => {});
    return null;
  }
  await dbRun('UPDATE sessions SET last_seen = CURRENT_TIMESTAMP WHERE token = ?', [token]).catch(() => {});
  const user = { id: row.id, username: row.username, is_admin: !!row.is_admin };
  if (row.impersonator_id) {
    user.impersonator = { id: row.impersonator_id, username: row.impersonator_username, is_admin: !!row.impersonator_is_admin };
  }
  return user;
}

// Find a user by username + password (login check). Returns the user object or null.
// When the username is unknown we still run a dummy scrypt verify against a fixed
// hash, so a missing user takes the same time as a wrong password (no user
// enumeration via timing).
async function authenticate(username, plain) {
  const row = await dbGet('SELECT id, username, password_hash, is_admin FROM users WHERE username = ?', [username || '']);
  if (!row) {
    await password.verify(plain || '', password.DUMMY_HASH);
    return null;
  }
  if (!(await password.verify(plain || '', row.password_hash))) return null;
  return { id: row.id, username: row.username, is_admin: !!row.is_admin };
}

// Set/clear the session cookie on a response. The Secure flag is set whenever
// the request arrived over TLS (Caddy terminates it; trust proxy lets us see
// that via req.secure), so the cookie is never leaked over plain HTTP.
// Ohne "Angemeldet bleiben" bekommt das Cookie kein maxAge: der Browser wirft es
// beim Schließen weg. Das ist der Sinn der Abwahl — auf einem fremden Rechner soll
// nichts zurückbleiben.
function setSessionCookie(res, token, dauerhaft = true) {
  const secure = !!(res.req && res.req.secure);
  const opts = { httpOnly: true, sameSite: 'lax', path: '/', secure };
  if (dauerhaft) opts.maxAge = SESSION_MAX_AGE * 1000;
  res.cookie(SESSION_COOKIE, token, opts);
}
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();
  // limit = null/0 ⇒ kein Limit, alle anstehenden Termine (z. B. Startseite).
  if (!limit) {
    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`,
      [currentUserId(), now]
    );
  }
  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, berufsbezeichnungen, 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,
       berufsbezeichnungen = excluded.berufsbezeichnungen,
       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.berufsbezeichnungen, r.zusatz_begriffe, r.ausschluesse, r.zeitplan_tage, r.zeitplan_zeit]
  );
}

// Queue a search run for a user. The host-side runner executes it (this container
// has no `claude`/`ollama`). Refuses to pile up work: if a run is already waiting
// or in flight for that user, the existing one is returned instead of a second.
async function queueSuchlauf(userId, ausloeser) {
  const offen = await dbGet(
    "SELECT * FROM suchlaeufe WHERE user_id = ? AND status IN ('angefordert', 'laeuft') ORDER BY id DESC LIMIT 1",
    [userId]
  );
  if (offen) return { lauf: offen, neu: false };
  const res = await dbRun(
    "INSERT INTO suchlaeufe (user_id, status, ausloeser) VALUES (?, 'angefordert', ?)",
    [userId, ausloeser === 'zeitplan' ? 'zeitplan' : 'manuell']
  );
  const lauf = await dbGet('SELECT * FROM suchlaeufe WHERE id = ? AND user_id = ?', [res.lastID, userId]);
  return { lauf, neu: true };
}

// Match an incoming message to an application: first via In-Reply-To/References
// pointing at one of our sent messages, then by sender = a previous recipient.
async function matchBewerbung(msg) {
  const refs = []
    .concat((msg.inReplyTo || '').split(/\s+/))
    .concat((msg.references || '').split(/\s+/))
    .map((r) => r.replace(/[<>]/g, '').trim())
    .filter(Boolean);
  for (const mid of refs) {
    const row = await dbGet(
      "SELECT bewerbung_id FROM emails WHERE user_id = ? AND direction = 'out' AND message_id = ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1",
      [currentUserId(), mid]
    );
    if (row && row.bewerbung_id) return row.bewerbung_id;
  }
  if (msg.fromAddr) {
    const row = await dbGet(
      "SELECT bewerbung_id FROM emails WHERE user_id = ? AND direction = 'out' AND lower(to_addr) LIKE ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1",
      [currentUserId(), '%' + msg.fromAddr.toLowerCase() + '%']
    );
    if (row && row.bewerbung_id) return row.bewerbung_id;
  }
  return null;
}

let polling = false;
// Fetch new mail from the IMAP inbox, persist unseen messages, link them to the
// matching application and save their attachments. Safe to call concurrently
// (guarded) — used both by the interval poller and the manual "fetch" button.
// Runs in the current user's context (background loop sets it per user).
async function pollInbox() {
  if (!mailer.isConfigured() || polling) return { fetched: 0 };
  polling = true;
  try {
    const lastUid = Number(await getState('mail_last_uid')) || 0;
    const { messages, maxUid } = await mailer.fetchSince(lastUid);
    let stored = 0;
    for (const m of messages) {
      // Skip if we already have this message (id or uid) — idempotent.
      if (m.messageId) {
        const dup = await dbGet('SELECT id FROM emails WHERE user_id = ? AND message_id = ?', [currentUserId(), m.messageId]);
        if (dup) continue;
      }
      const bewId = await matchBewerbung(m);
      const result = await dbRun(
        `INSERT INTO emails (user_id, bewerbung_id, direction, message_id, in_reply_to, email_references,
           from_addr, to_addr, subject, body_text, body_html, imap_uid, seen, email_date)
         VALUES (?, ?, 'in', ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`,
        [currentUserId(), bewId, m.messageId || null, m.inReplyTo || null, m.references || null,
         m.fromName ? `${m.fromName} <${m.fromAddr}>` : m.fromAddr, m.toAddr || '',
         m.subject || '', m.text || '', m.html || '', m.uid,
         (m.date instanceof Date ? m.date.toISOString() : new Date().toISOString())]
      );
      // Persist attachments to disk + link rows.
      for (const att of (m.attachments || [])) {
        const safe = String(att.filename || 'anhang').replace(/[^a-zA-Z0-9äöüÄÖÜß._ -]/g, '_').slice(0, 80);
        const storedName = `${result.lastID}_${Date.now()}_${safe}`;
        try {
          fs.writeFileSync(path.join(userStorageDir(emailAnhaengeDir), storedName), att.content);
          await dbRun('INSERT INTO email_anhaenge (user_id, email_id, name, mime, pfad) VALUES (?, ?, ?, ?, ?)',
            [currentUserId(), result.lastID, att.filename, att.contentType, storedName]);
        } catch (e) { /* ignore a single bad attachment */ }
      }
      stored++;
    }
    if (maxUid > lastUid) await setState('mail_last_uid', maxUid);
    return { fetched: stored };
  } catch (err) {
    console.error('IMAP-Abruf fehlgeschlagen:', err.message);
    return { fetched: 0, error: err.message };
  } finally {
    polling = false;
  }
}

// ---------------------------------------------------------------------------
//  Duplicate-application guard: spot an existing application for the same job so
//  the user doesn't accidentally apply twice. Matches on a normalised source URL
//  (strongest signal for imported postings) or an identical company + role. It
//  only warns — legitimate re-applications stay possible via a "force" flag.
// ---------------------------------------------------------------------------
function normText(s) {
  return String(s == null ? '' : s)
    .toLowerCase()
    .normalize('NFKD').replace(/[̀-ͯ]/g, '')   // strip diacritics (ä→a …)
    .replace(/[^a-z0-9]+/g, ' ')
    .trim();
}

function normUrl(u) {
  const raw = String(u == null ? '' : u).trim();
  if (!raw) return '';
  try {
    const url = new URL(raw);
    const host = url.hostname.replace(/^www\./, '').toLowerCase();
    // A job-identifying query param (Indeed jk/vjk, generic ids) pins the posting
    // regardless of tracking params or which path it was opened from.
    const idKeys = ['jk', 'vjk', 'jobkey', 'jobid', 'vacancyid', 'stellenangebotid', 'positionid', 'offerid', 'id'];
    let idPart = '';
    for (const [k, v] of url.searchParams.entries()) {
      if (v && idKeys.includes(k.toLowerCase())) { idPart = k.toLowerCase() + '=' + v.toLowerCase(); break; }
    }
    const pathn = url.pathname.replace(/\/+$/, '').toLowerCase();
    return idPart ? host + '|' + idPart : host + pathn;
  } catch (e) {
    return raw.toLowerCase().replace(/[?#].*$/, '');
  }
}

// Existing applications that look like the same job as {firma, stelle, quelle_url}.
// `excludeId` skips a specific row (e.g. when re-checking during an edit).
async function findDuplicateApplications({ firma, stelle, quelle_url, excludeId }) {
  const rows = await dbAll('SELECT id, datum, firma, stelle, ort, quelle_url, status FROM bewerbungen WHERE user_id = ?', [currentUserId()]);
  const fUrl = normUrl(quelle_url);
  const fFirma = normText(firma);
  const fStelle = normText(stelle);
  const matches = [];
  for (const r of rows) {
    if (excludeId && Number(r.id) === Number(excludeId)) continue;
    let reason = null;
    if (fUrl && normUrl(r.quelle_url) === fUrl) reason = 'url';
    else if (fFirma && fStelle && normText(r.firma) === fFirma && normText(r.stelle) === fStelle) reason = 'firma_stelle';
    if (reason) matches.push({ id: r.id, datum: r.datum, firma: r.firma, stelle: r.stelle, ort: r.ort, status: r.status, reason });
  }
  return matches;
}

// Recompute an application's current status from its latest timeline entry
async function syncCurrentStatus(bewerbungId) {
  const latest = await dbGet(
    'SELECT status FROM status_verlauf WHERE user_id = ? AND bewerbung_id = ? ORDER BY date(datum) DESC, id DESC LIMIT 1',
    [currentUserId(), bewerbungId]
  );
  await dbRun(
    'UPDATE bewerbungen SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
    [latest ? latest.status : '', bewerbungId, currentUserId()]
  );
}

// Attach the status timeline to each application (single query, grouped in JS)
async function attachVerlauf(applications) {
  if (!applications.length) return applications;
  const all = await dbAll('SELECT * FROM status_verlauf WHERE user_id = ? ORDER BY date(datum) ASC, id ASC', [currentUserId()]);
  const byApp = {};
  all.forEach((v) => { (byApp[v.bewerbung_id] = byApp[v.bewerbung_id] || []).push(v); });
  applications.forEach((a) => { a.verlauf = byApp[a.id] || []; });
  return applications;
}

// Run the AI document generation for one application (async, fire-and-forget).
// Loads the base documents + user settings, asks the LLM to tailor them to the
// job, writes the resulting PDFs to disk and links them as attachments.
async function runGeneration(bewerbungId, options = {}) {
  try {
    const U = currentUserId();
    const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [bewerbungId, U]);
    if (!bewerbung) return;
    const basisDokumente = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [U]);
    const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [U]);
    const settings = await loadSettings();
    const prompts = await loadPrompts();
    const design = await loadDesign();

    // Only the explicitly selected extra attachments are enclosed (default: none).
    const anlagenIds = Array.isArray(options.anlagenIds) ? options.anlagenIds.map(Number) : [];
    const selectedAnhaenge = basisAnhaenge.filter((a) => anlagenIds.includes(a.id));

    // Which documents to produce; unset means both (the default everywhere).
    const dokumente = normalizeDokumente(options.dokumente);

    const { documents, email } = await generateApplicationDocuments({
      job: {
        firma: bewerbung.firma,
        stelle: bewerbung.stelle,
        ort: bewerbung.ort,
        quelle_url: bewerbung.quelle_url,
        stellenbeschreibung: bewerbung.stellenbeschreibung,
      },
      basisDokumente,
      settings,
      prompts,
      design,
      dokumente,
      // Names of the selected attachments so the cover letter (and the LLM) lists
      // exactly these under "Anlagen".
      zusatzAnlagen: selectedAnhaenge.map((a) => a.name || a.dateiname),
      // Free-text notes (company address, contact person, extra context) for the LLM.
      llmNotizen: bewerbung.llm_notizen || '',
      // Signature image placed under the closing salutation (instead of the typed name).
      signatur: loadSignatur(),
      // Applicant photo placed in the CV header (top-right), optional.
      bewerbungsfoto: loadFoto(),
    });

    let seq = 0;
    const storeAnhang = async (name, filename, mime, buffer) => {
      // Sanitise the filename segment so a stored ba.dateiname like '../x' can
      // never escape the user's anhaenge directory via the on-disk path.
      const safe = safeFilename(filename);
      const stored = `${bewerbungId}_${Date.now()}_${seq++}_${safe}`;
      fs.writeFileSync(path.join(userStorageDir(anhaengeDir), stored), buffer);
      await dbRun(
        'INSERT INTO anhaenge (user_id, bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?, ?)',
        [U, bewerbungId, name, safe, mime, stored]
      );
    };

    // Generated (AI) documents
    for (const doc of documents) {
      await storeAnhang(doc.name, doc.filename, doc.mime, doc.buffer);
    }

    // Selected extra attachments (e.g. Zeugnisse) — copied as-is
    for (const ba of selectedAnhaenge) {
      const src = containedPath(userStorageDir(basisAnhaengeDir), ba.pfad);
      if (!src || !fs.existsSync(src)) continue;
      await storeAnhang(ba.name || ba.dateiname, ba.dateiname, ba.mime || 'application/octet-stream', fs.readFileSync(src));
    }

    await dbRun(
      "UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, " +
      "email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?",
      [(email && email.betreff) || '', (email && email.text) || '', bewerbungId, U]
    );
    console.log(`Bewerbungsunterlagen für #${bewerbungId} generiert (${documents.length} Dokument(e)).`);
  } catch (error) {
    console.error(`Generierung für #${bewerbungId} fehlgeschlagen:`, error.message);
    await dbRun(
      "UPDATE bewerbungen SET generierung_status = 'fehler', generierung_fehler = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?",
      [String(error.message || 'Unbekannter Fehler'), bewerbungId, currentUserId()]
    ).catch(() => {});
  }
}

// Initialize database — multi-user schema.
//
// Creates every table with a `user_id` owner column (NOT NULL, FK -> users) and
// per-user PRIMARY KEY / UNIQUE constraints. Fresh installs get this schema
// directly; legacy single-user installs are upgraded by runMigration() (below),
// which adds the user_id columns, recreates the per-user PK tables, backfills
// all existing rows to the admin user and moves on-disk files into a per-user
// subdirectory. runMigration() is idempotent and also runs on fresh installs
// (where it only creates the admin user + default settings row).
async function initializeDatabase() {
  const exec = (sql) => dbRun(sql);
  db.run('PRAGMA foreign_keys = ON');

  // Users + sessions (auth) ------------------------------------------------
  await exec(`
    CREATE TABLE IF NOT EXISTS users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      username TEXT NOT NULL UNIQUE,
      password_hash TEXT NOT NULL,
      is_admin INTEGER NOT NULL DEFAULT 0,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);
  await exec(`
    CREATE TABLE IF NOT EXISTS sessions (
      token TEXT PRIMARY KEY,
      user_id INTEGER NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
      impersonator_id INTEGER,
      dauerhaft INTEGER NOT NULL DEFAULT 1,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
      FOREIGN KEY (impersonator_id) REFERENCES users(id) ON DELETE SET NULL
    )
  `);
  await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)');
  // Audit trail for admin actions that act on another user's account (today:
  // impersonation start/stop). Persistent so a later review can reconstruct who
  // acted as whom and when — a plain console.log would not survive a restart.
  await exec(`
    CREATE TABLE IF NOT EXISTS audit_log (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      actor_user_id INTEGER,
      target_user_id INTEGER,
      action TEXT NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
      FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL
    )
  `);
  await exec('CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at)');

  // Upgrade legacy single-user installs BEFORE the per-user CREATE/INDEX
  // statements below: the migration adds the user_id column to every existing
  // per-user table (ALTER) and recreates the PK/UNIQUE tables per-user, so the
  // CREATE INDEX ... ON <table>(user_id) statements that follow find the column
  // already present. On a fresh install the migration only creates the admin
  // user + default settings row (none of the per-user tables exist yet, so every
  // step guards itself with tableExists and is a no-op). Idempotent.
  await runMigration({ db, dbAll, dbGet, dbRun });

  // Applications (core data) ----------------------------------------------
  await exec(`
    CREATE TABLE IF NOT EXISTS bewerbungen (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      datum DATE NOT NULL,
      firma TEXT NOT NULL,
      stelle TEXT NOT NULL,
      art TEXT,
      status TEXT,
      notizen TEXT,
      interne_notizen TEXT,
      ort TEXT,
      stellenbeschreibung TEXT,
      quelle_url TEXT,
      generierung_status TEXT,
      generierung_fehler TEXT,
      email_betreff TEXT,
      email_anschreiben TEXT,
      llm_notizen TEXT,
      labels TEXT,
      generierung_dokumente TEXT,
      email_empfaenger TEXT,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
    )
  `);
  await exec('CREATE INDEX IF NOT EXISTS idx_bewerbungen_user ON bewerbungen(user_id)');

  // Chronological status changes, each with an optional comment
  await exec(`
    CREATE TABLE IF NOT EXISTS status_verlauf (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      bewerbung_id INTEGER NOT NULL,
      datum DATE NOT NULL,
      status TEXT NOT NULL,
      kommentar TEXT,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
      FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
    )
  `);

  // Base documents (Basis-Unterlagen) — the foundation the AI tailors from
  await exec(`
    CREATE TABLE IF NOT EXISTS basis_dokumente (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      typ TEXT,
      name TEXT,
      inhalt TEXT NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
    )
  `);

  // Static extra attachments (e.g. Zeugnisse) attached to every application
  await exec(`
    CREATE TABLE IF NOT EXISTS basis_anhaenge (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      name TEXT,
      dateiname TEXT NOT NULL,
      mime TEXT,
      pfad TEXT NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
    )
  `);

  // Generated attachment files linked to an application
  await exec(`
    CREATE TABLE IF NOT EXISTS anhaenge (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      bewerbung_id INTEGER NOT NULL,
      name TEXT,
      dateiname TEXT NOT NULL,
      mime TEXT,
      pfad TEXT NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
      FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
    )
  `);

  // Private attachments linked to an application's internal notes.
  await exec(`
    CREATE TABLE IF NOT EXISTS interne_anhaenge (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      bewerbung_id INTEGER NOT NULL,
      name TEXT,
      dateiname TEXT NOT NULL,
      mime TEXT,
      pfad TEXT NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
      FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
    )
  `);

  // E-Mail correspondence (sent + received), linked to an application.
  await exec(`
    CREATE TABLE IF NOT EXISTS emails (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      bewerbung_id INTEGER,
      direction TEXT NOT NULL,
      message_id TEXT,
      in_reply_to TEXT,
      email_references TEXT,
      from_addr TEXT,
      to_addr TEXT,
      subject TEXT,
      body_text TEXT,
      body_html TEXT,
      attachments_json TEXT,
      imap_uid INTEGER,
      seen INTEGER DEFAULT 1,
      email_date DATETIME,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
      FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
    )
  `);
  await exec('CREATE INDEX IF NOT EXISTS idx_emails_user ON emails(user_id)');

  await exec(`
    CREATE TABLE IF NOT EXISTS email_anhaenge (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      email_id INTEGER NOT NULL,
      name TEXT,
      mime TEXT,
      pfad TEXT NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
      FOREIGN KEY (email_id) REFERENCES emails(id) ON DELETE CASCADE
    )
  `);

  // Per-user key/value store (cfg: settings + per-user state like last IMAP UID).
  await exec(`
    CREATE TABLE IF NOT EXISTS app_state (
      user_id INTEGER NOT NULL,
      key TEXT NOT NULL,
      value TEXT,
      PRIMARY KEY (user_id, key),
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
    )
  `);

  // Job offers ingested via the third-party REST API. (user_id, quelle,
  // external_id) identify an offer uniquely per user.
  await exec(`
    CREATE TABLE IF NOT EXISTS jobangebote (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      external_id TEXT,
      quelle TEXT NOT NULL DEFAULT 'drittanbieter',
      firma TEXT NOT NULL,
      stelle TEXT NOT NULL,
      ort TEXT,
      adresse TEXT,
      ansprechpartner TEXT,
      gehalt TEXT,
      beschreibung TEXT,
      quelle_url TEXT,
      art TEXT,
      anzeige_datum DATE,
      kontakt_email TEXT,
      status TEXT NOT NULL DEFAULT 'offen',
      verknuepfte_bewerbung_id INTEGER,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      url_norm TEXT,
      firma_slug TEXT,
      labels TEXT,
      UNIQUE (user_id, quelle, external_id),
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
      FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
    )
  `);
  await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)');
  await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)');

  // Favoriten-Merkmal (Stern): 0 = nicht gemerkt, 1 = favorisiert. Jobangebote
  // und Bewerbungen haben je ein eigenes, voneinander unabhängiges Merkmal —
  // ein gemerktes Angebot wird also nicht zur gemerkten Bewerbung (und umgekehrt).
  // Beide Spalten werden nur bei Bedarf nachgerüstet.

  // Jobangebote: Favoriten erscheinen oben in der Liste und im eigenen Tab
  // „Favoriten“ auf /jobangebote — unabhängig vom Status (offen / uebernommen /
  // abgelehnt). Die Favoriten bleiben auf die Jobangebote-Seite beschränkt.
  {
    const jobSpalten = await dbAll('PRAGMA table_info(jobangebote)');
    if (!jobSpalten.some((s) => s.name === 'favorit')) {
      await exec('ALTER TABLE jobangebote ADD COLUMN favorit INTEGER NOT NULL DEFAULT 0');
    }
  }
  await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_favorit ON jobangebote(user_id, favorit)');

  // Favoriten-Markierung für Bewerbungen. Erlaubt das Merken einzelner
  // Bewerbungen, unabhängig von ihrem Status.
  {
    const bewSpalten = await dbAll('PRAGMA table_info(bewerbungen)');
    if (!bewSpalten.some((s) => s.name === 'favorit')) {
      await exec('ALTER TABLE bewerbungen ADD COLUMN favorit INTEGER NOT NULL DEFAULT 0');
    }
  }

  // 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), *unter welchen Titeln* gesucht wird
  // (berufsbezeichnungen) und die Leitplanken; Technologien/Buzzwords leitet die
  // Suche weiter aus dem Lebenslauf ab — siehe 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 '[]',
      berufsbezeichnungen TEXT 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
    )
  `);
  // Bestehende Installationen: die Spalte kam nach dem CREATE TABLE oben dazu.
  // Leer heißt „wie bisher" — die Suche leitet die Rollen dann weiter selbst aus
  // dem Lebenslauf ab, niemandes Profil ändert sich durch das Update.
  const suchprofilSpalten = await dbAll('PRAGMA table_info(suchprofil)');
  if (!suchprofilSpalten.some((s) => s.name === 'berufsbezeichnungen')) {
    await exec("ALTER TABLE suchprofil ADD COLUMN berufsbezeichnungen TEXT DEFAULT ''");
  }

  // Queue + history of search runs. The app only ever *enqueues* (status
  // 'angefordert'); the host-side runner (bin/jobsuche-runner.sh) picks runs up,
  // because `claude`/`ollama` exist on the host, not in this container.
  await exec(`
    CREATE TABLE IF NOT EXISTS suchlaeufe (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      status TEXT NOT NULL DEFAULT 'angefordert',
      ausloeser TEXT NOT NULL DEFAULT 'manuell',
      angefordert_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      gestartet_at DATETIME,
      beendet_at DATETIME,
      neu INTEGER,
      dubletten INTEGER,
      verworfen INTEGER,
      fehler TEXT,
      log_datei TEXT,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
    )
  `);
  await exec('CREATE INDEX IF NOT EXISTS idx_suchlaeufe_user ON suchlaeufe(user_id, id DESC)');
  await exec('CREATE INDEX IF NOT EXISTS idx_suchlaeufe_status ON suchlaeufe(status)');

  // Personal details of the applicant (one row per user).
  await exec(`
    CREATE TABLE IF NOT EXISTS settings (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL UNIQUE,
      name TEXT,
      adresse TEXT,
      kundennummer TEXT,
      ort TEXT,
      webseite TEXT,
      email TEXT,
      telefon TEXT,
      geburtsdatum TEXT,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
    )
  `);

  // Volltext-Index über Bewerbungen + E-Mail-Korrespondenz. Muss nach den beiden
  // Quelltabellen entstehen, weil die Trigger auf ihnen sitzen; der Backfill
  // läuft nur beim ersten Start (siehe lib/suche.js).
  await suche.ensureSchema({ exec, dbGet });
}
// Initialize and start server
initializeDatabase().then(async () => {
  console.log('Database initialized successfully');

  // Load configuration from the DB. Must run before the boot checks below
  // (mailer/caldav configured?) and before any route that reads config — values
  // live in the DB now, not in the environment.
  await config.init({ dbAll, dbRun });

  // Import any config still supplied via the environment into the *admin's* rows
  // (one-time, idempotent). Env config is the admin's: config.get() has no
  // process.env fallback, precisely so that a newly created user does not
  // inherit the admin's mailbox, calendar, Ollama key and API token. This runs
  // here rather than inside runMigration() because on a fresh install app_state
  // does not exist yet while the migration is running.
  await importEnvIntoAdmin({ dbAll, dbGet });

  // Current user's id — set by the auth middleware (lib/context.js). Guaranteed
  // to be present inside any protected route or background-per-user task.
  const uid = () => currentUserId();

  // Inside a multer upload callback the per-request AsyncLocalStorage context
  // (which uid() reads) is not reliably propagated — busboy drives the multipart
  // stream from outside the userContext.run(...) scope, so currentUserId() can
  // come back null there. The request object, by contrast, always carries the
  // resolved user (set by the auth middleware before the route). Use this for
  // any INSERT done from within an uploadX(req, res, cb) callback, so the row is
  // not orphaned with user_id = NULL. Mirrors userStorageDirForReq's reasoning.
  const uidFromReq = (req) => (req && req.user && req.user.id) || 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;

  // Einheitlicher Aktivitäten-Feed: Statusänderungen UND E-Mails (empfangen wie
  // gesendet) werden zusammengeführt und chronologisch nach Erfassungszeit
  // sortiert. `typ` ('status' | 'email') unterscheidet die Herkunft für die
  // Darstellung; direction/from_addr/to_addr sind nur bei E-Mails gefüllt.
  // Das UNION steckt in einem Subselect, weil SQLite ORDER BY an einem Compound-
  // SELECT keine Ergebnisspalten nach Namen auflöst — erst der äußere SELECT
  // sieht created_at/sort_id als echte Spalten.
  const AKTIVITAET_FEED_INNER = `
    SELECT 'status' AS typ, sv.id AS sort_id, sv.created_at, sv.datum,
           sv.status, sv.kommentar, sv.bewerbung_id,
           b.firma, b.stelle,
           NULL AS direction, NULL AS subject, NULL AS from_addr, NULL AS to_addr
      FROM status_verlauf sv
      LEFT JOIN bewerbungen b ON b.id = sv.bewerbung_id AND b.user_id = sv.user_id
     WHERE sv.user_id = ?
    UNION ALL
    SELECT 'email' AS typ, e.id AS sort_id, e.created_at,
           COALESCE(e.email_date, e.created_at) AS datum,
           CASE WHEN e.direction = 'out' THEN 'E-Mail gesendet' ELSE 'E-Mail erhalten' END AS status,
           e.subject AS kommentar, e.bewerbung_id,
           b.firma, b.stelle,
           e.direction, e.subject, e.from_addr, e.to_addr
      FROM emails e
      LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id AND b.user_id = e.user_id
     WHERE e.user_id = ?
  `;

  async function loadAktivitaet(U, { limit = null, offset = 0 } = {}) {
    const params = [U, U];
    let sql = `SELECT * FROM (${AKTIVITAET_FEED_INNER}) AS feed ORDER BY created_at DESC, typ DESC, sort_id DESC`;
    if (limit != null) {
      sql += ' LIMIT ? OFFSET ?';
      params.push(limit, offset);
    }
    return dbAll(sql, params);
  }

  // Gesamtzahl der Aktivitäten = Statusänderungen + E-Mails (für die Paginierung
  // der /aktivitaeten-Seite).
  async function countAktivitaet(U) {
    const sv = await dbGet('SELECT COUNT(*) AS n FROM status_verlauf WHERE user_id = ?', [U]);
    const em = await dbGet('SELECT COUNT(*) AS n FROM emails WHERE user_id = ?', [U]);
    return (sv ? sv.n : 0) + (em ? em.n : 0);
  }

  // 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(null);

      // 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]);

      // Letzte Aktivitäten für die Startseite: die jüngsten Ereignisse quer über
      // alle Bewerbungen — Statusänderungen ebenso wie empfangene und gesendete
      // E-Mails. Sortiert nach Erfassungszeit (created_at), nicht nach dem
      // fachlichen Datum — sonst würde ein nachgetragener alter Eintrag oben
      // stehen, obwohl gerade etwas anderes passiert ist.
      const aktivitaet = await loadAktivitaet(U, { limit: 8 });

      // 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]);

      // Einrichtungsassistent: eingeblendet, wenn der Nutzer noch keine
      // Bewerbung angelegt hat und den Assistenten nicht dauerhaft ausgeblendet
      // hat. Der Wizard führt durch alle Bereiche, die sonst unter /einstellungen
      // liegen: Persönliche Angaben, Basis-Unterlagen, Ollama-KI, E-Mail,
      // Bewerbungskalender (CalDAV) und REST-API.
      const keinDashboard = !totalCount || totalCount.count === 0;
      const verstecktRow = keinDashboard
        ? await dbGet("SELECT value FROM app_state WHERE user_id = ? AND key = 'state:einrichtung_versteckt'", [U])
        : null;
      let setupWizard = { aktiv: false };
      if (keinDashboard && !(verstecktRow && verstecktRow.value === '1')) {
        const basisRows = await dbAll('SELECT id, typ, inhalt FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [U]);
        const anschreiben = basisRows.find((d) => d.typ === 'Anschreiben');
        const lebenslauf = basisRows.find((d) => d.typ === 'Lebenslauf');
        // Aktuelle Konfiguration für die Vorbelegung — Secrets (Passwörter, Token,
        // API-Schlüssel) werden bewusst nicht ans Frontend gegeben, sondern nur
        // als „vorhanden"-Flag.
        const cfg = config.getAll();
        setupWizard = {
          aktiv: true,
          persoenlich: await loadSettings(),
          anschreiben: anschreiben ? { id: anschreiben.id, inhalt: anschreiben.inhalt || '' } : null,
          lebenslauf: lebenslauf ? { id: lebenslauf.id, inhalt: lebenslauf.inhalt || '' } : null,
          hasApiKey: Boolean(config.get('OLLAMA_API_KEY')),
          ollamaModel: config.get('OLLAMA_MODEL'),
          ollamaHost: config.get('OLLAMA_HOST'),
          cfg: {
            MAIL_HOST: cfg.MAIL_HOST, MAIL_SMTP_PORT: cfg.MAIL_SMTP_PORT, MAIL_IMAP_PORT: cfg.MAIL_IMAP_PORT,
            MAIL_USER: cfg.MAIL_USER, MAIL_FROM_NAME: cfg.MAIL_FROM_NAME, MAIL_FROM: cfg.MAIL_FROM,
            MAIL_IMAP_MAILBOX: cfg.MAIL_IMAP_MAILBOX, MAIL_POLL_MS: cfg.MAIL_POLL_MS,
            hasMailPassword: Boolean(cfg.MAIL_PASSWORD),
            CALDAV_URL: cfg.CALDAV_URL, CALDAV_ALARM_MIN: cfg.CALDAV_ALARM_MIN, CALDAV_POLL_MS: cfg.CALDAV_POLL_MS,
            hasApiToken: Boolean(cfg.API_TOKEN),
          },
        };
      }

      // Gemerkte Bewerbungen — unabhängig vom Bewerbungs-Status, auf der
      // Startseite direkt unter den anstehenden Terminen.
      const bewerbungFavoriten = await dbAll(
        `SELECT b.id, b.firma, b.stelle, b.ort, b.status, b.datum, b.favorit
           FROM bewerbungen b
          WHERE b.user_id = ? AND b.favorit = 1
          ORDER BY b.updated_at DESC, b.id DESC`,
        [U]
      );

      res.render('index', {
        applications,
        statistics: {
          total: totalCount ? totalCount.count : 0,
          byArt,
          byStatus
        },
        availableMonths,
        exportMonths,
        currentFilter: { month, year },
        aktivitaet,
        kommendeTermine,
        caldavTz: caldav.TZ,
        artOptions: ART_OPTIONS,
        statusOptions: STATUS_OPTIONS,
        labelOptions: LABEL_OPTIONS,
        setupWizard,
        bewerbungFavoriten
      });
    } catch (error) {
      console.error('Error:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Alle Aktivitäten (Statusänderungen), seitenweise. Die Startseite zeigt nur die
  // jüngsten acht — hier steht der vollständige Verlauf über alle Bewerbungen.
  const AKTIVITAETEN_PRO_SEITE = 25;

  app.get('/aktivitaeten', async (req, res) => {
    try {
      const U = uid();
      const gesamt = await countAktivitaet(U);
      const seiten = Math.max(1, Math.ceil(gesamt / AKTIVITAETEN_PRO_SEITE));

      // Eine Seite außerhalb des Bereichs (getippt oder nach dem Löschen von
      // Einträgen) klemmen wir auf den gültigen Rand, statt eine leere Seite zu zeigen.
      const gewuenscht = parseInt(req.query.seite, 10);
      const seite = Math.min(Math.max(Number.isInteger(gewuenscht) ? gewuenscht : 1, 1), seiten);

      const aktivitaet = await loadAktivitaet(U, {
        limit: AKTIVITAETEN_PRO_SEITE,
        offset: (seite - 1) * AKTIVITAETEN_PRO_SEITE
      });

      res.render('aktivitaeten', {
        aktivitaet,
        gesamt,
        seite,
        seiten,
        proSeite: AKTIVITAETEN_PRO_SEITE,
        hideSettings: false,
      });
    } catch (error) {
      console.error('Error loading aktivitaeten:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Statistik — comprehensive, on its own page. The dashboard only carries a
  // quick glance now; everything that needs room to breathe lives here.
  app.get('/statistik', async (req, res) => {
    try {
      const U = uid();

      const totalRow = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [U]);
      const total = totalRow ? totalRow.count : 0;

      const byArt = await dbAll(`
        SELECT art, COUNT(*) as count FROM bewerbungen
        WHERE user_id = ? AND art IS NOT NULL AND art != ''
        GROUP BY art ORDER BY count DESC
      `, [U]);

      const statusRows = await dbAll(`
        SELECT status, COUNT(*) as count FROM bewerbungen
        WHERE user_id = ? AND status IS NOT NULL AND status != ''
        GROUP BY status
      `, [U]);
      const statusMap = {};
      statusRows.forEach((r) => { statusMap[r.status] = r.count; });
      // Preserve the canonical STATUS_OPTIONS order so the bars read as a funnel
      // top-to-bottom rather than a noisy rank-by-count.
      const byStatus = STATUS_OPTIONS
        .map((s) => ({ status: s, count: statusMap[s] || 0 }))
        .filter((x) => x.count > 0);

      const byOrt = await dbAll(`
        SELECT ort, COUNT(*) as count FROM bewerbungen
        WHERE user_id = ? AND ort IS NOT NULL AND ort != ''
        GROUP BY ort ORDER BY count DESC LIMIT 10
      `, [U]);

      // Monthly trend (oldest first so the chart reads left-to-right).
      const byMonth = await dbAll(`
        SELECT strftime('%Y-%m', datum) as ym, COUNT(*) as count
        FROM bewerbungen WHERE user_id = ? AND datum IS NOT NULL
        GROUP BY ym ORDER BY ym ASC
      `, [U]);

      // How many applications ever reached each status — drives the funnel.
      const verlaufStatus = await dbAll(`
        SELECT status, COUNT(DISTINCT bewerbung_id) as count
        FROM status_verlauf WHERE user_id = ? GROUP BY status
      `, [U]);
      const verlaufMap = {};
      verlaufStatus.forEach((r) => { verlaufMap[r.status] = r.count; });
      const ever = (s) => verlaufMap[s] || 0;
      const cur = (s) => statusMap[s] || 0;

      // Für den Funnel: welche Stationen hat JEDE Bewerbung durchlaufen. Eine
      // Stufe zählt eine Bewerbung genau einmal — ein späterer Status impliziert
      // die früheren (eine Einstellung ist auch ein geführtes Gespräch), und ein
      // Aufsummieren von ever() würde eine Bewerbung, die beide Stationen hat,
      // doppelt zählen.
      const stationen = new Map();
      const verlaufPaare = await dbAll(
        'SELECT DISTINCT bewerbung_id, status FROM status_verlauf WHERE user_id = ?', [U]
      );
      verlaufPaare.forEach((r) => {
        if (!stationen.has(r.bewerbung_id)) stationen.set(r.bewerbung_id, new Set());
        stationen.get(r.bewerbung_id).add(r.status);
      });
      const everAny = (...statusListe) => {
        let n = 0;
        for (const menge of stationen.values()) {
          if (statusListe.some((s) => menge.has(s))) n++;
        }
        return n;
      };

      const entwuerfe = cur('Entwurf');
      const gesendet = total - entwuerfe;
      const gespraech = cur('Vorstellungsgespräch');
      const eingestellt = cur('Einstellung');
      const absagen = cur('Absage') + cur('Absage von meiner Seite');

      // Any reply at all — everything past "Gesendet" except "Keine Rückmeldung".
      const rueckStatus = [
        'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', 'Telefonat',
        'Warten auf Rückmeldung', 'Warten auf meine Antwort',
        'Vorstellungsgespräch', 'Vertragsverhandlung',
        'Absage', 'Absage von meiner Seite', 'Einstellung'
      ];
      const antworten = rueckStatus.reduce((a, s) => a + cur(s), 0);

      const erfolgsquote = gesendet > 0 ? Math.round((eingestellt / gesendet) * 100) : 0;
      const antwortquote = gesendet > 0 ? Math.round((antworten / gesendet) * 100) : 0;
      const avgProMonat = byMonth.length > 0 ? Math.round((total / byMonth.length) * 10) / 10 : 0;

      // Funnel: Bewerbungen, die die Stufe je erreicht haben. Ein späterer Status
      // impliziert die früheren — wer eingestellt wurde, hat auch verhandelt und
      // ein Gespräch geführt, selbst wenn diese Zwischenschritte nie erfasst wurden.
      const funnel = [
        { label: 'Bewerbungen gesamt', count: total },
        { label: 'Versendet', count: ever('Gesendet') || gesendet },
        { label: 'Eingangsbestätigung', count: ever('Eingangsbestätigung') },
        { label: 'Vorstellungsgespräch', count: everAny('Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung') },
        { label: 'Vertragsverhandlung', count: everAny('Vertragsverhandlung', 'Einstellung') },
        { label: 'Einstellung', count: ever('Einstellung') }
      ];

      // Recent activity: latest status changes and emails with the application
      // they belong to.
      const aktivitaet = await loadAktivitaet(U, { limit: 12 });

      res.render('statistik', {
        total,
        byArt,
        byStatus,
        byOrt,
        byMonth,
        kpis: {
          gesendet,
          gespraech,
          eingestellt,
          absagen,
          antworten,
          erfolgsquote,
          antwortquote,
          avgProMonat,
          monateAktiv: byMonth.length
        },
        funnel,
        aktivitaet,
        statusOptions: STATUS_OPTIONS
      });
    } catch (error) {
      console.error('Error loading statistik:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Get single application
  app.get('/api/bewerbungen/:id', async (req, res) => {
    try {
      const { id } = req.params;
      const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);

      if (!application) {
        return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
      }

      res.json(application);
    } catch (error) {
      console.error('Error getting application:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // Get settings
  app.get('/api/settings', async (req, res) => {
    try {
      const settings = await loadSettings();
      res.json(settings);
    } catch (error) {
      console.error('Error getting settings:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // Save settings
  app.post('/api/settings', async (req, res) => {
    try {
      const { name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum } = req.body;
      const U = uid();

      await dbRun(
        `INSERT INTO settings (user_id, name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
         ON CONFLICT(user_id) DO UPDATE SET
           name = excluded.name, adresse = excluded.adresse, kundennummer = excluded.kundennummer,
           email = excluded.email, telefon = excluded.telefon, ort = excluded.ort,
           webseite = excluded.webseite, geburtsdatum = excluded.geburtsdatum`,
        [
          U,
          sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer),
          sanitizeInput(email), sanitizeInput(telefon), sanitizeInput(ort),
          sanitizeInput(webseite), sanitizeInput(geburtsdatum)
        ]
      );

      res.json({ success: true });
    } catch (error) {
      console.error('Error saving settings:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // ----- Einrichtungsassistent (Onboarding auf der Startseite) -----
  // JSON-Endpunkte, die der Wizard Schritt für Schritt per fetch anstößt — ohne
  // Seitenwechsel, damit der Nutzer im Flow bleibt. Jeder speichert für den
  // aktuellen Benutzer und antwortet mit { success } bzw. { error }.
  app.post('/api/einrichtung/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.json({ success: true });
    } catch (error) {
      console.error('Einrichtung/persoenlich:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // Basis-Unterlage (Anschreiben/Lebenslauf/…) je Typ upserten. Leerinhalt
  // löscht den Eintrag nicht, sondern speichert ihn als leeren Text — so bleibt
  // der Typ beim erneuten Öffnen sichtbar.
  app.post('/api/einrichtung/basis', async (req, res) => {
    try {
      const typ = String(req.body.typ || '').trim();
      if (!typ) return res.status(400).json({ error: 'Typ fehlt' });
      const inhalt = req.body.inhalt != null ? String(req.body.inhalt) : '';
      const existing = await dbGet('SELECT id FROM basis_dokumente WHERE user_id = ? AND typ = ?', [uid(), typ]);
      if (existing) {
        await dbRun('UPDATE basis_dokumente SET inhalt = ? WHERE id = ? AND user_id = ?',
          [inhalt, existing.id, uid()]);
      } else {
        await dbRun('INSERT INTO basis_dokumente (user_id, typ, name, inhalt) VALUES (?, ?, ?, ?)',
          [uid(), typ, typ, inhalt]);
      }
      res.json({ success: true });
    } catch (error) {
      console.error('Einrichtung/basis:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // Ollama-KI: nur die drei Ollama-Keys setzen, ohne die restliche Konfiguration
  // (Mail, Kalender, API-Token) anzutasten — config.saveAll würde alle nicht
  // übergebenen Keys leeren, daher gezielt über setForUser.
  app.post('/api/einrichtung/ollama', async (req, res) => {
    try {
      const u = uid();
      const apiKey = req.body.OLLAMA_API_KEY != null ? String(req.body.OLLAMA_API_KEY).trim() : '';
      const model = req.body.OLLAMA_MODEL != null ? String(req.body.OLLAMA_MODEL).trim() : '';
      const host = req.body.OLLAMA_HOST != null ? String(req.body.OLLAMA_HOST).trim() : '';
      if (host) {
        try { await assertSafeUrl(host); }
        catch (vErr) { return res.status(400).json({ error: 'Host abgelehnt: ' + (vErr.message || 'ungültig') }); }
      }
      await config.setForUser(u, 'OLLAMA_API_KEY', apiKey);
      if (model) await config.setForUser(u, 'OLLAMA_MODEL', model);
      if (host) await config.setForUser(u, 'OLLAMA_HOST', host);
      res.json({ success: true });
    } catch (error) {
      console.error('Einrichtung/ollama:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // Generisches Speichern für die weiteren Wizard-Schritte (E-Mail, CalDAV,
  // REST-API). Nimmt nur Schlüssel aus config.DEFAULTS entgegen und schreibt
  // sie gezielt via setForUser — ungenannte Keys bleiben unangetastet. Hosts
  // werden gegen SSRF geprüft, der API-Token auf Kollision mit anderem Benutzer.
  app.post('/api/einrichtung/config', async (req, res) => {
    try {
      const u = uid();
      const erlaubt = new Set(Object.keys(config.DEFAULTS));
      const werte = {};
      Object.keys(req.body || {}).forEach(function (k) {
        if (erlaubt.has(k)) werte[k] = String(req.body[k] == null ? '' : req.body[k]).trim();
      });
      // SSRF-Guard für externe Hosts, die der Server anruft.
      if (werte.OLLAMA_HOST) {
        try { await assertSafeUrl(werte.OLLAMA_HOST); }
        catch (vErr) { return res.status(400).json({ error: 'Ollama-Host abgelehnt: ' + (vErr.message || 'ungültig') }); }
      }
      if (werte.CALDAV_URL) {
        try { await assertSafeUrl(werte.CALDAV_URL); }
        catch (vErr) { return res.status(400).json({ error: 'Kalender-URL abgelehnt: ' + (vErr.message || 'ungültig') }); }
      }
      if (werte.MAIL_HOST) {
        try { await assertSafeHost(werte.MAIL_HOST); }
        catch (vErr) { return res.status(400).json({ error: 'Mail-Host abgelehnt: ' + (vErr.message || 'ungültig') }); }
      }
      // API-Token identifiziert den Benutzer gegenüber /api/v1 — Kollision würde
      // beide Nutzer aussperren, daher schon hier ablehnen.
      if (werte.API_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', werte.API_TOKEN, u]
        );
        if (fremd) return res.status(409).json({ error: 'API-Token bereits von anderem Benutzer belegt. Bitte „Neu generieren".' });
      }
      for (const k of Object.keys(werte)) await config.setForUser(u, k, werte[k]);
      res.json({ success: true });
    } catch (error) {
      console.error('Einrichtung/config:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // Wizard dauerhaft ausblenden (app_state-Flag), damit er nicht bei jedem
  // Besuch der leeren Startseite wieder erscheint.
  app.post('/api/einrichtung/verstecken', async (req, res) => {
    try {
      await dbRun(
        "INSERT INTO app_state (user_id, key, value) VALUES (?, 'state:einrichtung_versteckt', '1') " +
        "ON CONFLICT(user_id, key) DO UPDATE SET value = '1'",
        [uid()]
      );
      res.json({ success: true });
    } catch (error) {
      console.error('Einrichtung/verstecken:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // ----- Indeed import (called by the browser extension) -----
  app.post('/api/indeed-import', async (req, res) => {
    try {
      const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url, art } = req.body || {};

      if (!firma || !stelle) {
        return res.status(400).json({ error: 'Firma und Stelle sind erforderlich.' });
      }

      // Source of the capture: honour an explicit art, else infer from the URL.
      const quelle = deriveArt(quelle_url, art);

      // Duplicate guard: don't silently import the same posting twice.
      const forceImport = req.body.force === true || req.body.force === 'true';
      if (!forceImport) {
        const dups = await findDuplicateApplications({ firma, stelle, quelle_url });
        if (dups.length) {
          return res.status(409).json({
            duplicate: true,
            matches: dups,
            error: 'Für diese Stelle existiert bereits eine Bewerbung.',
          });
        }
      }

      const datum = new Date().toISOString().split('T')[0];
      // Keep the extra details (location, salary, source) visible in the notes too.
      const notizParts = [
        ort ? `Ort: ${ort}` : null,
        gehalt ? `Gehalt: ${gehalt}` : null,
        quelle_url ? `Quelle: ${quelle_url}` : null,
      ].filter(Boolean);
      const notizen = notizParts.join('\n');

      // Stored raw: every view renders these through EJS `<%= %>` (auto-escaped),
      // so this is XSS-safe — and it keeps the text clean for the AI and the PDFs
      // (no HTML entities leaking into the generated documents).
      const result = await dbRun(
        `INSERT INTO bewerbungen
           (user_id, datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
         VALUES (?, ?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
        [uid(), datum, firma, stelle, quelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
      );

      // Record the initial "Entwurf" status in the timeline
      await dbRun(
        'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
        [uid(), result.lastID, datum, 'Entwurf', `Automatisch aus dem Browser importiert (${quelle})`]
      );

      // Note: generation is NOT started automatically — the user reviews the draft,
      // adds LLM notes if needed, and triggers generation on the application page.
      res.json({
        success: true,
        id: result.lastID,
        url: `/bewerbung/${result.lastID}`,
        message: 'Bewerbung als Entwurf angelegt. Unterlagen können auf der Bewerbungsseite generiert werden.',
      });
    } catch (error) {
      console.error('Error importing job:', error);
      res.status(500).json({ error: 'Serverfehler beim Import.' });
    }
  });

  // Volltextsuche für die Startseite: Bewerbungen samt ihrer E-Mail-Korrespondenz
  // (Absender, Betreff, Text), Notizen und Stellenbeschreibung. Liefert je Treffer
  // die Fundstellen mit Snippet — die Marker darin ersetzt der Client durch <mark>.
  app.get('/api/suche', async (req, res) => {
    try {
      const q = String(req.query.q || '');
      if (q.trim().length < 2) return res.json({ q, treffer: [] });
      const treffer = await suche.suche(dbAll, uid(), q);
      res.json({ q, treffer });
    } catch (error) {
      console.error('Search error:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // Poll generation status + current attachments for one application
  app.get('/api/bewerbungen/:id/generierung', async (req, res) => {
    try {
      const { id } = req.params;
      const bewerbung = await dbGet(
        'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]
      );
      if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
      const anhaenge = await dbAll(
        'SELECT id, name, dateiname, mime FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC', [id, uid()]
      );
      res.json({
        status: bewerbung.generierung_status,
        fehler: bewerbung.generierung_fehler,
        anhaenge,
      });
    } catch (error) {
      console.error('Error fetching generation status:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // ----- Base documents (Basis-Unterlagen / Vorlagen) -----
  app.get('/api/basis-dokumente', async (req, res) => {
    try {
      const docs = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [uid()]);
      res.json(docs);
    } catch (error) {
      console.error('Error listing base documents:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // Create application
  app.post('/api/bewerbungen', async (req, res) => {
    try {
      const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body;
      const labels = serializeLabels(req.body.labels);

      // Duplicate guard: warn before creating a second application for the same
      // company + role (the client re-submits with force=true to confirm).
      const force = req.body.force === true || req.body.force === 'true';
      if (!force) {
        const dups = await findDuplicateApplications({ firma, stelle });
        if (dups.length) {
          return res.status(409).json({
            duplicate: true,
            matches: dups,
            error: 'Es gibt bereits eine Bewerbung für dieselbe Firma und Stelle.',
          });
        }
      }

      const result = await dbRun(
        'INSERT INTO bewerbungen (user_id, datum, firma, stelle, art, status, notizen, interne_notizen, labels) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
        [uid(), datum, sanitizeInput(firma), sanitizeInput(stelle),
         sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels]
      );

      // Record the initial status as the first timeline entry
      if (status && status.trim()) {
        await dbRun(
          'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
          [uid(), result.lastID, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
        );
      }

      const newApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [result.lastID, uid()]);

      res.json({ success: true, application: newApplication });
    } catch (error) {
      console.error('Error creating application:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // Update application
  app.put('/api/bewerbungen/:id', async (req, res) => {
    try {
      const { id } = req.params;
      const { datum, firma, stelle, art, status, notizen } = req.body;
      const existing = await dbGet('SELECT labels FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
      const labels = Object.prototype.hasOwnProperty.call(req.body, 'labels')
        ? serializeLabels(req.body.labels)
        : (existing ? existing.labels : '[]');

      await dbRun(
        'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
        [datum, sanitizeInput(firma), sanitizeInput(stelle),
         sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), labels, id, uid()]
      );

      const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
      
      res.json({ success: true, application: updatedApplication });
    } catch (error) {
      console.error('Error updating application:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // Delete application
  app.delete('/api/bewerbungen/:id', async (req, res) => {
    try {
      const { id } = req.params;
      await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
      // Remove private attachments belonging to the internal notes.
      const interne = await dbAll('SELECT pfad FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
      const intDir = userStorageDir(interneAnhaengeDir);
      interne.forEach((a) => {
        const fp = containedPath(intDir, a.pfad);
        if (fp) fs.promises.unlink(fp).catch(() => {});
      });
      await dbRun('DELETE FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
      await dbRun('DELETE FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);

      res.json({ success: true });
    } catch (error) {
      console.error('Error deleting application:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // Applications for PDF export (optionally filtered), including the status timeline
  app.get('/api/export', async (req, res) => {
    try {
      const { month, year } = req.query;
      const U = uid();

      // Effektives Datum einer Bewerbung = Datum ihrer letzten Statusänderung
      // (fällt auf das Bewerbungsdatum zurück, wenn es keinen Verlauf gibt). Der
      // Monatsexport listet eine Bewerbung im Monat ihres LETZTEN Status: Eine im
      // Juni gesendete Bewerbung, die im Juli zum Vorstellungsgespräch wird,
      // erscheint dadurch im Export für Juli.
      const base = `
        SELECT b.*, COALESCE(
          (SELECT MAX(date(sv.datum)) FROM status_verlauf sv WHERE sv.bewerbung_id = b.id AND sv.user_id = ?),
          date(b.datum)
        ) AS eff_datum
        FROM bewerbungen b
        WHERE b.user_id = ?
      `;
      let query = `SELECT * FROM (${base}) ORDER BY eff_datum DESC`;
      const params = [U, U];

      if (month && year) {
        query = `SELECT * FROM (${base}) WHERE strftime("%m", eff_datum) = ? AND strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`;
        params.push(month.padStart(2, '0'), year);
      } else if (month) {
        // A month without a year must still restrict the export to that month —
        // never fall through to exporting every application.
        query = `SELECT * FROM (${base}) WHERE strftime("%m", eff_datum) = ? ORDER BY eff_datum DESC`;
        params.push(month.padStart(2, '0'));
      } else if (year) {
        query = `SELECT * FROM (${base}) WHERE strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`;
        params.push(year);
      }

      const applications = await dbAll(query, params);
      await attachVerlauf(applications);
      // Internal notes must never reach the PDF/export
      applications.forEach((a) => { delete a.interne_notizen; });

      res.json(applications);
    } catch (error) {
      console.error('Error exporting applications:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // ----- Dedicated edit page + status-timeline management -----

  // Edit page for a single application
  app.get('/bewerbung/:id', async (req, res) => {
    try {
      const { id } = req.params;
      const U = uid();
      const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, U]);
      if (!application) return res.status(404).send('Bewerbung nicht gefunden');
      application.labelsArr = parseLabels(application.labels);

      const verlauf = await dbAll(
        'SELECT * FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ? ORDER BY date(datum) ASC, id ASC',
        [id, U]
      );

      const anhaenge = await dbAll(
        'SELECT id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
        [id, U]
      );
      // Private attachments belonging to the internal notes — never exported/sent.
      const interneAnhaenge = await dbAll(
        'SELECT id, name, dateiname, mime, created_at FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
        [id, U]
      );
      const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente WHERE user_id = ?', [U]);
      // Available static attachments (Zeugnisse etc.) to optionally enclose.
      const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [U]);
      // Calendar appointments for this application (mirrored to SOGo).
      const termine = await dbAll('SELECT * FROM termine WHERE bewerbung_id = ? AND user_id = ? ORDER BY start ASC', [id, U]);
      // Vorausgefüllte Werte für die Bearbeiten-Formulare (Berliner Wanduhr).
      termine.forEach((t) => {
        const s = caldav.utcToWallInputs(new Date(t.start));
        t.editDatum = s.datum;
        t.editVon = t.ganztags ? '09:00' : s.zeit;
        t.editBis = (!t.ganztags && t.ende) ? caldav.utcToWallInputs(new Date(t.ende)).zeit : '';
      });

      // 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 for this application; if there is none yet, the
        // user's preselection from the settings (default: both) pre-ticks the form.
        dokumentAuswahl: application.generierung_dokumente
          ? normalizeDokumente(String(application.generierung_dokumente).split(','))
          : standardDokumente(),
        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,
        // Vorausgewählte Anlagen aus den Einstellungen (Standard: keine).
        anlagenAuswahl: config.anlagenDefaultIds(),
        termine,
        caldavConfigured: caldav.isConfigured(),
        caldavTz: caldav.TZ,
        terminVorschlag: req.query.vorschlag === 'vg' ? { datum: String(req.query.vdatum || '') } : null,
        terminOk: !!req.query.terminok,
        terminError: req.query.terminerror ? String(req.query.terminerror) : '',
        artOptions: ART_OPTIONS,
        statusOptions: STATUS_OPTIONS,
        labelOptions: LABEL_OPTIONS,
        hideSettings: true
      });
    } catch (error) {
      console.error('Error loading edit page:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Update application core data (status is managed via the timeline)
  app.post('/bewerbung/:id', async (req, res) => {
    try {
      const { id } = req.params;
      const { datum, firma, stelle, art, notizen, interne_notizen } = req.body;
      const labels = serializeLabels(req.body.labels);

      await dbRun(
        'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
        [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels, id, uid()]
      );

      res.redirect('/bewerbung/' + id);
    } catch (error) {
      console.error('Error updating application:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Save the editable e-mail cover text (Begleit-E-Mail) after the user tweaks it.
  app.post('/bewerbung/:id/email', async (req, res) => {
    try {
      const { id } = req.params;
      const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
      if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');

      // Store raw; the value is HTML-escaped on render (EJS <%= %>), matching how
      // the generated e-mail is stored. Sanitising here would double-escape.
      await dbRun(
        'UPDATE bewerbungen SET email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
        [String(req.body.email_betreff || ''), String(req.body.email_anschreiben || ''), id, uid()]
      );
      res.redirect('/bewerbung/' + id + '#email');
    } catch (error) {
      console.error('Error saving e-mail text:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Send an e-mail for an application (initial application or a reply). Sends
  // via authenticated submission, records it as an outgoing message and links
  // the chosen generated attachments.
  app.post('/bewerbung/:id/email/send', async (req, res) => {
    const { id } = req.params;
    const back = (frag) => '/bewerbung/' + id + (frag || '#korrespondenz');
    try {
      const U = uid();
      const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, U]);
      if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
      if (!mailer.isConfigured()) {
        return res.redirect(back('?mailerror=' + encodeURIComponent('E-Mail ist nicht konfiguriert (Einstellungen).') + '#korrespondenz'));
      }

      const to = String(req.body.to || '').trim();
      const subject = String(req.body.subject || '').trim();
      const body = String(req.body.body || '');
      if (!to || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to)) {
        return res.redirect(back('?mailerror=' + encodeURIComponent('Bitte eine gültige Empfänger-Adresse angeben.') + '#korrespondenz'));
      }

      // Selected generated attachments (checkbox values = anhaenge ids).
      let anhangIds = req.body.anhang || [];
      if (!Array.isArray(anhangIds)) anhangIds = [anhangIds];
      const attachments = [];
      const attNames = [];
      const anhaengeUserDir = userStorageDir(anhaengeDir);
      for (const aid of anhangIds) {
        const a = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [aid, id, U]);
        if (!a) continue;
        const p = containedPath(anhaengeUserDir, a.pfad);
        if (!p || !fs.existsSync(p)) continue;
        attachments.push({ filename: a.dateiname, path: p, contentType: a.mime || undefined });
        attNames.push(a.dateiname);
      }

      // Selected static attachments (Zeugnisse etc.), e.g. from a reply form.
      // Default: none selected.
      let basisAnlageIds = req.body.basis_anlage || [];
      if (!Array.isArray(basisAnlageIds)) basisAnlageIds = [basisAnlageIds];
      const basisUserDir = userStorageDir(basisAnhaengeDir);
      for (const bid of basisAnlageIds) {
        const ba = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [bid, U]);
        if (!ba) continue;
        const p = containedPath(basisUserDir, ba.pfad);
        if (!p || !fs.existsSync(p)) continue;
        attachments.push({ filename: ba.dateiname, path: p, contentType: ba.mime || undefined });
        attNames.push(ba.dateiname);
      }

      // Threading headers when this is a reply to a stored message.
      let inReplyTo = null, references = null;
      if (req.body.reply_to) {
        const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [req.body.reply_to, id, U]);
        if (orig && orig.message_id) {
          inReplyTo = '<' + orig.message_id + '>';
          references = ((orig.email_references ? orig.email_references + ' ' : '') + inReplyTo).trim();
        }
      }

      const info = await mailer.sendMail({
        to, subject, text: body, attachments,
        inReplyTo, references,
      });
      const mid = String(info.messageId || '').replace(/[<>]/g, '');

      const emailRow = await dbRun(
        `INSERT INTO emails (user_id, bewerbung_id, direction, message_id, in_reply_to, email_references,
           from_addr, to_addr, subject, body_text, attachments_json, seen, email_date)
         VALUES (?, ?, 'out', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
        [U, id, mid, inReplyTo ? inReplyTo.replace(/[<>]/g, '') : null, references ? references.replace(/[<>]/g, '') : null,
         mailer.fromAddress(), to, subject, body, JSON.stringify(attNames), new Date().toISOString()]
      );

      // Keep a copy of each sent attachment linked to the outgoing message so it
      // is shown (and can be reopened) in the correspondence view afterwards.
      const emailAttDir = userStorageDir(emailAnhaengeDir);
      for (const att of attachments) {
        const safe = String(att.filename || 'anhang').replace(/[^a-zA-Z0-9äöüÄÖÜß._ -]/g, '_').slice(0, 80);
        const storedName = `${emailRow.lastID}_${Date.now()}_${safe}`;
        try {
          fs.copyFileSync(att.path, path.join(emailAttDir, storedName));
          await dbRun('INSERT INTO email_anhaenge (user_id, email_id, name, mime, pfad) VALUES (?, ?, ?, ?, ?)',
            [U, emailRow.lastID, att.filename, att.contentType || null, storedName]);
        } catch (e) { /* ignore a single bad attachment */ }
      }

      await dbRun('UPDATE bewerbungen SET email_empfaenger = ? WHERE id = ? AND user_id = ?', [to, id, U]);

      res.redirect(back('?mailok=' + encodeURIComponent('E-Mail an ' + to + ' gesendet.') + '#korrespondenz'));
    } catch (error) {
      console.error('Error sending e-mail:', error);
      res.redirect(back('?mailerror=' + encodeURIComponent('Versand fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')) + '#korrespondenz'));
    }
  });

  // AI-draft a reply to a received e-mail. Returns JSON {betreff, text} that the
  // frontend drops into the reply form for the user to edit before sending.
  app.post('/bewerbung/:id/email/ai-reply', async (req, res) => {
    try {
      const { id } = req.params;
      const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
      if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
      const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [req.body.email_id, id, uid()]);
      if (!orig) return res.status(404).json({ error: 'Nachricht nicht gefunden' });
      const settings = await loadSettings();

      const draft = await generateEmailReply({
        incoming: { from: orig.from_addr, subject: orig.subject, text: emailPlainText(orig) },
        job: { firma: bewerbung.firma, stelle: bewerbung.stelle },
        settings,
        prompts: await loadPrompts(),
        // Was der Nutzer über dem Antwortfeld vorgegeben hat, plus (beim zweiten
        // Klick) der Entwurf, der gerade im Feld steht — dann überarbeitet die KI
        // ihn, statt neu anzufangen.
        hinweise: String(req.body.hinweise || ''),
        entwurf: String(req.body.entwurf || ''),
        typ: String(req.body.typ || 'antwort'),
      });
      // Include the quoted original so the reply reads like a mail-client thread.
      res.json({ ...draft, quote: buildReplyQuote(orig) });
    } catch (error) {
      console.error('Error drafting AI reply:', error);
      res.status(500).json({ error: error.message || 'Serverfehler' });
    }
  });

  // Manually trigger an IMAP fetch of new replies, then return to the referring page.
  app.post('/email/fetch', async (req, res) => {
    const back = safeRedirect(req.body.back || req.get('referer') || '/');
    try {
      await pollInbox();
    } catch (e) { /* errors are logged inside pollInbox */ }
    res.redirect(back);
  });

  // Download an attachment that arrived with a received e-mail.
  app.get('/email-anhaenge/:id/download', async (req, res) => {
    try {
      const a = await dbGet('SELECT * FROM email_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
      if (!a) return res.status(404).send('Anhang nicht gefunden');
      const p = containedPath(userStorageDir(emailAnhaengeDir), a.pfad);
      if (!p || !fs.existsSync(p)) return res.status(404).send('Datei nicht gefunden');
      // inline=1 opens viewable files (PDF/image) in the browser tab instead of forcing a download.
      if (req.query.inline === '1') return serveInline(res, p, a.name || a.pfad, a.mime);
      res.download(p, displayFilename(a.name || a.pfad));
    } catch (error) {
      console.error('Error downloading e-mail attachment:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // ---------------------------------------------------------------------------
  //  Postfach: incoming e-mails that landed in the inbox but could not be
  //  matched to an application automatically. The user assigns them by hand.
  // ---------------------------------------------------------------------------

  // Page listing all e-mails with no bewerbung_id, plus every application as
  // assignment target.
  app.get('/postfach', async (req, res) => {
    try {
      const U = uid();
      const emails = await dbAll(
        `SELECT * FROM emails WHERE user_id = ? AND bewerbung_id IS NULL ORDER BY datetime(email_date) DESC, id DESC`,
        [U]
      );
      if (emails.length) {
        const eIds = emails.map((e) => e.id);
        const atts = await dbAll(
          `SELECT id, email_id, name, mime FROM email_anhaenge WHERE user_id = ? AND email_id IN (${eIds.map(() => '?').join(',')})`,
          [U, ...eIds]
        );
        const byEmail = {};
        atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
        emails.forEach((e) => { e.anhaenge = byEmail[e.id] || []; });
        decorateEmails(emails);
        // These are read now that they are shown — clears them from the bell.
        // (The `seen` values above are captured pre-update, so the "Neu" badge
        // still renders on this view.)
        await dbRun("UPDATE emails SET seen = 1 WHERE user_id = ? AND bewerbung_id IS NULL AND direction = 'in' AND seen = 0", [U]);
      }
      // Applications the user can assign an e-mail to (newest first).
      const bewerbungen = await dbAll(
        `SELECT id, firma, stelle, ort, datum FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC, created_at DESC`,
        [U]
      );
      res.render('postfach', {
        emails,
        bewerbungen,
        mailConfigured: mailer.isConfigured(),
        mailError: req.query.mailerror ? String(req.query.mailerror) : '',
        mailOk: req.query.mailok ? String(req.query.mailok) : '',
        hideSettings: true,
      });
    } catch (error) {
      console.error('Error loading postfach:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Assign an unlinked e-mail to an existing application.
  app.post('/postfach/:emailId/zuweisen', async (req, res) => {
    try {
      const bewerbungId = Number(req.body.bewerbung_id);
      if (!bewerbungId) {
        return res.redirect('/postfach?mailerror=' + encodeURIComponent('Bitte eine Bewerbung auswählen.'));
      }
      // Make sure the target application exists and the e-mail is still unlinked.
      const app = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [bewerbungId, uid()]);
      if (!app) {
        return res.redirect('/postfach?mailerror=' + encodeURIComponent('Ausgewählte Bewerbung existiert nicht.'));
      }
      await dbRun('UPDATE emails SET bewerbung_id = ? WHERE id = ? AND user_id = ? AND bewerbung_id IS NULL', [bewerbungId, req.params.emailId, uid()]);
      res.redirect('/postfach?mailok=' + encodeURIComponent('E-Mail wurde der Bewerbung zugewiesen.'));
    } catch (error) {
      console.error('Error assigning e-mail:', error);
      res.redirect('/postfach?mailerror=' + encodeURIComponent('Zuweisung fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')));
    }
  });

  // Delete an e-mail from the Postfach (removes its stored attachment files too).
  app.post('/postfach/:emailId/delete', async (req, res) => {
    try {
      const emailId = req.params.emailId;
      const atts = await dbAll('SELECT pfad FROM email_anhaenge WHERE email_id = ? AND user_id = ?', [emailId, uid()]);
      const attDir = userStorageDir(emailAnhaengeDir);
      for (const a of atts) {
        const fp = containedPath(attDir, a.pfad);
        if (fp) fs.promises.unlink(fp).catch(() => {});
      }
      await dbRun('DELETE FROM email_anhaenge WHERE email_id = ? AND user_id = ?', [emailId, uid()]);
      await dbRun('DELETE FROM emails WHERE id = ? AND user_id = ?', [emailId, uid()]);
      res.redirect('/postfach?mailok=' + encodeURIComponent('E-Mail wurde gelöscht.'));
    } catch (error) {
      console.error('Error deleting e-mail:', error);
      res.redirect('/postfach?mailerror=' + encodeURIComponent('Löschen fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')));
    }
  });

  // Count of unlinked e-mails — drives the "Postfach" header badge on every page.
  app.get('/api/emails/unassigned-count', async (req, res) => {
    try {
      const row = await dbGet('SELECT COUNT(*) as count FROM emails WHERE user_id = ? AND bewerbung_id IS NULL', [uid()]);
      res.json({ count: row ? row.count : 0 });
    } catch (error) {
      res.status(500).json({ count: 0 });
    }
  });

  // Unread received e-mails — drives the notification bell on every page. Returns
  // the total unread count plus the newest few as a ready-to-render list. Replies
  // auto-assigned to an application carry a link to that application; unassigned
  // mail links to the Postfach.
  app.get('/api/notifications', async (req, res) => {
    try {
      const cntRow = await dbGet(
        "SELECT COUNT(*) AS count FROM emails WHERE user_id = ? AND direction = 'in' AND seen = 0",
        [uid()]
      );
      const rows = await dbAll(
        `SELECT e.id, e.from_addr, e.subject, e.body_text, e.body_html, e.email_date, e.bewerbung_id,
                b.firma, b.stelle
           FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id AND b.user_id = e.user_id
          WHERE e.user_id = ? AND e.direction = 'in' AND e.seen = 0
          ORDER BY datetime(e.email_date) DESC, e.id DESC
          LIMIT 30`,
        [uid()]
      );
      const items = rows.map((e) => {
        const fromName = String(e.from_addr || '').replace(/<[^>]*>/, '').replace(/"/g, '').trim()
          || String(e.from_addr || '').trim();
        const snippet = emailPlainText(e).replace(/\s+/g, ' ').trim().slice(0, 140);
        return {
          id: e.id,
          from: fromName || '(unbekannt)',
          subject: e.subject || '(kein Betreff)',
          snippet,
          date: e.email_date || null,
          bewerbung_id: e.bewerbung_id || null,
          kontext: e.bewerbung_id ? [e.firma, e.stelle].filter(Boolean).join(' · ') : '',
          url: e.bewerbung_id ? ('/bewerbung/' + e.bewerbung_id + '#korrespondenz') : '/postfach',
        };
      });
      res.json({ count: cntRow ? cntRow.count : 0, items });
    } catch (error) {
      res.status(500).json({ count: 0, items: [] });
    }
  });

  // Mark every received e-mail as read (clears the notification bell).
  app.post('/api/emails/mark-all-read', async (req, res) => {
    try {
      await dbRun("UPDATE emails SET seen = 1 WHERE user_id = ? AND direction = 'in' AND seen = 0", [uid()]);
      res.json({ ok: true });
    } catch (error) {
      res.status(500).json({ ok: false });
    }
  });

  // Add a timeline entry (status change with date + comment)
  app.post('/bewerbung/:id/verlauf', async (req, res) => {
    try {
      const { id } = req.params;
      const { datum, status, kommentar } = req.body;

      if (datum && status && status.trim()) {
        await dbRun(
          'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
          [uid(), id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
        );
        await syncCurrentStatus(id);

        // Suggest a calendar entry when an interview was recorded (confirm + click).
        if (caldav.isConfigured() && /vorstellungsgespr/i.test(status)) {
          return res.redirect('/bewerbung/' + id + '?vorschlag=vg&vdatum=' + encodeURIComponent(datum) + '#termine');
        }
      }

      res.redirect('/bewerbung/' + id);
    } catch (error) {
      console.error('Error adding timeline entry:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // --- Application calendar appointments (mirrored to SOGo via CalDAV) ---
  app.post('/bewerbung/:id/termine', async (req, res) => {
    const { id } = req.params;
    try {
      const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
      if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
      if (!caldav.isConfigured()) {
        return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Kalender ist nicht konfiguriert.') + '#termine');
      }
      const b = req.body || {};
      if (!b.datum) {
        return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Bitte ein Datum angeben.') + '#termine');
      }
      const ganztags = b.ganztags === 'on' || b.ganztags === '1' || b.ganztags === 'true';
      const typ = b.typ === 'vorstellungsgespraech' ? 'vorstellungsgespraech' : 'termin';
      const titel = (b.titel || '').trim() || (typ === 'vorstellungsgespraech' ? 'Vorstellungsgespräch' : 'Termin');
      const erinnerung = Math.max(0, parseInt(b.erinnerung_min, 10) || 0);

      let start, ende = null;
      if (ganztags) {
        const [y, mo, d] = String(b.datum).split('-').map(Number);
        start = caldav.wallToUtc(y, mo, d, 0, 0);
      } else {
        start = caldav.localInputToUtc(b.datum, b.von || '09:00');
        if (b.bis) ende = caldav.localInputToUtc(b.datum, b.bis);
      }

      const created = await caldav.createEvent({
        summary: titel, location: b.ort || '', description: b.notiz || '',
        start, end: ende, allDay: ganztags, alarmMin: erinnerung,
      });

      await dbRun(
        `INSERT INTO termine (user_id, bewerbung_id, typ, titel, ort, notiz, start, ende, ganztags, erinnerung_min, caldav_uid, caldav_href, caldav_etag)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        [uid(), id, typ, sanitizeInput(titel), sanitizeInput(b.ort || ''), sanitizeInput(b.notiz || ''),
          start.toISOString(), ende ? ende.toISOString() : null, ganztags ? 1 : 0, erinnerung,
          created.uid, created.href, created.etag]
      );
      res.redirect('/bewerbung/' + id + '?terminok=1#termine');
    } catch (error) {
      console.error('Error creating termin:', error);
      res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent(error.message || 'Termin konnte nicht angelegt werden.') + '#termine');
    }
  });

  app.post('/bewerbung/:id/termine/:tid/delete', async (req, res) => {
    const { id, tid } = req.params;
    try {
      const t = await dbGet('SELECT * FROM termine WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [tid, id, uid()]);
      if (t) {
        await caldav.deleteEvent({ href: t.caldav_href, etag: t.caldav_etag }).catch((e) => console.warn('CalDAV delete:', e.message));
        await dbRun('DELETE FROM termine WHERE id = ? AND user_id = ?', [tid, uid()]);
      }
      res.redirect('/bewerbung/' + id + '#termine');
    } catch (error) {
      console.error('Error deleting termin:', error);
      res.redirect('/bewerbung/' + id + '#termine');
    }
  });

  // Termin bearbeiten: aktualisiert den Kalender-Termin (CalDAV) und das
  // lokale Spiegelbild. Legt einen neuen CalDAV-Eintrag an, falls der alte
  // entfernt wurde (z. B. remote gelöscht).
  app.post('/bewerbung/:id/termine/:tid/edit', 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) return res.redirect('/bewerbung/' + id + '#termine');
      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 evt = {
        summary: titel, location: b.ort || '', description: b.notiz || '',
        start, end: ende, allDay: ganztags, alarmMin: erinnerung,
      };

      let href = t.caldav_href, etag = t.caldav_etag, caldavUid = t.caldav_uid;
      if (href) {
        const upd = await caldav.updateEvent({ href, etag, ...evt }).catch((e) => {
          // 412/404 bedeutet: der entfernte Termin wurde zwischenzeitlich
          // geändert oder gelöscht — dann legen wir ihn neu an.
          if (/HTTP (404|412)/.test(e.message)) return null;
          throw e;
        });
        if (upd) { etag = upd.etag; caldavUid = upd.uid; }
        else { const created = await caldav.createEvent(evt); href = created.href; etag = created.etag; caldavUid = created.uid; }
      } else {
        const created = await caldav.createEvent(evt);
        href = created.href; etag = created.etag; caldavUid = created.uid;
      }

      await dbRun(
        `UPDATE termine SET typ = ?, titel = ?, ort = ?, notiz = ?, start = ?, ende = ?, ganztags = ?,
           erinnerung_min = ?, caldav_uid = ?, caldav_href = ?, caldav_etag = ?, updated_at = CURRENT_TIMESTAMP
         WHERE id = ? AND user_id = ?`,
        [typ, sanitizeInput(titel), sanitizeInput(b.ort || ''), sanitizeInput(b.notiz || ''),
          start.toISOString(), ende ? ende.toISOString() : null, ganztags ? 1 : 0, erinnerung,
          caldavUid, href, etag, tid, uid()]
      );
      res.redirect('/bewerbung/' + id + '?terminok=1#termine');
    } catch (error) {
      console.error('Error editing termin:', error);
      res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent(error.message || 'Termin konnte nicht aktualisiert werden.') + '#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,
        // Set by the upload routes when multer rejected a file (too large, wrong
        // type) — without this the page just came back without the attachment.
        fehler: req.query.fehler ? String(req.query.fehler) : null,
        anhangMaxMb: ANHANG_MAX_MB,
      });
    } 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(mitFehler('/vorlagen', uploadFehlerText(err, ANHANG_MAX_MB)));
        }
        if (!req.file) {
          return res.redirect(mitFehler('/vorlagen', 'Es wurde keine Datei ausgewählt.'));
        }
        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 (?, ?, ?, ?, ?)',
          [uidFromReq(req), name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
        );
        res.redirect('/vorlagen');
      } catch (error) {
        console.error('Error saving attachment:', error);
        res.status(500).send('Serverfehler');
      }
    });
  });

  // Download a static attachment
  app.get('/anlagen/:id/download', async (req, res) => {
    try {
      const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
      if (!a) return res.status(404).send('Anlage nicht gefunden');
      const filePath = containedPath(userStorageDir(basisAnhaengeDir), a.pfad);
      if (!filePath || !fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
      res.download(filePath, displayFilename(a.dateiname));
    } catch (error) {
      console.error('Error downloading attachment:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Delete a static attachment
  app.post('/anlagen/:id/delete', async (req, res) => {
    try {
      const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
      if (a) {
        const fp = containedPath(userStorageDir(basisAnhaengeDir), a.pfad);
        if (fp) fs.promises.unlink(fp).catch(() => {});
        await dbRun('DELETE FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
      }
      res.redirect('/vorlagen');
    } catch (error) {
      console.error('Error deleting attachment:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // ----- Signature (Unterschrift) -----

  // Serve the current signature image (for the preview on the Vorlagen page)
  app.get('/unterschrift', (req, res) => {
    const file = currentSignaturFile();
    if (!file) return res.status(404).send('Keine Unterschrift');
    res.sendFile(file);
  });

  // Upload / replace the signature
  app.post('/unterschrift', (req, res) => {
    uploadSignatur(req, res, (err) => {
      try {
        if (err) {
          console.error('Signature upload error:', err.message);
          return res.redirect(mitFehler('/vorlagen', uploadFehlerText(err, BILD_MAX_MB)));
        }
        if (req.file) {
          // keep only the newly uploaded file (in the user's subdir). Resolve the
          // dir from req.user (not currentUserId()) — this runs in the multer
          // callback where the AsyncLocalStorage user context can be absent.
          const dir = userStorageDirForReq(signaturDir, req);
          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);
          return res.redirect(mitFehler('/vorlagen', uploadFehlerText(err, BILD_MAX_MB)));
        }
        if (req.file) {
          // keep only the newly uploaded file (in the user's subdir). Resolve the
          // dir from req.user — see /unterschrift handler for why (multer callback).
          const dir = userStorageDirForReq(fotoDir, req);
          fs.readdirSync(dir).forEach((f) => {
            if (f !== req.file.filename) fs.promises.unlink(path.join(dir, f)).catch(() => {});
          });
        }
        res.redirect('/vorlagen');
      } catch (error) {
        console.error('Error saving photo:', error);
        res.status(500).send('Serverfehler');
      }
    });
  });

  // Delete the photo
  app.post('/bewerbungsfoto/delete', (req, res) => {
    try {
      const dir = userStorageDir(fotoDir);
      fs.readdirSync(dir).forEach((f) => fs.promises.unlink(path.join(dir, f)).catch(() => {}));
    } catch (e) { /* ignore */ }
    res.redirect('/vorlagen');
  });

  // Download a generated attachment
  app.get('/anhaenge/:id/download', async (req, res) => {
    try {
      const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
      if (!anhang) return res.status(404).send('Anhang nicht gefunden');
      const filePath = containedPath(userStorageDir(anhaengeDir), anhang.pfad);
      if (!filePath || !fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
      // inline=1 opens viewable files (PDF/image) in the browser tab instead of forcing a download.
      if (req.query.inline === '1') return serveInline(res, filePath, anhang.dateiname, anhang.mime);
      res.download(filePath, displayFilename(anhang.dateiname));
    } catch (error) {
      console.error('Error downloading attachment:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Delete a generated attachment
  app.post('/bewerbung/:id/anhaenge/:anhangId/delete', async (req, res) => {
    try {
      const { id, anhangId } = req.params;
      const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
      if (anhang) {
        const filePath = containedPath(userStorageDir(anhaengeDir), anhang.pfad);
        if (filePath) fs.promises.unlink(filePath).catch(() => {});
        await dbRun('DELETE FROM anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]);
      }
      res.redirect('/bewerbung/' + id);
    } catch (error) {
      console.error('Error deleting attachment:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // ----- Private attachments (internal notes) -----

  // Upload a private attachment for an application's internal notes
  app.post('/bewerbung/:id/interne-anhaenge', (req, res) => {
    uploadInterneAnhang(req, res, async (err) => {
      try {
        const { id } = req.params;
        if (err) {
          console.error('Interne-Anhang upload error:', err.message);
          return res.redirect('/bewerbung/' + id);
        }
        if (req.file) {
          const original = req.file.originalname || req.file.filename;
          const name = (req.body.name && req.body.name.trim())
            ? sanitizeInput(req.body.name.trim())
            : sanitizeInput(original.replace(/\.[^.]+$/, ''));
          await dbRun(
            'INSERT INTO interne_anhaenge (user_id, bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?, ?)',
            [uidFromReq(req), id, name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
          );
        }
        res.redirect('/bewerbung/' + id);
      } catch (error) {
        console.error('Error saving internal attachment:', error);
        res.status(500).send('Serverfehler');
      }
    });
  });

  // Download a private attachment (inline=1 opens PDFs/images in the browser)
  app.get('/bewerbung/:id/interne-anhaenge/:anhangId/download', async (req, res) => {
    try {
      const { id, anhangId } = req.params;
      const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
      if (!anhang) return res.status(404).send('Anhang nicht gefunden');
      const filePath = containedPath(userStorageDir(interneAnhaengeDir), anhang.pfad);
      if (!filePath || !fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
      if (req.query.inline === '1') return serveInline(res, filePath, anhang.dateiname, anhang.mime);
      res.download(filePath, displayFilename(anhang.dateiname));
    } catch (error) {
      console.error('Error downloading internal attachment:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Delete a private attachment
  app.post('/bewerbung/:id/interne-anhaenge/:anhangId/delete', async (req, res) => {
    try {
      const { id, anhangId } = req.params;
      const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
      if (anhang) {
        const fp = containedPath(userStorageDir(interneAnhaengeDir), anhang.pfad);
        if (fp) fs.promises.unlink(fp).catch(() => {});
        await dbRun('DELETE FROM interne_anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]);
      }
      res.redirect('/bewerbung/' + id);
    } catch (error) {
      console.error('Error deleting internal attachment:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Start (or re-run) the AI generation for an application. Saves the LLM notes
  // first, removes any previously generated attachments, then generates.
  app.post('/bewerbung/:id/generieren', async (req, res) => {
    try {
      const { id } = req.params;
      const U = uid();
      const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [id, U]);
      if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');

      // Persist the LLM notes the user provided (used as context during generation)
      if (typeof req.body.llm_notizen !== 'undefined') {
        await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ? AND user_id = ?', [req.body.llm_notizen || '', id, U]);
      }

      const anhaengeUserDir = userStorageDir(anhaengeDir);
      const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, U]);
      for (const a of alte) {
        const fp = containedPath(anhaengeUserDir, a.pfad);
        if (fp) fs.promises.unlink(fp).catch(() => {});
      }
      await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, U]);
      await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ? AND user_id = ?", [id, U]);

      // Selected extra attachments (checkbox values); none by default.
      const anlagenIds = [].concat(req.body.anlage || []).map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n));
      // Which documents to generate; nothing ticked = both (the default).
      const dokumente = normalizeDokumente(req.body.dokument);
      await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ? AND user_id = ?', [dokumente.join(','), id, U]);
      runGeneration(id, { anlagenIds, dokumente });
      res.redirect('/bewerbung/' + id);
    } catch (error) {
      console.error('Error generating documents:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // ----- Jobangebote page (list page) -----
  // The offers shown here are ingested by third-party software via the
  // /api/v1/joboffers REST endpoint (POST). The page itself is read-only plus
  // two manual actions: turn an offer into an application draft, or delete it.

  // Count of open offers, consumed by the header badge (no auth — just a number).
  app.get('/jobangebote/anzahl-offen', async (req, res) => {
    try {
      const row = await dbGet("SELECT COUNT(*) as count FROM jobangebote WHERE user_id = ? AND status = 'offen' AND favorit = 0", [uid()]);
      res.json({ count: row ? row.count : 0 });
    } catch (error) {
      res.status(500).json({ count: 0 });
    }
  });

  // Zähler für die Tab-Leiste der Jobangebote-Seiten (offen / gemerkt /
  // übernommen). Die Favoriten zählen über alle Status hinweg.
  async function jobangeboteZaehler() {
    const row = await dbGet(
      `SELECT
         COUNT(*) FILTER (WHERE status = 'offen' AND favorit = 0) AS offen,
         COUNT(*) FILTER (WHERE status = 'uebernommen')            AS uebernommen,
         COUNT(*) FILTER (WHERE favorit = 1)                     AS favoriten
       FROM jobangebote WHERE user_id = ?`,
      [uid()]
    );
    return {
      offenCount: row ? row.offen : 0,
      uebernommenCount: row ? row.uebernommen : 0,
      favoritenCount: row ? row.favoriten : 0,
    };
  }

  // Main list: only OPEN offers (still to be decided on). Taken-over offers
  // live on their own page (/jobangebote/uebernommen). Gemerkte (favorisierte)
  // Angebote erscheinen ausschließlich im eigenen Tab (/jobangebote/favoriten)
  // und werden hier ausgeblendet.
  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' AND j.favorit = 0
         ORDER BY j.created_at DESC, j.id DESC`,
        [uid()]
      );
      jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
      const zaehler = await jobangeboteZaehler();
      res.render('jobangebote', {
        jobangebote,
        ...zaehler,
        ansicht: 'offen',
        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');
    }
  });

  // Gemerkte Angebote — quer über alle Status (offen, übernommen, abgelehnt).
  // Nutzt dieselbe Ansicht wie die offene Liste, nur mit anderer Auswahl.
  app.get('/jobangebote/favoriten', 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.favorit = 1
         ORDER BY j.updated_at DESC, j.id DESC`,
        [uid()]
      );
      jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
      const zaehler = await jobangeboteZaehler();
      res.render('jobangebote', {
        jobangebote,
        ...zaehler,
        ansicht: 'favoriten',
        artOptions: ART_OPTIONS,
        statusOptions: STATUS_OPTIONS,
        labelOptions: LABEL_OPTIONS,
        hideSettings: false,
      });
    } catch (error) {
      console.error('Error listing favorite 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.favorit DESC, j.updated_at DESC, j.id DESC`,
        [uid()]
      );
      uebernommen.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
      const zaehler = await jobangeboteZaehler();
      res.render('jobangebote_uebernommen', {
        uebernommen,
        ...zaehler,
        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');
    }
  });

  // Nach Bearbeiten/Löschen zurück auf den Tab, von dem die Aktion kam
  // (Formularfeld `zurueck`) — sonst auf die offene Liste.
  function jobangeboteZiel(req) {
    return (req.body && req.body.zurueck === 'favoriten') ? '/jobangebote/favoriten' : '/jobangebote';
  }

  // 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(jobangeboteZiel(req));
    } 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(jobangeboteZiel(req));
    } catch (error) {
      console.error('Error deleting job offer:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Jobangebot merken/entmerken (Stern). Toggle, antwortet mit dem neuen Zustand
  // — der Stern-Button in partials/favorit-button spricht diesen Endpunkt an.
  // Getrennt von den Bewerbungs-Favoriten: das Merken eines Angebots berührt
  // eine daraus entstandene Bewerbung nicht.
  app.post('/jobangebote/:id/favorit', async (req, res) => {
    try {
      const angebot = await dbGet('SELECT favorit FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
      if (!angebot) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
      const neu = angebot.favorit ? 0 : 1;
      await dbRun('UPDATE jobangebote SET favorit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [neu, req.params.id, uid()]);
      res.json({ success: true, favorit: neu });
    } catch (error) {
      console.error('Error toggling job offer favorite:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // Bewerbung als Favorit merken/entmerken — unabhängig vom Bewerbungs-Status.
  app.post('/api/bewerbungen/:id/favorit', async (req, res) => {
    try {
      const bewerbung = await dbGet('SELECT favorit FROM bewerbungen WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
      if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
      const neu = bewerbung.favorit ? 0 : 1;
      await dbRun('UPDATE bewerbungen SET favorit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [neu, req.params.id, uid()]);
      res.json({ success: true, favorit: neu });
    } catch (error) {
      console.error('Error toggling bewerbung favorite:', error);
      res.status(500).json({ error: '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', async (req, res) => {
    try {
      // Optionen, die erst zur Laufzeit feststehen (Feld-Definition: optionsFrom).
      const anlagen = await dbAll(
        'SELECT id, name, dateiname FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC',
        [uid()]
      );
      res.render('settings', {
        felder: config.FIELDS,
        werte: config.getAll(),
        auswahllisten: {
          anlagen: anlagen.map((a) => ({ value: String(a.id), label: a.name || a.dateiname })),
        },
        hideSettings: true,
      });
    } catch (error) {
      console.error('Error loading settings:', error);
      res.status(500).send('Serverfehler');
    }
  });

  app.post('/einstellungen', async (req, res) => {
    try {
      const werte = {};
      for (const sektion of config.FIELDS) {
        for (const f of sektion.items) {
          const roh = req.body[f.key];
          if (f.type === 'checkboxes') {
            // Checkbox-Gruppe: Express liefert je nach Anzahl Häkchen einen String
            // oder ein Array (inkl. des leeren Hidden-Werts) — als Komma-Liste ablegen.
            werte[f.key] = []
              .concat(roh == null ? [] : roh)
              .map((v) => String(v).trim())
              .filter(Boolean)
              .join(',');
          } else {
            werte[f.key] = roh != null ? String(roh) : '';
          }
        }
      }

      // The API token identifies the user to /api/v1, so it must not collide with
      // another user's: the auth lookup would otherwise be ambiguous (it now fails
      // closed, which would lock *both* users out). Reject the save instead.
      const token = (werte.API_TOKEN || '').trim();
      if (token) {
        const fremd = await dbGet(
          `SELECT u.username FROM app_state a JOIN users u ON u.id = a.user_id
            WHERE a.key = ? AND a.value = ? AND a.user_id != ?`,
          [config.PREFIX + 'API_TOKEN', token, uid()]
        );
        if (fremd) {
          return res.status(409).send('Dieser API-Token ist bereits von einem anderen Benutzer belegt. Bitte „Neu generieren“ verwenden.');
        }
      }

      // SSRF guard: the user can configure external hosts the server will call
      // (Ollama, CalDAV, mail). Validate them against a metadata/link-local
      // blocklist so a user cannot point the server at a cloud metadata endpoint
      // to exfiltrate instance credentials. Localhost / LAN stays allowed on
      // purpose (the app supports a local Ollama or on-prem CalDAV).
      try {
        if ((werte.OLLAMA_HOST || '').trim()) await assertSafeUrl(werte.OLLAMA_HOST.trim());
        if ((werte.CALDAV_URL || '').trim()) await assertSafeUrl(werte.CALDAV_URL.trim());
        if ((werte.MAIL_HOST || '').trim()) await assertSafeHost(werte.MAIL_HOST.trim());
      } catch (vErr) {
        return res.status(400).send('Einstellung abgelehnt: ' + (vErr.message || 'ungültiger Host'));
      }

      await config.saveAll(werte);
      res.redirect('/einstellungen');
    } catch (error) {
      console.error('Error saving settings:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // ----- Jobsuche: Suchprofil + Suchläufe -----
  // The profile steers *where* to search; the roles/technologies come from the
  // user's own Lebenslauf at run time. Runs are only queued here — the host-side
  // runner executes them (see lib/suchprofil for why).
  //
  // An admin may manage another user's profile via ?user=<id> / the hidden
  // `ziel_user` field; everyone else is always confined to their own.
  function resolveZielUser(req) {
    const roh = req.query.user != null ? req.query.user : (req.body || {}).ziel_user;
    const id = Number(roh);
    if (!roh || Number.isNaN(id) || id === Number(req.user.id)) return { id: Number(req.user.id), fremd: false };
    if (!req.user.is_admin) return null;         // nur Admins dürfen fremde Profile sehen/ändern
    return { id, fremd: true };
  }

  app.get('/jobsuche', async (req, res) => {
    try {
      const ziel = resolveZielUser(req);
      if (!ziel) return res.status(403).send('Zugriff verweigert – nur für Administratoren.');
      const zielUser = await dbGet('SELECT id, username FROM users WHERE id = ?', [ziel.id]);
      if (!zielUser) return res.status(404).send('Benutzer nicht gefunden.');

      const profil = await loadSuchprofil(ziel.id);
      const laeufe = await dbAll(
        'SELECT * FROM suchlaeufe WHERE user_id = ? ORDER BY id DESC LIMIT 10',
        [ziel.id]
      );
      // Warn up front instead of letting a run fail in the background: without a
      // Lebenslauf there is no profile to derive roles from, and without an Ollama
      // key the run has no model to talk to (both are per-user now).
      const lebenslauf = await dbGet(
        "SELECT id FROM basis_dokumente WHERE user_id = ? AND typ = 'Lebenslauf' AND inhalt IS NOT NULL AND inhalt != '' LIMIT 1",
        [ziel.id]
      );
      // Der KI-Feinschliff liest ALLE Basis-Unterlagen, nicht nur den Lebenslauf —
      // ein Kurzprofil allein reicht ihm schon.
      const unterlagen = await dbGet(
        "SELECT id FROM basis_dokumente WHERE user_id = ? AND inhalt IS NOT NULL AND inhalt != '' LIMIT 1",
        [ziel.id]
      );
      const ollamaKey = await dbGet(
        "SELECT value FROM app_state WHERE user_id = ? AND key = 'cfg:OLLAMA_API_KEY' AND value != ''",
        [ziel.id]
      );

      res.render('jobsuche', {
        profil,
        laeufe,
        zielUser,
        fremd: ziel.fremd,
        modi: suchprofil.MODI,
        wochentage: suchprofil.WOCHENTAGE,
        maxStaedte: suchprofil.MAX_STAEDTE,
        hatLebenslauf: Boolean(lebenslauf),
        hatUnterlagen: Boolean(unterlagen),
        hatOllamaKey: Boolean(ollamaKey),
        fehler: req.query.fehler ? String(req.query.fehler) : null,
        hinweis: req.query.hinweis ? String(req.query.hinweis) : null,
        hideSettings: false,
      });
    } catch (error) {
      console.error('Error loading jobsuche:', error);
      res.status(500).send('Serverfehler');
    }
  });

  app.post('/jobsuche', async (req, res) => {
    try {
      const ziel = resolveZielUser(req);
      if (!ziel) return res.status(403).send('Zugriff verweigert – nur für Administratoren.');
      const suffix = ziel.fremd ? `?user=${ziel.id}` : '';

      const profil = suchprofil.fromForm(req.body, sanitizeInput);
      const fehler = suchprofil.validate(profil);
      if (fehler.length) {
        return res.redirect(`/jobsuche${suffix}${suffix ? '&' : '?'}fehler=${encodeURIComponent(fehler.join(' '))}`);
      }
      await saveSuchprofil(ziel.id, profil);
      res.redirect(`/jobsuche${suffix}${suffix ? '&' : '?'}hinweis=${encodeURIComponent('Suchprofil gespeichert.')}`);
    } catch (error) {
      console.error('Error saving suchprofil:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // KI-Vorschlag für den Feinschliff (Zusatzbegriffe / Ausschlüsse), abgeleitet
  // aus den Basis-Unterlagen des Zielbenutzers. Der Vorschlag wird nur
  // zurückgegeben, nicht gespeichert — der Nutzer prüft ihn im Formular und
  // speichert selbst. Anders als der Suchlauf läuft das direkt hier: es ist ein
  // einzelner Ollama-Aufruf, kein Web-Rechercheauftrag für den Host-Runner.
  app.post('/jobsuche/feinschliff', async (req, res) => {
    try {
      const ziel = resolveZielUser(req);
      if (!ziel) return res.status(403).json({ error: 'Zugriff verweigert – nur für Administratoren.' });

      const unterlagen = await dbAll(
        `SELECT typ, name, inhalt FROM basis_dokumente
          WHERE user_id = ? AND inhalt IS NOT NULL AND inhalt != ''
          ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 WHEN 'Profil/Kurzprofil' THEN 1 WHEN 'Anschreiben' THEN 2 ELSE 3 END, id ASC`,
        [ziel.id]
      );
      if (!unterlagen.length) {
        return res.status(400).json({
          error: 'Keine Basis-Unterlagen hinterlegt – lege zuerst unter „Vorlagen“ einen Lebenslauf an.',
        });
      }

      // Modus/Städte kommen aus dem Formular (ggf. ungespeichert), der Rest ist
      // für den Vorschlag irrelevant.
      const profil = suchprofil.fromForm({ ...req.body, aktiv: '0' }, sanitizeInput);
      const text = unterlagen
        .map((d) => `## ${d.typ}${d.name ? ` – ${d.name}` : ''}\n${String(d.inhalt).trim()}`)
        .join('\n\n');

      const vorschlag = await generateFeinschliff({
        unterlagen: text,
        profil,
        prompts: await loadPrompts(),
      });
      res.json(vorschlag);
    } catch (error) {
      console.error('Error generating Feinschliff:', error);
      res.status(500).json({ error: error.message || 'Serverfehler' });
    }
  });

  // "Jetzt suchen": queue a run. Picked up by the host runner within a few minutes.
  app.post('/jobsuche/start', async (req, res) => {
    try {
      const ziel = resolveZielUser(req);
      if (!ziel) return res.status(403).send('Zugriff verweigert – nur für Administratoren.');
      const suffix = ziel.fremd ? `?user=${ziel.id}` : '';
      const sep = suffix ? '&' : '?';

      const profil = await loadSuchprofil(ziel.id);
      const fehler = suchprofil.validate(profil);
      if (fehler.length) {
        return res.redirect(`/jobsuche${suffix}${sep}fehler=${encodeURIComponent(fehler.join(' '))}`);
      }
      const { neu } = await queueSuchlauf(ziel.id, 'manuell');
      const msg = neu
        ? 'Suchlauf angefordert – er startet innerhalb weniger Minuten.'
        : 'Es läuft bereits ein Suchlauf – es wurde kein zweiter gestartet.';
      res.redirect(`/jobsuche${suffix}${sep}hinweis=${encodeURIComponent(msg)}`);
    } catch (error) {
      console.error('Error queueing suchlauf:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Poll target for the page (shows a run moving from angefordert -> läuft -> fertig).
  app.get('/jobsuche/laeufe', async (req, res) => {
    try {
      const ziel = resolveZielUser(req);
      if (!ziel) return res.status(403).json({ error: 'Zugriff verweigert' });
      const laeufe = await dbAll(
        'SELECT * FROM suchlaeufe WHERE user_id = ? ORDER BY id DESC LIMIT 10',
        [ziel.id]
      );
      res.json(laeufe);
    } catch (error) {
      console.error('Error listing suchlaeufe:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // ----- Admin: Benutzerverwaltung (nur für Admins) -----
  // Admins legen neue Benutzer an, setzen Passwörter zurück und löschen
  // Benutzer. Beim Löschen eines Benutzers löscht die DB per ON DELETE CASCADE
  // alle seine Daten (Bewerbungen, E-Mails, Termine, Chat, Dateien liegen in
  // data/<dir>/<userId>/ und müssen separat entfernt werden — siehe unten).
  function requireAdmin(req, res, next) {
    // Admin rights are suspended while impersonating another user: the admin is
    // reviewing that user's account, not exercising admin powers. This also
    // keeps the impersonated session from reaching admin endpoints at all.
    if (req.user && req.user.is_admin && !req.impersonator) return next();
    if (req.path.startsWith('/admin/') || req.xhr || (req.get('accept') || '').includes('application/json')) {
      return res.status(403).send('Zugriff verweigert – nur für Administratoren.');
    }
    res.status(403).send('Zugriff verweigert – nur für Administratoren.');
  }

  app.get('/admin', requireAdmin, async (req, res) => {
    try {
      const users = await dbAll(
        'SELECT id, username, is_admin, created_at, (SELECT COUNT(*) FROM bewerbungen b WHERE b.user_id = users.id) AS anzahl_bewerbungen FROM users ORDER BY is_admin DESC, id ASC'
      );
      res.render('admin', { users, currentUserId: uid(), hideSettings: false });
    } catch (error) {
      console.error('Admin list error:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Neuen Benutzer anlegen.
  app.post('/admin/users', requireAdmin, async (req, res) => {
    try {
      const username = sanitizeInput((req.body.username || '').trim());
      const plain = req.body.password || '';
      const isAdmin = req.body.is_admin === '1' || req.body.is_admin === 'on';
      if (!username || !plain) return res.status(400).send('Benutzername und Passwort erforderlich.');
      if (username.length > 64) return res.status(400).send('Benutzername zu lang.');
      const dup = await dbGet('SELECT id FROM users WHERE username = ?', [username]);
      if (dup) return res.status(409).send('Benutzername bereits vergeben.');
      await dbRun('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)',
        [username, password.hash(plain), isAdmin ? 1 : 0]);
      res.redirect('/admin');
    } catch (error) {
      console.error('Admin create user error:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Passwort zurücksetzen.
  app.post('/admin/users/:id/reset-password', requireAdmin, async (req, res) => {
    try {
      const id = Number(req.params.id);
      const plain = req.body.password || '';
      if (!plain) return res.status(400).send('Passwort erforderlich.');
      await dbRun('UPDATE users SET password_hash = ? WHERE id = ?', [password.hash(plain), id]);
      await dbRun('DELETE FROM sessions WHERE user_id = ?', [id]); // alle Sessions des Benutzers ungültigen
      res.redirect('/admin');
    } catch (error) {
      console.error('Admin reset password error:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Benutzer löschen (mit allen Daten + Dateien). Der letzte Admin darf nicht
  // gelöscht werden, sonst sperrt man sich selbst aus.
  app.post('/admin/users/:id/delete', requireAdmin, async (req, res) => {
    try {
      const id = Number(req.params.id);
      if (id === Number(req.user.id)) return res.status(400).send('Man kann sich nicht selbst löschen.');
      const target = await dbGet('SELECT is_admin FROM users WHERE id = ?', [id]);
      if (!target) return res.status(404).send('Benutzer nicht gefunden.');
      if (target.is_admin) {
        const adminCount = await dbGet('SELECT COUNT(*) as count FROM users WHERE is_admin = 1');
        if (adminCount && adminCount.count <= 1) return res.status(400).send('Der letzte Admin darf nicht gelöscht werden.');
      }
      // Dateien des Benutzers auf der Festplatte entfernen (die DB-Zeilen löscht
      // ON DELETE CASCADE).
      for (const dir of migrate.STORAGE_DIRS) {
        const base = path.join(dataDir, dir, String(id));
        if (fs.existsSync(base)) fs.rmSync(base, { recursive: true, force: true });
      }
      await dbRun('DELETE FROM users WHERE id = ?', [id]);
      res.redirect('/admin');
    } catch (error) {
      console.error('Admin delete user error:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // --- Impersonation: admin "logs in as" a user --------------------------
  // Best practice (Django/Flask-impersonate style): the admin's own session
  // keeps its cookie token, but its user_id switches to the target and the
  // original admin is preserved in sessions.impersonator_id. Everything the app
  // does (DB queries via uid(), per-user config, file dirs) then runs as the
  // target — the admin sees exactly the user's account. Admin rights are
  // suspended while impersonating (requireAdmin), and a banner + "switch back"
  // action are always one click away. Every start/stop is recorded in audit_log.

  async function auditLog(actorId, targetId, action) {
    await dbRun(
      'INSERT INTO audit_log (actor_user_id, target_user_id, action) VALUES (?, ?, ?)',
      [actorId || null, targetId || null, action]
    ).catch((e) => console.error('audit_log write failed:', e.message));
  }

  // Start impersonating a user. Admin-only, and never while already
  // impersonating (no nesting — switch back first).
  app.post('/admin/users/:id/impersonate', requireAdmin, async (req, res) => {
    try {
      const targetId = Number(req.params.id);
      if (!targetId || targetId === Number(req.user.id)) {
        return res.status(400).send('Man kann sich nicht selbst imitieren.');
      }
      const target = await dbGet('SELECT id, username FROM users WHERE id = ?', [targetId]);
      if (!target) return res.status(404).send('Benutzer nicht gefunden.');
      const token = req.sessionToken;
      if (!token) return res.status(400).send('Keine Sitzung.');
      await dbRun(
        'UPDATE sessions SET user_id = ?, impersonator_id = ?, last_seen = CURRENT_TIMESTAMP WHERE token = ?',
        [target.id, req.user.id, token]
      );
      await auditLog(req.user.id, target.id, `impersonate_start → ${target.username}`);
      console.log(`Impersonation: Admin #${req.user.id} (${req.user.username}) → User #${target.id} (${target.username})`);
      res.redirect('/');
    } catch (error) {
      console.error('Admin impersonate error:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Switch back to the original admin. This is intentionally NOT requireAdmin:
  // it runs while impersonating, when req.user is the target (non-admin). The
  // guard is the session's impersonator_id — only an actual impersonation can
  // stop one, so a normal user session (impersonator_id NULL) cannot use it.
  app.post('/admin/impersonate/stop', async (req, res) => {
    try {
      const token = req.sessionToken;
      if (!token) return res.redirect('/');
      const row = await dbGet(
        'SELECT user_id, impersonator_id FROM sessions WHERE token = ?',
        [token]
      );
      if (!row || !row.impersonator_id) {
        return res.status(403).send('Keine aktive Impersonation.');
      }
      const adminId = row.impersonator_id;
      const targetId = row.user_id;
      await dbRun(
        'UPDATE sessions SET user_id = ?, impersonator_id = NULL, last_seen = CURRENT_TIMESTAMP WHERE token = ?',
        [adminId, token]
      );
      await auditLog(adminId, targetId, 'impersonate_stop');
      console.log(`Impersonation: Admin #${adminId} switched back from User #${targetId}`);
      res.redirect('/admin');
    } catch (error) {
      console.error('Admin impersonate stop error:', error);
      res.status(500).send('Serverfehler');
    }
  });

  // Recent impersonation audit entries for the admin dashboard.
  app.get('/admin/audit/impersonations', requireAdmin, async (req, res) => {
    try {
      const rows = await dbAll(
        `SELECT a.action, a.created_at,
                au.username AS actor, tu.username AS target
           FROM audit_log a
           LEFT JOIN users au ON au.id = a.actor_user_id
           LEFT JOIN users tu ON tu.id = a.target_user_id
          WHERE a.action LIKE 'impersonate_%'
          ORDER BY a.created_at DESC
          LIMIT 25`
      );
      res.json({ items: rows });
    } catch (error) {
      console.error('Audit list error:', error);
      res.status(500).json({ error: 'Serverfehler' });
    }
  });

  // ----- Conversational KI-Chat (Ollama, streaming) -----
  // Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the
  // assistant answer is streamed back via Server-Sent Events.
  async function gatherChatContext() {
    const [settings, profilRows, prompts] = await Promise.all([
      loadSettings(),
      dbAll(
        `SELECT inhalt FROM basis_dokumente
          WHERE user_id = ? AND typ IN ('Lebenslauf', 'Profil/Kurzprofil') AND inhalt IS NOT NULL AND inhalt != ''
          ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END`,
        [uid()]
      ),
      loadPrompts(),
    ]);
    // Lightweight core context only: name, date and the user's profile (static,
    // small). All application/appointment data is fetched on demand via tools,
    // so the system prompt stays tiny regardless of how many bewerbungen exist.
    const profil = (profilRows.map((r) => (r.inhalt || '').trim()).join('\n\n---\n\n')).slice(0, 1800);
    const heute = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
    return { heute, profil, prompts, settings };
  }

  // Ollama tool definitions the assistant can call to look up application data.
  const CHAT_TOOLS = [
    {
      type: 'function',
      function: {
        name: 'suche_bewerbungen',
        description: 'Volltextsuche über Bewerbungen UND ihre E-Mail-Korrespondenz: Firma, Stelle, Notizen, Stellenbeschreibung, Absender/Empfänger, Betreff und Mailtext. Nutze dies auch, wenn der Nutzer nur einen Namen einer Ansprechpartnerin, einen Betreff oder eine Formulierung aus einer E-Mail nennt. Liefert je Treffer die Bewerbung samt Fundstelle (wo der Begriff steht) — hole Details mit bewerbung_detail.',
        parameters: {
          type: 'object',
          properties: {
            query: { type: 'string', description: 'Suchbegriff (mind. 2 Zeichen). Mehrere Wörter werden UND-verknüpft; "in Anführungszeichen" sucht die Wortfolge, feld:wert grenzt ein (firma:, stelle:, von:, betreff:), -wort schließt aus.' },
          },
          required: ['query'],
        },
      },
    },
    {
      type: 'function',
      function: {
        name: 'list_bewerbungen',
        description: 'Listet Bewerbungen auf, standardmäßig die jüngsten. Optional nach Status gefiltert. Für einen Überblick über alle laufenden/abgeschlossenen Bewerbungen.',
        parameters: {
          type: 'object',
          properties: {
            status: { type: 'string', description: 'Optional: nur Bewerbungen mit diesem Status (z. B. offen, absage, eingeladen)' },
            limit: { type: 'integer', description: 'Max. Anzahl Treffer (Standard 20, max 40)' },
          },
        },
      },
    },
    {
      type: 'function',
      function: {
        name: 'bewerbung_detail',
        description: 'Liefert volle Details zu einer Bewerbung: Stellenbeschreibung, Notizen, interne Notizen, Kontakt, Quell-URL und die letzten Korrespondenz-Betreffe. Setze die id aus suche_bewerbungen/list_bewerbungen voraus.',
        parameters: {
          type: 'object',
          properties: { id: { type: 'integer', description: 'Bewerbungs-ID' } },
          required: ['id'],
        },
      },
    },
    {
      type: 'function',
      function: {
        name: 'kommende_termine',
        description: 'Liefert die nächsten Termine (Gespräche, Fristen) mit Titel, Startzeit (UTC-ISO), verknüpfter Bewerbung.',
        parameters: { type: 'object', properties: {} },
      },
    },
    {
      type: 'function',
      function: {
        name: 'web_suche',
        description: 'Sucht im Internet und liefert Treffer mit Titel, URL und Auszug. Nutze dies für alles, was NICHT in der Bewerbungsdatenbank steht: Hintergrund zu einer Firma (Größe, Produkte, Standorte, Kultur, aktuelle News, Geschäftsberichte, Bewertungen), Gehaltsspannen, Branchen- oder Technologiefragen, Vorbereitung auf ein Vorstellungsgespräch. Hole dir Firmenname/Stelle zuerst per bewerbung_detail aus der Datenbank und suche dann gezielt danach.',
        parameters: {
          type: 'object',
          properties: {
            query: { type: 'string', description: 'Suchanfrage in natürlicher Sprache, möglichst konkret (z. B. „Muster GmbH München Mitarbeiterzahl Produkte 2026").' },
            max_results: { type: 'integer', description: 'Anzahl Treffer (Standard 5, max 10).' },
          },
          required: ['query'],
        },
      },
    },
    {
      type: 'function',
      function: {
        name: 'web_seite_lesen',
        description: 'Lädt eine konkrete Webseite und liefert ihren Text. Nutze dies, wenn ein Treffer aus web_suche vertieft werden soll (z. B. die „Über uns"-/Karriereseite der Firma) oder wenn der Nutzer bzw. die Bewerbung eine URL nennt (quelle_url der Stellenanzeige).',
        parameters: {
          type: 'object',
          properties: {
            url: { type: 'string', description: 'Vollständige URL inkl. https://' },
          },
          required: ['url'],
        },
      },
    },
  ];

  // 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…',
    web_suche: 'Im Internet wird recherchiert…',
    web_seite_lesen: 'Webseite wird gelesen…',
  };

  // Bei der Web-Recherche zeigt das Label mit an, wonach gesucht bzw. was geladen
  // wird — sonst steht der Nutzer vor einem stummen „recherchiert…", ohne zu
  // sehen, ob der Assistent die richtige Firma erwischt hat.
  function chatToolLabel(name, args) {
    const basis = CHAT_TOOL_LABELS[name] || name;
    const a = args || {};
    if (name === 'web_suche' && a.query) return `Im Internet wird recherchiert: „${String(a.query).slice(0, 80)}"`;
    if (name === 'web_seite_lesen' && a.url) {
      try { return `Webseite wird gelesen: ${new URL(String(a.url)).hostname}`; } catch (e) { /* Label ohne Host */ }
    }
    return basis;
  }

  // Execute one tool call against the database. Returns a JSON-serialisable
  // value that is fed back to the model as the tool result.
  async function executeChatTool(name, args) {
    const a = args || {};
    if (name === 'suche_bewerbungen') {
      const q = String(a.query || '').trim();
      if (q.length < 2) return { treffer: [], hinweis: 'Suchbegriff zu kurz' };
      // Dieselbe Volltextsuche wie auf der Startseite (lib/suche.js) — der Assistent
      // findet eine Bewerbung dadurch auch über einen Namen aus der Korrespondenz.
      const treffer = await suche.suche(dbAll, uid(), q, 20);
      return {
        treffer: treffer.map((t) => ({
          id: t.bewerbung_id,
          ...(t.bewerbung || {}),
          // Wo der Begriff steht, ohne Markierungszeichen — die versteht das Modell nicht.
          fundstellen: t.fundstellen.map((f) => ({
            wo: f.quelle === 'email'
              ? `E-Mail${f.email && f.email.betreff ? ` „${f.email.betreff}"` : ''}${f.email && f.email.von ? ` von ${f.email.von}` : ''}`
              : f.feld_label,
            auszug: suche.ohneMarker(f.snippet),
          })),
        })),
      };
    }
    if (name === 'list_bewerbungen') {
      const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 40);
      const status = String(a.status || '').trim();
      const sql = `SELECT id, firma, stelle, status, datum, ort FROM bewerbungen
        WHERE user_id = ?${status ? ' AND status = ?' : ''} ORDER BY datum DESC, created_at DESC LIMIT ?`;
      const rows = await dbAll(sql, status ? [uid(), status, limit] : [uid(), limit]);
      return { bewerbungen: rows };
    }
    if (name === 'bewerbung_detail') {
      const id = Number(a.id);
      if (!id) return { error: 'keine id' };
      const row = await dbGet(
        `SELECT b.id, b.firma, b.stelle, b.status, b.datum, b.ort, b.notizen,
                b.interne_notizen, b.stellenbeschreibung, b.quelle_url,
                j.kontakt_email AS ja_kontakt, j.ansprechpartner AS ja_ansprech, j.beschreibung AS ja_beschreibung
           FROM bewerbungen b
           LEFT JOIN jobangebote j ON j.verknuepfte_bewerbung_id = b.id AND j.user_id = b.user_id
          WHERE b.id = ? AND b.user_id = ?`,
        [id, uid()]
      );
      if (!row) return { error: 'nicht gefunden' };
      const em = await dbAll(
        `SELECT direction, subject, from_addr FROM emails WHERE bewerbung_id = ? AND user_id = ?
          ORDER BY email_date DESC, created_at DESC LIMIT 8`,
        [id, uid()]
      );
      return {
        id: row.id, firma: row.firma, stelle: row.stelle, status: row.status, datum: row.datum, ort: row.ort,
        quelle_url: row.quelle_url,
        kontakt_email: row.ja_kontakt || null,
        ansprechpartner: row.ja_ansprech || null,
        notizen: (row.notizen || '').trim(),
        interne_notizen: (row.interne_notizen || '').trim(),
        stellenbeschreibung: (row.stellenbeschreibung || row.ja_beschreibung || '').trim().slice(0, 1200),
        korrespondenz: em.map((e) => ({
          direction: e.direction, subject: e.subject, von: e.from_addr,
        })),
      };
    }
    if (name === 'kommende_termine') {
      const rows = await upcomingTermine(10);
      return {
        termine: rows.map((t) => ({
          titel: t.titel, start: t.start,
          bewerbung_id: t.bewerbung_id,
          bewerbung: t.bewerbung_firma || null,
        })),
      };
    }
    // Web-Recherche (Ollama Web Search) — Fehler werden als Tool-Ergebnis
    // zurückgegeben (runChat fängt sie ohnehin ab), damit das Modell einen
    // Ausfall der Websuche dem Nutzer sagen kann, statt den Chat abzubrechen.
    if (name === 'web_suche') {
      return websuche.suche(a.query, a.max_results);
    }
    if (name === 'web_seite_lesen') {
      return websuche.seiteLesen(a.url);
    }
    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()]);
      // Vorläufiger Titel aus der ersten Nachricht, damit in der Seitenleiste
      // sofort etwas steht. Sobald die Antwort durch ist, formuliert das Modell
      // daraus einen richtigen Titel (siehe unten).
      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, args) => send({ type: 'tool', name, label: chatToolLabel(name, args) }),
        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();
      return;
    }

    // Titel vom Modell formulieren lassen — erst NACH dem 'done', damit die
    // Antwort nicht auf den Zusatzaufruf warten muss; der Titel kommt als
    // eigenes Event nach und die Seitenleiste zieht nach.
    //
    // Nur beim ersten Schlagabtausch (history = genau die eine Nutzernachricht)
    // und nur, wenn der Thread vorher keinen Titel hatte — sonst würde ein
    // selbst vergebener Name wieder überschrieben.
    const ersterSchlagabtausch = history.length === 1;
    if (!aborted && !thread.titel && ersterSchlagabtausch && assistantText) {
      try {
        const titel = await chat.generateTitle({
          userText, assistantText, signal: controller.signal,
        });
        if (titel && !aborted) {
          await dbRun(
            'UPDATE chat_threads SET titel = ? WHERE id = ? AND user_id = ?',
            [sanitizeInput(titel).slice(0, 120), threadId, uid()]
          );
          send({ type: 'titel', titel });
        }
      } catch (e) {
        // Der Fallback-Titel aus der ersten Nachricht steht bereits — nichts tun.
      }
    }
    res.end();
  });

  // ----- Third-party REST API (/api/v1) + OpenAPI/Swagger -----
  // Per-user API key: each user may set their own API_TOKEN on /einstellungen.
  // The X-API-Key header resolves to the owning user, and every request then
  // operates only on that user's data (see lib/api.js).
  const apiToken = () => config.get('API_TOKEN') || '';
  app.use('/api/v1', createExternalApi({
    dbGet,
    dbAll,
    dbRun,
    sanitizeInput,
    attachVerlauf,
    findDuplicateApplications,
    syncCurrentStatus,
    runGeneration,
    anhaengeDir,
    emailAnhaengeDir,
    userStorageDir,
    apiToken,
  }));

  // Serve the OpenAPI document, with the real request host injected as server.
  app.get('/swagger.json', (req, res) => {
    const proto = req.get('x-forwarded-proto') || req.protocol;
    const host = req.get('host') || `localhost:${PORT}`;
    res.json(buildOpenApiSpec(`${proto}://${host}`));
  });

  // Swagger UI (loaded from CDN; consistent with the app's other CDN usage).
  app.get('/swagger', (req, res) => {
    const proto = req.get('x-forwarded-proto') || req.protocol;
    const host = req.get('host') || `localhost:${PORT}`;
    const specUrl = `${proto}://${host}/swagger.json`;
    res.type('text/html').send(`<!DOCTYPE html>
<html lang="de">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>NextJobs – API-Dokumentation</title>
  <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
  <style>
    html { box-sizing: border-box; overflow-y: scroll; }
    *, *::before, *::after { box-sizing: inherit; }
    body { margin: 0; background: #fafafa; }
    .topbar { display:flex; align-items:center; gap:12px; padding:10px 16px;
              background:#1f2937; color:#fff; font-family:system-ui,sans-serif; }
    .topbar a { color:#93c5fd; text-decoration:none; font-weight:600; }
  </style>
</head>
<body>
  <div class="topbar">
    <strong>NextJobs REST-API</strong>
    <span style="opacity:.7">Drittanbieter-Schnittstelle v1</span>
    <span style="margin-left:auto">Authentifizierung: Header <code>X-API-Key</code></span>
    <a href="/">← zur App</a>
  </div>
  <div id="swagger-ui"></div>
  <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" charset="UTF-8"></script>
  <script>
    window.onload = () => {
      window.ui = SwaggerUIBundle({
        url: ${JSON.stringify(specUrl)},
        dom_id: '#swagger-ui',
        deepLinking: true,
        persistAuthorization: true,
      });
    };
  </script>
</body>
</html>`);
  });
  app.get('/api-docs', (req, res) => res.redirect(301, '/swagger'));

  // Start server
  app.listen(PORT, () => {
    console.log(`Server läuft auf http://localhost:${PORT}`);
    console.log('REST-API (/api/v1): pro Benutzer über X-API-Key (Token in den Einstellungen je Benutzer gesetzt) – Swagger unter /swagger');
  });

  // Background loops are per-user: every user owns their own mail/calendar
  // config, so polling iterates all users and runs each user's poll inside
  // that user's context (config.get() then resolves to the user's own values).
  async function forEachUser(fn) {
    const users = await dbAll('SELECT id, username, is_admin FROM users ORDER BY id');
    for (const u of users) {
      try { await config.ensureLoaded(u.id); } catch (e) { continue; }
      try { await userContext.run(u, fn); } catch (e) { /* errors logged inside fn */ }
    }
  }

  async function pollInboxAllUsers() {
    await forEachUser(async () => {
      try { await pollInbox(); } catch (e) { /* logged inside pollInbox */ }
    });
  }
  async function refreshCaldavAllUsers() {
    await forEachUser(async () => {
      try { await refreshCaldav(); } catch (e) { /* logged inside refreshCaldav */ }
    });
  }

  // E-Mail: verify SMTP on startup (per-user, but verification only needs the
  // first configured user to confirm reachability) and poll every user's IMAP
  // inbox for replies on the configured interval.
  const pollMs = Math.max(60000, Number(config.get('MAIL_POLL_MS')) || 180000);
  setTimeout(() => { pollInboxAllUsers().catch(() => {}); }, 8000);    // initial fetch after boot
  setInterval(() => { pollInboxAllUsers().catch(() => {}); }, pollMs); // periodic fetch

  // Calendar: reconcile every user's appointments with their CalDAV calendar.
  const calPoll = Math.max(60000, Number(config.get('CALDAV_POLL_MS')) || 300000);
  setTimeout(() => { refreshCaldavAllUsers().catch(() => {}); }, 10000); // initial sync after boot
  setInterval(() => { refreshCaldavAllUsers().catch(() => {}); }, calPoll); // periodic reconcile

  // Global error handler: never leak internals. API/AJAX callers get JSON, everyone
  // else gets a plain 500. Logged server-side with the stack for debugging.
  app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
    if (res.headersSent) return next(err);
    console.error('Unhandled error:', err);
    const wantsJson = (req.get('accept') || '').includes('application/json') || req.path.startsWith('/api/') || req.xhr;
    if (wantsJson) return res.status(500).json({ error: 'Serverfehler' });
    res.status(500).send('Serverfehler');
  });

  // Handle 404
  app.use((req, res) => {
    res.status(404).send('Seite nicht gefunden');
  });
}).catch((err) => {
  console.error('Failed to initialize database:', err);
  process.exit(1);
});

// Close database on exit
process.on('SIGINT', () => {
  db.close();
  process.exit();
});

process.on('SIGTERM', () => {
  db.close();
  process.exit();
});
