Files
jobbi-bewerbung/lib/api.js
T
thomasandClaude Opus 4.8 d1116d522d Vor dem Generieren waehlen: Anschreiben, Lebenslauf oder beides
Auf der Bewerbungsseite laesst sich jetzt auswaehlen, welche Unterlagen die KI
erzeugt. Standard bleibt beides; die letzte Auswahl wird gemerkt und ist beim
naechsten Generieren vorbelegt. Ohne Haken bricht das Formular ab statt still
beides zu erzeugen.

Nicht gewaehlte Dokumente werden gar nicht erst angefragt: JSON-Schema und
Skelett im Prompt werden auf die gewuenschten Teile reduziert, das spart einen
Gutteil der Generierungszeit. Gerendert wird ebenfalls nur das Gewaehlte - auch
wenn das Modell sich nicht an das Schema haelt und trotzdem alles liefert.

Fuer den E-Mail-Versand mitgedacht:
- Wird kein Lebenslauf erzeugt, fuehrt ihn das Anschreiben nicht mehr unter
  "Anlagen" auf. Sonst kuendigt der Brief eine Anlage an, die nie mitgeht.
- Der Prompt bekommt zusaetzlich die Liste der Dateien, die der Bewerbungsmail
  tatsaechlich anhaengen (inkl. Anschreiben selbst), damit der Begleittext keine
  Unterlagen nennt, die nicht dabei sind.
- Die Anhang-Checkboxen im Mailformular ziehen sich aus den erzeugten Anhaengen,
  greifen also automatisch.

Die REST-API kennt das Feld ebenfalls (POST .../generate, "dokumente"), inkl.
OpenAPI-Doku.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:18:02 +02:00

738 lines
28 KiB
JavaScript

