"Bewerbung senden" now asks for an extra confirmation (with the recipient) before dispatching. Job offers gain adresse and ansprechpartner fields — shown, editable, and carried through the REST API upsert (Swagger updated). Taking an offer over now writes the employer address (street, number, city) and the contact person into the application's AI notes (llm_notizen), so the LLM can use them for the letter's Anschriftfeld and salutation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
702 lines
26 KiB
JavaScript
702 lines
26 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 STATUS_OPTIONS = [
|
|
'Entwurf', 'Gesendet', 'Eingangsbestätigung', '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 } = 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 (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);
|
|
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, 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 || ''),
|
|
]
|
|
);
|
|
|
|
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 });
|
|
} 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(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 = ?, 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),
|
|
id,
|
|
]
|
|
);
|
|
|
|
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
|
res.json({ success: true, 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]
|
|
);
|
|
|
|
runGeneration(id);
|
|
res.status(202).json({ success: true });
|
|
} 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`
|
|
);
|
|
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(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 || '');
|
|
// Column order matches the UPDATE/INSERT statements below (url_norm 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'),
|
|
urlNorm,
|
|
];
|
|
|
|
// 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 = ?,
|
|
url_norm = ?, 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: 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, url_norm)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[externalId, quelle, ...fields]
|
|
);
|
|
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [result.lastID]);
|
|
res.status(201).json({ success: true, action: 'created', joboffer: 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 }; |