Let job offers carry a full description into AI generation
Job offers can now be edited in the UI — a large description textarea (any length) plus the other fields — so a full job posting can be pasted before turning it into an application. Taking an offer over copies its complete description into the application's stellenbeschreibung, which is exactly the text handed to the LLM. New primary action "Übernehmen & KI-Unterlagen" creates the application and immediately starts generation; "Nur als Entwurf" keeps the plain draft path. New routes: POST /jobangebote/:id/bearbeiten and /jobangebote/:id/uebernehmen-generieren. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1684,56 +1684,118 @@ initializeDatabase().then(() => {
|
||||
// Convert a job offer into a Bewerbung draft (mirrors the Indeed import flow:
|
||||
// creates a bewerbung with status "Entwurf", records the initial timeline entry,
|
||||
// and links the offer back to it).
|
||||
// Create a Bewerbung draft from an offer (or return the already-linked one).
|
||||
// The offer's FULL description is carried into `stellenbeschreibung`, which is
|
||||
// exactly the text handed to the LLM during generation. Returns the id.
|
||||
async function uebernehmeAngebot(angebot) {
|
||||
if (angebot.verknuepfte_bewerbung_id) return angebot.verknuepfte_bewerbung_id;
|
||||
|
||||
const datum = new Date().toISOString().split('T')[0];
|
||||
const notizParts = [
|
||||
angebot.ort ? `Ort: ${angebot.ort}` : null,
|
||||
angebot.gehalt ? `Gehalt: ${angebot.gehalt}` : null,
|
||||
angebot.kontakt_email ? `Kontakt: ${angebot.kontakt_email}` : null,
|
||||
angebot.quelle_url ? `Quelle: ${angebot.quelle_url}` : null,
|
||||
angebot.quelle ? `Importiert via: ${angebot.quelle}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const result = await dbRun(
|
||||
`INSERT INTO bewerbungen
|
||||
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung,
|
||||
quelle_url, email_empfaenger, generierung_status)
|
||||
VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, ?, 'nicht_gestartet')`,
|
||||
[
|
||||
datum,
|
||||
sanitizeInput(angebot.firma),
|
||||
sanitizeInput(angebot.stelle),
|
||||
sanitizeInput(angebot.art || deriveArt(angebot.quelle_url, null)),
|
||||
notizParts.join('\n'),
|
||||
angebot.ort || '',
|
||||
angebot.beschreibung || '',
|
||||
angebot.quelle_url || '',
|
||||
angebot.kontakt_email || '',
|
||||
]
|
||||
);
|
||||
|
||||
await dbRun(
|
||||
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
||||
[result.lastID, datum, 'Entwurf', `Automatisch aus Jobangebot übernommen (${angebot.quelle || 'drittanbieter'})`]
|
||||
);
|
||||
await dbRun(
|
||||
'UPDATE jobangebote SET verknuepfte_bewerbung_id = ?, status = "uebernommen", updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[result.lastID, angebot.id]
|
||||
);
|
||||
return result.lastID;
|
||||
}
|
||||
|
||||
app.post('/jobangebote/:id/uebernehmen', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [id]);
|
||||
const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
if (!angebot) return res.status(404).send('Jobangebot nicht gefunden');
|
||||
if (angebot.verknuepfte_bewerbung_id) {
|
||||
return res.redirect('/bewerbung/' + angebot.verknuepfte_bewerbung_id);
|
||||
}
|
||||
|
||||
const datum = new Date().toISOString().split('T')[0];
|
||||
const notizParts = [
|
||||
angebot.ort ? `Ort: ${angebot.ort}` : null,
|
||||
angebot.gehalt ? `Gehalt: ${angebot.gehalt}` : null,
|
||||
angebot.quelle_url ? `Quelle: ${angebot.quelle_url}` : null,
|
||||
angebot.quelle ? `Importiert via: ${angebot.quelle}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const result = await dbRun(
|
||||
`INSERT INTO bewerbungen
|
||||
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung,
|
||||
quelle_url, generierung_status)
|
||||
VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
|
||||
[
|
||||
datum,
|
||||
sanitizeInput(angebot.firma),
|
||||
sanitizeInput(angebot.stelle),
|
||||
sanitizeInput(angebot.art || deriveArt(angebot.quelle_url, null)),
|
||||
notizParts.join('\n'),
|
||||
angebot.ort || '',
|
||||
angebot.beschreibung || '',
|
||||
angebot.quelle_url || '',
|
||||
]
|
||||
);
|
||||
|
||||
await dbRun(
|
||||
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
||||
[result.lastID, datum, 'Entwurf', `Automatisch aus Jobangebot übernommen (${angebot.quelle || 'drittanbieter'})`]
|
||||
);
|
||||
await dbRun(
|
||||
'UPDATE jobangebote SET verknuepfte_bewerbung_id = ?, status = "uebernommen", updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[result.lastID, id]
|
||||
);
|
||||
|
||||
res.redirect('/bewerbung/' + result.lastID);
|
||||
const bewerbungId = await uebernehmeAngebot(angebot);
|
||||
res.redirect('/bewerbung/' + bewerbungId);
|
||||
} catch (error) {
|
||||
console.error('Error converting job offer:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Übernehmen AND immediately kick off the AI generation, then jump to the
|
||||
// application page where the progress is shown.
|
||||
app.post('/jobangebote/:id/uebernehmen-generieren', async (req, res) => {
|
||||
try {
|
||||
const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
if (!angebot) return res.status(404).send('Jobangebot nicht gefunden');
|
||||
const bewerbungId = await uebernehmeAngebot(angebot);
|
||||
|
||||
// Clear any previously generated files, then (re)start generation.
|
||||
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [bewerbungId]);
|
||||
for (const a of alte) fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
|
||||
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [bewerbungId]);
|
||||
await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?", [bewerbungId]);
|
||||
runGeneration(bewerbungId);
|
||||
|
||||
res.redirect('/bewerbung/' + bewerbungId);
|
||||
} catch (error) {
|
||||
console.error('Error converting + generating from job offer:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Edit an offer's fields — mainly to paste/adjust the full job description
|
||||
// (any length) before turning it into an application.
|
||||
app.post('/jobangebote/:id/bearbeiten', async (req, res) => {
|
||||
try {
|
||||
const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
if (!angebot) return res.status(404).send('Jobangebot nicht gefunden');
|
||||
|
||||
const b = req.body || {};
|
||||
const quelleUrl = sanitizeInput(b.quelle_url || '');
|
||||
await dbRun(
|
||||
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, gehalt = ?,
|
||||
beschreibung = ?, kontakt_email = ?, quelle_url = ?, anzeige_datum = ?,
|
||||
url_norm = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
[
|
||||
sanitizeInput((b.firma || '').trim()) || angebot.firma,
|
||||
sanitizeInput((b.stelle || '').trim()) || angebot.stelle,
|
||||
sanitizeInput(b.ort || ''),
|
||||
sanitizeInput(b.gehalt || ''),
|
||||
sanitizeInput(b.beschreibung || ''),
|
||||
sanitizeInput(b.kontakt_email || ''),
|
||||
quelleUrl,
|
||||
sanitizeInput(b.anzeige_datum || ''),
|
||||
blacklist.normalizeUrl(b.quelle_url || '') || null,
|
||||
req.params.id,
|
||||
]
|
||||
);
|
||||
res.redirect('/jobangebote');
|
||||
} catch (error) {
|
||||
console.error('Error editing job offer:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a job offer. Deleting always blacklists it first, so the same offer
|
||||
// can never be ingested/listed again (the requirement: never appears twice,
|
||||
// even after deletion). The entry can be removed later on /blacklist.
|
||||
|
||||
+59
-3
@@ -120,12 +120,20 @@
|
||||
Zur Bewerbung
|
||||
</a>
|
||||
<% } else { %>
|
||||
<form action="/jobangebote/<%= j.id %>/uebernehmen" method="POST">
|
||||
<form action="/jobangebote/<%= j.id %>/uebernehmen-generieren" method="POST">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors w-full"
|
||||
title="Aus diesem Angebot eine Bewerbung (Entwurf) anlegen">
|
||||
title="Bewerbung anlegen und sofort KI-Unterlagen (Anschreiben + Lebenslauf) generieren">
|
||||
<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 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path></svg>
|
||||
Übernehmen & KI-Unterlagen
|
||||
</button>
|
||||
</form>
|
||||
<form action="/jobangebote/<%= j.id %>/uebernehmen" method="POST">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs 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 w-full"
|
||||
title="Nur einen Bewerbungs-Entwurf anlegen (ohne KI-Generierung)">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
|
||||
Als Bewerbung übernehmen
|
||||
Nur als Entwurf
|
||||
</button>
|
||||
</form>
|
||||
<% } %>
|
||||
@@ -139,6 +147,54 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit form: paste/adjust the full job description (any length). -->
|
||||
<details class="mt-3 border-t border-gray-200 dark:border-gray-600 pt-3">
|
||||
<summary class="cursor-pointer inline-flex items-center gap-1.5 text-xs text-blue-600 dark:text-blue-400 hover:underline select-none">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||
Beschreibung / Details bearbeiten
|
||||
</summary>
|
||||
<form action="/jobangebote/<%= j.id %>/bearbeiten" method="POST" class="mt-3 space-y-3">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<label class="block text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Firma</span>
|
||||
<input name="firma" value="<%= j.firma %>" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
</label>
|
||||
<label class="block text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Stelle</span>
|
||||
<input name="stelle" value="<%= j.stelle %>" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
</label>
|
||||
<label class="block text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Ort</span>
|
||||
<input name="ort" value="<%= j.ort || '' %>" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
</label>
|
||||
<label class="block text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Gehalt</span>
|
||||
<input name="gehalt" value="<%= j.gehalt || '' %>" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
</label>
|
||||
<label class="block text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Kontakt-E-Mail</span>
|
||||
<input name="kontakt_email" type="email" value="<%= j.kontakt_email || '' %>" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
</label>
|
||||
<label class="block text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Anzeige-Datum</span>
|
||||
<input name="anzeige_datum" type="date" value="<%= j.anzeige_datum ? new Date(j.anzeige_datum).toISOString().split('T')[0] : '' %>" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
</label>
|
||||
<label class="block text-xs sm:col-span-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">Quelle-URL</span>
|
||||
<input name="quelle_url" value="<%= j.quelle_url || '' %>" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
</label>
|
||||
</div>
|
||||
<label class="block text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Stellenbeschreibung <span class="text-gray-400">(beliebig lang — wird bei „Übernehmen“ als Stellenbeschreibung für die KI mitgenommen)</span></span>
|
||||
<textarea name="beschreibung" rows="10" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm leading-relaxed" placeholder="Vollständige Stellenbeschreibung hier einfügen …"><%= j.beschreibung || '' %></textarea>
|
||||
</label>
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs 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>
|
||||
</form>
|
||||
</details>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user