// Third-party REST API (v1) for the Bewerbungs-Tracker.
//
// Mounted under /api/v1 in server.js. All endpoints except /health require an
// API key (env API_TOKEN) sent in the X-API-Key header. Reuses the server's
// existing DB helpers, sanitizer, duplicate guard, generation runner and
// attachment directories so behaviour stays consistent with the web UI.
const express = require('express');
const path = require('path');
const fs = require('fs');
const blacklist = require('./blacklist');
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
const { normalizeDokumente } = require('./documents');
// Replace the stored labels JSON string with a real array on outgoing rows.
function withLabels(row) {
if (row) row.labels = parseLabels(row.labels);
return row;
}
const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Absage', 'Einstellung', 'Keine Rückmeldung',
];
function createExternalApi(deps) {
const {
dbGet,
dbAll,
dbRun,
sanitizeInput,
attachVerlauf,
findDuplicateApplications,
syncCurrentStatus,
runGeneration,
anhaengeDir,
emailAnhaengeDir,
apiToken,
} = deps;
const router = express.Router();
// --- API key auth --------------------------------------------------
// /health is public so monitoring tools can probe availability; everything
// else returns 401 when the header is missing/wrong or the token isn't set.
router.use((req, res, next) => {
if (req.path === '/health') return next();
if (!apiToken) {
return res.status(503).json({ error: 'API-Token nicht konfiguriert (API_TOKEN-Umgebungsvariable fehlt).' });
}
const provided = req.get('X-API-Key');
if (!provided || provided !== apiToken) {
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
}
next();
});
// --- helpers -------------------------------------------------------
async function getApplication(id) {
return dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
}
// --- System --------------------------------------------------------
router.get('/health', (req, res) => {
res.json({ status: 'ok', api: 'bewerbungs-tracker/v1' });
});
// --- Applications --------------------------------------------------
router.get('/applications', async (req, res) => {
try {
const { month, year, status, art, search, label } = req.query;
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
const where = [];
const params = [];
if (label && LABEL_OPTIONS.includes(label)) {
// labels is a JSON array string; match the quoted label token.
where.push('labels LIKE ?');
params.push(`%"${label}"%`);
}
if (month) {
where.push('strftime("%m", datum) = ?');
params.push(String(month).padStart(2, '0'));
}
if (year) {
where.push('strftime("%Y", datum) = ?');
params.push(String(year));
}
if (status) {
where.push('status = ?');
params.push(status);
}
if (art) {
where.push('art = ?');
params.push(art);
}
if (search) {
where.push('(firma LIKE ? OR stelle LIKE ?)');
const term = `%${search}%`;
params.push(term, term);
}
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
const applications = await dbAll(
`SELECT * FROM bewerbungen ${clause} ORDER BY datum DESC, created_at DESC LIMIT ? OFFSET ?`,
[...params, limit, offset]
);
await attachVerlauf(applications);
applications.forEach(withLabels);
res.json(applications);
} catch (error) {
console.error('API list applications error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.post('/applications', async (req, res) => {
try {
const b = req.body || {};
const { firma, stelle } = b;
if (!firma || !stelle) {
return res.status(400).json({ error: 'firma und stelle sind erforderlich.' });
}
const datum = b.datum || new Date().toISOString().split('T')[0];
const force = b.force === true || b.force === 'true';
if (!force) {
const dups = await findDuplicateApplications({ firma, stelle });
if (dups.length) {
return res.status(409).json({
duplicate: true,
matches: dups,
error: 'Es gibt bereits eine Bewerbung für dieselbe Firma und Stelle.',
});
}
}
const result = await dbRun(
`INSERT INTO bewerbungen
(datum, firma, stelle, art, status, notizen, interne_notizen, ort,
stellenbeschreibung, quelle_url, llm_notizen, labels, generierung_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
[
datum,
sanitizeInput(firma),
sanitizeInput(stelle),
sanitizeInput(b.art || ''),
sanitizeInput(b.status || ''),
sanitizeInput(b.notizen || ''),
sanitizeInput(b.interne_notizen || ''),
sanitizeInput(b.ort || ''),
sanitizeInput(b.stellenbeschreibung || ''),
sanitizeInput(b.quelle_url || ''),
sanitizeInput(b.llm_notizen || ''),
serializeLabels(b.labels),
]
);
if (b.status && String(b.status).trim()) {
await dbRun(
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
[result.lastID, datum, sanitizeInput(b.status), sanitizeInput(b.kommentar || '')]
);
}
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]);
res.json({ success: true, application: withLabels(application) });
} catch (error) {
console.error('API create application error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.get('/applications/:id', async (req, res) => {
try {
const application = await getApplication(req.params.id);
if (!application) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
await attachVerlauf([application]);
res.json(withLabels(application));
} catch (error) {
console.error('API get application error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.put('/applications/:id', async (req, res) => {
try {
const { id } = req.params;
const existing = await getApplication(id);
if (!existing) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const b = req.body || {};
if (!b.firma || !b.stelle || !b.datum) {
return res.status(400).json({ error: 'datum, firma und stelle sind erforderlich.' });
}
await dbRun(
`UPDATE bewerbungen SET
datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?,
interne_notizen = ?, ort = ?, stellenbeschreibung = ?, quelle_url = ?,
llm_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
b.datum,
sanitizeInput(b.firma),
sanitizeInput(b.stelle),
sanitizeInput(b.art ?? existing.art),
sanitizeInput(b.status ?? existing.status),
sanitizeInput(b.notizen ?? existing.notizen),
sanitizeInput(b.interne_notizen ?? existing.interne_notizen),
sanitizeInput(b.ort ?? existing.ort),
sanitizeInput(b.stellenbeschreibung ?? existing.stellenbeschreibung),
sanitizeInput(b.quelle_url ?? existing.quelle_url),
sanitizeInput(b.llm_notizen ?? existing.llm_notizen),
Object.prototype.hasOwnProperty.call(b, 'labels')
? serializeLabels(b.labels)
: (existing.labels || '[]'),
id,
]
);
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
res.json({ success: true, application: withLabels(application) });
} catch (error) {
console.error('API update application error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.delete('/applications/:id', async (req, res) => {
try {
const { id } = req.params;
const existing = await getApplication(id);
if (!existing) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]);
await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]);
res.json({ success: true });
} catch (error) {
console.error('API delete application error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Timeline ------------------------------------------------------
router.get('/applications/:id/timeline', async (req, res) => {
try {
const { id } = req.params;
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const verlauf = await dbAll(
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC',
[id]
);
res.json(verlauf);
} catch (error) {
console.error('API list timeline error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.post('/applications/:id/timeline', async (req, res) => {
try {
const { id } = req.params;
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const { status, datum, kommentar } = req.body || {};
if (!status || !STATUS_OPTIONS.includes(status)) {
return res.status(400).json({ error: `status erforderlich (eine von: ${STATUS_OPTIONS.join(', ')})` });
}
const day = datum || new Date().toISOString().split('T')[0];
const result = await dbRun(
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
[id, day, status, sanitizeInput(kommentar || '')]
);
await syncCurrentStatus(id);
const entry = await dbGet('SELECT * FROM status_verlauf WHERE id = ?', [result.lastID]);
res.json(entry);
} catch (error) {
console.error('API add timeline error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.delete('/applications/:id/timeline/:eintragId', async (req, res) => {
try {
const { id, eintragId } = req.params;
const entry = await dbGet(
'SELECT id FROM status_verlauf WHERE id = ? AND bewerbung_id = ?',
[eintragId, id]
);
if (!entry) return res.status(404).json({ error: 'Verlaufseintrag nicht gefunden' });
await dbRun('DELETE FROM status_verlauf WHERE id = ?', [eintragId]);
await syncCurrentStatus(id);
res.json({ success: true });
} catch (error) {
console.error('API delete timeline error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Attachments ---------------------------------------------------
router.get('/applications/:id/attachments', async (req, res) => {
try {
const { id } = req.params;
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const anhaenge = await dbAll(
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
[id]
);
res.json(anhaenge);
} catch (error) {
console.error('API list attachments error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.get('/applications/:id/attachments/:attachmentId', async (req, res) => {
try {
const { id, attachmentId } = req.params;
const anhang = await dbGet(
'SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?',
[attachmentId, id]
);
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
const file = path.join(anhaengeDir, anhang.pfad);
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
res.download(file, anhang.dateiname || anhang.name || path.basename(anhang.pfad));
} catch (error) {
console.error('API download attachment error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Emails --------------------------------------------------------
router.get('/applications/:id/emails', async (req, res) => {
try {
const { id } = req.params;
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const emails = await dbAll(
'SELECT * FROM emails WHERE bewerbung_id = ? ORDER BY datetime(email_date) ASC, id ASC',
[id]
);
if (emails.length) {
const eIds = emails.map((e) => e.id);
const atts = await dbAll(
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE email_id IN (${eIds.map(() => '?').join(',')})`,
eIds
);
const byEmail = {};
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
emails.forEach((e) => { e.anhaenge = byEmail[e.id] || []; });
}
res.json(emails);
} catch (error) {
console.error('API list emails error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.get('/emails/:emailId/attachments/:attachmentId', async (req, res) => {
try {
const { emailId, attachmentId } = req.params;
const anhang = await dbGet(
'SELECT * FROM email_anhaenge WHERE id = ? AND email_id = ?',
[attachmentId, emailId]
);
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
const file = path.join(emailAnhaengeDir, anhang.pfad);
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
res.download(file, anhang.name || path.basename(anhang.pfad));
} catch (error) {
console.error('API download email attachment error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Generation ----------------------------------------------------
router.post('/applications/:id/generate', async (req, res) => {
try {
const { id } = req.params;
const bewerbung = await getApplication(id);
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
if (typeof (req.body || {}).llm_notizen !== 'undefined') {
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ?', [req.body.llm_notizen || '', id]);
}
// Drop existing generated attachments + their files before re-generating.
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]);
for (const a of alte) {
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
}
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]);
await dbRun(
"UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?",
[id]
);
// Optional: IDs of static attachments (basis_anhaenge) to enclose; none
// by default. Also reflected in the cover letter's "Anlagen" list.
const anlagenIds = Array.isArray((req.body || {}).anlagen)
? req.body.anlagen.map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n))
: [];
// Optional: which documents to produce (["anschreiben"], ["lebenslauf"] or
// both). Omitted / empty means both.
const dokumente = normalizeDokumente((req.body || {}).dokumente);
await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ?', [dokumente.join(','), id]);
runGeneration(id, { anlagenIds, dokumente });
res.status(202).json({ success: true, dokumente });
} catch (error) {
console.error('API generate error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.get('/applications/:id/generation-status', async (req, res) => {
try {
const { id } = req.params;
const bewerbung = await dbGet(
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?',
[id]
);
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const anhaenge = await dbAll(
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
[id]
);
res.json({ status: bewerbung.generierung_status, fehler: bewerbung.generierung_fehler, anhaenge });
} catch (error) {
console.error('API generation status error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Settings ------------------------------------------------------
router.get('/settings', async (req, res) => {
try {
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
res.json(settings);
} catch (error) {
console.error('API get settings error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.put('/settings', async (req, res) => {
try {
const { name, adresse, kundennummer } = req.body || {};
await dbRun(
'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1',
[sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)]
);
res.json({ success: true });
} catch (error) {
console.error('API save settings error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Statistics & export -------------------------------------------
router.get('/statistics', async (req, res) => {
try {
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen');
const byArt = await dbAll(`
SELECT art, COUNT(*) as count FROM bewerbungen
WHERE art IS NOT NULL AND art != ''
GROUP BY art ORDER BY count DESC
`);
const byStatus = await dbAll(`
SELECT status, COUNT(*) as count FROM bewerbungen
WHERE status IS NOT NULL AND status != ''
GROUP BY status ORDER BY count DESC
`);
res.json({
total: totalCount ? totalCount.count : 0,
byArt,
byStatus,
});
} catch (error) {
console.error('API statistics error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.get('/export', async (req, res) => {
try {
const { month, year } = req.query;
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC';
const params = [];
if (month && year) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
params.push(String(month).padStart(2, '0'), String(year));
} else if (month) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? ORDER BY datum DESC';
params.push(String(month).padStart(2, '0'));
} else if (year) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC';
params.push(String(year));
}
const applications = await dbAll(query, params);
await attachVerlauf(applications);
// Internal notes never leave the tracker via export.
applications.forEach((a) => { delete a.interne_notizen; });
res.json(applications);
} catch (error) {
console.error('API export error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Templates -----------------------------------------------------
router.get('/templates', async (req, res) => {
try {
const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
res.json(docs);
} catch (error) {
console.error('API list templates error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Job offers (ingested by third-party software) -----------------
// POST upserts by (quelle, external_id): re-sending the same offer updates
// it instead of creating a duplicate. `quelle` defaults to "drittanbieter".
router.get('/joboffers', async (req, res) => {
try {
const rows = await dbAll(
`SELECT * FROM jobangebote ORDER BY created_at DESC, id DESC`
);
rows.forEach(withLabels);
res.json(rows);
} catch (error) {
console.error('API list job offers error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Blacklist (must be registered BEFORE /joboffers/:id) ----------
// Blocks offers from ever (re)appearing. See lib/blacklist for the match
// rules. A deleted offer is auto-blacklisted so it can never return.
router.get('/joboffers/blacklist', async (req, res) => {
try {
const rows = await dbAll(
'SELECT * FROM jobangebote_blacklist ORDER BY created_at DESC, id DESC'
);
res.json(rows);
} catch (error) {
console.error('API list blacklist error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.post('/joboffers/blacklist', async (req, res) => {
try {
const b = req.body || {};
const entry = blacklist.buildManualEntry({
typ: b.typ,
wert: b.wert != null ? sanitizeInput(String(b.wert)) : '',
firma: sanitizeInput(b.firma || ''),
stelle: sanitizeInput(b.stelle || ''),
ort: sanitizeInput(b.ort || ''),
grund: sanitizeInput(b.grund || ''),
});
if (!entry) {
return res.status(400).json({
error: `Ungültiger Blacklist-Eintrag. typ muss eine von ${blacklist.TYPES.join(', ')} sein und einen passenden Wert haben.`,
});
}
const cols = blacklist.COLUMNS;
const result = await dbRun(
`INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`,
cols.map((c) => (entry[c] === undefined ? null : entry[c]))
);
const row = await dbGet('SELECT * FROM jobangebote_blacklist WHERE id = ?', [result.lastID]);
res.status(201).json({ success: true, entry: row });
} catch (error) {
console.error('API create blacklist entry error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.delete('/joboffers/blacklist/:id', async (req, res) => {
try {
const row = await dbGet('SELECT id FROM jobangebote_blacklist WHERE id = ?', [req.params.id]);
if (!row) return res.status(404).json({ error: 'Blacklist-Eintrag nicht gefunden' });
await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (error) {
console.error('API delete blacklist entry error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.get('/joboffers/:id', async (req, res) => {
try {
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
res.json(withLabels(row));
} catch (error) {
console.error('API get job offer error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.post('/joboffers', async (req, res) => {
try {
const b = req.body || {};
if (!b.firma || !b.stelle) {
return res.status(400).json({ error: 'firma und stelle sind erforderlich.' });
}
// Reject blacklisted offers outright — they must never reappear.
const blacklistRows = await dbAll('SELECT * FROM jobangebote_blacklist');
const blocked = blacklist.matchBlacklist(blacklistRows, b);
if (blocked) {
return res.status(409).json({
blacklisted: true,
matched_by: blocked.typ,
blacklist_entry: blocked,
error: 'Dieses Jobangebot steht auf der Blacklist und wird nicht (erneut) aufgenommen.',
});
}
const quelle = sanitizeInput(b.quelle || 'drittanbieter');
const externalId = b.external_id != null ? sanitizeInput(String(b.external_id)) : null;
const urlNorm = blacklist.normalizeUrl(b.quelle_url || '') || null;
const anzeigeDatum = sanitizeInput(b.anzeige_datum || '');
const kontaktEmail = sanitizeInput(b.kontakt_email || '');
// Company slug: stored as supplied by the client; derived from firma only
// when omitted. This is the key the blacklist matches on (see below).
const firmaSlug = b.firma_slug != null && String(b.firma_slug).trim() !== ''
? sanitizeInput(String(b.firma_slug))
: (blacklist.firmaSlug(b.firma) || null);
// Column order matches the UPDATE/INSERT statements below (firma_slug last).
const fields = [
sanitizeInput(b.firma),
sanitizeInput(b.stelle),
sanitizeInput(b.ort || ''),
sanitizeInput(b.adresse || ''),
sanitizeInput(b.ansprechpartner || ''),
sanitizeInput(b.gehalt || ''),
sanitizeInput(b.beschreibung || ''),
sanitizeInput(b.quelle_url || ''),
sanitizeInput(b.art || ''),
anzeigeDatum,
kontaktEmail,
sanitizeInput(b.status || 'offen'),
serializeLabels(b.labels),
urlNorm,
firmaSlug,
];
// De-dup: prefer a (quelle, external_id) match, else the same normalized
// URL — so the same posting never lands twice, even with a new id.
let existing = null;
if (externalId) {
existing = await dbGet(
'SELECT id FROM jobangebote WHERE quelle = ? AND external_id = ?',
[quelle, externalId]
);
}
if (!existing && urlNorm) {
existing = await dbGet(
'SELECT id FROM jobangebote WHERE url_norm = ? ORDER BY id ASC LIMIT 1',
[urlNorm]
);
}
if (existing) {
await dbRun(
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
beschreibung = ?, quelle_url = ?, art = ?, anzeige_datum = ?, kontakt_email = ?, status = ?,
labels = ?, url_norm = ?, firma_slug = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[...fields, existing.id]
);
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [existing.id]);
return res.json({ success: true, action: 'updated', joboffer: withLabels(row) });
}
const result = await dbRun(
`INSERT INTO jobangebote
(external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, labels, url_norm, firma_slug)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[externalId, quelle, ...fields]
);
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [result.lastID]);
res.status(201).json({ success: true, action: 'created', joboffer: withLabels(row) });
} catch (error) {
console.error('API create job offer error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Delete a job offer. By default it is auto-blacklisted first so it can never
// be ingested again; pass ?blacklist=false to hard-delete without blocking.
router.delete('/joboffers/:id', async (req, res) => {
try {
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
const skipBlacklist = req.query.blacklist === 'false' || req.query.blacklist === '0';
let blacklisted = false;
if (!skipBlacklist) {
const rows = await dbAll('SELECT * FROM jobangebote_blacklist');
if (!blacklist.matchBlacklist(rows, row)) {
const cols = blacklist.COLUMNS;
const entry = blacklist.buildAutoEntry(row, 'Jobangebot gelöscht (REST-API)');
await dbRun(
`INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`,
cols.map((c) => (entry[c] === undefined ? null : entry[c]))
);
}
blacklisted = true;
}
await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]);
res.json({ success: true, blacklisted });
} catch (error) {
console.error('API delete job offer error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
return router;
}
module.exports = { createExternalApi };