Labels für Stellen (Bewerbungen + Jobangebote), inkl. API & Swagger

Mehrere Labels pro Stelle (Regional, Remote-Deutschlandweit,
Homeoffice-Deutschlandweit), gespeichert als JSON-Array in einer neuen
labels-Spalte beider Tabellen (Migration). Geteiltes lib/labels.js mit
parse/serialize; wiederverwendbare Partials fuer Chips + Mehrfachauswahl.

- Web: setzen im Hinzufuegen-Modal, auf der Bearbeiten-Seite und im
  Jobangebot-Bearbeiten-Formular; Anzeige als Chips in den Listen.
- Uebernahme eines Angebots traegt dessen Labels in die neue Bewerbung.
- REST-API: labels[] in /applications und /joboffers (GET/POST/PUT),
  Filter ?label=…; OpenAPI/Swagger-Schemas + Enums erweitert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 15:23:44 +02:00
co-authored by Claude Opus 4.8
parent d1c6743d2f
commit 03760d293f
10 changed files with 178 additions and 28 deletions
+32 -13
View File
@@ -9,6 +9,13 @@ const express = require('express');
const path = require('path');
const fs = require('fs');
const blacklist = require('./blacklist');
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
// Replace the stored labels JSON string with a real array on outgoing rows.
function withLabels(row) {
if (row) row.labels = parseLabels(row.labels);
return row;
}
const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
@@ -60,12 +67,17 @@ function createExternalApi(deps) {
// --- Applications --------------------------------------------------
router.get('/applications', async (req, res) => {
try {
const { month, year, status, art, search } = req.query;
const { month, year, status, art, search, label } = 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 (label && LABEL_OPTIONS.includes(label)) {
// labels is a JSON array string; match the quoted label token.
where.push('labels LIKE ?');
params.push(`%"${label}"%`);
}
if (month) {
where.push('strftime("%m", datum) = ?');
params.push(String(month).padStart(2, '0'));
@@ -94,6 +106,7 @@ function createExternalApi(deps) {
[...params, limit, offset]
);
await attachVerlauf(applications);
applications.forEach(withLabels);
res.json(applications);
} catch (error) {
console.error('API list applications error:', error);
@@ -125,8 +138,8 @@ function createExternalApi(deps) {
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')`,
stellenbeschreibung, quelle_url, llm_notizen, labels, generierung_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
[
datum,
sanitizeInput(firma),
@@ -139,6 +152,7 @@ function createExternalApi(deps) {
sanitizeInput(b.stellenbeschreibung || ''),
sanitizeInput(b.quelle_url || ''),
sanitizeInput(b.llm_notizen || ''),
serializeLabels(b.labels),
]
);
@@ -150,7 +164,7 @@ function createExternalApi(deps) {
}
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]);
res.json({ success: true, application });
res.json({ success: true, application: withLabels(application) });
} catch (error) {
console.error('API create application error:', error);
res.status(500).json({ error: 'Serverfehler' });
@@ -162,7 +176,7 @@ function createExternalApi(deps) {
const application = await getApplication(req.params.id);
if (!application) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
await attachVerlauf([application]);
res.json(application);
res.json(withLabels(application));
} catch (error) {
console.error('API get application error:', error);
res.status(500).json({ error: 'Serverfehler' });
@@ -184,7 +198,7 @@ function createExternalApi(deps) {
`UPDATE bewerbungen SET
datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?,
interne_notizen = ?, ort = ?, stellenbeschreibung = ?, quelle_url = ?,
llm_notizen = ?, updated_at = CURRENT_TIMESTAMP
llm_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
b.datum,
@@ -198,12 +212,15 @@ function createExternalApi(deps) {
sanitizeInput(b.stellenbeschreibung ?? existing.stellenbeschreibung),
sanitizeInput(b.quelle_url ?? existing.quelle_url),
sanitizeInput(b.llm_notizen ?? existing.llm_notizen),
Object.prototype.hasOwnProperty.call(b, 'labels')
? serializeLabels(b.labels)
: (existing.labels || '[]'),
id,
]
);
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
res.json({ success: true, application });
res.json({ success: true, application: withLabels(application) });
} catch (error) {
console.error('API update application error:', error);
res.status(500).json({ error: 'Serverfehler' });
@@ -516,6 +533,7 @@ function createExternalApi(deps) {
const rows = await dbAll(
`SELECT * FROM jobangebote ORDER BY created_at DESC, id DESC`
);
rows.forEach(withLabels);
res.json(rows);
} catch (error) {
console.error('API list job offers error:', error);
@@ -583,7 +601,7 @@ function createExternalApi(deps) {
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);
res.json(withLabels(row));
} catch (error) {
console.error('API get job offer error:', error);
res.status(500).json({ error: 'Serverfehler' });
@@ -628,6 +646,7 @@ function createExternalApi(deps) {
anzeigeDatum,
kontaktEmail,
sanitizeInput(b.status || 'offen'),
serializeLabels(b.labels),
urlNorm,
];
@@ -651,21 +670,21 @@ function createExternalApi(deps) {
await dbRun(
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
beschreibung = ?, quelle_url = ?, art = ?, anzeige_datum = ?, kontakt_email = ?, status = ?,
url_norm = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
labels = ?, url_norm = ?, 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 });
return res.json({ success: true, action: 'updated', joboffer: withLabels(row) });
}
const result = await dbRun(
`INSERT INTO jobangebote
(external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, url_norm)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, labels, url_norm)
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 });
res.status(201).json({ success: true, action: 'created', joboffer: withLabels(row) });
} catch (error) {
console.error('API create job offer error:', error);
res.status(500).json({ error: 'Serverfehler' });
+44
View File
@@ -0,0 +1,44 @@
// Shared label vocabulary for Stellen (Bewerbungen + Jobangebote).
//
// Labels describe the work-location character of a position. Several labels may
// be set per Stelle. They are stored as a JSON array string in the `labels`
// column and exposed as a string[] through the web UI and the REST API.
const LABEL_OPTIONS = [
'Regional',
'Remote-Deutschlandweit',
'Homeoffice-Deutschlandweit',
];
// Normalize any incoming labels value (array, JSON string, comma-separated
// string, or a single label) into a clean, de-duplicated array limited to
// LABEL_OPTIONS and returned in canonical order for stable output.
function parseLabels(raw) {
if (raw == null || raw === '') return [];
let arr;
if (Array.isArray(raw)) {
arr = raw;
} else if (typeof raw === 'string') {
const s = raw.trim();
if (!s) return [];
if (s[0] === '[') {
try { arr = JSON.parse(s); } catch (e) { arr = []; }
if (!Array.isArray(arr)) arr = [];
} else {
arr = s.split(',');
}
} else {
return [];
}
const chosen = new Set(
arr.map((v) => String(v).trim()).filter((v) => LABEL_OPTIONS.includes(v))
);
return LABEL_OPTIONS.filter((l) => chosen.has(l));
}
// Canonical JSON string for storage in the DB (always a valid array).
function serializeLabels(raw) {
return JSON.stringify(parseLabels(raw));
}
module.exports = { LABEL_OPTIONS, parseLabels, serializeLabels };
+14
View File
@@ -11,6 +11,14 @@ const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Absage', 'Einstellung', 'Keine Rückmeldung',
];
const { LABEL_OPTIONS } = require('./labels');
// Reusable schema for the labels array (work-location labels of a Stelle).
const labelsSchema = {
type: 'array',
items: { type: 'string', enum: LABEL_OPTIONS },
description: 'Labels der Stelle (Arbeitsort). Mehrere möglich.',
};
// Shared reusable schemas -----------------------------------------------
@@ -79,6 +87,7 @@ function buildOpenApiSpec(baseUrl = '') {
{ 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: 'label', in: 'query', schema: { type: 'string', enum: LABEL_OPTIONS }, description: 'Filter nach Label (Stellen mit diesem Label)' },
{ 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)' },
@@ -663,6 +672,7 @@ function buildOpenApiSpec(baseUrl = '') {
stelle: { type: 'string' },
art: { type: 'string', enum: ART_OPTIONS, nullable: true },
status: { type: 'string', enum: STATUS_OPTIONS, nullable: true },
labels: { ...labelsSchema, nullable: true },
notizen: { type: 'string', nullable: true },
interne_notizen: { type: 'string', nullable: true },
ort: { type: 'string', nullable: true },
@@ -692,6 +702,7 @@ function buildOpenApiSpec(baseUrl = '') {
stelle: { type: 'string' },
art: { type: 'string', enum: ART_OPTIONS },
status: { type: 'string', enum: STATUS_OPTIONS },
labels: labelsSchema,
notizen: { type: 'string' },
interne_notizen: { type: 'string' },
ort: { type: 'string' },
@@ -711,6 +722,7 @@ function buildOpenApiSpec(baseUrl = '') {
stelle: { type: 'string' },
art: { type: 'string', enum: ART_OPTIONS },
status: { type: 'string', enum: STATUS_OPTIONS },
labels: labelsSchema,
notizen: { type: 'string' },
interne_notizen: { type: 'string' },
ort: { type: 'string' },
@@ -871,6 +883,7 @@ function buildOpenApiSpec(baseUrl = '') {
anzeige_datum: { type: 'string', format: 'date', nullable: true, description: 'Datum der eigentlichen Stellenanzeige (vom Drittanbieter übergeben)' },
kontakt_email: { type: 'string', format: 'email', nullable: true, description: 'Kontakt-E-Mail-Adresse der Stelle' },
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'] },
labels: { ...labelsSchema, nullable: true },
verknuepfte_bewerbung_id: { type: 'integer', nullable: true },
url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL für die De-Duplizierung (serverseitig gesetzt)' },
created_at: { type: 'string', format: 'date-time' },
@@ -895,6 +908,7 @@ function buildOpenApiSpec(baseUrl = '') {
anzeige_datum: { type: 'string', format: 'date', description: 'Datum der eigentlichen Stellenanzeige (ISO YYYY-MM-DD)' },
kontakt_email: { type: 'string', format: 'email', description: 'Kontakt-E-Mail-Adresse der Stelle' },
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'], default: 'offen' },
labels: labelsSchema,
},
},
JobOfferResult: {