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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user