"Bewerbung senden" now asks for an extra confirmation (with the recipient) before dispatching. Job offers gain adresse and ansprechpartner fields — shown, editable, and carried through the REST API upsert (Swagger updated). Taking an offer over now writes the employer address (street, number, city) and the contact person into the application's AI notes (llm_notizen), so the LLM can use them for the letter's Anschriftfeld and salutation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
966 lines
37 KiB
JavaScript
966 lines
37 KiB
JavaScript
// OpenAPI 3.0 specification for the third-party REST API mounted under /api/v1.
|
||
// Kept as a plain factory so the serving route can inject the real `servers`
|
||
// (request host) before returning the document to /swagger.json.
|
||
|
||
const ART_OPTIONS = [
|
||
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone',
|
||
'Firmenwebsite', 'Post', 'Initiativbewerbung',
|
||
'Arbeitsagentur', 'Sonstiges',
|
||
];
|
||
const STATUS_OPTIONS = [
|
||
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
||
'Absage', 'Einstellung', 'Keine Rückmeldung',
|
||
];
|
||
|
||
// Shared reusable schemas -----------------------------------------------
|
||
|
||
const errorResponse = {
|
||
description: 'Fehlerantwort',
|
||
content: {
|
||
'application/json': {
|
||
schema: { $ref: '#/components/schemas/Error' },
|
||
},
|
||
},
|
||
};
|
||
|
||
function buildOpenApiSpec(baseUrl = '') {
|
||
return {
|
||
openapi: '3.0.3',
|
||
info: {
|
||
title: 'Bewerbungs-Tracker REST-API',
|
||
version: '1.0.0',
|
||
description:
|
||
'REST-API für Drittanbietersoftware zum Lesen und Verwalten von ' +
|
||
'Bewerbungen, Statusverlauf, generierten Unterlagen (PDFs), ' +
|
||
'E-Mail-Korrespondenz, Einstellungen und Statistiken.\n\n' +
|
||
'Alle Endpunkte sind unter `/api/v1` gemountet und erfordern ' +
|
||
'Authentifizierung über einen API-Key im Header `X-API-Key` ' +
|
||
'(konfiguriert via Umgebungsvariable `API_TOKEN`).',
|
||
},
|
||
servers: baseUrl ? [{ url: `${baseUrl}/api/v1` }] : [{ url: '/api/v1' }],
|
||
tags: [
|
||
{ name: 'Applications', description: 'Bewerbungen (CRUD + Statusverlauf)' },
|
||
{ name: 'Attachments', description: 'Generierte Bewerbungsunterlagen (PDFs)' },
|
||
{ name: 'Emails', description: 'E-Mail-Korrespondenz zu einer Bewerbung' },
|
||
{ name: 'Generation', description: 'KI-Generierung der Unterlagen anstoßen/abfragen' },
|
||
{ name: 'Settings', description: 'Benutzer- / Jobcenter-Einstellungen' },
|
||
{ name: 'Statistics', description: 'Statistiken und Export' },
|
||
{ name: 'Templates', description: 'Basis-Unterlagen (Vorlagen)' },
|
||
{ name: 'JobOffers', description: 'Jobangebote (Einspielung durch Drittanbietersoftware)' },
|
||
{ name: 'System', description: 'System-Endpunkte' },
|
||
],
|
||
paths: {
|
||
'/health': {
|
||
get: {
|
||
tags: ['System'],
|
||
summary: 'Verfügbarkeit prüfen',
|
||
description: 'Liefert den Status der API. Ohne Authentifizierung aufrufbar.',
|
||
security: [],
|
||
responses: {
|
||
'200': {
|
||
description: 'API erreichbar',
|
||
content: {
|
||
'application/json': {
|
||
schema: { $ref: '#/components/schemas/Health' },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
'/applications': {
|
||
get: {
|
||
tags: ['Applications'],
|
||
summary: 'Bewerbungen auflisten',
|
||
description: 'Liefert alle Bewerbungen, optional gefiltert nach Datum, Art, Status oder Freitext.',
|
||
parameters: [
|
||
{ name: 'month', in: 'query', schema: { type: 'string', pattern: '^[0-9]{1,2}$' }, description: 'Monat (01–12)' },
|
||
{ name: 'year', in: 'query', schema: { type: 'string', pattern: '^[0-9]{4}$' }, description: 'Jahr (z. B. 2026)' },
|
||
{ name: 'status', in: 'query', schema: { type: 'string', enum: STATUS_OPTIONS }, description: 'Filter nach Status' },
|
||
{ name: 'art', in: 'query', schema: { type: 'string', enum: ART_OPTIONS }, description: 'Filter nach Bewerbungsart' },
|
||
{ name: 'search', in: 'query', schema: { type: 'string' }, description: 'Freitextsuche in Firma und Stelle' },
|
||
{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 500, default: 100 }, description: 'Max. Anzahl Ergebnisse' },
|
||
{ name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0, default: 0 }, description: 'Ergebnis-Offset (Paging)' },
|
||
],
|
||
responses: {
|
||
'200': {
|
||
description: 'Liste der Bewerbungen (mit Statusverlauf)',
|
||
content: {
|
||
'application/json': {
|
||
schema: {
|
||
type: 'array',
|
||
items: { $ref: '#/components/schemas/Application' },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
'401': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
post: {
|
||
tags: ['Applications'],
|
||
summary: 'Neue Bewerbung anlegen',
|
||
description:
|
||
'Legt eine neue Bewerbung an. Ein Duplicate-Guard warnt (HTTP 409), ' +
|
||
'falls bereits eine Bewerbung für dieselbe Firma + Stelle existiert; ' +
|
||
'mit `force=true` wird sie trotzdem angelegt.',
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
'application/json': {
|
||
schema: { $ref: '#/components/schemas/ApplicationCreate' },
|
||
},
|
||
},
|
||
},
|
||
responses: {
|
||
'200': {
|
||
description: 'Bewerbung angelegt',
|
||
content: {
|
||
'application/json': {
|
||
schema: { $ref: '#/components/schemas/ApplicationCreated' },
|
||
},
|
||
},
|
||
},
|
||
'400': errorResponse,
|
||
'401': errorResponse,
|
||
'409': {
|
||
description: 'Mögliche Dublette erkannt',
|
||
content: {
|
||
'application/json': { schema: { $ref: '#/components/schemas/DuplicateConflict' } },
|
||
},
|
||
},
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/applications/{id}': {
|
||
get: {
|
||
tags: ['Applications'],
|
||
summary: 'Einzelne Bewerbung abrufen',
|
||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||
responses: {
|
||
'200': {
|
||
description: 'Bewerbung',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Application' } } },
|
||
},
|
||
'401': errorResponse,
|
||
'404': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
put: {
|
||
tags: ['Applications'],
|
||
summary: 'Bewerbung aktualisieren',
|
||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||
requestBody: {
|
||
required: true,
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/ApplicationUpdate' } } },
|
||
},
|
||
responses: {
|
||
'200': {
|
||
description: 'Aktualisierte Bewerbung',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/ApplicationCreated' } } },
|
||
},
|
||
'401': errorResponse,
|
||
'404': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
delete: {
|
||
tags: ['Applications'],
|
||
summary: 'Bewerbung löschen',
|
||
description: 'Löscht die Bewerbung inkl. Statusverlauf und verknüpfter Anhänge (kaskadierend).',
|
||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||
responses: {
|
||
'200': { description: 'Gelöscht', content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } } },
|
||
'401': errorResponse,
|
||
'404': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/applications/{id}/timeline': {
|
||
get: {
|
||
tags: ['Applications'],
|
||
summary: 'Statusverlauf abrufen',
|
||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||
responses: {
|
||
'200': {
|
||
description: 'Statusverlauf (chronologisch)',
|
||
content: {
|
||
'application/json': {
|
||
schema: { type: 'array', items: { $ref: '#/components/schemas/TimelineEntry' } },
|
||
},
|
||
},
|
||
},
|
||
'401': errorResponse,
|
||
'404': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
post: {
|
||
tags: ['Applications'],
|
||
summary: 'Statusverlauf-Eintrag hinzufügen',
|
||
description: 'Fügt einen neuen Status-Verlaufseintrag hinzu und aktualisiert den aktuellen Status der Bewerbung.',
|
||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||
requestBody: {
|
||
required: true,
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/TimelineEntryCreate' } } },
|
||
},
|
||
responses: {
|
||
'200': {
|
||
description: 'Angelegter Eintrag',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/TimelineEntry' } } },
|
||
},
|
||
'400': errorResponse,
|
||
'401': errorResponse,
|
||
'404': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/applications/{id}/timeline/{eintragId}': {
|
||
delete: {
|
||
tags: ['Applications'],
|
||
summary: 'Statusverlauf-Eintrag löschen',
|
||
parameters: [
|
||
{ $ref: '#/components/parameters/ApplicationId' },
|
||
{ name: 'eintragId', in: 'path', required: true, schema: { type: 'integer' } },
|
||
],
|
||
responses: {
|
||
'200': { description: 'Gelöscht', content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } } },
|
||
'401': errorResponse,
|
||
'404': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/applications/{id}/attachments': {
|
||
get: {
|
||
tags: ['Attachments'],
|
||
summary: 'Generierte Anhänge auflisten',
|
||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||
responses: {
|
||
'200': {
|
||
description: 'Anhänge (Metadaten, ohne Dateiinhalt)',
|
||
content: {
|
||
'application/json': {
|
||
schema: { type: 'array', items: { $ref: '#/components/schemas/Attachment' } },
|
||
},
|
||
},
|
||
},
|
||
'401': errorResponse,
|
||
'404': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/applications/{id}/attachments/{attachmentId}': {
|
||
get: {
|
||
tags: ['Attachments'],
|
||
summary: 'Anhang herunterladen',
|
||
description: 'Liefert die Binärdatei (i. d. R. ein generiertes PDF) als Download.',
|
||
parameters: [
|
||
{ $ref: '#/components/parameters/ApplicationId' },
|
||
{ name: 'attachmentId', in: 'path', required: true, schema: { type: 'integer' } },
|
||
],
|
||
responses: {
|
||
'200': {
|
||
description: 'Binärdatei',
|
||
content: {
|
||
'application/octet-stream': { schema: { type: 'string', format: 'binary' } },
|
||
},
|
||
},
|
||
'401': errorResponse,
|
||
'404': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/applications/{id}/emails': {
|
||
get: {
|
||
tags: ['Emails'],
|
||
summary: 'E-Mail-Korrespondenz abrufen',
|
||
description: 'Gesendete und empfangene E-Mails zur Bewerbung (chronologisch), inkl. Anhang-Metadaten.',
|
||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||
responses: {
|
||
'200': {
|
||
description: 'E-Mails',
|
||
content: {
|
||
'application/json': {
|
||
schema: { type: 'array', items: { $ref: '#/components/schemas/Email' } },
|
||
},
|
||
},
|
||
},
|
||
'401': errorResponse,
|
||
'404': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/emails/{emailId}/attachments/{attachmentId}': {
|
||
get: {
|
||
tags: ['Emails'],
|
||
summary: 'E-Mail-Anhang herunterladen',
|
||
parameters: [
|
||
{ name: 'emailId', in: 'path', required: true, schema: { type: 'integer' } },
|
||
{ name: 'attachmentId', in: 'path', required: true, schema: { type: 'integer' } },
|
||
],
|
||
responses: {
|
||
'200': {
|
||
description: 'Binärdatei',
|
||
content: {
|
||
'application/octet-stream': { schema: { type: 'string', format: 'binary' } },
|
||
},
|
||
},
|
||
'401': errorResponse,
|
||
'404': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/applications/{id}/generate': {
|
||
post: {
|
||
tags: ['Generation'],
|
||
summary: 'Unterlagen-Generierung anstoßen',
|
||
description:
|
||
'Startet die asynchrone KI-Generierung der Bewerbungsunterlagen (Anschreiben + Lebenslauf als PDF). ' +
|
||
'Bestehende Anhänge werden vorher gelöscht. Den Fortschritt via ' +
|
||
'`GET /applications/{id}/generation-status` abfragen.',
|
||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||
requestBody: {
|
||
required: false,
|
||
content: {
|
||
'application/json': {
|
||
schema: {
|
||
type: 'object',
|
||
properties: {
|
||
llm_notizen: { type: 'string', description: 'Freitext-Kontext für die KI (optional)' },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
responses: {
|
||
'202': {
|
||
description: 'Generierung gestartet',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } },
|
||
},
|
||
'401': errorResponse,
|
||
'404': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/applications/{id}/generation-status': {
|
||
get: {
|
||
tags: ['Generation'],
|
||
summary: 'Generierungsstatus abfragen',
|
||
parameters: [{ $ref: '#/components/parameters/ApplicationId' }],
|
||
responses: {
|
||
'200': {
|
||
description: 'Status + aktuelle Anhänge',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/GenerationStatus' } } },
|
||
},
|
||
'401': errorResponse,
|
||
'404': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/settings': {
|
||
get: {
|
||
tags: ['Settings'],
|
||
summary: 'Einstellungen abrufen',
|
||
responses: {
|
||
'200': { description: 'Einstellungen', content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } } },
|
||
'401': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
put: {
|
||
tags: ['Settings'],
|
||
summary: 'Einstellungen speichern',
|
||
requestBody: {
|
||
required: true,
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } },
|
||
},
|
||
responses: {
|
||
'200': { description: 'Gespeichert', content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } } },
|
||
'401': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/statistics': {
|
||
get: {
|
||
tags: ['Statistics'],
|
||
summary: 'Statistiken abrufen',
|
||
description: 'Gesamtzahl sowie Aufschlüsselung nach Art und Status.',
|
||
responses: {
|
||
'200': {
|
||
description: 'Statistiken',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Statistics' } } },
|
||
},
|
||
'401': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/export': {
|
||
get: {
|
||
tags: ['Statistics'],
|
||
summary: 'Bewerbungen exportieren',
|
||
description: 'Wie `GET /applications`, aber ohne interne Notizen – geeignet für PDF-/Jobcenter-Export.',
|
||
parameters: [
|
||
{ name: 'month', in: 'query', schema: { type: 'string', pattern: '^[0-9]{1,2}$' } },
|
||
{ name: 'year', in: 'query', schema: { type: 'string', pattern: '^[0-9]{4}$' } },
|
||
],
|
||
responses: {
|
||
'200': {
|
||
description: 'Bewerbungen (ohne interne_notizen)',
|
||
content: {
|
||
'application/json': {
|
||
schema: { type: 'array', items: { $ref: '#/components/schemas/Application' } },
|
||
},
|
||
},
|
||
},
|
||
'401': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/templates': {
|
||
get: {
|
||
tags: ['Templates'],
|
||
summary: 'Basis-Unterlagen (Vorlagen) auflisten',
|
||
responses: {
|
||
'200': {
|
||
description: 'Vorlagen',
|
||
content: {
|
||
'application/json': {
|
||
schema: { type: 'array', items: { $ref: '#/components/schemas/Template' } },
|
||
},
|
||
},
|
||
},
|
||
'401': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/joboffers': {
|
||
get: {
|
||
tags: ['JobOffers'],
|
||
summary: 'Jobangebote auflisten',
|
||
responses: {
|
||
'200': {
|
||
description: 'Jobangebote (neueste zuerst)',
|
||
content: {
|
||
'application/json': {
|
||
schema: { type: 'array', items: { $ref: '#/components/schemas/JobOffer' } },
|
||
},
|
||
},
|
||
},
|
||
'401': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
post: {
|
||
tags: ['JobOffers'],
|
||
summary: 'Jobangebot einspielen (Upsert)',
|
||
description:
|
||
'Spielt ein Jobangebot ein. Wird von Drittanbietersoftware genutzt, um ' +
|
||
'Stellen in den Tracker zu übernehmen. De-Dup: Upsert über (quelle, ' +
|
||
'external_id) oder – falls keine external_id passt – über die normalisierte ' +
|
||
'URL (quelle_url). Dieselbe Stelle landet damit nie doppelt. Steht das ' +
|
||
'Angebot auf der Blacklist (siehe /joboffers/blacklist), wird es mit 409 ' +
|
||
'abgelehnt und NICHT aufgenommen. Rückgabe 201 (created) oder 200 (updated).',
|
||
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,
|
||
'409': {
|
||
description: 'Angebot steht auf der Blacklist und wurde nicht aufgenommen',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/BlacklistConflict' } } },
|
||
},
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
// NOTE: registered before /joboffers/{id} so "blacklist" is not read as an id.
|
||
'/joboffers/blacklist': {
|
||
get: {
|
||
tags: ['JobOffers'],
|
||
summary: 'Blacklist auflisten',
|
||
description: 'Alle Blacklist-Einträge (neueste zuerst). Blockierte Angebote werden nie eingespielt.',
|
||
responses: {
|
||
'200': {
|
||
description: 'Blacklist-Einträge',
|
||
content: {
|
||
'application/json': {
|
||
schema: { type: 'array', items: { $ref: '#/components/schemas/BlacklistEntry' } },
|
||
},
|
||
},
|
||
},
|
||
'401': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
post: {
|
||
tags: ['JobOffers'],
|
||
summary: 'Blacklist-Eintrag anlegen',
|
||
description:
|
||
'Blockiert Angebote anhand einer URL, einer ganzen Domain, einer Firma ' +
|
||
'oder einer Firma + Stelle. Passende Angebote werden danach nicht mehr eingespielt.',
|
||
requestBody: {
|
||
required: true,
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/BlacklistEntryCreate' } } },
|
||
},
|
||
responses: {
|
||
'201': {
|
||
description: 'Eintrag angelegt',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/BlacklistEntryResult' } } },
|
||
},
|
||
'400': errorResponse,
|
||
'401': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
},
|
||
|
||
'/joboffers/blacklist/{id}': {
|
||
delete: {
|
||
tags: ['JobOffers'],
|
||
summary: 'Blacklist-Eintrag entfernen',
|
||
description: 'Entfernt einen Block. Betroffene Angebote können danach wieder eingespielt werden.',
|
||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
|
||
responses: {
|
||
'200': { description: 'Entfernt', content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } } },
|
||
'401': errorResponse,
|
||
'404': 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',
|
||
description:
|
||
'Löscht ein Jobangebot. Standardmäßig wird es zuvor automatisch auf die ' +
|
||
'Blacklist gesetzt, damit dieselbe Stelle nie erneut eingespielt wird. Mit ' +
|
||
'?blacklist=false wird ohne Blockierung hart gelöscht.',
|
||
parameters: [
|
||
{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
|
||
{
|
||
name: 'blacklist',
|
||
in: 'query',
|
||
required: false,
|
||
schema: { type: 'boolean', default: true },
|
||
description: 'false = löschen ohne Blacklisting (Standard: true).',
|
||
},
|
||
],
|
||
responses: {
|
||
'200': { description: 'Gelöscht', content: { 'application/json': { schema: { $ref: '#/components/schemas/JobOfferDeleteResult' } } } },
|
||
'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 },
|
||
adresse: { type: 'string', nullable: true, description: 'Anschrift des Arbeitgebers (Straße, Hausnummer, PLZ, Ort)' },
|
||
ansprechpartner: { type: 'string', nullable: true, description: 'Name des Ansprechpartners für die Bewerbung' },
|
||
gehalt: { type: 'string', nullable: true },
|
||
beschreibung: { type: 'string', nullable: true },
|
||
quelle_url: { type: 'string', nullable: true },
|
||
art: { type: 'string', nullable: true },
|
||
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'] },
|
||
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' },
|
||
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' },
|
||
adresse: { type: 'string', description: 'Anschrift des Arbeitgebers (Straße, Hausnummer, PLZ, Ort)' },
|
||
ansprechpartner: { type: 'string', description: 'Name des Ansprechpartners für die Bewerbung' },
|
||
gehalt: { type: 'string' },
|
||
beschreibung: { type: 'string', description: 'Stellenbeschreibungstext' },
|
||
quelle_url: { type: 'string' },
|
||
art: { type: 'string', enum: ART_OPTIONS },
|
||
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' },
|
||
},
|
||
},
|
||
JobOfferResult: {
|
||
type: 'object',
|
||
properties: {
|
||
success: { type: 'boolean', example: true },
|
||
action: { type: 'string', enum: ['created', 'updated'] },
|
||
joboffer: { $ref: '#/components/schemas/JobOffer' },
|
||
},
|
||
},
|
||
JobOfferDeleteResult: {
|
||
type: 'object',
|
||
properties: {
|
||
success: { type: 'boolean', example: true },
|
||
blacklisted: { type: 'boolean', description: 'true, wenn das Angebot beim Löschen auf die Blacklist gesetzt wurde' },
|
||
},
|
||
},
|
||
BlacklistEntry: {
|
||
type: 'object',
|
||
description: 'Ein Blacklist-Eintrag. Nur die zum typ passenden Felder sind gesetzt.',
|
||
properties: {
|
||
id: { type: 'integer' },
|
||
typ: { type: 'string', enum: ['url', 'domain', 'firma', 'firma_stelle', 'auto'] },
|
||
url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL (Tracking-Parameter entfernt)' },
|
||
domain: { type: 'string', nullable: true },
|
||
quelle: { type: 'string', nullable: true },
|
||
external_id: { type: 'string', nullable: true },
|
||
firma_norm: { type: 'string', nullable: true },
|
||
stelle_norm: { type: 'string', nullable: true },
|
||
ort_norm: { type: 'string', nullable: true },
|
||
firma: { type: 'string', nullable: true },
|
||
stelle: { type: 'string', nullable: true },
|
||
quelle_url: { type: 'string', nullable: true },
|
||
grund: { type: 'string', nullable: true },
|
||
created_at: { type: 'string', format: 'date-time' },
|
||
},
|
||
},
|
||
BlacklistEntryCreate: {
|
||
type: 'object',
|
||
required: ['typ'],
|
||
properties: {
|
||
typ: {
|
||
type: 'string',
|
||
enum: ['url', 'domain', 'firma', 'firma_stelle'],
|
||
description: 'url/domain/firma nutzen "wert"; firma_stelle nutzt "firma"+"stelle" (+optional "ort").',
|
||
},
|
||
wert: { type: 'string', description: 'URL (typ=url), Domain (typ=domain) oder Firmenname (typ=firma).' },
|
||
firma: { type: 'string', description: 'Nur bei typ=firma_stelle.' },
|
||
stelle: { type: 'string', description: 'Nur bei typ=firma_stelle.' },
|
||
ort: { type: 'string', description: 'Optional bei typ=firma_stelle.' },
|
||
grund: { type: 'string', description: 'Optionaler Freitext-Grund.' },
|
||
},
|
||
},
|
||
BlacklistEntryResult: {
|
||
type: 'object',
|
||
properties: {
|
||
success: { type: 'boolean', example: true },
|
||
entry: { $ref: '#/components/schemas/BlacklistEntry' },
|
||
},
|
||
},
|
||
BlacklistConflict: {
|
||
type: 'object',
|
||
properties: {
|
||
blacklisted: { type: 'boolean', example: true },
|
||
matched_by: { type: 'string', description: 'typ des greifenden Blacklist-Eintrags' },
|
||
blacklist_entry: { $ref: '#/components/schemas/BlacklistEntry' },
|
||
error: { type: 'string' },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
security: [{ ApiKeyAuth: [] }],
|
||
};
|
||
}
|
||
|
||
module.exports = { buildOpenApiSpec }; |