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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user