// 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', ]; 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: '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: '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). ' + 'Standard: keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".', }, }, }, }, }, }, 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 }, 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', 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'] }, labels: { ...labelsSchema, nullable: true }, verknuepfte_bewerbung_id: { type: 'integer', nullable: true }, url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL für die De-Duplizierung (serverseitig gesetzt)' }, created_at: { type: 'string', format: 'date-time' }, 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' }, 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 }, 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 };