Jobsuche als Feature der App: Suchprofil pro Benutzer statt Cron-Prompt

Die Jobsuche lag in zwei Host-Skripten (jobsuche-cron.sh, jobsuche-remote-cron.sh)
aus der Ein-Benutzer-Zeit: Die Suchkriterien (sieben Staedte, Rollen, Buzzwords)
standen fest im Prompt, und importiert wurde mit EINEM globalen API-Token aus der
.env - der zufaellig dem Admin gehoerte. Auf der Multi-User-Plattform ist beides
hinfaellig.

Jetzt legt jeder Benutzer sein Suchprofil selbst fest (/jobsuche):
- Modus: regional / 100 % Remote / beides
- Staedte (die erste gilt als Wohnort und wird hoechstpriorisiert, max. 12)
- Feinschliff: zusaetzliche Begriffe, Ausschluesse
- Zeitplan: Wochentage + Uhrzeit, plus Button "Jetzt suchen"
Rollen und Technologien bleiben abgeleitet - aus dem Lebenslauf des Benutzers
(basis_dokumente), nicht aus einer gepflegten Liste. Admins koennen Profil und
Zeitplan eines Benutzers ueber /jobsuche?user=<id> mitpflegen (Link im Admin-Panel).

Aufteilung App/Host: Der Container hat weder claude noch ollama. Die App reiht
Laeufe daher nur in die Warteschlange ein (Tabelle suchlaeufe); der neue Runner
auf dem Host (scripts/jobsuche-runner.js, Cron alle 5 Min) arbeitet sie ab, baut
den Prompt je Benutzer aus dessen Profil + Lebenslauf und laeuft mit DESSEN
Zugangsdaten:
- BEWERBUNG_API_KEY = eigener API-Token des Benutzers (wird beim ersten Lauf
  automatisch erzeugt), damit Treffer im richtigen Konto landen und durch dieselbe
  Dedup-/Blacklist-Logik gehen,
- ANTHROPIC_BASE_URL/AUTH_TOKEN = eigener Ollama-Key des Benutzers ueber die
  Anthropic-kompatible Schnittstelle von Ollama Cloud (https://ollama.com/v1/messages,
  verifiziert: gueltiger Key -> 200, ungueltiger -> 401). Damit zahlt jeder seine
  eigene Suche, statt alles ueber die Host-Subscription zu buchen
  (JOBSUCHE_KI_AUTH=host stellt das alte Verhalten wieder her).

Ohne eigenen Ollama-Key oder ohne Lebenslauf bricht der Lauf mit klarer Meldung ab
statt still nichts zu tun; die Oberflaeche warnt vorab. Kein Stapeln: solange ein
Lauf offen ist, erzeugt ein weiterer Klick keinen zweiten. Verwaiste Laeufe
(Prozess weg) werden nach Zeitlimit als Fehler freigegeben.

