Der X-API-Key identifiziert den Benutzer; alle Endpunkte waren bereits auf dessen user_id gescoped (27 Operationen geprueft). Zwei Luecken blieben: - Die Aufloesung Token -> Benutzer nahm bei mehrdeutigem Token per LIMIT 1 einfach den ersten Treffer. Haetten zwei Benutzer denselben Token, saehe der eine die Daten des anderen. Jetzt: fail closed (401 + Logeintrag), und /einstellungen weist einen bereits vergebenen Token mit 409 ab. - Ein leerer Token galt als Wert: wer seine Einstellungen einmal gespeichert hatte, besass eine API_TOKEN-Zeile mit ''. Leere Werte matchen jetzt nie. Neu in den Einstellungen (Abschnitt REST-API): - "Neu generieren" erzeugt einen zufaelligen Token (32 Byte, crypto.get- RandomValues); er wird nur ins Feld gefuellt und erst beim Speichern aktiv, ein Fehlklick laesst sich also verwerfen. - "Kopieren" legt den Token in die Zwischenablage; das Auge blendet ihn ein (bestand bereits fuer Secret-Felder). API aktualisiert: - PUT /settings kannte nur name/adresse/kundennummer, GET lieferte aber alle acht Felder. Jetzt schreibt PUT alle (email, telefon, ort, webseite, geburtsdatum) und aendert nur die im Body uebergebenen Felder; die Antwort enthaelt den neuen Stand. Swagger: - Beschreibung sagte "konfiguriert via Umgebungsvariable API_TOKEN" - das gilt seit der Multi-User-Umstellung nicht mehr. Jetzt dokumentiert: Token pro Benutzer aus den Einstellungen, Zugriff nur auf eigene Daten, fremde id -> 404, unbekannter/leerer/mehrdeutiger Token -> 401. - Settings-Schema um die fehlenden fuenf Felder ergaenzt. Verifiziert mit zwei Benutzern und je eigenem Key: Lesen, Aendern, Loeschen, Timeline, Anhaenge, E-Mails, Generierung, Jobangebote und Blacklist des jeweils anderen liefern durchgaengig 404; Listen, Export und Statistik zeigen nur eigene Daten; kollidierender Token -> 409; mehrdeutiger Token in der DB -> 401 fuer beide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3445 lines
143 KiB
JavaScript
3445 lines
143 KiB
JavaScript
const express = require('express');
|
||
const sqlite3 = require('sqlite3').verbose();
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const crypto = require('crypto');
|
||
const multer = require('multer');
|
||
|
||
// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
|
||
// (git-ignored) .env file into process.env without overwriting existing vars.
|
||
(function loadEnv() {
|
||
try {
|
||
const envPath = path.join(__dirname, '.env');
|
||
if (!fs.existsSync(envPath)) return;
|
||
for (const raw of fs.readFileSync(envPath, 'utf8').split('\n')) {
|
||
const line = raw.trim();
|
||
if (!line || line.startsWith('#')) continue;
|
||
const eq = line.indexOf('=');
|
||
if (eq === -1) continue;
|
||
const key = line.slice(0, eq).trim();
|
||
let val = line.slice(eq + 1).trim();
|
||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||
val = val.slice(1, -1);
|
||
}
|
||
if (key && !(key in process.env)) process.env[key] = val;
|
||
}
|
||
} catch (e) {
|
||
console.warn('Konnte .env nicht laden:', e.message);
|
||
}
|
||
})();
|
||
|
||
const {
|
||
generateApplicationDocuments, generateEmailReply, renderDesignVorschau,
|
||
DOKUMENT_TYPEN, normalizeDokumente,
|
||
} = require('./lib/documents');
|
||
const chat = require('./lib/chat');
|
||
const promptStore = require('./lib/prompts');
|
||
const designStore = require('./lib/design');
|
||
const mailer = require('./lib/mailer');
|
||
const { createExternalApi } = require('./lib/api');
|
||
const { buildOpenApiSpec } = require('./lib/openapi');
|
||
const blacklist = require('./lib/blacklist');
|
||
const caldav = require('./lib/caldav');
|
||
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./lib/labels');
|
||
const config = require('./lib/config');
|
||
const { userContext, currentUser, currentUserId } = require('./lib/context');
|
||
const password = require('./lib/password');
|
||
const migrate = require('./lib/migrate-multiuser');
|
||
const { runMigration, importEnvIntoAdmin } = migrate;
|
||
|
||
const app = express();
|
||
const PORT = process.env.PORT || 3000;
|
||
|
||
// Shared option lists (used in multiple views)
|
||
const ART_OPTIONS = [
|
||
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone',
|
||
'Firmenwebsite', 'Post', 'Initiativbewerbung',
|
||
'Arbeitsagentur', 'Sonstiges'
|
||
];
|
||
const STATUS_OPTIONS = [
|
||
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
||
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||
'Vorstellungsgespräch', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung'
|
||
];
|
||
// Base document types the user can provide as a foundation for AI tailoring
|
||
const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges'];
|
||
|
||
// Pick the application "source" (art) for a browser-captured job. Honour an
|
||
// explicit value from the extension, otherwise infer it from the URL host so a
|
||
// capture from any website is labelled sensibly.
|
||
function deriveArt(url, provided) {
|
||
if (provided && ART_OPTIONS.includes(provided)) return provided;
|
||
const host = (String(url || '').match(/^https?:\/\/([^/]+)/i) || [, ''])[1].toLowerCase();
|
||
if (!host) return 'Sonstiges';
|
||
if (host.includes('indeed')) return 'Indeed';
|
||
if (host.includes('stepstone')) return 'StepStone';
|
||
if (host.includes('arbeitsagentur')) return 'Arbeitsagentur';
|
||
if (/(linkedin|xing|monster|stellenanzeigen|kimeta|glassdoor|jobware|meinestadt|jobs\.|karriere\.)/.test(host)) return 'Online-Portal';
|
||
return 'Firmenwebsite';
|
||
}
|
||
|
||
// Middleware
|
||
app.use(express.json({ limit: '2mb' }));
|
||
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
||
app.use(express.static(path.join(__dirname, 'public')));
|
||
|
||
// Allow the browser extension (running on indeed.com) to call the import API.
|
||
// Kept narrow: only the extension-facing endpoints need cross-origin access.
|
||
app.use('/api/indeed-import', (req, res, next) => {
|
||
res.header('Access-Control-Allow-Origin', '*');
|
||
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
||
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
||
next();
|
||
});
|
||
|
||
// Set EJS as template engine
|
||
app.set('view engine', 'ejs');
|
||
app.set('views', path.join(__dirname, 'views'));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Auth: session cookie -> req.user + per-request user context
|
||
// ---------------------------------------------------------------------------
|
||
// Loads the session (if any) from the cookie and stores the resolved user on
|
||
// req.user / res.locals.user. The whole request then runs inside the
|
||
// AsyncLocalStorage user context, so the libs (config/mailer/caldav/chat) and
|
||
// the per-user query helpers below read the current user transparently.
|
||
app.use(async (req, res, next) => {
|
||
try {
|
||
const cookies = parseCookies(req.headers.cookie);
|
||
const user = await loadSessionUser(cookies[SESSION_COOKIE]);
|
||
req.user = user;
|
||
res.locals.user = user;
|
||
// Warm this user's cfg rows before anything reads them: config.get() is
|
||
// synchronous and answers from the per-user cache, so a user whose rows were
|
||
// never loaded would silently read as "unconfigured". Until now the process
|
||
// .env fallback papered over that (it happened to hold the admin's values);
|
||
// with the fallback gone, the config must actually be loaded per request.
|
||
if (user) await config.ensureLoaded(user.id);
|
||
userContext.run(user, next);
|
||
} catch (e) {
|
||
console.error('Session-Laden fehlgeschlagen:', e.message);
|
||
req.user = null;
|
||
res.locals.user = null;
|
||
next();
|
||
}
|
||
});
|
||
|
||
// Paths that do not require an authenticated session. The external /api/v1 has
|
||
// its own X-API-Key auth; everything else below requireAuth needs a session.
|
||
const PUBLIC_PATHS = new Set(['/login', '/logout']);
|
||
function isPublicPath(p) {
|
||
if (PUBLIC_PATHS.has(p)) return true;
|
||
if (p === '/health' || p === '/swagger' || p === '/swagger.json' || p === '/api-docs') return true;
|
||
if (p.startsWith('/api/v1/')) return true; // own X-API-Key auth
|
||
return false;
|
||
}
|
||
|
||
// Require an authenticated user. Browser requests are redirected to /login; API
|
||
// requests get 401 JSON. Must be registered before any protected route.
|
||
function requireAuth(req, res, next) {
|
||
if (req.user) return next();
|
||
if (isPublicPath(req.path)) return next();
|
||
const wantsJson = (req.get('accept') || '').includes('application/json')
|
||
|| req.path.startsWith('/api/')
|
||
|| req.xhr;
|
||
if (wantsJson) return res.status(401).json({ error: 'Nicht angemeldet.' });
|
||
return res.redirect('/login');
|
||
}
|
||
|
||
// Login page + form handler.
|
||
app.get('/login', (req, res) => {
|
||
if (req.user) return res.redirect('/');
|
||
res.render('login', { error: null, username: '' });
|
||
});
|
||
|
||
app.post('/login', async (req, res) => {
|
||
const { username, password: plain } = req.body || {};
|
||
const user = await authenticate(username, plain);
|
||
if (!user) {
|
||
return res.status(401).render('login', { error: 'Benutzername oder Passwort falsch.', username: username || '' });
|
||
}
|
||
const token = await createSession(user.id);
|
||
setSessionCookie(res, token);
|
||
res.redirect('/');
|
||
});
|
||
|
||
app.post('/logout', async (req, res) => {
|
||
const cookies = parseCookies(req.headers.cookie);
|
||
await destroySession(cookies[SESSION_COOKIE]);
|
||
clearSessionCookie(res);
|
||
res.redirect('/login');
|
||
});
|
||
// `GET /logout` is convenient for the header link (no JS needed).
|
||
app.get('/logout', (req, res) => { clearSessionCookie(res); res.redirect('/login'); });
|
||
|
||
// Protect everything registered below this point.
|
||
app.use(requireAuth);
|
||
|
||
// Ensure data directory exists
|
||
const dataDir = path.join(__dirname, 'data');
|
||
if (!fs.existsSync(dataDir)) {
|
||
fs.mkdirSync(dataDir, { recursive: true });
|
||
}
|
||
|
||
// Directory for generated attachment files (application documents)
|
||
const anhaengeDir = path.join(dataDir, 'anhaenge');
|
||
if (!fs.existsSync(anhaengeDir)) {
|
||
fs.mkdirSync(anhaengeDir, { recursive: true });
|
||
}
|
||
|
||
// Directory for attachments received via IMAP (reply e-mails).
|
||
const emailAnhaengeDir = path.join(dataDir, 'email_anhaenge');
|
||
if (!fs.existsSync(emailAnhaengeDir)) {
|
||
fs.mkdirSync(emailAnhaengeDir, { recursive: true });
|
||
}
|
||
|
||
// Resolve a per-user storage directory (data/<base>/<userId>/), creating it on
|
||
// first use. Used for every attachment / signature / photo path so users' files
|
||
// are isolated on disk the same way their DB rows are. Reads the current user
|
||
// from the request/task context (lib/context.js).
|
||
function userStorageDir(base) {
|
||
const dir = path.join(base, String(currentUserId()));
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
return dir;
|
||
}
|
||
|
||
// Same as userStorageDir but resolves the user id from req.user explicitly (used
|
||
// inside multer callbacks, where we prefer the request's user over the async
|
||
// context to stay robust against callback timing).
|
||
function userStorageDirForReq(base, req) {
|
||
const id = (req && req.user && req.user.id) || currentUserId();
|
||
const dir = path.join(base, String(id));
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
return dir;
|
||
}
|
||
|
||
// Serve a stored file with `Content-Disposition: inline` so the browser opens it
|
||
// (PDF/image) in a tab instead of downloading. Falls back to the file extension
|
||
// when no MIME type is stored.
|
||
function serveInline(res, filePath, filename, mime) {
|
||
const type = mime || {
|
||
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
||
}[path.extname(filename || filePath).toLowerCase()];
|
||
if (type) res.type(type);
|
||
const safe = String(filename || 'datei').replace(/["\r\n]/g, '');
|
||
res.setHeader('Content-Disposition',
|
||
`inline; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
|
||
res.sendFile(filePath);
|
||
}
|
||
|
||
// --- E-mail display + reply helpers --------------------------------------
|
||
// A stored message is rendered as HTML when we have an HTML body (or the plain
|
||
// body is actually HTML markup — some senders put HTML in the text/plain part).
|
||
// Otherwise it is shown as plain text. HTML is displayed inside a sandboxed
|
||
// iframe (see the views), so scripts never run.
|
||
function looksLikeHtml(s) {
|
||
return /<(?:!doctype|html|body|div|table|p|br|span|a|img|ul|ol|h[1-6])\b|<\/[a-z]/i.test(String(s || ''));
|
||
}
|
||
function emailDisplayHtml(e) {
|
||
if (e.body_html && e.body_html.trim()) return e.body_html;
|
||
if (e.body_text && looksLikeHtml(e.body_text)) return e.body_text;
|
||
return null;
|
||
}
|
||
// Wrap raw e-mail HTML in a minimal document for the sandboxed iframe: a white
|
||
// background, readable defaults, images constrained to the width and links that
|
||
// open in a new tab. No scripts are enabled by the iframe sandbox.
|
||
function buildEmailSrcdoc(html) {
|
||
return '<!doctype html><html><head><meta charset="utf-8">' +
|
||
'<meta name="referrer" content="no-referrer"><base target="_blank">' +
|
||
'<style>html,body{margin:0;padding:10px;background:#fff;color:#111;' +
|
||
'font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;' +
|
||
'word-break:break-word;overflow-wrap:anywhere}' +
|
||
'img{max-width:100%!important;height:auto}table{max-width:100%!important}' +
|
||
'a{color:#2563eb}*{max-width:100%}</style></head><body>' +
|
||
String(html || '') + '</body></html>';
|
||
}
|
||
// Plain-text version of a message body, used as the source for reply quoting.
|
||
function emailPlainText(e) {
|
||
const html = emailDisplayHtml(e);
|
||
if (html && (!e.body_text || looksLikeHtml(e.body_text))) return mailer.htmlToText(html);
|
||
return String(e.body_text || '');
|
||
}
|
||
// Build a mail-client style quote of a received message: an attribution line
|
||
// followed by the original body with every line prefixed by "> ".
|
||
function buildReplyQuote(e) {
|
||
const d = e.email_date ? new Date(e.email_date) : null;
|
||
const when = d && !isNaN(d.getTime()) ? d.toLocaleString('de-DE') : '';
|
||
const who = String(e.from_addr || '').trim();
|
||
const src = emailPlainText(e).replace(/\r\n/g, '\n').replace(/\s+$/, '');
|
||
const quoted = src.split('\n').map((l) => '> ' + l).join('\n');
|
||
const attribution = who ? `Am ${when} schrieb ${who}:` : (when ? `Am ${when}:` : '');
|
||
return (attribution ? attribution + '\n\n' : '') + quoted;
|
||
}
|
||
// Attach display fields (and, for received mail, a reply quote) to each row.
|
||
function decorateEmails(emails) {
|
||
emails.forEach((e) => {
|
||
const html = emailDisplayHtml(e);
|
||
e.display_srcdoc = html ? buildEmailSrcdoc(html) : null;
|
||
if (e.direction !== 'out') e.reply_quote = buildReplyQuote(e);
|
||
});
|
||
}
|
||
|
||
// Directory for static extra attachments (e.g. Zeugnisse) the user uploads once
|
||
// and that are sent along with every generated application.
|
||
const basisAnhaengeDir = path.join(dataDir, 'basis_anhaenge');
|
||
if (!fs.existsSync(basisAnhaengeDir)) {
|
||
fs.mkdirSync(basisAnhaengeDir, { recursive: true });
|
||
}
|
||
|
||
// Multipart upload for those static attachments (stored under the user's subdir)
|
||
const uploadBasisAnhang = multer({
|
||
storage: multer.diskStorage({
|
||
destination: (req, file, cb) => cb(null, userStorageDirForReq(basisAnhaengeDir, req)),
|
||
filename: (req, file, cb) => {
|
||
const safe = String(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||
cb(null, `${Date.now()}_${safe}`);
|
||
},
|
||
}),
|
||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
||
}).single('datei');
|
||
|
||
// Directory for private attachments the user keeps alongside the internal
|
||
// notes of an application. These are never exported into the PDF and never
|
||
// sent with an application — they are for the user only.
|
||
const interneAnhaengeDir = path.join(dataDir, 'interne_anhaenge');
|
||
if (!fs.existsSync(interneAnhaengeDir)) {
|
||
fs.mkdirSync(interneAnhaengeDir, { recursive: true });
|
||
}
|
||
const uploadInterneAnhang = multer({
|
||
storage: multer.diskStorage({
|
||
destination: (req, file, cb) => cb(null, userStorageDirForReq(interneAnhaengeDir, req)),
|
||
filename: (req, file, cb) => {
|
||
const safe = String(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||
cb(null, `${Date.now()}_${safe}`);
|
||
},
|
||
}),
|
||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
||
}).single('datei');
|
||
|
||
// Directory + uploader for the applicant's signature image (used in the letter)
|
||
const signaturDir = path.join(dataDir, 'signatur');
|
||
if (!fs.existsSync(signaturDir)) {
|
||
fs.mkdirSync(signaturDir, { recursive: true });
|
||
}
|
||
const uploadSignatur = multer({
|
||
storage: multer.diskStorage({
|
||
destination: (req, file, cb) => cb(null, userStorageDirForReq(signaturDir, req)),
|
||
filename: (req, file, cb) => {
|
||
const ext = (path.extname(file.originalname) || '.png').toLowerCase();
|
||
cb(null, `signatur_${Date.now()}${ext}`);
|
||
},
|
||
}),
|
||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
||
fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)),
|
||
}).single('signatur');
|
||
|
||
// The single stored signature file, if any (in the current user's subdir).
|
||
function currentSignaturFile() {
|
||
try {
|
||
const dir = userStorageDir(signaturDir);
|
||
const files = fs.readdirSync(dir).filter((f) => !f.startsWith('.'));
|
||
return files.length ? path.join(dir, files[0]) : null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Read the signature as a data URL + jsPDF format, for embedding in the letter.
|
||
function loadSignatur() {
|
||
const file = currentSignaturFile();
|
||
if (!file) return null;
|
||
try {
|
||
const ext = path.extname(file).toLowerCase();
|
||
const format = (ext === '.jpg' || ext === '.jpeg') ? 'JPEG' : 'PNG';
|
||
const mime = format === 'JPEG' ? 'image/jpeg' : 'image/png';
|
||
const b64 = fs.readFileSync(file).toString('base64');
|
||
return { dataUrl: `data:${mime};base64,${b64}`, format };
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Directory + uploader for the applicant's portrait photo (used in the CV)
|
||
const fotoDir = path.join(dataDir, 'bewerberfoto');
|
||
if (!fs.existsSync(fotoDir)) {
|
||
fs.mkdirSync(fotoDir, { recursive: true });
|
||
}
|
||
const uploadFoto = multer({
|
||
storage: multer.diskStorage({
|
||
destination: (req, file, cb) => cb(null, userStorageDirForReq(fotoDir, req)),
|
||
filename: (req, file, cb) => {
|
||
const ext = (path.extname(file.originalname) || '.png').toLowerCase();
|
||
cb(null, `foto_${Date.now()}${ext}`);
|
||
},
|
||
}),
|
||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
||
fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)),
|
||
}).single('foto');
|
||
|
||
// The single stored photo file, if any (in the current user's subdir).
|
||
function currentFotoFile() {
|
||
try {
|
||
const dir = userStorageDir(fotoDir);
|
||
const files = fs.readdirSync(dir).filter((f) => !f.startsWith('.'));
|
||
return files.length ? path.join(dir, files[0]) : null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Read the photo as a data URL + jsPDF format, for embedding in the CV.
|
||
function loadFoto() {
|
||
const file = currentFotoFile();
|
||
if (!file) return null;
|
||
try {
|
||
const ext = path.extname(file).toLowerCase();
|
||
const format = (ext === '.jpg' || ext === '.jpeg') ? 'JPEG' : 'PNG';
|
||
const mime = format === 'JPEG' ? 'image/jpeg' : 'image/png';
|
||
const b64 = fs.readFileSync(file).toString('base64');
|
||
return { dataUrl: `data:${mime};base64,${b64}`, format };
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Database setup
|
||
const dbPath = path.join(dataDir, 'bewerbungen.db');
|
||
const db = new sqlite3.Database(dbPath);
|
||
|
||
// Sanitize input to prevent XSS
|
||
function sanitizeInput(input) {
|
||
if (typeof input !== 'string') return input;
|
||
return input
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// Promise wrapper for db operations
|
||
function dbGet(sql, params = []) {
|
||
return new Promise((resolve, reject) => {
|
||
db.get(sql, params, (err, result) => {
|
||
if (err) reject(err);
|
||
else resolve(result);
|
||
});
|
||
});
|
||
}
|
||
|
||
function dbAll(sql, params = []) {
|
||
return new Promise((resolve, reject) => {
|
||
db.all(sql, params, (err, results) => {
|
||
if (err) reject(err);
|
||
else resolve(results);
|
||
});
|
||
});
|
||
}
|
||
|
||
function dbRun(sql, params = []) {
|
||
return new Promise((resolve, reject) => {
|
||
db.run(sql, params, function(err) {
|
||
if (err) reject(err);
|
||
else resolve({ lastID: this.lastID, changes: this.changes });
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Multi-user: sessions, login, per-request user context
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const SESSION_COOKIE = 'sid';
|
||
const SESSION_MAX_AGE = 30 * 24 * 3600; // 30 days, in seconds
|
||
|
||
// Minimal cookie parser (no cookie-parser dependency): { name: value }.
|
||
function parseCookies(header) {
|
||
const out = {};
|
||
if (!header) return out;
|
||
for (const part of String(header).split(';')) {
|
||
const eq = part.indexOf('=');
|
||
if (eq === -1) continue;
|
||
const k = part.slice(0, eq).trim();
|
||
const v = part.slice(eq + 1).trim();
|
||
if (k) out[k] = decodeURIComponent(v);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Create a session row for a user and return the opaque token to store in the cookie.
|
||
async function createSession(userId) {
|
||
const token = crypto.randomBytes(32).toString('hex');
|
||
await dbRun('INSERT INTO sessions (token, user_id) VALUES (?, ?)', [token, userId]);
|
||
return token;
|
||
}
|
||
|
||
async function destroySession(token) {
|
||
if (token) await dbRun('DELETE FROM sessions WHERE token = ?', [token]).catch(() => {});
|
||
}
|
||
|
||
// Resolve the user a session token belongs to, or null. Touches last_seen.
|
||
async function loadSessionUser(token) {
|
||
if (!token) return null;
|
||
const row = await dbGet(
|
||
`SELECT u.id AS id, u.username AS username, u.is_admin AS is_admin
|
||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||
WHERE s.token = ?`,
|
||
[token]
|
||
);
|
||
if (!row) return null;
|
||
await dbRun('UPDATE sessions SET last_seen = CURRENT_TIMESTAMP WHERE token = ?', [token]).catch(() => {});
|
||
return { id: row.id, username: row.username, is_admin: !!row.is_admin };
|
||
}
|
||
|
||
// Find a user by username + password (login check). Returns the user object or null.
|
||
async function authenticate(username, plain) {
|
||
const row = await dbGet('SELECT id, username, password_hash, is_admin FROM users WHERE username = ?', [username || '']);
|
||
if (!row) return null;
|
||
if (!password.verify(plain || '', row.password_hash)) return null;
|
||
return { id: row.id, username: row.username, is_admin: !!row.is_admin };
|
||
}
|
||
|
||
// Set/clear the session cookie on a response.
|
||
function setSessionCookie(res, token) {
|
||
res.cookie(SESSION_COOKIE, token, {
|
||
httpOnly: true, sameSite: 'lax', path: '/', maxAge: SESSION_MAX_AGE * 1000,
|
||
});
|
||
}
|
||
function clearSessionCookie(res) {
|
||
res.clearCookie(SESSION_COOKIE, { path: '/' });
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Job-offer blacklist helpers (shared shape with lib/api.js)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Insert one prepared blacklist entry (see lib/blacklist.buildManual/AutoEntry).
|
||
async function insertBlacklistEntry(entry) {
|
||
const cols = blacklist.COLUMNS;
|
||
const placeholders = cols.map(() => '?').join(', ');
|
||
const values = cols.map((c) => (entry[c] === undefined ? null : entry[c]));
|
||
return dbRun(
|
||
`INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${placeholders})`,
|
||
[currentUserId(), ...values]
|
||
);
|
||
}
|
||
|
||
// Auto-blacklist an offer row so it can never be ingested again, then it is safe
|
||
// to delete. Skips silently if the offer is already covered by an entry.
|
||
async function autoBlacklistOffer(offer, grund) {
|
||
if (!offer) return;
|
||
const rows = await dbAll('SELECT * FROM jobangebote_blacklist WHERE user_id = ?', [currentUserId()]);
|
||
if (blacklist.matchBlacklist(rows, offer)) return; // already blocked
|
||
await insertBlacklistEntry(blacklist.buildAutoEntry(offer, grund));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Application calendar (CalDAV) helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Upcoming appointments (not yet ended), newest first, for the dashboard widget.
|
||
async function upcomingTermine(limit = 6) {
|
||
const now = new Date().toISOString();
|
||
return dbAll(
|
||
`SELECT t.*, b.firma AS bewerbung_firma, b.stelle AS bewerbung_stelle
|
||
FROM termine t LEFT JOIN bewerbungen b ON b.id = t.bewerbung_id
|
||
WHERE t.user_id = ? AND COALESCE(t.ende, t.start) >= ?
|
||
ORDER BY t.start ASC LIMIT ?`,
|
||
[currentUserId(), now, limit]
|
||
);
|
||
}
|
||
|
||
// Reconcile our tracked appointments with the SOGo calendar: reflect remote
|
||
// edits and drop entries deleted remotely. Cheap ctag check first. Best-effort.
|
||
async function refreshCaldav() {
|
||
if (!caldav.isConfigured()) return;
|
||
const ctag = await caldav.getCtag().catch(() => null);
|
||
if (ctag) {
|
||
const prev = await getState('caldav_ctag');
|
||
if (prev && prev === ctag) return;
|
||
}
|
||
const from = new Date(Date.now() - 24 * 3600 * 1000);
|
||
const to = new Date(Date.now() + 180 * 24 * 3600 * 1000);
|
||
const remote = await caldav.listEvents({ from, to });
|
||
const byUid = new Map(remote.map((e) => [e.uid, e]));
|
||
const local = await dbAll(
|
||
'SELECT * FROM termine WHERE user_id = ? AND caldav_uid IS NOT NULL AND start >= ? AND start <= ?',
|
||
[currentUserId(), from.toISOString(), to.toISOString()]
|
||
);
|
||
for (const t of local) {
|
||
const r = byUid.get(t.caldav_uid);
|
||
if (!r) {
|
||
await dbRun('DELETE FROM termine WHERE id = ? AND user_id = ?', [t.id, currentUserId()]);
|
||
} else {
|
||
await dbRun(
|
||
`UPDATE termine SET titel = ?, ort = ?, notiz = ?, start = ?, ende = ?, ganztags = ?,
|
||
caldav_etag = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?`,
|
||
[
|
||
sanitizeInput(r.summary || t.titel), sanitizeInput(r.location || ''), sanitizeInput(r.description || ''),
|
||
(r.start || new Date(t.start)).toISOString(), r.end ? r.end.toISOString() : null,
|
||
r.allDay ? 1 : 0, r.etag || t.caldav_etag, t.id, currentUserId(),
|
||
]
|
||
);
|
||
}
|
||
}
|
||
if (ctag) await setState('caldav_ctag', ctag);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// E-Mail correspondence: IMAP polling, storing & matching incoming replies
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function getState(key) {
|
||
const row = await dbGet('SELECT value FROM app_state WHERE user_id = ? AND key = ?', [currentUserId(), key]);
|
||
return row ? row.value : null;
|
||
}
|
||
async function setState(key, value) {
|
||
await dbRun(
|
||
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
|
||
[currentUserId(), key, String(value)]
|
||
);
|
||
}
|
||
|
||
// The user's KI prompt overrides as { key: text }. Keys without a row keep the
|
||
// default from lib/prompts.js. Read fresh on every generation so an edit takes
|
||
// effect immediately, without a restart.
|
||
async function loadPrompts() {
|
||
try {
|
||
const rows = await dbAll('SELECT key, inhalt FROM prompts WHERE user_id = ?', [currentUserId()]);
|
||
return Object.fromEntries(rows.map((r) => [r.key, r.inhalt]));
|
||
} catch (e) {
|
||
console.error('Konnte Prompts nicht laden, nutze Standardtexte:', e.message);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
// The user's design overrides as { key: value }. Unset keys keep the defaults
|
||
// from lib/design.js. Read fresh per generation, like the prompts.
|
||
async function loadDesign() {
|
||
try {
|
||
const rows = await dbAll('SELECT key, value FROM design WHERE user_id = ?', [currentUserId()]);
|
||
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
||
} catch (e) {
|
||
console.error('Konnte Design nicht laden, nutze Standardwerte:', e.message);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
// The user's personal details (Persönliche Angaben). A user who has never saved
|
||
// the form has no settings row at all — that is a valid state, not an error, so
|
||
// this returns an empty object rather than undefined. Same contract as
|
||
// loadPrompts()/loadDesign(): "no row" means "nothing set yet". Every consumer
|
||
// (views, PDF generation, KI context) reads individual fields off the result,
|
||
// so they all degrade to empty instead of crashing.
|
||
async function loadSettings() {
|
||
try {
|
||
const row = await dbGet('SELECT * FROM settings WHERE user_id = ?', [currentUserId()]);
|
||
return row || {};
|
||
} catch (e) {
|
||
console.error('Konnte Persönliche Angaben nicht laden:', e.message);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
// Match an incoming message to an application: first via In-Reply-To/References
|
||
// pointing at one of our sent messages, then by sender = a previous recipient.
|
||
async function matchBewerbung(msg) {
|
||
const refs = []
|
||
.concat((msg.inReplyTo || '').split(/\s+/))
|
||
.concat((msg.references || '').split(/\s+/))
|
||
.map((r) => r.replace(/[<>]/g, '').trim())
|
||
.filter(Boolean);
|
||
for (const mid of refs) {
|
||
const row = await dbGet(
|
||
"SELECT bewerbung_id FROM emails WHERE user_id = ? AND direction = 'out' AND message_id = ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1",
|
||
[currentUserId(), mid]
|
||
);
|
||
if (row && row.bewerbung_id) return row.bewerbung_id;
|
||
}
|
||
if (msg.fromAddr) {
|
||
const row = await dbGet(
|
||
"SELECT bewerbung_id FROM emails WHERE user_id = ? AND direction = 'out' AND lower(to_addr) LIKE ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1",
|
||
[currentUserId(), '%' + msg.fromAddr.toLowerCase() + '%']
|
||
);
|
||
if (row && row.bewerbung_id) return row.bewerbung_id;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
let polling = false;
|
||
// Fetch new mail from the IMAP inbox, persist unseen messages, link them to the
|
||
// matching application and save their attachments. Safe to call concurrently
|
||
// (guarded) — used both by the interval poller and the manual "fetch" button.
|
||
// Runs in the current user's context (background loop sets it per user).
|
||
async function pollInbox() {
|
||
if (!mailer.isConfigured() || polling) return { fetched: 0 };
|
||
polling = true;
|
||
try {
|
||
const lastUid = Number(await getState('mail_last_uid')) || 0;
|
||
const { messages, maxUid } = await mailer.fetchSince(lastUid);
|
||
let stored = 0;
|
||
for (const m of messages) {
|
||
// Skip if we already have this message (id or uid) — idempotent.
|
||
if (m.messageId) {
|
||
const dup = await dbGet('SELECT id FROM emails WHERE user_id = ? AND message_id = ?', [currentUserId(), m.messageId]);
|
||
if (dup) continue;
|
||
}
|
||
const bewId = await matchBewerbung(m);
|
||
const result = await dbRun(
|
||
`INSERT INTO emails (user_id, bewerbung_id, direction, message_id, in_reply_to, email_references,
|
||
from_addr, to_addr, subject, body_text, body_html, imap_uid, seen, email_date)
|
||
VALUES (?, ?, 'in', ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`,
|
||
[currentUserId(), bewId, m.messageId || null, m.inReplyTo || null, m.references || null,
|
||
m.fromName ? `${m.fromName} <${m.fromAddr}>` : m.fromAddr, m.toAddr || '',
|
||
m.subject || '', m.text || '', m.html || '', m.uid,
|
||
(m.date instanceof Date ? m.date.toISOString() : new Date().toISOString())]
|
||
);
|
||
// Persist attachments to disk + link rows.
|
||
for (const att of (m.attachments || [])) {
|
||
const safe = String(att.filename || 'anhang').replace(/[^a-zA-Z0-9äöüÄÖÜß._ -]/g, '_').slice(0, 80);
|
||
const storedName = `${result.lastID}_${Date.now()}_${safe}`;
|
||
try {
|
||
fs.writeFileSync(path.join(userStorageDir(emailAnhaengeDir), storedName), att.content);
|
||
await dbRun('INSERT INTO email_anhaenge (user_id, email_id, name, mime, pfad) VALUES (?, ?, ?, ?, ?)',
|
||
[currentUserId(), result.lastID, att.filename, att.contentType, storedName]);
|
||
} catch (e) { /* ignore a single bad attachment */ }
|
||
}
|
||
stored++;
|
||
}
|
||
if (maxUid > lastUid) await setState('mail_last_uid', maxUid);
|
||
return { fetched: stored };
|
||
} catch (err) {
|
||
console.error('IMAP-Abruf fehlgeschlagen:', err.message);
|
||
return { fetched: 0, error: err.message };
|
||
} finally {
|
||
polling = false;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Duplicate-application guard: spot an existing application for the same job so
|
||
// the user doesn't accidentally apply twice. Matches on a normalised source URL
|
||
// (strongest signal for imported postings) or an identical company + role. It
|
||
// only warns — legitimate re-applications stay possible via a "force" flag.
|
||
// ---------------------------------------------------------------------------
|
||
function normText(s) {
|
||
return String(s == null ? '' : s)
|
||
.toLowerCase()
|
||
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip diacritics (ä→a …)
|
||
.replace(/[^a-z0-9]+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function normUrl(u) {
|
||
const raw = String(u == null ? '' : u).trim();
|
||
if (!raw) return '';
|
||
try {
|
||
const url = new URL(raw);
|
||
const host = url.hostname.replace(/^www\./, '').toLowerCase();
|
||
// A job-identifying query param (Indeed jk/vjk, generic ids) pins the posting
|
||
// regardless of tracking params or which path it was opened from.
|
||
const idKeys = ['jk', 'vjk', 'jobkey', 'jobid', 'vacancyid', 'stellenangebotid', 'positionid', 'offerid', 'id'];
|
||
let idPart = '';
|
||
for (const [k, v] of url.searchParams.entries()) {
|
||
if (v && idKeys.includes(k.toLowerCase())) { idPart = k.toLowerCase() + '=' + v.toLowerCase(); break; }
|
||
}
|
||
const pathn = url.pathname.replace(/\/+$/, '').toLowerCase();
|
||
return idPart ? host + '|' + idPart : host + pathn;
|
||
} catch (e) {
|
||
return raw.toLowerCase().replace(/[?#].*$/, '');
|
||
}
|
||
}
|
||
|
||
// Existing applications that look like the same job as {firma, stelle, quelle_url}.
|
||
// `excludeId` skips a specific row (e.g. when re-checking during an edit).
|
||
async function findDuplicateApplications({ firma, stelle, quelle_url, excludeId }) {
|
||
const rows = await dbAll('SELECT id, datum, firma, stelle, ort, quelle_url, status FROM bewerbungen WHERE user_id = ?', [currentUserId()]);
|
||
const fUrl = normUrl(quelle_url);
|
||
const fFirma = normText(firma);
|
||
const fStelle = normText(stelle);
|
||
const matches = [];
|
||
for (const r of rows) {
|
||
if (excludeId && Number(r.id) === Number(excludeId)) continue;
|
||
let reason = null;
|
||
if (fUrl && normUrl(r.quelle_url) === fUrl) reason = 'url';
|
||
else if (fFirma && fStelle && normText(r.firma) === fFirma && normText(r.stelle) === fStelle) reason = 'firma_stelle';
|
||
if (reason) matches.push({ id: r.id, datum: r.datum, firma: r.firma, stelle: r.stelle, ort: r.ort, status: r.status, reason });
|
||
}
|
||
return matches;
|
||
}
|
||
|
||
// Recompute an application's current status from its latest timeline entry
|
||
async function syncCurrentStatus(bewerbungId) {
|
||
const latest = await dbGet(
|
||
'SELECT status FROM status_verlauf WHERE user_id = ? AND bewerbung_id = ? ORDER BY date(datum) DESC, id DESC LIMIT 1',
|
||
[currentUserId(), bewerbungId]
|
||
);
|
||
await dbRun(
|
||
'UPDATE bewerbungen SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
|
||
[latest ? latest.status : '', bewerbungId, currentUserId()]
|
||
);
|
||
}
|
||
|
||
// Attach the status timeline to each application (single query, grouped in JS)
|
||
async function attachVerlauf(applications) {
|
||
if (!applications.length) return applications;
|
||
const all = await dbAll('SELECT * FROM status_verlauf WHERE user_id = ? ORDER BY date(datum) ASC, id ASC', [currentUserId()]);
|
||
const byApp = {};
|
||
all.forEach((v) => { (byApp[v.bewerbung_id] = byApp[v.bewerbung_id] || []).push(v); });
|
||
applications.forEach((a) => { a.verlauf = byApp[a.id] || []; });
|
||
return applications;
|
||
}
|
||
|
||
// Run the AI document generation for one application (async, fire-and-forget).
|
||
// Loads the base documents + user settings, asks the LLM to tailor them to the
|
||
// job, writes the resulting PDFs to disk and links them as attachments.
|
||
async function runGeneration(bewerbungId, options = {}) {
|
||
try {
|
||
const U = currentUserId();
|
||
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [bewerbungId, U]);
|
||
if (!bewerbung) return;
|
||
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [U]);
|
||
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [U]);
|
||
const settings = await loadSettings();
|
||
const prompts = await loadPrompts();
|
||
const design = await loadDesign();
|
||
|
||
// Only the explicitly selected extra attachments are enclosed (default: none).
|
||
const anlagenIds = Array.isArray(options.anlagenIds) ? options.anlagenIds.map(Number) : [];
|
||
const selectedAnhaenge = basisAnhaenge.filter((a) => anlagenIds.includes(a.id));
|
||
|
||
// Which documents to produce; unset means both (the default everywhere).
|
||
const dokumente = normalizeDokumente(options.dokumente);
|
||
|
||
const { documents, email } = await generateApplicationDocuments({
|
||
job: {
|
||
firma: bewerbung.firma,
|
||
stelle: bewerbung.stelle,
|
||
ort: bewerbung.ort,
|
||
quelle_url: bewerbung.quelle_url,
|
||
stellenbeschreibung: bewerbung.stellenbeschreibung,
|
||
},
|
||
basisDokumente,
|
||
settings,
|
||
prompts,
|
||
design,
|
||
dokumente,
|
||
// Names of the selected attachments so the cover letter (and the LLM) lists
|
||
// exactly these under "Anlagen".
|
||
zusatzAnlagen: selectedAnhaenge.map((a) => a.name || a.dateiname),
|
||
// Free-text notes (company address, contact person, extra context) for the LLM.
|
||
llmNotizen: bewerbung.llm_notizen || '',
|
||
// Signature image placed under the closing salutation (instead of the typed name).
|
||
signatur: loadSignatur(),
|
||
// Applicant photo placed in the CV header (top-right), optional.
|
||
bewerbungsfoto: loadFoto(),
|
||
});
|
||
|
||
let seq = 0;
|
||
const storeAnhang = async (name, filename, mime, buffer) => {
|
||
const stored = `${bewerbungId}_${Date.now()}_${seq++}_${filename}`;
|
||
fs.writeFileSync(path.join(userStorageDir(anhaengeDir), stored), buffer);
|
||
await dbRun(
|
||
'INSERT INTO anhaenge (user_id, bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?, ?)',
|
||
[U, bewerbungId, name, filename, mime, stored]
|
||
);
|
||
};
|
||
|
||
// Generated (AI) documents
|
||
for (const doc of documents) {
|
||
await storeAnhang(doc.name, doc.filename, doc.mime, doc.buffer);
|
||
}
|
||
|
||
// Selected extra attachments (e.g. Zeugnisse) — copied as-is
|
||
for (const ba of selectedAnhaenge) {
|
||
const src = path.join(userStorageDir(basisAnhaengeDir), ba.pfad);
|
||
if (!fs.existsSync(src)) continue;
|
||
await storeAnhang(ba.name || ba.dateiname, ba.dateiname, ba.mime || 'application/octet-stream', fs.readFileSync(src));
|
||
}
|
||
|
||
await dbRun(
|
||
"UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, " +
|
||
"email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?",
|
||
[(email && email.betreff) || '', (email && email.text) || '', bewerbungId, U]
|
||
);
|
||
console.log(`Bewerbungsunterlagen für #${bewerbungId} generiert (${documents.length} Dokument(e)).`);
|
||
} catch (error) {
|
||
console.error(`Generierung für #${bewerbungId} fehlgeschlagen:`, error.message);
|
||
await dbRun(
|
||
"UPDATE bewerbungen SET generierung_status = 'fehler', generierung_fehler = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?",
|
||
[String(error.message || 'Unbekannter Fehler'), bewerbungId, currentUserId()]
|
||
).catch(() => {});
|
||
}
|
||
}
|
||
|
||
// Initialize database — multi-user schema.
|
||
//
|
||
// Creates every table with a `user_id` owner column (NOT NULL, FK -> users) and
|
||
// per-user PRIMARY KEY / UNIQUE constraints. Fresh installs get this schema
|
||
// directly; legacy single-user installs are upgraded by runMigration() (below),
|
||
// which adds the user_id columns, recreates the per-user PK tables, backfills
|
||
// all existing rows to the admin user and moves on-disk files into a per-user
|
||
// subdirectory. runMigration() is idempotent and also runs on fresh installs
|
||
// (where it only creates the admin user + default settings row).
|
||
async function initializeDatabase() {
|
||
const exec = (sql) => dbRun(sql);
|
||
db.run('PRAGMA foreign_keys = ON');
|
||
|
||
// Users + sessions (auth) ------------------------------------------------
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
username TEXT NOT NULL UNIQUE,
|
||
password_hash TEXT NOT NULL,
|
||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
`);
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
token TEXT PRIMARY KEY,
|
||
user_id INTEGER NOT NULL,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)');
|
||
|
||
// Upgrade legacy single-user installs BEFORE the per-user CREATE/INDEX
|
||
// statements below: the migration adds the user_id column to every existing
|
||
// per-user table (ALTER) and recreates the PK/UNIQUE tables per-user, so the
|
||
// CREATE INDEX ... ON <table>(user_id) statements that follow find the column
|
||
// already present. On a fresh install the migration only creates the admin
|
||
// user + default settings row (none of the per-user tables exist yet, so every
|
||
// step guards itself with tableExists and is a no-op). Idempotent.
|
||
await runMigration({ db, dbAll, dbGet, dbRun });
|
||
|
||
// Applications (core data) ----------------------------------------------
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS bewerbungen (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
datum DATE NOT NULL,
|
||
firma TEXT NOT NULL,
|
||
stelle TEXT NOT NULL,
|
||
art TEXT,
|
||
status TEXT,
|
||
notizen TEXT,
|
||
interne_notizen TEXT,
|
||
ort TEXT,
|
||
stellenbeschreibung TEXT,
|
||
quelle_url TEXT,
|
||
generierung_status TEXT,
|
||
generierung_fehler TEXT,
|
||
email_betreff TEXT,
|
||
email_anschreiben TEXT,
|
||
llm_notizen TEXT,
|
||
labels TEXT,
|
||
generierung_dokumente TEXT,
|
||
email_empfaenger TEXT,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
await exec('CREATE INDEX IF NOT EXISTS idx_bewerbungen_user ON bewerbungen(user_id)');
|
||
|
||
// Chronological status changes, each with an optional comment
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS status_verlauf (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
bewerbung_id INTEGER NOT NULL,
|
||
datum DATE NOT NULL,
|
||
status TEXT NOT NULL,
|
||
kommentar TEXT,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
|
||
// Base documents (Basis-Unterlagen) — the foundation the AI tailors from
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS basis_dokumente (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
typ TEXT,
|
||
name TEXT,
|
||
inhalt TEXT NOT NULL,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
|
||
// Static extra attachments (e.g. Zeugnisse) attached to every application
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS basis_anhaenge (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
name TEXT,
|
||
dateiname TEXT NOT NULL,
|
||
mime TEXT,
|
||
pfad TEXT NOT NULL,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
|
||
// Generated attachment files linked to an application
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS anhaenge (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
bewerbung_id INTEGER NOT NULL,
|
||
name TEXT,
|
||
dateiname TEXT NOT NULL,
|
||
mime TEXT,
|
||
pfad TEXT NOT NULL,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
|
||
// Private attachments linked to an application's internal notes.
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS interne_anhaenge (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
bewerbung_id INTEGER NOT NULL,
|
||
name TEXT,
|
||
dateiname TEXT NOT NULL,
|
||
mime TEXT,
|
||
pfad TEXT NOT NULL,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
|
||
// E-Mail correspondence (sent + received), linked to an application.
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS emails (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
bewerbung_id INTEGER,
|
||
direction TEXT NOT NULL,
|
||
message_id TEXT,
|
||
in_reply_to TEXT,
|
||
email_references TEXT,
|
||
from_addr TEXT,
|
||
to_addr TEXT,
|
||
subject TEXT,
|
||
body_text TEXT,
|
||
body_html TEXT,
|
||
attachments_json TEXT,
|
||
imap_uid INTEGER,
|
||
seen INTEGER DEFAULT 1,
|
||
email_date DATETIME,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
await exec('CREATE INDEX IF NOT EXISTS idx_emails_user ON emails(user_id)');
|
||
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS email_anhaenge (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
email_id INTEGER NOT NULL,
|
||
name TEXT,
|
||
mime TEXT,
|
||
pfad TEXT NOT NULL,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (email_id) REFERENCES emails(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
|
||
// Per-user key/value store (cfg: settings + per-user state like last IMAP UID).
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS app_state (
|
||
user_id INTEGER NOT NULL,
|
||
key TEXT NOT NULL,
|
||
value TEXT,
|
||
PRIMARY KEY (user_id, key),
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
|
||
// Job offers ingested via the third-party REST API. (user_id, quelle,
|
||
// external_id) identify an offer uniquely per user.
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS jobangebote (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
external_id TEXT,
|
||
quelle TEXT NOT NULL DEFAULT 'drittanbieter',
|
||
firma TEXT NOT NULL,
|
||
stelle TEXT NOT NULL,
|
||
ort TEXT,
|
||
adresse TEXT,
|
||
ansprechpartner TEXT,
|
||
gehalt TEXT,
|
||
beschreibung TEXT,
|
||
quelle_url TEXT,
|
||
art TEXT,
|
||
anzeige_datum DATE,
|
||
kontakt_email TEXT,
|
||
status TEXT NOT NULL DEFAULT 'offen',
|
||
verknuepfte_bewerbung_id INTEGER,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
url_norm TEXT,
|
||
firma_slug TEXT,
|
||
labels TEXT,
|
||
UNIQUE (user_id, quelle, external_id),
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
|
||
)
|
||
`);
|
||
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)');
|
||
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)');
|
||
|
||
// Blacklist of job offers that must never (re)appear in the list (per user).
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS jobangebote_blacklist (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
typ TEXT NOT NULL DEFAULT 'auto',
|
||
url_norm TEXT,
|
||
domain TEXT,
|
||
quelle TEXT,
|
||
external_id TEXT,
|
||
firma_norm TEXT,
|
||
firma_slug TEXT,
|
||
stelle_norm TEXT,
|
||
ort_norm TEXT,
|
||
firma TEXT,
|
||
stelle TEXT,
|
||
quelle_url TEXT,
|
||
grund TEXT,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
|
||
// Application calendar appointments, mirrored to the CalDAV calendar.
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS termine (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
bewerbung_id INTEGER,
|
||
typ TEXT NOT NULL DEFAULT 'termin',
|
||
titel TEXT NOT NULL,
|
||
ort TEXT,
|
||
notiz TEXT,
|
||
start TEXT NOT NULL,
|
||
ende TEXT,
|
||
ganztags INTEGER DEFAULT 0,
|
||
erinnerung_min INTEGER DEFAULT 60,
|
||
caldav_uid TEXT,
|
||
caldav_href TEXT,
|
||
caldav_etag TEXT,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
|
||
)
|
||
`);
|
||
|
||
// Conversational KI-Chat: threads and their messages (per user).
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS chat_threads (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
titel TEXT,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL,
|
||
thread_id INTEGER NOT NULL,
|
||
role TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (thread_id) REFERENCES chat_threads(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
await exec('CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id)');
|
||
|
||
// Overridden KI system prompts (per user). Only edited prompts are stored.
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS prompts (
|
||
user_id INTEGER NOT NULL,
|
||
key TEXT NOT NULL,
|
||
inhalt TEXT NOT NULL,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (user_id, key),
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
|
||
// Design choices for the generated PDFs (per user, same contract as prompts).
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS design (
|
||
user_id INTEGER NOT NULL,
|
||
key TEXT NOT NULL,
|
||
value TEXT NOT NULL,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (user_id, key),
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
|
||
// Personal details of the applicant (one row per user).
|
||
await exec(`
|
||
CREATE TABLE IF NOT EXISTS settings (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL UNIQUE,
|
||
name TEXT,
|
||
adresse TEXT,
|
||
kundennummer TEXT,
|
||
ort TEXT,
|
||
webseite TEXT,
|
||
email TEXT,
|
||
telefon TEXT,
|
||
geburtsdatum TEXT,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)
|
||
`);
|
||
}
|
||
// Initialize and start server
|
||
initializeDatabase().then(async () => {
|
||
console.log('Database initialized successfully');
|
||
|
||
// Load configuration from the DB. Must run before the boot checks below
|
||
// (mailer/caldav configured?) and before any route that reads config — values
|
||
// live in the DB now, not in the environment.
|
||
await config.init({ dbAll, dbRun });
|
||
|
||
// Import any config still supplied via the environment into the *admin's* rows
|
||
// (one-time, idempotent). Env config is the admin's: config.get() has no
|
||
// process.env fallback, precisely so that a newly created user does not
|
||
// inherit the admin's mailbox, calendar, Ollama key and API token. This runs
|
||
// here rather than inside runMigration() because on a fresh install app_state
|
||
// does not exist yet while the migration is running.
|
||
await importEnvIntoAdmin({ dbAll, dbGet });
|
||
|
||
// Current user's id — set by the auth middleware (lib/context.js). Guaranteed
|
||
// to be present inside any protected route or background-per-user task.
|
||
const uid = () => currentUserId();
|
||
|
||
// Resolve a per-user storage directory (data/<base>/<userId>/), creating it
|
||
// on first use. Used for every attachment / signature / photo path so users'
|
||
// files are isolated on disk the same way their DB rows are.
|
||
const userDir = userStorageDir;
|
||
|
||
// Routes
|
||
app.get('/', async (req, res) => {
|
||
try {
|
||
const { month, year } = req.query;
|
||
const U = uid();
|
||
|
||
let query = 'SELECT * FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC, created_at DESC';
|
||
const params = [U];
|
||
if (month && year) {
|
||
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC';
|
||
params.push(month.padStart(2, '0'), year);
|
||
} else if (year) {
|
||
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC';
|
||
params.push(year);
|
||
}
|
||
|
||
const applications = await dbAll(query, params);
|
||
await attachVerlauf(applications);
|
||
applications.forEach((a) => { a.labelsArr = parseLabels(a.labels); });
|
||
// Upcoming calendar appointments for the dashboard widget.
|
||
const kommendeTermine = await upcomingTermine(6);
|
||
|
||
// Get statistics
|
||
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [U]);
|
||
const byArt = await dbAll(`
|
||
SELECT art, COUNT(*) as count FROM bewerbungen
|
||
WHERE user_id = ? AND art IS NOT NULL AND art != ''
|
||
GROUP BY art ORDER BY count DESC
|
||
`, [U]);
|
||
const byStatus = await dbAll(`
|
||
SELECT status, COUNT(*) as count FROM bewerbungen
|
||
WHERE user_id = ? AND status IS NOT NULL AND status != ''
|
||
GROUP BY status ORDER BY count DESC
|
||
`, [U]);
|
||
|
||
// Get available months/years for filter
|
||
const availableMonths = await dbAll(`
|
||
SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth,
|
||
strftime("%m", datum) as month,
|
||
strftime("%Y", datum) as year
|
||
FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC
|
||
`, [U]);
|
||
|
||
// Months/years for the PDF export, keyed by the effective date (last status
|
||
// change) so a period like Juli 2026 is selectable even when the underlying
|
||
// application was created in an earlier month.
|
||
const exportMonths = await dbAll(`
|
||
SELECT DISTINCT strftime("%Y-%m", eff) as yearmonth,
|
||
strftime("%m", eff) as month,
|
||
strftime("%Y", eff) as year
|
||
FROM (
|
||
SELECT COALESCE(
|
||
(SELECT MAX(date(sv.datum)) FROM status_verlauf sv WHERE sv.bewerbung_id = b.id AND sv.user_id = ?),
|
||
date(b.datum)
|
||
) AS eff
|
||
FROM bewerbungen b
|
||
WHERE b.user_id = ?
|
||
)
|
||
ORDER BY yearmonth DESC
|
||
`, [U, U]);
|
||
|
||
res.render('index', {
|
||
applications,
|
||
statistics: {
|
||
total: totalCount ? totalCount.count : 0,
|
||
byArt,
|
||
byStatus
|
||
},
|
||
availableMonths,
|
||
exportMonths,
|
||
currentFilter: { month, year },
|
||
kommendeTermine,
|
||
caldavTz: caldav.TZ,
|
||
artOptions: ART_OPTIONS,
|
||
statusOptions: STATUS_OPTIONS,
|
||
labelOptions: LABEL_OPTIONS
|
||
});
|
||
} catch (error) {
|
||
console.error('Error:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Get single application
|
||
app.get('/api/bewerbungen/:id', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||
|
||
if (!application) {
|
||
return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||
}
|
||
|
||
res.json(application);
|
||
} catch (error) {
|
||
console.error('Error getting application:', error);
|
||
res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
});
|
||
|
||
// Get settings
|
||
app.get('/api/settings', async (req, res) => {
|
||
try {
|
||
const settings = await loadSettings();
|
||
res.json(settings);
|
||
} catch (error) {
|
||
console.error('Error getting settings:', error);
|
||
res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
});
|
||
|
||
// Save settings
|
||
app.post('/api/settings', async (req, res) => {
|
||
try {
|
||
const { name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum } = req.body;
|
||
const U = uid();
|
||
|
||
await dbRun(
|
||
`INSERT INTO settings (user_id, name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(user_id) DO UPDATE SET
|
||
name = excluded.name, adresse = excluded.adresse, kundennummer = excluded.kundennummer,
|
||
email = excluded.email, telefon = excluded.telefon, ort = excluded.ort,
|
||
webseite = excluded.webseite, geburtsdatum = excluded.geburtsdatum`,
|
||
[
|
||
U,
|
||
sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer),
|
||
sanitizeInput(email), sanitizeInput(telefon), sanitizeInput(ort),
|
||
sanitizeInput(webseite), sanitizeInput(geburtsdatum)
|
||
]
|
||
);
|
||
|
||
res.json({ success: true });
|
||
} catch (error) {
|
||
console.error('Error saving settings:', error);
|
||
res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
});
|
||
|
||
// ----- Indeed import (called by the browser extension) -----
|
||
app.post('/api/indeed-import', async (req, res) => {
|
||
try {
|
||
const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url, art } = req.body || {};
|
||
|
||
if (!firma || !stelle) {
|
||
return res.status(400).json({ error: 'Firma und Stelle sind erforderlich.' });
|
||
}
|
||
|
||
// Source of the capture: honour an explicit art, else infer from the URL.
|
||
const quelle = deriveArt(quelle_url, art);
|
||
|
||
// Duplicate guard: don't silently import the same posting twice.
|
||
const forceImport = req.body.force === true || req.body.force === 'true';
|
||
if (!forceImport) {
|
||
const dups = await findDuplicateApplications({ firma, stelle, quelle_url });
|
||
if (dups.length) {
|
||
return res.status(409).json({
|
||
duplicate: true,
|
||
matches: dups,
|
||
error: 'Für diese Stelle existiert bereits eine Bewerbung.',
|
||
});
|
||
}
|
||
}
|
||
|
||
const datum = new Date().toISOString().split('T')[0];
|
||
// Keep the extra details (location, salary, source) visible in the notes too.
|
||
const notizParts = [
|
||
ort ? `Ort: ${ort}` : null,
|
||
gehalt ? `Gehalt: ${gehalt}` : null,
|
||
quelle_url ? `Quelle: ${quelle_url}` : null,
|
||
].filter(Boolean);
|
||
const notizen = notizParts.join('\n');
|
||
|
||
// Stored raw: every view renders these through EJS `<%= %>` (auto-escaped),
|
||
// so this is XSS-safe — and it keeps the text clean for the AI and the PDFs
|
||
// (no HTML entities leaking into the generated documents).
|
||
const result = await dbRun(
|
||
`INSERT INTO bewerbungen
|
||
(user_id, datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
|
||
VALUES (?, ?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
|
||
[uid(), datum, firma, stelle, quelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
|
||
);
|
||
|
||
// Record the initial "Entwurf" status in the timeline
|
||
await dbRun(
|
||
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
|
||
[uid(), result.lastID, datum, 'Entwurf', `Automatisch aus dem Browser importiert (${quelle})`]
|
||
);
|
||
|
||
// Note: generation is NOT started automatically — the user reviews the draft,
|
||
// adds LLM notes if needed, and triggers generation on the application page.
|
||
res.json({
|
||
success: true,
|
||
id: result.lastID,
|
||
url: `/bewerbung/${result.lastID}`,
|
||
message: 'Bewerbung als Entwurf angelegt. Unterlagen können auf der Bewerbungsseite generiert werden.',
|
||
});
|
||
} catch (error) {
|
||
console.error('Error importing job:', error);
|
||
res.status(500).json({ error: 'Serverfehler beim Import.' });
|
||
}
|
||
});
|
||
|
||
// Poll generation status + current attachments for one application
|
||
app.get('/api/bewerbungen/:id/generierung', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const bewerbung = await dbGet(
|
||
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]
|
||
);
|
||
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||
const anhaenge = await dbAll(
|
||
'SELECT id, name, dateiname, mime FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC', [id, uid()]
|
||
);
|
||
res.json({
|
||
status: bewerbung.generierung_status,
|
||
fehler: bewerbung.generierung_fehler,
|
||
anhaenge,
|
||
});
|
||
} catch (error) {
|
||
console.error('Error fetching generation status:', error);
|
||
res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
});
|
||
|
||
// ----- Base documents (Basis-Unterlagen / Vorlagen) -----
|
||
app.get('/api/basis-dokumente', async (req, res) => {
|
||
try {
|
||
const docs = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [uid()]);
|
||
res.json(docs);
|
||
} catch (error) {
|
||
console.error('Error listing base documents:', error);
|
||
res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
});
|
||
|
||
// Create application
|
||
app.post('/api/bewerbungen', async (req, res) => {
|
||
try {
|
||
const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body;
|
||
const labels = serializeLabels(req.body.labels);
|
||
|
||
// Duplicate guard: warn before creating a second application for the same
|
||
// company + role (the client re-submits with force=true to confirm).
|
||
const force = req.body.force === true || req.body.force === 'true';
|
||
if (!force) {
|
||
const dups = await findDuplicateApplications({ firma, stelle });
|
||
if (dups.length) {
|
||
return res.status(409).json({
|
||
duplicate: true,
|
||
matches: dups,
|
||
error: 'Es gibt bereits eine Bewerbung für dieselbe Firma und Stelle.',
|
||
});
|
||
}
|
||
}
|
||
|
||
const result = await dbRun(
|
||
'INSERT INTO bewerbungen (user_id, datum, firma, stelle, art, status, notizen, interne_notizen, labels) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||
[uid(), datum, sanitizeInput(firma), sanitizeInput(stelle),
|
||
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels]
|
||
);
|
||
|
||
// Record the initial status as the first timeline entry
|
||
if (status && status.trim()) {
|
||
await dbRun(
|
||
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
|
||
[uid(), result.lastID, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
|
||
);
|
||
}
|
||
|
||
const newApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
|
||
|
||
res.json({ success: true, application: newApplication });
|
||
} catch (error) {
|
||
console.error('Error creating application:', error);
|
||
res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
});
|
||
|
||
// Update application
|
||
app.put('/api/bewerbungen/:id', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const { datum, firma, stelle, art, status, notizen } = req.body;
|
||
const existing = await dbGet('SELECT labels FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||
const labels = Object.prototype.hasOwnProperty.call(req.body, 'labels')
|
||
? serializeLabels(req.body.labels)
|
||
: (existing ? existing.labels : '[]');
|
||
|
||
await dbRun(
|
||
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
|
||
[datum, sanitizeInput(firma), sanitizeInput(stelle),
|
||
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), labels, id, uid()]
|
||
);
|
||
|
||
const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||
|
||
res.json({ success: true, application: updatedApplication });
|
||
} catch (error) {
|
||
console.error('Error updating application:', error);
|
||
res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
});
|
||
|
||
// Delete application
|
||
app.delete('/api/bewerbungen/:id', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||
// Remove private attachments belonging to the internal notes.
|
||
const interne = await dbAll('SELECT pfad FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||
const intDir = userStorageDir(interneAnhaengeDir);
|
||
interne.forEach((a) => fs.promises.unlink(path.join(intDir, a.pfad)).catch(() => {}));
|
||
await dbRun('DELETE FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||
await dbRun('DELETE FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||
|
||
res.json({ success: true });
|
||
} catch (error) {
|
||
console.error('Error deleting application:', error);
|
||
res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
});
|
||
|
||
// Applications for PDF export (optionally filtered), including the status timeline
|
||
app.get('/api/export', async (req, res) => {
|
||
try {
|
||
const { month, year } = req.query;
|
||
const U = uid();
|
||
|
||
// Effektives Datum einer Bewerbung = Datum ihrer letzten Statusänderung
|
||
// (fällt auf das Bewerbungsdatum zurück, wenn es keinen Verlauf gibt). Der
|
||
// Monatsexport listet eine Bewerbung im Monat ihres LETZTEN Status: Eine im
|
||
// Juni gesendete Bewerbung, die im Juli zum Vorstellungsgespräch wird,
|
||
// erscheint dadurch im Export für Juli.
|
||
const base = `
|
||
SELECT b.*, COALESCE(
|
||
(SELECT MAX(date(sv.datum)) FROM status_verlauf sv WHERE sv.bewerbung_id = b.id AND sv.user_id = ?),
|
||
date(b.datum)
|
||
) AS eff_datum
|
||
FROM bewerbungen b
|
||
WHERE b.user_id = ?
|
||
`;
|
||
let query = `SELECT * FROM (${base}) ORDER BY eff_datum DESC`;
|
||
const params = [U, U];
|
||
|
||
if (month && year) {
|
||
query = `SELECT * FROM (${base}) AND strftime("%m", eff_datum) = ? AND strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`;
|
||
params.push(month.padStart(2, '0'), year);
|
||
} else if (month) {
|
||
// A month without a year must still restrict the export to that month —
|
||
// never fall through to exporting every application.
|
||
query = `SELECT * FROM (${base}) AND strftime("%m", eff_datum) = ? ORDER BY eff_datum DESC`;
|
||
params.push(month.padStart(2, '0'));
|
||
} else if (year) {
|
||
query = `SELECT * FROM (${base}) AND strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`;
|
||
params.push(year);
|
||
}
|
||
|
||
const applications = await dbAll(query, params);
|
||
await attachVerlauf(applications);
|
||
// Internal notes must never reach the PDF/export
|
||
applications.forEach((a) => { delete a.interne_notizen; });
|
||
|
||
res.json(applications);
|
||
} catch (error) {
|
||
console.error('Error exporting applications:', error);
|
||
res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
});
|
||
|
||
// ----- Dedicated edit page + status-timeline management -----
|
||
|
||
// Edit page for a single application
|
||
app.get('/bewerbung/:id', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const U = uid();
|
||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, U]);
|
||
if (!application) return res.status(404).send('Bewerbung nicht gefunden');
|
||
application.labelsArr = parseLabels(application.labels);
|
||
|
||
const verlauf = await dbAll(
|
||
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ? ORDER BY date(datum) ASC, id ASC',
|
||
[id, U]
|
||
);
|
||
|
||
const anhaenge = await dbAll(
|
||
'SELECT id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
|
||
[id, U]
|
||
);
|
||
// Private attachments belonging to the internal notes — never exported/sent.
|
||
const interneAnhaenge = await dbAll(
|
||
'SELECT id, name, dateiname, mime, created_at FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
|
||
[id, U]
|
||
);
|
||
const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente WHERE user_id = ?', [U]);
|
||
// Available static attachments (Zeugnisse etc.) to optionally enclose.
|
||
const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [U]);
|
||
// Calendar appointments for this application (mirrored to SOGo).
|
||
const termine = await dbAll('SELECT * FROM termine WHERE bewerbung_id = ? AND user_id = ? ORDER BY start ASC', [id, U]);
|
||
|
||
// E-Mail correspondence (sent + received), oldest first, with attachments.
|
||
const emails = await dbAll(
|
||
'SELECT * FROM emails WHERE bewerbung_id = ? AND user_id = ? ORDER BY datetime(email_date) ASC, id ASC',
|
||
[id, U]
|
||
);
|
||
if (emails.length) {
|
||
const eIds = emails.map((e) => e.id);
|
||
const atts = await dbAll(
|
||
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE user_id = ? AND email_id IN (${eIds.map(() => '?').join(',')})`,
|
||
[U, ...eIds]
|
||
);
|
||
const byEmail = {};
|
||
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
|
||
emails.forEach((e) => {
|
||
e.anhaenge = byEmail[e.id] || [];
|
||
// Bare address for prefilling a reply's "To" (from "Name <addr>").
|
||
const m = String(e.from_addr || '').match(/<([^>]+)>/);
|
||
e.from_addr_clean = m ? m[1] : String(e.from_addr || '').trim();
|
||
});
|
||
// HTML rendering (sandboxed iframe) + a quote of each received message.
|
||
decorateEmails(emails);
|
||
// Mark received messages as read now that they are shown.
|
||
await dbRun("UPDATE emails SET seen = 1 WHERE bewerbung_id = ? AND user_id = ? AND direction = 'in' AND seen = 0", [id, U]);
|
||
}
|
||
|
||
res.render('bewerbung', {
|
||
application,
|
||
verlauf,
|
||
anhaenge,
|
||
interneAnhaenge,
|
||
emails,
|
||
// Last choice of documents (defaults to both), for pre-ticking the form.
|
||
dokumentAuswahl: normalizeDokumente(
|
||
application.generierung_dokumente ? String(application.generierung_dokumente).split(',') : null
|
||
),
|
||
mailConfigured: mailer.isConfigured(),
|
||
mailFrom: mailer.isConfigured() ? mailer.fromField() : '',
|
||
mailError: req.query.mailerror ? String(req.query.mailerror) : '',
|
||
mailOk: req.query.mailok ? String(req.query.mailok) : '',
|
||
basisCount: basisCountRow ? basisCountRow.count : 0,
|
||
basisAnhaenge,
|
||
termine,
|
||
caldavConfigured: caldav.isConfigured(),
|
||
caldavTz: caldav.TZ,
|
||
terminVorschlag: req.query.vorschlag === 'vg' ? { datum: String(req.query.vdatum || '') } : null,
|
||
terminOk: !!req.query.terminok,
|
||
terminError: req.query.terminerror ? String(req.query.terminerror) : '',
|
||
artOptions: ART_OPTIONS,
|
||
statusOptions: STATUS_OPTIONS,
|
||
labelOptions: LABEL_OPTIONS,
|
||
hideSettings: true
|
||
});
|
||
} catch (error) {
|
||
console.error('Error loading edit page:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Update application core data (status is managed via the timeline)
|
||
app.post('/bewerbung/:id', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const { datum, firma, stelle, art, notizen, interne_notizen } = req.body;
|
||
const labels = serializeLabels(req.body.labels);
|
||
|
||
await dbRun(
|
||
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
|
||
[datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels, id, uid()]
|
||
);
|
||
|
||
res.redirect('/bewerbung/' + id);
|
||
} catch (error) {
|
||
console.error('Error updating application:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Save the editable e-mail cover text (Begleit-E-Mail) after the user tweaks it.
|
||
app.post('/bewerbung/:id/email', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
|
||
|
||
// Store raw; the value is HTML-escaped on render (EJS <%= %>), matching how
|
||
// the generated e-mail is stored. Sanitising here would double-escape.
|
||
await dbRun(
|
||
'UPDATE bewerbungen SET email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
|
||
[String(req.body.email_betreff || ''), String(req.body.email_anschreiben || ''), id, uid()]
|
||
);
|
||
res.redirect('/bewerbung/' + id + '#email');
|
||
} catch (error) {
|
||
console.error('Error saving e-mail text:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Send an e-mail for an application (initial application or a reply). Sends
|
||
// via authenticated submission, records it as an outgoing message and links
|
||
// the chosen generated attachments.
|
||
app.post('/bewerbung/:id/email/send', async (req, res) => {
|
||
const { id } = req.params;
|
||
const back = (frag) => '/bewerbung/' + id + (frag || '#korrespondenz');
|
||
try {
|
||
const U = uid();
|
||
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, U]);
|
||
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
|
||
if (!mailer.isConfigured()) {
|
||
return res.redirect(back('?mailerror=' + encodeURIComponent('E-Mail ist nicht konfiguriert (Einstellungen).') + '#korrespondenz'));
|
||
}
|
||
|
||
const to = String(req.body.to || '').trim();
|
||
const subject = String(req.body.subject || '').trim();
|
||
const body = String(req.body.body || '');
|
||
if (!to || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to)) {
|
||
return res.redirect(back('?mailerror=' + encodeURIComponent('Bitte eine gültige Empfänger-Adresse angeben.') + '#korrespondenz'));
|
||
}
|
||
|
||
// Selected generated attachments (checkbox values = anhaenge ids).
|
||
let anhangIds = req.body.anhang || [];
|
||
if (!Array.isArray(anhangIds)) anhangIds = [anhangIds];
|
||
const attachments = [];
|
||
const attNames = [];
|
||
const anhaengeUserDir = userStorageDir(anhaengeDir);
|
||
for (const aid of anhangIds) {
|
||
const a = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [aid, id, U]);
|
||
if (!a) continue;
|
||
const p = path.join(anhaengeUserDir, a.pfad);
|
||
if (!fs.existsSync(p)) continue;
|
||
attachments.push({ filename: a.dateiname, path: p, contentType: a.mime || undefined });
|
||
attNames.push(a.dateiname);
|
||
}
|
||
|
||
// Selected static attachments (Zeugnisse etc.), e.g. from a reply form.
|
||
// Default: none selected.
|
||
let basisAnlageIds = req.body.basis_anlage || [];
|
||
if (!Array.isArray(basisAnlageIds)) basisAnlageIds = [basisAnlageIds];
|
||
const basisUserDir = userStorageDir(basisAnhaengeDir);
|
||
for (const bid of basisAnlageIds) {
|
||
const ba = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [bid, U]);
|
||
if (!ba) continue;
|
||
const p = path.join(basisUserDir, ba.pfad);
|
||
if (!fs.existsSync(p)) continue;
|
||
attachments.push({ filename: ba.dateiname, path: p, contentType: ba.mime || undefined });
|
||
attNames.push(ba.dateiname);
|
||
}
|
||
|
||
// Threading headers when this is a reply to a stored message.
|
||
let inReplyTo = null, references = null;
|
||
if (req.body.reply_to) {
|
||
const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [req.body.reply_to, id, U]);
|
||
if (orig && orig.message_id) {
|
||
inReplyTo = '<' + orig.message_id + '>';
|
||
references = ((orig.email_references ? orig.email_references + ' ' : '') + inReplyTo).trim();
|
||
}
|
||
}
|
||
|
||
const info = await mailer.sendMail({
|
||
to, subject, text: body, attachments,
|
||
inReplyTo, references,
|
||
});
|
||
const mid = String(info.messageId || '').replace(/[<>]/g, '');
|
||
|
||
const emailRow = await dbRun(
|
||
`INSERT INTO emails (user_id, bewerbung_id, direction, message_id, in_reply_to, email_references,
|
||
from_addr, to_addr, subject, body_text, attachments_json, seen, email_date)
|
||
VALUES (?, ?, 'out', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
|
||
[U, id, mid, inReplyTo ? inReplyTo.replace(/[<>]/g, '') : null, references ? references.replace(/[<>]/g, '') : null,
|
||
mailer.fromAddress(), to, subject, body, JSON.stringify(attNames), new Date().toISOString()]
|
||
);
|
||
|
||
// Keep a copy of each sent attachment linked to the outgoing message so it
|
||
// is shown (and can be reopened) in the correspondence view afterwards.
|
||
const emailAttDir = userStorageDir(emailAnhaengeDir);
|
||
for (const att of attachments) {
|
||
const safe = String(att.filename || 'anhang').replace(/[^a-zA-Z0-9äöüÄÖÜß._ -]/g, '_').slice(0, 80);
|
||
const storedName = `${emailRow.lastID}_${Date.now()}_${safe}`;
|
||
try {
|
||
fs.copyFileSync(att.path, path.join(emailAttDir, storedName));
|
||
await dbRun('INSERT INTO email_anhaenge (user_id, email_id, name, mime, pfad) VALUES (?, ?, ?, ?, ?)',
|
||
[U, emailRow.lastID, att.filename, att.contentType || null, storedName]);
|
||
} catch (e) { /* ignore a single bad attachment */ }
|
||
}
|
||
|
||
await dbRun('UPDATE bewerbungen SET email_empfaenger = ? WHERE id = ? AND user_id = ?', [to, id, U]);
|
||
|
||
res.redirect(back('?mailok=' + encodeURIComponent('E-Mail an ' + to + ' gesendet.') + '#korrespondenz'));
|
||
} catch (error) {
|
||
console.error('Error sending e-mail:', error);
|
||
res.redirect(back('?mailerror=' + encodeURIComponent('Versand fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')) + '#korrespondenz'));
|
||
}
|
||
});
|
||
|
||
// AI-draft a reply to a received e-mail. Returns JSON {betreff, text} that the
|
||
// frontend drops into the reply form for the user to edit before sending.
|
||
app.post('/bewerbung/:id/email/ai-reply', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||
const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [req.body.email_id, id, uid()]);
|
||
if (!orig) return res.status(404).json({ error: 'Nachricht nicht gefunden' });
|
||
const settings = await loadSettings();
|
||
|
||
const draft = await generateEmailReply({
|
||
incoming: { from: orig.from_addr, subject: orig.subject, text: emailPlainText(orig) },
|
||
job: { firma: bewerbung.firma, stelle: bewerbung.stelle },
|
||
settings,
|
||
prompts: await loadPrompts(),
|
||
hinweise: String(req.body.hinweise || ''),
|
||
typ: String(req.body.typ || 'antwort'),
|
||
});
|
||
// Include the quoted original so the reply reads like a mail-client thread.
|
||
res.json({ ...draft, quote: buildReplyQuote(orig) });
|
||
} catch (error) {
|
||
console.error('Error drafting AI reply:', error);
|
||
res.status(500).json({ error: error.message || 'Serverfehler' });
|
||
}
|
||
});
|
||
|
||
// Manually trigger an IMAP fetch of new replies, then return to the referring page.
|
||
app.post('/email/fetch', async (req, res) => {
|
||
const back = req.body.back || req.get('referer') || '/';
|
||
try {
|
||
await pollInbox();
|
||
} catch (e) { /* errors are logged inside pollInbox */ }
|
||
res.redirect(back);
|
||
});
|
||
|
||
// Download an attachment that arrived with a received e-mail.
|
||
app.get('/email-anhaenge/:id/download', async (req, res) => {
|
||
try {
|
||
const a = await dbGet('SELECT * FROM email_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||
if (!a) return res.status(404).send('Anhang nicht gefunden');
|
||
const p = path.join(userStorageDir(emailAnhaengeDir), a.pfad);
|
||
if (!fs.existsSync(p)) return res.status(404).send('Datei nicht gefunden');
|
||
// inline=1 opens viewable files (PDF/image) in the browser tab instead of forcing a download.
|
||
if (req.query.inline === '1') return serveInline(res, p, a.name || a.pfad, a.mime);
|
||
res.download(p, a.name || a.pfad);
|
||
} catch (error) {
|
||
console.error('Error downloading e-mail attachment:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Postfach: incoming e-mails that landed in the inbox but could not be
|
||
// matched to an application automatically. The user assigns them by hand.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Page listing all e-mails with no bewerbung_id, plus every application as
|
||
// assignment target.
|
||
app.get('/postfach', async (req, res) => {
|
||
try {
|
||
const U = uid();
|
||
const emails = await dbAll(
|
||
`SELECT * FROM emails WHERE user_id = ? AND bewerbung_id IS NULL ORDER BY datetime(email_date) DESC, id DESC`,
|
||
[U]
|
||
);
|
||
if (emails.length) {
|
||
const eIds = emails.map((e) => e.id);
|
||
const atts = await dbAll(
|
||
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE user_id = ? AND email_id IN (${eIds.map(() => '?').join(',')})`,
|
||
[U, ...eIds]
|
||
);
|
||
const byEmail = {};
|
||
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
|
||
emails.forEach((e) => { e.anhaenge = byEmail[e.id] || []; });
|
||
decorateEmails(emails);
|
||
// These are read now that they are shown — clears them from the bell.
|
||
// (The `seen` values above are captured pre-update, so the "Neu" badge
|
||
// still renders on this view.)
|
||
await dbRun("UPDATE emails SET seen = 1 WHERE user_id = ? AND bewerbung_id IS NULL AND direction = 'in' AND seen = 0", [U]);
|
||
}
|
||
// Applications the user can assign an e-mail to (newest first).
|
||
const bewerbungen = await dbAll(
|
||
`SELECT id, firma, stelle, ort, datum FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC, created_at DESC`,
|
||
[U]
|
||
);
|
||
res.render('postfach', {
|
||
emails,
|
||
bewerbungen,
|
||
mailConfigured: mailer.isConfigured(),
|
||
mailError: req.query.mailerror ? String(req.query.mailerror) : '',
|
||
mailOk: req.query.mailok ? String(req.query.mailok) : '',
|
||
hideSettings: true,
|
||
});
|
||
} catch (error) {
|
||
console.error('Error loading postfach:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Assign an unlinked e-mail to an existing application.
|
||
app.post('/postfach/:emailId/zuweisen', async (req, res) => {
|
||
try {
|
||
const bewerbungId = Number(req.body.bewerbung_id);
|
||
if (!bewerbungId) {
|
||
return res.redirect('/postfach?mailerror=' + encodeURIComponent('Bitte eine Bewerbung auswählen.'));
|
||
}
|
||
// Make sure the target application exists and the e-mail is still unlinked.
|
||
const app = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [bewerbungId, uid()]);
|
||
if (!app) {
|
||
return res.redirect('/postfach?mailerror=' + encodeURIComponent('Ausgewählte Bewerbung existiert nicht.'));
|
||
}
|
||
await dbRun('UPDATE emails SET bewerbung_id = ? WHERE id = ? AND user_id = ? AND bewerbung_id IS NULL', [bewerbungId, req.params.emailId, uid()]);
|
||
res.redirect('/postfach?mailok=' + encodeURIComponent('E-Mail wurde der Bewerbung zugewiesen.'));
|
||
} catch (error) {
|
||
console.error('Error assigning e-mail:', error);
|
||
res.redirect('/postfach?mailerror=' + encodeURIComponent('Zuweisung fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')));
|
||
}
|
||
});
|
||
|
||
// Delete an e-mail from the Postfach (removes its stored attachment files too).
|
||
app.post('/postfach/:emailId/delete', async (req, res) => {
|
||
try {
|
||
const emailId = req.params.emailId;
|
||
const atts = await dbAll('SELECT pfad FROM email_anhaenge WHERE email_id = ? AND user_id = ?', [emailId, uid()]);
|
||
const attDir = userStorageDir(emailAnhaengeDir);
|
||
for (const a of atts) {
|
||
fs.promises.unlink(path.join(attDir, a.pfad)).catch(() => {});
|
||
}
|
||
await dbRun('DELETE FROM email_anhaenge WHERE email_id = ? AND user_id = ?', [emailId, uid()]);
|
||
await dbRun('DELETE FROM emails WHERE id = ? AND user_id = ?', [emailId, uid()]);
|
||
res.redirect('/postfach?mailok=' + encodeURIComponent('E-Mail wurde gelöscht.'));
|
||
} catch (error) {
|
||
console.error('Error deleting e-mail:', error);
|
||
res.redirect('/postfach?mailerror=' + encodeURIComponent('Löschen fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')));
|
||
}
|
||
});
|
||
|
||
// Count of unlinked e-mails — drives the "Postfach" header badge on every page.
|
||
app.get('/api/emails/unassigned-count', async (req, res) => {
|
||
try {
|
||
const row = await dbGet('SELECT COUNT(*) as count FROM emails WHERE user_id = ? AND bewerbung_id IS NULL', [uid()]);
|
||
res.json({ count: row ? row.count : 0 });
|
||
} catch (error) {
|
||
res.status(500).json({ count: 0 });
|
||
}
|
||
});
|
||
|
||
// Unread received e-mails — drives the notification bell on every page. Returns
|
||
// the total unread count plus the newest few as a ready-to-render list. Replies
|
||
// auto-assigned to an application carry a link to that application; unassigned
|
||
// mail links to the Postfach.
|
||
app.get('/api/notifications', async (req, res) => {
|
||
try {
|
||
const cntRow = await dbGet(
|
||
"SELECT COUNT(*) AS count FROM emails WHERE user_id = ? AND direction = 'in' AND seen = 0",
|
||
[uid()]
|
||
);
|
||
const rows = await dbAll(
|
||
`SELECT e.id, e.from_addr, e.subject, e.body_text, e.body_html, e.email_date, e.bewerbung_id,
|
||
b.firma, b.stelle
|
||
FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id AND b.user_id = e.user_id
|
||
WHERE e.user_id = ? AND e.direction = 'in' AND e.seen = 0
|
||
ORDER BY datetime(e.email_date) DESC, e.id DESC
|
||
LIMIT 30`,
|
||
[uid()]
|
||
);
|
||
const items = rows.map((e) => {
|
||
const fromName = String(e.from_addr || '').replace(/<[^>]*>/, '').replace(/"/g, '').trim()
|
||
|| String(e.from_addr || '').trim();
|
||
const snippet = emailPlainText(e).replace(/\s+/g, ' ').trim().slice(0, 140);
|
||
return {
|
||
id: e.id,
|
||
from: fromName || '(unbekannt)',
|
||
subject: e.subject || '(kein Betreff)',
|
||
snippet,
|
||
date: e.email_date || null,
|
||
bewerbung_id: e.bewerbung_id || null,
|
||
kontext: e.bewerbung_id ? [e.firma, e.stelle].filter(Boolean).join(' · ') : '',
|
||
url: e.bewerbung_id ? ('/bewerbung/' + e.bewerbung_id + '#korrespondenz') : '/postfach',
|
||
};
|
||
});
|
||
res.json({ count: cntRow ? cntRow.count : 0, items });
|
||
} catch (error) {
|
||
res.status(500).json({ count: 0, items: [] });
|
||
}
|
||
});
|
||
|
||
// Mark every received e-mail as read (clears the notification bell).
|
||
app.post('/api/emails/mark-all-read', async (req, res) => {
|
||
try {
|
||
await dbRun("UPDATE emails SET seen = 1 WHERE user_id = ? AND direction = 'in' AND seen = 0", [uid()]);
|
||
res.json({ ok: true });
|
||
} catch (error) {
|
||
res.status(500).json({ ok: false });
|
||
}
|
||
});
|
||
|
||
// Add a timeline entry (status change with date + comment)
|
||
app.post('/bewerbung/:id/verlauf', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const { datum, status, kommentar } = req.body;
|
||
|
||
if (datum && status && status.trim()) {
|
||
await dbRun(
|
||
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
|
||
[uid(), id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
|
||
);
|
||
await syncCurrentStatus(id);
|
||
|
||
// Suggest a calendar entry when an interview was recorded (confirm + click).
|
||
if (caldav.isConfigured() && /vorstellungsgespr/i.test(status)) {
|
||
return res.redirect('/bewerbung/' + id + '?vorschlag=vg&vdatum=' + encodeURIComponent(datum) + '#termine');
|
||
}
|
||
}
|
||
|
||
res.redirect('/bewerbung/' + id);
|
||
} catch (error) {
|
||
console.error('Error adding timeline entry:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// --- Application calendar appointments (mirrored to SOGo via CalDAV) ---
|
||
app.post('/bewerbung/:id/termine', async (req, res) => {
|
||
const { id } = req.params;
|
||
try {
|
||
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
|
||
if (!caldav.isConfigured()) {
|
||
return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Kalender ist nicht konfiguriert.') + '#termine');
|
||
}
|
||
const b = req.body || {};
|
||
if (!b.datum) {
|
||
return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Bitte ein Datum angeben.') + '#termine');
|
||
}
|
||
const ganztags = b.ganztags === 'on' || b.ganztags === '1' || b.ganztags === 'true';
|
||
const typ = b.typ === 'vorstellungsgespraech' ? 'vorstellungsgespraech' : 'termin';
|
||
const titel = (b.titel || '').trim() || (typ === 'vorstellungsgespraech' ? 'Vorstellungsgespräch' : 'Termin');
|
||
const erinnerung = Math.max(0, parseInt(b.erinnerung_min, 10) || 0);
|
||
|
||
let start, ende = null;
|
||
if (ganztags) {
|
||
const [y, mo, d] = String(b.datum).split('-').map(Number);
|
||
start = caldav.wallToUtc(y, mo, d, 0, 0);
|
||
} else {
|
||
start = caldav.localInputToUtc(b.datum, b.von || '09:00');
|
||
if (b.bis) ende = caldav.localInputToUtc(b.datum, b.bis);
|
||
}
|
||
|
||
const created = await caldav.createEvent({
|
||
summary: titel, location: b.ort || '', description: b.notiz || '',
|
||
start, end: ende, allDay: ganztags, alarmMin: erinnerung,
|
||
});
|
||
|
||
await dbRun(
|
||
`INSERT INTO termine (user_id, bewerbung_id, typ, titel, ort, notiz, start, ende, ganztags, erinnerung_min, caldav_uid, caldav_href, caldav_etag)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
[uid(), id, typ, sanitizeInput(titel), sanitizeInput(b.ort || ''), sanitizeInput(b.notiz || ''),
|
||
start.toISOString(), ende ? ende.toISOString() : null, ganztags ? 1 : 0, erinnerung,
|
||
created.uid, created.href, created.etag]
|
||
);
|
||
res.redirect('/bewerbung/' + id + '?terminok=1#termine');
|
||
} catch (error) {
|
||
console.error('Error creating termin:', error);
|
||
res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent(error.message || 'Termin konnte nicht angelegt werden.') + '#termine');
|
||
}
|
||
});
|
||
|
||
app.post('/bewerbung/:id/termine/:tid/delete', async (req, res) => {
|
||
const { id, tid } = req.params;
|
||
try {
|
||
const t = await dbGet('SELECT * FROM termine WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [tid, id, uid()]);
|
||
if (t) {
|
||
await caldav.deleteEvent({ href: t.caldav_href, etag: t.caldav_etag }).catch((e) => console.warn('CalDAV delete:', e.message));
|
||
await dbRun('DELETE FROM termine WHERE id = ? AND user_id = ?', [tid, uid()]);
|
||
}
|
||
res.redirect('/bewerbung/' + id + '#termine');
|
||
} catch (error) {
|
||
console.error('Error deleting termin:', error);
|
||
res.redirect('/bewerbung/' + id + '#termine');
|
||
}
|
||
});
|
||
|
||
// Update a timeline entry
|
||
app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => {
|
||
try {
|
||
const { id, eintragId } = req.params;
|
||
const { datum, status, kommentar } = req.body;
|
||
|
||
if (datum && status && status.trim()) {
|
||
await dbRun(
|
||
'UPDATE status_verlauf SET datum = ?, status = ?, kommentar = ? WHERE id = ? AND bewerbung_id = ? AND user_id = ?',
|
||
[datum, sanitizeInput(status), sanitizeInput(kommentar || ''), eintragId, id, uid()]
|
||
);
|
||
await syncCurrentStatus(id);
|
||
}
|
||
|
||
res.redirect('/bewerbung/' + id);
|
||
} catch (error) {
|
||
console.error('Error updating timeline entry:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Delete a timeline entry
|
||
app.post('/bewerbung/:id/verlauf/:eintragId/delete', async (req, res) => {
|
||
try {
|
||
const { id, eintragId } = req.params;
|
||
await dbRun('DELETE FROM status_verlauf WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [eintragId, id, uid()]);
|
||
await syncCurrentStatus(id);
|
||
res.redirect('/bewerbung/' + id);
|
||
} catch (error) {
|
||
console.error('Error deleting timeline entry:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// ----- Vorlagen (Basis-Unterlagen) management page -----
|
||
app.get('/vorlagen', async (req, res) => {
|
||
try {
|
||
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [uid()]);
|
||
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [uid()]);
|
||
const design = await loadDesign();
|
||
res.render('vorlagen', {
|
||
basisDokumente,
|
||
basisAnhaenge,
|
||
settings: await loadSettings(),
|
||
prompts: promptStore.list(await loadPrompts()),
|
||
designFelder: designStore.list(design),
|
||
designAngepasst: designStore.isAngepasst(design),
|
||
designFotoAn: designStore.settings(design).foto_anzeigen === '1',
|
||
hasSignatur: Boolean(currentSignaturFile()),
|
||
hasFoto: Boolean(currentFotoFile()),
|
||
basisTypOptions: BASIS_TYP_OPTIONS,
|
||
hasApiKey: Boolean(config.get('OLLAMA_API_KEY')),
|
||
hideSettings: true,
|
||
});
|
||
} catch (error) {
|
||
console.error('Error loading vorlagen:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// ----- Persönliche Angaben (under Vorlagen, formerly the index-page modal) -----
|
||
// Registered before /vorlagen/:id so the generic base-document handler doesn't
|
||
// swallow this path. Stores name/address/contact data that flows into every
|
||
// generated document (replacing the previous KI extraction from basis texts).
|
||
app.post('/vorlagen/persoenlich', async (req, res) => {
|
||
try {
|
||
const { name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum } = req.body;
|
||
await dbRun(
|
||
`INSERT INTO settings (user_id, name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(user_id) DO UPDATE SET
|
||
name = excluded.name, adresse = excluded.adresse, kundennummer = excluded.kundennummer,
|
||
email = excluded.email, telefon = excluded.telefon, ort = excluded.ort,
|
||
webseite = excluded.webseite, geburtsdatum = excluded.geburtsdatum`,
|
||
[
|
||
uid(),
|
||
sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer),
|
||
sanitizeInput(email), sanitizeInput(telefon), sanitizeInput(ort),
|
||
sanitizeInput(webseite), sanitizeInput(geburtsdatum)
|
||
]
|
||
);
|
||
res.redirect('/vorlagen#persoenlich');
|
||
} catch (error) {
|
||
console.error('Error saving personal data:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// ----- Editable KI prompts -----
|
||
// Registered before /vorlagen/:id so the generic base-document handlers don't
|
||
// swallow these paths. The prompt text is stored raw (no HTML escaping): it is
|
||
// sent to the LLM, never rendered as markup — the views escape it on output.
|
||
|
||
// Save an overridden prompt. Empty text = fall back to the default.
|
||
app.post('/vorlagen/prompts/:key', async (req, res) => {
|
||
try {
|
||
const { key } = req.params;
|
||
if (!promptStore.isKnownKey(key)) return res.status(404).send('Unbekannter Prompt');
|
||
const inhalt = String(req.body.inhalt || '').trim();
|
||
if (!inhalt || inhalt === promptStore.defaultText(key).trim()) {
|
||
await dbRun('DELETE FROM prompts WHERE user_id = ? AND key = ?', [uid(), key]);
|
||
} else {
|
||
await dbRun(
|
||
`INSERT INTO prompts (user_id, key, inhalt, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||
ON CONFLICT(user_id, key) DO UPDATE SET inhalt = excluded.inhalt, updated_at = CURRENT_TIMESTAMP`,
|
||
[uid(), key, inhalt]
|
||
);
|
||
}
|
||
res.redirect('/vorlagen#prompts');
|
||
} catch (error) {
|
||
console.error('Error saving prompt:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Restore the default text of a prompt by dropping the override.
|
||
app.post('/vorlagen/prompts/:key/reset', async (req, res) => {
|
||
try {
|
||
await dbRun('DELETE FROM prompts WHERE user_id = ? AND key = ?', [uid(), req.params.key]);
|
||
res.redirect('/vorlagen#prompts');
|
||
} catch (error) {
|
||
console.error('Error resetting prompt:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// ----- Design of the generated PDFs -----
|
||
|
||
// Save the design choices. Values are validated by lib/design.js on read, and
|
||
// a choice equal to the default is stored as a deletion, so the DB only ever
|
||
// holds real deviations.
|
||
app.post('/vorlagen/design', async (req, res) => {
|
||
try {
|
||
const gewaehlt = designStore.settings(req.body);
|
||
// Compare against what the *chosen layout* ships with, not the global
|
||
// defaults — otherwise picking "social" would persist its own pink/round
|
||
// defaults as if the user had overridden them. `layout` itself keeps the
|
||
// global default as its yardstick, so choosing a non-default layout is
|
||
// stored (comparing it against itself would never save anything).
|
||
const basis = {
|
||
...designStore.DEFAULTS,
|
||
...(designStore.LAYOUT_DEFAULTS[gewaehlt.layout] || {}),
|
||
layout: designStore.DEFAULTS.layout,
|
||
};
|
||
for (const [key, value] of Object.entries(gewaehlt)) {
|
||
if (value === basis[key]) {
|
||
await dbRun('DELETE FROM design WHERE user_id = ? AND key = ?', [uid(), key]);
|
||
} else {
|
||
await dbRun(
|
||
`INSERT INTO design (user_id, key, value, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
|
||
[uid(), key, value]
|
||
);
|
||
}
|
||
}
|
||
res.redirect('/vorlagen#design');
|
||
} catch (error) {
|
||
console.error('Error saving design:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Back to the shipped design.
|
||
app.post('/vorlagen/design/reset', async (req, res) => {
|
||
try {
|
||
await dbRun('DELETE FROM design WHERE user_id = ?', [uid()]);
|
||
res.redirect('/vorlagen#design');
|
||
} catch (error) {
|
||
console.error('Error resetting design:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Preview PDF (Anschreiben or Lebenslauf) with sample content — lets the user
|
||
// see a design choice without spending an LLM run. Query params override the
|
||
// saved design, so the form can preview a selection before it is saved.
|
||
app.get('/vorlagen/design/vorschau/:doc.pdf', async (req, res) => {
|
||
try {
|
||
const welches = req.params.doc === 'lebenslauf' ? 'lebenslauf' : 'anschreiben';
|
||
const gespeichert = await loadDesign();
|
||
// Only known design keys from the query are honoured; design.settings()
|
||
// drops anything invalid.
|
||
const design = { ...gespeichert, ...req.query };
|
||
const settings = await loadSettings();
|
||
const pdfs = renderDesignVorschau({
|
||
settings,
|
||
signatur: loadSignatur(),
|
||
bewerbungsfoto: loadFoto(),
|
||
design,
|
||
});
|
||
res.type('application/pdf');
|
||
res.setHeader('Content-Disposition', `inline; filename="Vorschau_${welches}.pdf"`);
|
||
res.send(pdfs[welches]);
|
||
} catch (error) {
|
||
console.error('Error rendering design preview:', error);
|
||
res.status(500).send('Vorschau konnte nicht erzeugt werden');
|
||
}
|
||
});
|
||
|
||
// Add a base document
|
||
app.post('/vorlagen', async (req, res) => {
|
||
try {
|
||
const { typ, name, inhalt } = req.body;
|
||
if (inhalt && inhalt.trim()) {
|
||
await dbRun(
|
||
'INSERT INTO basis_dokumente (user_id, typ, name, inhalt) VALUES (?, ?, ?, ?)',
|
||
[uid(), sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt]
|
||
);
|
||
}
|
||
res.redirect('/vorlagen');
|
||
} catch (error) {
|
||
console.error('Error adding base document:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Update a base document
|
||
app.post('/vorlagen/:id', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const { typ, name, inhalt } = req.body;
|
||
await dbRun(
|
||
'UPDATE basis_dokumente SET typ = ?, name = ?, inhalt = ? WHERE id = ? AND user_id = ?',
|
||
[sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt || '', id, uid()]
|
||
);
|
||
res.redirect('/vorlagen');
|
||
} catch (error) {
|
||
console.error('Error updating base document:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Delete a base document
|
||
app.post('/vorlagen/:id/delete', async (req, res) => {
|
||
try {
|
||
await dbRun('DELETE FROM basis_dokumente WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||
res.redirect('/vorlagen');
|
||
} catch (error) {
|
||
console.error('Error deleting base document:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// ----- Static extra attachments (Zeugnisse etc.) -----
|
||
|
||
// Upload an attachment
|
||
app.post('/anlagen', (req, res) => {
|
||
uploadBasisAnhang(req, res, async (err) => {
|
||
try {
|
||
if (err) {
|
||
console.error('Upload error:', err.message);
|
||
return res.redirect('/vorlagen');
|
||
}
|
||
if (req.file) {
|
||
const original = req.file.originalname || req.file.filename;
|
||
const name = (req.body.name && req.body.name.trim())
|
||
? sanitizeInput(req.body.name.trim())
|
||
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
|
||
await dbRun(
|
||
'INSERT INTO basis_anhaenge (user_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
|
||
[uid(), name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
|
||
);
|
||
}
|
||
res.redirect('/vorlagen');
|
||
} catch (error) {
|
||
console.error('Error saving attachment:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Download a static attachment
|
||
app.get('/anlagen/:id/download', async (req, res) => {
|
||
try {
|
||
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||
if (!a) return res.status(404).send('Anlage nicht gefunden');
|
||
const filePath = path.join(userStorageDir(basisAnhaengeDir), a.pfad);
|
||
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||
res.download(filePath, a.dateiname);
|
||
} catch (error) {
|
||
console.error('Error downloading attachment:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Delete a static attachment
|
||
app.post('/anlagen/:id/delete', async (req, res) => {
|
||
try {
|
||
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||
if (a) {
|
||
fs.promises.unlink(path.join(userStorageDir(basisAnhaengeDir), a.pfad)).catch(() => {});
|
||
await dbRun('DELETE FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||
}
|
||
res.redirect('/vorlagen');
|
||
} catch (error) {
|
||
console.error('Error deleting attachment:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// ----- Signature (Unterschrift) -----
|
||
|
||
// Serve the current signature image (for the preview on the Vorlagen page)
|
||
app.get('/unterschrift', (req, res) => {
|
||
const file = currentSignaturFile();
|
||
if (!file) return res.status(404).send('Keine Unterschrift');
|
||
res.sendFile(file);
|
||
});
|
||
|
||
// Upload / replace the signature
|
||
app.post('/unterschrift', (req, res) => {
|
||
uploadSignatur(req, res, (err) => {
|
||
try {
|
||
if (err) console.error('Signature upload error:', err.message);
|
||
if (req.file) {
|
||
// keep only the newly uploaded file (in the user's subdir)
|
||
const dir = userStorageDir(signaturDir);
|
||
fs.readdirSync(dir).forEach((f) => {
|
||
if (f !== req.file.filename) fs.promises.unlink(path.join(dir, f)).catch(() => {});
|
||
});
|
||
}
|
||
res.redirect('/vorlagen');
|
||
} catch (error) {
|
||
console.error('Error saving signature:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Delete the signature
|
||
app.post('/unterschrift/delete', (req, res) => {
|
||
try {
|
||
const dir = userStorageDir(signaturDir);
|
||
fs.readdirSync(dir).forEach((f) => fs.promises.unlink(path.join(dir, f)).catch(() => {}));
|
||
} catch (e) { /* ignore */ }
|
||
res.redirect('/vorlagen');
|
||
});
|
||
|
||
// ----- Applicant photo (Bewerberfoto, used in the CV) -----
|
||
|
||
// Serve the current photo (for the preview on the Vorlagen page)
|
||
app.get('/bewerbungsfoto', (req, res) => {
|
||
const file = currentFotoFile();
|
||
if (!file) return res.status(404).send('Kein Bewerberfoto');
|
||
res.sendFile(file);
|
||
});
|
||
|
||
// Upload / replace the photo
|
||
app.post('/bewerbungsfoto', (req, res) => {
|
||
uploadFoto(req, res, (err) => {
|
||
try {
|
||
if (err) console.error('Photo upload error:', err.message);
|
||
if (req.file) {
|
||
// keep only the newly uploaded file (in the user's subdir)
|
||
const dir = userStorageDir(fotoDir);
|
||
fs.readdirSync(dir).forEach((f) => {
|
||
if (f !== req.file.filename) fs.promises.unlink(path.join(dir, f)).catch(() => {});
|
||
});
|
||
}
|
||
res.redirect('/vorlagen');
|
||
} catch (error) {
|
||
console.error('Error saving photo:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Delete the photo
|
||
app.post('/bewerbungsfoto/delete', (req, res) => {
|
||
try {
|
||
const dir = userStorageDir(fotoDir);
|
||
fs.readdirSync(dir).forEach((f) => fs.promises.unlink(path.join(dir, f)).catch(() => {}));
|
||
} catch (e) { /* ignore */ }
|
||
res.redirect('/vorlagen');
|
||
});
|
||
|
||
// Download a generated attachment
|
||
app.get('/anhaenge/:id/download', async (req, res) => {
|
||
try {
|
||
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||
if (!anhang) return res.status(404).send('Anhang nicht gefunden');
|
||
const filePath = path.join(userStorageDir(anhaengeDir), anhang.pfad);
|
||
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||
// inline=1 opens viewable files (PDF/image) in the browser tab instead of forcing a download.
|
||
if (req.query.inline === '1') return serveInline(res, filePath, anhang.dateiname, anhang.mime);
|
||
res.download(filePath, anhang.dateiname);
|
||
} catch (error) {
|
||
console.error('Error downloading attachment:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Delete a generated attachment
|
||
app.post('/bewerbung/:id/anhaenge/:anhangId/delete', async (req, res) => {
|
||
try {
|
||
const { id, anhangId } = req.params;
|
||
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
|
||
if (anhang) {
|
||
const filePath = path.join(userStorageDir(anhaengeDir), anhang.pfad);
|
||
fs.promises.unlink(filePath).catch(() => {});
|
||
await dbRun('DELETE FROM anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]);
|
||
}
|
||
res.redirect('/bewerbung/' + id);
|
||
} catch (error) {
|
||
console.error('Error deleting attachment:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// ----- Private attachments (internal notes) -----
|
||
|
||
// Upload a private attachment for an application's internal notes
|
||
app.post('/bewerbung/:id/interne-anhaenge', (req, res) => {
|
||
uploadInterneAnhang(req, res, async (err) => {
|
||
try {
|
||
const { id } = req.params;
|
||
if (err) {
|
||
console.error('Interne-Anhang upload error:', err.message);
|
||
return res.redirect('/bewerbung/' + id);
|
||
}
|
||
if (req.file) {
|
||
const original = req.file.originalname || req.file.filename;
|
||
const name = (req.body.name && req.body.name.trim())
|
||
? sanitizeInput(req.body.name.trim())
|
||
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
|
||
await dbRun(
|
||
'INSERT INTO interne_anhaenge (user_id, bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?, ?)',
|
||
[uid(), id, name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
|
||
);
|
||
}
|
||
res.redirect('/bewerbung/' + id);
|
||
} catch (error) {
|
||
console.error('Error saving internal attachment:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Download a private attachment (inline=1 opens PDFs/images in the browser)
|
||
app.get('/bewerbung/:id/interne-anhaenge/:anhangId/download', async (req, res) => {
|
||
try {
|
||
const { id, anhangId } = req.params;
|
||
const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
|
||
if (!anhang) return res.status(404).send('Anhang nicht gefunden');
|
||
const filePath = path.join(userStorageDir(interneAnhaengeDir), anhang.pfad);
|
||
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||
if (req.query.inline === '1') return serveInline(res, filePath, anhang.dateiname, anhang.mime);
|
||
res.download(filePath, anhang.dateiname);
|
||
} catch (error) {
|
||
console.error('Error downloading internal attachment:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Delete a private attachment
|
||
app.post('/bewerbung/:id/interne-anhaenge/:anhangId/delete', async (req, res) => {
|
||
try {
|
||
const { id, anhangId } = req.params;
|
||
const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
|
||
if (anhang) {
|
||
fs.promises.unlink(path.join(userStorageDir(interneAnhaengeDir), anhang.pfad)).catch(() => {});
|
||
await dbRun('DELETE FROM interne_anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]);
|
||
}
|
||
res.redirect('/bewerbung/' + id);
|
||
} catch (error) {
|
||
console.error('Error deleting internal attachment:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Start (or re-run) the AI generation for an application. Saves the LLM notes
|
||
// first, removes any previously generated attachments, then generates.
|
||
app.post('/bewerbung/:id/generieren', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const U = uid();
|
||
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ? AND user_id = ?', [id, U]);
|
||
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
|
||
|
||
// Persist the LLM notes the user provided (used as context during generation)
|
||
if (typeof req.body.llm_notizen !== 'undefined') {
|
||
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ? AND user_id = ?', [req.body.llm_notizen || '', id, U]);
|
||
}
|
||
|
||
const anhaengeUserDir = userStorageDir(anhaengeDir);
|
||
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, U]);
|
||
for (const a of alte) {
|
||
fs.promises.unlink(path.join(anhaengeUserDir, a.pfad)).catch(() => {});
|
||
}
|
||
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, U]);
|
||
await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ? AND user_id = ?", [id, U]);
|
||
|
||
// Selected extra attachments (checkbox values); none by default.
|
||
const anlagenIds = [].concat(req.body.anlage || []).map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n));
|
||
// Which documents to generate; nothing ticked = both (the default).
|
||
const dokumente = normalizeDokumente(req.body.dokument);
|
||
await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ? AND user_id = ?', [dokumente.join(','), id, U]);
|
||
runGeneration(id, { anlagenIds, dokumente });
|
||
res.redirect('/bewerbung/' + id);
|
||
} catch (error) {
|
||
console.error('Error generating documents:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// ----- Jobangebote page (list page) -----
|
||
// The offers shown here are ingested by third-party software via the
|
||
// /api/v1/joboffers REST endpoint (POST). The page itself is read-only plus
|
||
// two manual actions: turn an offer into an application draft, or delete it.
|
||
|
||
// Count of open offers, consumed by the header badge (no auth — just a number).
|
||
app.get('/jobangebote/anzahl-offen', async (req, res) => {
|
||
try {
|
||
const row = await dbGet("SELECT COUNT(*) as count FROM jobangebote WHERE user_id = ? AND status = 'offen'", [uid()]);
|
||
res.json({ count: row ? row.count : 0 });
|
||
} catch (error) {
|
||
res.status(500).json({ count: 0 });
|
||
}
|
||
});
|
||
|
||
// Main list: only OPEN offers (still to be decided on). Taken-over offers
|
||
// live on their own page (/jobangebote/uebernommen).
|
||
app.get('/jobangebote', async (req, res) => {
|
||
try {
|
||
const jobangebote = await dbAll(
|
||
`SELECT j.*, b.datum AS bewerbung_datum
|
||
FROM jobangebote j
|
||
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id
|
||
WHERE j.user_id = ? AND j.status = 'offen'
|
||
ORDER BY j.created_at DESC, j.id DESC`,
|
||
[uid()]
|
||
);
|
||
jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
|
||
const uebernommenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE user_id = ? AND status = 'uebernommen'", [uid()]);
|
||
res.render('jobangebote', {
|
||
jobangebote,
|
||
uebernommenCount: uebernommenRow ? uebernommenRow.c : 0,
|
||
artOptions: ART_OPTIONS,
|
||
statusOptions: STATUS_OPTIONS,
|
||
labelOptions: LABEL_OPTIONS,
|
||
hideSettings: false,
|
||
});
|
||
} catch (error) {
|
||
console.error('Error listing job offers:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Taken-over offers: which offers were turned into applications.
|
||
app.get('/jobangebote/uebernommen', async (req, res) => {
|
||
try {
|
||
const uebernommen = await dbAll(
|
||
`SELECT j.*, b.datum AS bewerbung_datum, b.status AS bewerbung_status
|
||
FROM jobangebote j
|
||
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id
|
||
WHERE j.user_id = ? AND j.status = 'uebernommen'
|
||
ORDER BY j.updated_at DESC, j.id DESC`,
|
||
[uid()]
|
||
);
|
||
uebernommen.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
|
||
const offenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE user_id = ? AND status = 'offen'", [uid()]);
|
||
res.render('jobangebote_uebernommen', {
|
||
uebernommen,
|
||
offenCount: offenRow ? offenRow.c : 0,
|
||
hideSettings: false,
|
||
});
|
||
} catch (error) {
|
||
console.error('Error listing taken-over offers:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Convert a job offer into a Bewerbung draft (mirrors the Indeed import flow:
|
||
// creates a bewerbung with status "Entwurf", records the initial timeline entry,
|
||
// and links the offer back to it).
|
||
// Create a Bewerbung draft from an offer (or return the already-linked one).
|
||
// The offer's FULL description is carried into `stellenbeschreibung`, which is
|
||
// exactly the text handed to the LLM during generation. Returns the id.
|
||
async function uebernehmeAngebot(angebot) {
|
||
if (angebot.verknuepfte_bewerbung_id) return angebot.verknuepfte_bewerbung_id;
|
||
|
||
const datum = new Date().toISOString().split('T')[0];
|
||
const notizParts = [
|
||
angebot.ort ? `Ort: ${angebot.ort}` : null,
|
||
angebot.gehalt ? `Gehalt: ${angebot.gehalt}` : null,
|
||
angebot.kontakt_email ? `Kontakt: ${angebot.kontakt_email}` : null,
|
||
angebot.quelle_url ? `Quelle: ${angebot.quelle_url}` : null,
|
||
angebot.quelle ? `Importiert via: ${angebot.quelle}` : null,
|
||
].filter(Boolean);
|
||
|
||
// Employer address (street, house number, city) and contact person go into
|
||
// the AI notes so the LLM can use them for the letter's Anschriftfeld and
|
||
// salutation.
|
||
let anschrift = (angebot.adresse || '').trim();
|
||
if (angebot.ort && !anschrift.toLowerCase().includes(String(angebot.ort).toLowerCase())) {
|
||
anschrift = anschrift ? `${anschrift}, ${angebot.ort}` : String(angebot.ort);
|
||
}
|
||
const llmNotizen = [
|
||
anschrift ? `Anschrift des Arbeitgebers: ${anschrift}` : null,
|
||
angebot.ansprechpartner ? `Ansprechpartner: ${angebot.ansprechpartner}` : null,
|
||
].filter(Boolean).join('\n');
|
||
|
||
const result = await dbRun(
|
||
`INSERT INTO bewerbungen
|
||
(user_id, datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung,
|
||
quelle_url, email_empfaenger, llm_notizen, labels, generierung_status)
|
||
VALUES (?, ?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
|
||
[
|
||
currentUserId(),
|
||
datum,
|
||
sanitizeInput(angebot.firma),
|
||
sanitizeInput(angebot.stelle),
|
||
sanitizeInput(angebot.art || deriveArt(angebot.quelle_url, null)),
|
||
notizParts.join('\n'),
|
||
angebot.ort || '',
|
||
angebot.beschreibung || '',
|
||
angebot.quelle_url || '',
|
||
angebot.kontakt_email || '',
|
||
llmNotizen,
|
||
serializeLabels(angebot.labels),
|
||
]
|
||
);
|
||
|
||
await dbRun(
|
||
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
|
||
[currentUserId(), result.lastID, datum, 'Entwurf', `Automatisch aus Jobangebot übernommen (${angebot.quelle || 'drittanbieter'})`]
|
||
);
|
||
await dbRun(
|
||
'UPDATE jobangebote SET verknuepfte_bewerbung_id = ?, status = "uebernommen", updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
|
||
[result.lastID, angebot.id, currentUserId()]
|
||
);
|
||
return result.lastID;
|
||
}
|
||
|
||
app.post('/jobangebote/:id/uebernehmen', async (req, res) => {
|
||
try {
|
||
const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||
if (!angebot) return res.status(404).send('Jobangebot nicht gefunden');
|
||
const bewerbungId = await uebernehmeAngebot(angebot);
|
||
res.redirect('/bewerbung/' + bewerbungId);
|
||
} catch (error) {
|
||
console.error('Error converting job offer:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Edit an offer's fields — mainly to paste/adjust the full job description
|
||
// (any length) before turning it into an application.
|
||
app.post('/jobangebote/:id/bearbeiten', async (req, res) => {
|
||
try {
|
||
const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||
if (!angebot) return res.status(404).send('Jobangebot nicht gefunden');
|
||
|
||
const b = req.body || {};
|
||
const quelleUrl = sanitizeInput(b.quelle_url || '');
|
||
await dbRun(
|
||
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
|
||
beschreibung = ?, kontakt_email = ?, quelle_url = ?, anzeige_datum = ?,
|
||
labels = ?, url_norm = ?, updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ? AND user_id = ?`,
|
||
[
|
||
sanitizeInput((b.firma || '').trim()) || angebot.firma,
|
||
sanitizeInput((b.stelle || '').trim()) || angebot.stelle,
|
||
sanitizeInput(b.ort || ''),
|
||
sanitizeInput(b.adresse || ''),
|
||
sanitizeInput(b.ansprechpartner || ''),
|
||
sanitizeInput(b.gehalt || ''),
|
||
sanitizeInput(b.beschreibung || ''),
|
||
sanitizeInput(b.kontakt_email || ''),
|
||
quelleUrl,
|
||
sanitizeInput(b.anzeige_datum || ''),
|
||
serializeLabels(b.labels),
|
||
blacklist.normalizeUrl(b.quelle_url || '') || null,
|
||
req.params.id,
|
||
uid(),
|
||
]
|
||
);
|
||
res.redirect('/jobangebote');
|
||
} catch (error) {
|
||
console.error('Error editing job offer:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Delete a job offer. Deleting always blacklists it first, so the same offer
|
||
// can never be ingested/listed again (the requirement: never appears twice,
|
||
// even after deletion). The entry can be removed later on /blacklist.
|
||
app.post('/jobangebote/:id/delete', async (req, res) => {
|
||
try {
|
||
const offer = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||
if (offer) {
|
||
await autoBlacklistOffer(offer, 'Jobangebot gelöscht (Web-UI)');
|
||
await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||
}
|
||
res.redirect('/jobangebote');
|
||
} catch (error) {
|
||
console.error('Error deleting job offer:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Blacklist management page — see what is blocked and remove entries.
|
||
app.get('/blacklist', async (req, res) => {
|
||
try {
|
||
const eintraege = await dbAll(
|
||
'SELECT * FROM jobangebote_blacklist WHERE user_id = ? ORDER BY created_at DESC, id DESC',
|
||
[uid()]
|
||
);
|
||
res.render('blacklist', { eintraege, blacklistTypen: blacklist.TYPES, hideSettings: false });
|
||
} catch (error) {
|
||
console.error('Error listing blacklist:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Add a manual blacklist entry (URL / domain / company / company+title).
|
||
app.post('/blacklist', async (req, res) => {
|
||
try {
|
||
const entry = blacklist.buildManualEntry({
|
||
typ: req.body.typ,
|
||
wert: sanitizeInput(req.body.wert || ''),
|
||
firma: sanitizeInput(req.body.firma || ''),
|
||
stelle: sanitizeInput(req.body.stelle || ''),
|
||
ort: sanitizeInput(req.body.ort || ''),
|
||
grund: sanitizeInput(req.body.grund || ''),
|
||
});
|
||
if (entry) await insertBlacklistEntry(entry);
|
||
res.redirect('/blacklist');
|
||
} catch (error) {
|
||
console.error('Error adding blacklist entry:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Remove a blacklist entry (offer may then be ingested again).
|
||
app.post('/blacklist/:id/delete', async (req, res) => {
|
||
try {
|
||
await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||
res.redirect('/blacklist');
|
||
} catch (error) {
|
||
console.error('Error deleting blacklist entry:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// ----- Einstellungen (früher .env — jetzt in der Datenbank) -----
|
||
// Alle Konfigurationswerte (Ollama, E-Mail, CalDAV, REST-API) liegen in der
|
||
// app_state-Tabelle und sind hier editierbar. Speichern wirkt sofort, ein
|
||
// Neustart ist nicht nötig (die Libs lesen per config.get() zur Laufzeit).
|
||
app.get('/einstellungen', (req, res) => {
|
||
res.render('settings', {
|
||
felder: config.FIELDS,
|
||
werte: config.getAll(),
|
||
hideSettings: true,
|
||
});
|
||
});
|
||
|
||
app.post('/einstellungen', async (req, res) => {
|
||
try {
|
||
const werte = {};
|
||
for (const sektion of config.FIELDS) {
|
||
for (const f of sektion.items) {
|
||
werte[f.key] = req.body[f.key] != null ? String(req.body[f.key]) : '';
|
||
}
|
||
}
|
||
|
||
// The API token identifies the user to /api/v1, so it must not collide with
|
||
// another user's: the auth lookup would otherwise be ambiguous (it now fails
|
||
// closed, which would lock *both* users out). Reject the save instead.
|
||
const token = (werte.API_TOKEN || '').trim();
|
||
if (token) {
|
||
const fremd = await dbGet(
|
||
`SELECT u.username FROM app_state a JOIN users u ON u.id = a.user_id
|
||
WHERE a.key = ? AND a.value = ? AND a.user_id != ?`,
|
||
[config.PREFIX + 'API_TOKEN', token, uid()]
|
||
);
|
||
if (fremd) {
|
||
return res.status(409).send('Dieser API-Token ist bereits von einem anderen Benutzer belegt. Bitte „Neu generieren“ verwenden.');
|
||
}
|
||
}
|
||
|
||
await config.saveAll(werte);
|
||
res.redirect('/einstellungen');
|
||
} catch (error) {
|
||
console.error('Error saving settings:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// ----- Admin: Benutzerverwaltung (nur für Admins) -----
|
||
// Admins legen neue Benutzer an, setzen Passwörter zurück und löschen
|
||
// Benutzer. Beim Löschen eines Benutzers löscht die DB per ON DELETE CASCADE
|
||
// alle seine Daten (Bewerbungen, E-Mails, Termine, Chat, Dateien liegen in
|
||
// data/<dir>/<userId>/ und müssen separat entfernt werden — siehe unten).
|
||
function requireAdmin(req, res, next) {
|
||
if (req.user && req.user.is_admin) return next();
|
||
if (req.path.startsWith('/admin/') || req.xhr || (req.get('accept') || '').includes('application/json')) {
|
||
return res.status(403).send('Zugriff verweigert – nur für Administratoren.');
|
||
}
|
||
res.status(403).send('Zugriff verweigert – nur für Administratoren.');
|
||
}
|
||
|
||
app.get('/admin', requireAdmin, async (req, res) => {
|
||
try {
|
||
const users = await dbAll(
|
||
'SELECT id, username, is_admin, created_at, (SELECT COUNT(*) FROM bewerbungen b WHERE b.user_id = users.id) AS anzahl_bewerbungen FROM users ORDER BY is_admin DESC, id ASC'
|
||
);
|
||
res.render('admin', { users, currentUserId: uid(), hideSettings: false });
|
||
} catch (error) {
|
||
console.error('Admin list error:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Neuen Benutzer anlegen.
|
||
app.post('/admin/users', requireAdmin, async (req, res) => {
|
||
try {
|
||
const username = sanitizeInput((req.body.username || '').trim());
|
||
const plain = req.body.password || '';
|
||
const isAdmin = req.body.is_admin === '1' || req.body.is_admin === 'on';
|
||
if (!username || !plain) return res.status(400).send('Benutzername und Passwort erforderlich.');
|
||
if (username.length > 64) return res.status(400).send('Benutzername zu lang.');
|
||
const dup = await dbGet('SELECT id FROM users WHERE username = ?', [username]);
|
||
if (dup) return res.status(409).send('Benutzername bereits vergeben.');
|
||
await dbRun('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)',
|
||
[username, password.hash(plain), isAdmin ? 1 : 0]);
|
||
res.redirect('/admin');
|
||
} catch (error) {
|
||
console.error('Admin create user error:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Passwort zurücksetzen.
|
||
app.post('/admin/users/:id/reset-password', requireAdmin, async (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
const plain = req.body.password || '';
|
||
if (!plain) return res.status(400).send('Passwort erforderlich.');
|
||
await dbRun('UPDATE users SET password_hash = ? WHERE id = ?', [password.hash(plain), id]);
|
||
await dbRun('DELETE FROM sessions WHERE user_id = ?', [id]); // alle Sessions des Benutzers ungültigen
|
||
res.redirect('/admin');
|
||
} catch (error) {
|
||
console.error('Admin reset password error:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Benutzer löschen (mit allen Daten + Dateien). Der letzte Admin darf nicht
|
||
// gelöscht werden, sonst sperrt man sich selbst aus.
|
||
app.post('/admin/users/:id/delete', requireAdmin, async (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
if (id === Number(req.user.id)) return res.status(400).send('Man kann sich nicht selbst löschen.');
|
||
const target = await dbGet('SELECT is_admin FROM users WHERE id = ?', [id]);
|
||
if (!target) return res.status(404).send('Benutzer nicht gefunden.');
|
||
if (target.is_admin) {
|
||
const adminCount = await dbGet('SELECT COUNT(*) as count FROM users WHERE is_admin = 1');
|
||
if (adminCount && adminCount.count <= 1) return res.status(400).send('Der letzte Admin darf nicht gelöscht werden.');
|
||
}
|
||
// Dateien des Benutzers auf der Festplatte entfernen (die DB-Zeilen löscht
|
||
// ON DELETE CASCADE).
|
||
for (const dir of migrate.STORAGE_DIRS) {
|
||
const base = path.join(dataDir, dir, String(id));
|
||
if (fs.existsSync(base)) fs.rmSync(base, { recursive: true, force: true });
|
||
}
|
||
await dbRun('DELETE FROM users WHERE id = ?', [id]);
|
||
res.redirect('/admin');
|
||
} catch (error) {
|
||
console.error('Admin delete user error:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// ----- Conversational KI-Chat (Ollama, streaming) -----
|
||
// Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the
|
||
// assistant answer is streamed back via Server-Sent Events.
|
||
async function gatherChatContext() {
|
||
const [settings, profilRows, prompts] = await Promise.all([
|
||
loadSettings(),
|
||
dbAll(
|
||
`SELECT inhalt FROM basis_dokumente
|
||
WHERE user_id = ? AND typ IN ('Lebenslauf', 'Profil/Kurzprofil') AND inhalt IS NOT NULL AND inhalt != ''
|
||
ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END`,
|
||
[uid()]
|
||
),
|
||
loadPrompts(),
|
||
]);
|
||
// Lightweight core context only: name, date and the user's profile (static,
|
||
// small). All application/appointment data is fetched on demand via tools,
|
||
// so the system prompt stays tiny regardless of how many bewerbungen exist.
|
||
const profil = (profilRows.map((r) => (r.inhalt || '').trim()).join('\n\n---\n\n')).slice(0, 1800);
|
||
const heute = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||
return { heute, profil, prompts, settings };
|
||
}
|
||
|
||
// Ollama tool definitions the assistant can call to look up application data.
|
||
const CHAT_TOOLS = [
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'suche_bewerbungen',
|
||
description: 'Durchsucht Bewerbungen nach Firmen- oder Stellenname (Teiltreffer). Nutze dies, wenn der Nutzer eine konkrete Firma/Stelle nennt oder fragt, was zu einer Firma bekannt ist. Liefert eine kompakte Trefferliste (id, firma, stelle, status, datum, ort) — hole Details mit bewerbung_detail.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
query: { type: 'string', description: 'Suchbegriff, z. B. Firmen- oder Stellenname (mind. 2 Zeichen)' },
|
||
},
|
||
required: ['query'],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'list_bewerbungen',
|
||
description: 'Listet Bewerbungen auf, standardmäßig die jüngsten. Optional nach Status gefiltert. Für einen Überblick über alle laufenden/abgeschlossenen Bewerbungen.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
status: { type: 'string', description: 'Optional: nur Bewerbungen mit diesem Status (z. B. offen, absage, eingeladen)' },
|
||
limit: { type: 'integer', description: 'Max. Anzahl Treffer (Standard 20, max 40)' },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'bewerbung_detail',
|
||
description: 'Liefert volle Details zu einer Bewerbung: Stellenbeschreibung, Notizen, interne Notizen, Kontakt, Quell-URL und die letzten Korrespondenz-Betreffe. Setze die id aus suche_bewerbungen/list_bewerbungen voraus.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: { id: { type: 'integer', description: 'Bewerbungs-ID' } },
|
||
required: ['id'],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'kommende_termine',
|
||
description: 'Liefert die nächsten Termine (Gespräche, Fristen) mit Titel, Startzeit (UTC-ISO), verknüpfter Bewerbung.',
|
||
parameters: { type: 'object', properties: {} },
|
||
},
|
||
},
|
||
];
|
||
|
||
// Tool labels shown in the UI while a tool call is in flight.
|
||
const CHAT_TOOL_LABELS = {
|
||
suche_bewerbungen: 'Bewerbungen werden durchsucht…',
|
||
list_bewerbungen: 'Bewerbungen werden geladen…',
|
||
bewerbung_detail: 'Bewerbungsdetails werden geladen…',
|
||
kommende_termine: 'Termine werden geladen…',
|
||
};
|
||
|
||
// Execute one tool call against the database. Returns a JSON-serialisable
|
||
// value that is fed back to the model as the tool result.
|
||
async function executeChatTool(name, args) {
|
||
const a = args || {};
|
||
if (name === 'suche_bewerbungen') {
|
||
const q = String(a.query || '').trim();
|
||
if (q.length < 2) return { treffer: [], hinweis: 'Suchbegriff zu kurz' };
|
||
const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`;
|
||
const rows = await dbAll(
|
||
`SELECT id, firma, stelle, status, datum, ort FROM bewerbungen
|
||
WHERE user_id = ? AND (firma LIKE ? ESCAPE '\\' OR stelle LIKE ? ESCAPE '\\')
|
||
ORDER BY datum DESC, created_at DESC LIMIT 20`,
|
||
[uid(), like, like]
|
||
);
|
||
return { treffer: rows };
|
||
}
|
||
if (name === 'list_bewerbungen') {
|
||
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 40);
|
||
const status = String(a.status || '').trim();
|
||
const sql = `SELECT id, firma, stelle, status, datum, ort FROM bewerbungen
|
||
WHERE user_id = ?${status ? ' AND status = ?' : ''} ORDER BY datum DESC, created_at DESC LIMIT ?`;
|
||
const rows = await dbAll(sql, status ? [uid(), status, limit] : [uid(), limit]);
|
||
return { bewerbungen: rows };
|
||
}
|
||
if (name === 'bewerbung_detail') {
|
||
const id = Number(a.id);
|
||
if (!id) return { error: 'keine id' };
|
||
const row = await dbGet(
|
||
`SELECT b.id, b.firma, b.stelle, b.status, b.datum, b.ort, b.notizen,
|
||
b.interne_notizen, b.stellenbeschreibung, b.quelle_url,
|
||
j.kontakt_email AS ja_kontakt, j.ansprechpartner AS ja_ansprech, j.beschreibung AS ja_beschreibung
|
||
FROM bewerbungen b
|
||
LEFT JOIN jobangebote j ON j.verknuepfte_bewerbung_id = b.id AND j.user_id = b.user_id
|
||
WHERE b.id = ? AND b.user_id = ?`,
|
||
[id, uid()]
|
||
);
|
||
if (!row) return { error: 'nicht gefunden' };
|
||
const em = await dbAll(
|
||
`SELECT direction, subject, from_addr FROM emails WHERE bewerbung_id = ? AND user_id = ?
|
||
ORDER BY email_date DESC, created_at DESC LIMIT 8`,
|
||
[id, uid()]
|
||
);
|
||
return {
|
||
id: row.id, firma: row.firma, stelle: row.stelle, status: row.status, datum: row.datum, ort: row.ort,
|
||
quelle_url: row.quelle_url,
|
||
kontakt_email: row.ja_kontakt || null,
|
||
ansprechpartner: row.ja_ansprech || null,
|
||
notizen: (row.notizen || '').trim(),
|
||
interne_notizen: (row.interne_notizen || '').trim(),
|
||
stellenbeschreibung: (row.stellenbeschreibung || row.ja_beschreibung || '').trim().slice(0, 1200),
|
||
korrespondenz: em.map((e) => ({
|
||
direction: e.direction, subject: e.subject, von: e.from_addr,
|
||
})),
|
||
};
|
||
}
|
||
if (name === 'kommende_termine') {
|
||
const rows = await upcomingTermine(10);
|
||
return {
|
||
termine: rows.map((t) => ({
|
||
titel: t.titel, start: t.start,
|
||
bewerbung_id: t.bewerbung_id,
|
||
bewerbung: t.bewerbung_firma || null,
|
||
})),
|
||
};
|
||
}
|
||
return { error: 'unbekanntes Werkzeug: ' + name };
|
||
}
|
||
|
||
// Chat page: list threads + render the active thread (or a fresh empty one).
|
||
app.get('/chat', async (req, res) => {
|
||
if (!chat.isConfigured()) return res.status(503).send('KI-Chat deaktiviert – kein Ollama-API-Schlüssel konfiguriert (unter „Einstellungen“ eintragen).');
|
||
try {
|
||
const threads = await dbAll(
|
||
'SELECT id, titel, updated_at FROM chat_threads WHERE user_id = ? ORDER BY updated_at DESC',
|
||
[uid()]
|
||
);
|
||
const activeId = req.query.thread ? Number(req.query.thread) : (threads[0] && threads[0].id);
|
||
let messages = [];
|
||
if (activeId) {
|
||
messages = await dbAll(
|
||
`SELECT m.id, m.role, m.content, m.created_at FROM chat_messages m
|
||
JOIN chat_threads t ON t.id = m.thread_id
|
||
WHERE m.thread_id = ? AND t.user_id = ? ORDER BY m.id ASC`,
|
||
[activeId, uid()]
|
||
);
|
||
}
|
||
res.render('chat', {
|
||
threads, activeId, messages,
|
||
hasApiKey: true, hideSettings: false,
|
||
});
|
||
} catch (error) {
|
||
console.error('Chat page error:', error);
|
||
res.status(500).send('Serverfehler');
|
||
}
|
||
});
|
||
|
||
// Create a new thread. Optional `titel` in the body.
|
||
app.post('/chat/api/threads', async (req, res) => {
|
||
try {
|
||
const titel = sanitizeInput((req.body.titel || '').trim()).slice(0, 120) || null;
|
||
const { lastID } = await dbRun('INSERT INTO chat_threads (user_id, titel) VALUES (?, ?)', [uid(), titel]);
|
||
res.json({ id: lastID, titel });
|
||
} catch (error) {
|
||
console.error('Create thread error:', error);
|
||
res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
});
|
||
|
||
// Delete a thread (cascades to its messages).
|
||
app.delete('/chat/api/threads/:id', async (req, res) => {
|
||
try {
|
||
await dbRun('DELETE FROM chat_threads WHERE id = ? AND user_id = ?', [Number(req.params.id), uid()]);
|
||
res.json({ ok: true });
|
||
} catch (error) {
|
||
console.error('Delete thread error:', error);
|
||
res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
});
|
||
|
||
// Rename a thread (e.g. auto-title from first message).
|
||
app.patch('/chat/api/threads/:id', async (req, res) => {
|
||
try {
|
||
const titel = sanitizeInput((req.body.titel || '').trim()).slice(0, 120);
|
||
await dbRun('UPDATE chat_threads SET titel = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
|
||
[titel, Number(req.params.id), uid()]);
|
||
res.json({ ok: true });
|
||
} catch (error) {
|
||
console.error('Rename thread error:', error);
|
||
res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
});
|
||
|
||
// Send a user message and stream the assistant reply via SSE.
|
||
app.post('/chat/api/threads/:id/messages', async (req, res) => {
|
||
if (!chat.isConfigured()) return res.status(503).json({ error: 'KI-Chat deaktiviert.' });
|
||
const threadId = Number(req.params.id);
|
||
const userText = sanitizeInput((req.body.content || '').trim());
|
||
if (!userText) return res.status(400).json({ error: 'Leere Nachricht.' });
|
||
|
||
let thread;
|
||
try {
|
||
thread = await dbGet('SELECT id, titel FROM chat_threads WHERE id = ? AND user_id = ?', [threadId, uid()]);
|
||
} catch (e) { /* fall through */ }
|
||
if (!thread) return res.status(404).json({ error: 'Thread nicht gefunden.' });
|
||
|
||
// Persist the user message, then load the full prior history for context.
|
||
try {
|
||
await dbRun('INSERT INTO chat_messages (user_id, thread_id, role, content) VALUES (?, ?, ?, ?)',
|
||
[uid(), threadId, 'user', userText]);
|
||
await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [threadId, uid()]);
|
||
// Auto-title the thread from the first user message, if untitled.
|
||
if (!thread.titel) {
|
||
const first = await dbGet('SELECT content FROM chat_messages WHERE thread_id = ? AND user_id = ? ORDER BY id ASC LIMIT 1', [threadId, uid()]);
|
||
if (first) {
|
||
const t = first.content.slice(0, 60).replace(/\s+/g, ' ').trim();
|
||
if (t) await dbRun('UPDATE chat_threads SET titel = ? WHERE id = ? AND user_id = ? AND (titel IS NULL OR titel = "")', [t, threadId, uid()]);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Persist user message error:', error);
|
||
return res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
|
||
let history;
|
||
try {
|
||
history = await dbAll(
|
||
'SELECT role, content FROM chat_messages WHERE thread_id = ? AND user_id = ? ORDER BY id ASC',
|
||
[threadId, uid()]
|
||
);
|
||
} catch (error) {
|
||
return res.status(500).json({ error: 'Serverfehler' });
|
||
}
|
||
|
||
// SSE setup. Keep the connection alive; flush headers immediately.
|
||
res.setHeader('Content-Type', 'text/event-stream');
|
||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||
res.setHeader('Connection', 'keep-alive');
|
||
res.setHeader('X-Accel-Buffering', 'no');
|
||
res.flushHeaders && res.flushHeaders();
|
||
|
||
const send = (obj) => {
|
||
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
||
};
|
||
|
||
// AbortController so a closed client stops the upstream Ollama stream.
|
||
const controller = new AbortController();
|
||
let aborted = false;
|
||
req.on('close', () => { aborted = true; controller.abort(); });
|
||
|
||
// Trim very old history to bound token cost (keep the last 20 turns).
|
||
const trimmed = history.slice(-40);
|
||
const messages = trimmed.map((m) => ({ role: m.role, content: m.content }));
|
||
|
||
let context;
|
||
try { context = await gatherChatContext(); }
|
||
catch (e) { context = {}; }
|
||
const system = chat.buildContextPrompt(context);
|
||
|
||
let assistantText = '';
|
||
try {
|
||
assistantText = await chat.runChat({
|
||
system,
|
||
messages,
|
||
tools: CHAT_TOOLS,
|
||
signal: controller.signal,
|
||
onToken: (delta) => send({ type: 'token', content: delta }),
|
||
onToolCall: (name) => send({ type: 'tool', name, label: CHAT_TOOL_LABELS[name] || name }),
|
||
executeTool: executeChatTool,
|
||
});
|
||
} catch (err) {
|
||
if (aborted) { res.end(); return; }
|
||
send({ type: 'error', message: err.message || 'KI-Fehler' });
|
||
res.end();
|
||
return;
|
||
}
|
||
|
||
// Persist the (possibly empty) assistant reply.
|
||
const saved = assistantText || '(keine Antwort)';
|
||
try {
|
||
const { lastID } = await dbRun(
|
||
'INSERT INTO chat_messages (user_id, thread_id, role, content) VALUES (?, ?, ?, ?)',
|
||
[uid(), threadId, 'assistant', saved]
|
||
);
|
||
await dbRun('UPDATE chat_threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [threadId, uid()]);
|
||
const threadRow = await dbGet('SELECT titel FROM chat_threads WHERE id = ? AND user_id = ?', [threadId, uid()]);
|
||
send({ type: 'done', messageId: lastID, content: saved, titel: threadRow && threadRow.titel });
|
||
} catch (error) {
|
||
send({ type: 'error', message: 'Antwort konnte nicht gespeichert werden.' });
|
||
}
|
||
res.end();
|
||
});
|
||
|
||
// ----- Third-party REST API (/api/v1) + OpenAPI/Swagger -----
|
||
// Per-user API key: each user may set their own API_TOKEN on /einstellungen.
|
||
// The X-API-Key header resolves to the owning user, and every request then
|
||
// operates only on that user's data (see lib/api.js).
|
||
const apiToken = () => config.get('API_TOKEN') || '';
|
||
app.use('/api/v1', createExternalApi({
|
||
dbGet,
|
||
dbAll,
|
||
dbRun,
|
||
sanitizeInput,
|
||
attachVerlauf,
|
||
findDuplicateApplications,
|
||
syncCurrentStatus,
|
||
runGeneration,
|
||
anhaengeDir,
|
||
emailAnhaengeDir,
|
||
userStorageDir,
|
||
apiToken,
|
||
}));
|
||
|
||
// Serve the OpenAPI document, with the real request host injected as server.
|
||
app.get('/swagger.json', (req, res) => {
|
||
const proto = req.get('x-forwarded-proto') || req.protocol;
|
||
const host = req.get('host') || `localhost:${PORT}`;
|
||
res.json(buildOpenApiSpec(`${proto}://${host}`));
|
||
});
|
||
|
||
// Swagger UI (loaded from CDN; consistent with the app's other CDN usage).
|
||
app.get('/swagger', (req, res) => {
|
||
const proto = req.get('x-forwarded-proto') || req.protocol;
|
||
const host = req.get('host') || `localhost:${PORT}`;
|
||
const specUrl = `${proto}://${host}/swagger.json`;
|
||
res.type('text/html').send(`<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Bewerbungs-Tracker – API-Dokumentation</title>
|
||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||
<style>
|
||
html { box-sizing: border-box; overflow-y: scroll; }
|
||
*, *::before, *::after { box-sizing: inherit; }
|
||
body { margin: 0; background: #fafafa; }
|
||
.topbar { display:flex; align-items:center; gap:12px; padding:10px 16px;
|
||
background:#1f2937; color:#fff; font-family:system-ui,sans-serif; }
|
||
.topbar a { color:#93c5fd; text-decoration:none; font-weight:600; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="topbar">
|
||
<strong>Bewerbungs-Tracker REST-API</strong>
|
||
<span style="opacity:.7">Drittanbieter-Schnittstelle v1</span>
|
||
<span style="margin-left:auto">Authentifizierung: Header <code>X-API-Key</code></span>
|
||
<a href="/">← zur App</a>
|
||
</div>
|
||
<div id="swagger-ui"></div>
|
||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" charset="UTF-8"></script>
|
||
<script>
|
||
window.onload = () => {
|
||
window.ui = SwaggerUIBundle({
|
||
url: ${JSON.stringify(specUrl)},
|
||
dom_id: '#swagger-ui',
|
||
deepLinking: true,
|
||
persistAuthorization: true,
|
||
});
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>`);
|
||
});
|
||
app.get('/api-docs', (req, res) => res.redirect(301, '/swagger'));
|
||
|
||
// Start server
|
||
app.listen(PORT, () => {
|
||
console.log(`Server läuft auf http://localhost:${PORT}`);
|
||
console.log('REST-API (/api/v1): pro Benutzer über X-API-Key (Token in den Einstellungen je Benutzer gesetzt) – Swagger unter /swagger');
|
||
});
|
||
|
||
// Background loops are per-user: every user owns their own mail/calendar
|
||
// config, so polling iterates all users and runs each user's poll inside
|
||
// that user's context (config.get() then resolves to the user's own values).
|
||
async function forEachUser(fn) {
|
||
const users = await dbAll('SELECT id, username, is_admin FROM users ORDER BY id');
|
||
for (const u of users) {
|
||
try { await config.ensureLoaded(u.id); } catch (e) { continue; }
|
||
try { await userContext.run(u, fn); } catch (e) { /* errors logged inside fn */ }
|
||
}
|
||
}
|
||
|
||
async function pollInboxAllUsers() {
|
||
await forEachUser(async () => {
|
||
try { await pollInbox(); } catch (e) { /* logged inside pollInbox */ }
|
||
});
|
||
}
|
||
async function refreshCaldavAllUsers() {
|
||
await forEachUser(async () => {
|
||
try { await refreshCaldav(); } catch (e) { /* logged inside refreshCaldav */ }
|
||
});
|
||
}
|
||
|
||
// E-Mail: verify SMTP on startup (per-user, but verification only needs the
|
||
// first configured user to confirm reachability) and poll every user's IMAP
|
||
// inbox for replies on the configured interval.
|
||
const pollMs = Math.max(60000, Number(config.get('MAIL_POLL_MS')) || 180000);
|
||
setTimeout(() => { pollInboxAllUsers().catch(() => {}); }, 8000); // initial fetch after boot
|
||
setInterval(() => { pollInboxAllUsers().catch(() => {}); }, pollMs); // periodic fetch
|
||
|
||
// Calendar: reconcile every user's appointments with their CalDAV calendar.
|
||
const calPoll = Math.max(60000, Number(config.get('CALDAV_POLL_MS')) || 300000);
|
||
setTimeout(() => { refreshCaldavAllUsers().catch(() => {}); }, 10000); // initial sync after boot
|
||
setInterval(() => { refreshCaldavAllUsers().catch(() => {}); }, calPoll); // periodic reconcile
|
||
|
||
// Handle 404
|
||
app.use((req, res) => {
|
||
res.status(404).send('Seite nicht gefunden');
|
||
});
|
||
}).catch((err) => {
|
||
console.error('Failed to initialize database:', err);
|
||
process.exit(1);
|
||
});
|
||
|
||
// Close database on exit
|
||
process.on('SIGINT', () => {
|
||
db.close();
|
||
process.exit();
|
||
});
|
||
|
||
process.on('SIGTERM', () => {
|
||
db.close();
|
||
process.exit();
|
||
});
|