diff --git a/.env.example b/.env.example
index 3b75529..bd5e88a 100644
--- a/.env.example
+++ b/.env.example
@@ -24,3 +24,11 @@ MAIL_FROM=name@example.com
# IMAP-Postfach + Abrufintervall (ms) für neue Antwortmails
MAIL_IMAP_MAILBOX=INBOX
MAIL_POLL_MS=180000
+
+# --- REST-API für Drittanbietersoftware (/api/v1) ---
+# Ist dieser Schlüssel gesetzt, ist die API aktiv und erwartet den Wert im
+# Header "X-API-Key" jedes Requests. Ohne Schlüssel antwortet die API (bis auf
+# /health) mit 503. Swagger-UI läuft unter /swagger, das OpenAPI-Dokument unter
+# /swagger.json – beides ist auch ohne Token erreichbar.
+# Beispiel: API_TOKEN=dein_geheimer_schluessel
+API_TOKEN=
diff --git a/README.md b/README.md
index 67b0ed9..3b7714c 100644
--- a/README.md
+++ b/README.md
@@ -207,6 +207,55 @@ Generierten Anhang (PDF) herunterladen
### GET /api/bewerbungen/filter
Bewerbungen mit Filter abrufen
+## REST-API für Drittanbietersoftware (`/api/v1`)
+
+Zusätzlich zu den internen Endpunkten gibt es eine eigenständige, versionierte
+REST-API unter `/api/v1` für externe Software. Sie ist vollständig mit
+OpenAPI 3.0 dokumentiert; eine interaktive Swagger-UI läuft unter **`/swagger`**,
+das Rohdokument unter **`/swagger.json`**.
+
+### Authentifizierung
+
+Jeder Endpunkt (außer `GET /api/v1/health`) erfordert einen API-Key im Header
+`X-API-Key`. Der Schlüssel wird über die Umgebungsvariable `API_TOKEN` konfiguriert
+(z. B. in `.env`, siehe `.env.example`). Ist `API_TOKEN` nicht gesetzt, antwortet
+die API mit `503` – sie gibt nie ungeschützt Daten heraus. Swagger/UI sind
+auch ohne Token erreichbar (die Dokumentation enthält keine sensiblen Daten).
+
+```bash
+curl -H "X-API-Key: $API_TOKEN" http://localhost:3000/api/v1/applications
+```
+
+### Endpunkte
+
+| Methode | Pfad | Beschreibung |
+|---------|------|--------------|
+| GET | `/api/v1/health` | Verfügbarkeit (ohne Auth) |
+| GET | `/api/v1/applications` | Bewerbungen auflisten (Filter: `month`, `year`, `status`, `art`, `search`, `limit`, `offset`) |
+| POST | `/api/v1/applications` | Bewerbung anlegen (Duplicate-Guard via `force`) |
+| GET | `/api/v1/applications/{id}` | Einzelne Bewerbung |
+| PUT | `/api/v1/applications/{id}` | Bewerbung aktualisieren |
+| DELETE | `/api/v1/applications/{id}` | Bewerbung löschen |
+| GET | `/api/v1/applications/{id}/timeline` | Statusverlauf |
+| POST | `/api/v1/applications/{id}/timeline` | Status-Eintrag hinzufügen |
+| DELETE | `/api/v1/applications/{id}/timeline/{eintragId}` | Status-Eintrag löschen |
+| GET | `/api/v1/applications/{id}/attachments` | Generierte Anhänge auflisten |
+| GET | `/api/v1/applications/{id}/attachments/{attachmentId}` | Anhang (PDF) herunterladen |
+| GET | `/api/v1/applications/{id}/emails` | E-Mail-Korrespondenz |
+| GET | `/api/v1/emails/{emailId}/attachments/{attachmentId}` | E-Mail-Anhang herunterladen |
+| POST | `/api/v1/applications/{id}/generate` | KI-Generierung anstoßen (async, `202`) |
+| GET | `/api/v1/applications/{id}/generation-status` | Generierungsstatus abfragen |
+| GET | `/api/v1/settings` | Einstellungen abrufen |
+| PUT | `/api/v1/settings` | Einstellungen speichern |
+| GET | `/api/v1/statistics` | Statistiken (Gesamt, nach Art/Status) |
+| GET | `/api/v1/export` | Bewerbungen exportieren (ohne interne Notizen) |
+| GET | `/api/v1/templates` | Basis-Unterlagen (Vorlagen) |
+
+Die vollständige, maschinenlesbare Dokumentation (Parameter, Schemas,
+Fehlerantworten) liegt unter `/swagger.json` und ist in der Swagger-UI unter
+`/swagger` interaktiv bedienbar (inkl. „Authorize“ zum Eintragen des API-Keys
+für Test-Requests).
+
## PDF-Export
Der PDF-Export generiert ein professionelles Dokument mit:
diff --git a/lib/api.js b/lib/api.js
new file mode 100644
index 0000000..6f58352
--- /dev/null
+++ b/lib/api.js
@@ -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 };
\ No newline at end of file
diff --git a/lib/openapi.js b/lib/openapi.js
new file mode 100644
index 0000000..63f43c2
--- /dev/null
+++ b/lib/openapi.js
@@ -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 };
\ No newline at end of file
diff --git a/server.js b/server.js
index 84e6ffe..a254c84 100644
--- a/server.js
+++ b/server.js
@@ -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(`
+
+
+
+
+ Bewerbungs-Tracker – API-Dokumentation
+
+
+
+
+
+
Bewerbungs-Tracker REST-API
+
Drittanbieter-Schnittstelle v1
+
Authentifizierung: Header X-API-Key
+
← zur App
+
+
+
+
+
+
+`);
+ });
+ 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.
diff --git a/views/jobangebote.ejs b/views/jobangebote.ejs
new file mode 100644
index 0000000..48bc227
--- /dev/null
+++ b/views/jobangebote.ejs
@@ -0,0 +1,194 @@
+
+
+
+ <%- include('partials/head') %>
+
+
+ <%- include('partials/header') %>
+
+
+
+
+
+
+
+ Zurück zur Übersicht
+
+
+
+
+
+
Jobangebote
+
+ Hier landen Stellen, die von Drittanbietersoftware über die
+ POST /api/v1/joboffers
+ REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick
+ als Bewerbung-Entwurf übernehmen.
+
+
+
+
+ <%= jobangebote.length %> Angebot(e)
+
+
+
+ <% if (jobangebote && jobangebote.length) { %>
+
+ <% jobangebote.forEach(function(j){ %>
+
+
+
+
+
+ <%= j.status === 'offen' ? 'Offen' : (j.status === 'uebernommen' ? 'Übernommen' : (j.status === 'abgelehnt' ? 'Abgelehnt' : j.status)) %>
+
+ <% if (j.quelle && j.quelle !== 'drittanbieter') { %>
+ Quelle: <%= j.quelle %>
+ <% } %>
+ <% if (j.art) { %>
+ <%= j.art %>
+ <% } %>
+
+
<%= j.firma %>
+
<%= j.stelle %>
+ <% if (j.ort || j.gehalt) { %>
+
+ <% if (j.ort) { %><%= j.ort %><% } %>
+ <% if (j.ort && j.gehalt) { %> · <% } %>
+ <% if (j.gehalt) { %><%= j.gehalt %><% } %>
+
+ <% } %>
+ <% if (j.beschreibung) { %>
+
<%= j.beschreibung %>
+ <% if (j.beschreibung.length > 400) { %>
+
Vollständig anzeigen
+ <% } %>
+ <% } %>
+ <% if (j.quelle_url) { %>
+
+
+ <%= j.quelle_url %>
+
+ <% } %>
+
+ Eingegangen: <%= j.created_at ? new Date(j.created_at + 'Z').toLocaleString('de-DE') : '' %>
+ <% if (j.verknuepfte_bewerbung_id) { %>
+ · Bewerbung #<%= j.verknuepfte_bewerbung_id %>
+ <% } %>
+
+
+
+
+
+ <% if (j.verknuepfte_bewerbung_id) { %>
+
+
+ Zur Bewerbung
+
+ <% } else { %>
+
+ <% } %>
+
+
+
+
+ <% }); %>
+
+ <% } else { %>
+
+
+
+
+
Noch keine Jobangebote vorhanden.
+
+ Drittanbietersoftware kann Angebote über
+ POST /api/v1/joboffers
+ einspielen.
+
+
+ <% } %>
+
+
+
+
+
+ Wie werden Jobangebote eingespielt?
+
+
+
Angebot über die REST-API anlegen (API-Key im Header X-API-Key):
+
curl -X POST https://<host>/api/v1/joboffers \
+ -H "X-API-Key: $API_TOKEN" -H "Content-Type: application/json" \
+ -d '{
+ "external_id": "job-12345",
+ "quelle": "mein-crm",
+ "firma": "Acme GmbH",
+ "stelle": "Softwareentwickler (m/w/d)",
+ "ort": "Berlin",
+ "gehalt": "55.000 €",
+ "beschreibung": "…",
+ "quelle_url": "https://acme.example/job/12345",
+ "art": "Online-Portal"
+ }'
+
Wiederholtes Senden mit derselben quelle + external_id aktualisiert das Angebot (Upsert). Die Doku/Swagger-UI liegt unter /swagger .
+
+
+
+
+ <%- include('partials/footer') %>
+
+
+
+
\ No newline at end of file
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index e44289e..7f2a709 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -20,6 +20,18 @@
0
+
+
+
+
+
+ Jobangebote
+ 0
+
+
0) { b.textContent = d.count; b.classList.remove('hidden'); }
}).catch(function () { /* ignore — badge stays hidden */ });
})();
+ // Jobangebote badge: count of open offers ingested via the REST API.
+ (function () {
+ fetch('/jobangebote/anzahl-offen').then(function (r) { return r.json(); }).then(function (d) {
+ var b = document.getElementById('jobangeboteBadge');
+ if (b && d && d.count > 0) { b.textContent = d.count; b.classList.remove('hidden'); }
+ }).catch(function () { /* ignore */ });
+ })();