From 88875dbc339566c52a10089a7f6a37bbb2b48e0a Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Fri, 3 Jul 2026 21:30:33 +0200 Subject: [PATCH] Add third-party REST API (/api/v1) + Swagger UI and Jobangebote page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 8 + README.md | 49 +++ lib/api.js | 597 +++++++++++++++++++++++++++ lib/openapi.js | 823 ++++++++++++++++++++++++++++++++++++++ server.js | 199 +++++++++ views/jobangebote.ejs | 194 +++++++++ views/partials/header.ejs | 19 + 7 files changed, 1889 insertions(+) create mode 100644 lib/api.js create mode 100644 lib/openapi.js create mode 100644 views/jobangebote.ejs 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) { %> + + <% } %> + <% } %> + <% 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) { 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 */ }); + })();