Add third-party REST API (/api/v1) + Swagger UI and Jobangebote page

- REST API under /api/v1 with X-API-Key auth (API_TOKEN), documented with
  OpenAPI 3.0; Swagger UI at /swagger, spec at /swagger.json
- Endpoints: applications CRUD + timeline, attachments/emails download,
  generation trigger/status, settings, statistics, export, templates, joboffers
- Jobangebote page (/jobangebote) listing offers ingested via the REST API,
  with "Als Bewerbung übernehmen" and delete actions; header nav + badge
- jobangebote table with (quelle, external_id) upsert for third-party ingestion

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-03 21:30:33 +02:00
co-authored by Claude
parent 0af731695a
commit 88875dbc33
7 changed files with 1889 additions and 0 deletions
+8
View File
@@ -24,3 +24,11 @@ MAIL_FROM=name@example.com
# IMAP-Postfach + Abrufintervall (ms) für neue Antwortmails # IMAP-Postfach + Abrufintervall (ms) für neue Antwortmails
MAIL_IMAP_MAILBOX=INBOX MAIL_IMAP_MAILBOX=INBOX
MAIL_POLL_MS=180000 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=
+49
View File
@@ -207,6 +207,55 @@ Generierten Anhang (PDF) herunterladen
### GET /api/bewerbungen/filter ### GET /api/bewerbungen/filter
Bewerbungen mit Filter abrufen 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 ## PDF-Export
Der PDF-Export generiert ein professionelles Dokument mit: Der PDF-Export generiert ein professionelles Dokument mit:
+597
View File
@@ -0,0 +1,597 @@
// Third-party REST API (v1) for the Bewerbungs-Tracker.
//
// Mounted under /api/v1 in server.js. All endpoints except /health require an
// API key (env API_TOKEN) sent in the X-API-Key header. Reuses the server's
// existing DB helpers, sanitizer, duplicate guard, generation runner and
// attachment directories so behaviour stays consistent with the web UI.
const express = require('express');
const path = require('path');
const fs = require('fs');
const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Absage', 'Einstellung', 'Keine Rückmeldung',
];
function createExternalApi(deps) {
const {
dbGet,
dbAll,
dbRun,
sanitizeInput,
attachVerlauf,
findDuplicateApplications,
syncCurrentStatus,
runGeneration,
anhaengeDir,
emailAnhaengeDir,
apiToken,
} = deps;
const router = express.Router();
// --- API key auth --------------------------------------------------
// /health is public so monitoring tools can probe availability; everything
// else returns 401 when the header is missing/wrong or the token isn't set.
router.use((req, res, next) => {
if (req.path === '/health') return next();
if (!apiToken) {
return res.status(503).json({ error: 'API-Token nicht konfiguriert (API_TOKEN-Umgebungsvariable fehlt).' });
}
const provided = req.get('X-API-Key');
if (!provided || provided !== apiToken) {
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
}
next();
});
// --- helpers -------------------------------------------------------
async function getApplication(id) {
return dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
}
// --- System --------------------------------------------------------
router.get('/health', (req, res) => {
res.json({ status: 'ok', api: 'bewerbungs-tracker/v1' });
});
// --- Applications --------------------------------------------------
router.get('/applications', async (req, res) => {
try {
const { month, year, status, art, search } = req.query;
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
const where = [];
const params = [];
if (month) {
where.push('strftime("%m", datum) = ?');
params.push(String(month).padStart(2, '0'));
}
if (year) {
where.push('strftime("%Y", datum) = ?');
params.push(String(year));
}
if (status) {
where.push('status = ?');
params.push(status);
}
if (art) {
where.push('art = ?');
params.push(art);
}
if (search) {
where.push('(firma LIKE ? OR stelle LIKE ?)');
const term = `%${search}%`;
params.push(term, term);
}
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
const applications = await dbAll(
`SELECT * FROM bewerbungen ${clause} ORDER BY datum DESC, created_at DESC LIMIT ? OFFSET ?`,
[...params, limit, offset]
);
await attachVerlauf(applications);
res.json(applications);
} catch (error) {
console.error('API list applications error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.post('/applications', async (req, res) => {
try {
const b = req.body || {};
const { firma, stelle } = b;
if (!firma || !stelle) {
return res.status(400).json({ error: 'firma und stelle sind erforderlich.' });
}
const datum = b.datum || new Date().toISOString().split('T')[0];
const force = b.force === true || b.force === 'true';
if (!force) {
const dups = await findDuplicateApplications({ firma, stelle });
if (dups.length) {
return res.status(409).json({
duplicate: true,
matches: dups,
error: 'Es gibt bereits eine Bewerbung für dieselbe Firma und Stelle.',
});
}
}
const result = await dbRun(
`INSERT INTO bewerbungen
(datum, firma, stelle, art, status, notizen, interne_notizen, ort,
stellenbeschreibung, quelle_url, llm_notizen, generierung_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
[
datum,
sanitizeInput(firma),
sanitizeInput(stelle),
sanitizeInput(b.art || ''),
sanitizeInput(b.status || ''),
sanitizeInput(b.notizen || ''),
sanitizeInput(b.interne_notizen || ''),
sanitizeInput(b.ort || ''),
sanitizeInput(b.stellenbeschreibung || ''),
sanitizeInput(b.quelle_url || ''),
sanitizeInput(b.llm_notizen || ''),
]
);
if (b.status && String(b.status).trim()) {
await dbRun(
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
[result.lastID, datum, sanitizeInput(b.status), sanitizeInput(b.kommentar || '')]
);
}
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]);
res.json({ success: true, application });
} catch (error) {
console.error('API create application error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.get('/applications/:id', async (req, res) => {
try {
const application = await getApplication(req.params.id);
if (!application) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
await attachVerlauf([application]);
res.json(application);
} catch (error) {
console.error('API get application error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.put('/applications/:id', async (req, res) => {
try {
const { id } = req.params;
const existing = await getApplication(id);
if (!existing) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const b = req.body || {};
if (!b.firma || !b.stelle || !b.datum) {
return res.status(400).json({ error: 'datum, firma und stelle sind erforderlich.' });
}
await dbRun(
`UPDATE bewerbungen SET
datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?,
interne_notizen = ?, ort = ?, stellenbeschreibung = ?, quelle_url = ?,
llm_notizen = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
b.datum,
sanitizeInput(b.firma),
sanitizeInput(b.stelle),
sanitizeInput(b.art ?? existing.art),
sanitizeInput(b.status ?? existing.status),
sanitizeInput(b.notizen ?? existing.notizen),
sanitizeInput(b.interne_notizen ?? existing.interne_notizen),
sanitizeInput(b.ort ?? existing.ort),
sanitizeInput(b.stellenbeschreibung ?? existing.stellenbeschreibung),
sanitizeInput(b.quelle_url ?? existing.quelle_url),
sanitizeInput(b.llm_notizen ?? existing.llm_notizen),
id,
]
);
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
res.json({ success: true, application });
} catch (error) {
console.error('API update application error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.delete('/applications/:id', async (req, res) => {
try {
const { id } = req.params;
const existing = await getApplication(id);
if (!existing) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]);
await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]);
res.json({ success: true });
} catch (error) {
console.error('API delete application error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Timeline ------------------------------------------------------
router.get('/applications/:id/timeline', async (req, res) => {
try {
const { id } = req.params;
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const verlauf = await dbAll(
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC',
[id]
);
res.json(verlauf);
} catch (error) {
console.error('API list timeline error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.post('/applications/:id/timeline', async (req, res) => {
try {
const { id } = req.params;
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const { status, datum, kommentar } = req.body || {};
if (!status || !STATUS_OPTIONS.includes(status)) {
return res.status(400).json({ error: `status erforderlich (eine von: ${STATUS_OPTIONS.join(', ')})` });
}
const day = datum || new Date().toISOString().split('T')[0];
const result = await dbRun(
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
[id, day, status, sanitizeInput(kommentar || '')]
);
await syncCurrentStatus(id);
const entry = await dbGet('SELECT * FROM status_verlauf WHERE id = ?', [result.lastID]);
res.json(entry);
} catch (error) {
console.error('API add timeline error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.delete('/applications/:id/timeline/:eintragId', async (req, res) => {
try {
const { id, eintragId } = req.params;
const entry = await dbGet(
'SELECT id FROM status_verlauf WHERE id = ? AND bewerbung_id = ?',
[eintragId, id]
);
if (!entry) return res.status(404).json({ error: 'Verlaufseintrag nicht gefunden' });
await dbRun('DELETE FROM status_verlauf WHERE id = ?', [eintragId]);
await syncCurrentStatus(id);
res.json({ success: true });
} catch (error) {
console.error('API delete timeline error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Attachments ---------------------------------------------------
router.get('/applications/:id/attachments', async (req, res) => {
try {
const { id } = req.params;
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const anhaenge = await dbAll(
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
[id]
);
res.json(anhaenge);
} catch (error) {
console.error('API list attachments error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.get('/applications/:id/attachments/:attachmentId', async (req, res) => {
try {
const { id, attachmentId } = req.params;
const anhang = await dbGet(
'SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?',
[attachmentId, id]
);
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
const file = path.join(anhaengeDir, anhang.pfad);
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
res.download(file, anhang.dateiname || anhang.name || path.basename(anhang.pfad));
} catch (error) {
console.error('API download attachment error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Emails --------------------------------------------------------
router.get('/applications/:id/emails', async (req, res) => {
try {
const { id } = req.params;
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const emails = await dbAll(
'SELECT * FROM emails WHERE bewerbung_id = ? ORDER BY datetime(email_date) ASC, id ASC',
[id]
);
if (emails.length) {
const eIds = emails.map((e) => e.id);
const atts = await dbAll(
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE email_id IN (${eIds.map(() => '?').join(',')})`,
eIds
);
const byEmail = {};
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
emails.forEach((e) => { e.anhaenge = byEmail[e.id] || []; });
}
res.json(emails);
} catch (error) {
console.error('API list emails error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.get('/emails/:emailId/attachments/:attachmentId', async (req, res) => {
try {
const { emailId, attachmentId } = req.params;
const anhang = await dbGet(
'SELECT * FROM email_anhaenge WHERE id = ? AND email_id = ?',
[attachmentId, emailId]
);
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
const file = path.join(emailAnhaengeDir, anhang.pfad);
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
res.download(file, anhang.name || path.basename(anhang.pfad));
} catch (error) {
console.error('API download email attachment error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Generation ----------------------------------------------------
router.post('/applications/:id/generate', async (req, res) => {
try {
const { id } = req.params;
const bewerbung = await getApplication(id);
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
if (typeof (req.body || {}).llm_notizen !== 'undefined') {
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ?', [req.body.llm_notizen || '', id]);
}
// Drop existing generated attachments + their files before re-generating.
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]);
for (const a of alte) {
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
}
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]);
await dbRun(
"UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?",
[id]
);
runGeneration(id);
res.status(202).json({ success: true });
} catch (error) {
console.error('API generate error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.get('/applications/:id/generation-status', async (req, res) => {
try {
const { id } = req.params;
const bewerbung = await dbGet(
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?',
[id]
);
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const anhaenge = await dbAll(
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
[id]
);
res.json({ status: bewerbung.generierung_status, fehler: bewerbung.generierung_fehler, anhaenge });
} catch (error) {
console.error('API generation status error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Settings ------------------------------------------------------
router.get('/settings', async (req, res) => {
try {
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
res.json(settings);
} catch (error) {
console.error('API get settings error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.put('/settings', async (req, res) => {
try {
const { name, adresse, kundennummer } = req.body || {};
await dbRun(
'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1',
[sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)]
);
res.json({ success: true });
} catch (error) {
console.error('API save settings error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Statistics & export -------------------------------------------
router.get('/statistics', async (req, res) => {
try {
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen');
const byArt = await dbAll(`
SELECT art, COUNT(*) as count FROM bewerbungen
WHERE art IS NOT NULL AND art != ''
GROUP BY art ORDER BY count DESC
`);
const byStatus = await dbAll(`
SELECT status, COUNT(*) as count FROM bewerbungen
WHERE status IS NOT NULL AND status != ''
GROUP BY status ORDER BY count DESC
`);
res.json({
total: totalCount ? totalCount.count : 0,
byArt,
byStatus,
});
} catch (error) {
console.error('API statistics error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.get('/export', async (req, res) => {
try {
const { month, year } = req.query;
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC';
const params = [];
if (month && year) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
params.push(String(month).padStart(2, '0'), String(year));
} else if (month) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? ORDER BY datum DESC';
params.push(String(month).padStart(2, '0'));
} else if (year) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC';
params.push(String(year));
}
const applications = await dbAll(query, params);
await attachVerlauf(applications);
// Internal notes never leave the tracker via export.
applications.forEach((a) => { delete a.interne_notizen; });
res.json(applications);
} catch (error) {
console.error('API export error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Templates -----------------------------------------------------
router.get('/templates', async (req, res) => {
try {
const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
res.json(docs);
} catch (error) {
console.error('API list templates error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// --- Job offers (ingested by third-party software) -----------------
// POST upserts by (quelle, external_id): re-sending the same offer updates
// it instead of creating a duplicate. `quelle` defaults to "drittanbieter".
router.get('/joboffers', async (req, res) => {
try {
const rows = await dbAll(
`SELECT * FROM jobangebote ORDER BY created_at DESC, id DESC`
);
res.json(rows);
} catch (error) {
console.error('API list job offers error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.get('/joboffers/:id', async (req, res) => {
try {
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
res.json(row);
} catch (error) {
console.error('API get job offer error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.post('/joboffers', async (req, res) => {
try {
const b = req.body || {};
if (!b.firma || !b.stelle) {
return res.status(400).json({ error: 'firma und stelle sind erforderlich.' });
}
const quelle = sanitizeInput(b.quelle || 'drittanbieter');
const externalId = b.external_id != null ? sanitizeInput(String(b.external_id)) : null;
const fields = [
sanitizeInput(b.firma),
sanitizeInput(b.stelle),
sanitizeInput(b.ort || ''),
sanitizeInput(b.gehalt || ''),
sanitizeInput(b.beschreibung || ''),
sanitizeInput(b.quelle_url || ''),
sanitizeInput(b.art || ''),
sanitizeInput(b.status || 'offen'),
];
// Upsert: a matching (quelle, external_id) row is updated, else inserted.
if (externalId) {
const existing = await dbGet(
'SELECT id FROM jobangebote WHERE quelle = ? AND external_id = ?',
[quelle, externalId]
);
if (existing) {
await dbRun(
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, gehalt = ?,
beschreibung = ?, quelle_url = ?, art = ?, status = ?,
updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[...fields, existing.id]
);
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [existing.id]);
return res.json({ success: true, action: 'updated', joboffer: row });
}
}
const result = await dbRun(
`INSERT INTO jobangebote
(external_id, quelle, firma, stelle, ort, gehalt, beschreibung, quelle_url, art, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[externalId, quelle, ...fields]
);
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [result.lastID]);
res.status(201).json({ success: true, action: 'created', joboffer: row });
} catch (error) {
console.error('API create job offer error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
router.delete('/joboffers/:id', async (req, res) => {
try {
const row = await dbGet('SELECT id FROM jobangebote WHERE id = ?', [req.params.id]);
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (error) {
console.error('API delete job offer error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
return router;
}
module.exports = { createExternalApi };
+823
View File
@@ -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 (0112)' },
{ 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 };
+199
View File
@@ -29,6 +29,8 @@ const multer = require('multer');
const { generateApplicationDocuments, generateEmailReply } = require('./lib/documents'); const { generateApplicationDocuments, generateEmailReply } = require('./lib/documents');
const mailer = require('./lib/mailer'); const mailer = require('./lib/mailer');
const { createExternalApi } = require('./lib/api');
const { buildOpenApiSpec } = require('./lib/openapi');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -602,6 +604,29 @@ function initializeDatabase() {
value TEXT 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). // Remember the last recipient address per application (prefill).
db.run('ALTER TABLE bewerbungen ADD COLUMN email_empfaenger TEXT', () => {}); db.run('ALTER TABLE bewerbungen ADD COLUMN email_empfaenger TEXT', () => {});
@@ -1558,9 +1583,183 @@ initializeDatabase().then(() => {
} }
}); });
// ----- Jobangebote page (list page) -----
// The offers shown here are ingested by third-party software via the
// /api/v1/joboffers REST endpoint (POST). The page itself is read-only plus
// two manual actions: turn an offer into an application draft, or delete it.
// Count of open offers, consumed by the header badge (no auth — just a number).
app.get('/jobangebote/anzahl-offen', async (req, res) => {
try {
const row = await dbGet("SELECT COUNT(*) as count FROM jobangebote WHERE status = 'offen'");
res.json({ count: row ? row.count : 0 });
} catch (error) {
res.status(500).json({ count: 0 });
}
});
app.get('/jobangebote', async (req, res) => {
try {
const jobangebote = await dbAll(
`SELECT j.*, b.datum AS bewerbung_datum
FROM jobangebote j
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id
ORDER BY j.created_at DESC, j.id DESC`
);
res.render('jobangebote', {
jobangebote,
artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS,
hideSettings: false,
});
} catch (error) {
console.error('Error listing job offers:', error);
res.status(500).send('Serverfehler');
}
});
// Convert a job offer into a Bewerbung draft (mirrors the Indeed import flow:
// creates a bewerbung with status "Entwurf", records the initial timeline entry,
// and links the offer back to it).
app.post('/jobangebote/:id/uebernehmen', async (req, res) => {
try {
const { id } = req.params;
const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [id]);
if (!angebot) return res.status(404).send('Jobangebot nicht gefunden');
if (angebot.verknuepfte_bewerbung_id) {
return res.redirect('/bewerbung/' + angebot.verknuepfte_bewerbung_id);
}
const datum = new Date().toISOString().split('T')[0];
const notizParts = [
angebot.ort ? `Ort: ${angebot.ort}` : null,
angebot.gehalt ? `Gehalt: ${angebot.gehalt}` : null,
angebot.quelle_url ? `Quelle: ${angebot.quelle_url}` : null,
angebot.quelle ? `Importiert via: ${angebot.quelle}` : null,
].filter(Boolean);
const result = await dbRun(
`INSERT INTO bewerbungen
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung,
quelle_url, generierung_status)
VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
[
datum,
sanitizeInput(angebot.firma),
sanitizeInput(angebot.stelle),
sanitizeInput(angebot.art || deriveArt(angebot.quelle_url, null)),
notizParts.join('\n'),
angebot.ort || '',
angebot.beschreibung || '',
angebot.quelle_url || '',
]
);
await dbRun(
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
[result.lastID, datum, 'Entwurf', `Automatisch aus Jobangebot übernommen (${angebot.quelle || 'drittanbieter'})`]
);
await dbRun(
'UPDATE jobangebote SET verknuepfte_bewerbung_id = ?, status = "uebernommen", updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[result.lastID, id]
);
res.redirect('/bewerbung/' + result.lastID);
} catch (error) {
console.error('Error converting job offer:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a job offer (cascades nothing — verknuepfte_bewerbung_id is SET NULL).
app.post('/jobangebote/:id/delete', async (req, res) => {
try {
await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]);
res.redirect('/jobangebote');
} catch (error) {
console.error('Error deleting job offer:', error);
res.status(500).send('Serverfehler');
}
});
// ----- Third-party REST API (/api/v1) + OpenAPI/Swagger -----
// API key for third-party software. When unset, the API responds 503 on
// every endpoint except /health — it never silently exposes data.
const apiToken = process.env.API_TOKEN || '';
app.use('/api/v1', createExternalApi({
dbGet,
dbAll,
dbRun,
sanitizeInput,
attachVerlauf,
findDuplicateApplications,
syncCurrentStatus,
runGeneration,
anhaengeDir,
emailAnhaengeDir,
apiToken,
}));
// Serve the OpenAPI document, with the real request host injected as server.
app.get('/swagger.json', (req, res) => {
const proto = req.get('x-forwarded-proto') || req.protocol;
const host = req.get('host') || `localhost:${PORT}`;
res.json(buildOpenApiSpec(`${proto}://${host}`));
});
// Swagger UI (loaded from CDN; consistent with the app's other CDN usage).
app.get('/swagger', (req, res) => {
const proto = req.get('x-forwarded-proto') || req.protocol;
const host = req.get('host') || `localhost:${PORT}`;
const specUrl = `${proto}://${host}/swagger.json`;
res.type('text/html').send(`<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bewerbungs-Tracker API-Dokumentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *::before, *::after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
.topbar { display:flex; align-items:center; gap:12px; padding:10px 16px;
background:#1f2937; color:#fff; font-family:system-ui,sans-serif; }
.topbar a { color:#93c5fd; text-decoration:none; font-weight:600; }
</style>
</head>
<body>
<div class="topbar">
<strong>Bewerbungs-Tracker REST-API</strong>
<span style="opacity:.7">Drittanbieter-Schnittstelle v1</span>
<span style="margin-left:auto">Authentifizierung: Header <code>X-API-Key</code></span>
<a href="/">← zur App</a>
</div>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" charset="UTF-8"></script>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js" charset="UTF-8"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: ${JSON.stringify(specUrl)},
dom_id: '#swagger-ui',
deepLinking: true,
presets: [SwaggerUIBundle.presets.apisAndSaver, SwaggerUIStandalonePreset],
layout: 'StandaloneLayout',
persistAuthorization: true,
});
};
</script>
</body>
</html>`);
});
app.get('/api-docs', (req, res) => res.redirect(301, '/swagger'));
// Start server // Start server
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server läuft auf http://localhost:${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. // E-Mail: verify SMTP on startup and poll the IMAP inbox for replies.
+194
View File
@@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="de">
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<%- include('partials/header') %>
<main class="container mx-auto px-4 py-8 max-w-5xl">
<!-- Back link -->
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Zurück zur Übersicht
</a>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Jobangebote</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Hier landen Stellen, die von Drittanbietersoftware über die
<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700">POST /api/v1/joboffers</code>
REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick
als Bewerbung-Entwurf übernehmen.
</p>
</div>
<span class="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-blue-50 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
<%= jobangebote.length %> Angebot(e)
</span>
</div>
<% if (jobangebote && jobangebote.length) { %>
<ul class="mt-4 space-y-4">
<% jobangebote.forEach(function(j){ %>
<li class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <%= j.status === 'offen' ? 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-200' : (j.status === 'uebernommen' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200' : 'bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200') %>">
<%= j.status === 'offen' ? 'Offen' : (j.status === 'uebernommen' ? 'Übernommen' : (j.status === 'abgelehnt' ? 'Abgelehnt' : j.status)) %>
</span>
<% if (j.quelle && j.quelle !== 'drittanbieter') { %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Quelle: <%= j.quelle %></span>
<% } %>
<% if (j.art) { %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"><%= j.art %></span>
<% } %>
</div>
<p class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words"><%= j.firma %></p>
<p class="text-sm text-gray-700 dark:text-gray-200 break-words"><%= j.stelle %></p>
<% if (j.ort || j.gehalt) { %>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
<% if (j.ort) { %><%= j.ort %><% } %>
<% if (j.ort && j.gehalt) { %> · <% } %>
<% if (j.gehalt) { %><%= j.gehalt %><% } %>
</p>
<% } %>
<% if (j.beschreibung) { %>
<p class="joboffer-desc whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300 mt-2 max-h-32 overflow-hidden"><%= j.beschreibung %></p>
<% if (j.beschreibung.length > 400) { %>
<button type="button" class="desc-toggle text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 hover:underline mt-1" data-expanded="false">Vollständig anzeigen</button>
<% } %>
<% } %>
<% if (j.quelle_url) { %>
<a href="<%= j.quelle_url %>" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-2 break-all">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
<span class="truncate"><%= j.quelle_url %></span>
</a>
<% } %>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">
Eingegangen: <%= j.created_at ? new Date(j.created_at + 'Z').toLocaleString('de-DE') : '' %>
<% if (j.verknuepfte_bewerbung_id) { %>
· <a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>" class="text-blue-600 dark:text-blue-400 hover:underline">Bewerbung #<%= j.verknuepfte_bewerbung_id %></a>
<% } %>
</p>
</div>
<!-- Actions -->
<div class="shrink-0 flex flex-col gap-2 self-start">
<% if (j.verknuepfte_bewerbung_id) { %>
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Zur Bewerbung
</a>
<% } else { %>
<form action="/jobangebote/<%= j.id %>/uebernehmen" method="POST">
<button type="submit"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors w-full"
title="Aus diesem Angebot eine Bewerbung (Entwurf) anlegen">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
Als Bewerbung übernehmen
</button>
</form>
<% } %>
<form action="/jobangebote/<%= j.id %>/delete" method="POST"
onsubmit="return confirm('Jobangebot „<%= j.firma %> · <%= j.stelle %>“ löschen?');">
<button type="submit"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors w-full">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
Löschen
</button>
</form>
</div>
</div>
</li>
<% }); %>
</ul>
<% } else { %>
<div class="text-center py-12">
<svg class="w-14 h-14 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Noch keine Jobangebote vorhanden.</p>
<p class="text-xs text-gray-400 dark:text-gray-500">
Drittanbietersoftware kann Angebote über
<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700">POST /api/v1/joboffers</code>
einspielen.
</p>
</div>
<% } %>
</div>
<!-- Ingestion hint / mini example -->
<details class="bg-white dark:bg-gray-800 rounded-lg shadow-md px-6 py-4 text-sm">
<summary class="cursor-pointer text-gray-600 dark:text-gray-300 font-medium">
Wie werden Jobangebote eingespielt?
</summary>
<div class="mt-3 text-gray-600 dark:text-gray-400 space-y-2">
<p>Angebot über die REST-API anlegen (API-Key im Header <code>X-API-Key</code>):</p>
<pre class="overflow-x-auto rounded-md bg-gray-900 text-gray-100 text-xs px-4 py-3"><code>curl -X POST https://&lt;host&gt;/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"
}'</code></pre>
<p class="text-xs">Wiederholtes Senden mit derselben <code>quelle</code> + <code>external_id</code> aktualisiert das Angebot (Upsert). Die Doku/Swagger-UI liegt unter <a href="/swagger" class="text-blue-600 dark:text-blue-400 hover:underline">/swagger</a>.</p>
</div>
</details>
</main>
<%- include('partials/footer') %>
<script>
(function () {
const root = document.documentElement;
const dm = localStorage.getItem('darkMode');
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
const toggle = document.getElementById('darkModeToggle');
const sun = document.getElementById('sunIcon');
const moon = document.getElementById('moonIcon');
function sync() {
const d = root.classList.contains('dark');
if (sun) sun.classList.toggle('hidden', d);
if (moon) moon.classList.toggle('hidden', !d);
}
sync();
if (toggle) toggle.addEventListener('click', () => {
root.classList.toggle('dark');
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
sync();
});
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
// Expand/collapse the full description for each card.
document.querySelectorAll('.desc-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
var body = btn.previousElementSibling;
if (!body || !body.classList.contains('joboffer-desc')) return;
var expanded = btn.dataset.expanded === 'true';
body.classList.toggle('max-h-32', expanded);
body.classList.toggle('overflow-hidden', expanded);
btn.dataset.expanded = expanded ? 'false' : 'true';
btn.textContent = expanded ? 'Vollständig anzeigen' : 'Weniger anzeigen';
});
});
})();
</script>
</body>
</html>
+19
View File
@@ -20,6 +20,18 @@
<span id="unassignedBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span> <span id="unassignedBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
</a> </a>
<!-- Jobangebote (ingested via third-party REST API) link -->
<a href="/jobangebote"
id="jobangeboteLink"
class="relative flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
title="Über die REST-API eingespielte Jobangebote">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
<span class="hidden sm:inline">Jobangebote</span>
<span id="jobangeboteBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-green-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
</a>
<!-- Vorlagen (base documents) link --> <!-- Vorlagen (base documents) link -->
<a href="/vorlagen" <a href="/vorlagen"
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium" class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
@@ -68,5 +80,12 @@
if (b && d && d.count > 0) { b.textContent = d.count; b.classList.remove('hidden'); } if (b && d && d.count > 0) { b.textContent = d.count; b.classList.remove('hidden'); }
}).catch(function () { /* ignore — badge stays 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 */ });
})();
</script> </script>
</header> </header>