Einrichtung: Setup-Assistent fuer neue Nutzer auf der Startseite

Nutzer ohne Bewerbungen sehen einen mehrstufigen Wizard, der Persoenliche
Angaben, Basis-Unterlagen und Ollama-KI per fetch speichert. Dashboard bleibt
im DOM (hidden), damit main.js und "Bewerbung manuell anlegen" funktionieren.
Neue Endpunkte /api/einrichtung/{persoenlich,basis,ollama,verstecken} mit
SSRF-Guard; Ollama gezielt via setForUser, ohne Mail/Kalender/Token zu leeren.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-14 12:48:26 +02:00
co-authored by Claude
parent 92ade10a37
commit 1bc0188fcc
3 changed files with 640 additions and 1 deletions
+113 -1
View File
@@ -1707,6 +1707,30 @@ initializeDatabase().then(async () => {
ORDER BY yearmonth DESC
`, [U, U]);
// Einrichtungsassistent: eingeblendet, wenn der Nutzer noch keine
// Bewerbung angelegt hat und den Assistenten nicht dauerhaft ausgeblendet
// hat. Der Wizard selbst führt dann durch die fehlenden Einstellungen
// (Persönliche Angaben, Basis-Unterlagen, Ollama-KI).
const keinDashboard = !totalCount || totalCount.count === 0;
const verstecktRow = keinDashboard
? await dbGet("SELECT value FROM app_state WHERE user_id = ? AND key = 'state:einrichtung_versteckt'", [U])
: null;
let setupWizard = { aktiv: false };
if (keinDashboard && !(verstecktRow && verstecktRow.value === '1')) {
const basisRows = await dbAll('SELECT id, typ, inhalt FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [U]);
const anschreiben = basisRows.find((d) => d.typ === 'Anschreiben');
const lebenslauf = basisRows.find((d) => d.typ === 'Lebenslauf');
setupWizard = {
aktiv: true,
persoenlich: await loadSettings(),
anschreiben: anschreiben ? { id: anschreiben.id, inhalt: anschreiben.inhalt || '' } : null,
lebenslauf: lebenslauf ? { id: lebenslauf.id, inhalt: lebenslauf.inhalt || '' } : null,
hasApiKey: Boolean(config.get('OLLAMA_API_KEY')),
ollamaModel: config.get('OLLAMA_MODEL'),
ollamaHost: config.get('OLLAMA_HOST'),
};
}
res.render('index', {
applications,
statistics: {
@@ -1722,7 +1746,8 @@ initializeDatabase().then(async () => {
caldavTz: caldav.TZ,
artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS,
labelOptions: LABEL_OPTIONS
labelOptions: LABEL_OPTIONS,
setupWizard
});
} catch (error) {
console.error('Error:', error);
@@ -1967,6 +1992,93 @@ initializeDatabase().then(async () => {
}
});
// ----- Einrichtungsassistent (Onboarding auf der Startseite) -----
// JSON-Endpunkte, die der Wizard Schritt für Schritt per fetch anstößt — ohne
// Seitenwechsel, damit der Nutzer im Flow bleibt. Jeder speichert für den
// aktuellen Benutzer und antwortet mit { success } bzw. { error }.
app.post('/api/einrichtung/persoenlich', async (req, res) => {
try {
const { name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum } = req.body;
await dbRun(
`INSERT INTO settings (user_id, name, adresse, kundennummer, email, telefon, ort, webseite, geburtsdatum)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
name = excluded.name, adresse = excluded.adresse, kundennummer = excluded.kundennummer,
email = excluded.email, telefon = excluded.telefon, ort = excluded.ort,
webseite = excluded.webseite, geburtsdatum = excluded.geburtsdatum`,
[uid(), sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer),
sanitizeInput(email), sanitizeInput(telefon), sanitizeInput(ort),
sanitizeInput(webseite), sanitizeInput(geburtsdatum)]
);
res.json({ success: true });
} catch (error) {
console.error('Einrichtung/persoenlich:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Basis-Unterlage (Anschreiben/Lebenslauf/…) je Typ upserten. Leerinhalt
// löscht den Eintrag nicht, sondern speichert ihn als leeren Text — so bleibt
// der Typ beim erneuten Öffnen sichtbar.
app.post('/api/einrichtung/basis', async (req, res) => {
try {
const typ = String(req.body.typ || '').trim();
if (!typ) return res.status(400).json({ error: 'Typ fehlt' });
const inhalt = req.body.inhalt != null ? String(req.body.inhalt) : '';
const existing = await dbGet('SELECT id FROM basis_dokumente WHERE user_id = ? AND typ = ?', [uid(), typ]);
if (existing) {
await dbRun('UPDATE basis_dokumente SET inhalt = ? WHERE id = ? AND user_id = ?',
[inhalt, existing.id, uid()]);
} else {
await dbRun('INSERT INTO basis_dokumente (user_id, typ, name, inhalt) VALUES (?, ?, ?, ?)',
[uid(), typ, typ, inhalt]);
}
res.json({ success: true });
} catch (error) {
console.error('Einrichtung/basis:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Ollama-KI: nur die drei Ollama-Keys setzen, ohne die restliche Konfiguration
// (Mail, Kalender, API-Token) anzutasten — config.saveAll würde alle nicht
// übergebenen Keys leeren, daher gezielt über setForUser.
app.post('/api/einrichtung/ollama', async (req, res) => {
try {
const u = uid();
const apiKey = req.body.OLLAMA_API_KEY != null ? String(req.body.OLLAMA_API_KEY).trim() : '';
const model = req.body.OLLAMA_MODEL != null ? String(req.body.OLLAMA_MODEL).trim() : '';
const host = req.body.OLLAMA_HOST != null ? String(req.body.OLLAMA_HOST).trim() : '';
if (host) {
try { await assertSafeUrl(host); }
catch (vErr) { return res.status(400).json({ error: 'Host abgelehnt: ' + (vErr.message || 'ungültig') }); }
}
await config.setForUser(u, 'OLLAMA_API_KEY', apiKey);
if (model) await config.setForUser(u, 'OLLAMA_MODEL', model);
if (host) await config.setForUser(u, 'OLLAMA_HOST', host);
res.json({ success: true });
} catch (error) {
console.error('Einrichtung/ollama:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Wizard dauerhaft ausblenden (app_state-Flag), damit er nicht bei jedem
// Besuch der leeren Startseite wieder erscheint.
app.post('/api/einrichtung/verstecken', async (req, res) => {
try {
await dbRun(
"INSERT INTO app_state (user_id, key, value) VALUES (?, 'state:einrichtung_versteckt', '1') " +
"ON CONFLICT(user_id, key) DO UPDATE SET value = '1'",
[uid()]
);
res.json({ success: true });
} catch (error) {
console.error('Einrichtung/verstecken:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// ----- Indeed import (called by the browser extension) -----
app.post('/api/indeed-import', async (req, res) => {
try {