Jobangebote lassen sich per Stern merken — eigene Spalte `favorit`, getrennt von den Bewerbungs-Favoriten. Gemerkte Angebote stehen oben in der offenen Liste und haben einen eigenen Tab, der Favoriten quer über alle Status zeigt und ausgeblendet bleibt, solange es keine gibt. Der Stern wird nur in der Weboberfläche gesetzt; ein erneutes Einspielen desselben Angebots über die REST-API überschreibt ihn nicht. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1059 lines
42 KiB
JavaScript
1059 lines
42 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', '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). ' +
|
||
'Standard: 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 oder leer = beide (Standard). ' +
|
||
'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 }; |