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:
2026-07-03 21:30:33 +02:00
co-authored by Claude
parent 0af731695a
commit 88875dbc33
7 changed files with 1889 additions and 0 deletions
+199
View File
@@ -29,6 +29,8 @@ const multer = require('multer');
const { generateApplicationDocuments, generateEmailReply } = require('./lib/documents');
const mailer = require('./lib/mailer');
const { createExternalApi } = require('./lib/api');
const { buildOpenApiSpec } = require('./lib/openapi');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -602,6 +604,29 @@ function initializeDatabase() {
value TEXT
)
`);
// Job offers ingested via the third-party REST API (/api/v1/joboffers).
// `quelle` + `external_id` identify an offer from one source uniquely,
// so re-sending the same offer updates it instead of creating a copy.
db.run(`
CREATE TABLE IF NOT EXISTS jobangebote (
id INTEGER PRIMARY KEY AUTOINCREMENT,
external_id TEXT,
quelle TEXT NOT NULL DEFAULT 'drittanbieter',
firma TEXT NOT NULL,
stelle TEXT NOT NULL,
ort TEXT,
gehalt TEXT,
beschreibung TEXT,
quelle_url TEXT,
art TEXT,
status TEXT NOT NULL DEFAULT 'offen',
verknuepfte_bewerbung_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (quelle, external_id),
FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
)
`);
// Remember the last recipient address per application (prefill).
db.run('ALTER TABLE bewerbungen ADD COLUMN email_empfaenger TEXT', () => {});
@@ -1558,9 +1583,183 @@ initializeDatabase().then(() => {
}
});
// ----- Jobangebote page (list page) -----
// The offers shown here are ingested by third-party software via the
// /api/v1/joboffers REST endpoint (POST). The page itself is read-only plus
// two manual actions: turn an offer into an application draft, or delete it.
// Count of open offers, consumed by the header badge (no auth — just a number).
app.get('/jobangebote/anzahl-offen', async (req, res) => {
try {
const row = await dbGet("SELECT COUNT(*) as count FROM jobangebote WHERE status = 'offen'");
res.json({ count: row ? row.count : 0 });
} catch (error) {
res.status(500).json({ count: 0 });
}
});
app.get('/jobangebote', async (req, res) => {
try {
const jobangebote = await dbAll(
`SELECT j.*, b.datum AS bewerbung_datum
FROM jobangebote j
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id
ORDER BY j.created_at DESC, j.id DESC`
);
res.render('jobangebote', {
jobangebote,
artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS,
hideSettings: false,
});
} catch (error) {
console.error('Error listing job offers:', error);
res.status(500).send('Serverfehler');
}
});
// Convert a job offer into a Bewerbung draft (mirrors the Indeed import flow:
// creates a bewerbung with status "Entwurf", records the initial timeline entry,
// and links the offer back to it).
app.post('/jobangebote/:id/uebernehmen', async (req, res) => {
try {
const { id } = req.params;
const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [id]);
if (!angebot) return res.status(404).send('Jobangebot nicht gefunden');
if (angebot.verknuepfte_bewerbung_id) {
return res.redirect('/bewerbung/' + angebot.verknuepfte_bewerbung_id);
}
const datum = new Date().toISOString().split('T')[0];
const notizParts = [
angebot.ort ? `Ort: ${angebot.ort}` : null,
angebot.gehalt ? `Gehalt: ${angebot.gehalt}` : null,
angebot.quelle_url ? `Quelle: ${angebot.quelle_url}` : null,
angebot.quelle ? `Importiert via: ${angebot.quelle}` : null,
].filter(Boolean);
const result = await dbRun(
`INSERT INTO bewerbungen
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung,
quelle_url, generierung_status)
VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
[
datum,
sanitizeInput(angebot.firma),
sanitizeInput(angebot.stelle),
sanitizeInput(angebot.art || deriveArt(angebot.quelle_url, null)),
notizParts.join('\n'),
angebot.ort || '',
angebot.beschreibung || '',
angebot.quelle_url || '',
]
);
await dbRun(
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
[result.lastID, datum, 'Entwurf', `Automatisch aus Jobangebot übernommen (${angebot.quelle || 'drittanbieter'})`]
);
await dbRun(
'UPDATE jobangebote SET verknuepfte_bewerbung_id = ?, status = "uebernommen", updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[result.lastID, id]
);
res.redirect('/bewerbung/' + result.lastID);
} catch (error) {
console.error('Error converting job offer:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a job offer (cascades nothing — verknuepfte_bewerbung_id is SET NULL).
app.post('/jobangebote/:id/delete', async (req, res) => {
try {
await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]);
res.redirect('/jobangebote');
} catch (error) {
console.error('Error deleting job offer:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Third-party REST API (/api/v1) + OpenAPI/Swagger -----
// API key for third-party software. When unset, the API responds 503 on
// every endpoint except /health — it never silently exposes data.
const apiToken = process.env.API_TOKEN || '';
app.use('/api/v1', createExternalApi({
dbGet,
dbAll,
dbRun,
sanitizeInput,
attachVerlauf,
findDuplicateApplications,
syncCurrentStatus,
runGeneration,
anhaengeDir,
emailAnhaengeDir,
apiToken,
}));
// Serve the OpenAPI document, with the real request host injected as server.
app.get('/swagger.json', (req, res) => {
const proto = req.get('x-forwarded-proto') || req.protocol;
const host = req.get('host') || `localhost:${PORT}`;
res.json(buildOpenApiSpec(`${proto}://${host}`));
});
// Swagger UI (loaded from CDN; consistent with the app's other CDN usage).
app.get('/swagger', (req, res) => {
const proto = req.get('x-forwarded-proto') || req.protocol;
const host = req.get('host') || `localhost:${PORT}`;
const specUrl = `${proto}://${host}/swagger.json`;
res.type('text/html').send(`<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bewerbungs-Tracker API-Dokumentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *::before, *::after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
.topbar { display:flex; align-items:center; gap:12px; padding:10px 16px;
background:#1f2937; color:#fff; font-family:system-ui,sans-serif; }
.topbar a { color:#93c5fd; text-decoration:none; font-weight:600; }
</style>
</head>
<body>
<div class="topbar">
<strong>Bewerbungs-Tracker REST-API</strong>
<span style="opacity:.7">Drittanbieter-Schnittstelle v1</span>
<span style="margin-left:auto">Authentifizierung: Header <code>X-API-Key</code></span>
<a href="/">← zur App</a>
</div>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" charset="UTF-8"></script>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js" charset="UTF-8"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: ${JSON.stringify(specUrl)},
dom_id: '#swagger-ui',
deepLinking: true,
presets: [SwaggerUIBundle.presets.apisAndSaver, SwaggerUIStandalonePreset],
layout: 'StandaloneLayout',
persistAuthorization: true,
});
};
</script>
</body>
</html>`);
});
app.get('/api-docs', (req, res) => res.redirect(301, '/swagger'));
// Start server
app.listen(PORT, () => {
console.log(`Server läuft auf http://localhost:${PORT}`);
if (apiToken) console.log('REST-API (/api/v1) aktiv Swagger unter /swagger');
else console.log('REST-API deaktiviert API_TOKEN fehlt (Swagger unter /swagger weiterhin verfügbar)');
});
// E-Mail: verify SMTP on startup and poll the IMAP inbox for replies.