Verifiziert mit zwei Benutzern: Zugriffsschutz (fremdes Profil -> 403), Speichern,
Warteschlange, automatische Key-Erzeugung, Prompt-Aufbau aus Profil + CV,
Ergebnis-Ruecklauf in die Oberflaeche, Fehlerpfade und die Faelligkeitslogik des
Zeitplans (Tag/Uhrzeit/bereits gelaufen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 23:28:56 +02:00
co-authored by Claude Opus 4.8
parent 1e14738258
commit 9418750061
8 changed files with 1141 additions and 0 deletions
+198
View File
@@ -46,6 +46,7 @@ const { userContext, currentUser, currentUserId } = require('./lib/context');
const password = require('./lib/password');
const migrate = require('./lib/migrate-multiuser');
const { runMigration, importEnvIntoAdmin } = migrate;
const suchprofil = require('./lib/suchprofil');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -642,6 +643,44 @@ async function loadSettings() {
}
}
// The user's job-search profile. Like the settings above, "no row" is a valid
// state and means "no search configured" — it reads as the defaults (inactive).
async function loadSuchprofil(userId) {
const row = await dbGet('SELECT * FROM suchprofil WHERE user_id = ?', [userId || currentUserId()]);
return suchprofil.fromRow(row);
}
async function saveSuchprofil(userId, profil) {
const r = suchprofil.toRow(profil);
await dbRun(
`INSERT INTO suchprofil (user_id, aktiv, modus, staedte, zusatz_begriffe, ausschluesse, zeitplan_tage, zeitplan_zeit, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
aktiv = excluded.aktiv, modus = excluded.modus, staedte = excluded.staedte,
zusatz_begriffe = excluded.zusatz_begriffe, ausschluesse = excluded.ausschluesse,
zeitplan_tage = excluded.zeitplan_tage, zeitplan_zeit = excluded.zeitplan_zeit,
updated_at = CURRENT_TIMESTAMP`,
[userId, r.aktiv, r.modus, r.staedte, r.zusatz_begriffe, r.ausschluesse, r.zeitplan_tage, r.zeitplan_zeit]
);
}
// Queue a search run for a user. The host-side runner executes it (this container
// has no `claude`/`ollama`). Refuses to pile up work: if a run is already waiting
// or in flight for that user, the existing one is returned instead of a second.
async function queueSuchlauf(userId, ausloeser) {
const offen = await dbGet(
"SELECT * FROM suchlaeufe WHERE user_id = ? AND status IN ('angefordert', 'laeuft') ORDER BY id DESC LIMIT 1",
[userId]
);
if (offen) return { lauf: offen, neu: false };
const res = await dbRun(
"INSERT INTO suchlaeufe (user_id, status, ausloeser) VALUES (?, 'angefordert', ?)",
[userId, ausloeser === 'zeitplan' ? 'zeitplan' : 'manuell']
);
const lauf = await dbGet('SELECT * FROM suchlaeufe WHERE id = ? AND user_id = ?', [res.lastID, userId]);
return { lauf, neu: true };
}
// Match an incoming message to an application: first via In-Reply-To/References
// pointing at one of our sent messages, then by sender = a previous recipient.
async function matchBewerbung(msg) {
@@ -1201,6 +1240,47 @@ async function initializeDatabase() {
)
`);
// Job-search profile (one row per user; a missing row = no search configured).
// The user sets *where* (Städte/Modus) and the guard rails; the roles and
// buzzwords are derived from their Lebenslauf at run time — see lib/suchprofil.
await exec(`
CREATE TABLE IF NOT EXISTS suchprofil (
user_id INTEGER PRIMARY KEY,
aktiv INTEGER NOT NULL DEFAULT 0,
modus TEXT NOT NULL DEFAULT 'regional',
staedte TEXT NOT NULL DEFAULT '[]',
zusatz_begriffe TEXT DEFAULT '',
ausschluesse TEXT DEFAULT '',
zeitplan_tage TEXT DEFAULT '1,2,3,4,5',
zeitplan_zeit TEXT DEFAULT '17:00',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Queue + history of search runs. The app only ever *enqueues* (status
// 'angefordert'); the host-side runner (bin/jobsuche-runner.sh) picks runs up,
// because `claude`/`ollama` exist on the host, not in this container.
await exec(`
CREATE TABLE IF NOT EXISTS suchlaeufe (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'angefordert',
ausloeser TEXT NOT NULL DEFAULT 'manuell',
angefordert_at DATETIME DEFAULT CURRENT_TIMESTAMP,
gestartet_at DATETIME,
beendet_at DATETIME,
neu INTEGER,
dubletten INTEGER,
verworfen INTEGER,
fehler TEXT,
log_datei TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
await exec('CREATE INDEX IF NOT EXISTS idx_suchlaeufe_user ON suchlaeufe(user_id, id DESC)');
await exec('CREATE INDEX IF NOT EXISTS idx_suchlaeufe_status ON suchlaeufe(status)');
// Personal details of the applicant (one row per user).
await exec(`
CREATE TABLE IF NOT EXISTS settings (
@@ -2912,6 +2992,124 @@ initializeDatabase().then(async () => {
}
});
// ----- Jobsuche: Suchprofil + Suchläufe -----
// The profile steers *where* to search; the roles/technologies come from the
// user's own Lebenslauf at run time. Runs are only queued here — the host-side
// runner executes them (see lib/suchprofil for why).
//
// An admin may manage another user's profile via ?user=<id> / the hidden
// `ziel_user` field; everyone else is always confined to their own.
function resolveZielUser(req) {
const roh = req.query.user != null ? req.query.user : (req.body || {}).ziel_user;
const id = Number(roh);
if (!roh || Number.isNaN(id) || id === Number(req.user.id)) return { id: Number(req.user.id), fremd: false };
if (!req.user.is_admin) return null; // nur Admins dürfen fremde Profile sehen/ändern
return { id, fremd: true };
}
app.get('/jobsuche', async (req, res) => {
try {
const ziel = resolveZielUser(req);
if (!ziel) return res.status(403).send('Zugriff verweigert nur für Administratoren.');
const zielUser = await dbGet('SELECT id, username FROM users WHERE id = ?', [ziel.id]);
if (!zielUser) return res.status(404).send('Benutzer nicht gefunden.');
const profil = await loadSuchprofil(ziel.id);
const laeufe = await dbAll(
'SELECT * FROM suchlaeufe WHERE user_id = ? ORDER BY id DESC LIMIT 10',
[ziel.id]
);
// Warn up front instead of letting a run fail in the background: without a
// Lebenslauf there is no profile to derive roles from, and without an Ollama
// key the run has no model to talk to (both are per-user now).
const lebenslauf = await dbGet(
"SELECT id FROM basis_dokumente WHERE user_id = ? AND typ = 'Lebenslauf' AND inhalt IS NOT NULL AND inhalt != '' LIMIT 1",
[ziel.id]
);
const ollamaKey = await dbGet(
"SELECT value FROM app_state WHERE user_id = ? AND key = 'cfg:OLLAMA_API_KEY' AND value != ''",
[ziel.id]
);
res.render('jobsuche', {
profil,
laeufe,
zielUser,
fremd: ziel.fremd,
modi: suchprofil.MODI,
wochentage: suchprofil.WOCHENTAGE,
maxStaedte: suchprofil.MAX_STAEDTE,
hatLebenslauf: Boolean(lebenslauf),
hatOllamaKey: Boolean(ollamaKey),
fehler: req.query.fehler ? String(req.query.fehler) : null,
hinweis: req.query.hinweis ? String(req.query.hinweis) : null,
hideSettings: false,
});
} catch (error) {
console.error('Error loading jobsuche:', error);
res.status(500).send('Serverfehler');
}
});
app.post('/jobsuche', async (req, res) => {
try {
const ziel = resolveZielUser(req);
if (!ziel) return res.status(403).send('Zugriff verweigert nur für Administratoren.');
const suffix = ziel.fremd ? `?user=${ziel.id}` : '';
const profil = suchprofil.fromForm(req.body, sanitizeInput);
const fehler = suchprofil.validate(profil);
if (fehler.length) {
return res.redirect(`/jobsuche${suffix}${suffix ? '&' : '?'}fehler=${encodeURIComponent(fehler.join(' '))}`);
}
await saveSuchprofil(ziel.id, profil);
res.redirect(`/jobsuche${suffix}${suffix ? '&' : '?'}hinweis=${encodeURIComponent('Suchprofil gespeichert.')}`);
} catch (error) {
console.error('Error saving suchprofil:', error);
res.status(500).send('Serverfehler');
}
});
// "Jetzt suchen": queue a run. Picked up by the host runner within a few minutes.
app.post('/jobsuche/start', async (req, res) => {
try {
const ziel = resolveZielUser(req);
if (!ziel) return res.status(403).send('Zugriff verweigert nur für Administratoren.');
const suffix = ziel.fremd ? `?user=${ziel.id}` : '';
const sep = suffix ? '&' : '?';
const profil = await loadSuchprofil(ziel.id);
const fehler = suchprofil.validate(profil);
if (fehler.length) {
return res.redirect(`/jobsuche${suffix}${sep}fehler=${encodeURIComponent(fehler.join(' '))}`);
}
const { neu } = await queueSuchlauf(ziel.id, 'manuell');
const msg = neu
? 'Suchlauf angefordert er startet innerhalb weniger Minuten.'
: 'Es läuft bereits ein Suchlauf es wurde kein zweiter gestartet.';
res.redirect(`/jobsuche${suffix}${sep}hinweis=${encodeURIComponent(msg)}`);
} catch (error) {
console.error('Error queueing suchlauf:', error);
res.status(500).send('Serverfehler');
}
});
// Poll target for the page (shows a run moving from angefordert -> läuft -> fertig).
app.get('/jobsuche/laeufe', async (req, res) => {
try {
const ziel = resolveZielUser(req);
if (!ziel) return res.status(403).json({ error: 'Zugriff verweigert' });
const laeufe = await dbAll(
'SELECT * FROM suchlaeufe WHERE user_id = ? ORDER BY id DESC LIMIT 10',
[ziel.id]
);
res.json(laeufe);
} catch (error) {
console.error('Error listing suchlaeufe:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// ----- Admin: Benutzerverwaltung (nur für Admins) -----
// Admins legen neue Benutzer an, setzen Passwörter zurück und löschen
// Benutzer. Beim Löschen eines Benutzers löscht die DB per ON DELETE CASCADE