- Startseite zeigt alle anstehenden Termine (vorher auf 6 begrenzt) - Termine lassen sich inline bearbeiten (CalDAV-Update, Neu-Anlage bei 404/412) - Neuer Bewerbungsstatus Telefonat (Listen, Farben, API, PDF, Statistik) Co-Authored-By: Claude <noreply@anthropic.com>
1060 lines
43 KiB
JavaScript
1060 lines
43 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',
|
||
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||
'Vorstellungsgespräch', 'Vertragsverhandlung',
|
||
'Absage', 'Absage von meiner Seite', '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 -----------------------------------------------
|
||
|
||
const errorResponse = {
|
||
description: 'Fehlerantwort',
|
||
content: {
|
||
'application/json': {
|
||
schema: { $ref: '#/components/schemas/Error' },
|
||
},
|
||
},
|
||
};
|
||
|
||
function buildOpenApiSpec(baseUrl = '') {
|
||
return {
|
||
openapi: '3.0.3',
|
||
info: {
|
||
title: 'NextJobs 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`.\n\n' +
|
||
'### Der API-Key gehört immer genau einem Benutzer\n\n' +
|
||
'Der Tracker ist eine Multi-User-Plattform. Jeder Benutzer legt seinen ' +
|
||
'eigenen Token unter **Einstellungen → REST-API** an („Neu generieren“) — ' +
|
||
'es gibt keinen globalen oder serverweiten Key.\n\n' +
|
||
'Der übergebene `X-API-Key` bestimmt, **wessen** Daten die Anfrage sieht: ' +
|
||
'jeder Endpunkt liest und schreibt ausschließlich die Daten des Benutzers, ' +
|
||
'dem der Token gehört. Fremde Bewerbungen, E-Mails, Anhänge, Jobangebote ' +
|
||
'oder Einstellungen sind über die API nicht erreichbar — auch nicht durch ' +
|
||
'Angabe einer fremden `id`. In dem Fall antwortet die API mit `404`, als ' +
|
||
'gäbe es den Datensatz nicht. Ein unbekannter, leerer oder mehrdeutiger ' +
|
||
'Token führt zu `401`.\n\n' +
|
||
'Ein neu generierter Token macht den vorherigen sofort ungültig.',
|
||
},
|
||
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: '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)' },
|
||
],
|
||
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)' },
|
||
anlagen: {
|
||
type: 'array',
|
||
items: { type: 'integer' },
|
||
description:
|
||
'IDs der zusätzlich beizulegenden statischen Anlagen (basis_anhaenge). ' +
|
||
'Weglassen = Vorauswahl aus den Einstellungen (GEN_ANLAGEN_DEFAULT, Standard: keine), ' +
|
||
'leeres Array = ausdrücklich keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".',
|
||
},
|
||
dokumente: {
|
||
type: 'array',
|
||
items: { type: 'string', enum: ['anschreiben', 'lebenslauf'] },
|
||
description:
|
||
'Welche Unterlagen erzeugt werden sollen. Weglassen = Vorauswahl aus den Einstellungen ' +
|
||
'(GEN_DOKUMENTE_DEFAULT, Standard: beide). Wird der Lebenslauf nicht erzeugt, führt ihn ' +
|
||
'das Anschreiben auch nicht unter "Anlagen" auf, und der E-Mail-Begleittext kündigt ihn nicht an.',
|
||
example: ['anschreiben'],
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
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: 'Persönliche Angaben abrufen',
|
||
description:
|
||
'Liefert die persönlichen Angaben **des Benutzers, dem der API-Key gehört**. ' +
|
||
'Hat er noch nichts gespeichert, ist die Antwort ein leeres Objekt `{}`.',
|
||
responses: {
|
||
'200': { description: 'Persönliche Angaben (ggf. `{}`)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } } },
|
||
'401': errorResponse,
|
||
'500': errorResponse,
|
||
},
|
||
},
|
||
put: {
|
||
tags: ['Settings'],
|
||
summary: 'Persönliche Angaben speichern',
|
||
description:
|
||
'Speichert die persönlichen Angaben des eigenen Benutzers. Es werden nur die ' +
|
||
'Felder geändert, die im Body vorkommen — weggelassene Felder behalten ihren ' +
|
||
'Wert. Existiert noch keine Zeile, wird sie angelegt.',
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
'application/json': {
|
||
schema: { $ref: '#/components/schemas/Settings' },
|
||
example: { name: 'Max Mustermann', ort: 'Berlin' },
|
||
},
|
||
},
|
||
},
|
||
responses: {
|
||
'200': {
|
||
description: 'Gespeichert; gibt den neuen Stand zurück',
|
||
content: {
|
||
'application/json': {
|
||
schema: {
|
||
type: 'object',
|
||
properties: {
|
||
success: { type: 'boolean', example: true },
|
||
settings: { $ref: '#/components/schemas/Settings' },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
'400': errorResponse,
|
||
'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:
|
||
'Persönlicher API-Token des Benutzers, zu finden unter Einstellungen → ' +
|
||
'REST-API (dort per „Neu generieren“ erzeugen und einblenden). Der Token ' +
|
||
'identifiziert den Benutzer — die Anfrage sieht und ändert nur dessen ' +
|
||
'eigene Daten.',
|
||
},
|
||
},
|
||
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 },
|
||
labels: { ...labelsSchema, 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 },
|
||
labels: labelsSchema,
|
||
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 },
|
||
labels: labelsSchema,
|
||
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',
|
||
description:
|
||
'Persönliche Angaben des Benutzers, dem der API-Key gehört (fließen in ' +
|
||
'die generierten Unterlagen ein). Hat der Benutzer noch nichts gespeichert, ' +
|
||
'liefert GET ein leeres Objekt `{}` — das ist ein gültiger Zustand.',
|
||
properties: {
|
||
name: { type: 'string', example: 'Max Mustermann' },
|
||
adresse: { type: 'string', example: 'Musterstraße 1, 12345 Musterstadt' },
|
||
kundennummer: { type: 'string', description: 'Kundennummer beim Jobcenter' },
|
||
email: { type: 'string', example: 'max@example.com' },
|
||
telefon: { type: 'string', example: '030 12345678' },
|
||
ort: { type: 'string', example: 'Berlin' },
|
||
webseite: { type: 'string', example: 'example.com' },
|
||
geburtsdatum: { type: 'string', example: '01.01.1990' },
|
||
},
|
||
},
|
||
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'] },
|
||
favorit: {
|
||
type: 'integer',
|
||
enum: [0, 1],
|
||
readOnly: true,
|
||
description: 'Vom Nutzer in der Weboberfläche gemerktes Angebot (Stern). Wird nur dort gesetzt ' +
|
||
'und beim erneuten Einspielen desselben Angebots nicht überschrieben. Unabhängig vom ' +
|
||
'Favoriten-Merkmal einer Bewerbung.',
|
||
},
|
||
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)' },
|
||
firma_slug: { type: 'string', nullable: true, description: 'Firmen-Slug für Blacklist-Abgleich; wie übergeben gespeichert, bei Fehlen aus firma abgeleitet.' },
|
||
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' },
|
||
firma_slug: { type: 'string', description: 'Optionaler Firmen-Slug für den Blacklist-Abgleich (z. B. "bosch"). Wird wie übergeben gespeichert; fehlt er, leitet der Server ihn aus firma ab.' },
|
||
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' },
|
||
labels: labelsSchema,
|
||
},
|
||
},
|
||
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 },
|
||
firma_slug: { type: 'string', nullable: true, description: 'Rechtsform-/Umlaut-robuster Firmen-Slug; primärer Abgleich für typ=firma/firma_stelle/auto.' },
|
||
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 }; |