// 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. 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 }, 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 }, 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 }, 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' }, }, }, }, }, security: [{ ApiKeyAuth: [] }], }; } module.exports = { buildOpenApiSpec };