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
+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 };