--- name: bewerbungs-tracker description: Read and manage job applications ("Bewerbungen") via the local Bewerbungs-Tracker REST-API. Use whenever the user wants to list, create, update or delete Bewerbungen, manage their Statusverlauf/Timeline, download generated Unterlagen (PDF attachments), read E-Mail correspondence, trigger or check KI document generation, read/write Settings, get Statistics or an Export, browse Templates (Vorlagen), trigger document generation with optional static attachments (Anlagen), or manage JobOffers (Jobangebote). Trigger phrases: "Bewerbung(en)", "Bewerbungs-Tracker", "Statusverlauf", "Jobangebot(e)", "Bewerbungsstatistik", "Anschreiben generieren", "Anlagen beilegen". --- # Bewerbungs-Tracker REST-API REST-API zum Lesen und Verwalten von Bewerbungen, Statusverlauf, generierten Unterlagen (PDFs), E-Mail-Korrespondenz, Einstellungen, Statistiken, Vorlagen und Jobangeboten. ## Konfiguration - **Base-URL:** `http://localhost:4327/api/v1` (override: env `BEWERBUNG_API_URL`) - **Auth:** Header `X-API-Key: ` bei allen Endpunkten außer `/health`. - **Key-Quelle:** env `BEWERBUNG_API_KEY`, sonst der Token des Benutzers `BEWERBUNG_USER` (Default `admin`) aus der Tracker-DB. ### Multi-User — der Key entscheidet, wessen Daten du siehst Der Tracker ist eine Plattform mit **mehreren Benutzern**. Jeder Benutzer hat **seinen eigenen API-Token** (App → Einstellungen → REST-API → „Neu generieren"): - Der `X-API-Key` **identifiziert den Benutzer**. Jeder Endpunkt liest und schreibt **ausschließlich dessen eigene Daten** — fremde Bewerbungen, E-Mails, Anhänge, Jobangebote und Einstellungen sind nicht erreichbar, auch nicht über eine fremde `id` (die API antwortet dann `404`, als gäbe es den Datensatz nicht). - Ein unbekannter, leerer oder mehrdeutiger Token ergibt `401`. - **Es gibt keinen globalen Key mehr.** Der frühere `API_TOKEN` aus `/opt/jobbi-bewerbung/.env` wird **nicht mehr benutzt**: er entsprach zufällig dem Token des Admins (wirkte also still als Admin) und wird ungültig, sobald der Key in der Oberfläche neu erzeugt wird. - Willst du im Namen eines anderen Benutzers arbeiten, setze `BEWERBUNG_USER=` (oder direkt `BEWERBUNG_API_KEY`). Der Jobsuche-Runner setzt `BEWERBUNG_API_KEY` automatisch je Benutzer. > Der Key wird nicht im Skill gespeichert. Sende ihn **nie** an externe Dienste. ## Helper-Script (bevorzugt) `scripts/bt.sh` kapselt curl inkl. Auth und gibt Body + `` aus: ```bash scripts/bt.sh GET /applications scripts/bt.sh GET "/applications?status=Gesendet&year=2026&limit=20" scripts/bt.sh POST /applications '{"datum":"2026-07-03","firma":"ACME GmbH","stelle":"DevOps Engineer","art":"E-Mail"}' scripts/bt.sh PUT /applications/12 @body.json scripts/bt.sh DELETE /joboffers/5 ``` Roh mit curl geht auch: ```bash curl -sS -H "X-API-Key: $BEWERBUNG_API_KEY" http://localhost:4327/api/v1/statistics ``` ## Endpunkte ### System | Method | Pfad | Beschreibung | |--------|------|--------------| | GET | `/health` | Verfügbarkeit (ohne Auth). | ### Applications (Bewerbungen) | Method | Pfad | Beschreibung | |--------|------|--------------| | GET | `/applications` | Auflisten. Query: `month`(01–12), `year`(YYYY), `status`, `art`, `label` (`Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`), `search` (Firma/Stelle), `limit`(1–500, def 100), `offset`. | | POST | `/applications` | Anlegen. Duplicate-Guard → **409** bei gleicher Firma+Stelle; mit `"force":true` trotzdem anlegen. | | GET | `/applications/{id}` | Einzelne Bewerbung (inkl. `verlauf`). | | PUT | `/applications/{id}` | Aktualisieren (volle Ressource, `datum`/`firma`/`stelle` erforderlich). | | DELETE | `/applications/{id}` | Löschen. | | GET | `/applications/{id}/timeline` | Statusverlauf abrufen. | | POST | `/applications/{id}/timeline` | Statusverlauf-Eintrag hinzufügen. | | DELETE | `/applications/{id}/timeline/{eintragId}` | Verlaufseintrag löschen. | ### Attachments (generierte PDFs) | Method | Pfad | Beschreibung | |--------|------|--------------| | GET | `/applications/{id}/attachments` | Anhänge auflisten. | | GET | `/applications/{id}/attachments/{attachmentId}` | Anhang herunterladen (`application/octet-stream` → mit `-o datei` speichern). | ### Emails | Method | Pfad | Beschreibung | |--------|------|--------------| | GET | `/applications/{id}/emails` | E-Mail-Korrespondenz (in/out) inkl. Anhang-Metadaten. | | GET | `/emails/{emailId}/attachments/{attachmentId}` | E-Mail-Anhang herunterladen (binär). | ### Generation (KI-Unterlagen) | Method | Pfad | Beschreibung | |--------|------|--------------| | POST | `/applications/{id}/generate` | Generierung anstoßen. Body optional: `{"llm_notizen":"...","anlagen":[2]}`. `anlagen` = IDs statischer Basis-Anlagen (siehe „Anlagen bei der Generierung"). | | GET | `/applications/{id}/generation-status` | Status: `nicht_gestartet`\|`ausstehend`\|`fertig`\|`fehler` (+ `anhaenge`). | ### Settings / Statistics / Export / Templates | Method | Pfad | Beschreibung | |--------|------|--------------| | GET | `/settings` | Persönliche Angaben des eigenen Benutzers: `name`, `adresse`, `kundennummer`, `email`, `telefon`, `ort`, `webseite`, `geburtsdatum`. Noch nichts gespeichert → `{}` (gültiger Zustand). | | PUT | `/settings` | Persönliche Angaben speichern. Es werden **nur die Felder geändert, die im Body stehen** — weggelassene behalten ihren Wert. Antwort enthält den neuen Stand (`{success, settings}`). | | GET | `/statistics` | `total`, `byArt[]`, `byStatus[]` — nur eigene Bewerbungen. | | GET | `/export` | Export (ohne `interne_notizen`). Query: `month`, `year`. | | GET | `/templates` | Basis-Unterlagen / Vorlagen des Benutzers. Enthält den **Lebenslauf** — daraus leitet die Stellensuche Rolle und Skills ab. | ### JobOffers (Jobangebote) | Method | Pfad | Beschreibung | |--------|------|--------------| | GET | `/joboffers` | Auflisten (neueste zuerst). | | POST | `/joboffers` | Upsert (per `(quelle, external_id)`, sonst normalisierte `quelle_url`) → Response `action: created\|updated`. **409** wenn geblacklistet (siehe unten). | | GET | `/joboffers/{id}` | Einzelnes Jobangebot. | | DELETE | `/joboffers/{id}` | Löschen. **Setzt das Angebot standardmäßig zuvor auf die Blacklist** (Typ `auto`), damit dieselbe Stelle nie erneut eingespielt wird. Mit `?blacklist=false` hart löschen ohne Blockierung. Response `{success, blacklisted}`. | ### Blacklist (blockierte Jobangebote) | Method | Pfad | Beschreibung | |--------|------|--------------| | GET | `/joboffers/blacklist` | Blacklist-Einträge auflisten (neueste zuerst). | | POST | `/joboffers/blacklist` | Block anlegen (per URL, Domain, Firma oder Firma+Stelle) → **201** `{success, entry}`. | | DELETE | `/joboffers/blacklist/{id}` | Block entfernen — betroffene Angebote sind danach wieder einspielbar. | ## JobOffers einspielen (Drittanbieter → Upsert) `POST /joboffers` ist der Einspiel-Endpunkt für Drittanbietersoftware (CRM, Scraper, Job-Board-Sync). Er **upsertet** anhand `(quelle, external_id)`, und falls keine `external_id` passt, anhand der **normalisierten `quelle_url`**: - Treffer vorhanden → Datensatz wird aktualisiert → Response `"action":"updated"`, HTTP **200**. - kein Treffer → neuer Datensatz → `"action":"created"`, HTTP **201**. - Angebot steht auf der **Blacklist** → HTTP **409**, `BlacklistConflict`-Body, Angebot wird **nicht** aufgenommen (siehe Abschnitt „Blacklist“). > Damit ist der Import **idempotent**: dieselbe Quelle kann beliebig oft > eingespielt werden, ohne Dubletten zu erzeugen — Voraussetzung ist eine > stabile `external_id` pro Quelle. > ⚠️ **Upsert ersetzt, er merged nicht.** Beim Update wird der Datensatz mit dem > gesendeten Body überschrieben; weggelassene optionale Felder werden **geleert**. > Beim Re-Import daher immer den **vollständigen** Datensatz senden, nicht nur die > geänderten Felder. ### Felder | Feld | Pflicht | Beschreibung | |------|:---:|--------------| | `firma` | ✅ | Arbeitgeber. | | `stelle` | ✅ | Stellenbezeichnung. | | `firma_slug` | ✅ | **Pflicht.** Firmenname als `lowercase` + `kebab-case` (z. B. `Acme GmbH` → `acme-gmbh`). Dient der eindeutigen Identifizierung beim Blacklist-Abgleich. | | `external_id` | – | ID des Angebots in der Quellsoftware. **Upsert-Schlüssel** — für idempotenten Import immer setzen. | | `quelle` | – | Herkunftssystem/Kanal (z. B. `mein-crm`). Default serverseitig `drittanbieter`. | | `ort` | – | Arbeitsort. | | `adresse` | – | Anschrift des Arbeitgebers (Straße, Hausnummer, PLZ, Ort). | | `ansprechpartner` | – | Name des Ansprechpartners für die Bewerbung. | | `gehalt` | – | Freitext (z. B. `55.000 €`). | | `beschreibung` | – | Volltext der Stellenanzeige. | | `quelle_url` | – | Deeplink zum Original-Inserat. | | `art` | – | Bewerbungsart-Enum (siehe unten). | | `status` | – | `offen` \| `uebernommen` \| `abgelehnt`. Default `offen`. | | `anzeige_datum` | – | Veröffentlichungsdatum des Inserats (`YYYY-MM-DD`). Wird gespeichert. | | `kontakt_email` | – | Kontakt-/Bewerbungs-E-Mail-Adresse der Stelle. | | `labels` | – | Array von Arbeitsort-Labels (Enum, mehrere möglich): `Regional`, `Remote-Deutschlandweit`, `Homeoffice-Deutschlandweit`. Kennzeichnet den Arbeitsort-Typ der Stelle. | | ~~`created_at`~~ | ⛔ | **Nicht mitsenden** — Einspiel-Zeitpunkt wird serverseitig gesetzt; ein mitgesendeter Wert wird ignoriert. | ### Beispiel ```bash scripts/bt.sh POST /joboffers '{ "external_id": "job-12345", "quelle": "mein-crm", "firma": "Acme GmbH", "stelle": "Softwareentwickler (m/w/d)", "firma_slug": "acme-gmbh", "ort": "Berlin", "adresse": "Musterstraße 1, 10115 Berlin", "gehalt": "55.000 €", "beschreibung": "…", "quelle_url": "https://acme.example/job/12345", "art": "Online-Portal", "anzeige_datum": "2026-07-01", "kontakt_email": "bewerbung@acme.example", "labels": ["Regional"] }' ``` Roh mit curl: ```bash curl -X POST "$BEWERBUNG_API_URL/joboffers" \ -H "X-API-Key: $BEWERBUNG_API_KEY" -H "Content-Type: application/json" \ -d '{"external_id":"job-12345","quelle":"mein-crm","firma":"Acme GmbH","stelle":"Softwareentwickler (m/w/d)","firma_slug":"acme-gmbh","art":"Online-Portal","anzeige_datum":"2026-07-01"}' ``` > JSON erlaubt **keine Kommentare** — die `// created_at …`-Zeile aus Notizen > vor dem Absenden entfernen, sonst schlägt das Parsen fehl (HTTP 400). ### Batch-Einspielung Kein Bulk-Endpunkt: pro Angebot ein `POST`. Für viele Angebote in einer Schleife importieren und `action` je Antwort auswerten: ```bash # offers.json = JSON-Array von JobOfferCreate-Objekten jq -c '.[]' offers.json | while read -r offer; do scripts/bt.sh POST /joboffers "$offer" | head -1 done ``` ## Blacklist (Angebote dauerhaft blockieren) Über `/joboffers/blacklist` lassen sich Angebote sperren. Ein geblacklistetes Angebot wird beim `POST /joboffers` **nicht** aufgenommen, sondern mit **409** und `BlacklistConflict`-Body abgelehnt (`blacklisted:true`, `matched_by`, `blacklist_entry`). Der Filter greift auf **künftige** Einspielungen; bereits importierte Angebote werden nicht rückwirkend gelöscht. ### Block-Typen (`typ`) | `typ` | Body-Felder | Blockiert | |-------|-------------|-----------| | `url` | `wert` = konkrete URL | genau dieses Inserat (URL wird normalisiert, Tracking-Parameter entfernt). | | `domain` | `wert` = Domain (z. B. `stellenbetrug.example`) | alle Angebote dieser Domain. | | `firma` | `wert` = Firmenname | alle Angebote dieser Firma. | | `firma_stelle` | `firma` + `stelle` (+ optional `ort`) | eine bestimmte Firma-Stelle-Kombination. | `grund` (Freitext) ist überall optional. Firma/Stelle/URL werden serverseitig normalisiert, der Vergleich ist also unabhängig von Groß-/Kleinschreibung und Tracking-Parametern. GET-Antworten enthalten die normalisierten Vergleichsfelder zusätzlich: `url_norm`, `domain`, `firma_norm`, `firma_slug`, `stelle_norm`, `ort_norm` (nur lesbar, serverseitig berechnet — nicht mitsenden). (Der Typ `auto` in Antworten markiert automatisch erzeugte Einträge — aus einer **Ablehnung** oder aus dem **Löschen eines Jobangebots** (`DELETE /joboffers/{id}`); er kann nicht direkt angelegt werden.) ### Beispiele ```bash # Ganze Domain sperren scripts/bt.sh POST /joboffers/blacklist '{"typ":"domain","wert":"stellenbetrug.example","grund":"Fake-Inserate"}' # Eine Firma sperren scripts/bt.sh POST /joboffers/blacklist '{"typ":"firma","wert":"Dubios GmbH"}' # Konkrete Firma+Stelle sperren scripts/bt.sh POST /joboffers/blacklist '{"typ":"firma_stelle","firma":"Acme GmbH","stelle":"Vertrieb (m/w/d)","ort":"Berlin"}' # Auflisten und wieder entfernen scripts/bt.sh GET /joboffers/blacklist scripts/bt.sh DELETE /joboffers/blacklist/3 ``` > ⚠️ Vor dem `POST /joboffers` (v. a. Batch-Import) kann ein 409 auftreten, weil > das Angebot geblacklistet ist. In Schleifen den Statuscode auswerten und solche > Angebote überspringen statt abzubrechen. ## Anlagen bei der Generierung (statische Basis-Anlagen) Beim Anstoßen der KI-Generierung (`POST /applications/{id}/generate`) lassen sich optional **statische Basis-Anlagen** (`basis_anhaenge`) beilegen — z. B. Zeugnisse, Zertifikate. Feld **`anlagen`**: ein Array von **Integer-IDs** dieser Basis-Anlagen. - **Default: keine.** Fehlt `anlagen` oder ist das Array leer, werden **keine** statischen Anlagen beigelegt. - Die gewählten Anlagen erscheinen zusätzlich **im Anschreiben unter „Anlagen"**. - IDs verweisen auf die im Tracker hinterlegten Basis-Anlagen (`basis_anhaenge`). Für die aktuell verfügbaren IDs/Namen im Tracker nachsehen (die Basis-Anlagen werden dort verwaltet); nur belegte IDs senden, keine raten. ```bash # Mit einer statischen Anlage (z. B. Abschlusszeugnis = ID 2) generieren scripts/bt.sh POST /applications/12/generate '{"anlagen":[2]}' # Mehrere Anlagen + KI-Kontext scripts/bt.sh POST /applications/12/generate '{"llm_notizen":"…","anlagen":[2,5]}' # Ohne Anlagen (Default) — Feld einfach weglassen scripts/bt.sh POST /applications/12/generate '{}' ``` ## Enums - **art:** `E-Mail`, `Online-Portal`, `Indeed`, `StepStone`, `Firmenwebsite`, `Post`, `Initiativbewerbung`, `Arbeitsagentur`, `Sonstiges` - **status (Bewerbung/Timeline):** `Entwurf`, `Gesendet`, `Eingangsbestätigung`, `In Bearbeitung`, `Interessiert`, `Warten auf Rückmeldung`, `Warten auf meine Antwort`, `Vorstellungsgespräch`, `Absage`, `Einstellung`, `Keine Rückmeldung` - **joboffer.status:** `offen`, `uebernommen`, `abgelehnt` - **labels (Arbeitsort, Array, mehrere möglich):** `Regional`, `Remote-Deutschlandweit`, `Homeoffice-Deutschlandweit` — auf `JobOffer` **und** `Application`; als `?label=…`-Filter auf `GET /applications` nutzbar. - **blacklist.typ (anlegen):** `url`, `domain`, `firma`, `firma_stelle` (Antworten können zusätzlich `auto` enthalten) - **email.direction:** `in`, `out` - **generation.status:** `nicht_gestartet`, `ausstehend`, `fertig`, `fehler` ## Feld-Referenz (wichtigste) **ApplicationCreate** — erforderlich `datum` (YYYY-MM-DD), `firma`, `stelle`; optional `art`, `status`, `notizen`, `interne_notizen`, `ort`, `stellenbeschreibung`, `quelle_url`, `llm_notizen`, `kommentar`, `force`, `labels` (Array: `Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`; auch als `?label=`-Filter beim Auflisten). `ApplicationUpdate` kennt `labels` ebenso. **TimelineEntryCreate** — erforderlich `status`; optional `datum`, `kommentar`. **GenerateRequest** (`POST /applications/{id}/generate`) — alle Felder optional: `llm_notizen` (Freitext-Kontext für die KI), `anlagen` (Array von Integer-IDs statischer Basis-Anlagen; Default **keine**, siehe Abschnitt „Anlagen bei der Generierung“). **JobOfferCreate** — erforderlich `firma`, `stelle`, `firma_slug` (Firmenname als `lowercase` + `kebab-case`, z. B. `acme-gmbh` — eindeutige ID für den Blacklist-Abgleich); optional `external_id`, `quelle`, `ort`, `adresse`, `ansprechpartner`, `gehalt`, `beschreibung`, `quelle_url`, `art`, `status`, `anzeige_datum`, `kontakt_email`, `labels` (Array: `Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`, mehrere möglich) (siehe Abschnitt „JobOffers einspielen“). `created_at` wird **serverseitig** gesetzt und darf nicht mitgesendet werden. **BlacklistEntryCreate** — erforderlich `typ` (`url`\|`domain`\|`firma`\| `firma_stelle`); bei `url`/`domain`/`firma` das Feld `wert`; bei `firma_stelle` die Felder `firma`+`stelle` (optional `ort`); `grund` überall optional (siehe Abschnitt „Blacklist“). ## Hinweise / Fehlerbehandlung - **401** `{"error":"Ungültiger oder fehlender API-Key ..."}` → Key falsch, leer oder keinem Benutzer zugeordnet. Der Key liegt **in der Datenbank** (pro Benutzer), nicht mehr in der `.env` — ein Serverneustart ist nach einer Key-Änderung **nicht** nötig, ein neuer Key gilt sofort und macht den alten ungültig. Häufigste Ursache: Der Token wurde in der Oberfläche neu generiert, die Umgebung nutzt noch den alten. - **404 obwohl die ID existiert** → Die ID gehört einem **anderen Benutzer**. Die API zeigt fremde Datensätze grundsätzlich als „nicht vorhanden". Prüfe, mit wessen Key du arbeitest (`BEWERBUNG_USER` / `BEWERBUNG_API_KEY`). - **409** bei `POST /applications` → mögliche Dublette (`matches[]`); mit `force:true` erzwingen. - **409** bei `POST /joboffers` → Angebot steht auf der **Blacklist** (`BlacklistConflict`: `blacklisted`, `matched_by`, `blacklist_entry`); nicht erzwingbar — erst den Blacklist-Eintrag entfernen (`DELETE /joboffers/blacklist/{id}`). - **404** → ID existiert nicht. - Binär-Downloads (Attachments) mit `curl ... -o datei` speichern, nicht ins Terminal. - Vor Löschungen (DELETE) beim User rückversichern. Vollständige OpenAPI-Definition: `http://localhost:4327/swagger.json`.