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:
@@ -0,0 +1,263 @@
|
|||||||
|
// Per-user job-search profile ("Suchprofil") + run queue.
|
||||||
|
//
|
||||||
|
// Background: the job search used to be two host-side cron scripts with the
|
||||||
|
// search criteria (seven cities, roles, buzzwords) hard-coded into the prompt —
|
||||||
|
// one user's profile, baked into the shell script. On a multi-user platform each
|
||||||
|
// user needs their own criteria, so the *what* now lives here, per user.
|
||||||
|
//
|
||||||
|
// Division of labour, forced by the deployment: the app runs in a container that
|
||||||
|
// has neither `claude` nor `ollama` (both live on the host), so the app cannot
|
||||||
|
// execute a search itself. Instead:
|
||||||
|
//
|
||||||
|
// app (container) -> writes a row into `suchlaeufe` (queued run)
|
||||||
|
// runner (host) -> picks queued/due runs up, executes the headless search,
|
||||||
|
// writes status + results back into the same table
|
||||||
|
//
|
||||||
|
// The runner talks to the tracker through the REST API using *that user's* API
|
||||||
|
// key, so an imported Jobangebot lands in the right account and goes through the
|
||||||
|
// same dedup/blacklist path as everything else.
|
||||||
|
//
|
||||||
|
// What the user configures (Städte, Modus, Zusatz-/Ausschlussbegriffe) and what
|
||||||
|
// is derived: the role titles and technology buzzwords still come from the user's
|
||||||
|
// own Lebenslauf (basis_dokumente, typ "Lebenslauf") — the profile steers *where*
|
||||||
|
// and sets guard rails, the CV supplies *what*.
|
||||||
|
|
||||||
|
const MODI = ['regional', 'remote', 'beides'];
|
||||||
|
|
||||||
|
// Weekdays as ISO numbers (1 = Monday … 7 = Sunday), matching what the UI shows.
|
||||||
|
const WOCHENTAGE = [
|
||||||
|
{ nr: 1, kurz: 'Mo' }, { nr: 2, kurz: 'Di' }, { nr: 3, kurz: 'Mi' },
|
||||||
|
{ nr: 4, kurz: 'Do' }, { nr: 5, kurz: 'Fr' }, { nr: 6, kurz: 'Sa' }, { nr: 7, kurz: 'So' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
aktiv: 0,
|
||||||
|
modus: 'regional',
|
||||||
|
staedte: [],
|
||||||
|
zusatz_begriffe: '',
|
||||||
|
ausschluesse: 'Zeitarbeit / Arbeitnehmerüberlassung, Personalvermittler / Headhunter',
|
||||||
|
zeitplan_tage: '1,2,3,4,5',
|
||||||
|
zeitplan_zeit: '17:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cap the city list: each city multiplies the number of web queries a run makes,
|
||||||
|
// and an unbounded list would turn one run into an all-night job.
|
||||||
|
const MAX_STAEDTE = 12;
|
||||||
|
|
||||||
|
function parseStaedte(raw) {
|
||||||
|
if (Array.isArray(raw)) return normalizeStaedte(raw);
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return [];
|
||||||
|
// Accept both the stored JSON array and free text from the form ("A, B\nC").
|
||||||
|
if (raw.trim().startsWith('[')) {
|
||||||
|
try { return normalizeStaedte(JSON.parse(raw)); } catch (e) { /* fall through */ }
|
||||||
|
}
|
||||||
|
return normalizeStaedte(raw.split(/[,;\n]/));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStaedte(list) {
|
||||||
|
const out = [];
|
||||||
|
for (const s of list) {
|
||||||
|
const v = String(s == null ? '' : s).trim().replace(/\s+/g, ' ');
|
||||||
|
if (!v) continue;
|
||||||
|
if (out.some((x) => x.toLowerCase() === v.toLowerCase())) continue; // case-insensitive dedup
|
||||||
|
out.push(v);
|
||||||
|
if (out.length >= MAX_STAEDTE) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTage(raw) {
|
||||||
|
const list = String(raw || '').split(',')
|
||||||
|
.map((n) => parseInt(n, 10))
|
||||||
|
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 7);
|
||||||
|
return [...new Set(list)].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "HH:MM", clamped to a valid time; anything unparseable falls back to the default.
|
||||||
|
function parseZeit(raw) {
|
||||||
|
const m = /^(\d{1,2}):(\d{2})$/.exec(String(raw || '').trim());
|
||||||
|
if (!m) return DEFAULTS.zeitplan_zeit;
|
||||||
|
const h = Math.min(23, Math.max(0, parseInt(m[1], 10)));
|
||||||
|
const min = Math.min(59, Math.max(0, parseInt(m[2], 10)));
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A DB row (or nothing) -> the profile object the app and the runner work with.
|
||||||
|
// A user without a row is a valid state: they simply have no search configured.
|
||||||
|
function fromRow(row) {
|
||||||
|
const r = row || {};
|
||||||
|
const modus = MODI.includes(r.modus) ? r.modus : DEFAULTS.modus;
|
||||||
|
return {
|
||||||
|
aktiv: r.aktiv ? 1 : 0,
|
||||||
|
modus,
|
||||||
|
staedte: parseStaedte(r.staedte),
|
||||||
|
zusatz_begriffe: r.zusatz_begriffe || '',
|
||||||
|
ausschluesse: r.ausschluesse != null ? r.ausschluesse : DEFAULTS.ausschluesse,
|
||||||
|
zeitplan_tage: parseTage(r.zeitplan_tage != null ? r.zeitplan_tage : DEFAULTS.zeitplan_tage),
|
||||||
|
zeitplan_zeit: parseZeit(r.zeitplan_zeit != null ? r.zeitplan_zeit : DEFAULTS.zeitplan_zeit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form body -> storable values. `sanitize` is the app's XSS sanitizer, injected so
|
||||||
|
// this module stays free of app dependencies.
|
||||||
|
function fromForm(body, sanitize = (v) => v) {
|
||||||
|
const b = body || {};
|
||||||
|
const modus = MODI.includes(b.modus) ? b.modus : DEFAULTS.modus;
|
||||||
|
const tage = Array.isArray(b.tage) ? b.tage : (b.tage ? [b.tage] : []);
|
||||||
|
return {
|
||||||
|
aktiv: (b.aktiv === '1' || b.aktiv === 'on' || b.aktiv === true) ? 1 : 0,
|
||||||
|
modus,
|
||||||
|
staedte: parseStaedte(b.staedte).map((s) => sanitize(s)),
|
||||||
|
zusatz_begriffe: sanitize(String(b.zusatz_begriffe || '').trim()),
|
||||||
|
ausschluesse: sanitize(String(b.ausschluesse || '').trim()),
|
||||||
|
zeitplan_tage: parseTage(tage.join(',')),
|
||||||
|
zeitplan_zeit: parseZeit(b.zeitplan_zeit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Values as they go into the DB (arrays serialized).
|
||||||
|
function toRow(profil) {
|
||||||
|
return {
|
||||||
|
aktiv: profil.aktiv ? 1 : 0,
|
||||||
|
modus: profil.modus,
|
||||||
|
staedte: JSON.stringify(profil.staedte || []),
|
||||||
|
zusatz_begriffe: profil.zusatz_begriffe || '',
|
||||||
|
ausschluesse: profil.ausschluesse || '',
|
||||||
|
zeitplan_tage: (profil.zeitplan_tage || []).join(','),
|
||||||
|
zeitplan_zeit: profil.zeitplan_zeit || DEFAULTS.zeitplan_zeit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is the profile usable for a run? A regional search without a single city would
|
||||||
|
// otherwise send the model off to search "everywhere", which is exactly the
|
||||||
|
// unbounded run we want to avoid.
|
||||||
|
function validate(profil) {
|
||||||
|
const fehler = [];
|
||||||
|
if (!MODI.includes(profil.modus)) fehler.push('Ungültiger Modus.');
|
||||||
|
if (profil.modus !== 'remote' && !(profil.staedte || []).length) {
|
||||||
|
fehler.push('Für die regionale Suche muss mindestens eine Stadt angegeben sein.');
|
||||||
|
}
|
||||||
|
if (profil.aktiv && !(profil.zeitplan_tage || []).length) {
|
||||||
|
fehler.push('Für den Zeitplan muss mindestens ein Wochentag gewählt sein.');
|
||||||
|
}
|
||||||
|
return fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is a scheduled run due? `letzterLauf` is the ISO timestamp of the last run that
|
||||||
|
// the schedule triggered (manual runs do not count, so a manual test does not
|
||||||
|
// swallow the day's scheduled run). Due means: today is one of the chosen days,
|
||||||
|
// the configured time has passed, and no scheduled run has happened since that
|
||||||
|
// time today.
|
||||||
|
function istFaellig(profil, jetzt, letzterLauf) {
|
||||||
|
if (!profil.aktiv) return false;
|
||||||
|
const tage = profil.zeitplan_tage || [];
|
||||||
|
if (!tage.length) return false;
|
||||||
|
|
||||||
|
const iso = jetzt.getDay() === 0 ? 7 : jetzt.getDay(); // JS: 0=So -> ISO 7
|
||||||
|
if (!tage.includes(iso)) return false;
|
||||||
|
|
||||||
|
const [h, m] = profil.zeitplan_zeit.split(':').map(Number);
|
||||||
|
const faelligAb = new Date(jetzt);
|
||||||
|
faelligAb.setHours(h, m, 0, 0);
|
||||||
|
if (jetzt < faelligAb) return false; // today's slot not reached yet
|
||||||
|
|
||||||
|
if (!letzterLauf) return true;
|
||||||
|
const letzte = new Date(letzterLauf);
|
||||||
|
if (Number.isNaN(letzte.getTime())) return true;
|
||||||
|
return letzte < faelligAb; // already ran for this slot?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the instruction the headless search agent gets. Everything user-specific
|
||||||
|
// is interpolated here, so the skill itself no longer carries anyone's cities.
|
||||||
|
// `profilText` is the user's CV/profile text (from their Lebenslauf template) —
|
||||||
|
// the roles and technologies to search for are derived from it, not configured.
|
||||||
|
function buildPrompt(profil, profilText) {
|
||||||
|
const teile = [];
|
||||||
|
|
||||||
|
teile.push(
|
||||||
|
'Führe eine IT-/Fach-Stellensuche vollständig autonom im Hintergrund aus, ohne jede Rückfrage. ' +
|
||||||
|
'Nutze dafür den Stellensuche-Skill (Quellen-Politik, Dedup, Blacklist, Volltext-Beschreibung).'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (profil.modus === 'regional') {
|
||||||
|
teile.push(
|
||||||
|
`Suche AUSSCHLIESSLICH Stellen mit Arbeitsort in diesen Städten: ${profil.staedte.join(', ')} ` +
|
||||||
|
`(Priorität in dieser Reihenfolge, die erste ist der Wohnort). Stellen außerhalb dieser Städte ` +
|
||||||
|
`verwerfen — auch das übrige Umland und reines Homeoffice/Remote ohne Sitz in einer dieser Städte. ` +
|
||||||
|
`Sitzt der Arbeitgeber in einer dieser Städte und bietet zusätzlich Homeoffice, ist die Stelle zulässig. ` +
|
||||||
|
`Setze IMMER labels=["Regional"].`
|
||||||
|
);
|
||||||
|
} else if (profil.modus === 'remote') {
|
||||||
|
teile.push(
|
||||||
|
'Suche AUSSCHLIESSLICH Stellen, die 100 % REMOTE / vollständig im Homeoffice und deutschlandweit ' +
|
||||||
|
'ortsunabhängig ausübbar sind (Anstellung in Deutschland). Firmensitz egal. Verwirf Präsenz-, Hybrid- ' +
|
||||||
|
'und ortsgebundene Stellen sowie vages „Homeoffice möglich" ohne klare 100-%-Remote-Zusage; bestätige ' +
|
||||||
|
'die Remote-Regelung live im Anzeigentext per WebFetch. Setze IMMER labels=["Remote"].'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
teile.push(
|
||||||
|
`Suche in ZWEI Durchgängen: (1) regional mit Arbeitsort ausschließlich in ${profil.staedte.join(', ')} ` +
|
||||||
|
`(Priorität in dieser Reihenfolge) — diese Treffer mit labels=["Regional"]; (2) deutschlandweit Stellen, ` +
|
||||||
|
`die 100 % REMOTE / vollständig im Homeoffice ausübbar sind (live per WebFetch bestätigen) — diese ` +
|
||||||
|
`Treffer mit labels=["Remote"]. Alles, was in keinen der beiden Durchgänge fällt, verwerfen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profilText && profilText.trim()) {
|
||||||
|
teile.push(
|
||||||
|
'Leite Rollenbezeichnungen (Query-Varianten) und Technologie-Buzzwords AUS DEM FOLGENDEN PROFIL DES ' +
|
||||||
|
'BEWERBERS ab — fasse die Rolle breit (dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln) und ' +
|
||||||
|
'gewichte Treffer höher, je mehr aus dem Profil passt:\n\n--- PROFIL ---\n' +
|
||||||
|
profilText.trim().slice(0, 6000) +
|
||||||
|
'\n--- ENDE PROFIL ---'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
teile.push(
|
||||||
|
'ACHTUNG: Es ist kein Lebenslauf/Profil hinterlegt. Leite Rollen und Suchbegriffe aus den bereits ' +
|
||||||
|
'erfassten Bewerbungen des Benutzers ab (GET /applications) und halte die Suche eng an deren Stellentiteln.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profil.zusatz_begriffe) {
|
||||||
|
teile.push(`Zusätzlich ausdrücklich berücksichtigen: ${profil.zusatz_begriffe}.`);
|
||||||
|
}
|
||||||
|
if (profil.ausschluesse) {
|
||||||
|
teile.push(`Ausschlusskriterien — solche Treffer verwerfen: ${profil.ausschluesse}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
teile.push(
|
||||||
|
'Fokussiere auf Original-Stellenanzeigen echter Arbeitgeber, bevorzugt direkt auf deren Karriereseite ' +
|
||||||
|
'(Firmen recherchieren, Karriereseite per WebFetch prüfen) — nicht nur breit ausgeschriebene Board-Anzeigen.'
|
||||||
|
);
|
||||||
|
|
||||||
|
teile.push(
|
||||||
|
'Pflege JEDEN neuen, live per WebFetch verifizierten Treffer sofort selbst als Jobangebot ein ' +
|
||||||
|
'(POST /joboffers, external_id DETERMINISTISCH aus firma+stelle mit Präfix "stellensuche-", NICHT aus einer ' +
|
||||||
|
'flüchtigen Board-ID, damit der Server-Upsert statt einer Dublette greift; art=Firmenwebsite wo möglich; die ' +
|
||||||
|
'VOLLSTÄNDIGE Stellenausschreibung als beschreibung; kontakt_email nur wenn belegt). Beachte die Dedup gegen ' +
|
||||||
|
'bereits erfasste Bewerbungen/Jobangebote und die Blacklist. Sperre jeden echten Neuimport (action:created) ' +
|
||||||
|
'danach sofort per POST /joboffers/blacklist (typ=firma_stelle, ort falls bekannt); bei 409 oder bloßem ' +
|
||||||
|
'updated NICHT blacklisten und NICHT mit force erzwingen.'
|
||||||
|
);
|
||||||
|
|
||||||
|
teile.push(
|
||||||
|
'Stelle am Ende KEINE Frage. Gib als LETZTE Zeile exakt diese Bilanz aus (nur Zahlen):\n' +
|
||||||
|
'ERGEBNIS neu=<X> dubletten=<Y> verworfen=<Z>'
|
||||||
|
);
|
||||||
|
|
||||||
|
return teile.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The runner reports back "ERGEBNIS neu=1 dubletten=2 verworfen=3"; pull the
|
||||||
|
// numbers out of whatever the agent printed. Missing counts read as 0.
|
||||||
|
function parseErgebnis(text) {
|
||||||
|
const m = /ERGEBNIS\s+neu=(\d+)\s+dubletten=(\d+)\s+verworfen=(\d+)/i.exec(String(text || ''));
|
||||||
|
if (!m) return null;
|
||||||
|
return { neu: Number(m[1]), dubletten: Number(m[2]), verworfen: Number(m[3]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MODI, WOCHENTAGE, DEFAULTS, MAX_STAEDTE,
|
||||||
|
fromRow, fromForm, toRow, validate, istFaellig, buildPrompt, parseErgebnis,
|
||||||
|
parseStaedte, parseTage, parseZeit,
|
||||||
|
};
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Host-side runner for the per-user job search.
|
||||||
|
//
|
||||||
|
// Why it lives on the host and not in the app: the app container has neither
|
||||||
|
// `claude` nor `ollama`. The app therefore only *queues* runs (table suchlaeufe);
|
||||||
|
// this script — started by cron every few minutes — picks them up and executes
|
||||||
|
// them. It replaces the two old single-user scripts (jobsuche-cron.sh and
|
||||||
|
// jobsuche-remote-cron.sh), whose search criteria were hard-coded into the prompt.
|
||||||
|
//
|
||||||
|
// Per run it:
|
||||||
|
// 1. enqueues scheduled runs that are due (per user's Zeitplan),
|
||||||
|
// 2. claims one queued run at a time (status angefordert -> laeuft),
|
||||||
|
// 3. builds the prompt from that user's Suchprofil + their Lebenslauf,
|
||||||
|
// 4. runs the headless agent with THAT user's credentials:
|
||||||
|
// - BEWERBUNG_API_KEY = the user's API token (created if missing), so every
|
||||||
|
// imported Jobangebot lands in their account and passes the same
|
||||||
|
// dedup/blacklist path as the rest of the app,
|
||||||
|
// - the model is driven by the user's own Ollama key (see KI_AUTH below),
|
||||||
|
// 5. parses the agent's final "ERGEBNIS neu=… dubletten=… verworfen=…" line and
|
||||||
|
// writes status + counts back, so the UI can show what happened.
|
||||||
|
//
|
||||||
|
// Everything is per user; nothing here reads a global .env any more.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const sqlite3 = require('sqlite3');
|
||||||
|
|
||||||
|
const suchprofil = require('../lib/suchprofil');
|
||||||
|
|
||||||
|
const DB_PATH = process.env.JOBSUCHE_DB || '/opt/jobbi-bewerbung/data/bewerbungen.db';
|
||||||
|
const LOG_DIR = process.env.JOBSUCHE_LOG_DIR || '/opt/jobbi-bewerbung/logs';
|
||||||
|
const API_URL = process.env.JOBSUCHE_API_URL || 'http://localhost:4327/api/v1';
|
||||||
|
// Swappable so the pipeline can be exercised end-to-end without burning tokens.
|
||||||
|
const CLAUDE_BIN = process.env.JOBSUCHE_CLAUDE_BIN || 'claude';
|
||||||
|
// 'benutzer' = each run uses that user's own Ollama key (they pay for their own
|
||||||
|
// search). 'host' = fall back to the machine's ollama login via `ollama launch`.
|
||||||
|
const KI_AUTH = process.env.JOBSUCHE_KI_AUTH || 'benutzer';
|
||||||
|
const RUN_TIMEOUT_MS = (Number(process.env.JOBSUCHE_TIMEOUT) || 2700) * 1000;
|
||||||
|
// A run that has been 'laeuft' for longer than this lost its process (reboot,
|
||||||
|
// OOM); it would otherwise block the user's queue forever.
|
||||||
|
const STALE_MS = RUN_TIMEOUT_MS + 10 * 60 * 1000;
|
||||||
|
const LOG_RETENTION_DAYS = Number(process.env.JOBSUCHE_LOG_RETENTION_DAYS) || 30;
|
||||||
|
const CFG = 'cfg:';
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(DB_PATH);
|
||||||
|
const all = (sql, p = []) => new Promise((res, rej) => db.all(sql, p, (e, r) => (e ? rej(e) : res(r))));
|
||||||
|
const get = (sql, p = []) => new Promise((res, rej) => db.get(sql, p, (e, r) => (e ? rej(e) : res(r))));
|
||||||
|
const run = (sql, p = []) => new Promise((res, rej) => db.run(sql, p, function (e) { e ? rej(e) : res(this); }));
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
console.log(`${new Date().toISOString()} ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cfgWert(userId, key) {
|
||||||
|
const row = await get('SELECT value FROM app_state WHERE user_id = ? AND key = ?', [userId, CFG + key]);
|
||||||
|
return row && row.value ? row.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user's API token, created on first use. Without it the agent could not
|
||||||
|
// import anything; making the user generate one by hand first would just be a
|
||||||
|
// trap ("profile active, nothing happens").
|
||||||
|
async function ensureApiKey(userId) {
|
||||||
|
const vorhanden = await cfgWert(userId, 'API_TOKEN');
|
||||||
|
if (vorhanden) return vorhanden;
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
await run(
|
||||||
|
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
|
||||||
|
[userId, CFG + 'API_TOKEN', token]
|
||||||
|
);
|
||||||
|
log(` API-Key für Benutzer ${userId} automatisch erzeugt.`);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CV text the search profile is derived from. Prefer the Lebenslauf, fall
|
||||||
|
// back to a Profil/Kurzprofil — the point is to give the model the user's actual
|
||||||
|
// roles and technologies rather than a hard-coded list.
|
||||||
|
async function profilText(userId) {
|
||||||
|
const rows = await all(
|
||||||
|
`SELECT typ, 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, id ASC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return rows.map((r) => (r.inhalt || '').trim()).filter(Boolean).join('\n\n---\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Queue scheduled runs that are due.
|
||||||
|
async function faelligeEinreihen() {
|
||||||
|
const jetzt = new Date();
|
||||||
|
const users = await all('SELECT user_id FROM suchprofil WHERE aktiv = 1');
|
||||||
|
for (const { user_id: userId } of users) {
|
||||||
|
const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId]));
|
||||||
|
if (suchprofil.validate(profil).length) continue; // unbrauchbares Profil -> kein Zeitplan-Lauf
|
||||||
|
|
||||||
|
// Only scheduled runs count here: a manual test run must not swallow the
|
||||||
|
// day's automatic run.
|
||||||
|
const letzter = await get(
|
||||||
|
"SELECT angefordert_at FROM suchlaeufe WHERE user_id = ? AND ausloeser = 'zeitplan' ORDER BY id DESC LIMIT 1",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const letzterAt = letzter ? `${letzter.angefordert_at}Z`.replace(' ', 'T') : null;
|
||||||
|
if (!suchprofil.istFaellig(profil, jetzt, letzterAt)) continue;
|
||||||
|
|
||||||
|
const offen = await get(
|
||||||
|
"SELECT id FROM suchlaeufe WHERE user_id = ? AND status IN ('angefordert','laeuft') LIMIT 1",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
if (offen) continue; // läuft schon etwas für diesen Benutzer
|
||||||
|
|
||||||
|
await run("INSERT INTO suchlaeufe (user_id, status, ausloeser) VALUES (?, 'angefordert', 'zeitplan')", [userId]);
|
||||||
|
log(`Zeitplan: Lauf für Benutzer ${userId} eingereiht.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs whose process died: release them so the user is not stuck.
|
||||||
|
async function verwaisteAufraeumen() {
|
||||||
|
const res = await run(
|
||||||
|
`UPDATE suchlaeufe SET status = 'fehler', beendet_at = CURRENT_TIMESTAMP,
|
||||||
|
fehler = 'Lauf abgebrochen (Prozess nicht mehr vorhanden).'
|
||||||
|
WHERE status = 'laeuft'
|
||||||
|
AND gestartet_at IS NOT NULL
|
||||||
|
AND (julianday('now') - julianday(gestartet_at)) * 86400000 > ?`,
|
||||||
|
[STALE_MS]
|
||||||
|
);
|
||||||
|
if (res.changes) log(`${res.changes} verwaiste(n) Lauf/Läufe als Fehler markiert.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the oldest queued run. The conditional UPDATE is the claim: a second
|
||||||
|
// runner starting concurrently gets changes = 0 and moves on.
|
||||||
|
async function naechstenLaufClaimen() {
|
||||||
|
const kandidat = await get("SELECT * FROM suchlaeufe WHERE status = 'angefordert' ORDER BY id ASC LIMIT 1");
|
||||||
|
if (!kandidat) return null;
|
||||||
|
const res = await run(
|
||||||
|
"UPDATE suchlaeufe SET status = 'laeuft', gestartet_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'angefordert'",
|
||||||
|
[kandidat.id]
|
||||||
|
);
|
||||||
|
if (!res.changes) return null;
|
||||||
|
return get('SELECT * FROM suchlaeufe WHERE id = ?', [kandidat.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function laufAbschliessen(id, status, felder = {}) {
|
||||||
|
await run(
|
||||||
|
`UPDATE suchlaeufe SET status = ?, beendet_at = CURRENT_TIMESTAMP,
|
||||||
|
neu = ?, dubletten = ?, verworfen = ?, fehler = ?, log_datei = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[status, felder.neu ?? null, felder.dubletten ?? null, felder.verworfen ?? null,
|
||||||
|
felder.fehler || null, felder.log || null, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn the headless agent and collect its output.
|
||||||
|
function agentStarten(prompt, env, logStream) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let argv;
|
||||||
|
if (KI_AUTH === 'host') {
|
||||||
|
// Legacy path: the machine's ollama login pays for the run.
|
||||||
|
argv = ['launch', 'claude', '--model', env.JOBSUCHE_MODEL, '--',
|
||||||
|
'--dangerously-skip-permissions', '-p', prompt];
|
||||||
|
argv = { cmd: 'ollama', args: argv };
|
||||||
|
} else {
|
||||||
|
// Per-user path: Claude Code talks to Ollama's Anthropic-compatible API with
|
||||||
|
// *this user's* key, so the search is billed to whoever requested it.
|
||||||
|
argv = { cmd: CLAUDE_BIN, args: ['--dangerously-skip-permissions', '-p', prompt] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = spawn(argv.cmd, argv.args, {
|
||||||
|
env,
|
||||||
|
cwd: '/opt/jobbi-bewerbung',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let ausgabe = '';
|
||||||
|
const sammeln = (buf) => {
|
||||||
|
const s = buf.toString();
|
||||||
|
ausgabe += s;
|
||||||
|
logStream.write(s);
|
||||||
|
};
|
||||||
|
kind.stdout.on('data', sammeln);
|
||||||
|
kind.stderr.on('data', sammeln);
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
kind.kill('SIGTERM');
|
||||||
|
setTimeout(() => kind.kill('SIGKILL'), 60000);
|
||||||
|
}, RUN_TIMEOUT_MS);
|
||||||
|
|
||||||
|
kind.on('error', (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ code: -1, ausgabe, fehler: `Agent nicht startbar: ${err.message}` });
|
||||||
|
});
|
||||||
|
kind.on('close', (code, signal) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const abgebrochen = signal === 'SIGTERM' || signal === 'SIGKILL';
|
||||||
|
resolve({
|
||||||
|
code,
|
||||||
|
ausgabe,
|
||||||
|
fehler: abgebrochen ? `Zeitlimit von ${RUN_TIMEOUT_MS / 1000}s überschritten.` : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function laufAusfuehren(lauf) {
|
||||||
|
const userId = lauf.user_id;
|
||||||
|
const user = await get('SELECT username FROM users WHERE id = ?', [userId]);
|
||||||
|
const name = user ? user.username : `#${userId}`;
|
||||||
|
log(`Lauf ${lauf.id} (Benutzer ${name}, ${lauf.ausloeser}) startet.`);
|
||||||
|
|
||||||
|
const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId]));
|
||||||
|
const fehler = suchprofil.validate(profil);
|
||||||
|
if (fehler.length) {
|
||||||
|
await laufAbschliessen(lauf.id, 'fehler', { fehler: fehler.join(' ') });
|
||||||
|
log(` abgebrochen: ${fehler.join(' ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ollamaKey = await cfgWert(userId, 'OLLAMA_API_KEY');
|
||||||
|
if (KI_AUTH !== 'host' && !ollamaKey) {
|
||||||
|
const msg = 'Kein eigener Ollama-API-Schlüssel hinterlegt — der Suchlauf läuft über das KI-Kontingent des Benutzers. Bitte in den Einstellungen eintragen.';
|
||||||
|
await laufAbschliessen(lauf.id, 'fehler', { fehler: msg });
|
||||||
|
log(` abgebrochen: kein Ollama-Key für ${name}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = await ensureApiKey(userId);
|
||||||
|
const modell = (await cfgWert(userId, 'OLLAMA_MODEL')) || 'glm-5.2:cloud';
|
||||||
|
const prompt = suchprofil.buildPrompt(profil, await profilText(userId));
|
||||||
|
|
||||||
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const logDatei = path.join(LOG_DIR, `jobsuche_${name}_${stamp}.log`);
|
||||||
|
const logStream = fs.createWriteStream(logDatei, { flags: 'a' });
|
||||||
|
logStream.write(`=== Suchlauf ${lauf.id} — Benutzer ${name} (${lauf.ausloeser}) ===\n`);
|
||||||
|
logStream.write(`Modus: ${profil.modus} | Städte: ${profil.staedte.join(', ') || '–'}\n`);
|
||||||
|
logStream.write(`Modell: ${modell} | KI-Auth: ${KI_AUTH}\n${'-'.repeat(50)}\n`);
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
HOME: process.env.HOME || '/root',
|
||||||
|
PATH: `/root/.local/bin:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}`,
|
||||||
|
IS_SANDBOX: '1',
|
||||||
|
// The tracker API — the agent imports Jobangebote as THIS user.
|
||||||
|
BEWERBUNG_API_URL: API_URL,
|
||||||
|
BEWERBUNG_API_KEY: apiKey,
|
||||||
|
JOBSUCHE_MODEL: modell,
|
||||||
|
};
|
||||||
|
if (KI_AUTH !== 'host') {
|
||||||
|
// Point Claude Code at Ollama's Anthropic-compatible endpoint with the user's key.
|
||||||
|
env.ANTHROPIC_BASE_URL = (await cfgWert(userId, 'OLLAMA_HOST')) || 'https://ollama.com';
|
||||||
|
env.ANTHROPIC_AUTH_TOKEN = ollamaKey;
|
||||||
|
env.ANTHROPIC_MODEL = modell;
|
||||||
|
env.ANTHROPIC_SMALL_FAST_MODEL = modell;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code, ausgabe, fehler: laufFehler } = await agentStarten(prompt, env, logStream);
|
||||||
|
const ergebnis = suchprofil.parseErgebnis(ausgabe);
|
||||||
|
|
||||||
|
logStream.write(`\n${'-'.repeat(50)}\nExit-Code: ${code}\n`);
|
||||||
|
logStream.end();
|
||||||
|
|
||||||
|
if (laufFehler) {
|
||||||
|
await laufAbschliessen(lauf.id, 'fehler', { fehler: laufFehler, log: logDatei });
|
||||||
|
log(` Lauf ${lauf.id} fehlgeschlagen: ${laufFehler}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code !== 0 && !ergebnis) {
|
||||||
|
await laufAbschliessen(lauf.id, 'fehler', {
|
||||||
|
fehler: `Suchlauf mit Exit-Code ${code} beendet (Details im Log).`,
|
||||||
|
log: logDatei,
|
||||||
|
});
|
||||||
|
log(` Lauf ${lauf.id} fehlgeschlagen (Exit ${code}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ergebnis) {
|
||||||
|
// The agent finished but did not report its tally — treat as done with unknown
|
||||||
|
// numbers rather than as a failure; the import itself already happened.
|
||||||
|
await laufAbschliessen(lauf.id, 'fertig', { neu: 0, dubletten: 0, verworfen: 0, log: logDatei });
|
||||||
|
log(` Lauf ${lauf.id} beendet, aber ohne ERGEBNIS-Zeile.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await laufAbschliessen(lauf.id, 'fertig', { ...ergebnis, log: logDatei });
|
||||||
|
log(` Lauf ${lauf.id} fertig: ${ergebnis.neu} neu, ${ergebnis.dubletten} Dubletten, ${ergebnis.verworfen} verworfen.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function alteLogsAufraeumen() {
|
||||||
|
try {
|
||||||
|
const grenze = Date.now() - LOG_RETENTION_DAYS * 86400000;
|
||||||
|
for (const f of fs.readdirSync(LOG_DIR)) {
|
||||||
|
if (!f.startsWith('jobsuche_') || !f.endsWith('.log')) continue;
|
||||||
|
const p = path.join(LOG_DIR, f);
|
||||||
|
if (fs.statSync(p).mtimeMs < grenze) fs.unlinkSync(p);
|
||||||
|
}
|
||||||
|
} catch (e) { /* Aufräumen ist best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!fs.existsSync(DB_PATH)) {
|
||||||
|
console.error(`Datenbank nicht gefunden: ${DB_PATH}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await run('PRAGMA busy_timeout = 10000'); // die App schreibt parallel
|
||||||
|
|
||||||
|
await verwaisteAufraeumen();
|
||||||
|
await faelligeEinreihen();
|
||||||
|
|
||||||
|
// Work the queue until it is empty. Runs are serialised on purpose: a search is
|
||||||
|
// web- and token-heavy, and several in parallel would trip rate limits.
|
||||||
|
let lauf;
|
||||||
|
let anzahl = 0;
|
||||||
|
while ((lauf = await naechstenLaufClaimen())) {
|
||||||
|
await laufAusfuehren(lauf);
|
||||||
|
anzahl += 1;
|
||||||
|
}
|
||||||
|
if (!anzahl) log('Nichts zu tun.');
|
||||||
|
|
||||||
|
alteLogsAufraeumen();
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('Runner-Fehler:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Cron-Einstiegspunkt für die Jobsuche (Multi-User).
|
||||||
|
#
|
||||||
|
# Ersetzt jobsuche-cron.sh + jobsuche-remote-cron.sh: Diese beiden hatten das
|
||||||
|
# Suchprofil (sieben Städte, Rollen, Buzzwords) fest im Prompt stehen und liefen
|
||||||
|
# mit EINEM globalen API-Token aus der .env — beides ist mit der Multi-User-
|
||||||
|
# Plattform hinfällig.
|
||||||
|
#
|
||||||
|
# Jetzt: Jeder Benutzer pflegt sein Suchprofil und seinen Zeitplan in der App
|
||||||
|
# (/jobsuche). Dieses Skript ruft nur noch den Runner auf, der fällige und manuell
|
||||||
|
# angeforderte Läufe abarbeitet — je Benutzer mit dessen eigenem Ollama- und
|
||||||
|
# API-Schlüssel.
|
||||||
|
#
|
||||||
|
# Cron (alle 5 Minuten — die Uhrzeit steuert der Benutzer, nicht der Cron):
|
||||||
|
# */5 * * * * /opt/jobbi-bewerbung/bin/jobsuche-runner.sh
|
||||||
|
#
|
||||||
|
# Manueller Lauf: /opt/jobbi-bewerbung/bin/jobsuche-runner.sh
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
export HOME="${HOME:-/root}"
|
||||||
|
export PATH="/root/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
|
||||||
|
# Claude Code verweigert --dangerously-skip-permissions als root; in dieser
|
||||||
|
# Container-/Sandbox-Umgebung mit IS_SANDBOX=1 erlaubt.
|
||||||
|
export IS_SANDBOX=1
|
||||||
|
|
||||||
|
PROJECT_DIR="/opt/jobbi-bewerbung"
|
||||||
|
LOG_DIR="$PROJECT_DIR/logs"
|
||||||
|
RUNNER="$PROJECT_DIR/source/scripts/jobsuche-runner.js"
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
LOG="$LOG_DIR/jobsuche-runner.log"
|
||||||
|
|
||||||
|
# Einzelinstanz: ein noch laufender Suchlauf darf vom nächsten Cron-Tick nicht
|
||||||
|
# ein zweites Mal gestartet werden.
|
||||||
|
exec 9>"$LOG_DIR/jobsuche-runner.lock"
|
||||||
|
if ! flock -n 9; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR" || exit 1
|
||||||
|
|
||||||
|
# API-Erreichbarkeit prüfen — ohne den Tracker kann der Agent nichts importieren.
|
||||||
|
if ! curl -fsS --max-time 10 http://localhost:4327/api/v1/health >/dev/null 2>&1; then
|
||||||
|
echo "$(date -Is) FEHLER: Bewerbungs-Tracker (localhost:4327) nicht erreichbar – Lauf übersprungen." >> "$LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
node "$RUNNER" >> "$LOG" 2>&1
|
||||||
|
STATUS=$?
|
||||||
|
|
||||||
|
# Log der Runner-Steuerung kurz halten (die eigentlichen Suchlauf-Logs liegen
|
||||||
|
# je Lauf separat und werden vom Runner selbst rotiert).
|
||||||
|
if [ -f "$LOG" ] && [ "$(wc -l < "$LOG")" -gt 5000 ]; then
|
||||||
|
tail -n 2000 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $STATUS
|
||||||
@@ -46,6 +46,7 @@ const { userContext, currentUser, currentUserId } = require('./lib/context');
|
|||||||
const password = require('./lib/password');
|
const password = require('./lib/password');
|
||||||
const migrate = require('./lib/migrate-multiuser');
|
const migrate = require('./lib/migrate-multiuser');
|
||||||
const { runMigration, importEnvIntoAdmin } = migrate;
|
const { runMigration, importEnvIntoAdmin } = migrate;
|
||||||
|
const suchprofil = require('./lib/suchprofil');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
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
|
// 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.
|
// pointing at one of our sent messages, then by sender = a previous recipient.
|
||||||
async function matchBewerbung(msg) {
|
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).
|
// Personal details of the applicant (one row per user).
|
||||||
await exec(`
|
await exec(`
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
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) -----
|
// ----- Admin: Benutzerverwaltung (nur für Admins) -----
|
||||||
// Admins legen neue Benutzer an, setzen Passwörter zurück und löschen
|
// 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
|
// Benutzer. Beim Löschen eines Benutzers löscht die DB per ON DELETE CASCADE
|
||||||
|
|||||||
@@ -74,6 +74,9 @@
|
|||||||
<td class="py-3 pr-4 text-gray-600 dark:text-gray-300"><%= u.anzahl_bewerbungen %></td>
|
<td class="py-3 pr-4 text-gray-600 dark:text-gray-300"><%= u.anzahl_bewerbungen %></td>
|
||||||
<td class="py-3 pr-4 text-gray-500 dark:text-gray-400"><%= u.created_at %></td>
|
<td class="py-3 pr-4 text-gray-500 dark:text-gray-400"><%= u.created_at %></td>
|
||||||
<td class="py-3 pr-4 text-right whitespace-nowrap">
|
<td class="py-3 pr-4 text-right whitespace-nowrap">
|
||||||
|
<a href="/jobsuche?user=<%= u.id %>"
|
||||||
|
class="text-sm text-blue-600 dark:text-blue-400 hover:underline mr-3"
|
||||||
|
title="Suchprofil und Zeitplan dieses Benutzers verwalten">Jobsuche</a>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onclick="document.getElementById('resetForm<%= u.id %>').classList.toggle('hidden')"
|
onclick="document.getElementById('resetForm<%= u.id %>').classList.toggle('hidden')"
|
||||||
class="text-sm text-blue-600 dark:text-blue-400 hover:underline mr-3">
|
class="text-sm text-blue-600 dark:text-blue-400 hover:underline mr-3">
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<%- include('partials/head') %>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
|
<%- include('partials/header') %>
|
||||||
|
|
||||||
|
<main class="flex-1 container mx-auto px-4 py-8 max-w-4xl">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 dark:text-white">Jobsuche</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Du legst fest, <strong>wo</strong> gesucht wird. <strong>Was</strong> gesucht wird – Rollen und
|
||||||
|
Technologien – leitet die KI aus deinem hinterlegten Lebenslauf ab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (fremd) { %>
|
||||||
|
<div class="mb-6 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4">
|
||||||
|
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
Du bearbeitest als Administrator das Suchprofil von <strong><%= zielUser.username %></strong>.
|
||||||
|
<a href="/jobsuche" class="underline">Zum eigenen Profil</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (fehler) { %>
|
||||||
|
<div class="mb-6 rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20 p-4">
|
||||||
|
<p class="text-sm text-red-800 dark:text-red-200"><%= fehler %></p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (hinweis) { %>
|
||||||
|
<div class="mb-6 rounded-lg border border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/20 p-4">
|
||||||
|
<p class="text-sm text-green-800 dark:text-green-200"><%= hinweis %></p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (!hatLebenslauf || !hatOllamaKey) { %>
|
||||||
|
<div class="mb-6 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4">
|
||||||
|
<p class="text-sm font-medium text-amber-800 dark:text-amber-200 mb-1">Vor dem ersten Suchlauf fehlt noch etwas:</p>
|
||||||
|
<ul class="text-sm text-amber-800 dark:text-amber-200 list-disc list-inside space-y-0.5">
|
||||||
|
<% if (!hatLebenslauf) { %>
|
||||||
|
<li>Kein Lebenslauf hinterlegt – ohne ihn kann die KI dein Suchprofil nicht ableiten.
|
||||||
|
<a href="/vorlagen" class="underline">Unter Vorlagen anlegen</a></li>
|
||||||
|
<% } %>
|
||||||
|
<% if (!hatOllamaKey) { %>
|
||||||
|
<li>Kein Ollama-API-Schlüssel – der Suchlauf läuft über dein eigenes KI-Kontingent.
|
||||||
|
<a href="/einstellungen" class="underline">In den Einstellungen eintragen</a></li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<form action="/jobsuche" method="POST" class="space-y-6">
|
||||||
|
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Automatische Suche</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Ist sie aktiv, sucht die KI nach Zeitplan und legt neue Treffer als Jobangebote an.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2 shrink-0">
|
||||||
|
<input type="checkbox" name="aktiv" value="1" <%= profil.aktiv ? 'checked' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">aktiv</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Wo gesucht wird</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">Modus und Orte.</p>
|
||||||
|
|
||||||
|
<div class="space-y-2 mb-5">
|
||||||
|
<% var modusText = {
|
||||||
|
regional: 'Nur Stellen mit Arbeitsort in den unten genannten Städten.',
|
||||||
|
remote: 'Nur Stellen, die zu 100 % im Homeoffice ausübbar sind – deutschlandweit, Firmensitz egal.',
|
||||||
|
beides: 'Zwei Durchgänge: regional in deinen Städten und zusätzlich 100 % Remote deutschlandweit.'
|
||||||
|
}; %>
|
||||||
|
<% modi.forEach(function (m) { %>
|
||||||
|
<label class="flex items-start gap-3 p-3 rounded-md border border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40">
|
||||||
|
<input type="radio" name="modus" value="<%= m %>" <%= profil.modus === m ? 'checked' : '' %>
|
||||||
|
class="mt-0.5 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium text-gray-800 dark:text-white">
|
||||||
|
<%= m === 'regional' ? 'Regional' : (m === 'remote' ? '100 % Remote' : 'Regional + Remote') %>
|
||||||
|
</span>
|
||||||
|
<span class="block text-xs text-gray-500 dark:text-gray-400"><%= modusText[m] %></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="staedte">Städte</label>
|
||||||
|
<textarea id="staedte" name="staedte" rows="3" spellcheck="false"
|
||||||
|
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
|
||||||
|
placeholder="Gladbeck, Bottrop, Gelsenkirchen, Essen"><%= profil.staedte.join(', ') %></textarea>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
Kommagetrennt, höchstens <%= maxStaedte %>. Die <strong>erste Stadt gilt als Wohnort</strong> und wird
|
||||||
|
am höchsten priorisiert. Bei „100 % Remote“ werden die Städte ignoriert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Feinschliff</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">
|
||||||
|
Optional. Rollen und Technologien kommen aus deinem Lebenslauf – hier ergänzt du nur Leitplanken.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zusatz_begriffe">Zusätzlich berücksichtigen</label>
|
||||||
|
<textarea id="zusatz_begriffe" name="zusatz_begriffe" rows="3"
|
||||||
|
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
|
||||||
|
placeholder="z. B. Teilzeit, Rechenzentrum, Systemhaus"><%= profil.zusatz_begriffe %></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="ausschluesse">Ausschließen</label>
|
||||||
|
<textarea id="ausschluesse" name="ausschluesse" rows="3"
|
||||||
|
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
|
||||||
|
placeholder="z. B. Zeitarbeit, Headhunter, Schichtdienst"><%= profil.ausschluesse %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Zeitplan</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">Wann die automatische Suche laufen soll.</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 mb-5">
|
||||||
|
<% wochentage.forEach(function (t) { %>
|
||||||
|
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-gray-300 dark:border-gray-600 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40">
|
||||||
|
<input type="checkbox" name="tage" value="<%= t.nr %>"
|
||||||
|
<%= profil.zeitplan_tage.indexOf(t.nr) !== -1 ? 'checked' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
|
||||||
|
<span class="text-gray-700 dark:text-gray-300"><%= t.kurz %></span>
|
||||||
|
</label>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zeitplan_zeit">Uhrzeit</label>
|
||||||
|
<input type="time" id="zeitplan_zeit" name="zeitplan_zeit" value="<%= profil.zeitplan_zeit %>"
|
||||||
|
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
Der Lauf startet innerhalb weniger Minuten nach dieser Uhrzeit.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<a href="/jobsuche<%= fremd ? '?user=' + zielUser.id : '' %>"
|
||||||
|
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
|
||||||
|
Verwerfen
|
||||||
|
</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mt-6">
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-5">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Suchläufe</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Ein Lauf durchsucht das Web und legt neue Treffer als
|
||||||
|
<a href="/jobangebote" class="text-blue-600 dark:text-blue-400 hover:underline">Jobangebote</a> an.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form action="/jobsuche/start" method="POST" class="shrink-0">
|
||||||
|
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Jetzt suchen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="laeufe">
|
||||||
|
<%- include('partials/suchlaeufe', { laeufe: laeufe }) %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<%- include('partials/footer') %>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const cy = document.getElementById('currentYear');
|
||||||
|
if (cy) cy.textContent = new Date().getFullYear();
|
||||||
|
|
||||||
|
// While a run is queued or in flight, refresh the list so the user sees it
|
||||||
|
// progress without reloading. Stops once nothing is pending any more.
|
||||||
|
const zielUser = <%- fremd ? zielUser.id : 'null' %>;
|
||||||
|
const url = '/jobsuche/laeufe' + (zielUser ? '?user=' + zielUser : '');
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function offen(laeufe) {
|
||||||
|
return laeufe.some(function (l) { return l.status === 'angefordert' || l.status === 'laeuft'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const laeufe = await res.json();
|
||||||
|
if (!offen(laeufe)) {
|
||||||
|
clearInterval(timer);
|
||||||
|
location.reload(); // fertig -> Ergebnisse serverseitig rendern
|
||||||
|
}
|
||||||
|
} catch (e) { /* nächster Tick */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = <%- JSON.stringify(laeufe.map(function (l) { return { status: l.status }; })) %>;
|
||||||
|
if (offen(initial)) timer = setInterval(poll, 5000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -32,6 +32,16 @@
|
|||||||
<span id="jobangeboteBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-green-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
|
<span id="jobangeboteBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-green-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Jobsuche (per-user search profile + runs) link -->
|
||||||
|
<a href="/jobsuche"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
||||||
|
title="Eigenes Suchprofil und Suchläufe verwalten">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Jobsuche</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Blacklist (blocked job offers) link -->
|
<!-- Blacklist (blocked job offers) link -->
|
||||||
<a href="/blacklist"
|
<a href="/blacklist"
|
||||||
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<% if (!laeufe.length) { %>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Noch kein Suchlauf durchgeführt.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th class="py-2 pr-4 font-medium">Zeitpunkt</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Auslöser</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Status</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Ergebnis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% laeufe.forEach(function (l) {
|
||||||
|
var badge = {
|
||||||
|
angefordert: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200',
|
||||||
|
laeuft: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
fertig: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
|
fehler: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
|
||||||
|
}[l.status] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200';
|
||||||
|
var text = {
|
||||||
|
angefordert: 'wartet', laeuft: 'läuft…', fertig: 'fertig', fehler: 'Fehler'
|
||||||
|
}[l.status] || l.status;
|
||||||
|
var zeit = l.gestartet_at || l.angefordert_at;
|
||||||
|
%>
|
||||||
|
<tr class="border-b border-gray-100 dark:border-gray-700/60">
|
||||||
|
<td class="py-2 pr-4 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
|
<%= zeit ? new Date(zeit + 'Z').toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '–' %>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 text-gray-500 dark:text-gray-400">
|
||||||
|
<%= l.ausloeser === 'zeitplan' ? 'Zeitplan' : 'manuell' %>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium <%= badge %>"><%= text %></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 text-gray-700 dark:text-gray-300">
|
||||||
|
<% if (l.status === 'fertig') { %>
|
||||||
|
<span class="font-medium text-green-700 dark:text-green-400"><%= l.neu || 0 %> neu</span>,
|
||||||
|
<%= l.dubletten || 0 %> Dubletten, <%= l.verworfen || 0 %> verworfen
|
||||||
|
<% } else if (l.status === 'fehler') { %>
|
||||||
|
<span class="text-red-700 dark:text-red-400" title="<%= l.fehler || '' %>">
|
||||||
|
<%= (l.fehler || 'Unbekannter Fehler').slice(0, 80) %>
|
||||||
|
</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">–</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
Reference in New Issue
Block a user