---
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: <token>` 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=<name>`
  (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 + `<http STATUS>` 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`.
