Add third-party REST API (/api/v1) + Swagger UI and Jobangebote page
- REST API under /api/v1 with X-API-Key auth (API_TOKEN), documented with OpenAPI 3.0; Swagger UI at /swagger, spec at /swagger.json - Endpoints: applications CRUD + timeline, attachments/emails download, generation trigger/status, settings, statistics, export, templates, joboffers - Jobangebote page (/jobangebote) listing offers ingested via the REST API, with "Als Bewerbung übernehmen" and delete actions; header nav + badge - jobangebote table with (quelle, external_id) upsert for third-party ingestion Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+597
@@ -0,0 +1,597 @@
|
||||
// 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 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' });
|
||||
}
|
||||
});
|
||||
|
||||
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.' });
|
||||
}
|
||||
const quelle = sanitizeInput(b.quelle || 'drittanbieter');
|
||||
const externalId = b.external_id != null ? sanitizeInput(String(b.external_id)) : null;
|
||||
const fields = [
|
||||
sanitizeInput(b.firma),
|
||||
sanitizeInput(b.stelle),
|
||||
sanitizeInput(b.ort || ''),
|
||||
sanitizeInput(b.gehalt || ''),
|
||||
sanitizeInput(b.beschreibung || ''),
|
||||
sanitizeInput(b.quelle_url || ''),
|
||||
sanitizeInput(b.art || ''),
|
||||
sanitizeInput(b.status || 'offen'),
|
||||
];
|
||||
|
||||
// Upsert: a matching (quelle, external_id) row is updated, else inserted.
|
||||
if (externalId) {
|
||||
const existing = await dbGet(
|
||||
'SELECT id FROM jobangebote WHERE quelle = ? AND external_id = ?',
|
||||
[quelle, externalId]
|
||||
);
|
||||
if (existing) {
|
||||
await dbRun(
|
||||
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, gehalt = ?,
|
||||
beschreibung = ?, quelle_url = ?, art = ?, status = ?,
|
||||
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, gehalt, beschreibung, quelle_url, art, status)
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/joboffers/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await dbGet('SELECT id FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
||||
await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('API delete job offer error:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createExternalApi };
|
||||
+823
@@ -0,0 +1,823 @@
|
||||
// OpenAPI 3.0 specification for the third-party REST API mounted under /api/v1.
|
||||
// Kept as a plain factory so the serving route can inject the real `servers`
|
||||
// (request host) before returning the document to /swagger.json.
|
||||
|
||||
const ART_OPTIONS = [
|
||||
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone',
|
||||
'Firmenwebsite', 'Post', 'Initiativbewerbung',
|
||||
'Arbeitsagentur', 'Sonstiges',
|
||||
];
|
||||
const STATUS_OPTIONS = [
|
||||
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
||||
'Absage', 'Einstellung', 'Keine Rückmeldung',
|
||||
];
|
||||
|
||||
// Shared reusable schemas -----------------------------------------------
|
||||
|
||||
const errorResponse = {
|
||||
description: 'Fehlerantwort',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function buildOpenApiSpec(baseUrl = '') {
|
||||
return {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'Bewerbungs-Tracker REST-API',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'REST-API für Drittanbietersoftware zum Lesen und Verwalten von ' +
|
||||
'Bewerbungen, Statusverlauf, generierten Unterlagen (PDFs), ' +
|
||||
'E-Mail-Korrespondenz, Einstellungen und Statistiken.\n\n' +
|
||||
'Alle Endpunkte sind unter `/api/v1` gemountet und erfordern ' +
|
||||
'Authentifizierung über einen API-Key im Header `X-API-Key` ' +
|
||||
'(konfiguriert via Umgebungsvariable `API_TOKEN`).',
|
||||
},
|
||||
servers: baseUrl ? [{ url: `${baseUrl}/api/v1` }] : [{ url: '/api/v1' }],
|
||||
tags: [
|
||||
{ name: 'Applications', description: 'Bewerbungen (CRUD + Statusverlauf)' },
|
||||
{ name: 'Attachments', description: 'Generierte Bewerbungsunterlagen (PDFs)' },
|
||||
{ name: 'Emails', description: 'E-Mail-Korrespondenz zu einer Bewerbung' },
|
||||
{ name: 'Generation', description: 'KI-Generierung der Unterlagen anstoßen/abfragen' },
|
||||
{ name: 'Settings', description: 'Benutzer- / Jobcenter-Einstellungen' },
|
||||
{ name: 'Statistics', description: 'Statistiken und Export' },
|
||||
{ name: 'Templates', description: 'Basis-Unterlagen (Vorlagen)' },
|
||||
{ name: 'JobOffers', description: 'Jobangebote (Einspielung durch Drittanbietersoftware)' },
|
||||
{ name: 'System', description: 'System-Endpunkte' },
|
||||
],
|
||||
paths: {
|
||||
'/health': {
|
||||
get: {
|
||||
tags: ['System'],
|
||||
summary: 'Verfügbarkeit prüfen',
|
||||
description: 'Liefert den Status der API. Ohne Authentifizierung aufrufbar.',
|
||||
security: [],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'API erreichbar',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Health' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/applications': {
|
||||
get: {
|
||||
tags: ['Applications'],
|
||||
summary: 'Bewerbungen auflisten',
|
||||
description: 'Liefert alle Bewerbungen, optional gefiltert nach Datum, Art, Status oder Freitext.',
|
||||
parameters: [
|
||||
{ name: 'month', in: 'query', schema: { type: 'string', pattern: '^[0-9]{1,2}$' }, description: 'Monat (01–12)' },
|
||||
{ name: 'year', in: 'query', schema: { type: 'string', pattern: '^[0-9]{4}$' }, description: 'Jahr (z. B. 2026)' },
|
||||
{ name: 'status', in: 'query', schema: { type: 'string', enum: STATUS_OPTIONS }, description: 'Filter nach Status' },
|
||||
{ name: 'art', in: 'query', schema: { type: 'string', enum: ART_OPTIONS }, description: 'Filter nach Bewerbungsart' },
|
||||
{ name: 'search', in: 'query', schema: { type: 'string' }, description: 'Freitextsuche in Firma und Stelle' },
|
||||
{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 500, default: 100 }, description: 'Max. Anzahl Ergebnisse' },
|
||||
{ name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0, default: 0 }, description: 'Ergebnis-Offset (Paging)' },
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Liste der Bewerbungen (mit Statusverlauf)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/Application' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ['Applications'],
|
||||
summary: 'Neue Bewerbung anlegen',
|
||||
description:
|
||||
'Legt eine neue Bewerbung an. Ein Duplicate-Guard warnt (HTTP 409), ' +
|
||||
'falls bereits eine Bewerbung für dieselbe Firma + Stelle existiert; ' +
|
||||
'mit `force=true` wird sie trotzdem angelegt.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ApplicationCreate' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Bewerbung angelegt',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ApplicationCreated' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'400': errorResponse,
|
||||
'401': errorResponse,
|
||||
'409': {
|
||||
description: 'Mögliche Dublette erkannt',
|
||||
content: {
|
||||
'application/json': { schema: { $ref: '#/components/schemas/DuplicateConflict' } },
|
||||
},
|
||||
},
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/applications/{id}': {
|
||||
get: {
|
||||
tags: ['Applications'],
|
||||
summary: 'Einzelne Bewerbung abrufen',
|
||||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Bewerbung',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Application' } } },
|
||||
},
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ['Applications'],
|
||||
summary: 'Bewerbung aktualisieren',
|
||||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/ApplicationUpdate' } } },
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Aktualisierte Bewerbung',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/ApplicationCreated' } } },
|
||||
},
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ['Applications'],
|
||||
summary: 'Bewerbung löschen',
|
||||
description: 'Löscht die Bewerbung inkl. Statusverlauf und verknüpfter Anhänge (kaskadierend).',
|
||||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||||
responses: {
|
||||
'200': { description: 'Gelöscht', content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } } },
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/applications/{id}/timeline': {
|
||||
get: {
|
||||
tags: ['Applications'],
|
||||
summary: 'Statusverlauf abrufen',
|
||||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Statusverlauf (chronologisch)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/TimelineEntry' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ['Applications'],
|
||||
summary: 'Statusverlauf-Eintrag hinzufügen',
|
||||
description: 'Fügt einen neuen Status-Verlaufseintrag hinzu und aktualisiert den aktuellen Status der Bewerbung.',
|
||||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/TimelineEntryCreate' } } },
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Angelegter Eintrag',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/TimelineEntry' } } },
|
||||
},
|
||||
'400': errorResponse,
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/applications/{id}/timeline/{eintragId}': {
|
||||
delete: {
|
||||
tags: ['Applications'],
|
||||
summary: 'Statusverlauf-Eintrag löschen',
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/ApplicationId' },
|
||||
{ name: 'eintragId', in: 'path', required: true, schema: { type: 'integer' } },
|
||||
],
|
||||
responses: {
|
||||
'200': { description: 'Gelöscht', content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } } },
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/applications/{id}/attachments': {
|
||||
get: {
|
||||
tags: ['Attachments'],
|
||||
summary: 'Generierte Anhänge auflisten',
|
||||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Anhänge (Metadaten, ohne Dateiinhalt)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/Attachment' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/applications/{id}/attachments/{attachmentId}': {
|
||||
get: {
|
||||
tags: ['Attachments'],
|
||||
summary: 'Anhang herunterladen',
|
||||
description: 'Liefert die Binärdatei (i. d. R. ein generiertes PDF) als Download.',
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/ApplicationId' },
|
||||
{ name: 'attachmentId', in: 'path', required: true, schema: { type: 'integer' } },
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Binärdatei',
|
||||
content: {
|
||||
'application/octet-stream': { schema: { type: 'string', format: 'binary' } },
|
||||
},
|
||||
},
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/applications/{id}/emails': {
|
||||
get: {
|
||||
tags: ['Emails'],
|
||||
summary: 'E-Mail-Korrespondenz abrufen',
|
||||
description: 'Gesendete und empfangene E-Mails zur Bewerbung (chronologisch), inkl. Anhang-Metadaten.',
|
||||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'E-Mails',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/Email' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/emails/{emailId}/attachments/{attachmentId}': {
|
||||
get: {
|
||||
tags: ['Emails'],
|
||||
summary: 'E-Mail-Anhang herunterladen',
|
||||
parameters: [
|
||||
{ name: 'emailId', in: 'path', required: true, schema: { type: 'integer' } },
|
||||
{ name: 'attachmentId', in: 'path', required: true, schema: { type: 'integer' } },
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Binärdatei',
|
||||
content: {
|
||||
'application/octet-stream': { schema: { type: 'string', format: 'binary' } },
|
||||
},
|
||||
},
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/applications/{id}/generate': {
|
||||
post: {
|
||||
tags: ['Generation'],
|
||||
summary: 'Unterlagen-Generierung anstoßen',
|
||||
description:
|
||||
'Startet die asynchrone KI-Generierung der Bewerbungsunterlagen (Anschreiben + Lebenslauf als PDF). ' +
|
||||
'Bestehende Anhänge werden vorher gelöscht. Den Fortschritt via ' +
|
||||
'`GET /applications/{id}/generation-status` abfragen.',
|
||||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
llm_notizen: { type: 'string', description: 'Freitext-Kontext für die KI (optional)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'202': {
|
||||
description: 'Generierung gestartet',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } },
|
||||
},
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/applications/{id}/generation-status': {
|
||||
get: {
|
||||
tags: ['Generation'],
|
||||
summary: 'Generierungsstatus abfragen',
|
||||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Status + aktuelle Anhänge',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/GenerationStatus' } } },
|
||||
},
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/settings': {
|
||||
get: {
|
||||
tags: ['Settings'],
|
||||
summary: 'Einstellungen abrufen',
|
||||
responses: {
|
||||
'200': { description: 'Einstellungen', content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } } },
|
||||
'401': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ['Settings'],
|
||||
summary: 'Einstellungen speichern',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } },
|
||||
},
|
||||
responses: {
|
||||
'200': { description: 'Gespeichert', content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } } },
|
||||
'401': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/statistics': {
|
||||
get: {
|
||||
tags: ['Statistics'],
|
||||
summary: 'Statistiken abrufen',
|
||||
description: 'Gesamtzahl sowie Aufschlüsselung nach Art und Status.',
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Statistiken',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Statistics' } } },
|
||||
},
|
||||
'401': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/export': {
|
||||
get: {
|
||||
tags: ['Statistics'],
|
||||
summary: 'Bewerbungen exportieren',
|
||||
description: 'Wie `GET /applications`, aber ohne interne Notizen – geeignet für PDF-/Jobcenter-Export.',
|
||||
parameters: [
|
||||
{ name: 'month', in: 'query', schema: { type: 'string', pattern: '^[0-9]{1,2}$' } },
|
||||
{ name: 'year', in: 'query', schema: { type: 'string', pattern: '^[0-9]{4}$' } },
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Bewerbungen (ohne interne_notizen)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/Application' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/templates': {
|
||||
get: {
|
||||
tags: ['Templates'],
|
||||
summary: 'Basis-Unterlagen (Vorlagen) auflisten',
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Vorlagen',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/Template' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/joboffers': {
|
||||
get: {
|
||||
tags: ['JobOffers'],
|
||||
summary: 'Jobangebote auflisten',
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Jobangebote (neueste zuerst)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/JobOffer' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ['JobOffers'],
|
||||
summary: 'Jobangebot einspielen (Upsert)',
|
||||
description:
|
||||
'Spielt ein Jobangebot ein. Wird von Drittanbietersoftware genutzt, um ' +
|
||||
'Stellen in den Tracker zu übernehmen. Upsert über (quelle, external_id): ' +
|
||||
'wiederholtes Senden desselben Angebots aktualisiert es statt ein Duplikat ' +
|
||||
'anzulegen. Wird 201 (created) oder 200 (updated) zurückgegeben.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/JobOfferCreate' } } },
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Angebot aktualisiert',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/JobOfferResult' } } },
|
||||
},
|
||||
'201': {
|
||||
description: 'Angebot angelegt',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/JobOfferResult' } } },
|
||||
},
|
||||
'400': errorResponse,
|
||||
'401': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'/joboffers/{id}': {
|
||||
get: {
|
||||
tags: ['JobOffers'],
|
||||
summary: 'Einzelnes Jobangebot abrufen',
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Jobangebot',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/JobOffer' } } },
|
||||
},
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ['JobOffers'],
|
||||
summary: 'Jobangebot löschen',
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
|
||||
responses: {
|
||||
'200': { description: 'Gelöscht', content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } } },
|
||||
'401': errorResponse,
|
||||
'404': errorResponse,
|
||||
'500': errorResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
components: {
|
||||
parameters: {
|
||||
ApplicationId: {
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'integer' },
|
||||
description: 'Bewerbungs-ID',
|
||||
},
|
||||
},
|
||||
securitySchemes: {
|
||||
ApiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'X-API-Key',
|
||||
description: 'API-Token aus der Umgebungsvariable API_TOKEN.',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Health: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', example: 'ok' },
|
||||
api: { type: 'string', example: 'bewerbungs-tracker/v1' },
|
||||
},
|
||||
},
|
||||
Ok: {
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', example: true } },
|
||||
},
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: { error: { type: 'string' } },
|
||||
},
|
||||
Application: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
datum: { type: 'string', format: 'date' },
|
||||
firma: { type: 'string' },
|
||||
stelle: { type: 'string' },
|
||||
art: { type: 'string', enum: ART_OPTIONS, nullable: true },
|
||||
status: { type: 'string', enum: STATUS_OPTIONS, nullable: true },
|
||||
notizen: { type: 'string', nullable: true },
|
||||
interne_notizen: { type: 'string', nullable: true },
|
||||
ort: { type: 'string', nullable: true },
|
||||
stellenbeschreibung: { type: 'string', nullable: true },
|
||||
quelle_url: { type: 'string', nullable: true },
|
||||
email_empfaenger: { type: 'string', nullable: true },
|
||||
email_betreff: { type: 'string', nullable: true },
|
||||
email_anschreiben: { type: 'string', nullable: true },
|
||||
llm_notizen: { type: 'string', nullable: true },
|
||||
generierung_status: { type: 'string', nullable: true },
|
||||
generierung_fehler: { type: 'string', nullable: true },
|
||||
verlauf: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: { $ref: '#/components/schemas/TimelineEntry' },
|
||||
},
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
updated_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
ApplicationCreate: {
|
||||
type: 'object',
|
||||
required: ['datum', 'firma', 'stelle'],
|
||||
properties: {
|
||||
datum: { type: 'string', format: 'date' },
|
||||
firma: { type: 'string' },
|
||||
stelle: { type: 'string' },
|
||||
art: { type: 'string', enum: ART_OPTIONS },
|
||||
status: { type: 'string', enum: STATUS_OPTIONS },
|
||||
notizen: { type: 'string' },
|
||||
interne_notizen: { type: 'string' },
|
||||
ort: { type: 'string' },
|
||||
stellenbeschreibung: { type: 'string' },
|
||||
quelle_url: { type: 'string' },
|
||||
llm_notizen: { type: 'string' },
|
||||
kommentar: { type: 'string', description: 'Kommentar zum initialen Status-Eintrag' },
|
||||
force: { type: 'boolean', description: 'Dubletten-Prüfung überspringen' },
|
||||
},
|
||||
},
|
||||
ApplicationUpdate: {
|
||||
type: 'object',
|
||||
required: ['datum', 'firma', 'stelle'],
|
||||
properties: {
|
||||
datum: { type: 'string', format: 'date' },
|
||||
firma: { type: 'string' },
|
||||
stelle: { type: 'string' },
|
||||
art: { type: 'string', enum: ART_OPTIONS },
|
||||
status: { type: 'string', enum: STATUS_OPTIONS },
|
||||
notizen: { type: 'string' },
|
||||
interne_notizen: { type: 'string' },
|
||||
ort: { type: 'string' },
|
||||
stellenbeschreibung: { type: 'string' },
|
||||
quelle_url: { type: 'string' },
|
||||
llm_notizen: { type: 'string' },
|
||||
},
|
||||
},
|
||||
ApplicationCreated: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
application: { $ref: '#/components/schemas/Application' },
|
||||
},
|
||||
},
|
||||
DuplicateConflict: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
duplicate: { type: 'boolean', example: true },
|
||||
error: { type: 'string' },
|
||||
matches: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
datum: { type: 'string', format: 'date' },
|
||||
firma: { type: 'string' },
|
||||
stelle: { type: 'string' },
|
||||
ort: { type: 'string', nullable: true },
|
||||
status: { type: 'string', nullable: true },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TimelineEntry: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
bewerbung_id: { type: 'integer' },
|
||||
datum: { type: 'string', format: 'date' },
|
||||
status: { type: 'string', enum: STATUS_OPTIONS },
|
||||
kommentar: { type: 'string', nullable: true },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
TimelineEntryCreate: {
|
||||
type: 'object',
|
||||
required: ['status'],
|
||||
properties: {
|
||||
datum: { type: 'string', format: 'date', description: 'Standard: heute' },
|
||||
status: { type: 'string', enum: STATUS_OPTIONS },
|
||||
kommentar: { type: 'string' },
|
||||
},
|
||||
},
|
||||
Attachment: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
bewerbung_id: { type: 'integer' },
|
||||
name: { type: 'string', nullable: true },
|
||||
dateiname: { type: 'string' },
|
||||
mime: { type: 'string', nullable: true },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
Email: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
bewerbung_id: { type: 'integer', nullable: true },
|
||||
direction: { type: 'string', enum: ['in', 'out'] },
|
||||
from_addr: { type: 'string', nullable: true },
|
||||
to_addr: { type: 'string', nullable: true },
|
||||
subject: { type: 'string', nullable: true },
|
||||
body_text: { type: 'string', nullable: true },
|
||||
email_date: { type: 'string', format: 'date-time', nullable: true },
|
||||
anhaenge: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
name: { type: 'string', nullable: true },
|
||||
mime: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
GenerationStatus: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', enum: ['nicht_gestartet', 'ausstehend', 'fertig', 'fehler'], nullable: true },
|
||||
fehler: { type: 'string', nullable: true },
|
||||
anhaenge: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/Attachment' },
|
||||
},
|
||||
},
|
||||
},
|
||||
Settings: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
adresse: { type: 'string' },
|
||||
kundennummer: { type: 'string' },
|
||||
},
|
||||
},
|
||||
Statistics: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: { type: 'integer' },
|
||||
byArt: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { art: { type: 'string' }, count: { type: 'integer' } },
|
||||
},
|
||||
},
|
||||
byStatus: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { status: { type: 'string' }, count: { type: 'integer' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Template: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
typ: { type: 'string' },
|
||||
name: { type: 'string', nullable: true },
|
||||
inhalt: { type: 'string' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
JobOffer: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
external_id: { type: 'string', nullable: true, description: 'ID im Drittanbietersystem (Upsert-Schlüssel)' },
|
||||
quelle: { type: 'string', example: 'drittanbieter' },
|
||||
firma: { type: 'string' },
|
||||
stelle: { type: 'string' },
|
||||
ort: { type: 'string', nullable: true },
|
||||
gehalt: { type: 'string', nullable: true },
|
||||
beschreibung: { type: 'string', nullable: true },
|
||||
quelle_url: { type: 'string', nullable: true },
|
||||
art: { type: 'string', nullable: true },
|
||||
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'] },
|
||||
verknuepfte_bewerbung_id: { type: 'integer', nullable: true },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
updated_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
JobOfferCreate: {
|
||||
type: 'object',
|
||||
required: ['firma', 'stelle'],
|
||||
properties: {
|
||||
external_id: { type: 'string', description: 'ID im Drittanbietersystem; bei Wiederholung wird das Angebot aktualisiert' },
|
||||
quelle: { type: 'string', example: 'drittanbieter', description: 'Name der Drittanbietersoftware (Standard: drittanbieter)' },
|
||||
firma: { type: 'string' },
|
||||
stelle: { type: 'string' },
|
||||
ort: { type: 'string' },
|
||||
gehalt: { type: 'string' },
|
||||
beschreibung: { type: 'string', description: 'Stellenbeschreibungstext' },
|
||||
quelle_url: { type: 'string' },
|
||||
art: { type: 'string', enum: ART_OPTIONS },
|
||||
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'], default: 'offen' },
|
||||
},
|
||||
},
|
||||
JobOfferResult: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
action: { type: 'string', enum: ['created', 'updated'] },
|
||||
joboffer: { $ref: '#/components/schemas/JobOffer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildOpenApiSpec };
|
||||
Reference in New Issue
Block a user