Compare commits
27
Commits
7c92f23351
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
472259f189 | ||
|
|
9ac946bc9e | ||
|
|
ddf909502a | ||
|
|
1e9618c920 | ||
|
|
a6a2fabf05 | ||
|
|
811188f492 | ||
|
|
1c913b2b28 | ||
|
|
fa07dbf90a | ||
|
|
ada69eb482 | ||
|
|
7d5dc582e4 | ||
|
|
1ba615fa80 | ||
|
|
96fd0e282d | ||
|
|
d9ba8698e0 | ||
|
|
ce5fa1eab1 | ||
|
|
8405b8c057 | ||
|
|
ec2927cac5 | ||
|
|
bd7d21bfba | ||
|
|
1dd4807ca6 | ||
|
|
d20a265ebc | ||
|
|
75813a38a4 | ||
|
|
cd30af3d69 | ||
|
|
0301a1c430 | ||
|
|
21608b8ca7 | ||
|
|
ffaaafed7e | ||
|
|
1f2e299c55 | ||
|
|
9a40f395c7 | ||
|
|
ce844412e5 |
@@ -1,6 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
data
|
data
|
||||||
extension
|
|
||||||
indeed_example.txt
|
indeed_example.txt
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
# Ollama Cloud API-Schlüssel – https://ollama.com/settings/keys
|
# Ollama Cloud API-Schlüssel – https://ollama.com/settings/keys
|
||||||
OLLAMA_API_KEY=
|
OLLAMA_API_KEY=
|
||||||
|
|
||||||
# Optional – Cloud-Modell (Standard: gpt-oss:120b)
|
# Optional – Cloud-Modell (Standard: glm-5.2:cloud)
|
||||||
# OLLAMA_MODEL=gpt-oss:120b
|
# OLLAMA_MODEL=glm-5.2:cloud
|
||||||
|
|
||||||
# Optional – anderer Host (z. B. lokale Ollama-Instanz: http://localhost:11434)
|
# Optional – anderer Host (z. B. lokale Ollama-Instanz: http://localhost:11434)
|
||||||
# OLLAMA_HOST=https://ollama.com
|
# OLLAMA_HOST=https://ollama.com
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ COPY server.js ./
|
|||||||
COPY lib ./lib
|
COPY lib ./lib
|
||||||
COPY views ./views
|
COPY views ./views
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
COPY extension ./extension
|
||||||
|
|
||||||
# Persisted SQLite data lives here; make it writable for the non-root user
|
# Persisted SQLite data lives here; make it writable for the non-root user
|
||||||
RUN mkdir -p /app/data && chown -R node:node /app
|
RUN mkdir -p /app/data && chown -R node:node /app
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# Agent-Image für die isolierte, pro-Benutzer-Jobsuche.
|
||||||
|
#
|
||||||
|
# Anders als das App-Image (Dockerfile im Repo-Root) enthält dieses Image KEINE
|
||||||
|
# Anwendung, sondern ausschließlich die Werkzeuge, die der headless Such-Agent
|
||||||
|
# braucht: Claude Code, curl/python3/jq für die Skill-Skripte (Arbeitsagentur-API,
|
||||||
|
# bt.sh) sowie git. Der eigentliche Suchlauf wird vom Host-Runner pro Lauf als
|
||||||
|
# `docker run --rm` gestartet, mit dem jeweiligen Benutzer-Home als Volume —
|
||||||
|
# dadurch landen Memories und Session-Contexte getrennt pro Benutzer.
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Werkzeuge für die Skill-Skripte: arbeitsagentur.sh = curl+python3, bt.sh =
|
||||||
|
# curl+jq, plus git/flock/bash/coreutils für die Skripte selbst.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl git python3 jq util-linux bash coreutils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Claude Code auf einem systemweiten Pfad, damit es unabhängig vom zur Laufzeit
|
||||||
|
# gemounteten HOME gefunden wird. Version an den Host-CLI (2.1.209) angeglichen;
|
||||||
|
# DISABLE_AUTOUPDATER verhindert, dass jeder Benutzer sein eigenes Update zieht.
|
||||||
|
RUN npm install -g @anthropic-ai/claude-code@2.1.209
|
||||||
|
ENV DISABLE_AUTOUPDATER=1
|
||||||
|
|
||||||
|
# Nicht-Root-Benutzer, als der der Such-Agent läuft. Das node:20-Image bringt
|
||||||
|
# bereits uid/gid 1000 (»node«) mit — wir verwenden es unverändert, damit der
|
||||||
|
# Container dieselbe uid hat wie das vom Host-Runner gechownte pro-Benutzer-Home.
|
||||||
|
# COPY/Zielordner müssen für uid 1000 schreibbar sein.
|
||||||
|
|
||||||
|
# Geteilte Skills (Shared-Code, lesbar für alle); der Entrypoint verlinkt sie
|
||||||
|
# pro Benutzer nach ~/.claude/skills.
|
||||||
|
COPY skills /opt/skills
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
# Schreibbarer Arbeitsordner (cwd während des Suchlaufs); Projekt-Key von Claude
|
||||||
|
# Code wird -work, also liegen Memory/Contexte unter ~/.claude/projects/-work.
|
||||||
|
# /home/agent wird im Image angelegt (uid 1000), damit der Entrypoint auch ohne
|
||||||
|
# gemountetes Volume (z. B. `docker run … claude --version`) läuft; im echten
|
||||||
|
# Lauf legt der Host-Runner das pro-Benutzer-Volume genau dortüber.
|
||||||
|
RUN mkdir -p /work /home/agent && chown -R 1000:1000 /work /home/agent
|
||||||
|
|
||||||
|
ENV HOME=/home/agent
|
||||||
|
|
||||||
|
WORKDIR /work
|
||||||
|
USER 1000:1000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["claude", "--version"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
# Der Container läuft mit einem pro Benutzer gemounteten HOME (anfangs leer).
|
||||||
|
# Claude Code schreibt seine Memories und Session-Contexte nach ~/.claude; die
|
||||||
|
# drei Such-Skills sind geteilter Code aus dem Image und werden hier nur
|
||||||
|
# verlinkt, statt pro Benutzer kopiert zu werden.
|
||||||
|
mkdir -p "$HOME/.claude"
|
||||||
|
ln -sfn /opt/skills "$HOME/.claude/skills"
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
---
|
||||||
|
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`.
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Helper for the Bewerbungs-Tracker REST-API.
|
||||||
|
# Usage:
|
||||||
|
# bt.sh GET /applications
|
||||||
|
# bt.sh GET "/applications?status=Gesendet&limit=20"
|
||||||
|
# bt.sh POST /applications '{"datum":"2026-07-03","firma":"ACME","stelle":"DevOps"}'
|
||||||
|
# bt.sh PUT /applications/12 @/path/to/body.json
|
||||||
|
# bt.sh DELETE /joboffers/5
|
||||||
|
#
|
||||||
|
# Config via env (with sensible defaults):
|
||||||
|
# BEWERBUNG_API_URL base URL (default http://localhost:4327/api/v1)
|
||||||
|
# BEWERBUNG_API_KEY API key (X-API-Key header)
|
||||||
|
# BEWERBUNG_USER username whose key to use (default: admin)
|
||||||
|
#
|
||||||
|
# MULTI-USER: the tracker has one API key PER USER; the key decides whose data you
|
||||||
|
# see and change. There is no global key any more. The old fallback (API_TOKEN from
|
||||||
|
# /opt/jobbi-bewerbung/.env) is gone on purpose: it happened to hold the admin's
|
||||||
|
# token, so it silently acted as admin — and it goes stale the moment a key is
|
||||||
|
# rotated in the UI (Einstellungen -> "Neu generieren").
|
||||||
|
#
|
||||||
|
# Key resolution:
|
||||||
|
# 1. $BEWERBUNG_API_KEY if set (this is what the Jobsuche-Runner passes per user)
|
||||||
|
# 2. otherwise: look up the key of $BEWERBUNG_USER (default admin) in the tracker DB
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${BEWERBUNG_API_URL:-http://localhost:4327/api/v1}"
|
||||||
|
DB="${BEWERBUNG_DB:-/opt/jobbi-bewerbung/data/bewerbungen.db}"
|
||||||
|
USER_NAME="${BEWERBUNG_USER:-admin}"
|
||||||
|
KEY="${BEWERBUNG_API_KEY:-}"
|
||||||
|
|
||||||
|
if [[ -z "$KEY" && -f "$DB" ]]; then
|
||||||
|
KEY="$(node -e "
|
||||||
|
const s = require('/opt/jobbi-bewerbung/source/node_modules/sqlite3');
|
||||||
|
const db = new s.Database(process.argv[1]);
|
||||||
|
db.get(
|
||||||
|
\"SELECT a.value FROM app_state a JOIN users u ON u.id = a.user_id \" +
|
||||||
|
\"WHERE u.username = ? AND a.key = 'cfg:API_TOKEN' AND a.value != ''\",
|
||||||
|
[process.argv[2]],
|
||||||
|
(e, r) => process.stdout.write(r && r.value ? r.value : '')
|
||||||
|
);
|
||||||
|
" "$DB" "$USER_NAME" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$KEY" ]]; then
|
||||||
|
echo "bt.sh: kein API-Key für Benutzer '$USER_NAME'." >&2
|
||||||
|
echo " -> In der App unter Einstellungen -> REST-API einen Token erzeugen," >&2
|
||||||
|
echo " oder BEWERBUNG_API_KEY / BEWERBUNG_USER setzen." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
METHOD="${1:?method required (GET/POST/PUT/DELETE)}"
|
||||||
|
PATH_ARG="${2:?path required, e.g. /applications}"
|
||||||
|
BODY="${3:-}"
|
||||||
|
|
||||||
|
url="${BASE}${PATH_ARG}"
|
||||||
|
args=(-sS -X "$METHOD" -H "X-API-Key: ${KEY}" -w $'\n<http %{http_code}>\n')
|
||||||
|
|
||||||
|
if [[ -n "$BODY" ]]; then
|
||||||
|
args+=(-H "Content-Type: application/json" --data-binary "$BODY")
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl "${args[@]}" "$url"
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
---
|
||||||
|
name: it-stellensuche-remote
|
||||||
|
description: Search the web for NEW 100% remote / homeoffice IT / system administrator jobs anywhere in Germany (Deutschland-weit, ortsunabhängig) that the user has NOT applied to yet, then present them and optionally import them as JobOffers into the Bewerbungs-Tracker. Use whenever the user wants to find new fully-remote IT/sysadmin postings, "Remote-Stellensuche", "Homeoffice-Jobs suchen", "100% Remote finden", "ortsunabhängige Systemadministrator-Stellen", or refresh open remote job leads. Deduplicates against existing Bewerbungen and Jobangebote via the bewerbungs-tracker skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Stellensuche REMOTE (100 % Homeoffice, deutschlandweit)
|
||||||
|
|
||||||
|
Findet **neue** Stellen im Web, die **100 % remote / vollständig im Homeoffice** und
|
||||||
|
**deutschlandweit ortsunabhängig** ausübbar sind, filtert alle raus, für die sich der
|
||||||
|
Nutzer schon beworben hat **oder die auf der Blacklist stehen**, und spielt sie auf
|
||||||
|
Wunsch als JobOffers in den Bewerbungs-Tracker ein.
|
||||||
|
|
||||||
|
> **Multi-User (WICHTIG):** Der Tracker hat mehrere Benutzer, jeder mit **eigenem
|
||||||
|
> Suchprofil und eigenem API-Key**. Der Remote-Modus ist inzwischen Teil des
|
||||||
|
> Suchprofils (`/jobsuche` → Modus „100 % Remote" bzw. „Regional + Remote"), und der
|
||||||
|
> Jobsuche-Runner ruft dafür den Skill `it-stellensuche` mit einem entsprechenden
|
||||||
|
> Prompt auf. Dieser Skill hier ist der **manuelle Einstieg** für eine reine
|
||||||
|
> Remote-Suche.
|
||||||
|
>
|
||||||
|
> Rolle und Technologien **nicht raten**: Sie kommen aus dem Lebenslauf des Benutzers,
|
||||||
|
> dem der `BEWERBUNG_API_KEY` gehört (`GET /templates`, typ `Lebenslauf`) — bzw. aus dem
|
||||||
|
> Profil, das der Prompt mitliefert. Die Rollen-/Buzzword-Listen unten sind **Beispiele
|
||||||
|
> für ein IT-Infrastruktur-Profil**, kein Default für jeden Benutzer.
|
||||||
|
|
||||||
|
> **Abgrenzung zum regionalen Skill:** Dieser Skill sucht **ausschließlich
|
||||||
|
> ortsunabhängige 100-%-Remote-Stellen deutschlandweit** — **ohne** Bindung an die
|
||||||
|
> sechs Städte. Der **Arbeitsort/Firmensitz spielt keine Rolle**, solange die
|
||||||
|
> Tätigkeit vollständig aus dem Homeoffice irgendwo in Deutschland erbracht werden
|
||||||
|
> kann. Regionale, ortsgebundene Stellen sind Sache des Skills `it-stellensuche`
|
||||||
|
> (sechs Städte) und werden hier **nicht** behandelt.
|
||||||
|
|
||||||
|
> **Blacklist-Prinzip (zentral):** Jeder erfolgreich importierte Job wird
|
||||||
|
> **unmittelbar danach geblacklistet**. So taucht dieselbe Stelle in späteren
|
||||||
|
> Suchläufen nie wieder auf — die Dublett-Vermeidung bleibt bestehen, selbst wenn
|
||||||
|
> das JobOffer später gelöscht oder in eine Bewerbung überführt wird. Umgekehrt
|
||||||
|
> werden Kandidaten, die schon auf der Blacklist stehen, gar nicht erst gelistet
|
||||||
|
> oder importiert. Die Blacklist/Dedup ist **gemeinsam** mit dem regionalen Skill —
|
||||||
|
> derselbe Job wird also über beide Suchen hinweg nicht doppelt erfasst.
|
||||||
|
|
||||||
|
## Suchprofil (Default)
|
||||||
|
|
||||||
|
**Rolle:** breit fassen — dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln.
|
||||||
|
Alle folgenden Bezeichnungen als Query-Varianten und für die Relevanzbewertung nutzen
|
||||||
|
(zutreffend, sobald der Schwerpunkt auf IT-Infrastruktur/Systembetrieb liegt):
|
||||||
|
|
||||||
|
- **Kern (deckt sich mit dem CV: 2nd-Level-Sysadmin + IT-Consultant):**
|
||||||
|
Systemadministrator, IT-Administrator, IT-Systemadministrator, IT-Systemadministration,
|
||||||
|
IT-Systemadministrator 2nd Level, 2nd-Level-Administrator, System-Administrator (m/w/d),
|
||||||
|
Fachinformatiker Systemintegration, Administrator (m/w/d).
|
||||||
|
- **Support/Betrieb:** 1st/2nd/3rd-Level-Support, 2nd-Level-Support, IT-Support,
|
||||||
|
IT-Supporter, IT-Techniker, IT-Systemtechniker, Systemtechniker, IT-Systembetreuer,
|
||||||
|
IT-Betreuer, IT-Allrounder, IT-Koordinator, IT-Mitarbeiter, IT-Operations / IT-Betrieb,
|
||||||
|
Remote-Support, Remote-Systemadministrator, Service-Desk, Application Manager (remote).
|
||||||
|
(Rein ortsgebundene Onsite-/Field-Service-Rollen entfallen hier — sie sind nicht
|
||||||
|
remote ausübbar.)
|
||||||
|
- **Infrastruktur/Netz:** System Engineer, Systems Engineer, Infrastructure Engineer,
|
||||||
|
IT-Infrastruktur, Infrastruktur-Administrator, Netzwerkadministrator, Network Engineer,
|
||||||
|
Virtualisierungsadministrator, Storage-/Backup-Administrator.
|
||||||
|
- **Cloud/Modern Workplace:** Cloud-Administrator, Cloud Engineer, Cloud-Operations-Engineer,
|
||||||
|
Microsoft-365-Administrator, M365-/Modern-Workplace-Administrator,
|
||||||
|
Azure-/Entra-Administrator, DevOps-Engineer (Infrastruktur-lastig),
|
||||||
|
Site Reliability Engineer (SRE), Platform Engineer, Linux-Administrator,
|
||||||
|
Linux System Engineer, Windows-Administrator.
|
||||||
|
- **Managed Services / Hosting / Betrieb (passt zu Hetzner/OVH im CV):**
|
||||||
|
IT-Consultant (remote), IT-Berater (technisch), IT-Systemberater,
|
||||||
|
Managed-Services-Engineer, MSP-Engineer, NOC-Engineer, Cloud Operations,
|
||||||
|
Hosting-Administrator, Hosting Engineer, Systemadministrator Rechenzentrum (remote).
|
||||||
|
|
||||||
|
Ausschließen bleiben reine Softwareentwickler-, Data-Science-, Vertriebs- und
|
||||||
|
SAP-only-Stellen, außer sie passen klar zum Infrastruktur-Profil.
|
||||||
|
|
||||||
|
> **Ex-Arbeitgeber IMMER ausschließen (harte Regel, unabhängig von der Schreibweise):**
|
||||||
|
> Stellen der **„IT-Problemlöser"** (Essen) — voller Firmenname u. a.
|
||||||
|
> **„IT Problemlöser Verwaltungs- und Handels GmbH"**, auch **„IT-Problemlöser GmbH"**,
|
||||||
|
> **„IT Problemlöser"** o. Ä. — werden **nie** gelistet oder importiert. Es ist der
|
||||||
|
> frühere Arbeitgeber des Nutzers (siehe Lebenslauf). Diese Firma taucht immer wieder
|
||||||
|
> mit **wechselnden Stellentiteln** auf; die `firma_stelle`-Blacklist greift dann nicht,
|
||||||
|
> weil der Titel abweicht. Deshalb **jeden Treffer verwerfen, dessen Firmenname `IT
|
||||||
|
> Problemlöser` / `IT-Problemlöser` (in jeder Rechtsform-/Schreibvariante) enthält** —
|
||||||
|
> ohne auf die Blacklist zu warten. (Firmenweite `firma`-Blacklist-Einträge greifen
|
||||||
|
> inzwischen schreibweisen-robust über `firma_slug` inkl. Präfix-Match — diese
|
||||||
|
> Modell-Regel bleibt trotzdem als zusätzliche Sicherung bestehen.)
|
||||||
|
|
||||||
|
**Arbeitsmodell (harter Filter — das Kernkriterium dieses Skills):**
|
||||||
|
Nur **100 % Remote / vollständig ortsunabhängige** Stellen, die **deutschlandweit
|
||||||
|
aus dem Homeoffice** erbracht werden können.
|
||||||
|
|
||||||
|
- **Zulässig** (behalten): Anzeigen, die klar als „100 % Remote", „Full Remote",
|
||||||
|
„vollständig remote", „remote-first", „ortsunabhängig", „deutschlandweit im
|
||||||
|
Homeoffice", „remote (Deutschland)" o. Ä. ausgeschrieben sind — die Tätigkeit ist
|
||||||
|
im Alltag komplett aus dem Homeoffice möglich. **Gelegentliche** Vor-Ort-Termine
|
||||||
|
(Onboarding, wenige Team-Events/Kick-offs pro Quartal/Jahr) sind unschädlich.
|
||||||
|
- **Verwerfen** (nicht listen, nicht importieren):
|
||||||
|
- **Reine Vor-Ort-/Präsenz-Stellen** (kein oder nur „nach Absprache"-Homeoffice).
|
||||||
|
- **Klassische Hybrid-Modelle mit festen Präsenz-/Bürotagen** (z. B. „2–3 Tage/
|
||||||
|
Woche vor Ort", „überwiegend Präsenz", „Homeoffice anteilig/tageweise möglich") —
|
||||||
|
das ist **nicht** 100 % remote.
|
||||||
|
- Stellen, die **Wohnsitz/Anwesenheit in einem bestimmten Ort oder Umkreis**
|
||||||
|
verlangen („Einsatzort …", „Wohnsitz im Raum …", „regelmäßig beim Kunden vor
|
||||||
|
Ort", „Rufbereitschaft vor Ort", Onsite-Field-Service).
|
||||||
|
- Stellen ohne **Anstellung in Deutschland** (z. B. „remote nur aus <anderes
|
||||||
|
Land>", Arbeitsvertrag/Steueransässigkeit außerhalb DE). Der Job muss für einen
|
||||||
|
in **Deutschland ansässigen** Bewerber als reguläre Anstellung offenstehen.
|
||||||
|
- **Kein geografischer Prioritäts-Ranking** (deutschlandweit gleichwertig) — statt
|
||||||
|
nach Ort **nach Relevanz** (Profil-/Buzzword-Passung, s. u.) und **Klarheit des
|
||||||
|
100-%-Remote-Versprechens** priorisieren.
|
||||||
|
|
||||||
|
> Bei Unsicherheit, ob eine Stelle wirklich 100 % remote ist, die Anzeige per
|
||||||
|
> `WebFetch` genau lesen (Abschnitte „Arbeitsmodell", „Das bieten wir", „Standort").
|
||||||
|
> Steht dort nur „Homeoffice möglich"/„flexibel" ohne klares 100-%-Remote, im Zweifel
|
||||||
|
> **verwerfen** — dieser Skill nimmt ausschließlich eindeutig vollständig remote
|
||||||
|
> Stellen auf.
|
||||||
|
|
||||||
|
**Skills/Buzzwords** (für Query-Varianten & Relevanzbewertung): erste Gruppe stammt
|
||||||
|
direkt aus dem Lebenslauf (Kernkompetenzen, höchste Gewichtung), die weiteren sind
|
||||||
|
naheliegende, zum Profil passende Technologien — als Suchbegriffe und zum Erkennen
|
||||||
|
passender Anzeigen nutzen, auch wenn sie nicht wörtlich im CV stehen:
|
||||||
|
|
||||||
|
- **Kern (aus dem CV, höchste Gewichtung):** Windows Server, Active Directory,
|
||||||
|
Exchange, Microsoft 365 / MS365, Linux-Administration (Debian/Ubuntu),
|
||||||
|
Docker/Container, Proxmox/Virtualisierung, Netzwerk (OPNsense, pfSense,
|
||||||
|
VPN: WireGuard/OpenVPN/IPsec, LAN/WAN, TCP/IP), Firewall, Monitoring
|
||||||
|
(Prometheus, Grafana, Beszel, Uptime Kuma), Hetzner/OVH Cloud, Self-Hosting,
|
||||||
|
Automatisierung, AI/Claude/Agent-Workflows.
|
||||||
|
- **Microsoft-/Windows-Umfeld:** Entra ID / Azure AD, Intune, Endpoint Manager,
|
||||||
|
Group Policy / GPO, WSUS, PowerShell, Hyper-V, SharePoint, Teams, Windows 10/11,
|
||||||
|
Client-Management, MDM.
|
||||||
|
- **Virtualisierung/Server:** VMware vSphere / ESXi, Hyper-V, Citrix, KVM,
|
||||||
|
Terminalserver / RDS, Server-Hardware (Dell/HPE/Lenovo).
|
||||||
|
- **Backup/Storage:** Veeam, Backup & Recovery, Datensicherung, NAS/SAN, TrueNAS,
|
||||||
|
ZFS, Ceph, Synology, QNAP.
|
||||||
|
- **Netzwerk/Security:** VLAN, Routing & Switching, Cisco, Fortinet/FortiGate,
|
||||||
|
Sophos, Ubiquiti/UniFi, DNS/DHCP, Reverse Proxy (Nginx, Traefik), Let's Encrypt,
|
||||||
|
IT-Security, Patch-Management, ISO 27001, IT-Grundschutz.
|
||||||
|
- **Cloud/DevOps/Automatisierung:** Microsoft Azure, AWS, Kubernetes, Ansible,
|
||||||
|
Terraform, GitLab / CI/CD, Bash-/Shell-Scripting, Portainer, Nextcloud.
|
||||||
|
- **Datenbanken/Web:** MySQL/MariaDB, PostgreSQL, MS SQL Server, Apache, Nginx, IIS.
|
||||||
|
- **Betrieb/Organisation:** Ticketsystem (Jira, OTRS, Zammad), Helpdesk,
|
||||||
|
Rechenzentrum, On-Premise, Managed Services, IT-Dienstleister, Systemhaus,
|
||||||
|
Cloud-Provider, Hosting-Anbieter.
|
||||||
|
|
||||||
|
> Aktuelles Profil (Titel, Skills, Ort) steht in der Lebenslauf-Vorlage der API:
|
||||||
|
> `bewerbungs-tracker` → `GET /templates` (typ `Lebenslauf`). Bei Bedarf dort
|
||||||
|
> gegenlesen, statt zu raten.
|
||||||
|
|
||||||
|
## Quellen-Politik (WICHTIG)
|
||||||
|
|
||||||
|
Ziel ist **immer die Original-Stellenausschreibung direkt beim echten Arbeitgeber**
|
||||||
|
— bevorzugt auf dessen **Firmen-/Karriereseite**.
|
||||||
|
|
||||||
|
- **Ausschließen (nicht listen, nicht importieren):**
|
||||||
|
- **Headhunter / Personalvermittler / Personalberatungen** (recruiten für Dritte).
|
||||||
|
- **Zeitarbeit / Arbeitnehmerüberlassung / Personaldienstleister** (z. B. Randstad,
|
||||||
|
Hays, GULP, Amadeus FiRe, Adecco, Manpower, Piening, Tempton, DIS AG, Robert Half
|
||||||
|
u. ä. — auch unbekannte, wenn die Anzeige „Arbeitnehmerüberlassung",
|
||||||
|
„im Kundenauftrag", „für unseren Kunden", „Personaldienstleister" o. Ä. nennt).
|
||||||
|
- **Stepstone** als Quelle — nicht als Board nutzen, keine `site:stepstone.de`-Query,
|
||||||
|
keine Stepstone-Links importieren.
|
||||||
|
- **Erkennungsmerkmale eines Vermittlers/Zeitarbeit** (bei Unsicherheit die
|
||||||
|
Firmen-/Impressumsseite per `WebFetch` prüfen): Formulierungen wie „für unseren
|
||||||
|
Kunden", „im Auftrag unseres Mandanten", „Arbeitnehmerüberlassung", „Direktvermittlung",
|
||||||
|
„Personaldienstleistung", „Recruiting-Partner"; oder die inserierende Firma ist erkennbar
|
||||||
|
eine Personal-/Recruiting-Agentur. → **verwerfen**.
|
||||||
|
- **Zulässig als Quelle**, wenn es zur Original-Anzeige des Arbeitgebers führt:
|
||||||
|
Firmen-Karriereseite (bevorzugt), Arbeitsagentur/Bundesagentur für Arbeit, Indeed
|
||||||
|
oder LinkedIn **nur** wenn die Anzeige eindeutig vom echten Arbeitgeber (nicht von
|
||||||
|
einem Vermittler) stammt. Führt ein Board-Treffer zu einer Firma, deren eigene
|
||||||
|
Karriereseite dieselbe Stelle direkt listet, **immer den Firmen-Direktlink** nehmen.
|
||||||
|
- **Remote-spezifische Boards** (nur als Ergänzung, immer zur Arbeitgeber-Originalanzeige
|
||||||
|
durchklicken): z. B. `germantechjobs.de`, `remotarr`, `remote.io`, `remoteok.com`,
|
||||||
|
`4dayweek.io`, `join.com`, `arbeitsagentur.de` (Filter „Homeoffice/Telearbeit"),
|
||||||
|
`de.indeed.com` (Filter „Remote"), `de.linkedin.com/jobs` (Filter „Remote") — auch hier
|
||||||
|
Vermittler/Zeitarbeit/Stepstone ausschließen und den Firmen-Direktlink bevorzugen.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Bereits erfasste Jobs + Blacklist laden** (Dedup-Basis). Dieser Schritt ist
|
||||||
|
**immer zuerst** auszuführen, damit Stellen aus früheren Suchläufen — **auch aus
|
||||||
|
dem regionalen Skill** — **nicht erneut** gefunden/importiert werden. Zwei Quellen:
|
||||||
|
```bash
|
||||||
|
scripts/applied-set.sh # firma_slug<TAB>firma<TAB>stelle<TAB>ort<TAB>quelle_url<TAB>external_id
|
||||||
|
scripts/blacklist-set.sh # id<TAB>typ<TAB>firma<TAB>stelle<TAB>ort<TAB>domain<TAB>url_norm<TAB>firma_norm<TAB>firma_slug<TAB>stelle_norm<TAB>ort_norm<TAB>grund
|
||||||
|
```
|
||||||
|
- **applied-set** (Bewerbungen **und** importierte Jobangebote): **eine Zeile
|
||||||
|
pro bereits erfasster Firma** (company-level, nicht mehr pro Stelle). Je Zeile
|
||||||
|
drei Dublett-Merkmale merken: **`firma_slug`** (robuster Firmen-Schlüssel:
|
||||||
|
lowercase, Umlaute→ae/oe/ue/ss, `(m/w/d)` raus, End-Rechtsform wie gmbh/ag/kg
|
||||||
|
entfernt, mit `-` verbunden), **`quelle_url`** und **`external_id`**.
|
||||||
|
- **blacklist-set** (gesperrte Muster): je Zeile den **`typ`** und die dazu
|
||||||
|
passenden Felder merken (`url_norm`, `domain`, `firma_norm`, **`firma_slug`**,
|
||||||
|
`stelle_norm`). Die `*_norm`/`firma_slug`-Felder liefert der Server bereits
|
||||||
|
normalisiert (lowercase, Tracking-Parameter entfernt, `(m/w/d)`/Rechtsform
|
||||||
|
bereinigt).
|
||||||
|
|
||||||
|
2. **Kandidaten sammeln — strukturierte Quelle zuerst, dann WebSearch.**
|
||||||
|
|
||||||
|
**(a) Arbeitsagentur-API mit Homeoffice-Filter (primär, strukturiert, frisch).**
|
||||||
|
Vor der Freitext-Suche die Jobsuche-API der Bundesagentur für Arbeit abfragen —
|
||||||
|
**deutschlandweit** (`wo` leer) mit `arbeitszeit=ho` (Homeoffice-Flag):
|
||||||
|
```bash
|
||||||
|
scripts/arbeitsagentur.sh search "<Rolle>" "" 0 30 100 ho
|
||||||
|
# -> firma_slug<TAB>firma<TAB>titel<TAB>ort<TAB>datum<TAB>refnr<TAB>angebotsart
|
||||||
|
```
|
||||||
|
Rollen aus dem Suchprofil durchrotieren (Systemadministrator, IT-Administrator,
|
||||||
|
Linux/Windows-Administrator, Cloud Administrator, System Engineer, DevOps, SRE, …).
|
||||||
|
Jede Zeile sofort per `firma_slug` gegen applied-set/blacklist prüfen (Firmen-Dedup,
|
||||||
|
Schritt 1). Für überlebende Treffer die Details holen:
|
||||||
|
```bash
|
||||||
|
scripts/arbeitsagentur.sh detail <refnr>
|
||||||
|
```
|
||||||
|
Detail liefert **Volltext** (→ `beschreibung`), **`EXTERNE_URL`** (→ bevorzugt
|
||||||
|
`quelle_url`), **`HOMEOFFICE_MOEGLICH`**, **Vergütung**, Firmensitz und die Flags
|
||||||
|
**`ZEITARBEIT_AUE` / `PRIVATE_ARBEITSVERMITTLUNG`** — `True` → Zeitarbeit/Vermittler
|
||||||
|
→ **verwerfen**. ⚠️ `arbeitszeit=ho`/`HOMEOFFICE_MOEGLICH` heißt nur „Homeoffice
|
||||||
|
möglich", **nicht** automatisch „100 % remote" — die harte 100-%-Remote-Prüfung
|
||||||
|
(Schritt 4) gilt unverändert am Volltext. „Keine aktive Anzeige" → verwerfen; `refnr`
|
||||||
|
stabil → Existenz-Check & `external_id`. (Die API ist ortsorientiert und deshalb hier
|
||||||
|
nur **eine** Quelle — WebSearch bleibt für Remote-Boards & remote-first-Firmen wichtig.)
|
||||||
|
|
||||||
|
**(b) WebSearch (ergänzend).** Mit dem `WebSearch`-Tool **viele** Queries fahren —
|
||||||
|
Rolle × **Remote/Homeoffice**, **deutschlandweit** (kein Ort anhängen), mit Fokus
|
||||||
|
auf **Original-Anzeigen der Arbeitgeber**.
|
||||||
|
|
||||||
|
> **Query-Strategie (Recall maximieren — WICHTIG, findet die Suche „kaum noch
|
||||||
|
> Stellen"):** Lieber **viele einfache, natürlichsprachliche Einzel-Queries** als
|
||||||
|
> wenige überladene. **Boolesche Operatoren sparsam einsetzen** — `OR`, `"…"`,
|
||||||
|
> `site:`, `inurl:` und `-minus`-Ausschlüsse **senken bei WebSearch oft drastisch
|
||||||
|
> die Trefferzahl**. Statt `A OR B "…" inurl:jobs`-Monsterqueries lieber **getrennte,
|
||||||
|
> kurze Queries**; Vermittler/Stepstone/Hybrid erst **nachträglich beim Filtern**
|
||||||
|
> (Schritt 3) rauswerfen. Jede Rolle mit **mehreren Remote-Synonymen** und Suffixen
|
||||||
|
> durchvariieren: Remote-Wörter `remote`, `100% Remote`, `Homeoffice`,
|
||||||
|
> `home office`, `ortsunabhängig`, `deutschlandweit`, `remote-first`, `full remote`;
|
||||||
|
> Suffixe `Stellenangebot`, `Job`, `Jobs`, `Festanstellung`, `unbefristet`,
|
||||||
|
> `Vollzeit`, `2026`.
|
||||||
|
|
||||||
|
**Rolle × Remote (mehrere Synonyme/Suffixe durchspielen), Beispiele:**
|
||||||
|
- `Systemadministrator 100% Remote Stellenangebot Deutschland`,
|
||||||
|
`Systemadministrator remote Homeoffice Job`, `Systemadministrator ortsunabhängig Festanstellung`
|
||||||
|
- `IT-Administrator remote deutschlandweit Job`, `IT-Administrator Homeoffice Vollzeit`,
|
||||||
|
`IT-Systemadministrator full remote 2026`
|
||||||
|
- `IT-Systemadministrator vollständig remote Stellenangebot`,
|
||||||
|
`2nd Level Support remote deutschlandweit`, `Remote-Systemadministrator Festanstellung`
|
||||||
|
- `Linux Administrator remote Deutschland`, `Linux System Engineer Homeoffice`,
|
||||||
|
`Windows Administrator remote deutschlandweit`
|
||||||
|
- `Microsoft 365 Administrator remote Homeoffice`, `Cloud Administrator 100% Homeoffice`,
|
||||||
|
`Cloud Engineer remote Deutschland`, `Azure Administrator remote Festanstellung`
|
||||||
|
- `System Engineer remote Deutschland`, `Infrastructure Engineer Homeoffice`,
|
||||||
|
`Platform Engineer remote deutschlandweit`, `SRE remote Deutschland`
|
||||||
|
- `Hosting Administrator remote`, `Managed Services Engineer remote Homeoffice`,
|
||||||
|
`NOC Engineer remote Deutschland`, `IT-Consultant remote Homeoffice`
|
||||||
|
- **Buzzword-getrieben** (holt Anzeigen mit abweichendem Titel, passendem Inhalt):
|
||||||
|
`VMware Administrator remote Deutschland`, `Proxmox remote Homeoffice Job`,
|
||||||
|
`Docker Kubernetes remote Administrator`, `Active Directory remote Homeoffice`,
|
||||||
|
`Veeam Backup remote Job`, `Hetzner OVH remote Administrator`,
|
||||||
|
`OPNsense pfSense Firewall remote Job`, `Ansible Automatisierung remote Deutschland`.
|
||||||
|
- **Remote-Boards zusätzlich** (jeweils **einzeln**, nicht stapeln): `germantechjobs
|
||||||
|
systemadministrator remote`, `remoteok IT administrator`, `4dayweek.io sysadmin`,
|
||||||
|
`join.com systemadministrator remote`, `Systemadministrator Homeoffice arbeitsagentur`,
|
||||||
|
`IT Administrator remote indeed`. Falls doch `site:`, dann einzeln:
|
||||||
|
`site:germantechjobs.de systemadministrator remote`,
|
||||||
|
`site:de.indeed.com IT Administrator remote`,
|
||||||
|
`site:arbeitsagentur.de Systemadministrator Telearbeit`. **Kein** `site:stepstone.de`.
|
||||||
|
Für Details/Arbeitsmodell/Firma einer Trefferseite `WebFetch` nutzen. Sieht ein
|
||||||
|
Treffer nach Firmen-Karriereseite aus: dort direkt nach der Einzelanzeige suchen.
|
||||||
|
|
||||||
|
2b. **Remote-freundliche Arbeitgeber gezielt finden** (Hauptquelle für „unentdeckte"
|
||||||
|
Stellen). Board-Suchen finden nur, was breit ausgeschrieben ist — viele
|
||||||
|
remote-first-Arbeitgeber posten IT-Stellen **ausschließlich auf der eigenen
|
||||||
|
Website**. Deshalb parallel zur Rolle-×-Remote-Suche **remote-affine Arbeitgeber
|
||||||
|
deutschlandweit identifizieren** und deren Karriereseite direkt öffnen:
|
||||||
|
- **Arbeitgeber-Typen recherchieren:** Cloud-/Hosting-Provider, Managed-Service-
|
||||||
|
Provider (MSP), SaaS-/Tech-Unternehmen, IT-Dienstleister/Systemhäuser mit
|
||||||
|
Remote-Kultur, verteilte/remote-first-Firmen. Beispiel-Queries:
|
||||||
|
`remote-first Unternehmen Deutschland IT`, `MSP remote Systemadministrator Karriere`,
|
||||||
|
`SaaS Unternehmen Deutschland remote Stellenangebote`,
|
||||||
|
`Cloud Provider Deutschland Karriere remote`.
|
||||||
|
- **Karriereseite direkt anfahren:** zu jeder gefundenen Firma
|
||||||
|
`WebSearch "<Firma> Karriere remote"` bzw. `"<Firma> Jobs remote"` und die
|
||||||
|
Jobliste per `WebFetch` öffnen; auf offene **100-%-Remote IT-/Administrator-/
|
||||||
|
Support-Stellen** prüfen. Auch `inurl:karriere`, `inurl:jobs`, `inurl:stellen`
|
||||||
|
gezielt einsetzen und gängige Bewerber-Portale erkennen (softgarden/onlyfy/
|
||||||
|
Personio/Workday/join.com).
|
||||||
|
- **Buzzwords als Türöffner:** findet die reine Titel-Suche wenig, mit den
|
||||||
|
Technologie-Buzzwords (oben) + „remote" suchen, z. B.
|
||||||
|
`Active Directory remote Administrator Deutschland`, `Veeam remote Job Homeoffice`.
|
||||||
|
- Jede so gefundene Stelle läuft durch dieselben Prüf-/Filter-/Dedup-Schritte
|
||||||
|
(3–7). Der Firmen-Direktlink ist hier ohnehin schon die bevorzugte `quelle_url`.
|
||||||
|
|
||||||
|
3. **Filtern & bewerten:**
|
||||||
|
- **Arbeitsmodell (harter Ausschluss):** **nur** eindeutig **100 % Remote /
|
||||||
|
vollständig ortsunabhängige** Stellen, deutschlandweit aus dem Homeoffice
|
||||||
|
ausübbar (Anstellung in Deutschland). Alles andere — reine Präsenz, Hybrid mit
|
||||||
|
festen Bürotagen, ortsgebundene/„Homeoffice möglich"-ohne-100%-Zusage,
|
||||||
|
Anstellung außerhalb DE — **verwerfen**. Kein geografisches Ranking; nach
|
||||||
|
Relevanz priorisieren.
|
||||||
|
- **Rolle:** Systemadministration/IT-Infrastruktur; keine reinen Entwickler-,
|
||||||
|
Vertriebs- oder SAP-only-Stellen (außer sie passen klar zum Profil).
|
||||||
|
- **Quelle (harter Ausschluss):** Headhunter/Personalvermittler, Zeitarbeit/
|
||||||
|
Arbeitnehmerüberlassung und Stepstone werden **verworfen** — siehe
|
||||||
|
„Quellen-Politik". Nur Original-Anzeigen echter Arbeitgeber behalten.
|
||||||
|
- **Dedup (persistent, FIRMEN-basiert):** **Eine Firma darf nur EINMAL gefunden
|
||||||
|
werden** (regional **und** remote gemeinsam). Treffer **verwerfen**, sobald
|
||||||
|
**eines** zutrifft: (a) gleiche/sehr ähnliche `quelle_url`, (b) gleiche
|
||||||
|
`external_id`, oder (c) **die Firma steht schon im Applied-Set** — unabhängig vom
|
||||||
|
Stellentitel. Firmen-Gleichheit über `firma_slug` prüfen: Kandidaten-Firma ebenso
|
||||||
|
sluggen (lowercase, Umlaute→ae/oe/ue/ss, `(m/w/d)` & End-Rechtsform raus, mit `-`
|
||||||
|
verbunden) und als **dieselbe Firma** werten, wenn der Slug **gleich** ist ODER
|
||||||
|
**ein Slug ein führendes Bindestrich-Präfix des anderen** ist (mind. 2 Tokens) —
|
||||||
|
so zählen **verschiedene Schreibweisen/Rechtsform-/Namensvarianten derselben
|
||||||
|
Firma als eine** (z. B. „IT-Problemlöser GmbH" = „IT Problemlöser Verwaltungs-
|
||||||
|
und Handels GmbH"). Offensichtlich derselbe Arbeitgeber → als Dublette verwerfen.
|
||||||
|
Distinkte Firmen mit nur gleichem ersten Wort („Meyer IT" vs. „Meyer Logistik")
|
||||||
|
sind **nicht** dieselbe Firma. Im Zweifel behalten und als „evtl. Dublette"
|
||||||
|
markieren.
|
||||||
|
- **Blacklist (harter Ausschluss):** Kandidat **verwerfen** (nicht listen, nicht
|
||||||
|
importieren), sobald er auf einen Blacklist-Eintrag passt — je nach `typ`:
|
||||||
|
- `url` → normalisierte Kandidaten-URL == `url_norm`.
|
||||||
|
- `domain` → Host der Kandidaten-URL == `domain` (auch Subdomains).
|
||||||
|
- `firma` → **dieselbe Firma** wie der Eintrag (Kandidaten-`firma_slug` gleich
|
||||||
|
`firma_slug` **oder** Präfix-Match wie bei der Dedup; `firma_norm` nur als
|
||||||
|
Fallback für Alt-Einträge ohne Slug).
|
||||||
|
- `firma_stelle` → dieselbe Firma (`firma_slug`, wie oben) **und** `stelle_norm`
|
||||||
|
passen (ist `ort_norm` gesetzt, muss auch der Ort passen).
|
||||||
|
- `auto` → wie die konkreten Felder, die der Eintrag trägt.
|
||||||
|
Diese Muster sind bewusst blockiert (u. a. jeder früher importierte Job, siehe
|
||||||
|
Schritt 6) — geblacklistete Stellen daher **stillschweigend überspringen**,
|
||||||
|
nicht als „evtl. Dublette" präsentieren.
|
||||||
|
- **Relevanz:** höher gewichten, je mehr Buzzwords aus dem Profil passen.
|
||||||
|
|
||||||
|
4. **Existenz prüfen, Link prüfen, Remote-Zusage bestätigen, Kontakt-E-Mail &
|
||||||
|
Firmensitz recherchieren** — für jeden Treffer, der in die engere Wahl kommt,
|
||||||
|
**zwingend einzeln**:
|
||||||
|
- **Existenz-Prüfung (Pflicht):** Jede Stelle mit `WebFetch` auf dem Einzel-Link
|
||||||
|
öffnen und bestätigen, dass die Anzeige **noch aktiv** ist (Firma + Stelle
|
||||||
|
stehen dort, kein 404/„Stelle nicht mehr verfügbar"/Redirect auf Jobliste).
|
||||||
|
Snippets aus der WebSearch reichen **nicht** — Suchindizes zeigen oft schon
|
||||||
|
abgelaufene Anzeigen. Kein Live-Nachweis → Treffer **verwerfen**. Bei bereits
|
||||||
|
importierten Angeboten, die nicht mehr existieren, das JobOffer löschen
|
||||||
|
(`DELETE /joboffers/{id}`).
|
||||||
|
- **100-%-Remote live bestätigen (Pflicht):** Im per `WebFetch` geöffneten
|
||||||
|
Anzeigentext prüfen, dass die Stelle **tatsächlich vollständig remote /
|
||||||
|
deutschlandweit ortsunabhängig** ist (Abschnitte „Arbeitsmodell", „Standort",
|
||||||
|
„Das bieten wir"). Nur „Homeoffice möglich"/„hybrid"/feste Präsenztage → **nicht**
|
||||||
|
100 % remote → **verwerfen**. Nur eindeutig vollständig remote Stellen behalten.
|
||||||
|
- **Vollständige Stellenausschreibung erfassen (Pflicht):** Beim `WebFetch` der
|
||||||
|
Einzelanzeige den **kompletten Ausschreibungstext** übernehmen — nicht nur eine
|
||||||
|
Kurzzusammenfassung. Dazu gehören: Einleitung/Unternehmensvorstellung, **Aufgaben/
|
||||||
|
Tätigkeiten**, **Anforderungen/Profil**, **Wir bieten/Benefits**, Angaben zu
|
||||||
|
Arbeitszeit/Vertragsart/**Remote-Regelung**, Gehalt (falls genannt), Bewerbungsweg/
|
||||||
|
Kontakt und Referenz-/Kennziffer. Text weitgehend **wortgetreu und vollständig**
|
||||||
|
sichern (nur Navigations-/Cookie-/Footer-Boilerplate der Seite weglassen), inkl.
|
||||||
|
der Gliederung/Überschriften. Dieser Volltext wandert in `beschreibung`
|
||||||
|
(Schritt 6). Ist die Seite lang/abgeschnitten, `WebFetch` gezielt erneut aufrufen.
|
||||||
|
- **`quelle_url` verifizieren:** Muss auf die **konkrete, noch aktive
|
||||||
|
Einzelanzeige** zeigen (nicht Trefferliste/Suchseite). Deeplink defekt/Liste →
|
||||||
|
per Suche den echten Einzel-Link finden (bevorzugt Firmenwebsite/Karriereseite),
|
||||||
|
sonst verwerfen.
|
||||||
|
- **Vermittler/Zeitarbeit aussortieren (Pflicht):** Zeigt der Treffer auf eine
|
||||||
|
Personalberatung/Headhunter/Zeitarbeit statt den echten Arbeitgeber, **verwerfen**.
|
||||||
|
Bei Unsicherheit die inserierende Firma per `WebFetch` (Impressum/„Über uns")
|
||||||
|
prüfen — Recruiting-/Personaldienstleister raus. Existiert dieselbe Stelle direkt
|
||||||
|
auf der Firmen-Karriereseite, diese Original-Anzeige verwenden.
|
||||||
|
- **Bewerber-Kontakt-E-Mail recherchieren:** Die E-Mail-Adresse ermitteln, an die
|
||||||
|
sich Bewerber wenden. Reihenfolge: (a) direkt in der Stellenanzeige genannte
|
||||||
|
Bewerbungs-/Kontaktadresse; (b) Karriere-/Kontaktseite der Firma
|
||||||
|
(`WebSearch` „<Firma> Karriere Kontakt Bewerbung E-Mail", `WebFetch` der Seite);
|
||||||
|
(c) allgemeine Bewerbungsadresse (`bewerbung@`/`jobs@`/`karriere@<domain>`) nur,
|
||||||
|
wenn auf der Firmenseite belegt. **Nicht raten** — nur belegte Adressen; sonst
|
||||||
|
`kontakt_email` leer lassen und als „nicht gefunden" markieren.
|
||||||
|
- **Firmensitz recherchieren (Pflicht, soweit belegbar):** Da die Stelle
|
||||||
|
ortsunabhängig ist, gibt es keinen festen Arbeitsort — als `adresse` den
|
||||||
|
**eingetragenen Firmensitz** des Arbeitgebers erfassen (Straße, Hausnummer, PLZ,
|
||||||
|
Ort; Quelle: Impressum/Kontaktseite, `WebSearch` „<Firma> Impressum Adresse",
|
||||||
|
`WebFetch` der Seite). **Nicht raten** — nur eine belegte Anschrift übernehmen;
|
||||||
|
sonst `adresse` leer lassen und als „nicht gefunden" markieren. Der Firmensitz
|
||||||
|
ist reine Zusatzinfo und **kein** Filterkriterium (die Stelle bleibt zulässig,
|
||||||
|
egal wo die Firma sitzt).
|
||||||
|
|
||||||
|
5. **Ergebnis präsentieren** als Tabelle: Firma · Stelle · **Arbeitsmodell (100 %
|
||||||
|
Remote)** · Firmensitz · Quelle (Board) · **verifizierter Link** ·
|
||||||
|
**Kontakt-E-Mail** · kurze Passt-Begründung. Sortierung: neue, klar passende
|
||||||
|
Treffer **nach Relevanz** (Buzzword-Passung; bei Gleichstand klar/eindeutig
|
||||||
|
100-%-Remote vor „remote, aber schwammig formuliert"). Fehlt Link, E-Mail oder
|
||||||
|
Firmensitz, kennzeichnen.
|
||||||
|
|
||||||
|
6. **Importieren** — im **interaktiven** Modus nur nach Rückfrage/Bestätigung des
|
||||||
|
Nutzers; im **autonomen Modus** (siehe unten) automatisch ohne Rückfrage. Jeden
|
||||||
|
neuen Treffer als JobOffer einspielen (Upsert, idempotent) über den
|
||||||
|
`bewerbungs-tracker`-Skill:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers '{
|
||||||
|
"external_id": "remote-<slug(firma)>-<slug(stelle)>",
|
||||||
|
// IMMER setzen und DETERMINISTISCH aus
|
||||||
|
// firma+stelle bilden — NICHT aus der
|
||||||
|
// Board-/Anzeigen-ID. Präfix "remote-" hält
|
||||||
|
// Remote-Funde auseinander; Slug = normalisiert
|
||||||
|
// wie der firma_slug (lowercase, ohne (m/w/d)
|
||||||
|
// & Rechtsform, Interpunktion→"-"), z. B.
|
||||||
|
// "remote-musterfirma-cloud-administrator".
|
||||||
|
// So bekommt DIESELBE Stelle über jeden Lauf
|
||||||
|
// UND jedes Board dasselbe external_id →
|
||||||
|
// Server-Upsert greift zuverlässig statt eine
|
||||||
|
// Dublette anzulegen. (Eine flüchtige Board-ID
|
||||||
|
// würde je Quelle abweichen und Duplikate
|
||||||
|
// erzeugen — daher NICHT nutzen.)
|
||||||
|
"quelle": "stellensuche-remote",
|
||||||
|
"firma": "…", "stelle": "…",
|
||||||
|
"ort": "Remote (Deutschland)", // ortsunabhängig; ggf. "100% Remote / Homeoffice"
|
||||||
|
"adresse": "<eingetragener Firmensitz: Straße Hausnr, PLZ Ort; recherchiert &
|
||||||
|
verifiziert; sonst weglassen>",
|
||||||
|
"gehalt": "…",
|
||||||
|
"beschreibung": "<VOLLSTÄNDIGE Stellenausschreibung>",
|
||||||
|
// Der komplette, in Schritt 4 per WebFetch
|
||||||
|
// erfasste Anzeigentext inkl. Remote-Regelung —
|
||||||
|
// wortgetreu und vollständig, nicht kürzen.
|
||||||
|
// Gliederung/Überschriften erhalten (Markdown ok).
|
||||||
|
"quelle_url": "<verifizierter Einzel-Link, bevorzugt Firmen-Karriereseite>",
|
||||||
|
"art": "Firmenwebsite",
|
||||||
|
"anzeige_datum": "YYYY-MM-DD",
|
||||||
|
"kontakt_email": "<recherchierte Bewerber-E-Mail, sonst weglassen>",
|
||||||
|
"labels": ["Remote-Deutschlandweit", "Homeoffice-Deutschlandweit"]
|
||||||
|
// IMMER beide setzen: dieser Skill sucht ausschließlich
|
||||||
|
// 100% Remote / Homeoffice deutschlandweit → Arbeitsort-
|
||||||
|
// Labels "Remote-Deutschlandweit" + "Homeoffice-Deutschlandweit".
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
`art`-Enum: `E-Mail`, `Online-Portal`, `Indeed`, `StepStone`, `Firmenwebsite`,
|
||||||
|
`Post`, `Initiativbewerbung`, `Arbeitsagentur`, `Sonstiges`. **Standard ist
|
||||||
|
`Firmenwebsite`** (Original-Anzeige des Arbeitgebers). `StepStone` nie verwenden
|
||||||
|
(ausgeschlossene Quelle); `Arbeitsagentur`/`Indeed` nur, wenn der Link direkt zur
|
||||||
|
Arbeitgeber-Anzeige führt.
|
||||||
|
|
||||||
|
**`409`-Antwort beim Import:** Steht der Job (trotz Vorfilter) auf der Blacklist,
|
||||||
|
antwortet `POST /joboffers` mit **409** (`BlacklistConflict`) und nimmt ihn
|
||||||
|
**nicht** auf. Das ist **kein Fehler** — als „übersprungen (Blacklist)" zählen
|
||||||
|
und mit dem nächsten Treffer weitermachen, **nicht** mit `force` erzwingen.
|
||||||
|
|
||||||
|
7. **Direkt blacklisten (nach jedem erfolgreichen Import).** Sobald ein JobOffer
|
||||||
|
angelegt wurde (Antwort **201** / `action:created`; bei `updated` ist der Job
|
||||||
|
bereits erfasst), die **Firma sofort ganz** blacklisten, damit dieser Arbeitgeber
|
||||||
|
in künftigen Läufen — remote **wie regional** — **mit keinem Stellentitel** wieder
|
||||||
|
auftaucht (Regel „eine Firma nur einmal"). Per `typ: firma` sperren — der Server
|
||||||
|
matcht firmenweit über `firma_slug` und deckt so abweichende Schreibweisen/
|
||||||
|
Rechtsformen mit ab:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers/blacklist '{
|
||||||
|
"typ": "firma",
|
||||||
|
"firma": "<firma wie importiert>",
|
||||||
|
"grund": "auto: stellensuche-remote-import <YYYY-MM-DD>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
Server-Antwort **201** = geblacklistet. Normalisierung (Groß/Klein, `(m/w/d)`,
|
||||||
|
Rechtsform, Umlaute) übernimmt der Server. Nur nach **echtem** Neuimport
|
||||||
|
blacklisten — nicht bei einem 409-Skip und nicht bei bloßem `updated`.
|
||||||
|
|
||||||
|
## Autonomer Modus (headless / geplant)
|
||||||
|
|
||||||
|
Wird der Skill **nicht-interaktiv** ausgeführt — d. h. ohne Nutzer, der bestätigen
|
||||||
|
kann (z. B. `claude -p "…" --dangerously-skip-permissions`, Cron/geplanter Lauf,
|
||||||
|
`ollama launch claude … -p …`) — im **autonomen Modus** arbeiten:
|
||||||
|
|
||||||
|
- **Ohne Rückfrage importieren:** Neue, geprüfte Treffer direkt als JobOffer
|
||||||
|
einspielen (Schritt 6) **und anschließend blacklisten** (Schritt 7). Es gibt
|
||||||
|
niemanden zum Bestätigen — nicht auf Eingabe warten.
|
||||||
|
- **Alle Prüfregeln gelten unverändert und strikt:** Existenz-Prüfung, **100-%-Remote
|
||||||
|
live bestätigt**, korrekter verifizierter Einzel-Link, Kontakt-E-Mail nur wenn
|
||||||
|
belegt, **Firmensitz nur wenn belegt**, die persistente Dedup **und der
|
||||||
|
Blacklist-Filter** — importiere **nur** echte, live verifizierte, eindeutig
|
||||||
|
vollständig remote, noch nicht erfasste und **nicht geblacklistete** Stellen.
|
||||||
|
Im Zweifel **nicht** importieren (lieber auslassen).
|
||||||
|
- **Keine Bewerbungen anlegen/versenden**, keine Löschungen bestehender Bewerbungen,
|
||||||
|
keine Generierung anstoßen — nur neue Jobangebote (`POST /joboffers`) einpflegen
|
||||||
|
und den jeweils importierten Job blacklisten (`POST /joboffers/blacklist`).
|
||||||
|
- **Kurzbericht ausgeben** (für das Log): je Treffer `action`
|
||||||
|
(created+blacklisted / updated / skip-dedup / skip-blacklist) mit Firma, Stelle,
|
||||||
|
Arbeitsmodell, Link; am Ende Zähler „X neu importiert & geblacklistet, Y als
|
||||||
|
Dublette/Blacklist übersprungen, Z verworfen (nicht verifizierbar / nicht 100 %
|
||||||
|
remote)".
|
||||||
|
|
||||||
|
## Regeln
|
||||||
|
|
||||||
|
- **Nur 100 % Remote, deutschlandweit.** Es werden **ausschließlich** eindeutig
|
||||||
|
vollständig remote / ortsunabhängige Stellen gelistet/importiert, die aus dem
|
||||||
|
Homeoffice irgendwo in Deutschland ausübbar sind (Anstellung in DE). Reine
|
||||||
|
Präsenz-, Hybrid- (feste Bürotage) und ortsgebundene Stellen werden **verworfen** —
|
||||||
|
Letztere sind Sache des regionalen Skills `it-stellensuche`. Kein Orts-Ranking,
|
||||||
|
Priorität nach Relevanz.
|
||||||
|
- **Labels `Remote-Deutschlandweit` + `Homeoffice-Deutschlandweit` setzen (Pflicht).**
|
||||||
|
Jedes von diesem Skill importierte JobOffer bekommt
|
||||||
|
`labels: ["Remote-Deutschlandweit", "Homeoffice-Deutschlandweit"]` (100 % remote,
|
||||||
|
deutschlandweit). Kein `Regional`-Label — ortsgebundene Stellen sind Sache des
|
||||||
|
Skills `it-stellensuche`.
|
||||||
|
- **Keine erfundenen Stellen.** Nur Jobs ausgeben, die per WebSearch/WebFetch real
|
||||||
|
belegt sind. **`quelle_url` muss der korrekte, geprüfte Direktlink zur
|
||||||
|
Einzelanzeige sein** (per `WebFetch` bestätigt) — keine Such-/Listenseiten, keine
|
||||||
|
geratenen URLs. Kein verifizierbarer Einzel-Link → Treffer nicht listen/importieren.
|
||||||
|
- **100 % Remote live belegt.** Vor dem Listen/Importieren im Anzeigentext bestätigen,
|
||||||
|
dass die Stelle wirklich vollständig remote ist. „Homeoffice möglich"/hybrid/feste
|
||||||
|
Präsenztage genügen **nicht** → verwerfen.
|
||||||
|
- **Nur Original-Anzeigen echter Arbeitgeber.** Headhunter/Personalvermittler,
|
||||||
|
Zeitarbeit/Arbeitnehmerüberlassung und **Stepstone** sind ausgeschlossen — weder
|
||||||
|
listen noch importieren (siehe „Quellen-Politik"). Bevorzugt der Firmen-Direktlink.
|
||||||
|
- **`beschreibung` = vollständige Ausschreibung.** Immer den kompletten, per WebFetch
|
||||||
|
erfassten Anzeigentext (Aufgaben, Anforderungen, Benefits, Remote-Regelung,
|
||||||
|
Bewerbungsweg, Kennziffer) übernehmen — nicht kürzen oder zusammenfassen.
|
||||||
|
- **Kontakt-E-Mail nicht erfinden.** `kontakt_email` nur setzen, wenn die Adresse in
|
||||||
|
der Anzeige oder auf der Firmen-Karriereseite belegt ist; sonst weglassen.
|
||||||
|
- **Firmensitz recherchieren, nicht erfinden.** `adresse` = belegter Firmensitz aus
|
||||||
|
dem Impressum; sonst leer lassen. Kein Filterkriterium — nur Zusatzinfo.
|
||||||
|
- **Keine Doppelfunde — eine Firma nur EINMAL, auch über Läufe und Skills hinweg.**
|
||||||
|
Vor dem Listen/Importieren immer `applied-set.sh` **und** `blacklist-set.sh` laden
|
||||||
|
und jeden Kandidaten per `quelle_url`, `external_id` **und `firma_slug`
|
||||||
|
(firmenweit)** sowie gegen die Blacklist abgleichen. Dedup/Blacklist sind mit dem
|
||||||
|
regionalen Skill **gemeinsam**. **Steht die Firma schon im Applied-Set oder auf der
|
||||||
|
Blacklist — egal unter welchem Stellentitel und egal in welcher Schreibweise/
|
||||||
|
Rechtsform (`firma_slug` gleich ODER Präfix-Match, mind. 2 Tokens) — wird kein
|
||||||
|
weiterer Treffer dieses Arbeitgebers ausgegeben oder importiert.** Auch **innerhalb
|
||||||
|
eines Laufs** jede Firma nur **einmal** präsentieren. Beim Import die `external_id`
|
||||||
|
**deterministisch aus `firma+stelle`** bilden (Präfix `remote-`), **nicht** aus
|
||||||
|
einer Board-/Anzeigen-ID — so ergibt dieselbe Stelle über jeden Lauf/jedes Board
|
||||||
|
dasselbe `external_id` (Server-Upsert statt Dublette). Der Server dedupliziert
|
||||||
|
`POST /joboffers` über `external_id` (Upsert) und die Blacklist (409, firmenweit
|
||||||
|
über `firma_slug`).
|
||||||
|
- **Blacklisten nach jedem Import (Pflicht, firmenweit).** Jeder frisch angelegte Job
|
||||||
|
(`action:created`) wird direkt anschließend per `POST /joboffers/blacklist`
|
||||||
|
(**`typ:firma`** — die ganze Firma) gesperrt (Schritt 7), damit dieser Arbeitgeber
|
||||||
|
in Folgeläufen mit keinem Titel wieder auftaucht. Nicht blacklisten bei `updated`
|
||||||
|
oder 409-Skip. Ein 409 beim `POST /joboffers` bedeutet „steht schon auf der
|
||||||
|
Blacklist" → überspringen, nie mit `force` erzwingen.
|
||||||
|
- **Interaktiv: nicht ungefragt importieren** — erst zeigen, dann auf Bestätigung
|
||||||
|
importieren. **Autonom/headless: automatisch importieren** (siehe „Autonomer
|
||||||
|
Modus"). In **keinem** Modus Bewerbungen anlegen/versenden.
|
||||||
|
- Board-Aggregatoren-Duplikate (dieselbe Stelle auf mehreren Portalen) zu einem
|
||||||
|
Eintrag zusammenfassen, bevorzugt mit Direkt-/Firmenwebsite-Link.
|
||||||
|
- Ist die Bewerbungs-Tracker-API nicht erreichbar (`GET /health`), Dedup nicht
|
||||||
|
möglich → Nutzer warnen und Treffer ohne Dedup mit Hinweis liefern.
|
||||||
|
|
||||||
|
## Abhängigkeiten
|
||||||
|
|
||||||
|
- Tools: `WebSearch`, `WebFetch`.
|
||||||
|
- Skill `bewerbungs-tracker` (für Dedup-Daten, JobOffer-Import und Blacklist:
|
||||||
|
`GET/POST /joboffers/blacklist`).
|
||||||
|
- Scripts: `scripts/arbeitsagentur.sh` (strukturierte Jobsuche-API der Bundesagentur
|
||||||
|
für Arbeit, `search`/`detail` — hier mit `arbeitszeit=ho`), `scripts/applied-set.sh`
|
||||||
|
(erfasste Firmen), `scripts/blacklist-set.sh` (gesperrte Muster) — allesamt
|
||||||
|
**Symlinks** auf die geteilten Scripts des Skills `it-stellensuche` (eine Quelle der
|
||||||
|
Wahrheit). `applied-set`/`blacklist-set` nutzen den `bt.sh`-Helfer des
|
||||||
|
`bewerbungs-tracker`-Skills; `arbeitsagentur.sh` nur `curl` + `python3`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../it-stellensuche/scripts/applied-set.sh
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../it-stellensuche/scripts/arbeitsagentur.sh
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../it-stellensuche/scripts/blacklist-set.sh
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
---
|
||||||
|
name: it-stellensuche
|
||||||
|
description: Search the web for NEW jobs matching the user's own search profile (cities / remote mode, taken from the Bewerbungs-Tracker) that they have NOT applied to yet, then present them and optionally import them as JobOffers into the Bewerbungs-Tracker. Use whenever the user wants to find new job postings, "Stellensuche", "neue Stellen suchen", "Jobs finden", "Systemadministrator-Stellen", or refresh open job leads. Deduplicates against existing Bewerbungen and Jobangebote via the bewerbungs-tracker skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Stellensuche (Suchprofil des jeweiligen Benutzers)
|
||||||
|
|
||||||
|
Findet **neue** Stellen im Web — **in den Städten bzw. im Modus, die das Suchprofil des
|
||||||
|
Benutzers vorgibt** —, filtert alle raus, für die sich der Nutzer schon beworben hat
|
||||||
|
**oder die auf der Blacklist stehen**, und spielt sie auf Wunsch als JobOffers in den
|
||||||
|
Bewerbungs-Tracker ein.
|
||||||
|
|
||||||
|
> **Multi-User (WICHTIG):** Der Tracker ist eine Plattform mit mehreren Benutzern; jeder
|
||||||
|
> hat **sein eigenes Suchprofil und seinen eigenen API-Key**. Region, Modus und
|
||||||
|
> Ausschlüsse stehen **nicht mehr in diesem Skill**, sondern kommen von außen:
|
||||||
|
>
|
||||||
|
> 1. **Aus dem Prompt**, wenn er sie nennt (so ruft der Jobsuche-Runner den Skill auf —
|
||||||
|
> er baut den Prompt aus dem Suchprofil des Benutzers). Der Prompt hat immer Vorrang.
|
||||||
|
> 2. **Sonst aus dem Tracker:** `GET /templates` (typ `Lebenslauf`) liefert Rolle und
|
||||||
|
> Technologien des Benutzers; nach Orten/Modus fragen, wenn der Prompt nichts sagt.
|
||||||
|
>
|
||||||
|
> Die Beispiele weiter unten (Rollen, Buzzwords, Städte) sind **nur Muster** — niemals
|
||||||
|
> ungefragt die Städte oder das Profil eines anderen Benutzers verwenden. Der API-Key in
|
||||||
|
> `BEWERBUNG_API_KEY` bestimmt, in wessen Konto importiert wird.
|
||||||
|
|
||||||
|
> **Blacklist-Prinzip (zentral):** Jeder erfolgreich importierte Job wird
|
||||||
|
> **unmittelbar danach geblacklistet**. So taucht dieselbe Stelle in späteren
|
||||||
|
> Suchläufen nie wieder auf — die Dublett-Vermeidung bleibt bestehen, selbst wenn
|
||||||
|
> das JobOffer später gelöscht oder in eine Bewerbung überführt wird. Umgekehrt
|
||||||
|
> werden Kandidaten, die schon auf der Blacklist stehen, gar nicht erst gelistet
|
||||||
|
> oder importiert.
|
||||||
|
|
||||||
|
## Suchprofil
|
||||||
|
|
||||||
|
**Quelle des Profils (in dieser Reihenfolge):**
|
||||||
|
|
||||||
|
1. **Der Prompt** — der Jobsuche-Runner hängt den Lebenslauf/das Kurzprofil des Benutzers
|
||||||
|
direkt an („--- PROFIL --- … --- ENDE PROFIL ---"). Rollen und Buzzwords **daraus**
|
||||||
|
ableiten.
|
||||||
|
2. **Der Tracker** — sonst `GET /templates` (typ `Lebenslauf`, sonst `Profil/Kurzprofil`)
|
||||||
|
des Benutzers, dem der `BEWERBUNG_API_KEY` gehört.
|
||||||
|
3. Erst wenn beides fehlt: aus den bereits erfassten Bewerbungen (`GET /applications`)
|
||||||
|
ableiten oder nachfragen.
|
||||||
|
|
||||||
|
**Rolle:** breit fassen — dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln.
|
||||||
|
Passende Bezeichnungen als Query-Varianten und für die Relevanzbewertung nutzen.
|
||||||
|
|
||||||
|
> Die folgende Liste ist ein **Beispiel für ein IT-Infrastruktur-Profil** — sie zeigt, wie
|
||||||
|
> breit zu fassen ist, ist aber **kein Default**. Für einen Benutzer mit anderem Beruf die
|
||||||
|
> Titel entsprechend aus **seinem** Lebenslauf bilden.
|
||||||
|
|
||||||
|
- **Kern (Beispiel: 2nd-Level-Sysadmin + IT-Consultant):**
|
||||||
|
Systemadministrator, IT-Administrator, IT-Systemadministrator, IT-Systemadministration,
|
||||||
|
IT-Systemadministrator 2nd Level, 2nd-Level-Administrator, System-Administrator (m/w/d),
|
||||||
|
Fachinformatiker Systemintegration, Administrator (m/w/d).
|
||||||
|
- **Support/Betrieb:** 1st/2nd/3rd-Level-Support, 2nd-Level-Support, IT-Support,
|
||||||
|
IT-Supporter, Support-Techniker, Helpdesk-Mitarbeiter, IT-Techniker,
|
||||||
|
IT-Systemtechniker, Systemtechniker, IT-Systemelektroniker, Servicetechniker IT,
|
||||||
|
Onsite-Techniker, Field-Service-Techniker, IT-Systembetreuer, IT-Betreuer,
|
||||||
|
IT-Allrounder, IT-Koordinator, IT-Mitarbeiter, IT-Operations / IT-Betrieb,
|
||||||
|
IT-Administrator EDV, EDV-Administrator, EDV-Betreuer.
|
||||||
|
- **Infrastruktur/Netz:** System Engineer, Systems Engineer, Infrastructure Engineer,
|
||||||
|
IT-Infrastruktur, Infrastruktur-Administrator, Netzwerkadministrator, Network Engineer,
|
||||||
|
Virtualisierungsadministrator, Storage-/Backup-Administrator, Rechenzentrums-Techniker,
|
||||||
|
Datacenter-Techniker.
|
||||||
|
- **Cloud/Hosting/Modern Workplace (passt zu Hetzner/OVH im CV):** Cloud-Administrator,
|
||||||
|
Cloud Engineer, Cloud-Operations-Engineer, Hosting-Administrator, Hosting Engineer,
|
||||||
|
Microsoft-365-Administrator, M365-/Modern-Workplace-Administrator,
|
||||||
|
Azure-/Entra-Administrator, DevOps-Engineer (Infrastruktur-lastig),
|
||||||
|
Linux-Administrator, Linux System Engineer, Windows-Administrator.
|
||||||
|
- **Beratung/Leitung (klein) — deckt die CV-Rolle „IT-Consultant" ab:** IT-Consultant,
|
||||||
|
IT-Berater (technisch, hands-on), IT-Systemberater, IT-Kundenberater (technisch),
|
||||||
|
IT-Teamleiter/IT-Leiter in kleinen Firmen (nur wenn hands-on).
|
||||||
|
|
||||||
|
Ausschließen bleiben reine Softwareentwickler-, Data-Science-, Vertriebs- und
|
||||||
|
SAP-only-Stellen, außer sie passen klar zum Infrastruktur-Profil.
|
||||||
|
|
||||||
|
> **Ex-Arbeitgeber IMMER ausschließen (harte Regel, unabhängig von der Schreibweise):**
|
||||||
|
> Stellen der **„IT-Problemlöser"** (Essen) — voller Firmenname u. a.
|
||||||
|
> **„IT Problemlöser Verwaltungs- und Handels GmbH"**, auch **„IT-Problemlöser GmbH"**,
|
||||||
|
> **„IT Problemlöser"** o. Ä. — werden **nie** gelistet oder importiert. Es ist der
|
||||||
|
> frühere Arbeitgeber des Nutzers (siehe Lebenslauf). Diese Firma taucht immer wieder
|
||||||
|
> mit **wechselnden Stellentiteln** auf; die `firma_stelle`-Blacklist greift dann nicht,
|
||||||
|
> weil der Titel abweicht. Deshalb **jeden Treffer verwerfen, dessen Firmenname `IT
|
||||||
|
> Problemlöser` / `IT-Problemlöser` (in jeder Rechtsform-/Schreibvariante) enthält** —
|
||||||
|
> ohne auf die Blacklist zu warten. (Firmenweite `firma`-Blacklist-Einträge greifen
|
||||||
|
> inzwischen schreibweisen-robust über `firma_slug` inkl. Präfix-Match — diese
|
||||||
|
> Modell-Regel bleibt trotzdem als zusätzliche Sicherung bestehen.)
|
||||||
|
|
||||||
|
**Region: kommt aus dem Suchprofil des Benutzers, nicht aus diesem Skill.**
|
||||||
|
|
||||||
|
Der Prompt nennt entweder eine **Städteliste** (regionaler Modus) oder **100 % Remote /
|
||||||
|
deutschlandweit** — oder beides. Es gilt strikt:
|
||||||
|
|
||||||
|
- **Regionaler Modus:** **Nur** Stellen mit Arbeitsort in einer der **genannten** Städte.
|
||||||
|
Die **erste genannte Stadt ist der Wohnort** und wird am höchsten priorisiert, danach
|
||||||
|
in der angegebenen Reihenfolge. Orte **außerhalb** dieser Liste werden **verworfen** —
|
||||||
|
auch das direkte Umland, und **auch reine Homeoffice-/Remote-Stellen ohne Sitz in einer
|
||||||
|
der genannten Städte**. Sitzt der Arbeitgeber in einer der Städte und bietet zusätzlich
|
||||||
|
Homeoffice, ist die Stelle zulässig.
|
||||||
|
- **Remote-Modus:** Nur Stellen, die **zu 100 % im Homeoffice** ausübbar sind
|
||||||
|
(deutschlandweit, Firmensitz egal). Präsenz, Hybrid mit festen Bürotagen und vages
|
||||||
|
„Homeoffice möglich" werden verworfen; die 100-%-Zusage live im Anzeigentext bestätigen.
|
||||||
|
|
||||||
|
Nennt der Prompt keine Region, **nachfragen** statt zu raten — niemals auf eine im Skill
|
||||||
|
hinterlegte Städteliste zurückfallen (die gibt es bewusst nicht mehr).
|
||||||
|
|
||||||
|
**Skills/Buzzwords** (für Query-Varianten & Relevanzbewertung): Die **Kernbegriffe kommen
|
||||||
|
aus dem Lebenslauf des jeweiligen Benutzers** (höchste Gewichtung); ergänze naheliegende,
|
||||||
|
dazu passende Technologien — als Suchbegriffe und zum Erkennen passender Anzeigen, auch
|
||||||
|
wenn sie nicht wörtlich im CV stehen.
|
||||||
|
|
||||||
|
> Auch die folgende Liste ist ein **Beispiel** (IT-Infrastruktur). Für andere Profile die
|
||||||
|
> Kernbegriffe aus **deren** Lebenslauf ziehen, nicht von hier.
|
||||||
|
|
||||||
|
- **Kern (Beispiel, aus einem Infrastruktur-CV):** Windows Server, Active Directory,
|
||||||
|
Exchange, Microsoft 365 / MS365, Linux-Administration (Debian/Ubuntu),
|
||||||
|
Docker/Container, Proxmox/Virtualisierung, Netzwerk (OPNsense, pfSense,
|
||||||
|
VPN: WireGuard/OpenVPN/IPsec, LAN/WAN, TCP/IP), Firewall, Monitoring
|
||||||
|
(Prometheus, Grafana, Beszel, Uptime Kuma), Hetzner/OVH Cloud, Self-Hosting,
|
||||||
|
Automatisierung, AI/Claude/Agent-Workflows.
|
||||||
|
- **Microsoft-/Windows-Umfeld:** Entra ID / Azure AD, Intune, Endpoint Manager,
|
||||||
|
Group Policy / GPO, WSUS, PowerShell, Hyper-V, SharePoint, Teams, Windows 10/11,
|
||||||
|
Client-Management, MDM.
|
||||||
|
- **Virtualisierung/Server:** VMware vSphere / ESXi, Hyper-V, Citrix, KVM,
|
||||||
|
Terminalserver / RDS, Server-Hardware (Dell/HPE/Lenovo).
|
||||||
|
- **Backup/Storage:** Veeam, Backup & Recovery, Datensicherung, NAS/SAN, TrueNAS,
|
||||||
|
ZFS, Ceph, Synology, QNAP.
|
||||||
|
- **Netzwerk/Security:** VLAN, Routing & Switching, Cisco, Fortinet/FortiGate,
|
||||||
|
Sophos, Ubiquiti/UniFi, DNS/DHCP, Reverse Proxy (Nginx, Traefik), Let's Encrypt,
|
||||||
|
IT-Security, Patch-Management, ISO 27001, IT-Grundschutz.
|
||||||
|
- **Cloud/DevOps/Automatisierung:** Microsoft Azure, AWS, Kubernetes, Ansible,
|
||||||
|
Terraform, GitLab / CI/CD, Bash-/Shell-Scripting, Portainer, Nextcloud.
|
||||||
|
- **Datenbanken/Web:** MySQL/MariaDB, PostgreSQL, MS SQL Server, Apache, Nginx, IIS.
|
||||||
|
- **Betrieb/Organisation:** Ticketsystem (Jira, OTRS, Zammad), Helpdesk,
|
||||||
|
Rechenzentrum, On-Premise, Managed Services, IT-Dienstleister, Systemhaus.
|
||||||
|
|
||||||
|
> Aktuelles Profil (Titel, Skills, Ort) steht in der Lebenslauf-Vorlage der API:
|
||||||
|
> `bewerbungs-tracker` → `GET /templates` (typ `Lebenslauf`). Bei Bedarf dort
|
||||||
|
> gegenlesen, statt zu raten.
|
||||||
|
|
||||||
|
## Quellen-Politik (WICHTIG)
|
||||||
|
|
||||||
|
Ziel ist **immer die Original-Stellenausschreibung direkt beim echten Arbeitgeber**
|
||||||
|
— bevorzugt auf dessen **Firmen-/Karriereseite**.
|
||||||
|
|
||||||
|
- **Ausschließen (nicht listen, nicht importieren):**
|
||||||
|
- **Headhunter / Personalvermittler / Personalberatungen** (recruiten für Dritte).
|
||||||
|
- **Zeitarbeit / Arbeitnehmerüberlassung / Personaldienstleister** (z. B. Randstad,
|
||||||
|
Hays, GULP, Amadeus FiRe, Adecco, Manpower, Piening, Tempton, DIS AG, Robert Half
|
||||||
|
u. ä. — auch unbekannte, wenn die Anzeige „Arbeitnehmerüberlassung",
|
||||||
|
„im Kundenauftrag", „für unseren Kunden", „Personaldienstleister" o. Ä. nennt).
|
||||||
|
- **Stepstone** als Quelle — nicht als Board nutzen, keine `site:stepstone.de`-Query,
|
||||||
|
keine Stepstone-Links importieren.
|
||||||
|
- **Erkennungsmerkmale eines Vermittlers/Zeitarbeit** (bei Unsicherheit die
|
||||||
|
Firmen-/Impressumsseite per `WebFetch` prüfen): Formulierungen wie „für unseren
|
||||||
|
Kunden", „im Auftrag unseres Mandanten", „Arbeitnehmerüberlassung", „Direktvermittlung",
|
||||||
|
„Personaldienstleistung", „Recruiting-Partner"; oder die inserierende Firma ist erkennbar
|
||||||
|
eine Personal-/Recruiting-Agentur. → **verwerfen**.
|
||||||
|
- **Zulässig als Quelle**, wenn es zur Original-Anzeige des Arbeitgebers führt:
|
||||||
|
Firmen-Karriereseite (bevorzugt), Arbeitsagentur/Bundesagentur für Arbeit, Indeed
|
||||||
|
oder LinkedIn **nur** wenn die Anzeige eindeutig vom echten Arbeitgeber (nicht von
|
||||||
|
einem Vermittler) stammt. Führt ein Board-Treffer zu einer Firma, deren eigene
|
||||||
|
Karriereseite dieselbe Stelle direkt listet, **immer den Firmen-Direktlink** nehmen.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Bereits erfasste Jobs + Blacklist laden** (Dedup-Basis). Dieser Schritt ist
|
||||||
|
**immer zuerst** auszuführen, damit Stellen aus früheren Suchläufen **nicht
|
||||||
|
erneut** gefunden/importiert werden. Zwei Quellen:
|
||||||
|
```bash
|
||||||
|
scripts/applied-set.sh # firma_slug<TAB>firma<TAB>stelle<TAB>ort<TAB>quelle_url<TAB>external_id
|
||||||
|
scripts/blacklist-set.sh # id<TAB>typ<TAB>firma<TAB>stelle<TAB>ort<TAB>domain<TAB>url_norm<TAB>firma_norm<TAB>firma_slug<TAB>stelle_norm<TAB>ort_norm<TAB>grund
|
||||||
|
```
|
||||||
|
- **applied-set** (Bewerbungen **und** importierte Jobangebote): **eine Zeile
|
||||||
|
pro bereits erfasster Firma** (company-level, nicht mehr pro Stelle). Je Zeile
|
||||||
|
drei Dublett-Merkmale merken: **`firma_slug`** (robuster Firmen-Schlüssel:
|
||||||
|
lowercase, Umlaute→ae/oe/ue/ss, `(m/w/d)` raus, End-Rechtsform wie gmbh/ag/kg
|
||||||
|
entfernt, mit `-` verbunden), **`quelle_url`** und **`external_id`**.
|
||||||
|
- **blacklist-set** (gesperrte Muster): je Zeile den **`typ`** und die dazu
|
||||||
|
passenden Felder merken (`url_norm`, `domain`, `firma_norm`, **`firma_slug`**,
|
||||||
|
`stelle_norm`). Die `*_norm`/`firma_slug`-Felder liefert der Server bereits
|
||||||
|
normalisiert (lowercase, Tracking-Parameter entfernt, `(m/w/d)`/Rechtsform
|
||||||
|
bereinigt).
|
||||||
|
|
||||||
|
2. **Kandidaten sammeln — strukturierte Quelle zuerst, dann WebSearch.**
|
||||||
|
|
||||||
|
**(a) Arbeitsagentur-API (primär — frisch, hohe Trefferzahl, strukturiert).** Vor
|
||||||
|
der Freitext-Suche die Jobsuche-API der Bundesagentur für Arbeit abfragen. Sie
|
||||||
|
liefert frische, deduplizierbare Treffer mit Firma, Ort, Datum und stabiler
|
||||||
|
`refnr` — genau das, was reine WebSearch nicht zuverlässig hergibt. Pro Rolle einen
|
||||||
|
Umkreis-Query:
|
||||||
|
```bash
|
||||||
|
scripts/arbeitsagentur.sh search "<Rolle>" "<Stadt>" 15 30 50
|
||||||
|
# -> firma_slug<TAB>firma<TAB>titel<TAB>ort<TAB>datum<TAB>refnr<TAB>angebotsart
|
||||||
|
```
|
||||||
|
Rollen aus dem Suchprofil durchrotieren (Systemadministrator, IT-Administrator,
|
||||||
|
Fachinformatiker Systemintegration, IT-Support, IT-Techniker, System Engineer,
|
||||||
|
Netzwerkadministrator, …). Städte: **Gladbeck** deckt per Umkreis 15–20 km
|
||||||
|
Bottrop/Gelsenkirchen/Essen/Oberhausen weitgehend mit ab; **Dorsten** und **Marl**
|
||||||
|
je separat (`wo=Dorsten`, `wo=Marl`, Umkreis ~10). `tage` (Default 30) steuert die
|
||||||
|
Frische. Achtung: der Umkreis zieht auch **Nachbarorte außerhalb der sieben
|
||||||
|
Städte** rein (z. B. Haltern, Recklinghausen, Herten) — die **harte Regionsregel**
|
||||||
|
(nur die sieben Städte, Schritt 3) über die `ort`-Spalte anwenden und Fremdorte
|
||||||
|
**verwerfen**. Jede verbleibende Zeile **sofort per `firma_slug`** gegen
|
||||||
|
applied-set/blacklist prüfen (Firmen-Dedup, Schritt 1) — bereits erfasste/gesperrte
|
||||||
|
Firmen raus. Für
|
||||||
|
**überlebende** Treffer die Details holen:
|
||||||
|
```bash
|
||||||
|
scripts/arbeitsagentur.sh detail <refnr>
|
||||||
|
```
|
||||||
|
Detail liefert **Volltext** (→ `beschreibung`), **`EXTERNE_URL`** (Original-Link des
|
||||||
|
Arbeitgebers → bevorzugt `quelle_url`), **Adresse**, **Vergütung** und die Flags
|
||||||
|
**`ZEITARBEIT_AUE` / `PRIVATE_ARBEITSVERMITTLUNG`** — steht dort `True`, ist es
|
||||||
|
Zeitarbeit/Vermittler → **verwerfen** (ohne WebFetch, spart einen Schritt). Meldet
|
||||||
|
das Detail „keine aktive Anzeige", ist sie abgelaufen → **verwerfen**. Die `refnr`
|
||||||
|
ist stabil → als Existenz-Nachweis und für die `external_id` nutzbar. Führt
|
||||||
|
`EXTERNE_URL` nur auf ein Board (nicht den Arbeitgeber), die Firmen-Karriereseite
|
||||||
|
suchen und deren Direktlink bevorzugen (Quellen-Politik gilt unverändert).
|
||||||
|
|
||||||
|
**(b) WebSearch (ergänzend — für alles, was nicht in der API steht).** Mit dem
|
||||||
|
`WebSearch`-Tool **viele** Queries fahren — Rolle × Ort für **jede der sieben
|
||||||
|
Städte**, mit Fokus auf **Original-Anzeigen der Arbeitgeber**. Nur diese sieben
|
||||||
|
Orte abfragen, keine weiteren Revierstädte.
|
||||||
|
|
||||||
|
> **Query-Strategie (Recall maximieren — WICHTIG, findet die Suche „kaum noch
|
||||||
|
> Stellen"):** Lieber **viele einfache, natürlichsprachliche Einzel-Queries** als
|
||||||
|
> wenige überladene. **Boolesche Operatoren sparsam einsetzen** — `OR`, `site:`,
|
||||||
|
> `inurl:` und `-minus`-Ausschlüsse **senken bei WebSearch oft drastisch die
|
||||||
|
> Trefferzahl**. Statt eine `A OR B OR C … -stepstone -zeitarbeit`-Monsterquery
|
||||||
|
> lieber **getrennte, kurze Queries** absetzen und Vermittler/Stepstone erst
|
||||||
|
> **nachträglich beim Filtern** (Schritt 3) rauswerfen. Pro Stadt **mehrere
|
||||||
|
> Formulierungen** durchvariieren:
|
||||||
|
> **Rolle × Ort × Suffix**, mit den Suffixen `Stellenangebot`, `Job`, `Jobs`,
|
||||||
|
> `Stelle`, `Vollzeit`, `Festanstellung`, `unbefristet`, `gesucht`, `m/w/d`, `2026`
|
||||||
|
> und **ganz ohne Suffix** (nur `Rolle Ort`). Zusätzlich **Buzzword × Ort**
|
||||||
|
> (s. u.). So deckt jede Stadt 10–15 verschiedene Queries ab statt 1–2.
|
||||||
|
|
||||||
|
**Rolle × Ort (je Stadt mehrere Suffixe durchspielen), Beispiele:**
|
||||||
|
- `IT-Systemadministrator Gladbeck Stellenangebot`, `Systemadministrator Gladbeck`,
|
||||||
|
`IT-Administrator Gladbeck Vollzeit`, `IT Systemadministrator Gladbeck m/w/d`
|
||||||
|
- `Systemadministrator Essen Jobs`, `2nd Level Support Essen`,
|
||||||
|
`IT-Administrator Essen Festanstellung`, `IT Systemadministrator Essen gesucht`
|
||||||
|
- `Fachinformatiker Systemintegration Oberhausen`, `IT-Administrator Oberhausen Stelle`,
|
||||||
|
`Systemadministrator Oberhausen unbefristet`
|
||||||
|
- `IT Administrator Gelsenkirchen Stellenangebot`, `Systemadministrator Gelsenkirchen`,
|
||||||
|
`IT-Systemadministrator Gelsenkirchen 2026`, `IT-Support Gelsenkirchen`
|
||||||
|
- `Systemadministrator Bottrop Jobs`, `IT-Administrator Bottrop`,
|
||||||
|
`IT Systemadministrator Bottrop Vollzeit`, `EDV-Administrator Bottrop`
|
||||||
|
- `IT-Administrator Dorsten Stellenangebot`, `Systemadministrator Dorsten`,
|
||||||
|
`IT-Techniker Dorsten Job`
|
||||||
|
- `Systemadministrator Marl Stellenangebot`, `IT-Administrator Marl`,
|
||||||
|
`IT Systemadministrator Marl Vollzeit`, `IT-Techniker Marl Job`
|
||||||
|
- Weitere Titel je Stadt durchrotieren: `System Engineer <Ort>`,
|
||||||
|
`IT-Systembetreuer <Ort>`, `Cloud Administrator <Ort>`, `Linux Administrator <Ort>`,
|
||||||
|
`Netzwerkadministrator <Ort>`, `IT-Consultant <Ort>`, `IT-Support <Ort>`.
|
||||||
|
- **Buzzword × Ort** (holt Anzeigen, die im Titel anders heißen, inhaltlich aber
|
||||||
|
passen — s. Skills/Buzzwords oben): `Active Directory <Ort> Stellenangebot`,
|
||||||
|
`Microsoft 365 Administrator <Ort>`, `VMware <Ort> IT Job`, `Proxmox <Ort>`,
|
||||||
|
`Hyper-V Administrator <Ort>`, `Veeam <Ort> IT`, `PowerShell Administrator <Ort>`.
|
||||||
|
- **Deutsche Jobbörsen zusätzlich anzapfen** (breitere Abdeckung, jeweils als
|
||||||
|
eigene, einfache Query — nicht stapeln): `<Rolle> <Ort> meinestadt`,
|
||||||
|
`<Rolle> <Ort> kimeta`, `<Rolle> <Ort> yourfirm`, `<Rolle> <Ort> arbeitsagentur`,
|
||||||
|
`<Rolle> <Ort> indeed`. Falls doch ein `site:`-Filter, dann **einzeln**:
|
||||||
|
`site:arbeitsagentur.de Systemadministrator <Ort>`, `site:de.indeed.com
|
||||||
|
IT-Administrator <Ort>`, `site:de.linkedin.com/jobs Systemadministrator <Ort>`.
|
||||||
|
**Kein** `site:stepstone.de`. Von Board-Treffern stets zur Original-Anzeige des
|
||||||
|
Arbeitgebers durchklicken.
|
||||||
|
- **Firmen-Karriereseiten gezielt** (bevorzugt): `IT-Administrator Essen Karriere`,
|
||||||
|
`Systemadministrator Gelsenkirchen Karriere Stellenangebot`,
|
||||||
|
`IT Jobs <Ort> Karriere` — Vermittler/Stepstone später beim Filtern aussortieren,
|
||||||
|
nicht per `-minus` in der Query.
|
||||||
|
|
||||||
|
Für Details/Ort/Firma einer Trefferseite `WebFetch` nutzen. Sieht ein Treffer nach
|
||||||
|
Firmen-Karriereseite aus: dort direkt nach der Einzelanzeige suchen.
|
||||||
|
|
||||||
|
2b. **Firmen zuerst finden, dann deren Karriereseite prüfen** (Hauptquelle für
|
||||||
|
„unentdeckte" Stellen). Board-Suchen finden nur, was breit ausgeschrieben ist —
|
||||||
|
viele Arbeitgeber posten IT-Stellen **ausschließlich auf der eigenen Website**.
|
||||||
|
Deshalb parallel zur Rolle-×-Ort-Suche **lokale Arbeitgeber in den sieben Städten
|
||||||
|
identifizieren** und deren Karriereseite direkt öffnen:
|
||||||
|
- **Arbeitgeber recherchieren** je Stadt — Firmen, die typischerweise interne IT
|
||||||
|
oder IT-Dienstleistung haben: Systemhäuser/IT-Dienstleister, Industrie &
|
||||||
|
Mittelstand, Chemie/Logistik (Essen, Oberhausen, Gelsenkirchen), Stadtwerke &
|
||||||
|
kommunale Betriebe, Krankenhäuser/Kliniken, Hochschulen, Versicherungen/Banken,
|
||||||
|
größere Handels-/Handwerksbetriebe. Beispiel-Queries:
|
||||||
|
`größte Arbeitgeber Essen IT`, `Systemhaus Gelsenkirchen`,
|
||||||
|
`IT-Dienstleister Oberhausen`, `Unternehmen Gladbeck IT-Abteilung`,
|
||||||
|
`Stadtwerke Bottrop Karriere`, `Klinikum Dorsten Stellenangebote IT`,
|
||||||
|
`Chemiepark Marl IT`, `Stadtwerke Marl Karriere`.
|
||||||
|
- **Karriereseite direkt anfahren:** zu jeder gefundenen Firma
|
||||||
|
`WebSearch "<Firma> Karriere"` bzw. `"<Firma> Stellenangebote"` und die
|
||||||
|
Jobliste per `WebFetch` öffnen; auf offene **IT-/Administrator-/Support-Stellen**
|
||||||
|
am jeweiligen Standort prüfen. Auch `inurl:karriere`, `inurl:jobs`,
|
||||||
|
`inurl:stellen` gezielt einsetzen und gängige Bewerber-Portale erkennen
|
||||||
|
(z. B. `/karriere`, `jobs.<firma>.de`, softgarden/onlyfy/Personio/Workday-Seiten).
|
||||||
|
- **Buzzwords als Türöffner:** findet die reine Titel-Suche wenig, mit den
|
||||||
|
Technologie-Buzzwords (oben) über die Stadt suchen, z. B.
|
||||||
|
`VMware Essen Stellenangebot`, `Active Directory Gelsenkirchen Job`,
|
||||||
|
`Microsoft 365 Administrator Oberhausen`, `Linux Bottrop IT` — so tauchen
|
||||||
|
Anzeigen auf, die im Titel anders heißen, inhaltlich aber passen.
|
||||||
|
- Jede so gefundene Stelle läuft durch dieselben Prüf-/Filter-/Dedup-Schritte
|
||||||
|
(3–7). Der Firmen-Direktlink ist hier ohnehin schon die bevorzugte `quelle_url`.
|
||||||
|
|
||||||
|
3. **Filtern & bewerten:**
|
||||||
|
- **Region (harter Ausschluss):** **nur** die sieben Städte Gladbeck, Essen,
|
||||||
|
Gelsenkirchen, Oberhausen, Bottrop, Dorsten, Marl. Jede Stelle mit Arbeitsort außerhalb
|
||||||
|
dieser sieben Städte **verwerfen** — auch übrige Revierstädte und reines
|
||||||
|
Homeoffice/Remote ohne Sitz in einer der sieben Städte. **Nach Nähe zu Gladbeck
|
||||||
|
priorisieren** (Gladbeck > Bottrop/Gelsenkirchen > Essen/Oberhausen > Dorsten/Marl).
|
||||||
|
- **Rolle:** Systemadministration/IT-Infrastruktur; keine reinen Entwickler-,
|
||||||
|
Vertriebs- oder SAP-only-Stellen (außer sie passen klar zum Profil).
|
||||||
|
- **Quelle (harter Ausschluss):** Headhunter/Personalvermittler, Zeitarbeit/
|
||||||
|
Arbeitnehmerüberlassung und Stepstone werden **verworfen** — siehe
|
||||||
|
„Quellen-Politik". Nur Original-Anzeigen echter Arbeitgeber behalten.
|
||||||
|
- **Dedup (persistent, FIRMEN-basiert):** **Eine Firma darf nur EINMAL gefunden
|
||||||
|
werden.** Treffer **verwerfen**, sobald **eines** zutrifft: (a) gleiche/sehr
|
||||||
|
ähnliche `quelle_url`, (b) gleiche `external_id`, oder (c) **die Firma steht
|
||||||
|
schon im Applied-Set** — unabhängig vom Stellentitel. Firmen-Gleichheit über
|
||||||
|
`firma_slug` prüfen: Kandidaten-Firma ebenso sluggen (lowercase, Umlaute→
|
||||||
|
ae/oe/ue/ss, `(m/w/d)` & End-Rechtsform raus, mit `-` verbunden) und als
|
||||||
|
**dieselbe Firma** werten, wenn der Slug **gleich** ist ODER **ein Slug ein
|
||||||
|
führendes Bindestrich-Präfix des anderen** ist (mind. 2 Tokens) — so zählen
|
||||||
|
**verschiedene Schreibweisen/Rechtsform-/Namensvarianten derselben Firma als
|
||||||
|
eine** (z. B. „IT-Problemlöser GmbH" = „IT Problemlöser Verwaltungs- und Handels
|
||||||
|
GmbH"; „Stadtwerke Essen" = „Stadtwerke Essen Netz GmbH"). Zusätzlich gesunder
|
||||||
|
Menschenverstand: ist offensichtlich derselbe Arbeitgeber gemeint, als Dublette
|
||||||
|
verwerfen. Distinkte Firmen mit nur gleichem ersten Wort (z. B. „Meyer IT" vs.
|
||||||
|
„Meyer Logistik") sind **nicht** dieselbe Firma. So taucht kein Arbeitgeber aus
|
||||||
|
früheren Läufen erneut auf. Im Zweifel behalten und als „evtl. Dublette" markieren.
|
||||||
|
- **Blacklist (harter Ausschluss):** Kandidat **verwerfen** (nicht listen, nicht
|
||||||
|
importieren), sobald er auf einen Blacklist-Eintrag passt — je nach `typ`:
|
||||||
|
- `url` → normalisierte Kandidaten-URL == `url_norm`.
|
||||||
|
- `domain` → Host der Kandidaten-URL == `domain` (auch Subdomains).
|
||||||
|
- `firma` → **dieselbe Firma** wie der Eintrag (Kandidaten-`firma_slug` gleich
|
||||||
|
`firma_slug` **oder** Präfix-Match wie bei der Dedup; `firma_norm` nur als
|
||||||
|
Fallback für Alt-Einträge ohne Slug).
|
||||||
|
- `firma_stelle` → dieselbe Firma (`firma_slug`, wie oben) **und** `stelle_norm`
|
||||||
|
passen (ist `ort_norm` gesetzt, muss auch der Ort passen).
|
||||||
|
- `auto` → wie die konkreten Felder, die der Eintrag trägt.
|
||||||
|
Diese Muster sind bewusst blockiert (u. a. jeder früher importierte Job, siehe
|
||||||
|
Schritt 6) — geblacklistete Stellen daher **stillschweigend überspringen**,
|
||||||
|
nicht als „evtl. Dublette“ präsentieren.
|
||||||
|
- **Relevanz:** höher gewichten, je mehr Buzzwords aus dem Profil passen.
|
||||||
|
|
||||||
|
4. **Existenz prüfen, Link prüfen, Kontakt-E-Mail & Firmenadresse recherchieren** —
|
||||||
|
für jeden Treffer, der in die engere Wahl kommt, **zwingend einzeln**:
|
||||||
|
- **Existenz-Prüfung (Pflicht):** Jede Stelle mit `WebFetch` auf dem Einzel-Link
|
||||||
|
öffnen und bestätigen, dass die Anzeige **noch aktiv** ist (Firma + Stelle
|
||||||
|
stehen dort, kein 404/„Stelle nicht mehr verfügbar"/Redirect auf Jobliste).
|
||||||
|
Snippets aus der WebSearch reichen **nicht** — Suchindizes zeigen oft schon
|
||||||
|
abgelaufene Anzeigen. Kein Live-Nachweis → Treffer **verwerfen** (nicht listen,
|
||||||
|
nicht importieren). Bei bereits importierten Angeboten, die nicht mehr existieren,
|
||||||
|
das JobOffer löschen (`DELETE /joboffers/{id}`).
|
||||||
|
- **Vollständige Stellenausschreibung erfassen (Pflicht):** Beim `WebFetch` der
|
||||||
|
Einzelanzeige den **kompletten Ausschreibungstext** übernehmen — nicht nur eine
|
||||||
|
Kurzzusammenfassung. Dazu gehören: Einleitung/Unternehmensvorstellung, **Aufgaben/
|
||||||
|
Tätigkeiten**, **Anforderungen/Profil**, **Wir bieten/Benefits**, Angaben zu
|
||||||
|
Arbeitszeit/Vertragsart/Standort, Gehalt (falls genannt), Bewerbungsweg/Kontakt
|
||||||
|
und Referenz-/Kennziffer. Text weitgehend **wortgetreu und vollständig** sichern
|
||||||
|
(nur Navigations-/Cookie-/Footer-Boilerplate der Seite weglassen), inkl. der
|
||||||
|
Gliederung/Überschriften. Dieser Volltext wandert in `beschreibung` (Schritt 6).
|
||||||
|
Ist die Seite lang/abgeschnitten, `WebFetch` gezielt erneut aufrufen, um alle
|
||||||
|
Abschnitte zu bekommen.
|
||||||
|
- **`quelle_url` verifizieren:** Muss auf die **konkrete, noch aktive
|
||||||
|
Einzelanzeige** zeigen (nicht Trefferliste/Suchseite). Deeplink defekt/Liste →
|
||||||
|
per Suche den echten Einzel-Link finden (bevorzugt Firmenwebsite/Karriereseite),
|
||||||
|
sonst verwerfen.
|
||||||
|
- **Vermittler/Zeitarbeit aussortieren (Pflicht):** Zeigt der Treffer auf eine
|
||||||
|
Personalberatung/Headhunter/Zeitarbeit statt den echten Arbeitgeber, **verwerfen**
|
||||||
|
(nicht listen, nicht importieren). Bei Unsicherheit die inserierende Firma per
|
||||||
|
`WebFetch` (Impressum/„Über uns") prüfen — Recruiting-/Personaldienstleister raus.
|
||||||
|
Existiert dieselbe Stelle direkt auf der Firmen-Karriereseite, diese
|
||||||
|
Original-Anzeige verwenden statt des Vermittler-Treffers.
|
||||||
|
- **Bewerber-Kontakt-E-Mail recherchieren:** Die E-Mail-Adresse ermitteln, an die
|
||||||
|
sich Bewerber wenden. Reihenfolge: (a) direkt in der Stellenanzeige genannte
|
||||||
|
Bewerbungs-/Kontaktadresse; (b) Karriere-/Kontaktseite der Firma
|
||||||
|
(`WebSearch` „<Firma> Karriere Kontakt Bewerbung E-Mail“, `WebFetch` der Seite);
|
||||||
|
(c) allgemeine Bewerbungsadresse (`bewerbung@`/`jobs@`/`karriere@<domain>`) nur,
|
||||||
|
wenn auf der Firmenseite belegt. **Nicht raten** — nur Adressen übernehmen, die
|
||||||
|
belegt sind; sonst `kontakt_email` leer lassen und als „nicht gefunden“ markieren.
|
||||||
|
- **Firmenadresse recherchieren (Pflicht):** Die **genaue Anschrift** des
|
||||||
|
Arbeitgebers ermitteln (Straße, Hausnummer, PLZ, Ort) — und zwar die, die
|
||||||
|
**exakt zu dieser Stelle passt**. Reihenfolge: (a) in der Stellenanzeige selbst
|
||||||
|
genannter Einsatz-/Arbeitsort (oft unter „Standort"/„Einsatzort"/am Anzeigenende);
|
||||||
|
(b) Impressum bzw. Kontakt-/Standortseite der Firma
|
||||||
|
(`WebSearch` „<Firma> Impressum Adresse <Ort>“, `WebFetch` der Seite).
|
||||||
|
⚠️ **Adresse muss zur Stelle passen:** Hat die Firma **mehrere Standorte/
|
||||||
|
Niederlassungen**, die Anschrift des **konkreten Arbeitsorts der Stelle** nehmen —
|
||||||
|
**nicht** blind die Zentrale/Hauptsitz-Adresse, wenn die Stelle an einem anderen
|
||||||
|
Standort ausgeschrieben ist. Nennt die Anzeige nur eine Stadt (`ort`), aber keine
|
||||||
|
Straße, gezielt die zu **dieser Stadt** gehörende Niederlassungsadresse suchen und
|
||||||
|
verifizieren. **Nicht raten** — nur eine belegte, zum Stellen-Ort passende Adresse
|
||||||
|
übernehmen; sonst `adresse` leer lassen und als „nicht gefunden“ markieren.
|
||||||
|
Passt die einzige auffindbare Firmenadresse **nicht** zum `ort` der Stelle
|
||||||
|
(anderer Standort), lieber leer lassen als eine falsche Zentrale eintragen.
|
||||||
|
|
||||||
|
5. **Ergebnis präsentieren** als Tabelle: Firma · Stelle · Ort · **Adresse** ·
|
||||||
|
Quelle (Board) · **verifizierter Link** · **Kontakt-E-Mail** · kurze
|
||||||
|
Passt-Begründung. Sortierung: neue, klar passende Treffer **nach Nähe zu Gladbeck**
|
||||||
|
(Gladbeck zuerst, dann Bottrop/Gelsenkirchen, dann Essen/Oberhausen, dann Dorsten/Marl).
|
||||||
|
Fehlt Link, E-Mail oder Adresse, kennzeichnen.
|
||||||
|
|
||||||
|
6. **Importieren** — im **interaktiven** Modus nur nach Rückfrage/Bestätigung des
|
||||||
|
Nutzers; im **autonomen Modus** (siehe unten) automatisch ohne Rückfrage. Jeden
|
||||||
|
neuen Treffer als JobOffer einspielen (Upsert, idempotent) über den
|
||||||
|
`bewerbungs-tracker`-Skill:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers '{
|
||||||
|
"external_id": "stellensuche-<slug(firma)>-<slug(stelle)>",
|
||||||
|
// IMMER setzen und DETERMINISTISCH aus
|
||||||
|
// firma+stelle bilden — NICHT aus der
|
||||||
|
// Board-/Anzeigen-ID. Slug = normalisiert
|
||||||
|
// wie der firma_slug (lowercase, ohne (m/w/d)
|
||||||
|
// & Rechtsform, Interpunktion→"-"), z. B.
|
||||||
|
// "stellensuche-musterfirma-it-systemadministrator".
|
||||||
|
// So bekommt DIESELBE Stelle über jeden
|
||||||
|
// Lauf UND jedes Board dasselbe external_id
|
||||||
|
// → Server-Upsert greift zuverlässig statt
|
||||||
|
// eine Dublette anzulegen. (Eine flüchtige
|
||||||
|
// Board-ID würde je Quelle abweichen und so
|
||||||
|
// Duplikate erzeugen — daher NICHT nutzen.)
|
||||||
|
"quelle": "stellensuche",
|
||||||
|
"firma": "…", "stelle": "…", "ort": "…",
|
||||||
|
"adresse": "<genaue Anschrift des Arbeitsorts der Stelle: Straße Hausnr, PLZ Ort;
|
||||||
|
recherchiert & verifiziert, passend zum Stellen-Standort; sonst weglassen>",
|
||||||
|
"gehalt": "…",
|
||||||
|
"beschreibung": "<VOLLSTÄNDIGE Stellenausschreibung>",
|
||||||
|
// Der komplette, in Schritt 4 per WebFetch
|
||||||
|
// erfasste Anzeigentext: Aufgaben, Anforderungen/
|
||||||
|
// Profil, Wir-bieten/Benefits, Arbeitszeit/
|
||||||
|
// Vertragsart, Standort, Gehalt, Bewerbungsweg,
|
||||||
|
// Referenz-/Kennziffer — wortgetreu und
|
||||||
|
// vollständig, nicht kürzen/zusammenfassen.
|
||||||
|
// Gliederung/Überschriften erhalten (Markdown
|
||||||
|
// ok). Nur Seiten-Boilerplate weglassen.
|
||||||
|
"quelle_url": "<verifizierter Einzel-Link, bevorzugt Firmen-Karriereseite>",
|
||||||
|
"art": "Firmenwebsite",
|
||||||
|
"anzeige_datum": "YYYY-MM-DD",
|
||||||
|
"kontakt_email": "<recherchierte Bewerber-E-Mail, sonst weglassen>",
|
||||||
|
"labels": ["Regional"] // IMMER setzen: dieser Skill sucht ortsgebunden in
|
||||||
|
// den sieben Städten → Arbeitsort-Label "Regional".
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
`art`-Enum: `E-Mail`, `Online-Portal`, `Indeed`, `StepStone`, `Firmenwebsite`,
|
||||||
|
`Post`, `Initiativbewerbung`, `Arbeitsagentur`, `Sonstiges`. **Standard ist
|
||||||
|
`Firmenwebsite`** (Original-Anzeige des Arbeitgebers). `StepStone` nie verwenden
|
||||||
|
(ausgeschlossene Quelle); `Arbeitsagentur`/`Indeed` nur, wenn der Link direkt zur
|
||||||
|
Arbeitgeber-Anzeige führt.
|
||||||
|
|
||||||
|
**`409`-Antwort beim Import:** Steht der Job (trotz Vorfilter) auf der Blacklist,
|
||||||
|
antwortet `POST /joboffers` mit **409** (`BlacklistConflict`) und nimmt ihn
|
||||||
|
**nicht** auf. Das ist **kein Fehler** — als „übersprungen (Blacklist)" zählen
|
||||||
|
und mit dem nächsten Treffer weitermachen, **nicht** mit `force` erzwingen.
|
||||||
|
|
||||||
|
7. **Direkt blacklisten (nach jedem erfolgreichen Import).** Sobald ein JobOffer
|
||||||
|
angelegt wurde (Antwort **201** / `action:created`; bei `updated` ist der Job
|
||||||
|
bereits erfasst), die **Firma sofort ganz** blacklisten, damit dieser Arbeitgeber
|
||||||
|
in künftigen Läufen **mit keinem Stellentitel** wieder auftaucht (Regel „eine Firma
|
||||||
|
nur einmal"). Per `typ: firma` sperren — der Server matcht firmenweit über
|
||||||
|
`firma_slug` und deckt so abweichende Schreibweisen/Rechtsformen mit ab:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers/blacklist '{
|
||||||
|
"typ": "firma",
|
||||||
|
"firma": "<firma wie importiert>",
|
||||||
|
"grund": "auto: stellensuche-import <YYYY-MM-DD>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
Server-Antwort **201** = geblacklistet. Normalisierung (Groß/Klein, `(m/w/d)`,
|
||||||
|
Rechtsform, Umlaute) übernimmt der Server. Nur nach **echtem** Neuimport
|
||||||
|
blacklisten — nicht bei einem 409-Skip (steht ja schon drauf) und nicht bei
|
||||||
|
bloßem `updated`.
|
||||||
|
|
||||||
|
## Autonomer Modus (headless / geplant)
|
||||||
|
|
||||||
|
Wird der Skill **nicht-interaktiv** ausgeführt — d. h. ohne Nutzer, der bestätigen
|
||||||
|
kann (z. B. `claude -p "…" --dangerously-skip-permissions`, Cron/geplanter Lauf,
|
||||||
|
`ollama launch claude … -p …`) — im **autonomen Modus** arbeiten:
|
||||||
|
|
||||||
|
- **Ohne Rückfrage importieren:** Neue, geprüfte Treffer direkt als JobOffer
|
||||||
|
einspielen (Schritt 6) **und anschließend blacklisten** (Schritt 7). Es gibt
|
||||||
|
niemanden zum Bestätigen — nicht auf Eingabe warten.
|
||||||
|
- **Alle Prüfregeln gelten unverändert und strikt:** Existenz-Prüfung (Schritt 4),
|
||||||
|
korrekter verifizierter Einzel-Link, Kontakt-E-Mail nur wenn belegt, **Firmenadresse
|
||||||
|
nur wenn belegt und zum Stellen-Ort passend** (Schritt 4), die
|
||||||
|
persistente Dedup **und der Blacklist-Filter** (Schritt 1/3) — importiere **nur**
|
||||||
|
echte, live verifizierte, noch nicht erfasste und **nicht geblacklistete** Stellen.
|
||||||
|
Im Zweifel **nicht** importieren (lieber auslassen).
|
||||||
|
- **Keine Bewerbungen anlegen/versenden**, keine Löschungen bestehender Bewerbungen,
|
||||||
|
keine Generierung anstoßen — nur neue Jobangebote (`POST /joboffers`) einpflegen
|
||||||
|
und den jeweils importierten Job blacklisten (`POST /joboffers/blacklist`).
|
||||||
|
- **Kurzbericht ausgeben** (für das Log): je Treffer `action`
|
||||||
|
(created+blacklisted / updated / skip-dedup / skip-blacklist) mit Firma, Stelle,
|
||||||
|
Ort, Link; am Ende Zähler „X neu importiert & geblacklistet, Y als Dublette/
|
||||||
|
Blacklist übersprungen, Z verworfen (nicht verifizierbar)".
|
||||||
|
|
||||||
|
## Regeln
|
||||||
|
|
||||||
|
- **Nur sieben Städte.** Es werden **ausschließlich** Stellen mit Arbeitsort in
|
||||||
|
Gladbeck, Essen, Gelsenkirchen, Oberhausen, Bottrop, Dorsten oder Marl gelistet/importiert.
|
||||||
|
Alles außerhalb dieser sieben Städte (übriges Ruhrgebiet, reines Remote/Homeoffice ohne
|
||||||
|
Sitz in einer der sieben Städte) wird **verworfen**. Priorität nach Nähe zu Gladbeck.
|
||||||
|
- **Label `Regional` setzen (Pflicht).** Jedes von diesem Skill importierte JobOffer
|
||||||
|
bekommt `labels: ["Regional"]` (ortsgebundene Stelle in einer der sieben Städte).
|
||||||
|
Kein Remote-/Homeoffice-Label — solche Stellen sind Sache des Skills
|
||||||
|
`it-stellensuche-remote`.
|
||||||
|
- **Keine erfundenen Stellen.** Nur Jobs ausgeben, die per WebSearch/WebFetch real
|
||||||
|
belegt sind. **`quelle_url` muss der korrekte, geprüfte Direktlink zur
|
||||||
|
Einzelanzeige sein** (per `WebFetch` bestätigt) — keine Such-/Listenseiten, keine
|
||||||
|
geratenen URLs. Kein verifizierbarer Einzel-Link → Treffer nicht listen/importieren.
|
||||||
|
- **Nur Original-Anzeigen echter Arbeitgeber.** Headhunter/Personalvermittler,
|
||||||
|
Zeitarbeit/Arbeitnehmerüberlassung und **Stepstone** sind ausgeschlossen — weder
|
||||||
|
listen noch importieren (siehe „Quellen-Politik"). Bevorzugt der Firmen-Direktlink;
|
||||||
|
im Zweifel (Vermittler? Zeitarbeit?) die inserierende Firma prüfen und lieber
|
||||||
|
verwerfen.
|
||||||
|
- **`beschreibung` = vollständige Ausschreibung.** Immer den kompletten, per WebFetch
|
||||||
|
erfassten Anzeigentext (Aufgaben, Anforderungen, Benefits, Konditionen, Bewerbungsweg,
|
||||||
|
Kennziffer) übernehmen — nicht kürzen oder zusammenfassen. Inhalt aus der realen
|
||||||
|
Anzeige, nicht frei ergänzen.
|
||||||
|
- **Kontakt-E-Mail nicht erfinden.** `kontakt_email` nur setzen, wenn die Adresse in
|
||||||
|
der Anzeige oder auf der Firmen-Karriereseite belegt ist; sonst weglassen.
|
||||||
|
- **Firmenadresse recherchieren, nicht erfinden — und passend zur Stelle.** `adresse`
|
||||||
|
(Straße, Hausnummer, PLZ, Ort) im Web ermitteln und nur eine **belegte** Anschrift
|
||||||
|
eintragen, die **genau zum Arbeitsort der Stelle** gehört. Bei mehreren Standorten
|
||||||
|
die Niederlassung der ausgeschriebenen Stelle nehmen, **nicht** pauschal den
|
||||||
|
Hauptsitz. Passt keine verifizierte Adresse zum Stellen-Ort, `adresse` leer lassen
|
||||||
|
statt eine falsche Zentrale einzutragen.
|
||||||
|
- **Keine Doppelfunde — eine Firma nur EINMAL, auch über Läufe hinweg.** Vor dem
|
||||||
|
Listen/Importieren immer `applied-set.sh` **und** `blacklist-set.sh` laden und
|
||||||
|
jeden Kandidaten per `quelle_url`, `external_id` **und `firma_slug` (firmenweit)**
|
||||||
|
sowie gegen die Blacklist abgleichen. **Steht die Firma schon im Applied-Set oder
|
||||||
|
auf der Blacklist — egal unter welchem Stellentitel und egal in welcher
|
||||||
|
Schreibweise/Rechtsform (`firma_slug` gleich ODER Präfix-Match, mind. 2 Tokens) —
|
||||||
|
wird kein weiterer Treffer dieses Arbeitgebers ausgegeben oder importiert.** Auch
|
||||||
|
**innerhalb eines Laufs** jede Firma nur **einmal** präsentieren (bei mehreren
|
||||||
|
Stellen derselben Firma die am besten passende wählen). Beim Import die
|
||||||
|
`external_id` **deterministisch aus `firma+stelle`** bilden, **nicht** aus einer
|
||||||
|
Board-/Anzeigen-ID — so ergibt dieselbe Stelle über jeden Lauf/jedes Board dasselbe
|
||||||
|
`external_id` (Server-Upsert statt Dublette). Der Server dedupliziert
|
||||||
|
`POST /joboffers` über `external_id` (Upsert) und die Blacklist (409, firmenweit
|
||||||
|
über `firma_slug`).
|
||||||
|
- **Blacklisten nach jedem Import (Pflicht, firmenweit).** Jeder frisch angelegte Job
|
||||||
|
(`action:created`) wird direkt anschließend per `POST /joboffers/blacklist`
|
||||||
|
(**`typ:firma`** — die ganze Firma) gesperrt (Schritt 7), damit dieser Arbeitgeber
|
||||||
|
in Folgeläufen mit keinem Titel wieder auftaucht. Nicht blacklisten bei `updated`
|
||||||
|
oder bei einem 409-Skip. Ein 409 beim `POST /joboffers` bedeutet „steht schon auf
|
||||||
|
der Blacklist" → überspringen, nie mit `force` erzwingen.
|
||||||
|
- **Interaktiv: nicht ungefragt importieren** — erst zeigen, dann auf Bestätigung
|
||||||
|
importieren. **Autonom/headless: automatisch importieren** (siehe „Autonomer
|
||||||
|
Modus"). In **keinem** Modus Bewerbungen anlegen/versenden.
|
||||||
|
- Board-Aggregatoren-Duplikate (dieselbe Stelle auf mehreren Portalen) zu einem
|
||||||
|
Eintrag zusammenfassen, bevorzugt mit Direkt-/Firmenwebsite-Link.
|
||||||
|
- Ist die Bewerbungs-Tracker-API nicht erreichbar (`GET /health`), Dedup nicht
|
||||||
|
möglich → Nutzer warnen und Treffer ohne Dedup mit Hinweis liefern.
|
||||||
|
|
||||||
|
## Abhängigkeiten
|
||||||
|
|
||||||
|
- Tools: `WebSearch`, `WebFetch`.
|
||||||
|
- Skill `bewerbungs-tracker` (für Dedup-Daten, JobOffer-Import und Blacklist:
|
||||||
|
`GET/POST /joboffers/blacklist`).
|
||||||
|
- Scripts: `scripts/arbeitsagentur.sh` (strukturierte Jobsuche-API der Bundesagentur
|
||||||
|
für Arbeit — primäre Quelle, `search`/`detail`), `scripts/applied-set.sh` (erfasste
|
||||||
|
Firmen), `scripts/blacklist-set.sh` (gesperrte Muster). `applied-set`/`blacklist-set`
|
||||||
|
nutzen den `bt.sh`-Helfer des `bewerbungs-tracker`-Skills; `arbeitsagentur.sh` nutzt
|
||||||
|
nur `curl` + `python3` (öffentliche API, kein Key nötig).
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Prints the set of jobs the user has ALREADY engaged with, for deduplication
|
||||||
|
# during a job search — so the same job is never found/imported twice, also on
|
||||||
|
# LATER runs. Combines existing Bewerbungen (/applications) and already-imported
|
||||||
|
# Jobangebote (/joboffers) from the Bewerbungs-Tracker API.
|
||||||
|
#
|
||||||
|
# Output: EINE Zeile pro bereits erfasster FIRMA (company-level), tab-separated:
|
||||||
|
# firma_slug <TAB> firma <TAB> stelle <TAB> ort <TAB> quelle_url <TAB> external_id
|
||||||
|
#
|
||||||
|
# firma_slug = robuster Firmen-Schlüssel (lowercase, Umlaute ae/oe/ue/ss,
|
||||||
|
# Diakritika entfernt, (m/w/d) raus, End-Rechtsform-Tokens wie
|
||||||
|
# gmbh/ag/kg entfernt, mit "-" verbunden). Identisch zu firmaSlug
|
||||||
|
# in lib/blacklist.js des Servers — deckt Schreibweisen-/
|
||||||
|
# Rechtsform-/Umlaut-Varianten EINER Firma ab.
|
||||||
|
#
|
||||||
|
# Regel: eine FIRMA darf nur EINMAL gefunden werden. Ein neuer Treffer gilt als
|
||||||
|
# Dublette (→ verwerfen), wenn seine Firma zu EINER Zeile hier passt:
|
||||||
|
# * gleicher firma_slug, ODER
|
||||||
|
# * ein firma_slug ist ein führendes Bindestrich-Präfix des anderen (≥2 Tokens)
|
||||||
|
# — d. h. Kurzname vs. voller Firmenname ("it-problemloeser" ⊂
|
||||||
|
# "it-problemloeser-verwaltungs-und-handels"), ODER
|
||||||
|
# * gleiche quelle_url ODER gleiche external_id.
|
||||||
|
# Es wird bewusst NICHT mehr nach Stellentitel unterschieden — eine bereits
|
||||||
|
# erfasste Firma taucht mit KEINEM (auch nicht neuem) Titel wieder auf.
|
||||||
|
# HTML-Entities werden dekodiert.
|
||||||
|
#
|
||||||
|
# Reuses the bewerbungs-tracker skill's helper for auth/base-URL.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BT="${BT_SCRIPT:-$HOME/.claude/skills/bewerbungs-tracker/scripts/bt.sh}"
|
||||||
|
if [[ ! -x "$BT" ]]; then
|
||||||
|
echo "applied-set.sh: bewerbungs-tracker helper not found at $BT" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# In Tempdateien schreiben (NICHT in Env-Vars): die kombinierte JSON aus
|
||||||
|
# /applications + /joboffers wird groß; als Env-Var würde sie ARG_MAX (argv+envp)
|
||||||
|
# sprengen ("Argument list too long" beim python3-Start). Dateien lesen umgeht das.
|
||||||
|
TMPDIR_AS="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMPDIR_AS"' EXIT
|
||||||
|
APPS_FILE="$TMPDIR_AS/apps.json"
|
||||||
|
OFFERS_FILE="$TMPDIR_AS/offers.json"
|
||||||
|
"$BT" GET '/applications?limit=500' > "$APPS_FILE"
|
||||||
|
"$BT" GET '/joboffers' > "$OFFERS_FILE"
|
||||||
|
|
||||||
|
APPS_FILE="$APPS_FILE" OFFERS_FILE="$OFFERS_FILE" python3 <<'PY'
|
||||||
|
import os, json, html, re, sys, unicodedata
|
||||||
|
|
||||||
|
def load(name):
|
||||||
|
with open(os.environ[name], encoding='utf-8') as fh:
|
||||||
|
raw = fh.read().split('<http')[0]
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def clean(v):
|
||||||
|
return html.unescape(str(v or '')).replace('\t', ' ').strip()
|
||||||
|
|
||||||
|
# Robuster Firmen-Schlüssel — MUSS mit firmaSlug() in lib/blacklist.js des
|
||||||
|
# Servers übereinstimmen (Umlaut-Translit, (m/w/d) raus, End-Rechtsform-Tokens
|
||||||
|
# entfernt, mit "-" verbunden), damit Skill- und Server-Dedup dieselbe Firma
|
||||||
|
# gleich erkennen.
|
||||||
|
_GENDER = re.compile(r'\((?:[mwdfax](?:\s*/\s*[mwdfax])*)\)', re.I) # (m/w/d), (w/m/x)…
|
||||||
|
_LEGAL_FORMS = {
|
||||||
|
'gmbh', 'ggmbh', 'mbh', 'ug', 'haftungsbeschraenkt', 'ag', 'kg', 'kgaa',
|
||||||
|
'ohg', 'gbr', 'se', 'ek', 'eg', 'ev', 'partg', 'partmbb', 'co', 'cie',
|
||||||
|
'inc', 'incorporated', 'llc', 'ltd', 'limited', 'plc', 'corp', 'corporation',
|
||||||
|
'company', 'sa', 'sarl', 'sas', 'bv', 'nv', 'oy', 'ab', 'as', 'aps', 'srl', 'spa',
|
||||||
|
}
|
||||||
|
def firma_slug(s):
|
||||||
|
t = html.unescape(str(s or '')).lower()
|
||||||
|
t = (t.replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace('ß', 'ss'))
|
||||||
|
t = unicodedata.normalize('NFKD', t)
|
||||||
|
t = ''.join(c for c in t if not unicodedata.combining(c))
|
||||||
|
t = _GENDER.sub(' ', t)
|
||||||
|
t = re.sub(r'[^a-z0-9]+', ' ', t)
|
||||||
|
t = re.sub(r'\s+', ' ', t).strip()
|
||||||
|
if not t:
|
||||||
|
return ''
|
||||||
|
words = t.split(' ')
|
||||||
|
kept = words[:]
|
||||||
|
while len(kept) > 1 and kept[-1] in _LEGAL_FORMS:
|
||||||
|
kept.pop()
|
||||||
|
return '-'.join(kept if kept else words)
|
||||||
|
|
||||||
|
# Eine Firma nur EINMAL: pro firma_slug genau eine Zeile (erste gewinnt).
|
||||||
|
seen = set()
|
||||||
|
def emit(firma, stelle, ort, url, ext):
|
||||||
|
slug = firma_slug(firma)
|
||||||
|
if not slug or slug in seen:
|
||||||
|
return
|
||||||
|
seen.add(slug)
|
||||||
|
print('\t'.join([slug, clean(firma), clean(stelle), clean(ort),
|
||||||
|
clean(url), clean(ext)]))
|
||||||
|
|
||||||
|
for a in load('APPS_FILE'):
|
||||||
|
emit(a.get('firma'), a.get('stelle'), a.get('ort'), a.get('quelle_url'), '')
|
||||||
|
for o in load('OFFERS_FILE'):
|
||||||
|
emit(o.get('firma'), o.get('stelle'), o.get('ort'), o.get('quelle_url'),
|
||||||
|
o.get('external_id'))
|
||||||
|
|
||||||
|
print(f'# {len(seen)} bereits erfasste Firmen (Bewerbungen + Jobangebote)', file=sys.stderr)
|
||||||
|
PY
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Query the Bundesagentur für Arbeit Jobsuche-API — a structured, FRESH, high-recall
|
||||||
|
# primary source for the it-stellensuche skills that complements free-text WebSearch.
|
||||||
|
# Results come back already shaped for the skill's firma_slug-based Dedup, and a
|
||||||
|
# posting's full detail (Volltext, Bewerbungs-URL, Zeitarbeit/Vermittler-Flags,
|
||||||
|
# Adresse) can be pulled with one call — no WebFetch needed for the basics.
|
||||||
|
#
|
||||||
|
# Public, read-only API. The API key is the well-known public value
|
||||||
|
# "jobboerse-jobsuche" (same key the arbeitsagentur.de site itself uses).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# arbeitsagentur.sh search <was> <wo> [umkreis_km] [tage] [size] [arbeitszeit]
|
||||||
|
# Lists postings for role <was> around location <wo>. One API call.
|
||||||
|
# umkreis_km default 15, tage (veröffentlicht seit) default 30, size default 50.
|
||||||
|
# <wo> leer lassen ("") = deutschlandweit. arbeitszeit optional, u. a. "ho"
|
||||||
|
# (Homeoffice) für die Remote-Suche, sonst weglassen.
|
||||||
|
# Output: one posting per line, tab-separated:
|
||||||
|
# firma_slug <TAB> firma <TAB> titel <TAB> ort <TAB> datum <TAB> refnr <TAB> angebotsart
|
||||||
|
# firma_slug is identical to applied-set.sh / firmaSlug() in lib/blacklist.js,
|
||||||
|
# so a hit can be dedup'd against the applied-set/blacklist immediately.
|
||||||
|
#
|
||||||
|
# arbeitsagentur.sh detail <refnr>
|
||||||
|
# Full posting for a refnr (from a search row). Prints a readable field block
|
||||||
|
# plus the complete Beschreibung — treat it like a WebFetch of the ad. Carries
|
||||||
|
# istArbeitnehmerUeberlassung / istPrivateArbeitsvermittlung → drop Zeitarbeit/
|
||||||
|
# Vermittler here WITHOUT a fetch; externeURL → the employer's original link
|
||||||
|
# (bevorzugt als quelle_url, sofern es zum echten Arbeitgeber führt).
|
||||||
|
#
|
||||||
|
# Existence check: refnr is stable; a detail call that returns no Titel/Beschreibung
|
||||||
|
# (HTTP 404 / error body) means the ad is gone → verwerfen (bzw. JobOffer löschen).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
API='https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v4'
|
||||||
|
KEY='jobboerse-jobsuche'
|
||||||
|
|
||||||
|
usage() { sed -n '2,25p' "$0" >&2; exit 2; }
|
||||||
|
|
||||||
|
TMP="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMP"' EXIT
|
||||||
|
|
||||||
|
cmd="${1:-}"; [[ -n "$cmd" ]] && shift || usage
|
||||||
|
|
||||||
|
if [[ "$cmd" == "search" ]]; then
|
||||||
|
was="${1:-}"; wo="${2:-}"; umkreis="${3:-15}"; tage="${4:-30}"; size="${5:-50}"; arbeitszeit="${6:-}"
|
||||||
|
[[ -n "$was" ]] || { echo "search: <was> fehlt" >&2; usage; }
|
||||||
|
# curl-Argumente als Array — sauberes, optionales wo=/arbeitszeit=.
|
||||||
|
args=(-sS -m 30 -G "$API/jobs" -H "X-API-Key: $KEY"
|
||||||
|
--data-urlencode "was=$was"
|
||||||
|
--data-urlencode "umkreis=$umkreis"
|
||||||
|
--data-urlencode "veroeffentlichtseit=$tage"
|
||||||
|
--data-urlencode "size=$size")
|
||||||
|
[[ -n "$wo" ]] && args+=(--data-urlencode "wo=$wo")
|
||||||
|
[[ -n "$arbeitszeit" ]] && args+=(--data-urlencode "arbeitszeit=$arbeitszeit")
|
||||||
|
curl "${args[@]}" -o "$TMP" || { echo "# Arbeitsagentur-API nicht erreichbar" >&2; exit 0; }
|
||||||
|
|
||||||
|
RESP_FILE="$TMP" python3 <<'PY'
|
||||||
|
import os, sys, json, html, re, unicodedata
|
||||||
|
|
||||||
|
# firma_slug — MUSS mit firmaSlug() (lib/blacklist.js) & applied-set.sh übereinstimmen.
|
||||||
|
_GENDER = re.compile(r'\((?:[mwdfax](?:\s*/\s*[mwdfax])*)\)', re.I)
|
||||||
|
_LEGAL = {'gmbh','ggmbh','mbh','ug','haftungsbeschraenkt','ag','kg','kgaa','ohg','gbr',
|
||||||
|
'se','ek','eg','ev','partg','partmbb','co','cie','inc','incorporated','llc',
|
||||||
|
'ltd','limited','plc','corp','corporation','company','sa','sarl','sas','bv',
|
||||||
|
'nv','oy','ab','as','aps','srl','spa'}
|
||||||
|
def firma_slug(s):
|
||||||
|
t = html.unescape(str(s or '')).lower()
|
||||||
|
t = t.replace('ä','ae').replace('ö','oe').replace('ü','ue').replace('ß','ss')
|
||||||
|
t = unicodedata.normalize('NFKD', t)
|
||||||
|
t = ''.join(c for c in t if not unicodedata.combining(c))
|
||||||
|
t = _GENDER.sub(' ', t)
|
||||||
|
t = re.sub(r'[^a-z0-9]+',' ',t)
|
||||||
|
t = re.sub(r'\s+',' ',t).strip()
|
||||||
|
if not t: return ''
|
||||||
|
w = t.split(' '); kept = w[:]
|
||||||
|
while len(kept) > 1 and kept[-1] in _LEGAL: kept.pop()
|
||||||
|
return '-'.join(kept if kept else w)
|
||||||
|
|
||||||
|
def clean(v):
|
||||||
|
return html.unescape(str(v if v is not None else '')).replace('\t',' ').replace('\n',' ').strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(os.environ['RESP_FILE'], encoding='utf-8') as fh:
|
||||||
|
d = json.load(fh)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'# Arbeitsagentur-API: keine/ungueltige Antwort ({e})', file=sys.stderr); sys.exit(0)
|
||||||
|
|
||||||
|
jobs = d.get('stellenangebote') or []
|
||||||
|
n = 0
|
||||||
|
for j in jobs:
|
||||||
|
slug = firma_slug(j.get('arbeitgeber'))
|
||||||
|
if not slug:
|
||||||
|
continue
|
||||||
|
ort = (j.get('arbeitsort') or {}).get('ort')
|
||||||
|
print('\t'.join([slug, clean(j.get('arbeitgeber')), clean(j.get('titel')), clean(ort),
|
||||||
|
clean(j.get('aktuelleVeroeffentlichungsdatum')),
|
||||||
|
clean(j.get('refnr')), clean(j.get('angebotsart') or 'ARBEIT')]))
|
||||||
|
n += 1
|
||||||
|
print(f'# {n} Treffer (Arbeitsagentur, {d.get("maxErgebnisse","?")} gesamt)', file=sys.stderr)
|
||||||
|
PY
|
||||||
|
|
||||||
|
elif [[ "$cmd" == "detail" ]]; then
|
||||||
|
ref="${1:-}"; [[ -n "$ref" ]] || { echo "detail: <refnr> fehlt" >&2; usage; }
|
||||||
|
seg="$(REF="$ref" python3 -c 'import base64,urllib.parse,os; print(urllib.parse.quote(base64.b64encode(os.environ["REF"].encode()).decode(), safe=""))')"
|
||||||
|
curl -sS -m 30 "$API/jobdetails/$seg" -H "X-API-Key: $KEY" -o "$TMP" \
|
||||||
|
|| { echo "# Arbeitsagentur-API nicht erreichbar" >&2; exit 0; }
|
||||||
|
|
||||||
|
REF="$ref" RESP_FILE="$TMP" python3 <<'PY'
|
||||||
|
import os, sys, json, html
|
||||||
|
try:
|
||||||
|
with open(os.environ['RESP_FILE'], encoding='utf-8') as fh:
|
||||||
|
d = json.load(fh)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'# detail: ungueltige Antwort ({e})', file=sys.stderr); sys.exit(0)
|
||||||
|
|
||||||
|
titel = d.get('stellenangebotsTitel') or d.get('titel')
|
||||||
|
if not titel and not d.get('stellenangebotsBeschreibung'):
|
||||||
|
print(f'# refnr {os.environ.get("REF","")}: keine aktive Anzeige (abgelaufen/entfernt)')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def u(v): return html.unescape(v) if isinstance(v, str) else v
|
||||||
|
lok = (d.get('stellenlokationen') or [{}])[0]
|
||||||
|
adr = ', '.join(x for x in [lok.get('strasse'),
|
||||||
|
' '.join(y for y in [lok.get('plz'), lok.get('ort')] if y)] if x)
|
||||||
|
|
||||||
|
print('FIRMA:', u(d.get('firma')))
|
||||||
|
print('TITEL:', u(titel))
|
||||||
|
print('ART:', d.get('stellenangebotsart'))
|
||||||
|
print('ZEITARBEIT_AUE:', d.get('istArbeitnehmerUeberlassung'))
|
||||||
|
print('PRIVATE_ARBEITSVERMITTLUNG:', d.get('istPrivateArbeitsvermittlung'))
|
||||||
|
print('HOMEOFFICE_MOEGLICH:', d.get('homeofficemoeglich'))
|
||||||
|
print('VERGUETUNG:', d.get('verguetungsangabe'))
|
||||||
|
print('ADRESSE:', adr or '(keine)')
|
||||||
|
print('EXTERNE_URL:', u(d.get('externeURL')) or '(keine — dann Firmen-Karriereseite suchen)')
|
||||||
|
print('REFERENZNUMMER:', d.get('referenznummer') or os.environ.get('REF',''))
|
||||||
|
print('VEROEFFENTLICHT:', d.get('datumErsteVeroeffentlichung') or d.get('aktuelleVeroeffentlichungsdatum'))
|
||||||
|
print('AENDERUNG:', d.get('aenderungsdatum'))
|
||||||
|
print('--- BESCHREIBUNG ---')
|
||||||
|
print(u(d.get('stellenangebotsBeschreibung')) or '(keine)')
|
||||||
|
PY
|
||||||
|
|
||||||
|
else
|
||||||
|
usage
|
||||||
|
fi
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Prints the Bewerbungs-Tracker BLACKLIST in a matchable, tab-separated form, so
|
||||||
|
# a job-search run can discard candidates that are blocked BEFORE listing or
|
||||||
|
# importing them (server also rejects blocked offers with 409 on POST /joboffers,
|
||||||
|
# but we don't want to even present them).
|
||||||
|
#
|
||||||
|
# Output: one blacklist entry per line, tab-separated:
|
||||||
|
# id <TAB> typ <TAB> firma <TAB> stelle <TAB> ort <TAB> domain <TAB> url_norm <TAB> firma_norm <TAB> firma_slug <TAB> stelle_norm <TAB> ort_norm <TAB> grund
|
||||||
|
#
|
||||||
|
# typ = url | domain | firma | firma_stelle | auto
|
||||||
|
#
|
||||||
|
# firma_slug = robuster Firmen-Schlüssel (Umlaut-Translit, End-Rechtsform raus,
|
||||||
|
# mit "-" verbunden) — identisch zu applied-set.sh und firmaSlug()
|
||||||
|
# in lib/blacklist.js. Damit greift die Firmen-Sperre auch bei
|
||||||
|
# abweichender Schreibweise/Rechtsform.
|
||||||
|
#
|
||||||
|
# A candidate is BLOCKED when one entry matches:
|
||||||
|
# typ=url -> candidate URL (normalized) == url_norm
|
||||||
|
# typ=domain -> candidate URL host == domain (also matches subdomains)
|
||||||
|
# typ=firma -> SAME company: candidate firma_slug == firma_slug, OR one
|
||||||
|
# firma_slug is a leading hyphen-prefix of the other (>=2
|
||||||
|
# tokens); firma_norm only as fallback for legacy rows.
|
||||||
|
# typ=firma_stelle -> same company (firma_slug, wie oben) AND stelle_norm equal
|
||||||
|
# (ort_norm only narrows further when set)
|
||||||
|
# typ=auto -> treat like the concrete fields it carries (url/firma_stelle)
|
||||||
|
#
|
||||||
|
# The server already provides the *_norm fields (lowercased, tracking params
|
||||||
|
# stripped, gender/legal-form removed), so compare against those.
|
||||||
|
#
|
||||||
|
# Reuses the bewerbungs-tracker skill's helper for auth/base-URL.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BT="${BT_SCRIPT:-$HOME/.claude/skills/bewerbungs-tracker/scripts/bt.sh}"
|
||||||
|
if [[ ! -x "$BT" ]]; then
|
||||||
|
echo "blacklist-set.sh: bewerbungs-tracker helper not found at $BT" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
bl="$("$BT" GET '/joboffers/blacklist')"
|
||||||
|
|
||||||
|
BL_JSON="$bl" python3 <<'PY'
|
||||||
|
import os, json, html, sys
|
||||||
|
|
||||||
|
def load(name):
|
||||||
|
raw = os.environ[name].split('<http')[0]
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def clean(v):
|
||||||
|
return html.unescape(str(v if v is not None else '')).replace('\t', ' ').strip()
|
||||||
|
|
||||||
|
rows = load('BL_JSON')
|
||||||
|
for e in rows:
|
||||||
|
print('\t'.join(clean(e.get(k)) for k in (
|
||||||
|
'id', 'typ', 'firma', 'stelle', 'ort', 'domain',
|
||||||
|
'url_norm', 'firma_norm', 'firma_slug', 'stelle_norm', 'ort_norm', 'grund')))
|
||||||
|
|
||||||
|
print(f'# {len(rows)} Blacklist-Eintraege', file=sys.stderr)
|
||||||
|
PY
|
||||||
+48
-37
@@ -1,50 +1,61 @@
|
|||||||
# NextJobs – Indeed Import (Browser-Erweiterung)
|
# NextJobs – Stellen-Import (Browser-Erweiterung)
|
||||||
|
|
||||||
Chromium-Erweiterung (Manifest V3), die auf Indeed einen Button **direkt neben der
|
Chromium-Erweiterung (Manifest V3), die **auf jeder Webseite** Stellen
|
||||||
Stellenbeschreibung** einfügt. Ein Klick sendet alle Stelleninformationen an den
|
ausliest und an NextJobs (`https://nextjobs.cc`) sendet. Auf Indeed wird
|
||||||
NextJobs. Dort wird die Stelle als **Entwurf** angelegt und die KI
|
zusätzlich ein Button *direkt neben der Stellenbeschreibung* eingeblendet.
|
||||||
erstellt automatisch zugeschnittene Bewerbungsunterlagen (auf Basis der hinterlegten
|
Die Stelle wird im Tracker als **Entwurf** angelegt; die KI erstellt daraus
|
||||||
Vorlagen).
|
zugeschnittene Bewerbungsunterlagen (auf Basis der hinterlegten Vorlagen).
|
||||||
|
|
||||||
|
## Multi-User & Authentifizierung
|
||||||
|
|
||||||
|
Die Tracker-Adresse ist fest `https://nextjobs.cc` (hardcodiert). Jeder
|
||||||
|
Benutzer authentifiziert sich mit seinem **persönlichen REST-API-Key**:
|
||||||
|
|
||||||
|
- Im Tracker unter **Einstellungen → API-Token (X-API-Key)** einen Token
|
||||||
|
setzen (falls noch keiner da ist: neu vergeben).
|
||||||
|
- In der Erweiterung auf das Symbol klicken → **Einstellungen** → Token
|
||||||
|
eintragen → **Speichern** + **Verbindung testen**.
|
||||||
|
|
||||||
|
Der Key wird im `X-API-Key`-Header gesendet; der Server ordnet den Import dem
|
||||||
|
Benutzerkonto zu, das diesen Token hinterlegt hat. Ohne (oder mit ungültigem)
|
||||||
|
Key antwortet der Server mit **401**. Der Key wird pro Browser-Profil in der
|
||||||
|
`chrome.storage.sync` gespeichert — wer mehrere Konten verwaltet, kann den Key
|
||||||
|
jederzeit in den Einstellungen wechseln.
|
||||||
|
|
||||||
## Installation (Entwicklermodus)
|
## Installation (Entwicklermodus)
|
||||||
|
|
||||||
1. NextJobs starten (Standard: `http://localhost:3000`).
|
1. In Chrome/Edge/Brave `chrome://extensions` öffnen.
|
||||||
2. In Chrome/Edge/Brave `chrome://extensions` öffnen.
|
2. **Entwicklermodus** oben rechts aktivieren.
|
||||||
3. **Entwicklermodus** oben rechts aktivieren.
|
3. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner
|
||||||
4. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner auswählen.
|
auswählen.
|
||||||
|
4. API-Key eintragen (siehe oben).
|
||||||
## Konfiguration
|
|
||||||
|
|
||||||
Auf das Erweiterungs-Symbol klicken und die **Tracker-Adresse** setzen
|
|
||||||
(z. B. `http://localhost:3000` oder die Adresse deiner Deployment-Instanz).
|
|
||||||
Mit **Verbindung testen** prüfen, ob der Tracker erreichbar ist.
|
|
||||||
|
|
||||||
## Verwendung
|
## Verwendung
|
||||||
|
|
||||||
1. Auf `indeed.com` eine Stelle öffnen.
|
1. Auf einer Stellenanzeige das Erweiterungs-Symbol klicken. Die Felder werden
|
||||||
2. Neben der Überschrift **„Vollständige Stellenbeschreibung"** erscheint der grüne
|
automatisch ausgelesen (Schema.org/JSON-LD → OpenGraph → Auswahl/Text).
|
||||||
Button **„An NextJobs senden"**.
|
Text auf der Seite markieren → wird als Beschreibung übernommen.
|
||||||
3. Klicken → die Stelle wird als Entwurf angelegt, die Unterlagen werden im
|
2. Felder prüfen, ggf. korrigieren und **An Tracker senden** klicken.
|
||||||
Hintergrund generiert. Über den Link „Entwurf öffnen" gelangst du direkt zur
|
Auf Indeed alternativ den grünen Button **„An NextJobs senden"** neben
|
||||||
Bewerbung im Tracker.
|
der Stellenbeschreibung.
|
||||||
|
3. Über den Link **Entwurf öffnen** gelangst du direkt zur Bewerbung im Tracker.
|
||||||
|
|
||||||
## Voraussetzungen im Tracker
|
## Voraussetzungen im Tracker
|
||||||
|
|
||||||
- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben, Lebenslauf)
|
- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben,
|
||||||
hinterlegen — diese dienen der KI als Faktengrundlage.
|
Lebenslauf) hinterlegen — diese dienen der KI als Faktengrundlage.
|
||||||
- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt – z. B. in der `.env`-Datei des Trackers –,
|
- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt – z. B. in den Einstellungen des
|
||||||
damit die KI-Generierung läuft.
|
Benutzers – damit die KI-Generierung läuft.
|
||||||
|
- Ein **API-Token (X-API-Key)** für den benutzten Account.
|
||||||
|
|
||||||
## Ausgelesene Felder
|
## Berechtigungen
|
||||||
|
|
||||||
| Feld | Quelle (Indeed DOM) |
|
| Berechtigung | Zweck |
|
||||||
|------|---------------------|
|
|---|---|
|
||||||
| Stelle | `[data-testid="jobsearch-JobInfoHeader-title"]` |
|
| `storage` | API-Key speichern (`chrome.storage.sync`) |
|
||||||
| Firma | `[data-testid="inlineHeader-companyName"]` (im `jobsearch-CompanyInfoContainer`) |
|
| `scripting` + `activeTab` | Stelle auf der aktiven Seite auslesen (nur auf Klick) |
|
||||||
| Ort | `[data-testid="jobsearch-JobInfoHeader-companyLocation"]` |
|
| `https://nextjobs.cc/*` | Import an den Tracker senden |
|
||||||
| Gehalt/Art | `#salaryInfoAndJobType` |
|
| `*://*.indeed.com/*` | Inline-Button auf Indeed einblenden |
|
||||||
| Stellenbeschreibung | `#jobDescriptionText` |
|
|
||||||
| Quelle-URL | aus `vjk`/`jk`-Parameter bzw. aktueller URL |
|
|
||||||
|
|
||||||
Indeed ist eine Single-Page-App; die Erweiterung erkennt Wechsel der geöffneten
|
Die Erweiterung liest nur die aktive Seite aus, wenn du sie aufrufst, und
|
||||||
Stelle per `MutationObserver` und hält den Button aktuell.
|
sendet die Daten ausschließlich an `https://nextjobs.cc`.
|
||||||
+26
-15
@@ -1,15 +1,18 @@
|
|||||||
// Service worker: performs the cross-origin POST to the NextJobs.
|
// Service worker: performs the cross-origin POST to NextJobs.
|
||||||
// Routing the request through the background worker (instead of the content
|
// Routing the request through the background worker (instead of a content
|
||||||
// script) keeps it independent of the Indeed page's Content-Security-Policy.
|
// script) keeps it independent of the page's Content-Security-Policy.
|
||||||
|
//
|
||||||
|
// Die Tracker-Adresse ist fest (https://nextjobs.cc). Die Authentifizierung
|
||||||
|
// läuft über den persönlichen REST-API-Key (Header X-API-Key), den jeder
|
||||||
|
// Benutzer im Popup hinterlegt — der Server ordnet den Import damit dem
|
||||||
|
// richtigen Benutzerkonto zu. Ohne Key lehnt der Server mit 401 ab.
|
||||||
|
|
||||||
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
|
const TRACKER_URL = 'https://nextjobs.cc';
|
||||||
|
|
||||||
function getTrackerUrl() {
|
function getApiKey() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
|
chrome.storage.sync.get({ apiKey: '' }, (items) => {
|
||||||
let url = (items.trackerUrl || DEFAULT_TRACKER_URL).trim();
|
resolve((items.apiKey || '').trim());
|
||||||
url = url.replace(/\/+$/, ''); // strip trailing slashes
|
|
||||||
resolve(url);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -18,10 +21,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
if (message && message.type === 'IMPORT_JOB') {
|
if (message && message.type === 'IMPORT_JOB') {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const base = await getTrackerUrl();
|
const apiKey = await getApiKey();
|
||||||
const res = await fetch(base + '/api/indeed-import', {
|
if (!apiKey) {
|
||||||
|
sendResponse({ ok: false, needKey: true, error: 'Kein API-Key hinterlegt.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch(TRACKER_URL + '/api/indeed-import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
||||||
body: JSON.stringify(message.payload),
|
body: JSON.stringify(message.payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,6 +46,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (res.status === 401) {
|
||||||
|
sendResponse({ ok: false, needKey: true, error: 'API-Key abgelehnt (401). Bitte in den Einstellungen prüfen.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
sendResponse({
|
sendResponse({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: (data && data.error) || `Server antwortete mit ${res.status}`,
|
error: (data && data.error) || `Server antwortete mit ${res.status}`,
|
||||||
@@ -50,14 +61,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
id: data.id,
|
id: data.id,
|
||||||
message: data.message,
|
message: data.message,
|
||||||
openUrl: data.url ? base + data.url : null,
|
openUrl: data.url ? TRACKER_URL + data.url : null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendResponse({
|
sendResponse({
|
||||||
ok: false,
|
ok: false,
|
||||||
error:
|
error:
|
||||||
'Konnte den NextJobs nicht erreichen. Läuft er, und ist die ' +
|
'Konnte NextJobs nicht erreichen (' + String(err.message) + '). ' +
|
||||||
'Adresse in den Erweiterungs-Einstellungen korrekt? (' + String(err.message) + ')',
|
'Prüfe deine Internetverbindung und ob der API-Key in den Einstellungen korrekt ist.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "NextJobs – Stellen-Import",
|
"name": "NextJobs – Stellen-Import",
|
||||||
"version": "1.1.0",
|
"version": "2.0.0",
|
||||||
"description": "Erfasst Stellen von jeder Webseite (nicht nur Indeed) über das Erweiterungs-Popup und sendet sie an den NextJobs, der automatisch zugeschnittene Bewerbungsunterlagen erstellt.",
|
"description": "Erfasst Stellen von jeder Webseite (nicht nur Indeed) über das Erweiterungs-Popup und sendet sie an NextJobs (https://nextjobs.cc), der automatisch zugeschnittene Bewerbungsunterlagen erstellt. Authentifizierung über deinen persönlichen REST-API-Key.",
|
||||||
"permissions": ["storage", "scripting", "activeTab"],
|
"permissions": ["storage", "scripting", "activeTab"],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"*://*.indeed.com/*",
|
"https://nextjobs.cc/*",
|
||||||
"http://localhost/*",
|
"*://*.indeed.com/*"
|
||||||
"http://127.0.0.1/*",
|
|
||||||
"http://*/*",
|
|
||||||
"https://*/*"
|
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,10 +94,11 @@
|
|||||||
|
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
|
|
||||||
<details id="settings">
|
<details id="settings" open>
|
||||||
<summary>Einstellungen</summary>
|
<summary>Einstellungen</summary>
|
||||||
<label for="trackerUrl">Tracker-Adresse</label>
|
<label for="apiKey">REST-API-Key</label>
|
||||||
<input type="url" id="trackerUrl" placeholder="http://localhost:3000" />
|
<input type="password" id="apiKey" placeholder="API-Key aus den Tracker-Einstellungen" autocomplete="off" />
|
||||||
|
<p class="hint" style="margin:6px 0 0">Deinen persönlichen Key findest du im Tracker unter <b>Einstellungen → API-Token (X-API-Key)</b>. Pro Benutzer eigener Key.</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="primary" id="saveBtn">Speichern</button>
|
<button class="primary" id="saveBtn">Speichern</button>
|
||||||
<button class="secondary" id="testBtn">Verbindung testen</button>
|
<button class="secondary" id="testBtn">Verbindung testen</button>
|
||||||
|
|||||||
+25
-14
@@ -1,36 +1,40 @@
|
|||||||
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
|
// Tracker-Adresse ist fest (gehostete Instanz). Jeder Benutzer gibt seinen
|
||||||
|
// eigenen REST-API-Key ein (X-API-Key), über den der Server den Import dem
|
||||||
|
// richtigen Benutzerkonto zuordnet — siehe /api/indeed-import serverseitig.
|
||||||
|
const TRACKER_URL = 'https://nextjobs.cc';
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const statusEl = $('status');
|
const statusEl = $('status');
|
||||||
|
|
||||||
function normalize(url) {
|
|
||||||
return (url || '').trim().replace(/\/+$/, '');
|
|
||||||
}
|
|
||||||
function setStatus(html, cls) {
|
function setStatus(html, cls) {
|
||||||
statusEl.innerHTML = html;
|
statusEl.innerHTML = html;
|
||||||
statusEl.className = cls || '';
|
statusEl.className = cls || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Settings (tracker URL) ------------------------------------------------
|
// ---- Settings (API-Key) ----------------------------------------------------
|
||||||
|
|
||||||
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
|
chrome.storage.sync.get({ apiKey: '' }, (items) => {
|
||||||
$('trackerUrl').value = items.trackerUrl || DEFAULT_TRACKER_URL;
|
$('apiKey').value = items.apiKey || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
$('saveBtn').addEventListener('click', () => {
|
$('saveBtn').addEventListener('click', () => {
|
||||||
const url = normalize($('trackerUrl').value) || DEFAULT_TRACKER_URL;
|
const key = $('apiKey').value.trim();
|
||||||
chrome.storage.sync.set({ trackerUrl: url }, () => {
|
chrome.storage.sync.set({ apiKey: key }, () => {
|
||||||
$('trackerUrl').value = url;
|
setStatus(key ? 'API-Key gespeichert.' : 'API-Key entfernt.', key ? 'ok' : '');
|
||||||
setStatus('Einstellungen gespeichert.', 'ok');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$('testBtn').addEventListener('click', async () => {
|
$('testBtn').addEventListener('click', async () => {
|
||||||
const url = normalize($('trackerUrl').value) || DEFAULT_TRACKER_URL;
|
const key = $('apiKey').value.trim();
|
||||||
|
if (!key) { setStatus('Bitte erst API-Key eingeben.', 'err'); return; }
|
||||||
setStatus('Teste Verbindung …', '');
|
setStatus('Teste Verbindung …', '');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url + '/api/settings', { method: 'GET' });
|
const res = await fetch(TRACKER_URL + '/api/v1/health', {
|
||||||
setStatus(res.ok ? 'Verbindung erfolgreich ✓' : 'Erreichbar, aber unerwartete Antwort (' + res.status + ').', res.ok ? 'ok' : 'err');
|
headers: { 'X-API-Key': key },
|
||||||
|
});
|
||||||
|
if (res.ok) setStatus('Verbindung erfolgreich ✓ — Key gültig.', 'ok');
|
||||||
|
else if (res.status === 401) setStatus('Key abgelehnt (401). Bitte API-Key prüfen.', 'err');
|
||||||
|
else setStatus('Erreichbar, aber unerwartete Antwort (' + res.status + ').', 'err');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('Nicht erreichbar: ' + err.message, 'err');
|
setStatus('Nicht erreichbar: ' + err.message, 'err');
|
||||||
}
|
}
|
||||||
@@ -158,6 +162,13 @@ function sendJob(payload) {
|
|||||||
setStatus('Fehler: ' + chrome.runtime.lastError.message, 'err');
|
setStatus('Fehler: ' + chrome.runtime.lastError.message, 'err');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (resp && resp.needKey) {
|
||||||
|
$('sendBtn').disabled = false;
|
||||||
|
setStatus('Bitte erst in den Einstellungen deinen API-Key hinterlegen.', 'err');
|
||||||
|
const s = document.getElementById('settings'); if (s) s.open = true;
|
||||||
|
$('apiKey').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (resp && resp.duplicate) {
|
if (resp && resp.duplicate) {
|
||||||
$('sendBtn').disabled = false;
|
$('sendBtn').disabled = false;
|
||||||
if (confirm(duplicateWarning(resp.matches))) {
|
if (confirm(duplicateWarning(resp.matches))) {
|
||||||
|
|||||||
+11
-7
@@ -12,7 +12,7 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const blacklist = require('./blacklist');
|
const blacklist = require('./blacklist');
|
||||||
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
|
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
|
||||||
const { normalizeDokumente } = require('./documents');
|
const { normalizeDokumente, standardDokumente } = require('./documents');
|
||||||
const { userContext, currentUserId } = require('./context');
|
const { userContext, currentUserId } = require('./context');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ function withLabels(row) {
|
|||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
||||||
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||||||
'Vorstellungsgespräch', 'Vertragsverhandlung',
|
'Vorstellungsgespräch', 'Vertragsverhandlung',
|
||||||
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung',
|
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung',
|
||||||
];
|
];
|
||||||
@@ -452,14 +452,18 @@ function createExternalApi(deps) {
|
|||||||
[id, uid()]
|
[id, uid()]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Optional: IDs of static attachments (basis_anhaenge) to enclose; none
|
// Optional: IDs of static attachments (basis_anhaenge) to enclose. Omitted
|
||||||
// by default. Also reflected in the cover letter's "Anlagen" list.
|
// means the user's preselection from the settings (GEN_ANLAGEN_DEFAULT,
|
||||||
|
// default: none); an empty array means explicitly none. Also reflected in
|
||||||
|
// the cover letter's "Anlagen" list.
|
||||||
const anlagenIds = Array.isArray((req.body || {}).anlagen)
|
const anlagenIds = Array.isArray((req.body || {}).anlagen)
|
||||||
? req.body.anlagen.map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n))
|
? req.body.anlagen.map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n))
|
||||||
: [];
|
: config.anlagenDefaultIds();
|
||||||
// Optional: which documents to produce (["anschreiben"], ["lebenslauf"] or
|
// Optional: which documents to produce (["anschreiben"], ["lebenslauf"] or
|
||||||
// both). Omitted / empty means both.
|
// both). Omitted / empty falls back to the user's preselection from the
|
||||||
const dokumente = normalizeDokumente((req.body || {}).dokumente);
|
// settings (GEN_DOKUMENTE_DEFAULT, default: both).
|
||||||
|
const gewuenscht = (req.body || {}).dokumente;
|
||||||
|
const dokumente = gewuenscht == null ? standardDokumente() : normalizeDokumente(gewuenscht);
|
||||||
await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ? AND user_id = ?', [dokumente.join(','), id, uid()]);
|
await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ? AND user_id = ?', [dokumente.join(','), id, uid()]);
|
||||||
runGeneration(id, { anlagenIds, dokumente });
|
runGeneration(id, { anlagenIds, dokumente });
|
||||||
res.status(202).json({ success: true, dokumente });
|
res.status(202).json({ success: true, dokumente });
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ function localInputToUtc(dateStr, timeStr) {
|
|||||||
return wallToUtc(y, mo, d, h || 0, mi || 0, TZ);
|
return wallToUtc(y, mo, d, h || 0, mi || 0, TZ);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UTC Date -> Berliner Wanduhr-Bestandteile { datum: 'YYYY-MM-DD', zeit: 'HH:MM' }
|
||||||
|
// (für vorausgefüllte Bearbeiten-Formulare).
|
||||||
|
function utcToWallInputs(date) {
|
||||||
|
const p = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||||
|
}).formatToParts(date).reduce((a, x) => (a[x.type] = x.value, a), {});
|
||||||
|
return { datum: `${p.year}-${p.month}-${p.day}`, zeit: `${p.hour}:${p.minute}` };
|
||||||
|
}
|
||||||
|
|
||||||
// UTC Date -> iCalendar UTC stamp "YYYYMMDDTHHMMSSZ".
|
// UTC Date -> iCalendar UTC stamp "YYYYMMDDTHHMMSSZ".
|
||||||
function toIcsUtc(date) {
|
function toIcsUtc(date) {
|
||||||
return date.getUTCFullYear() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate())
|
return date.getUTCFullYear() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate())
|
||||||
@@ -335,6 +345,7 @@ module.exports = {
|
|||||||
collectionUrl,
|
collectionUrl,
|
||||||
wallToUtc,
|
wallToUtc,
|
||||||
localInputToUtc,
|
localInputToUtc,
|
||||||
|
utcToWallInputs,
|
||||||
buildICS,
|
buildICS,
|
||||||
parseVEvent,
|
parseVEvent,
|
||||||
getCtag,
|
getCtag,
|
||||||
|
|||||||
+46
-4
@@ -23,9 +23,15 @@ const { currentUser } = require('./context');
|
|||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
// Ollama Cloud (KI text generation + chat).
|
// Ollama Cloud (KI text generation + chat).
|
||||||
OLLAMA_API_KEY: '',
|
OLLAMA_API_KEY: '',
|
||||||
OLLAMA_MODEL: 'gpt-oss:120b',
|
OLLAMA_MODEL: 'glm-5.2:cloud',
|
||||||
OLLAMA_HOST: 'https://ollama.com',
|
OLLAMA_HOST: 'https://ollama.com',
|
||||||
OLLAMA_TIMEOUT_MS: '300000',
|
OLLAMA_TIMEOUT_MS: '300000',
|
||||||
|
// Which documents are preselected when a generation is started (comma separated,
|
||||||
|
// see DOKUMENT_TYPEN in lib/documents.js). Empty means both.
|
||||||
|
GEN_DOKUMENTE_DEFAULT: 'anschreiben,lebenslauf',
|
||||||
|
// Which static attachments (basis_anhaenge IDs, comma separated) are preselected.
|
||||||
|
// Empty = none, which is the default.
|
||||||
|
GEN_ANLAGEN_DEFAULT: '',
|
||||||
// E-Mail (SMTP submission + IMAP receive).
|
// E-Mail (SMTP submission + IMAP receive).
|
||||||
MAIL_HOST: '',
|
MAIL_HOST: '',
|
||||||
MAIL_SMTP_PORT: '587',
|
MAIL_SMTP_PORT: '587',
|
||||||
@@ -52,11 +58,37 @@ const FIELDS = [
|
|||||||
beschreibung: 'Steuerung der KI, die Bewerbungsunterlagen erzeugt und den KI-Chat beantwortet. Ohne API-Schlüssel sind diese Funktionen deaktiviert.',
|
beschreibung: 'Steuerung der KI, die Bewerbungsunterlagen erzeugt und den KI-Chat beantwortet. Ohne API-Schlüssel sind diese Funktionen deaktiviert.',
|
||||||
items: [
|
items: [
|
||||||
{ key: 'OLLAMA_API_KEY', label: 'API-Schlüssel', secret: true, help: 'Schlüssel von https://ollama.com/settings/keys' },
|
{ key: 'OLLAMA_API_KEY', label: 'API-Schlüssel', secret: true, help: 'Schlüssel von https://ollama.com/settings/keys' },
|
||||||
{ key: 'OLLAMA_MODEL', label: 'Modell', help: 'Standard: gpt-oss:120b' },
|
{ key: 'OLLAMA_MODEL', label: 'Modell', help: 'Standard: glm-5.2:cloud' },
|
||||||
{ key: 'OLLAMA_HOST', label: 'Host', help: 'Standard: https://ollama.com — lokaler Ollama: http://localhost:11434' },
|
{ key: 'OLLAMA_HOST', label: 'Host', help: 'Standard: https://ollama.com — lokaler Ollama: http://localhost:11434' },
|
||||||
{ key: 'OLLAMA_TIMEOUT_MS', label: 'Timeout (ms)', help: 'Standard: 300000 (5 Min.)' },
|
{ key: 'OLLAMA_TIMEOUT_MS', label: 'Timeout (ms)', help: 'Standard: 300000 (5 Min.)' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
titel: 'Bewerbungsunterlagen (Vorauswahl)',
|
||||||
|
beschreibung: 'Was ist auf der Bewerbungsseite vorab angehakt, wenn die KI-Generierung gestartet wird? Pro Bewerbung lässt sich die Auswahl weiterhin ändern; die zuletzt gewählten Dokumente bleiben an der jeweiligen Bewerbung erhalten.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'GEN_DOKUMENTE_DEFAULT',
|
||||||
|
label: 'Standardmäßig generieren',
|
||||||
|
type: 'checkboxes',
|
||||||
|
options: [
|
||||||
|
{ value: 'anschreiben', label: 'Anschreiben' },
|
||||||
|
{ value: 'lebenslauf', label: 'Lebenslauf' },
|
||||||
|
],
|
||||||
|
help: 'Standard: beides. Die E-Mail-Begleitnachricht wird immer erzeugt. Ohne Häkchen gilt wieder beides.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'GEN_ANLAGEN_DEFAULT',
|
||||||
|
label: 'Standardmäßig beigelegte Anlagen',
|
||||||
|
type: 'checkboxes',
|
||||||
|
// Auswahlmöglichkeiten sind die eigenen Anlagen aus „Vorlagen“ und stehen
|
||||||
|
// erst zur Renderzeit fest — die Route reicht sie unter diesem Namen hinein.
|
||||||
|
optionsFrom: 'anlagen',
|
||||||
|
leerHinweis: 'Noch keine Anlagen hinterlegt – unter „Vorlagen“ kannst du Zeugnisse & Co. hochladen.',
|
||||||
|
help: 'Standard: keine. Angehakte Anlagen werden bei jeder Generierung vorausgewählt und im Anschreiben unter „Anlagen“ aufgeführt.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
titel: 'E-Mail (SMTP-Versand + IMAP-Empfang)',
|
titel: 'E-Mail (SMTP-Versand + IMAP-Empfang)',
|
||||||
beschreibung: 'Versand läuft über den eigenen Mailserver (DKIM/SPF/DMARC-Alignment). Ohne Host/Benutzer/Passwort ist der E-Mail-Teil deaktiviert. Pro Benutzer eigenes Postfach.',
|
beschreibung: 'Versand läuft über den eigenen Mailserver (DKIM/SPF/DMARC-Alignment). Ohne Host/Benutzer/Passwort ist der E-Mail-Teil deaktiviert. Pro Benutzer eigenes Postfach.',
|
||||||
@@ -159,11 +191,21 @@ function getAll() {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The current user's preselected static attachments (GEN_ANLAGEN_DEFAULT) as
|
||||||
|
// basis_anhaenge IDs. Deleted attachments may linger in the list; callers match
|
||||||
|
// them against the existing rows, so stale IDs simply never hit.
|
||||||
|
function anlagenDefaultIds() {
|
||||||
|
return String(get('GEN_ANLAGEN_DEFAULT') || '')
|
||||||
|
.split(',')
|
||||||
|
.map((v) => parseInt(v, 10))
|
||||||
|
.filter((n) => !Number.isNaN(n));
|
||||||
|
}
|
||||||
|
|
||||||
// Ollama bundle (shared by lib/documents.js + lib/chat.js), for the current user.
|
// Ollama bundle (shared by lib/documents.js + lib/chat.js), for the current user.
|
||||||
function ollama() {
|
function ollama() {
|
||||||
return {
|
return {
|
||||||
host: (get('OLLAMA_HOST') || 'https://ollama.com').replace(/\/+$/, ''),
|
host: (get('OLLAMA_HOST') || 'https://ollama.com').replace(/\/+$/, ''),
|
||||||
model: get('OLLAMA_MODEL') || 'gpt-oss:120b',
|
model: get('OLLAMA_MODEL') || 'glm-5.2:cloud',
|
||||||
timeoutMs: Number(get('OLLAMA_TIMEOUT_MS')) || 300000,
|
timeoutMs: Number(get('OLLAMA_TIMEOUT_MS')) || 300000,
|
||||||
apiKey: get('OLLAMA_API_KEY') || '',
|
apiKey: get('OLLAMA_API_KEY') || '',
|
||||||
};
|
};
|
||||||
@@ -213,5 +255,5 @@ async function setForUser(userId, key, value) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
DEFAULTS, FIELDS, PREFIX,
|
DEFAULTS, FIELDS, PREFIX,
|
||||||
get, getAll, ollama, init, saveAll, setForUser, ensureLoaded, invalidate,
|
get, getAll, ollama, anlagenDefaultIds, init, saveAll, setForUser, ensureLoaded, invalidate,
|
||||||
};
|
};
|
||||||
+168
-43
@@ -3,7 +3,7 @@
|
|||||||
// Same contract as lib/prompts.js: the defaults live here, an override is a row
|
// Same contract as lib/prompts.js: the defaults live here, an override is a row
|
||||||
// in the `design` table, and deleting the row restores the default.
|
// in the `design` table, and deleting the row restores the default.
|
||||||
//
|
//
|
||||||
// Two layouts ship today:
|
// Three layouts ship today:
|
||||||
//
|
//
|
||||||
// sidebar — the classic: neutral greys, tinted sidebar, DIN-5008 letter. The
|
// sidebar — the classic: neutral greys, tinted sidebar, DIN-5008 letter. The
|
||||||
// accent is a single restrained colour; hierarchy comes from weight
|
// accent is a single restrained colour; hierarchy comes from weight
|
||||||
@@ -13,15 +13,28 @@
|
|||||||
// headline, a full-page tinted background). Right for creative,
|
// headline, a full-page tinted background). Right for creative,
|
||||||
// social-media and design-adjacent roles — and deliberately loud.
|
// social-media and design-adjacent roles — and deliberately loud.
|
||||||
//
|
//
|
||||||
|
// sunny — an editorial poster look: white page, oversized display headings
|
||||||
|
// in a deep accent, pastel accent bars that alternate left/right, a
|
||||||
|
// photo breaking out of a tinted hero band, a solid role badge and
|
||||||
|
// level bars for the languages. Bold but tidy — right for marketing,
|
||||||
|
// media and design roles that still want a readable, gridded page.
|
||||||
|
//
|
||||||
|
// tupfen — the quiet one with a wink: a white page, the name centred and set
|
||||||
|
// large in plain ink, airy body text — and scattered coral dots in
|
||||||
|
// the margins, some running off the page edge. The dots are the only
|
||||||
|
// colour on the sheet; the résumé picks the motif back up as section
|
||||||
|
// markers, bullets and five-dot language meters. Restrained enough
|
||||||
|
// for any office, but nobody mistakes it for a template.
|
||||||
|
//
|
||||||
// The cover letter always follows the résumé's layout, so both documents arrive
|
// The cover letter always follows the résumé's layout, so both documents arrive
|
||||||
// as one coherent set — that is the whole point of picking a design here.
|
// as one coherent set — that is the whole point of picking a design here.
|
||||||
//
|
//
|
||||||
// The palette stays closed (no free colour picker): every tone is one that still
|
// The palette stays closed (no free colour picker): every tone is one that still
|
||||||
// works when an HR department prints it in greyscale.
|
// works when an HR department prints it in greyscale.
|
||||||
|
|
||||||
// Accent colours. In `sidebar` the accent is the *only* colour; in `social` the
|
// Accent colours. In `sidebar` the accent is the *only* colour; in `social` and
|
||||||
// whole page palette (background, cards, pills) is derived from it, so the
|
// `sunny` the whole page palette (bands, cards, pills) is derived from it, so
|
||||||
// playful layout works in pink as well as in deep blue.
|
// those layouts work in berry as well as in deep blue.
|
||||||
const AKZENTE = [
|
const AKZENTE = [
|
||||||
{ key: 'anthrazit', label: 'Anthrazit', rgb: [23, 23, 23] },
|
{ key: 'anthrazit', label: 'Anthrazit', rgb: [23, 23, 23] },
|
||||||
{ key: 'tiefblau', label: 'Tiefblau', rgb: [30, 58, 95] },
|
{ key: 'tiefblau', label: 'Tiefblau', rgb: [30, 58, 95] },
|
||||||
@@ -29,6 +42,11 @@ const AKZENTE = [
|
|||||||
{ key: 'waldgruen', label: 'Waldgrün', rgb: [27, 67, 50] },
|
{ key: 'waldgruen', label: 'Waldgrün', rgb: [27, 67, 50] },
|
||||||
{ key: 'stahlblau', label: 'Stahlblau', rgb: [51, 65, 85] },
|
{ key: 'stahlblau', label: 'Stahlblau', rgb: [51, 65, 85] },
|
||||||
{ key: 'kupfer', label: 'Kupfer', rgb: [124, 62, 24] },
|
{ key: 'kupfer', label: 'Kupfer', rgb: [124, 62, 24] },
|
||||||
|
{ key: 'beere', label: 'Beere (Sunny)', rgb: [109, 24, 74] },
|
||||||
|
// Deliberately deeper than the pastel dots of Tupfen's reference: in `sidebar`
|
||||||
|
// the accent *is* the text colour, and a pale coral would be unreadable there.
|
||||||
|
// Tupfen's dots are tinted up from this tone, which lands them back at pastel.
|
||||||
|
{ key: 'koralle', label: 'Koralle (Tupfen)', rgb: [222, 106, 106] },
|
||||||
{ key: 'pink', label: 'Pink (verspielt)', rgb: [232, 54, 143] },
|
{ key: 'pink', label: 'Pink (verspielt)', rgb: [232, 54, 143] },
|
||||||
{ key: 'violett', label: 'Violett (verspielt)', rgb: [147, 51, 187] },
|
{ key: 'violett', label: 'Violett (verspielt)', rgb: [147, 51, 187] },
|
||||||
];
|
];
|
||||||
@@ -36,6 +54,8 @@ const AKZENTE = [
|
|||||||
const LAYOUTS = [
|
const LAYOUTS = [
|
||||||
{ key: 'sidebar', label: 'Klassisch mit Sidebar (Standard)' },
|
{ key: 'sidebar', label: 'Klassisch mit Sidebar (Standard)' },
|
||||||
{ key: 'social', label: 'Social Media (verspielt)' },
|
{ key: 'social', label: 'Social Media (verspielt)' },
|
||||||
|
{ key: 'sunny', label: 'Sunny (plakativ, Farbbänder)' },
|
||||||
|
{ key: 'tupfen', label: 'Tupfen (dezent, verspielte Punkte)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SIDEBAR_VARIANTEN = [
|
const SIDEBAR_VARIANTEN = [
|
||||||
@@ -63,6 +83,12 @@ const SCHRIFT_STUFEN = [
|
|||||||
const LAYOUT_DEFAULTS = {
|
const LAYOUT_DEFAULTS = {
|
||||||
sidebar: { akzent: 'anthrazit', foto_form: 'eckig' },
|
sidebar: { akzent: 'anthrazit', foto_form: 'eckig' },
|
||||||
social: { akzent: 'pink', foto_form: 'rund' },
|
social: { akzent: 'pink', foto_form: 'rund' },
|
||||||
|
// Sunny is built around the berry/pastel pairing of its reference; the photo
|
||||||
|
// sits in a square frame that breaks out of the hero band, so square it is.
|
||||||
|
sunny: { akzent: 'beere', foto_form: 'eckig' },
|
||||||
|
// Tupfen scatters coral dots; a round portrait is simply the largest of them,
|
||||||
|
// so it falls in with the motif instead of fighting it.
|
||||||
|
tupfen: { akzent: 'koralle', foto_form: 'rund' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
@@ -129,18 +155,37 @@ function settings(overrides = {}) {
|
|||||||
function resolve(overrides = {}) {
|
function resolve(overrides = {}) {
|
||||||
const s = settings(overrides);
|
const s = settings(overrides);
|
||||||
const accent = findOption(AKZENTE, s.akzent, 'anthrazit').rgb;
|
const accent = findOption(AKZENTE, s.akzent, 'anthrazit').rgb;
|
||||||
const social = s.layout === 'social';
|
|
||||||
|
|
||||||
let sidebarBg;
|
let sidebarBg;
|
||||||
if (s.sidebar === 'weiss') sidebarBg = [255, 255, 255];
|
if (s.sidebar === 'weiss') sidebarBg = [255, 255, 255];
|
||||||
else if (s.sidebar === 'getoent') sidebarBg = tint(accent, 0.08);
|
else if (s.sidebar === 'getoent') sidebarBg = tint(accent, 0.08);
|
||||||
else sidebarBg = NEUTRAL_SIDEBAR;
|
else sidebarBg = NEUTRAL_SIDEBAR;
|
||||||
|
|
||||||
// The playful layout derives its whole palette from the accent: a tinted page,
|
// Résumé palettes, one per layout. Every key exists in every palette so the
|
||||||
// near-white cards with a soft border, pill chips, and a warm dark ink that is
|
// renderer can read `t.rc.<x>` without knowing which layout it is drawing.
|
||||||
// the accent shaded down rather than plain black.
|
const RC = {
|
||||||
const rc = social
|
sidebar: {
|
||||||
? {
|
ink: [33, 33, 33],
|
||||||
|
sub: [92, 96, 100],
|
||||||
|
hair: [208, 210, 214],
|
||||||
|
accent,
|
||||||
|
pageBg: null, // no page tint in the classic layout
|
||||||
|
cardBg: [255, 255, 255],
|
||||||
|
cardBorder: [208, 210, 214],
|
||||||
|
deko: [208, 210, 214], // the classic layout draws no sparkles; kept for shape parity
|
||||||
|
dekoHell: [230, 232, 235],
|
||||||
|
band: tint(accent, 0.14),
|
||||||
|
pillBg: tint(accent, 0.12),
|
||||||
|
pillInk: accent,
|
||||||
|
sidebarBg,
|
||||||
|
track: [199, 202, 208],
|
||||||
|
onSide: [38, 40, 44],
|
||||||
|
onSideSub: [96, 100, 106],
|
||||||
|
},
|
||||||
|
// The playful layout derives its whole palette from the accent: a tinted
|
||||||
|
// page, near-white cards with a soft border, pill chips, and a warm dark ink
|
||||||
|
// that is the accent shaded down rather than plain black.
|
||||||
|
social: {
|
||||||
ink: shade(accent, 0.35),
|
ink: shade(accent, 0.35),
|
||||||
sub: tint(shade(accent, 0.55), 0.75),
|
sub: tint(shade(accent, 0.55), 0.75),
|
||||||
hair: tint(accent, 0.30),
|
hair: tint(accent, 0.30),
|
||||||
@@ -151,46 +196,66 @@ function resolve(overrides = {}) {
|
|||||||
// Sparkles/blob sit on the tinted page, so they need more punch than the
|
// Sparkles/blob sit on the tinted page, so they need more punch than the
|
||||||
// card border or they simply vanish.
|
// card border or they simply vanish.
|
||||||
deko: tint(accent, 0.45),
|
deko: tint(accent, 0.45),
|
||||||
|
dekoHell: tint(accent, 0.22),
|
||||||
|
band: tint(accent, 0.22),
|
||||||
pillBg: tint(accent, 0.18),
|
pillBg: tint(accent, 0.18),
|
||||||
pillInk: shade(accent, 0.6),
|
pillInk: shade(accent, 0.6),
|
||||||
sidebarBg, // unused in this layout, kept so the shape stays stable
|
sidebarBg, // unused in this layout, kept so the shape stays stable
|
||||||
track: tint(accent, 0.45),
|
track: tint(accent, 0.45),
|
||||||
onSide: shade(accent, 0.35),
|
onSide: shade(accent, 0.35),
|
||||||
onSideSub: tint(shade(accent, 0.55), 0.75),
|
onSideSub: tint(shade(accent, 0.55), 0.75),
|
||||||
}
|
},
|
||||||
: {
|
// Sunny keeps the page white and puts the colour into solid blocks: pastel
|
||||||
ink: [33, 33, 33],
|
// bands and rails on one side, the full-strength accent in the display
|
||||||
sub: [92, 96, 100],
|
// headings and the role badge. Only two tones, used at maximum contrast.
|
||||||
hair: [208, 210, 214],
|
sunny: {
|
||||||
|
ink: [40, 36, 42],
|
||||||
|
sub: [124, 118, 126],
|
||||||
|
hair: tint(accent, 0.22),
|
||||||
accent,
|
accent,
|
||||||
pageBg: null, // no page tint in the classic layout
|
pageBg: null, // white page — the colour lives in the bands
|
||||||
cardBg: [255, 255, 255],
|
cardBg: [255, 255, 255],
|
||||||
cardBorder: [208, 210, 214],
|
cardBorder: tint(accent, 0.22),
|
||||||
deko: [208, 210, 214], // the classic layout draws no sparkles; kept for shape parity
|
deko: tint(accent, 0.30),
|
||||||
|
dekoHell: tint(accent, 0.16),
|
||||||
|
band: tint(accent, 0.24), // the pastel hero band and section bars
|
||||||
|
pillBg: accent, // solid badge…
|
||||||
|
pillInk: [255, 255, 255], // …with the text knocked out white
|
||||||
|
sidebarBg, // unused
|
||||||
|
track: tint(accent, 0.16), // rail behind a language level bar
|
||||||
|
onSide: [40, 36, 42],
|
||||||
|
onSideSub: [124, 118, 126],
|
||||||
|
},
|
||||||
|
// Tupfen is a monochrome page that happens to have confetti on it. The text
|
||||||
|
// is ink on white throughout — the accent never touches a paragraph, only
|
||||||
|
// the dots: the scattered ones in the margins, the section markers, the
|
||||||
|
// bullets, the language meters. The hairline stays a *neutral* grey rather
|
||||||
|
// than a tinted one, or the restraint collapses and the page turns pink.
|
||||||
|
tupfen: {
|
||||||
|
ink: [26, 26, 28],
|
||||||
|
sub: [112, 114, 120],
|
||||||
|
hair: [226, 228, 232],
|
||||||
|
accent,
|
||||||
|
pageBg: null, // white page — the dots are the whole design
|
||||||
|
cardBg: [255, 255, 255],
|
||||||
|
cardBorder: [226, 228, 232],
|
||||||
|
deko: tint(accent, 0.62), // the dots' base tone
|
||||||
|
dekoHell: tint(accent, 0.34), // …and the paler ones behind them, for depth
|
||||||
|
band: tint(accent, 0.14),
|
||||||
pillBg: tint(accent, 0.12),
|
pillBg: tint(accent, 0.12),
|
||||||
pillInk: accent,
|
pillInk: shade(accent, 0.72),
|
||||||
sidebarBg,
|
sidebarBg, // unused
|
||||||
track: [199, 202, 208],
|
track: tint(accent, 0.20), // an unfilled dot of a language meter
|
||||||
onSide: [38, 40, 44],
|
onSide: [26, 26, 28],
|
||||||
onSideSub: [96, 100, 106],
|
onSideSub: [112, 114, 120],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
const rc = RC[s.layout] || RC.sidebar;
|
||||||
|
|
||||||
// The cover letter always mirrors the résumé, so the two documents read as one
|
// The cover letter always mirrors the résumé, so the two documents read as one
|
||||||
// set — a playful CV never arrives with a stiff monochrome letter.
|
// set — a playful CV never arrives with a stiff monochrome letter.
|
||||||
const lc = social
|
const LC = {
|
||||||
? {
|
sidebar: {
|
||||||
ink: rc.ink,
|
|
||||||
muted: rc.sub,
|
|
||||||
hair: rc.hair,
|
|
||||||
accent,
|
|
||||||
pageBg: rc.pageBg,
|
|
||||||
cardBg: rc.cardBg,
|
|
||||||
cardBorder: rc.cardBorder,
|
|
||||||
deko: rc.deko,
|
|
||||||
pillBg: rc.pillBg,
|
|
||||||
pillInk: rc.pillInk,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
ink: [26, 26, 26],
|
ink: [26, 26, 26],
|
||||||
muted: [92, 96, 100],
|
muted: [92, 96, 100],
|
||||||
hair: [206, 208, 212],
|
hair: [206, 208, 212],
|
||||||
@@ -199,18 +264,78 @@ function resolve(overrides = {}) {
|
|||||||
cardBg: [255, 255, 255],
|
cardBg: [255, 255, 255],
|
||||||
cardBorder: [206, 208, 212],
|
cardBorder: [206, 208, 212],
|
||||||
deko: [206, 208, 212],
|
deko: [206, 208, 212],
|
||||||
|
dekoHell: [230, 232, 235],
|
||||||
|
band: rc.band,
|
||||||
pillBg: tint(accent, 0.12),
|
pillBg: tint(accent, 0.12),
|
||||||
pillInk: accent,
|
pillInk: accent,
|
||||||
|
},
|
||||||
|
social: {
|
||||||
|
ink: rc.ink,
|
||||||
|
muted: rc.sub,
|
||||||
|
hair: rc.hair,
|
||||||
|
accent,
|
||||||
|
pageBg: rc.pageBg,
|
||||||
|
cardBg: rc.cardBg,
|
||||||
|
cardBorder: rc.cardBorder,
|
||||||
|
deko: rc.deko,
|
||||||
|
dekoHell: rc.dekoHell,
|
||||||
|
band: rc.band,
|
||||||
|
pillBg: rc.pillBg,
|
||||||
|
pillInk: rc.pillInk,
|
||||||
|
},
|
||||||
|
sunny: {
|
||||||
|
ink: rc.ink,
|
||||||
|
muted: rc.sub,
|
||||||
|
hair: rc.hair,
|
||||||
|
accent,
|
||||||
|
pageBg: rc.pageBg,
|
||||||
|
cardBg: rc.cardBg,
|
||||||
|
cardBorder: rc.cardBorder,
|
||||||
|
deko: rc.deko,
|
||||||
|
dekoHell: rc.dekoHell,
|
||||||
|
band: rc.band,
|
||||||
|
// The letter's role badge sits on the pastel head band, so a solid accent
|
||||||
|
// slug reads too loud next to the résumé's poster. A white pill with the
|
||||||
|
// accent knocked out keeps the motif but softens it — the letter is the
|
||||||
|
// quiet twin, not a second poster.
|
||||||
|
pillBg: rc.cardBg,
|
||||||
|
pillInk: accent,
|
||||||
|
},
|
||||||
|
// Tupfen's letter is not the quieter twin of anything — the résumé is already
|
||||||
|
// quiet. Both sheets are the same white page with the same confetti, so the
|
||||||
|
// letter simply takes the résumé's tones unchanged.
|
||||||
|
tupfen: {
|
||||||
|
ink: rc.ink,
|
||||||
|
muted: rc.sub,
|
||||||
|
hair: rc.hair,
|
||||||
|
accent,
|
||||||
|
pageBg: rc.pageBg,
|
||||||
|
cardBg: rc.cardBg,
|
||||||
|
cardBorder: rc.cardBorder,
|
||||||
|
deko: rc.deko,
|
||||||
|
dekoHell: rc.dekoHell,
|
||||||
|
band: rc.band,
|
||||||
|
pillBg: rc.pillBg,
|
||||||
|
pillInk: rc.pillInk,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const lc = LC[s.layout] || LC.sidebar;
|
||||||
|
|
||||||
|
// Which typefaces the renderer registers and draws with. The playful layout
|
||||||
|
// adds a script face for its "Hallo, ich bin" line; Sunny lives off Poppins
|
||||||
|
// Bold alone (its whole hierarchy is size, not style); the classic one stays
|
||||||
|
// on Lato and embeds nothing extra. Tupfen takes Poppins too — the reference's
|
||||||
|
// geometric grotesk, whose round, wide letterforms rhyme with the dots.
|
||||||
|
const FONTS = {
|
||||||
|
sidebar: { sans: 'Lato', script: null },
|
||||||
|
social: { sans: 'Poppins', script: 'Pacifico' },
|
||||||
|
sunny: { sans: 'Poppins', script: null },
|
||||||
|
tupfen: { sans: 'Poppins', script: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout: s.layout,
|
layout: s.layout,
|
||||||
// Which typefaces the renderer registers and draws with. The playful layout
|
fonts: FONTS[s.layout] || FONTS.sidebar,
|
||||||
// uses a geometric sans plus a script face for the "Hallo, ich bin" line;
|
|
||||||
// the classic one stays on Lato and embeds nothing extra.
|
|
||||||
fonts: social
|
|
||||||
? { sans: 'Poppins', script: 'Pacifico' }
|
|
||||||
: { sans: 'Lato', script: null },
|
|
||||||
rc,
|
rc,
|
||||||
lc,
|
lc,
|
||||||
foto: {
|
foto: {
|
||||||
|
|||||||
+1181
-10
File diff suppressed because it is too large
Load Diff
+14
-5
@@ -9,7 +9,7 @@ const ART_OPTIONS = [
|
|||||||
];
|
];
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
||||||
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||||||
'Vorstellungsgespräch', 'Vertragsverhandlung',
|
'Vorstellungsgespräch', 'Vertragsverhandlung',
|
||||||
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung',
|
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung',
|
||||||
];
|
];
|
||||||
@@ -373,15 +373,16 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
items: { type: 'integer' },
|
items: { type: 'integer' },
|
||||||
description:
|
description:
|
||||||
'IDs der zusätzlich beizulegenden statischen Anlagen (basis_anhaenge). ' +
|
'IDs der zusätzlich beizulegenden statischen Anlagen (basis_anhaenge). ' +
|
||||||
'Standard: keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".',
|
'Weglassen = Vorauswahl aus den Einstellungen (GEN_ANLAGEN_DEFAULT, Standard: keine), ' +
|
||||||
|
'leeres Array = ausdrücklich keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".',
|
||||||
},
|
},
|
||||||
dokumente: {
|
dokumente: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: { type: 'string', enum: ['anschreiben', 'lebenslauf'] },
|
items: { type: 'string', enum: ['anschreiben', 'lebenslauf'] },
|
||||||
description:
|
description:
|
||||||
'Welche Unterlagen erzeugt werden sollen. Weglassen oder leer = beide (Standard). ' +
|
'Welche Unterlagen erzeugt werden sollen. Weglassen = Vorauswahl aus den Einstellungen ' +
|
||||||
'Wird der Lebenslauf nicht erzeugt, führt ihn das Anschreiben auch nicht unter ' +
|
'(GEN_DOKUMENTE_DEFAULT, Standard: beide). Wird der Lebenslauf nicht erzeugt, führt ihn ' +
|
||||||
'"Anlagen" auf, und der E-Mail-Begleittext kündigt ihn nicht an.',
|
'das Anschreiben auch nicht unter "Anlagen" auf, und der E-Mail-Begleittext kündigt ihn nicht an.',
|
||||||
example: ['anschreiben'],
|
example: ['anschreiben'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -944,6 +945,14 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
anzeige_datum: { type: 'string', format: 'date', nullable: true, description: 'Datum der eigentlichen Stellenanzeige (vom Drittanbieter übergeben)' },
|
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' },
|
kontakt_email: { type: 'string', format: 'email', nullable: true, description: 'Kontakt-E-Mail-Adresse der Stelle' },
|
||||||
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'] },
|
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 },
|
labels: { ...labelsSchema, nullable: true },
|
||||||
verknuepfte_bewerbung_id: { type: 'integer', 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)' },
|
url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL für die De-Duplizierung (serverseitig gesetzt)' },
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
// Minimal, dependency-free ZIP (STORE, no compression) for serving the
|
||||||
|
// browser extension as a single downloadable file. The files are tiny, so
|
||||||
|
// skipping DEFLATE keeps the code small and needs no native tooling in the
|
||||||
|
// container (the slim runtime image ships no `zip` binary).
|
||||||
|
//
|
||||||
|
// Returns a Buffer containing a valid .zip whose entries sit at the archive
|
||||||
|
// root (flat — matching "load unpacked extension" on an extracted folder).
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const CRC_TABLE = (() => {
|
||||||
|
const t = new Uint32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
t[n] = c >>> 0;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOS time/date for a fixed deterministic stamp (1980-01-01 00:00:00).
|
||||||
|
// We avoid per-build timestamps so the archive is byte-identical for a given
|
||||||
|
// source state (cache-friendly, reproducible).
|
||||||
|
const DOS_TIME = 0x0000;
|
||||||
|
const DOS_DATE = 0x0021; // 1980-01-01
|
||||||
|
|
||||||
|
function u16(n) { return Buffer.from([n & 0xff, (n >>> 8) & 0xff]); }
|
||||||
|
function u32(n) { return Buffer.from([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]); }
|
||||||
|
|
||||||
|
// Files: array of { name, data: Buffer } (root-relative, no leading slash).
|
||||||
|
function zipFiles(files) {
|
||||||
|
const localParts = [];
|
||||||
|
const centralParts = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const nameBuf = Buffer.from(f.name, 'utf8');
|
||||||
|
const crc = crc32(f.data);
|
||||||
|
const size = f.data.length;
|
||||||
|
|
||||||
|
const local = Buffer.concat([
|
||||||
|
u32(0x04034b50), // local file header signature
|
||||||
|
u16(20), // version needed to extract
|
||||||
|
u16(0), // general purpose bit flag
|
||||||
|
u16(0), // compression method (0 = store)
|
||||||
|
u16(DOS_TIME), u16(DOS_DATE),
|
||||||
|
u32(crc), u32(size), u32(size),
|
||||||
|
u16(nameBuf.length), u16(0),
|
||||||
|
nameBuf,
|
||||||
|
f.data,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const central = Buffer.concat([
|
||||||
|
u32(0x02014b50), // central directory header signature
|
||||||
|
u16(20), // version made by
|
||||||
|
u16(20), // version needed to extract
|
||||||
|
u16(0), u16(0),
|
||||||
|
u16(DOS_TIME), u16(DOS_DATE),
|
||||||
|
u32(crc), u32(size), u32(size),
|
||||||
|
u16(nameBuf.length), u16(0), u16(0), // name len, extra len, comment len
|
||||||
|
u16(0), // disk number start
|
||||||
|
u16(0), // internal attributes
|
||||||
|
u32(0), // external attributes
|
||||||
|
u32(offset), // local header offset
|
||||||
|
nameBuf,
|
||||||
|
]);
|
||||||
|
|
||||||
|
localParts.push(local);
|
||||||
|
centralParts.push(central);
|
||||||
|
offset += local.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralBuf = Buffer.concat(centralParts);
|
||||||
|
const end = Buffer.concat([
|
||||||
|
u32(0x06054b50), // end of central directory signature
|
||||||
|
u16(0), u16(0), // disk number, disk with CD
|
||||||
|
u16(files.length), u16(files.length), // entries on this disk, total
|
||||||
|
u32(centralBuf.length), u32(offset),
|
||||||
|
u16(0), // comment length
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Buffer.concat([...localParts, centralBuf, end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read every file in a directory (non-recursive, flat) and return a zip Buffer.
|
||||||
|
function zipDir(dir) {
|
||||||
|
const names = fs.readdirSync(dir).sort();
|
||||||
|
const files = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const full = path.join(dir, name);
|
||||||
|
if (!fs.statSync(full).isFile()) continue;
|
||||||
|
files.push({ name, data: fs.readFileSync(full) });
|
||||||
|
}
|
||||||
|
return zipFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { zipDir, zipFiles, crc32 };
|
||||||
+2
-1
@@ -8,7 +8,8 @@
|
|||||||
"dev": "nodemon server.js",
|
"dev": "nodemon server.js",
|
||||||
"docker:build": "docker build -t git.hackner.dev/thomas/jobbi-bewerbung:latest .",
|
"docker:build": "docker build -t git.hackner.dev/thomas/jobbi-bewerbung:latest .",
|
||||||
"docker:push": "docker push git.hackner.dev/thomas/jobbi-bewerbung:latest",
|
"docker:push": "docker push git.hackner.dev/thomas/jobbi-bewerbung:latest",
|
||||||
"docker:deploy": "npm run docker:build && npm run docker:push"
|
"docker:deploy": "npm run docker:build && npm run docker:push",
|
||||||
|
"docker:build-agent": "docker build -t jobbi-bewerbung-agent:latest agent"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ejs": "^3.1.9",
|
"ejs": "^3.1.9",
|
||||||
|
|||||||
+29
-11
@@ -452,19 +452,37 @@ button.loading::after {
|
|||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
/* Modals: the overlay locks body scroll (overflow:hidden on <body>), so a panel
|
/* Modals: the overlay locks body scroll (overflow:hidden on <body>), so a panel
|
||||||
taller than the viewport would have its bottom cut off and unreachable. Cap
|
taller than the viewport must scroll. The scroll container is the INNER
|
||||||
the panel to the viewport and let it scroll internally; align the wrapper to
|
wrapper, not the backdrop, and it gets a DEFINITE height:100vh + overflow-y:auto.
|
||||||
the top so the close button stays in reach instead of being clipped. */
|
A mere min-height:100vh (Tailwind's min-h-screen) is not enough: in Safari the
|
||||||
@media (max-width: 640px) {
|
flex wrapper then does NOT grow to fit the panel, the panel overflows visibly
|
||||||
.max-w-lg,
|
("card geht aus dem Bild") but the container reports no overflow — so nothing
|
||||||
.max-w-md {
|
scrolls and the Save button is unreachable. A definite height + overflow
|
||||||
max-width: calc(100vw - 2rem) !important;
|
forces a real scroll container in every browser. The panel keeps margin:auto
|
||||||
max-height: calc(100vh - 2rem) !important;
|
so a SHORT modal stays centered, while a tall one starts at the top (no
|
||||||
|
top-clip) and scrolls to the bottom. -webkit-overflow-scrolling gives older
|
||||||
|
iOS momentum touch scrolling. */
|
||||||
|
.fixed.inset-0 {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
.fixed.inset-0 > .min-h-screen {
|
||||||
|
height: 100vh !important;
|
||||||
|
min-height: 0 !important;
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
margin: 0 !important;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
|
||||||
.fixed.inset-0 > .min-h-screen {
|
|
||||||
align-items: flex-start !important;
|
align-items: flex-start !important;
|
||||||
|
}
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-lg,
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-md,
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-2xl,
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-3xl,
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-4xl {
|
||||||
|
margin: auto !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-lg,
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-md {
|
||||||
|
max-width: calc(100vw - 2rem) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -355,7 +355,7 @@ function generatePdfDocument(settings, applications, month, year) {
|
|||||||
|
|
||||||
// ---- Group entries by status (order kept in sync with views/index.ejs) ----
|
// ---- Group entries by status (order kept in sync with views/index.ejs) ----
|
||||||
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
||||||
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||||||
'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung'];
|
'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung'];
|
||||||
// Accent colour per status (RGB) – reused by the chart and the list headings
|
// Accent colour per status (RGB) – reused by the chart and the list headings
|
||||||
const statusColors = {
|
const statusColors = {
|
||||||
@@ -364,6 +364,7 @@ function generatePdfDocument(settings, applications, month, year) {
|
|||||||
'Eingangsbestätigung': [14, 165, 233],
|
'Eingangsbestätigung': [14, 165, 233],
|
||||||
'In Bearbeitung': [20, 184, 166],
|
'In Bearbeitung': [20, 184, 166],
|
||||||
'Interessiert': [236, 72, 153],
|
'Interessiert': [236, 72, 153],
|
||||||
|
'Telefonat': [244, 114, 182],
|
||||||
'Warten auf Rückmeldung': [249, 115, 22],
|
'Warten auf Rückmeldung': [249, 115, 22],
|
||||||
'Warten auf meine Antwort': [217, 70, 239],
|
'Warten auf meine Antwort': [217, 70, 239],
|
||||||
'Vorstellungsgespräch': [245, 158, 11],
|
'Vorstellungsgespräch': [245, 158, 11],
|
||||||
|
|||||||
+119
-65
@@ -2,29 +2,39 @@
|
|||||||
// Host-side runner for the per-user job search.
|
// Host-side runner for the per-user job search.
|
||||||
//
|
//
|
||||||
// Why it lives on the host and not in the app: the app container has neither
|
// Why it lives on the host and not in the app: the app container has neither
|
||||||
// `claude` nor `ollama`. The app therefore only *queues* runs (table suchlaeufe);
|
// `claude` nor `ollama` nor a docker socket. The app therefore only *queues*
|
||||||
// this script — started by cron every few minutes — picks them up and executes
|
// runs (table suchlaeufe); this script — started by cron every few minutes —
|
||||||
// them. It replaces the two old single-user scripts (jobsuche-cron.sh and
|
// picks them up and executes them. It replaces the two old single-user scripts
|
||||||
// jobsuche-remote-cron.sh), whose search criteria were hard-coded into the prompt.
|
// (jobsuche-cron.sh and jobsuche-remote-cron.sh), whose search criteria were
|
||||||
|
// hard-coded into the prompt.
|
||||||
|
//
|
||||||
|
// Isolation: every run executes in its OWN throwaway Docker container
|
||||||
|
// (AGENT_IMAGE), with the requesting user's dedicated home directory mounted
|
||||||
|
// as /home/agent. That home IS that user's ~/.claude (memories, session
|
||||||
|
// contexts, history, settings) — never shared with another user. The three
|
||||||
|
// search skills are shared code baked into the image and symlinked into
|
||||||
|
// ~/.claude/skills by the image entrypoint.
|
||||||
//
|
//
|
||||||
// Per run it:
|
// Per run it:
|
||||||
// 1. enqueues scheduled runs that are due (per user's Zeitplan),
|
// 1. enqueues scheduled runs that are due (per user's Zeitplan),
|
||||||
// 2. claims one queued run at a time (status angefordert -> laeuft),
|
// 2. claims queued runs (status angefordert -> laeuft) and runs them in
|
||||||
|
// parallel up to MAX_PARALLEL — different users have different Ollama
|
||||||
|
// keys and thus separate rate limits, so parallelism is safe across users,
|
||||||
// 3. builds the prompt from that user's Suchprofil + their Lebenslauf,
|
// 3. builds the prompt from that user's Suchprofil + their Lebenslauf,
|
||||||
// 4. runs the headless agent with THAT user's credentials:
|
// 4. starts a container that runs the headless agent with THAT user's
|
||||||
// - BEWERBUNG_API_KEY = the user's API token (created if missing), so every
|
// credentials:
|
||||||
// imported Jobangebot lands in their account and passes the same
|
// - BEWERBUNG_API_KEY = the user's API token (created if missing), so
|
||||||
// dedup/blacklist path as the rest of the app,
|
// every imported Jobangebot lands in their account and passes the
|
||||||
// - the model is driven by the user's own Ollama key (see KI_AUTH below),
|
// same dedup/blacklist path as the rest of the app,
|
||||||
// 5. parses the agent's final "ERGEBNIS neu=… dubletten=… verworfen=…" line and
|
// - ANTHROPIC_* = the user's own Ollama endpoint+key, so the search is
|
||||||
// writes status + counts back, so the UI can show what happened.
|
// billed to whoever requested it,
|
||||||
//
|
// 5. parses the agent's final "ERGEBNIS neu=… dubletten=… verworfen=…" line
|
||||||
// Everything is per user; nothing here reads a global .env any more.
|
// and writes status + counts back, so the UI can show what happened.
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { spawn } = require('child_process');
|
const { spawn, spawnSync } = require('child_process');
|
||||||
const sqlite3 = require('sqlite3');
|
const sqlite3 = require('sqlite3');
|
||||||
|
|
||||||
const suchprofil = require('../lib/suchprofil');
|
const suchprofil = require('../lib/suchprofil');
|
||||||
@@ -32,16 +42,25 @@ const suchprofil = require('../lib/suchprofil');
|
|||||||
const DB_PATH = process.env.JOBSUCHE_DB || '/opt/jobbi-bewerbung/data/bewerbungen.db';
|
const DB_PATH = process.env.JOBSUCHE_DB || '/opt/jobbi-bewerbung/data/bewerbungen.db';
|
||||||
const LOG_DIR = process.env.JOBSUCHE_LOG_DIR || '/opt/jobbi-bewerbung/logs';
|
const LOG_DIR = process.env.JOBSUCHE_LOG_DIR || '/opt/jobbi-bewerbung/logs';
|
||||||
const API_URL = process.env.JOBSUCHE_API_URL || 'http://localhost:4327/api/v1';
|
const API_URL = process.env.JOBSUCHE_API_URL || 'http://localhost:4327/api/v1';
|
||||||
// Swappable so the pipeline can be exercised end-to-end without burning tokens.
|
|
||||||
const CLAUDE_BIN = process.env.JOBSUCHE_CLAUDE_BIN || 'claude';
|
|
||||||
// 'benutzer' = each run uses that user's own Ollama key (they pay for their own
|
|
||||||
// search). 'host' = fall back to the machine's ollama login via `ollama launch`.
|
|
||||||
const KI_AUTH = process.env.JOBSUCHE_KI_AUTH || 'benutzer';
|
|
||||||
const RUN_TIMEOUT_MS = (Number(process.env.JOBSUCHE_TIMEOUT) || 2700) * 1000;
|
const RUN_TIMEOUT_MS = (Number(process.env.JOBSUCHE_TIMEOUT) || 2700) * 1000;
|
||||||
// A run that has been 'laeuft' for longer than this lost its process (reboot,
|
// A run that has been 'laeuft' for longer than this lost its process (reboot,
|
||||||
// OOM); it would otherwise block the user's queue forever.
|
// OOM); it would otherwise block the user's queue forever.
|
||||||
const STALE_MS = RUN_TIMEOUT_MS + 10 * 60 * 1000;
|
const STALE_MS = RUN_TIMEOUT_MS + 10 * 60 * 1000;
|
||||||
const LOG_RETENTION_DAYS = Number(process.env.JOBSUCHE_LOG_RETENTION_DAYS) || 30;
|
const LOG_RETENTION_DAYS = Number(process.env.JOBSUCHE_LOG_RETENTION_DAYS) || 30;
|
||||||
|
|
||||||
|
// Agent-Container. Das Image wird lokal gebaut (npm run docker:build-agent),
|
||||||
|
// nicht über die Registry verteilt — es läuft nur auf diesem Host.
|
||||||
|
const AGENT_IMAGE = process.env.JOBSUCHE_AGENT_IMAGE || 'jobbi-bewerbung-agent:latest';
|
||||||
|
// uid/gid, als die der Container läuft und der das pro-Benutzer-Home gehört.
|
||||||
|
const AGENT_UID = Number(process.env.JOBSUCHE_AGENT_UID) || 1000;
|
||||||
|
const AGENT_GID = Number(process.env.JOBSUCHE_AGENT_GID) || AGENT_UID;
|
||||||
|
// Pro-Benutzer-Verzeichnisse (je HOME = je ~/.claude).
|
||||||
|
const AGENTS_DIR = process.env.JOBSUCHE_AGENTS_DIR || '/opt/jobbi-bewerbung/agents';
|
||||||
|
// Gleichzeitig laufende Such-Container. Verschiedene Benutzer haben getrennte
|
||||||
|
// Ollama-Keys -> getrennte Rate-Limits, deshalb ist pro-Benutzer-Parallelität
|
||||||
|
// sicher. Der Wert begrenzt lediglich die Host-Last.
|
||||||
|
const MAX_PARALLEL = Number(process.env.JOBSUCHE_MAX_PARALLEL) || 4;
|
||||||
|
|
||||||
const CFG = 'cfg:';
|
const CFG = 'cfg:';
|
||||||
|
|
||||||
const db = new sqlite3.Database(DB_PATH);
|
const db = new sqlite3.Database(DB_PATH);
|
||||||
@@ -88,6 +107,16 @@ async function profilText(userId) {
|
|||||||
return rows.map((r) => (r.inhalt || '').trim()).filter(Boolean).join('\n\n---\n\n');
|
return rows.map((r) => (r.inhalt || '').trim()).filter(Boolean).join('\n\n---\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legt das pro-Benutzer-Home an (falls noch nicht vorhanden) und übergibt es
|
||||||
|
// an den Container-Agent. Bleibt über Läufe hinweg bestehen, damit Memories
|
||||||
|
// und Contexte erhalten bleiben.
|
||||||
|
function ensureAgentDir(userId) {
|
||||||
|
const dir = path.join(AGENTS_DIR, String(userId));
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
spawnSync('chown', ['-R', `${AGENT_UID}:${AGENT_GID}`, dir]);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Queue scheduled runs that are due.
|
// 1. Queue scheduled runs that are due.
|
||||||
async function faelligeEinreihen() {
|
async function faelligeEinreihen() {
|
||||||
const jetzt = new Date();
|
const jetzt = new Date();
|
||||||
@@ -120,7 +149,7 @@ async function faelligeEinreihen() {
|
|||||||
async function verwaisteAufraeumen() {
|
async function verwaisteAufraeumen() {
|
||||||
const res = await run(
|
const res = await run(
|
||||||
`UPDATE suchlaeufe SET status = 'fehler', beendet_at = CURRENT_TIMESTAMP,
|
`UPDATE suchlaeufe SET status = 'fehler', beendet_at = CURRENT_TIMESTAMP,
|
||||||
fehler = 'Lauf abgebrochen (Prozess nicht mehr vorhanden).'
|
fehler = 'Lauf abgebrochen (Container nicht mehr vorhanden).'
|
||||||
WHERE status = 'laeuft'
|
WHERE status = 'laeuft'
|
||||||
AND gestartet_at IS NOT NULL
|
AND gestartet_at IS NOT NULL
|
||||||
AND (julianday('now') - julianday(gestartet_at)) * 86400000 > ?`,
|
AND (julianday('now') - julianday(gestartet_at)) * 86400000 > ?`,
|
||||||
@@ -152,26 +181,30 @@ async function laufAbschliessen(id, status, felder = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn the headless agent and collect its output.
|
// Start the headless agent inside a throwaway container for this user. Only the
|
||||||
function agentStarten(prompt, env, logStream) {
|
// curated env below is passed in (not process.env) so no host secrets leak.
|
||||||
|
function agentStarten(prompt, envObj, logStream, containerName, agentDir) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let argv;
|
const args = [
|
||||||
if (KI_AUTH === 'host') {
|
'run', '--rm',
|
||||||
// Legacy path: the machine's ollama login pays for the run.
|
'--name', containerName,
|
||||||
argv = ['launch', 'claude', '--model', env.JOBSUCHE_MODEL, '--',
|
// --network host: der Agent erreicht den Tracker (localhost:4327), das
|
||||||
'--dangerously-skip-permissions', '-p', prompt];
|
// Internet (WebSearch/WebFetch/Arbeitsagentur) und den Ollama-Endpoint
|
||||||
argv = { cmd: 'ollama', args: argv };
|
// des Benutzers — alles über die Host-Netzwerk-Sicht.
|
||||||
} else {
|
'--network', 'host',
|
||||||
// Per-user path: Claude Code talks to Ollama's Anthropic-compatible API with
|
'--user', `${AGENT_UID}:${AGENT_GID}`,
|
||||||
// *this user's* key, so the search is billed to whoever requested it.
|
'-e', 'HOME=/home/agent',
|
||||||
argv = { cmd: CLAUDE_BIN, args: ['--dangerously-skip-permissions', '-p', prompt] };
|
'-e', 'IS_SANDBOX=1',
|
||||||
}
|
];
|
||||||
|
for (const [k, v] of Object.entries(envObj)) args.push('-e', `${k}=${v}`);
|
||||||
|
args.push(
|
||||||
|
'-v', `${agentDir}:/home/agent`,
|
||||||
|
'-w', '/work',
|
||||||
|
AGENT_IMAGE,
|
||||||
|
'claude', '--dangerously-skip-permissions', '-p', prompt,
|
||||||
|
);
|
||||||
|
|
||||||
const kind = spawn(argv.cmd, argv.args, {
|
const kind = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
env,
|
|
||||||
cwd: '/opt/jobbi-bewerbung',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
let ausgabe = '';
|
let ausgabe = '';
|
||||||
const sammeln = (buf) => {
|
const sammeln = (buf) => {
|
||||||
@@ -182,14 +215,17 @@ function agentStarten(prompt, env, logStream) {
|
|||||||
kind.stdout.on('data', sammeln);
|
kind.stdout.on('data', sammeln);
|
||||||
kind.stderr.on('data', sammeln);
|
kind.stderr.on('data', sammeln);
|
||||||
|
|
||||||
|
// Beim Timeout den `docker run`-Client killen UND den Container per Name
|
||||||
|
// stoppen — ein kill des Clients allein ließe den Container sonst weiterlaufen.
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
spawnSync('docker', ['kill', containerName], { stdio: 'ignore' });
|
||||||
kind.kill('SIGTERM');
|
kind.kill('SIGTERM');
|
||||||
setTimeout(() => kind.kill('SIGKILL'), 60000);
|
setTimeout(() => kind.kill('SIGKILL'), 10000);
|
||||||
}, RUN_TIMEOUT_MS);
|
}, RUN_TIMEOUT_MS);
|
||||||
|
|
||||||
kind.on('error', (err) => {
|
kind.on('error', (err) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve({ code: -1, ausgabe, fehler: `Agent nicht startbar: ${err.message}` });
|
resolve({ code: -1, ausgabe, fehler: `Agent-Container nicht startbar: ${err.message}` });
|
||||||
});
|
});
|
||||||
kind.on('close', (code, signal) => {
|
kind.on('close', (code, signal) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -207,7 +243,8 @@ async function laufAusfuehren(lauf) {
|
|||||||
const userId = lauf.user_id;
|
const userId = lauf.user_id;
|
||||||
const user = await get('SELECT username FROM users WHERE id = ?', [userId]);
|
const user = await get('SELECT username FROM users WHERE id = ?', [userId]);
|
||||||
const name = user ? user.username : `#${userId}`;
|
const name = user ? user.username : `#${userId}`;
|
||||||
log(`Lauf ${lauf.id} (Benutzer ${name}, ${lauf.ausloeser}) startet.`);
|
const containerName = `jobbi-agent-${userId}-${lauf.id}`;
|
||||||
|
log(`Lauf ${lauf.id} (Benutzer ${name}, ${lauf.ausloeser}) startet im Container ${containerName}.`);
|
||||||
|
|
||||||
const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId]));
|
const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId]));
|
||||||
const fehler = suchprofil.validate(profil);
|
const fehler = suchprofil.validate(profil);
|
||||||
@@ -218,7 +255,7 @@ async function laufAusfuehren(lauf) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ollamaKey = await cfgWert(userId, 'OLLAMA_API_KEY');
|
const ollamaKey = await cfgWert(userId, 'OLLAMA_API_KEY');
|
||||||
if (KI_AUTH !== 'host' && !ollamaKey) {
|
if (!ollamaKey) {
|
||||||
const msg = 'Kein eigener Ollama-API-Schlüssel hinterlegt — der Suchlauf läuft über das KI-Kontingent des Benutzers. Bitte in den Einstellungen eintragen.';
|
const msg = 'Kein eigener Ollama-API-Schlüssel hinterlegt — der Suchlauf läuft über das KI-Kontingent des Benutzers. Bitte in den Einstellungen eintragen.';
|
||||||
await laufAbschliessen(lauf.id, 'fehler', { fehler: msg });
|
await laufAbschliessen(lauf.id, 'fehler', { fehler: msg });
|
||||||
log(` abgebrochen: kein Ollama-Key für ${name}.`);
|
log(` abgebrochen: kein Ollama-Key für ${name}.`);
|
||||||
@@ -235,27 +272,21 @@ async function laufAusfuehren(lauf) {
|
|||||||
const logStream = fs.createWriteStream(logDatei, { flags: 'a' });
|
const logStream = fs.createWriteStream(logDatei, { flags: 'a' });
|
||||||
logStream.write(`=== Suchlauf ${lauf.id} — Benutzer ${name} (${lauf.ausloeser}) ===\n`);
|
logStream.write(`=== Suchlauf ${lauf.id} — Benutzer ${name} (${lauf.ausloeser}) ===\n`);
|
||||||
logStream.write(`Modus: ${profil.modus} | Städte: ${profil.staedte.join(', ') || '–'}\n`);
|
logStream.write(`Modus: ${profil.modus} | Städte: ${profil.staedte.join(', ') || '–'}\n`);
|
||||||
logStream.write(`Modell: ${modell} | KI-Auth: ${KI_AUTH}\n${'-'.repeat(50)}\n`);
|
logStream.write(`Modell: ${modell} | Container: ${containerName}\n${'-'.repeat(50)}\n`);
|
||||||
|
|
||||||
const env = {
|
// Nur kuratierte Env-Vars in den Container — keine Host-Secrets.
|
||||||
...process.env,
|
const envObj = {
|
||||||
HOME: process.env.HOME || '/root',
|
ANTHROPIC_BASE_URL: (await cfgWert(userId, 'OLLAMA_HOST')) || 'https://ollama.com',
|
||||||
PATH: `/root/.local/bin:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}`,
|
ANTHROPIC_AUTH_TOKEN: ollamaKey,
|
||||||
IS_SANDBOX: '1',
|
ANTHROPIC_MODEL: modell,
|
||||||
// The tracker API — the agent imports Jobangebote as THIS user.
|
ANTHROPIC_SMALL_FAST_MODEL: modell,
|
||||||
BEWERBUNG_API_URL: API_URL,
|
BEWERBUNG_API_URL: API_URL,
|
||||||
BEWERBUNG_API_KEY: apiKey,
|
BEWERBUNG_API_KEY: apiKey,
|
||||||
JOBSUCHE_MODEL: modell,
|
JOBSUCHE_MODEL: modell,
|
||||||
};
|
};
|
||||||
if (KI_AUTH !== 'host') {
|
|
||||||
// Point Claude Code at Ollama's Anthropic-compatible endpoint with the user's key.
|
|
||||||
env.ANTHROPIC_BASE_URL = (await cfgWert(userId, 'OLLAMA_HOST')) || 'https://ollama.com';
|
|
||||||
env.ANTHROPIC_AUTH_TOKEN = ollamaKey;
|
|
||||||
env.ANTHROPIC_MODEL = modell;
|
|
||||||
env.ANTHROPIC_SMALL_FAST_MODEL = modell;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { code, ausgabe, fehler: laufFehler } = await agentStarten(prompt, env, logStream);
|
const agentDir = ensureAgentDir(userId);
|
||||||
|
const { code, ausgabe, fehler: laufFehler } = await agentStarten(prompt, envObj, logStream, containerName, agentDir);
|
||||||
const ergebnis = suchprofil.parseErgebnis(ausgabe);
|
const ergebnis = suchprofil.parseErgebnis(ausgabe);
|
||||||
|
|
||||||
logStream.write(`\n${'-'.repeat(50)}\nExit-Code: ${code}\n`);
|
logStream.write(`\n${'-'.repeat(50)}\nExit-Code: ${code}\n`);
|
||||||
@@ -286,6 +317,27 @@ async function laufAusfuehren(lauf) {
|
|||||||
log(` Lauf ${lauf.id} fertig: ${ergebnis.neu} neu, ${ergebnis.dubletten} Dubletten, ${ergebnis.verworfen} verworfen.`);
|
log(` Lauf ${lauf.id} fertig: ${ergebnis.neu} neu, ${ergebnis.dubletten} Dubletten, ${ergebnis.verworfen} verworfen.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Führt eine Liste von Läufen mit höchstens `parallel` gleichzeitig aus. Jeder
|
||||||
|
// Lauf läuft in seinem eigenen Container; verschiedene Benutzer haben getrennte
|
||||||
|
// Ollama-Keys, deshalb ist pro-Benutzer-Parallelität sicher.
|
||||||
|
async function runPool(items, parallel, fn) {
|
||||||
|
const queue = items.slice();
|
||||||
|
const workers = Array.from({ length: Math.min(parallel, queue.length || 1) }, async () => {
|
||||||
|
while (queue.length) {
|
||||||
|
const item = queue.shift();
|
||||||
|
try {
|
||||||
|
await fn(item);
|
||||||
|
} catch (e) {
|
||||||
|
log(` Lauf ${item.id} unerwartet abgebrochen: ${e && e.message ? e.message : e}`);
|
||||||
|
try {
|
||||||
|
await laufAbschliessen(item.id, 'fehler', { fehler: `Runner-Ausnahme: ${e && e.message ? e.message : e}` });
|
||||||
|
} catch (_) { /* Status ist dann zwar offen, aber ein Folge-Tick räumt auf. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
}
|
||||||
|
|
||||||
function alteLogsAufraeumen() {
|
function alteLogsAufraeumen() {
|
||||||
try {
|
try {
|
||||||
const grenze = Date.now() - LOG_RETENTION_DAYS * 86400000;
|
const grenze = Date.now() - LOG_RETENTION_DAYS * 86400000;
|
||||||
@@ -307,15 +359,17 @@ async function main() {
|
|||||||
await verwaisteAufraeumen();
|
await verwaisteAufraeumen();
|
||||||
await faelligeEinreihen();
|
await faelligeEinreihen();
|
||||||
|
|
||||||
// Work the queue until it is empty. Runs are serialised on purpose: a search is
|
// Alle wartenden Läufe einsammeln (pro Benutzer steht ohnehin nur einer an)
|
||||||
// web- and token-heavy, and several in parallel would trip rate limits.
|
// und mit begrenzter Parallelität abarbeiten.
|
||||||
|
const laeufe = [];
|
||||||
let lauf;
|
let lauf;
|
||||||
let anzahl = 0;
|
while ((lauf = await naechstenLaufClaimen())) laeufe.push(lauf);
|
||||||
while ((lauf = await naechstenLaufClaimen())) {
|
if (!laeufe.length) {
|
||||||
await laufAusfuehren(lauf);
|
log('Nichts zu tun.');
|
||||||
anzahl += 1;
|
} else {
|
||||||
|
log(`${laeufe.length} Lauf/Läufe eingereiht, starte bis zu ${MAX_PARALLEL} parallel.`);
|
||||||
|
await runPool(laeufe, MAX_PARALLEL, laufAusfuehren);
|
||||||
}
|
}
|
||||||
if (!anzahl) log('Nichts zu tun.');
|
|
||||||
|
|
||||||
alteLogsAufraeumen();
|
alteLogsAufraeumen();
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
# Jetzt: Jeder Benutzer pflegt sein Suchprofil und seinen Zeitplan in der App
|
# Jetzt: Jeder Benutzer pflegt sein Suchprofil und seinen Zeitplan in der App
|
||||||
# (/jobsuche). Dieses Skript ruft nur noch den Runner auf, der fällige und manuell
|
# (/jobsuche). Dieses Skript ruft nur noch den Runner auf, der fällige und manuell
|
||||||
# angeforderte Läufe abarbeitet — je Benutzer mit dessen eigenem Ollama- und
|
# angeforderte Läufe abarbeitet — je Benutzer mit dessen eigenem Ollama- und
|
||||||
# API-Schlüssel.
|
# API-Schlüssel. Jeder Suchlauf läuft isoliert im eigenen Docker-Container pro
|
||||||
|
# Benutzer; der Runner selbst bleibt auf dem Host (Docker-CLI + DB-Zugriff).
|
||||||
#
|
#
|
||||||
# Cron (alle 5 Minuten — die Uhrzeit steuert der Benutzer, nicht der Cron):
|
# Cron (alle 5 Minuten — die Uhrzeit steuert der Benutzer, nicht der Cron):
|
||||||
# */5 * * * * /opt/jobbi-bewerbung/bin/jobsuche-runner.sh
|
# */5 * * * * /opt/jobbi-bewerbung/bin/jobsuche-runner.sh
|
||||||
@@ -26,13 +27,13 @@ export PATH="/root/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
|
|||||||
# wartenden Suchläufe blieben ewig im Status 'angefordert' hängen.
|
# wartenden Suchläufe blieben ewig im Status 'angefordert' hängen.
|
||||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||||
# Claude Code verweigert --dangerously-skip-permissions als root; in dieser
|
|
||||||
# Container-/Sandbox-Umgebung mit IS_SANDBOX=1 erlaubt.
|
|
||||||
export IS_SANDBOX=1
|
|
||||||
|
|
||||||
PROJECT_DIR="/opt/jobbi-bewerbung"
|
PROJECT_DIR="/opt/jobbi-bewerbung"
|
||||||
LOG_DIR="$PROJECT_DIR/logs"
|
LOG_DIR="$PROJECT_DIR/logs"
|
||||||
RUNNER="$PROJECT_DIR/source/scripts/jobsuche-runner.js"
|
RUNNER="$PROJECT_DIR/source/scripts/jobsuche-runner.js"
|
||||||
|
# Agent-Image (pro Suchlauf ein frischer Container). Wird lokal gebaut:
|
||||||
|
# npm --prefix "$PROJECT_DIR/source" run docker:build-agent
|
||||||
|
AGENT_IMAGE="${JOBSUCHE_AGENT_IMAGE:-jobbi-bewerbung-agent:latest}"
|
||||||
|
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
LOG="$LOG_DIR/jobsuche-runner.log"
|
LOG="$LOG_DIR/jobsuche-runner.log"
|
||||||
@@ -52,6 +53,12 @@ if ! curl -fsS --max-time 10 http://localhost:4327/api/v1/health >/dev/null 2>&1
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Agent-Image muss vorhanden sein, sonst stapeln sich fehlgeschlagene Läufe.
|
||||||
|
if ! docker image inspect "$AGENT_IMAGE" >/dev/null 2>&1; then
|
||||||
|
echo "$(date -Is) FEHLER: Agent-Image '$AGENT_IMAGE' fehlt – bitte bauen: npm --prefix $PROJECT_DIR/source run docker:build-agent" >> "$LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
node "$RUNNER" >> "$LOG" 2>&1
|
node "$RUNNER" >> "$LOG" 2>&1
|
||||||
STATUS=$?
|
STATUS=$?
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const multer = require('multer');
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
generateApplicationDocuments, generateEmailReply, generateFeinschliff, renderDesignVorschau,
|
generateApplicationDocuments, generateEmailReply, generateFeinschliff, renderDesignVorschau,
|
||||||
DOKUMENT_TYPEN, normalizeDokumente,
|
DOKUMENT_TYPEN, normalizeDokumente, standardDokumente,
|
||||||
} = require('./lib/documents');
|
} = require('./lib/documents');
|
||||||
const chat = require('./lib/chat');
|
const chat = require('./lib/chat');
|
||||||
const websuche = require('./lib/websuche');
|
const websuche = require('./lib/websuche');
|
||||||
@@ -50,6 +50,7 @@ const migrate = require('./lib/migrate-multiuser');
|
|||||||
const { runMigration, importEnvIntoAdmin } = migrate;
|
const { runMigration, importEnvIntoAdmin } = migrate;
|
||||||
const suchprofil = require('./lib/suchprofil');
|
const suchprofil = require('./lib/suchprofil');
|
||||||
const suche = require('./lib/suche');
|
const suche = require('./lib/suche');
|
||||||
|
const { zipDir } = require('./lib/zipdir');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -62,7 +63,7 @@ const ART_OPTIONS = [
|
|||||||
];
|
];
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
||||||
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||||||
'Vorstellungsgespräch', 'Vertragsverhandlung',
|
'Vorstellungsgespräch', 'Vertragsverhandlung',
|
||||||
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung'
|
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung'
|
||||||
];
|
];
|
||||||
@@ -158,6 +159,18 @@ app.use('/api/indeed-import', (req, res, next) => {
|
|||||||
|
|
||||||
// Set EJS as template engine
|
// Set EJS as template engine
|
||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
|
|
||||||
|
// Cache-busting-Version für styles.css: die mtime der Datei beim Start. Wirkt
|
||||||
|
// nur als Query-Parameter am <link href>, also nach jedem Deploy (neuer
|
||||||
|
// Container → neue mtime → neue URL) erzwingt sie einen echten NeuDownload —
|
||||||
|
// "no-cache" allein reicht manchmal nicht, wenn ein Browser die alte Datei
|
||||||
|
// hart zwischengespeichert hat. Innerhalb eines Laufs bleibt der Wert stabil.
|
||||||
|
try {
|
||||||
|
const cssStat = fs.statSync(path.join(__dirname, 'public', 'css', 'styles.css'));
|
||||||
|
app.locals.assetVersion = String(cssStat.mtimeMs || Date.now());
|
||||||
|
} catch (e) {
|
||||||
|
app.locals.assetVersion = String(Date.now());
|
||||||
|
}
|
||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -204,6 +217,7 @@ function isPublicPath(p) {
|
|||||||
if (PUBLIC_PATHS.has(p)) return true;
|
if (PUBLIC_PATHS.has(p)) return true;
|
||||||
if (p === '/health' || p === '/swagger' || p === '/swagger.json' || p === '/api-docs') return true;
|
if (p === '/health' || p === '/swagger' || p === '/swagger.json' || p === '/api-docs') return true;
|
||||||
if (p.startsWith('/api/v1/')) return true; // own X-API-Key auth
|
if (p.startsWith('/api/v1/')) return true; // own X-API-Key auth
|
||||||
|
if (p === '/api/indeed-import') return true; // X-API-Key auth (Browser-Plugin)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +260,38 @@ function loginOk(req) {
|
|||||||
loginFails.delete(clientIp(req));
|
loginFails.delete(clientIp(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// X-API-Key-Authentifizierung für Endpunkte, die ohne Browser-Session von außen
|
||||||
|
// kommen (heute: das Browser-Plugin über /api/indeed-import). Löst den Header
|
||||||
|
// X-API-Key zum Benutzer auf, dessen cfg:API_TOKEN ihn trägt, und läuft den
|
||||||
|
// Request in dessen Kontext — identisch zu /api/v1 (siehe lib/api.js). Ein
|
||||||
|
// fehlender oder unbekannter Key → 401; ein mehrdeutiger Token → fail closed.
|
||||||
|
async function requireApiKey(req, res, next) {
|
||||||
|
const provided = req.get('X-API-Key');
|
||||||
|
if (!provided) {
|
||||||
|
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const matches = await dbAll(
|
||||||
|
`SELECT u.id, u.username, u.is_admin
|
||||||
|
FROM app_state a JOIN users u ON u.id = a.user_id
|
||||||
|
WHERE a.key = ? AND a.value = ? AND a.value != ''`,
|
||||||
|
['cfg:API_TOKEN', provided]
|
||||||
|
);
|
||||||
|
if (matches.length !== 1) {
|
||||||
|
if (matches.length > 1) console.error('Plugin-Auth: Token ist mehreren Benutzern zugeordnet — Zugriff verweigert.');
|
||||||
|
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||||
|
}
|
||||||
|
const user = matches[0];
|
||||||
|
req.user = user;
|
||||||
|
req.apiUser = user; // kenntzeichnet API- (ggf. nicht Admin-) Kontext
|
||||||
|
await config.ensureLoaded(user.id);
|
||||||
|
userContext.run(user, next);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Plugin-API-Auth-Fehler:', err);
|
||||||
|
res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Login page + form handler.
|
// Login page + form handler.
|
||||||
app.get('/login', (req, res) => {
|
app.get('/login', (req, res) => {
|
||||||
if (req.user) return res.redirect('/');
|
if (req.user) return res.redirect('/');
|
||||||
@@ -386,7 +432,7 @@ function emailPlainText(e) {
|
|||||||
// followed by the original body with every line prefixed by "> ".
|
// followed by the original body with every line prefixed by "> ".
|
||||||
function buildReplyQuote(e) {
|
function buildReplyQuote(e) {
|
||||||
const d = e.email_date ? new Date(e.email_date) : null;
|
const d = e.email_date ? new Date(e.email_date) : null;
|
||||||
const when = d && !isNaN(d.getTime()) ? d.toLocaleString('de-DE') : '';
|
const when = d && !isNaN(d.getTime()) ? d.toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : '';
|
||||||
const who = String(e.from_addr || '').trim();
|
const who = String(e.from_addr || '').trim();
|
||||||
const src = emailPlainText(e).replace(/\r\n/g, '\n').replace(/\s+$/, '');
|
const src = emailPlainText(e).replace(/\r\n/g, '\n').replace(/\s+$/, '');
|
||||||
const quoted = src.split('\n').map((l) => '> ' + l).join('\n');
|
const quoted = src.split('\n').map((l) => '> ' + l).join('\n');
|
||||||
@@ -417,9 +463,61 @@ if (!fs.existsSync(basisAnhaengeDir)) {
|
|||||||
const ATTACHMENT_FILTER = (req, file, cb) => {
|
const ATTACHMENT_FILTER = (req, file, cb) => {
|
||||||
const ext = path.extname(file.originalname).toLowerCase();
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
const ok = /^\.(pdf|png|jpe?g|gif|webp|docx?|rtf|odt|ods|txt|csv|xlsx?)$/.test(ext);
|
const ok = /^\.(pdf|png|jpe?g|gif|webp|docx?|rtf|odt|ods|txt|csv|xlsx?)$/.test(ext);
|
||||||
cb(ok ? null : new Error('Dateityp nicht erlaubt'), ok);
|
cb(ok ? null : dateitypFehler(), ok);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Upload size limits. The numbers live here (not inline in the multer configs)
|
||||||
|
// because the message shown to the user quotes them — a limit that says one
|
||||||
|
// thing and enforces another is worse than no message at all.
|
||||||
|
const ANHANG_MAX_MB = 30;
|
||||||
|
const BILD_MAX_MB = 5;
|
||||||
|
|
||||||
|
function dateitypFehler() {
|
||||||
|
const err = new Error('Dateityp nicht erlaubt');
|
||||||
|
err.code = 'DATEITYP';
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multer reports a rejected upload through its callback's `err`: a blown size
|
||||||
|
// limit, a blocked file type, a failed write. Those used to be logged to the
|
||||||
|
// server console and otherwise swallowed — the browser was redirected back to a
|
||||||
|
// page that simply did not list the file, which to the user looks exactly like
|
||||||
|
// "it uploaded but it isn't shown". Turn the error into a sentence they can act
|
||||||
|
// on. Returns null when the upload was fine.
|
||||||
|
function uploadFehlerText(err, maxMb) {
|
||||||
|
if (!err) return null;
|
||||||
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return `Die Datei ist zu groß. Erlaubt sind maximal ${maxMb} MB — bitte komprimiere sie (z. B. den Scan als PDF verkleinern) und lade sie erneut hoch.`;
|
||||||
|
}
|
||||||
|
if (err.code === 'DATEITYP') {
|
||||||
|
return 'Dieser Dateityp ist nicht erlaubt. Möglich sind PDF, Bilder (PNG, JPG, GIF, WebP), Word, ODT, RTF, Text, CSV und Excel.';
|
||||||
|
}
|
||||||
|
if (err.code === 'BILDTYP') {
|
||||||
|
return 'Für dieses Bild sind nur PNG- oder JPG-Dateien möglich.';
|
||||||
|
}
|
||||||
|
return `Die Datei konnte nicht hochgeladen werden: ${err.message || 'unbekannter Fehler'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bilder (Unterschrift, Bewerbungsfoto): nur PNG/JPG, und der Dateiname muss zur
|
||||||
|
// MIME-Art passen. Wie oben mit einem Fehler ablehnen statt still zu verwerfen.
|
||||||
|
const BILD_FILTER = (req, file, cb) => {
|
||||||
|
const ok = /^image\/(png|jpe?g)$/.test(file.mimetype)
|
||||||
|
&& /^\.(png|jpe?g)$/.test(path.extname(file.originalname).toLowerCase());
|
||||||
|
cb(ok ? null : bildtypFehler(), ok);
|
||||||
|
};
|
||||||
|
|
||||||
|
function bildtypFehler() {
|
||||||
|
const err = new Error('Nur PNG oder JPG');
|
||||||
|
err.code = 'BILDTYP';
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Ziel plus Fehlermeldung als Query-Parameter — dasselbe Muster wie auf der
|
||||||
|
// Jobsuche-Seite (?fehler=…), das die Views bereits als Banner rendern.
|
||||||
|
function mitFehler(pfad, text) {
|
||||||
|
return `${pfad}${pfad.includes('?') ? '&' : '?'}fehler=${encodeURIComponent(text)}`;
|
||||||
|
}
|
||||||
|
|
||||||
const uploadBasisAnhang = multer({
|
const uploadBasisAnhang = multer({
|
||||||
storage: multer.diskStorage({
|
storage: multer.diskStorage({
|
||||||
destination: (req, file, cb) => cb(null, userStorageDirForReq(basisAnhaengeDir, req)),
|
destination: (req, file, cb) => cb(null, userStorageDirForReq(basisAnhaengeDir, req)),
|
||||||
@@ -428,7 +526,7 @@ const uploadBasisAnhang = multer({
|
|||||||
cb(null, `${Date.now()}_${safe}`);
|
cb(null, `${Date.now()}_${safe}`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
limits: { fileSize: ANHANG_MAX_MB * 1024 * 1024 },
|
||||||
fileFilter: ATTACHMENT_FILTER,
|
fileFilter: ATTACHMENT_FILTER,
|
||||||
}).single('datei');
|
}).single('datei');
|
||||||
|
|
||||||
@@ -447,7 +545,7 @@ const uploadInterneAnhang = multer({
|
|||||||
cb(null, `${Date.now()}_${safe}`);
|
cb(null, `${Date.now()}_${safe}`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
limits: { fileSize: ANHANG_MAX_MB * 1024 * 1024 },
|
||||||
fileFilter: ATTACHMENT_FILTER,
|
fileFilter: ATTACHMENT_FILTER,
|
||||||
}).single('datei');
|
}).single('datei');
|
||||||
|
|
||||||
@@ -466,10 +564,8 @@ const uploadSignatur = multer({
|
|||||||
cb(null, `signatur_${Date.now()}${ext}`);
|
cb(null, `signatur_${Date.now()}${ext}`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
limits: { fileSize: BILD_MAX_MB * 1024 * 1024 },
|
||||||
fileFilter: (req, file, cb) => cb(null,
|
fileFilter: BILD_FILTER,
|
||||||
/^image\/(png|jpe?g)$/.test(file.mimetype)
|
|
||||||
&& /^\.(png|jpe?g)$/.test(path.extname(file.originalname).toLowerCase())),
|
|
||||||
}).single('signatur');
|
}).single('signatur');
|
||||||
|
|
||||||
// The single stored signature file, if any (in the current user's subdir).
|
// The single stored signature file, if any (in the current user's subdir).
|
||||||
@@ -511,10 +607,8 @@ const uploadFoto = multer({
|
|||||||
cb(null, `foto_${Date.now()}${ext}`);
|
cb(null, `foto_${Date.now()}${ext}`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
limits: { fileSize: BILD_MAX_MB * 1024 * 1024 },
|
||||||
fileFilter: (req, file, cb) => cb(null,
|
fileFilter: BILD_FILTER,
|
||||||
/^image\/(png|jpe?g)$/.test(file.mimetype)
|
|
||||||
&& /^\.(png|jpe?g)$/.test(path.extname(file.originalname).toLowerCase())),
|
|
||||||
}).single('foto');
|
}).single('foto');
|
||||||
|
|
||||||
// The single stored photo file, if any (in the current user's subdir).
|
// The single stored photo file, if any (in the current user's subdir).
|
||||||
@@ -814,6 +908,16 @@ async function autoBlacklistOffer(offer, grund) {
|
|||||||
// Upcoming appointments (not yet ended), newest first, for the dashboard widget.
|
// Upcoming appointments (not yet ended), newest first, for the dashboard widget.
|
||||||
async function upcomingTermine(limit = 6) {
|
async function upcomingTermine(limit = 6) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
// limit = null/0 ⇒ kein Limit, alle anstehenden Termine (z. B. Startseite).
|
||||||
|
if (!limit) {
|
||||||
|
return dbAll(
|
||||||
|
`SELECT t.*, b.firma AS bewerbung_firma, b.stelle AS bewerbung_stelle
|
||||||
|
FROM termine t LEFT JOIN bewerbungen b ON b.id = t.bewerbung_id
|
||||||
|
WHERE t.user_id = ? AND COALESCE(t.ende, t.start) >= ?
|
||||||
|
ORDER BY t.start ASC`,
|
||||||
|
[currentUserId(), now]
|
||||||
|
);
|
||||||
|
}
|
||||||
return dbAll(
|
return dbAll(
|
||||||
`SELECT t.*, b.firma AS bewerbung_firma, b.stelle AS bewerbung_stelle
|
`SELECT t.*, b.firma AS bewerbung_firma, b.stelle AS bewerbung_stelle
|
||||||
FROM termine t LEFT JOIN bewerbungen b ON b.id = t.bewerbung_id
|
FROM termine t LEFT JOIN bewerbungen b ON b.id = t.bewerbung_id
|
||||||
@@ -1208,9 +1312,27 @@ async function initializeDatabase() {
|
|||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- Darf der Nutzer selber Suchläufe starten („Jetzt suchen") und darf er
|
||||||
|
-- den Zeitplan (Wochentage/Uhrzeit/aktiv) ändern? Beides ist standardmäßig
|
||||||
|
-- AUS — der Admin aktiviert es pro Nutzer. Der automatische Zeitplan selbst
|
||||||
|
-- läuft weiter, sobald aktiviert (nur das Starten/Ändern ist beschränkt).
|
||||||
|
darf_suchlauf_starten INTEGER NOT NULL DEFAULT 0,
|
||||||
|
darf_zeitplan_aendern INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
// Bestehende Installationen: die beiden Spalten kamen nach dem CREATE TABLE
|
||||||
|
// dazu. ALTER ist idempotent via PRAGMA table_info (Default 0 = deaktiviert,
|
||||||
|
// auch für alle existierenden Nutzer).
|
||||||
|
{
|
||||||
|
const userSpalten = await dbAll('PRAGMA table_info(users)');
|
||||||
|
if (!userSpalten.some((s) => s.name === 'darf_suchlauf_starten')) {
|
||||||
|
await exec('ALTER TABLE users ADD COLUMN darf_suchlauf_starten INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
if (!userSpalten.some((s) => s.name === 'darf_zeitplan_aendern')) {
|
||||||
|
await exec('ALTER TABLE users ADD COLUMN darf_zeitplan_aendern INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
await exec(`
|
await exec(`
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
@@ -1438,9 +1560,22 @@ async function initializeDatabase() {
|
|||||||
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)');
|
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)');
|
||||||
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)');
|
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)');
|
||||||
|
|
||||||
// Favoriten-Merkmal (Stern): 0 = nicht gemerkt, 1 = favorisiert. Favoriten
|
// Favoriten-Merkmal (Stern): 0 = nicht gemerkt, 1 = favorisiert. Jobangebote
|
||||||
// erscheinen oben auf der Startseite — unabhängig vom Status (offen /
|
// und Bewerbungen haben je ein eigenes, voneinander unabhängiges Merkmal —
|
||||||
// uebernommen / abgelehnt). Spalte wird nur bei Bedarf angelegt.
|
// ein gemerktes Angebot wird also nicht zur gemerkten Bewerbung (und umgekehrt).
|
||||||
|
// Beide Spalten werden nur bei Bedarf nachgerüstet.
|
||||||
|
|
||||||
|
// Jobangebote: Favoriten erscheinen oben in der Liste und im eigenen Tab
|
||||||
|
// „Favoriten“ auf /jobangebote — unabhängig vom Status (offen / uebernommen /
|
||||||
|
// abgelehnt). Die Favoriten bleiben auf die Jobangebote-Seite beschränkt.
|
||||||
|
{
|
||||||
|
const jobSpalten = await dbAll('PRAGMA table_info(jobangebote)');
|
||||||
|
if (!jobSpalten.some((s) => s.name === 'favorit')) {
|
||||||
|
await exec('ALTER TABLE jobangebote ADD COLUMN favorit INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_favorit ON jobangebote(user_id, favorit)');
|
||||||
|
|
||||||
// Favoriten-Markierung für Bewerbungen. Erlaubt das Merken einzelner
|
// Favoriten-Markierung für Bewerbungen. Erlaubt das Merken einzelner
|
||||||
// Bewerbungen, unabhängig von ihrem Status.
|
// Bewerbungen, unabhängig von ihrem Status.
|
||||||
{
|
{
|
||||||
@@ -1639,6 +1774,15 @@ initializeDatabase().then(async () => {
|
|||||||
// to be present inside any protected route or background-per-user task.
|
// to be present inside any protected route or background-per-user task.
|
||||||
const uid = () => currentUserId();
|
const uid = () => currentUserId();
|
||||||
|
|
||||||
|
// Inside a multer upload callback the per-request AsyncLocalStorage context
|
||||||
|
// (which uid() reads) is not reliably propagated — busboy drives the multipart
|
||||||
|
// stream from outside the userContext.run(...) scope, so currentUserId() can
|
||||||
|
// come back null there. The request object, by contrast, always carries the
|
||||||
|
// resolved user (set by the auth middleware before the route). Use this for
|
||||||
|
// any INSERT done from within an uploadX(req, res, cb) callback, so the row is
|
||||||
|
// not orphaned with user_id = NULL. Mirrors userStorageDirForReq's reasoning.
|
||||||
|
const uidFromReq = (req) => (req && req.user && req.user.id) || currentUserId();
|
||||||
|
|
||||||
// Resolve a per-user storage directory (data/<base>/<userId>/), creating it
|
// Resolve a per-user storage directory (data/<base>/<userId>/), creating it
|
||||||
// on first use. Used for every attachment / signature / photo path so users'
|
// on first use. Used for every attachment / signature / photo path so users'
|
||||||
// files are isolated on disk the same way their DB rows are.
|
// files are isolated on disk the same way their DB rows are.
|
||||||
@@ -1709,7 +1853,7 @@ initializeDatabase().then(async () => {
|
|||||||
await attachVerlauf(applications);
|
await attachVerlauf(applications);
|
||||||
applications.forEach((a) => { a.labelsArr = parseLabels(a.labels); });
|
applications.forEach((a) => { a.labelsArr = parseLabels(a.labels); });
|
||||||
// Upcoming calendar appointments for the dashboard widget.
|
// Upcoming calendar appointments for the dashboard widget.
|
||||||
const kommendeTermine = await upcomingTermine(6);
|
const kommendeTermine = await upcomingTermine(null);
|
||||||
|
|
||||||
// Get statistics
|
// Get statistics
|
||||||
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [U]);
|
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [U]);
|
||||||
@@ -1794,8 +1938,8 @@ initializeDatabase().then(async () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gemerkte Bewerbungen — unabhängig vom Bewerbungs-Status, ganz oben auf
|
// Gemerkte Bewerbungen — unabhängig vom Bewerbungs-Status, auf der
|
||||||
// der Startseite.
|
// Startseite direkt unter den anstehenden Terminen.
|
||||||
const bewerbungFavoriten = await dbAll(
|
const bewerbungFavoriten = await dbAll(
|
||||||
`SELECT b.id, b.firma, b.stelle, b.ort, b.status, b.datum, b.favorit
|
`SELECT b.id, b.firma, b.stelle, b.ort, b.status, b.datum, b.favorit
|
||||||
FROM bewerbungen b
|
FROM bewerbungen b
|
||||||
@@ -1943,7 +2087,7 @@ initializeDatabase().then(async () => {
|
|||||||
|
|
||||||
// Any reply at all — everything past "Gesendet" except "Keine Rückmeldung".
|
// Any reply at all — everything past "Gesendet" except "Keine Rückmeldung".
|
||||||
const rueckStatus = [
|
const rueckStatus = [
|
||||||
'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert',
|
'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', 'Telefonat',
|
||||||
'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||||||
'Vorstellungsgespräch', 'Vertragsverhandlung',
|
'Vorstellungsgespräch', 'Vertragsverhandlung',
|
||||||
'Absage', 'Absage von meiner Seite', 'Einstellung'
|
'Absage', 'Absage von meiner Seite', 'Einstellung'
|
||||||
@@ -2184,7 +2328,7 @@ initializeDatabase().then(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ----- Indeed import (called by the browser extension) -----
|
// ----- Indeed import (called by the browser extension) -----
|
||||||
app.post('/api/indeed-import', async (req, res) => {
|
app.post('/api/indeed-import', requireApiKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url, art } = req.body || {};
|
const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url, art } = req.body || {};
|
||||||
|
|
||||||
@@ -2247,6 +2391,24 @@ initializeDatabase().then(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ----- Browser-Plugin als ZIP herunterladen -----
|
||||||
|
// Packt das Verzeichnis extension/ on the fly (STORE, dep-frei). Liegt hinter
|
||||||
|
// requireAuth — nur angemeldete Nutzer laden das Plugin. Der Dateiname ist
|
||||||
|
// stabil, das Archiv deterministisch (fester Zeitstempel) → cache-freundlich.
|
||||||
|
app.get('/plugin/nextjobs-stellen-import.zip', (req, res) => {
|
||||||
|
try {
|
||||||
|
const dir = path.join(__dirname, 'extension');
|
||||||
|
const zip = zipDir(dir);
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="nextjobs-stellen-import.zip"');
|
||||||
|
res.setHeader('Content-Length', zip.length);
|
||||||
|
res.send(zip);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Plugin-Download:', error);
|
||||||
|
res.status(500).send('Plugin konnte nicht gepackt werden.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Volltextsuche für die Startseite: Bewerbungen samt ihrer E-Mail-Korrespondenz
|
// Volltextsuche für die Startseite: Bewerbungen samt ihrer E-Mail-Korrespondenz
|
||||||
// (Absender, Betreff, Text), Notizen und Stellenbeschreibung. Liefert je Treffer
|
// (Absender, Betreff, Text), Notizen und Stellenbeschreibung. Liefert je Treffer
|
||||||
// die Fundstellen mit Snippet — die Marker darin ersetzt der Client durch <mark>.
|
// die Fundstellen mit Snippet — die Marker darin ersetzt der Client durch <mark>.
|
||||||
@@ -2462,6 +2624,13 @@ initializeDatabase().then(async () => {
|
|||||||
const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [U]);
|
const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [U]);
|
||||||
// Calendar appointments for this application (mirrored to SOGo).
|
// Calendar appointments for this application (mirrored to SOGo).
|
||||||
const termine = await dbAll('SELECT * FROM termine WHERE bewerbung_id = ? AND user_id = ? ORDER BY start ASC', [id, U]);
|
const termine = await dbAll('SELECT * FROM termine WHERE bewerbung_id = ? AND user_id = ? ORDER BY start ASC', [id, U]);
|
||||||
|
// Vorausgefüllte Werte für die Bearbeiten-Formulare (Berliner Wanduhr).
|
||||||
|
termine.forEach((t) => {
|
||||||
|
const s = caldav.utcToWallInputs(new Date(t.start));
|
||||||
|
t.editDatum = s.datum;
|
||||||
|
t.editVon = t.ganztags ? '09:00' : s.zeit;
|
||||||
|
t.editBis = (!t.ganztags && t.ende) ? caldav.utcToWallInputs(new Date(t.ende)).zeit : '';
|
||||||
|
});
|
||||||
|
|
||||||
// E-Mail correspondence (sent + received), oldest first, with attachments.
|
// E-Mail correspondence (sent + received), oldest first, with attachments.
|
||||||
const emails = await dbAll(
|
const emails = await dbAll(
|
||||||
@@ -2494,16 +2663,19 @@ initializeDatabase().then(async () => {
|
|||||||
anhaenge,
|
anhaenge,
|
||||||
interneAnhaenge,
|
interneAnhaenge,
|
||||||
emails,
|
emails,
|
||||||
// Last choice of documents (defaults to both), for pre-ticking the form.
|
// Last choice of documents for this application; if there is none yet, the
|
||||||
dokumentAuswahl: normalizeDokumente(
|
// user's preselection from the settings (default: both) pre-ticks the form.
|
||||||
application.generierung_dokumente ? String(application.generierung_dokumente).split(',') : null
|
dokumentAuswahl: application.generierung_dokumente
|
||||||
),
|
? normalizeDokumente(String(application.generierung_dokumente).split(','))
|
||||||
|
: standardDokumente(),
|
||||||
mailConfigured: mailer.isConfigured(),
|
mailConfigured: mailer.isConfigured(),
|
||||||
mailFrom: mailer.isConfigured() ? mailer.fromField() : '',
|
mailFrom: mailer.isConfigured() ? mailer.fromField() : '',
|
||||||
mailError: req.query.mailerror ? String(req.query.mailerror) : '',
|
mailError: req.query.mailerror ? String(req.query.mailerror) : '',
|
||||||
mailOk: req.query.mailok ? String(req.query.mailok) : '',
|
mailOk: req.query.mailok ? String(req.query.mailok) : '',
|
||||||
basisCount: basisCountRow ? basisCountRow.count : 0,
|
basisCount: basisCountRow ? basisCountRow.count : 0,
|
||||||
basisAnhaenge,
|
basisAnhaenge,
|
||||||
|
// Vorausgewählte Anlagen aus den Einstellungen (Standard: keine).
|
||||||
|
anlagenAuswahl: config.anlagenDefaultIds(),
|
||||||
termine,
|
termine,
|
||||||
caldavConfigured: caldav.isConfigured(),
|
caldavConfigured: caldav.isConfigured(),
|
||||||
caldavTz: caldav.TZ,
|
caldavTz: caldav.TZ,
|
||||||
@@ -2946,6 +3118,70 @@ initializeDatabase().then(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Termin bearbeiten: aktualisiert den Kalender-Termin (CalDAV) und das
|
||||||
|
// lokale Spiegelbild. Legt einen neuen CalDAV-Eintrag an, falls der alte
|
||||||
|
// entfernt wurde (z. B. remote gelöscht).
|
||||||
|
app.post('/bewerbung/:id/termine/:tid/edit', async (req, res) => {
|
||||||
|
const { id, tid } = req.params;
|
||||||
|
try {
|
||||||
|
const t = await dbGet('SELECT * FROM termine WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [tid, id, uid()]);
|
||||||
|
if (!t) return res.redirect('/bewerbung/' + id + '#termine');
|
||||||
|
if (!caldav.isConfigured()) {
|
||||||
|
return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Kalender ist nicht konfiguriert.') + '#termine');
|
||||||
|
}
|
||||||
|
const b = req.body || {};
|
||||||
|
if (!b.datum) {
|
||||||
|
return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Bitte ein Datum angeben.') + '#termine');
|
||||||
|
}
|
||||||
|
const ganztags = b.ganztags === 'on' || b.ganztags === '1' || b.ganztags === 'true';
|
||||||
|
const typ = b.typ === 'vorstellungsgespraech' ? 'vorstellungsgespraech' : 'termin';
|
||||||
|
const titel = (b.titel || '').trim() || (typ === 'vorstellungsgespraech' ? 'Vorstellungsgespräch' : 'Termin');
|
||||||
|
const erinnerung = Math.max(0, parseInt(b.erinnerung_min, 10) || 0);
|
||||||
|
|
||||||
|
let start, ende = null;
|
||||||
|
if (ganztags) {
|
||||||
|
const [y, mo, d] = String(b.datum).split('-').map(Number);
|
||||||
|
start = caldav.wallToUtc(y, mo, d, 0, 0);
|
||||||
|
} else {
|
||||||
|
start = caldav.localInputToUtc(b.datum, b.von || '09:00');
|
||||||
|
if (b.bis) ende = caldav.localInputToUtc(b.datum, b.bis);
|
||||||
|
}
|
||||||
|
|
||||||
|
const evt = {
|
||||||
|
summary: titel, location: b.ort || '', description: b.notiz || '',
|
||||||
|
start, end: ende, allDay: ganztags, alarmMin: erinnerung,
|
||||||
|
};
|
||||||
|
|
||||||
|
let href = t.caldav_href, etag = t.caldav_etag, caldavUid = t.caldav_uid;
|
||||||
|
if (href) {
|
||||||
|
const upd = await caldav.updateEvent({ href, etag, ...evt }).catch((e) => {
|
||||||
|
// 412/404 bedeutet: der entfernte Termin wurde zwischenzeitlich
|
||||||
|
// geändert oder gelöscht — dann legen wir ihn neu an.
|
||||||
|
if (/HTTP (404|412)/.test(e.message)) return null;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
if (upd) { etag = upd.etag; caldavUid = upd.uid; }
|
||||||
|
else { const created = await caldav.createEvent(evt); href = created.href; etag = created.etag; caldavUid = created.uid; }
|
||||||
|
} else {
|
||||||
|
const created = await caldav.createEvent(evt);
|
||||||
|
href = created.href; etag = created.etag; caldavUid = created.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbRun(
|
||||||
|
`UPDATE termine SET typ = ?, titel = ?, ort = ?, notiz = ?, start = ?, ende = ?, ganztags = ?,
|
||||||
|
erinnerung_min = ?, caldav_uid = ?, caldav_href = ?, caldav_etag = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND user_id = ?`,
|
||||||
|
[typ, sanitizeInput(titel), sanitizeInput(b.ort || ''), sanitizeInput(b.notiz || ''),
|
||||||
|
start.toISOString(), ende ? ende.toISOString() : null, ganztags ? 1 : 0, erinnerung,
|
||||||
|
caldavUid, href, etag, tid, uid()]
|
||||||
|
);
|
||||||
|
res.redirect('/bewerbung/' + id + '?terminok=1#termine');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error editing termin:', error);
|
||||||
|
res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent(error.message || 'Termin konnte nicht aktualisiert werden.') + '#termine');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Update a timeline entry
|
// Update a timeline entry
|
||||||
app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => {
|
app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -2999,6 +3235,10 @@ initializeDatabase().then(async () => {
|
|||||||
basisTypOptions: BASIS_TYP_OPTIONS,
|
basisTypOptions: BASIS_TYP_OPTIONS,
|
||||||
hasApiKey: Boolean(config.get('OLLAMA_API_KEY')),
|
hasApiKey: Boolean(config.get('OLLAMA_API_KEY')),
|
||||||
hideSettings: true,
|
hideSettings: true,
|
||||||
|
// Set by the upload routes when multer rejected a file (too large, wrong
|
||||||
|
// type) — without this the page just came back without the attachment.
|
||||||
|
fehler: req.query.fehler ? String(req.query.fehler) : null,
|
||||||
|
anhangMaxMb: ANHANG_MAX_MB,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading vorlagen:', error);
|
console.error('Error loading vorlagen:', error);
|
||||||
@@ -3197,18 +3437,19 @@ initializeDatabase().then(async () => {
|
|||||||
try {
|
try {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Upload error:', err.message);
|
console.error('Upload error:', err.message);
|
||||||
return res.redirect('/vorlagen');
|
return res.redirect(mitFehler('/vorlagen', uploadFehlerText(err, ANHANG_MAX_MB)));
|
||||||
|
}
|
||||||
|
if (!req.file) {
|
||||||
|
return res.redirect(mitFehler('/vorlagen', 'Es wurde keine Datei ausgewählt.'));
|
||||||
}
|
}
|
||||||
if (req.file) {
|
|
||||||
const original = req.file.originalname || req.file.filename;
|
const original = req.file.originalname || req.file.filename;
|
||||||
const name = (req.body.name && req.body.name.trim())
|
const name = (req.body.name && req.body.name.trim())
|
||||||
? sanitizeInput(req.body.name.trim())
|
? sanitizeInput(req.body.name.trim())
|
||||||
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
|
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
|
||||||
await dbRun(
|
await dbRun(
|
||||||
'INSERT INTO basis_anhaenge (user_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO basis_anhaenge (user_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
|
||||||
[uid(), name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
|
[uidFromReq(req), name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
res.redirect('/vorlagen');
|
res.redirect('/vorlagen');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving attachment:', error);
|
console.error('Error saving attachment:', error);
|
||||||
@@ -3260,10 +3501,15 @@ initializeDatabase().then(async () => {
|
|||||||
app.post('/unterschrift', (req, res) => {
|
app.post('/unterschrift', (req, res) => {
|
||||||
uploadSignatur(req, res, (err) => {
|
uploadSignatur(req, res, (err) => {
|
||||||
try {
|
try {
|
||||||
if (err) console.error('Signature upload error:', err.message);
|
if (err) {
|
||||||
|
console.error('Signature upload error:', err.message);
|
||||||
|
return res.redirect(mitFehler('/vorlagen', uploadFehlerText(err, BILD_MAX_MB)));
|
||||||
|
}
|
||||||
if (req.file) {
|
if (req.file) {
|
||||||
// keep only the newly uploaded file (in the user's subdir)
|
// keep only the newly uploaded file (in the user's subdir). Resolve the
|
||||||
const dir = userStorageDir(signaturDir);
|
// dir from req.user (not currentUserId()) — this runs in the multer
|
||||||
|
// callback where the AsyncLocalStorage user context can be absent.
|
||||||
|
const dir = userStorageDirForReq(signaturDir, req);
|
||||||
fs.readdirSync(dir).forEach((f) => {
|
fs.readdirSync(dir).forEach((f) => {
|
||||||
if (f !== req.file.filename) fs.promises.unlink(path.join(dir, f)).catch(() => {});
|
if (f !== req.file.filename) fs.promises.unlink(path.join(dir, f)).catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -3298,10 +3544,14 @@ initializeDatabase().then(async () => {
|
|||||||
app.post('/bewerbungsfoto', (req, res) => {
|
app.post('/bewerbungsfoto', (req, res) => {
|
||||||
uploadFoto(req, res, (err) => {
|
uploadFoto(req, res, (err) => {
|
||||||
try {
|
try {
|
||||||
if (err) console.error('Photo upload error:', err.message);
|
if (err) {
|
||||||
|
console.error('Photo upload error:', err.message);
|
||||||
|
return res.redirect(mitFehler('/vorlagen', uploadFehlerText(err, BILD_MAX_MB)));
|
||||||
|
}
|
||||||
if (req.file) {
|
if (req.file) {
|
||||||
// keep only the newly uploaded file (in the user's subdir)
|
// keep only the newly uploaded file (in the user's subdir). Resolve the
|
||||||
const dir = userStorageDir(fotoDir);
|
// dir from req.user — see /unterschrift handler for why (multer callback).
|
||||||
|
const dir = userStorageDirForReq(fotoDir, req);
|
||||||
fs.readdirSync(dir).forEach((f) => {
|
fs.readdirSync(dir).forEach((f) => {
|
||||||
if (f !== req.file.filename) fs.promises.unlink(path.join(dir, f)).catch(() => {});
|
if (f !== req.file.filename) fs.promises.unlink(path.join(dir, f)).catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -3374,7 +3624,7 @@ initializeDatabase().then(async () => {
|
|||||||
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
|
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
|
||||||
await dbRun(
|
await dbRun(
|
||||||
'INSERT INTO interne_anhaenge (user_id, bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?, ?)',
|
'INSERT INTO interne_anhaenge (user_id, bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
[uid(), id, name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
|
[uidFromReq(req), id, name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
res.redirect('/bewerbung/' + id);
|
res.redirect('/bewerbung/' + id);
|
||||||
@@ -3462,30 +3712,51 @@ initializeDatabase().then(async () => {
|
|||||||
// Count of open offers, consumed by the header badge (no auth — just a number).
|
// Count of open offers, consumed by the header badge (no auth — just a number).
|
||||||
app.get('/jobangebote/anzahl-offen', async (req, res) => {
|
app.get('/jobangebote/anzahl-offen', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const row = await dbGet("SELECT COUNT(*) as count FROM jobangebote WHERE user_id = ? AND status = 'offen'", [uid()]);
|
const row = await dbGet("SELECT COUNT(*) as count FROM jobangebote WHERE user_id = ? AND status = 'offen' AND favorit = 0", [uid()]);
|
||||||
res.json({ count: row ? row.count : 0 });
|
res.json({ count: row ? row.count : 0 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ count: 0 });
|
res.status(500).json({ count: 0 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Zähler für die Tab-Leiste der Jobangebote-Seiten (offen / gemerkt /
|
||||||
|
// übernommen). Die Favoriten zählen über alle Status hinweg.
|
||||||
|
async function jobangeboteZaehler() {
|
||||||
|
const row = await dbGet(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'offen' AND favorit = 0) AS offen,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'uebernommen') AS uebernommen,
|
||||||
|
COUNT(*) FILTER (WHERE favorit = 1) AS favoriten
|
||||||
|
FROM jobangebote WHERE user_id = ?`,
|
||||||
|
[uid()]
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
offenCount: row ? row.offen : 0,
|
||||||
|
uebernommenCount: row ? row.uebernommen : 0,
|
||||||
|
favoritenCount: row ? row.favoriten : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Main list: only OPEN offers (still to be decided on). Taken-over offers
|
// Main list: only OPEN offers (still to be decided on). Taken-over offers
|
||||||
// live on their own page (/jobangebote/uebernommen).
|
// live on their own page (/jobangebote/uebernommen). Gemerkte (favorisierte)
|
||||||
|
// Angebote erscheinen ausschließlich im eigenen Tab (/jobangebote/favoriten)
|
||||||
|
// und werden hier ausgeblendet.
|
||||||
app.get('/jobangebote', async (req, res) => {
|
app.get('/jobangebote', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const jobangebote = await dbAll(
|
const jobangebote = await dbAll(
|
||||||
`SELECT j.*, b.datum AS bewerbung_datum
|
`SELECT j.*, b.datum AS bewerbung_datum
|
||||||
FROM jobangebote j
|
FROM jobangebote j
|
||||||
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id
|
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id
|
||||||
WHERE j.user_id = ? AND j.status = 'offen'
|
WHERE j.user_id = ? AND j.status = 'offen' AND j.favorit = 0
|
||||||
ORDER BY j.created_at DESC, j.id DESC`,
|
ORDER BY j.created_at DESC, j.id DESC`,
|
||||||
[uid()]
|
[uid()]
|
||||||
);
|
);
|
||||||
jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
|
jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
|
||||||
const uebernommenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE user_id = ? AND status = 'uebernommen'", [uid()]);
|
const zaehler = await jobangeboteZaehler();
|
||||||
res.render('jobangebote', {
|
res.render('jobangebote', {
|
||||||
jobangebote,
|
jobangebote,
|
||||||
uebernommenCount: uebernommenRow ? uebernommenRow.c : 0,
|
...zaehler,
|
||||||
|
ansicht: 'offen',
|
||||||
artOptions: ART_OPTIONS,
|
artOptions: ART_OPTIONS,
|
||||||
statusOptions: STATUS_OPTIONS,
|
statusOptions: STATUS_OPTIONS,
|
||||||
labelOptions: LABEL_OPTIONS,
|
labelOptions: LABEL_OPTIONS,
|
||||||
@@ -3497,6 +3768,35 @@ initializeDatabase().then(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Gemerkte Angebote — quer über alle Status (offen, übernommen, abgelehnt).
|
||||||
|
// Nutzt dieselbe Ansicht wie die offene Liste, nur mit anderer Auswahl.
|
||||||
|
app.get('/jobangebote/favoriten', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const jobangebote = await dbAll(
|
||||||
|
`SELECT j.*, b.datum AS bewerbung_datum
|
||||||
|
FROM jobangebote j
|
||||||
|
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id
|
||||||
|
WHERE j.user_id = ? AND j.favorit = 1
|
||||||
|
ORDER BY j.updated_at DESC, j.id DESC`,
|
||||||
|
[uid()]
|
||||||
|
);
|
||||||
|
jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
|
||||||
|
const zaehler = await jobangeboteZaehler();
|
||||||
|
res.render('jobangebote', {
|
||||||
|
jobangebote,
|
||||||
|
...zaehler,
|
||||||
|
ansicht: 'favoriten',
|
||||||
|
artOptions: ART_OPTIONS,
|
||||||
|
statusOptions: STATUS_OPTIONS,
|
||||||
|
labelOptions: LABEL_OPTIONS,
|
||||||
|
hideSettings: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing favorite job offers:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Taken-over offers: which offers were turned into applications.
|
// Taken-over offers: which offers were turned into applications.
|
||||||
app.get('/jobangebote/uebernommen', async (req, res) => {
|
app.get('/jobangebote/uebernommen', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -3505,14 +3805,14 @@ initializeDatabase().then(async () => {
|
|||||||
FROM jobangebote j
|
FROM jobangebote j
|
||||||
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id
|
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id
|
||||||
WHERE j.user_id = ? AND j.status = 'uebernommen'
|
WHERE j.user_id = ? AND j.status = 'uebernommen'
|
||||||
ORDER BY j.updated_at DESC, j.id DESC`,
|
ORDER BY j.favorit DESC, j.updated_at DESC, j.id DESC`,
|
||||||
[uid()]
|
[uid()]
|
||||||
);
|
);
|
||||||
uebernommen.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
|
uebernommen.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
|
||||||
const offenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE user_id = ? AND status = 'offen'", [uid()]);
|
const zaehler = await jobangeboteZaehler();
|
||||||
res.render('jobangebote_uebernommen', {
|
res.render('jobangebote_uebernommen', {
|
||||||
uebernommen,
|
uebernommen,
|
||||||
offenCount: offenRow ? offenRow.c : 0,
|
...zaehler,
|
||||||
hideSettings: false,
|
hideSettings: false,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3595,6 +3895,12 @@ initializeDatabase().then(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nach Bearbeiten/Löschen zurück auf den Tab, von dem die Aktion kam
|
||||||
|
// (Formularfeld `zurueck`) — sonst auf die offene Liste.
|
||||||
|
function jobangeboteZiel(req) {
|
||||||
|
return (req.body && req.body.zurueck === 'favoriten') ? '/jobangebote/favoriten' : '/jobangebote';
|
||||||
|
}
|
||||||
|
|
||||||
// Edit an offer's fields — mainly to paste/adjust the full job description
|
// Edit an offer's fields — mainly to paste/adjust the full job description
|
||||||
// (any length) before turning it into an application.
|
// (any length) before turning it into an application.
|
||||||
app.post('/jobangebote/:id/bearbeiten', async (req, res) => {
|
app.post('/jobangebote/:id/bearbeiten', async (req, res) => {
|
||||||
@@ -3626,7 +3932,7 @@ initializeDatabase().then(async () => {
|
|||||||
uid(),
|
uid(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
res.redirect('/jobangebote');
|
res.redirect(jobangeboteZiel(req));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error editing job offer:', error);
|
console.error('Error editing job offer:', error);
|
||||||
res.status(500).send('Serverfehler');
|
res.status(500).send('Serverfehler');
|
||||||
@@ -3643,13 +3949,30 @@ initializeDatabase().then(async () => {
|
|||||||
await autoBlacklistOffer(offer, 'Jobangebot gelöscht (Web-UI)');
|
await autoBlacklistOffer(offer, 'Jobangebot gelöscht (Web-UI)');
|
||||||
await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||||
}
|
}
|
||||||
res.redirect('/jobangebote');
|
res.redirect(jobangeboteZiel(req));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting job offer:', error);
|
console.error('Error deleting job offer:', error);
|
||||||
res.status(500).send('Serverfehler');
|
res.status(500).send('Serverfehler');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Jobangebot merken/entmerken (Stern). Toggle, antwortet mit dem neuen Zustand
|
||||||
|
// — der Stern-Button in partials/favorit-button spricht diesen Endpunkt an.
|
||||||
|
// Getrennt von den Bewerbungs-Favoriten: das Merken eines Angebots berührt
|
||||||
|
// eine daraus entstandene Bewerbung nicht.
|
||||||
|
app.post('/jobangebote/:id/favorit', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const angebot = await dbGet('SELECT favorit FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||||
|
if (!angebot) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
||||||
|
const neu = angebot.favorit ? 0 : 1;
|
||||||
|
await dbRun('UPDATE jobangebote SET favorit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [neu, req.params.id, uid()]);
|
||||||
|
res.json({ success: true, favorit: neu });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling job offer favorite:', error);
|
||||||
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Bewerbung als Favorit merken/entmerken — unabhängig vom Bewerbungs-Status.
|
// Bewerbung als Favorit merken/entmerken — unabhängig vom Bewerbungs-Status.
|
||||||
app.post('/api/bewerbungen/:id/favorit', async (req, res) => {
|
app.post('/api/bewerbungen/:id/favorit', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -3712,12 +4035,25 @@ initializeDatabase().then(async () => {
|
|||||||
// Alle Konfigurationswerte (Ollama, E-Mail, CalDAV, REST-API) liegen in der
|
// Alle Konfigurationswerte (Ollama, E-Mail, CalDAV, REST-API) liegen in der
|
||||||
// app_state-Tabelle und sind hier editierbar. Speichern wirkt sofort, ein
|
// app_state-Tabelle und sind hier editierbar. Speichern wirkt sofort, ein
|
||||||
// Neustart ist nicht nötig (die Libs lesen per config.get() zur Laufzeit).
|
// Neustart ist nicht nötig (die Libs lesen per config.get() zur Laufzeit).
|
||||||
app.get('/einstellungen', (req, res) => {
|
app.get('/einstellungen', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Optionen, die erst zur Laufzeit feststehen (Feld-Definition: optionsFrom).
|
||||||
|
const anlagen = await dbAll(
|
||||||
|
'SELECT id, name, dateiname FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC',
|
||||||
|
[uid()]
|
||||||
|
);
|
||||||
res.render('settings', {
|
res.render('settings', {
|
||||||
felder: config.FIELDS,
|
felder: config.FIELDS,
|
||||||
werte: config.getAll(),
|
werte: config.getAll(),
|
||||||
|
auswahllisten: {
|
||||||
|
anlagen: anlagen.map((a) => ({ value: String(a.id), label: a.name || a.dateiname })),
|
||||||
|
},
|
||||||
hideSettings: true,
|
hideSettings: true,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/einstellungen', async (req, res) => {
|
app.post('/einstellungen', async (req, res) => {
|
||||||
@@ -3725,7 +4061,18 @@ initializeDatabase().then(async () => {
|
|||||||
const werte = {};
|
const werte = {};
|
||||||
for (const sektion of config.FIELDS) {
|
for (const sektion of config.FIELDS) {
|
||||||
for (const f of sektion.items) {
|
for (const f of sektion.items) {
|
||||||
werte[f.key] = req.body[f.key] != null ? String(req.body[f.key]) : '';
|
const roh = req.body[f.key];
|
||||||
|
if (f.type === 'checkboxes') {
|
||||||
|
// Checkbox-Gruppe: Express liefert je nach Anzahl Häkchen einen String
|
||||||
|
// oder ein Array (inkl. des leeren Hidden-Werts) — als Komma-Liste ablegen.
|
||||||
|
werte[f.key] = []
|
||||||
|
.concat(roh == null ? [] : roh)
|
||||||
|
.map((v) => String(v).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(',');
|
||||||
|
} else {
|
||||||
|
werte[f.key] = roh != null ? String(roh) : '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3809,6 +4156,16 @@ initializeDatabase().then(async () => {
|
|||||||
"SELECT value FROM app_state WHERE user_id = ? AND key = 'cfg:OLLAMA_API_KEY' AND value != ''",
|
"SELECT value FROM app_state WHERE user_id = ? AND key = 'cfg:OLLAMA_API_KEY' AND value != ''",
|
||||||
[ziel.id]
|
[ziel.id]
|
||||||
);
|
);
|
||||||
|
// Pro-Nutzer-Rechte: darf der Zielbenutzer Suchläufe manuell starten und
|
||||||
|
// darf er den Zeitplan ändern? Ein Admin, der ein fremdes Profil verwaltet
|
||||||
|
// (fremd), darf beides immer — die Rechte schränken nur den eigentlichen
|
||||||
|
// Nutzer auf seiner eigenen /jobsuche ein.
|
||||||
|
const rechte = await dbGet(
|
||||||
|
'SELECT darf_suchlauf_starten, darf_zeitplan_aendern FROM users WHERE id = ?',
|
||||||
|
[ziel.id]
|
||||||
|
);
|
||||||
|
const darfSuchlaufStarten = ziel.fremd || Boolean(rechte && rechte.darf_suchlauf_starten);
|
||||||
|
const darfZeitplanAendern = ziel.fremd || Boolean(rechte && rechte.darf_zeitplan_aendern);
|
||||||
|
|
||||||
res.render('jobsuche', {
|
res.render('jobsuche', {
|
||||||
profil,
|
profil,
|
||||||
@@ -3821,6 +4178,8 @@ initializeDatabase().then(async () => {
|
|||||||
hatLebenslauf: Boolean(lebenslauf),
|
hatLebenslauf: Boolean(lebenslauf),
|
||||||
hatUnterlagen: Boolean(unterlagen),
|
hatUnterlagen: Boolean(unterlagen),
|
||||||
hatOllamaKey: Boolean(ollamaKey),
|
hatOllamaKey: Boolean(ollamaKey),
|
||||||
|
darfSuchlaufStarten,
|
||||||
|
darfZeitplanAendern,
|
||||||
fehler: req.query.fehler ? String(req.query.fehler) : null,
|
fehler: req.query.fehler ? String(req.query.fehler) : null,
|
||||||
hinweis: req.query.hinweis ? String(req.query.hinweis) : null,
|
hinweis: req.query.hinweis ? String(req.query.hinweis) : null,
|
||||||
hideSettings: false,
|
hideSettings: false,
|
||||||
@@ -3838,6 +4197,21 @@ initializeDatabase().then(async () => {
|
|||||||
const suffix = ziel.fremd ? `?user=${ziel.id}` : '';
|
const suffix = ziel.fremd ? `?user=${ziel.id}` : '';
|
||||||
|
|
||||||
const profil = suchprofil.fromForm(req.body, sanitizeInput);
|
const profil = suchprofil.fromForm(req.body, sanitizeInput);
|
||||||
|
|
||||||
|
// Ohne das Recht „Zeitplan ändern" (und nicht als Admin unterwegs) darf
|
||||||
|
// der Nutzer die Wochentage/Uhrzeit/den aktiv-Schalter nicht anfassen —
|
||||||
|
// der adminstrulierte Zeitplan bleibt erhalten. Nur die Suchbegriffe /
|
||||||
|
// der Modus sind dann noch editierbar.
|
||||||
|
if (!ziel.fremd) {
|
||||||
|
const rechte = await dbGet('SELECT darf_zeitplan_aendern FROM users WHERE id = ?', [ziel.id]);
|
||||||
|
if (!rechte || !rechte.darf_zeitplan_aendern) {
|
||||||
|
const aktuell = await loadSuchprofil(ziel.id);
|
||||||
|
profil.aktiv = aktuell.aktiv;
|
||||||
|
profil.zeitplan_tage = aktuell.zeitplan_tage;
|
||||||
|
profil.zeitplan_zeit = aktuell.zeitplan_zeit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fehler = suchprofil.validate(profil);
|
const fehler = suchprofil.validate(profil);
|
||||||
if (fehler.length) {
|
if (fehler.length) {
|
||||||
return res.redirect(`/jobsuche${suffix}${suffix ? '&' : '?'}fehler=${encodeURIComponent(fehler.join(' '))}`);
|
return res.redirect(`/jobsuche${suffix}${suffix ? '&' : '?'}fehler=${encodeURIComponent(fehler.join(' '))}`);
|
||||||
@@ -3899,6 +4273,16 @@ initializeDatabase().then(async () => {
|
|||||||
const suffix = ziel.fremd ? `?user=${ziel.id}` : '';
|
const suffix = ziel.fremd ? `?user=${ziel.id}` : '';
|
||||||
const sep = suffix ? '&' : '?';
|
const sep = suffix ? '&' : '?';
|
||||||
|
|
||||||
|
// Manuelle Suchläufe sind ein eigenes Recht. Ein Admin, der ein fremdes
|
||||||
|
// Profil verwaltet, darf immer starten — der eigentliche Nutzer nur, wenn
|
||||||
|
// der Admin es ihm freigegeben hat.
|
||||||
|
if (!ziel.fremd) {
|
||||||
|
const rechte = await dbGet('SELECT darf_suchlauf_starten FROM users WHERE id = ?', [ziel.id]);
|
||||||
|
if (!rechte || !rechte.darf_suchlauf_starten) {
|
||||||
|
return res.status(403).send('Suchläufe dürfen nur von einem Administrator gestartet werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const profil = await loadSuchprofil(ziel.id);
|
const profil = await loadSuchprofil(ziel.id);
|
||||||
const fehler = suchprofil.validate(profil);
|
const fehler = suchprofil.validate(profil);
|
||||||
if (fehler.length) {
|
if (fehler.length) {
|
||||||
@@ -4019,6 +4403,88 @@ initializeDatabase().then(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Einzelnen Benutzer verwalten (Detailansicht von „Bestehende Benutzer").
|
||||||
|
app.get('/admin/users/:id', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const user = await dbGet('SELECT id, username, is_admin, darf_suchlauf_starten, darf_zeitplan_aendern, created_at FROM users WHERE id = ?', [id]);
|
||||||
|
if (!user) return res.status(404).send('Benutzer nicht gefunden.');
|
||||||
|
const anzahl = await dbGet('SELECT COUNT(*) AS c FROM bewerbungen WHERE user_id = ?', [id]);
|
||||||
|
const bewerbungen = await dbAll(
|
||||||
|
'SELECT id, firma, stelle, status, datum FROM bewerbungen WHERE user_id = ? ORDER BY date(datum) DESC, created_at DESC LIMIT 10',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const adminCount = await dbGet('SELECT COUNT(*) AS c FROM users WHERE is_admin = 1');
|
||||||
|
res.render('admin-user', {
|
||||||
|
user,
|
||||||
|
currentUserId: req.user.id,
|
||||||
|
anzahlBewerbungen: anzahl ? anzahl.c : 0,
|
||||||
|
bewerbungen: bewerbungen || [],
|
||||||
|
adminCount: adminCount ? adminCount.c : 0,
|
||||||
|
hideSettings: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin user detail error:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin-Status eines Benutzers umschalten. Der letzte Admin darf nicht
|
||||||
|
// abgesetzt werden (sonst sperrt man sich aus).
|
||||||
|
app.post('/admin/users/:id/toggle-admin', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const target = await dbGet('SELECT id, is_admin FROM users WHERE id = ?', [id]);
|
||||||
|
if (!target) return res.status(404).send('Benutzer nicht gefunden.');
|
||||||
|
if (target.is_admin) {
|
||||||
|
const adminCount = await dbGet('SELECT COUNT(*) AS c FROM users WHERE is_admin = 1');
|
||||||
|
if (adminCount && adminCount.c <= 1) return res.status(400).send('Der letzte Admin darf nicht abgesetzt werden.');
|
||||||
|
}
|
||||||
|
const neu = target.is_admin ? 0 : 1;
|
||||||
|
await dbRun('UPDATE users SET is_admin = ? WHERE id = ?', [neu, id]);
|
||||||
|
await auditLog(req.user.id, id, `admin_toggle → ${neu ? 'admin' : 'user'}`);
|
||||||
|
res.redirect('/admin/users/' + id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin toggle-admin error:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Darf der Nutzer Suchläufe selber starten („Jetzt suchen")? Standardmäßig
|
||||||
|
// aus — der Admin gibt es pro Nutzer frei. Der automatische Zeitplan läuft
|
||||||
|
// davon unberührt weiter, sobald er aktiviert ist.
|
||||||
|
app.post('/admin/users/:id/toggle-suchlauf', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const target = await dbGet('SELECT id, darf_suchlauf_starten FROM users WHERE id = ?', [id]);
|
||||||
|
if (!target) return res.status(404).send('Benutzer nicht gefunden.');
|
||||||
|
const neu = target.darf_suchlauf_starten ? 0 : 1;
|
||||||
|
await dbRun('UPDATE users SET darf_suchlauf_starten = ? WHERE id = ?', [neu, id]);
|
||||||
|
await auditLog(req.user.id, id, `suchlauf_starten → ${neu ? 'an' : 'aus'}`);
|
||||||
|
res.redirect('/admin/users/' + id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin toggle-suchlauf error:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Darf der Nutzer den Zeitplan (Wochentage/Uhrzeit/aktiv) ändern?
|
||||||
|
// Standardmäßig aus — nur der Admin stellt den Zeitplan ein.
|
||||||
|
app.post('/admin/users/:id/toggle-zeitplan', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const target = await dbGet('SELECT id, darf_zeitplan_aendern FROM users WHERE id = ?', [id]);
|
||||||
|
if (!target) return res.status(404).send('Benutzer nicht gefunden.');
|
||||||
|
const neu = target.darf_zeitplan_aendern ? 0 : 1;
|
||||||
|
await dbRun('UPDATE users SET darf_zeitplan_aendern = ? WHERE id = ?', [neu, id]);
|
||||||
|
await auditLog(req.user.id, id, `zeitplan_aendern → ${neu ? 'an' : 'aus'}`);
|
||||||
|
res.redirect('/admin/users/' + id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin toggle-zeitplan error:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Impersonation: admin "logs in as" a user --------------------------
|
// --- Impersonation: admin "logs in as" a user --------------------------
|
||||||
// Best practice (Django/Flask-impersonate style): the admin's own session
|
// Best practice (Django/Flask-impersonate style): the admin's own session
|
||||||
// keeps its cookie token, but its user_id switches to the target and the
|
// keeps its cookie token, but its user_id switches to the target and the
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<%- include('partials/head') %>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
|
<%- include('partials/header') %>
|
||||||
|
|
||||||
|
<main class="flex-1 container mx-auto px-4 py-8 max-w-5xl">
|
||||||
|
<a href="/admin" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||||
|
</svg>
|
||||||
|
Zur Benutzerverwaltung
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<%
|
||||||
|
const istIch = user.id === currentUserId;
|
||||||
|
const letzterAdmin = user.is_admin && adminCount <= 1;
|
||||||
|
%>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-4 mb-8">
|
||||||
|
<span class="grid h-14 w-14 place-items-center rounded-full text-lg font-semibold <%= user.is_admin ? 'bg-blue-600/10 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' %>">
|
||||||
|
<%= String(user.username || '?').charAt(0).toUpperCase() %>
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2.5">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800 dark:text-white"><%= user.username %></h1>
|
||||||
|
<% if (user.is_admin) { %>
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">Admin</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">Benutzer</span>
|
||||||
|
<% } %>
|
||||||
|
<% if (istIch) { %><span class="text-xs text-gray-400">(du)</span><% } %>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
<%= anzahlBewerbungen %> <%= anzahlBewerbungen === 1 ? 'Bewerbung' : 'Bewerbungen' %> ·
|
||||||
|
angelegt <time data-dt="<%= user.created_at %>"><%= user.created_at %></time>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||||
|
<!-- Verwaltung -->
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Benutzer verwalten</h2>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<!-- Admin-Status -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Admin-Status</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<% if (letzterAdmin) { %>Letzter Admin – kann nicht abgesetzt werden.<% } else { %>Admins können Benutzer verwalten und andere Konten übernehmen.<% } %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/users/<%= user.id %>/toggle-admin" class="inline">
|
||||||
|
<button type="submit" <%= letzterAdmin ? 'disabled' : '' %>
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
<%= user.is_admin ? 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600' : 'bg-blue-600 hover:bg-blue-700 text-white' %>">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0a1.724 1.724 0 002.573 1.066c.78-.448 1.707.479 1.26 1.26a1.724 1.724 0 001.065 2.572c.921.3.921 1.603 0 1.902a1.724 1.724 0 00-1.066 2.573c.448.78-.479 1.707-1.26 1.26a1.724 1.724 0 00-2.572 1.065c-.3.921-1.603.921-1.902 0a1.724 1.724 0 00-2.573-1.066c-.78.448-1.707-.479-1.26-1.26a1.724 1.724 0 00-1.065-2.572c-.921-.3-.921-1.603 0-1.902a1.724 1.724 0 001.066-2.573c-.448-.78.479-1.707 1.26-1.26.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||||
|
<%= user.is_admin ? 'Zum Benutzer herabstufen' : 'Zum Admin machen' %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Suchläufe starten -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Suchläufe selber starten</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Manueller „Jetzt suchen"-Knopf auf der Jobsuche. Der automatische Zeitplan läuft davon unberührt.</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/users/<%= user.id %>/toggle-suchlauf" class="inline">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors
|
||||||
|
<%= user.darf_suchlauf_starten ? 'bg-green-600 hover:bg-green-700 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600' %>">
|
||||||
|
<span class="inline-block w-2 h-2 rounded-full <%= user.darf_suchlauf_starten ? 'bg-white' : 'bg-gray-400' %>"></span>
|
||||||
|
<%= user.darf_suchlauf_starten ? 'Erlaubt' : 'Gesperrt' %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Zeitplan ändern -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Zeitplan ändern dürfen</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Wochentage, Uhrzeit und „aktiv". Standard: Mo–Fr 17:00, verwaltet vom Administrator.</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/users/<%= user.id %>/toggle-zeitplan" class="inline">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors
|
||||||
|
<%= user.darf_zeitplan_aendern ? 'bg-green-600 hover:bg-green-700 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600' %>">
|
||||||
|
<span class="inline-block w-2 h-2 rounded-full <%= user.darf_zeitplan_aendern ? 'bg-white' : 'bg-gray-400' %>"></span>
|
||||||
|
<%= user.darf_zeitplan_aendern ? 'Erlaubt' : 'Gesperrt' %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Passwort zurücksetzen -->
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Passwort zurücksetzen</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Alle Sitzungen dieses Benutzers werden ungültig.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="document.getElementById('resetForm').classList.toggle('hidden')"
|
||||||
|
class="act-btn act-blue">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
|
||||||
|
<span>Neues Passwort</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="resetForm" method="POST" action="/admin/users/<%= user.id %>/reset-password" class="hidden flex items-end gap-3 flex-wrap rounded-lg border border-blue-200 bg-blue-50/50 p-4 dark:border-blue-500/20 dark:bg-blue-500/5">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Neues Passwort für <%= user.username %>
|
||||||
|
<input name="password" type="password" required
|
||||||
|
class="mt-1.5 block w-full sm:w-72 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium px-4 py-2 shadow-sm">Speichern</button>
|
||||||
|
<button type="button" onclick="document.getElementById('resetForm').classList.toggle('hidden')"
|
||||||
|
class="rounded-lg px-3 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Anmelden als / Suchprofil -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% if (!istIch) { %>
|
||||||
|
<form method="POST" action="/admin/users/<%= user.id %>/impersonate" class="inline"
|
||||||
|
onsubmit="return confirm('Als „<%= user.username %>“ anmelden? Du siehst dann dessen Konto. Admin-Rechte sind dabei pausiert; über das Banner oben kannst du zurückwechseln.');">
|
||||||
|
<button type="submit" class="act-btn act-emerald" title="Dieses Konto übernehmen (Impersonation)">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14v7m-4-4h8"></path></svg>
|
||||||
|
<span>Anmelden als</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<a href="/jobsuche?user=<%= user.id %>" class="act-btn act-neutral" title="Suchprofil & Zeitplan dieses Benutzers verwalten">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
||||||
|
<span>Jobsuche</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (!istIch) { %>
|
||||||
|
<hr class="border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Löschen -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-rose-700 dark:text-rose-400">Benutzer löschen</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Löscht das Konto inkl. aller Bewerbungen und Dateien. Unumkehrbar.</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/users/<%= user.id %>/delete" class="inline"
|
||||||
|
onsubmit="return confirm('Benutzer „<%= user.username %>“ inkl. aller Daten wirklich löschen? Das lässt sich nicht rückgängig machen.');">
|
||||||
|
<button type="submit" class="act-btn act-danger" title="Benutzer inkl. aller Daten löschen">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
|
<span>Löschen</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Letzte Bewerbungen -->
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Letzte Bewerbungen</h2>
|
||||||
|
<% if (bewerbungen.length) { %>
|
||||||
|
<ul class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
<% bewerbungen.forEach(function(b){ %>
|
||||||
|
<li class="py-3 flex items-center gap-3">
|
||||||
|
<span class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0
|
||||||
|
<%= b.status === 'Einstellung' ? 'bg-green-500' :
|
||||||
|
b.status === 'Absage' ? 'bg-red-500' :
|
||||||
|
b.status === 'Vorstellungsgespräch' ? 'bg-yellow-500' :
|
||||||
|
'bg-gray-300' %>"></span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate"><%= b.firma %></p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"><%= b.stelle %></p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap shrink-0"><%= new Date(b.datum).toLocaleDateString('de-DE') %></span>
|
||||||
|
</li>
|
||||||
|
<% }); %>
|
||||||
|
</ul>
|
||||||
|
<% if (anzahlBewerbungen > bewerbungen.length) { %>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3">… und <%= anzahlBewerbungen - bewerbungen.length %> weitere.</p>
|
||||||
|
<% } %>
|
||||||
|
<% } else { %>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Noch keine Bewerbungen.</p>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<%- include('partials/footer') %>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.act-btn{display:inline-flex;align-items:center;gap:.375rem;border-radius:.5rem;padding:.375rem .625rem;font-size:.75rem;font-weight:500;transition:background-color .15s,color .15s;line-height:1;}
|
||||||
|
.act-neutral{color:#4b5563}.act-neutral:hover{background:#f3f4f6}
|
||||||
|
.dark .act-neutral{color:#d1d5db}.dark .act-neutral:hover{background:rgba(255,255,255,.06)}
|
||||||
|
.act-emerald{color:#047857}.act-emerald:hover{background:#ecfdf5}
|
||||||
|
.dark .act-emerald{color:#6ee7b7}.dark .act-emerald:hover{background:rgba(16,185,129,.12)}
|
||||||
|
.act-blue{color:#2563eb}.act-blue:hover{background:#eff6ff}
|
||||||
|
.dark .act-blue{color:#93c5fd}.dark .act-blue:hover{background:rgba(59,130,246,.12)}
|
||||||
|
.act-danger{color:#e11d48}.act-danger:hover{background:#fff1f2}
|
||||||
|
.dark .act-danger{color:#fda4af}.dark .act-danger:hover{background:rgba(244,63,94,.12)}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Relative Zeitangaben (data-dt) wie in der Admin-Übersicht formatieren.
|
||||||
|
(function () {
|
||||||
|
document.querySelectorAll('time[data-dt]').forEach(function (t) {
|
||||||
|
var s = t.getAttribute('data-dt');
|
||||||
|
if (!s) return;
|
||||||
|
var d = new Date(s.replace(' ', 'T') + 'Z');
|
||||||
|
if (isNaN(d.getTime())) return;
|
||||||
|
t.textContent = d.toLocaleDateString('de-DE') + ', ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('partials/favorit-script') %>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+4
-17
@@ -104,13 +104,14 @@
|
|||||||
<% users.forEach(function(u) { %>
|
<% users.forEach(function(u) { %>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="py-3 px-6 font-medium text-gray-800 dark:text-gray-100">
|
<td class="py-3 px-6 font-medium text-gray-800 dark:text-gray-100">
|
||||||
<div class="flex items-center gap-2.5">
|
<a href="/admin/users/<%= u.id %>" class="flex items-center gap-2.5 group -mx-1.5 -my-1 px-1.5 py-1 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700/60 transition-colors">
|
||||||
<span class="grid h-8 w-8 place-items-center rounded-full text-xs font-semibold <%= u.is_admin ? 'bg-blue-600/10 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' %>">
|
<span class="grid h-8 w-8 place-items-center rounded-full text-xs font-semibold <%= u.is_admin ? 'bg-blue-600/10 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' %>">
|
||||||
<%= String(u.username || '?').charAt(0).toUpperCase() %>
|
<%= String(u.username || '?').charAt(0).toUpperCase() %>
|
||||||
</span>
|
</span>
|
||||||
<span><%= u.username %></span>
|
<span class="group-hover:text-blue-600 dark:group-hover:text-blue-400"><%= u.username %></span>
|
||||||
<% if (u.id === currentUserId) { %><span class="text-xs text-gray-400">(du)</span><% } %>
|
<% if (u.id === currentUserId) { %><span class="text-xs text-gray-400">(du)</span><% } %>
|
||||||
</div>
|
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 group-hover:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 pr-4">
|
<td class="py-3 pr-4">
|
||||||
<% if (u.is_admin) { %>
|
<% if (u.is_admin) { %>
|
||||||
@@ -128,11 +129,6 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="py-3 pr-6">
|
<td class="py-3 pr-6">
|
||||||
<div class="flex flex-wrap items-center justify-end gap-1.5">
|
<div class="flex flex-wrap items-center justify-end gap-1.5">
|
||||||
<a href="/jobsuche?user=<%= u.id %>"
|
|
||||||
class="act-btn act-neutral" title="Suchprofil & Zeitplan dieses Benutzers verwalten">
|
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
|
||||||
<span>Jobsuche</span>
|
|
||||||
</a>
|
|
||||||
<% if (u.id !== currentUserId) { %>
|
<% if (u.id !== currentUserId) { %>
|
||||||
<form method="POST" action="/admin/users/<%= u.id %>/impersonate" class="inline"
|
<form method="POST" action="/admin/users/<%= u.id %>/impersonate" class="inline"
|
||||||
onsubmit="return confirm('Als „<%= u.username %>“ anmelden? Du siehst dann dessen Konto. Admin-Rechte sind dabei pausiert; über das Banner oben kannst du zurückwechseln.');">
|
onsubmit="return confirm('Als „<%= u.username %>“ anmelden? Du siehst dann dessen Konto. Admin-Rechte sind dabei pausiert; über das Banner oben kannst du zurückwechseln.');">
|
||||||
@@ -148,15 +144,6 @@
|
|||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
|
||||||
<span>Passwort</span>
|
<span>Passwort</span>
|
||||||
</button>
|
</button>
|
||||||
<% if (u.id !== currentUserId) { %>
|
|
||||||
<form method="POST" action="/admin/users/<%= u.id %>/delete" class="inline"
|
|
||||||
onsubmit="return confirm('Benutzer „<%= u.username %>“ inkl. aller Daten wirklich löschen? Das lässt sich nicht rückgängig machen.');">
|
|
||||||
<button type="submit" class="act-btn act-danger" title="Benutzer inkl. aller Daten löschen">
|
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
|
||||||
<span>Löschen</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<% } %>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
'Eingangsbestätigung': 'bg-blue-500',
|
'Eingangsbestätigung': 'bg-blue-500',
|
||||||
'In Bearbeitung': 'bg-teal-500',
|
'In Bearbeitung': 'bg-teal-500',
|
||||||
'Interessiert': 'bg-pink-500',
|
'Interessiert': 'bg-pink-500',
|
||||||
|
'Telefonat': 'bg-sky-500',
|
||||||
'Warten auf Rückmeldung': 'bg-orange-500',
|
'Warten auf Rückmeldung': 'bg-orange-500',
|
||||||
'Warten auf meine Antwort': 'bg-fuchsia-500',
|
'Warten auf meine Antwort': 'bg-fuchsia-500',
|
||||||
'Vorstellungsgespräch': 'bg-yellow-500',
|
'Vorstellungsgespräch': 'bg-yellow-500',
|
||||||
|
|||||||
+555
-422
File diff suppressed because it is too large
Load Diff
+21
-78
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
<main class="flex-1 container mx-auto px-4 py-8">
|
<main class="flex-1 container mx-auto px-4 py-8">
|
||||||
<% var zeigeDashboard = !(typeof setupWizard !== 'undefined' && setupWizard && setupWizard.aktiv); %>
|
<% var zeigeDashboard = !(typeof setupWizard !== 'undefined' && setupWizard && setupWizard.aktiv); %>
|
||||||
<%- include('partials/bewerbung-favoriten') %>
|
|
||||||
<% if (!zeigeDashboard) { %>
|
<% if (!zeigeDashboard) { %>
|
||||||
<%- include('partials/einrichtung') %>
|
<%- include('partials/einrichtung') %>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -110,12 +109,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Gemerkte Bewerbungen — direkt unter den anstehenden Terminen -->
|
||||||
|
<%- include('partials/bewerbung-favoriten') %>
|
||||||
|
|
||||||
<!-- Applications -->
|
<!-- Applications -->
|
||||||
<style>
|
<style>
|
||||||
/* Shared column grid – used by the header labels and every application line */
|
/* Shared column grid – used by the header labels and every application line */
|
||||||
.app-grid {
|
.app-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.25rem 5.5rem minmax(7rem, 1.6fr) minmax(8rem, 2fr) 5.5rem 8.5rem 4.5rem;
|
grid-template-columns: 1.25rem 5.5rem minmax(7rem, 1.6fr) minmax(8rem, 2fr) 5.5rem 8.5rem 5.5rem;
|
||||||
gap: 0 0.75rem;
|
gap: 0 0.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -143,7 +145,7 @@
|
|||||||
// Status-Reihenfolge synchron zu public/js/main.js halten. Steht hier oben,
|
// Status-Reihenfolge synchron zu public/js/main.js halten. Steht hier oben,
|
||||||
// weil sowohl die Liste als auch die Aktivitäten weiter unten sie brauchen.
|
// weil sowohl die Liste als auch die Aktivitäten weiter unten sie brauchen.
|
||||||
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
||||||
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||||||
'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung'];
|
'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung'];
|
||||||
const statusDot = {
|
const statusDot = {
|
||||||
'Entwurf': 'bg-purple-500',
|
'Entwurf': 'bg-purple-500',
|
||||||
@@ -151,6 +153,7 @@
|
|||||||
'Eingangsbestätigung': 'bg-blue-500',
|
'Eingangsbestätigung': 'bg-blue-500',
|
||||||
'In Bearbeitung': 'bg-teal-500',
|
'In Bearbeitung': 'bg-teal-500',
|
||||||
'Interessiert': 'bg-pink-500',
|
'Interessiert': 'bg-pink-500',
|
||||||
|
'Telefonat': 'bg-sky-500',
|
||||||
'Warten auf Rückmeldung': 'bg-orange-500',
|
'Warten auf Rückmeldung': 'bg-orange-500',
|
||||||
'Warten auf meine Antwort': 'bg-fuchsia-500',
|
'Warten auf meine Antwort': 'bg-fuchsia-500',
|
||||||
'Vorstellungsgespräch': 'bg-yellow-500',
|
'Vorstellungsgespräch': 'bg-yellow-500',
|
||||||
@@ -210,22 +213,10 @@
|
|||||||
<div class="group-body collapsible">
|
<div class="group-body collapsible">
|
||||||
<div class="collapsible-inner">
|
<div class="collapsible-inner">
|
||||||
<% groups[statusKey].forEach(app => { %>
|
<% groups[statusKey].forEach(app => { %>
|
||||||
<%
|
<div class="app-row border-t border-gray-100 dark:border-gray-700/60">
|
||||||
const hatNotizen = app.notizen && app.notizen.trim().length > 0;
|
<!-- Eine Zeile pro Bewerbung – Klick öffnet die ganze Bewerbung -->
|
||||||
const hatInterne = app.interne_notizen && app.interne_notizen.trim().length > 0;
|
<div class="app-line app-grid px-6 py-3 text-sm transition-colors cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50" data-bewerbung="<%= app.id %>">
|
||||||
const hatVerlauf = app.verlauf && app.verlauf.length > 0;
|
<span class="flex items-center justify-center"></span>
|
||||||
const hatDetails = hatNotizen || hatInterne || hatVerlauf;
|
|
||||||
%>
|
|
||||||
<div class="app-row border-t border-gray-100 dark:border-gray-700/60 <%= hatDetails ? 'has-details' : '' %>">
|
|
||||||
<!-- Eine Zeile pro Bewerbung -->
|
|
||||||
<div class="app-line app-grid px-6 py-3 text-sm transition-colors <%= hatDetails ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50' : '' %>">
|
|
||||||
<span class="flex items-center justify-center">
|
|
||||||
<% if (hatDetails) { %>
|
|
||||||
<svg class="chevron w-4 h-4 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
<% } %>
|
|
||||||
</span>
|
|
||||||
<span class="whitespace-nowrap text-gray-900 dark:text-white"><%= new Date(app.datum).toLocaleDateString('de-DE') %></span>
|
<span class="whitespace-nowrap text-gray-900 dark:text-white"><%= new Date(app.datum).toLocaleDateString('de-DE') %></span>
|
||||||
<span class="min-w-0 truncate font-medium text-gray-900 dark:text-white" title="<%= app.firma %>"><%= app.firma %></span>
|
<span class="min-w-0 truncate font-medium text-gray-900 dark:text-white" title="<%= app.firma %>"><%= app.firma %></span>
|
||||||
<span class="min-w-0">
|
<span class="min-w-0">
|
||||||
@@ -244,6 +235,7 @@
|
|||||||
app.status === 'Vertragsverhandlung' ? 'bg-lime-100 text-lime-800 dark:bg-lime-800 dark:text-lime-100' :
|
app.status === 'Vertragsverhandlung' ? 'bg-lime-100 text-lime-800 dark:bg-lime-800 dark:text-lime-100' :
|
||||||
app.status === 'In Bearbeitung' ? 'bg-teal-100 text-teal-800 dark:bg-teal-800 dark:text-teal-100' :
|
app.status === 'In Bearbeitung' ? 'bg-teal-100 text-teal-800 dark:bg-teal-800 dark:text-teal-100' :
|
||||||
app.status === 'Interessiert' ? 'bg-pink-100 text-pink-800 dark:bg-pink-800 dark:text-pink-100' :
|
app.status === 'Interessiert' ? 'bg-pink-100 text-pink-800 dark:bg-pink-800 dark:text-pink-100' :
|
||||||
|
app.status === 'Telefonat' ? 'bg-sky-100 text-sky-800 dark:bg-sky-800 dark:text-sky-100' :
|
||||||
app.status === 'Warten auf Rückmeldung' ? 'bg-orange-100 text-orange-800 dark:bg-orange-800 dark:text-orange-100' :
|
app.status === 'Warten auf Rückmeldung' ? 'bg-orange-100 text-orange-800 dark:bg-orange-800 dark:text-orange-100' :
|
||||||
app.status === 'Warten auf meine Antwort' ? 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-800 dark:text-fuchsia-100' :
|
app.status === 'Warten auf meine Antwort' ? 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-800 dark:text-fuchsia-100' :
|
||||||
app.status === 'Entwurf' ? 'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100' :
|
app.status === 'Entwurf' ? 'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100' :
|
||||||
@@ -269,58 +261,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<% if (hatDetails) { %>
|
|
||||||
<!-- Klappbarer Detailbereich unter der Zeile -->
|
|
||||||
<div class="app-details collapsible">
|
|
||||||
<div class="collapsible-inner">
|
|
||||||
<div class="px-6 pb-5 pt-1 space-y-3">
|
|
||||||
<% if (hatVerlauf) { %>
|
|
||||||
<div class="rounded-lg border-l-4 border-indigo-400 dark:border-indigo-500 bg-indigo-50/60 dark:bg-gray-700/40 px-4 py-3">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<svg class="w-4 h-4 text-indigo-500 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">Status-Verlauf</span>
|
|
||||||
</div>
|
|
||||||
<ol class="space-y-1.5">
|
|
||||||
<% app.verlauf.forEach(v => { %>
|
|
||||||
<li class="flex flex-wrap gap-x-2 text-sm">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400 whitespace-nowrap"><%= new Date(v.datum).toLocaleDateString('de-DE') %></span>
|
|
||||||
<span class="font-semibold text-gray-800 dark:text-gray-100"><%= v.status %></span>
|
|
||||||
<% if (v.kommentar && v.kommentar.trim()) { %>
|
|
||||||
<span class="text-gray-600 dark:text-gray-300 whitespace-pre-wrap break-words">– <%= v.kommentar %></span>
|
|
||||||
<% } %>
|
|
||||||
</li>
|
|
||||||
<% }); %>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
<% if (hatNotizen) { %>
|
|
||||||
<div class="rounded-lg border-l-4 border-blue-400 dark:border-blue-500 bg-blue-50/70 dark:bg-gray-700/40 px-4 py-3">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<svg class="w-4 h-4 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-wider text-blue-600 dark:text-blue-400">Notizen</span>
|
|
||||||
</div>
|
|
||||||
<p class="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-800 dark:text-gray-200"><%= app.notizen %></p>
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
<% if (hatInterne) { %>
|
|
||||||
<div class="rounded-lg border-l-4 border-amber-400 dark:border-amber-500 bg-amber-50/70 dark:bg-gray-700/40 px-4 py-3">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<svg class="w-4 h-4 text-amber-500 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">Interne Notizen · nicht im PDF</span>
|
|
||||||
</div>
|
|
||||||
<p class="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-800 dark:text-gray-200"><%= app.interne_notizen %></p>
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
</div>
|
</div>
|
||||||
<% }); %>
|
<% }); %>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,14 +372,13 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detailbereich einer einzelnen Bewerbung auf-/zuklappen.
|
// Klick auf eine Bewerbungs-Zeile öffnet die ganze Bewerbung
|
||||||
qsa('.app-row.has-details').forEach(function (row) {
|
// (/bewerbung/:id). Klicks auf die Aktions-Buttons (Merken,
|
||||||
var line = row.querySelector('.app-line');
|
// Bearbeiten, Löschen) werden nicht abgefangen.
|
||||||
|
qsa('.app-line[data-bewerbung]').forEach(function (line) {
|
||||||
line.addEventListener('click', function (e) {
|
line.addEventListener('click', function (e) {
|
||||||
if (e.target.closest('.app-actions')) return; // Bearbeiten/Löschen nicht abfangen
|
if (e.target.closest('.app-actions')) return;
|
||||||
var details = row.querySelector('.app-details');
|
window.location.href = '/bewerbung/' + line.getAttribute('data-bewerbung');
|
||||||
var chev = line.querySelector('.chevron');
|
|
||||||
setOpen(details, chev, !details.classList.contains('open'));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -459,6 +398,7 @@
|
|||||||
var fehler = document.getElementById('searchError');
|
var fehler = document.getElementById('searchError');
|
||||||
var listCard = document.getElementById('listCard');
|
var listCard = document.getElementById('listCard');
|
||||||
var termineCard = document.getElementById('termineCard');
|
var termineCard = document.getElementById('termineCard');
|
||||||
|
var bewerbungFavoritenCard = document.getElementById('bewerbungFavoritenCard');
|
||||||
var aktivitaetCard = document.getElementById('aktivitaetCard');
|
var aktivitaetCard = document.getElementById('aktivitaetCard');
|
||||||
|
|
||||||
var MIN = 2; // kürzere Eingaben liefern nur Rauschen
|
var MIN = 2; // kürzere Eingaben liefern nur Rauschen
|
||||||
@@ -575,6 +515,7 @@
|
|||||||
panel.classList.add('hidden');
|
panel.classList.add('hidden');
|
||||||
if (listCard) listCard.classList.remove('hidden');
|
if (listCard) listCard.classList.remove('hidden');
|
||||||
if (termineCard) termineCard.classList.remove('hidden');
|
if (termineCard) termineCard.classList.remove('hidden');
|
||||||
|
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.remove('hidden');
|
||||||
if (aktivitaetCard) aktivitaetCard.classList.remove('hidden');
|
if (aktivitaetCard) aktivitaetCard.classList.remove('hidden');
|
||||||
input.setAttribute('aria-expanded', 'false');
|
input.setAttribute('aria-expanded', 'false');
|
||||||
aktiv = -1;
|
aktiv = -1;
|
||||||
@@ -595,6 +536,7 @@
|
|||||||
panel.classList.remove('hidden');
|
panel.classList.remove('hidden');
|
||||||
if (listCard) listCard.classList.add('hidden');
|
if (listCard) listCard.classList.add('hidden');
|
||||||
if (termineCard) termineCard.classList.add('hidden');
|
if (termineCard) termineCard.classList.add('hidden');
|
||||||
|
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.add('hidden');
|
||||||
if (aktivitaetCard) aktivitaetCard.classList.add('hidden');
|
if (aktivitaetCard) aktivitaetCard.classList.add('hidden');
|
||||||
input.setAttribute('aria-expanded', 'true');
|
input.setAttribute('aria-expanded', 'true');
|
||||||
aktiv = -1;
|
aktiv = -1;
|
||||||
@@ -609,6 +551,7 @@
|
|||||||
panel.classList.remove('hidden');
|
panel.classList.remove('hidden');
|
||||||
if (listCard) listCard.classList.add('hidden');
|
if (listCard) listCard.classList.add('hidden');
|
||||||
if (termineCard) termineCard.classList.add('hidden');
|
if (termineCard) termineCard.classList.add('hidden');
|
||||||
|
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.add('hidden');
|
||||||
if (aktivitaetCard) aktivitaetCard.classList.add('hidden');
|
if (aktivitaetCard) aktivitaetCard.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-13
@@ -15,26 +15,35 @@
|
|||||||
Zurück zur Übersicht
|
Zurück zur Übersicht
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Tabs: open offers vs. taken-over offers -->
|
<!-- Tabs: open offers · favorites · taken-over offers -->
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-6">
|
<% var favAnsicht = (typeof ansicht !== 'undefined' && ansicht === 'favoriten'); %>
|
||||||
<a href="/jobangebote" class="px-3 py-1.5 rounded-md text-sm font-medium bg-blue-600 text-white shadow-sm">
|
<%- include('partials/jobangebote-tabs', {
|
||||||
Offen (<%= jobangebote.length %>)
|
aktiv: favAnsicht ? 'favoriten' : 'offen',
|
||||||
</a>
|
offenCount: offenCount,
|
||||||
<a href="/jobangebote/uebernommen" class="px-3 py-1.5 rounded-md text-sm font-medium bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
favoritenCount: favoritenCount,
|
||||||
Übernommen (<%= uebernommenCount %>)
|
uebernommenCount: uebernommenCount,
|
||||||
</a>
|
}) %>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
<%- include('partials/favorit-script') %>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8" data-fav-card data-fav-view="<%= favAnsicht ? 'favoriten' : 'offen' %>">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Jobangebote</h2>
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white"><%= favAnsicht ? 'Gemerkte Jobangebote' : 'Jobangebote' %></h2>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
<% if (favAnsicht) { %>
|
||||||
|
Angebote, die du dir mit dem Stern gemerkt hast — unabhängig davon, ob sie
|
||||||
|
noch offen oder bereits übernommen sind. Ein erneuter Klick auf den Stern
|
||||||
|
entfernt das Angebot wieder aus dieser Liste. Die Merkliste gilt nur für
|
||||||
|
Jobangebote und ist von den gemerkten Bewerbungen getrennt.
|
||||||
|
<% } else { %>
|
||||||
Hier landen Stellen, die von Drittanbietersoftware über die
|
Hier landen Stellen, die von Drittanbietersoftware über die
|
||||||
<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700">POST /api/v1/joboffers</code>
|
<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700">POST /api/v1/joboffers</code>
|
||||||
REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick
|
REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick
|
||||||
als Bewerbung-Entwurf übernehmen. Hier siehst du nur <strong>offene</strong>
|
als Bewerbung-Entwurf übernehmen. Hier siehst du nur <strong>offene</strong>
|
||||||
Angebote — bereits übernommene findest du im Tab „Übernommen“.
|
Angebote, die du dir <em>nicht</em> gemerkt hast — bereits übernommene
|
||||||
|
findest du im Tab „Übernommen“, gemerkte im Tab „Gemerkt“.
|
||||||
|
<% } %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0 flex items-center gap-2">
|
<div class="shrink-0 flex items-center gap-2">
|
||||||
@@ -52,7 +61,9 @@
|
|||||||
<% if (jobangebote && jobangebote.length) { %>
|
<% if (jobangebote && jobangebote.length) { %>
|
||||||
<ul class="mt-4 space-y-4">
|
<ul class="mt-4 space-y-4">
|
||||||
<% jobangebote.forEach(function(j){ %>
|
<% jobangebote.forEach(function(j){ %>
|
||||||
<li class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-4">
|
<li data-fav-item class="rounded-lg border px-4 py-4 <%= j.favorit
|
||||||
|
? 'border-amber-300 dark:border-amber-400/40 bg-amber-50/60 dark:bg-amber-500/5'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40' %>">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
<div class="flex flex-wrap items-center gap-2 mb-1">
|
||||||
@@ -139,6 +150,7 @@
|
|||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="shrink-0 flex flex-col gap-2 self-start">
|
<div class="shrink-0 flex flex-col gap-2 self-start">
|
||||||
|
<%- include('partials/favorit-button', { j: j }) %>
|
||||||
<% if (j.verknuepfte_bewerbung_id) { %>
|
<% if (j.verknuepfte_bewerbung_id) { %>
|
||||||
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
|
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
|
||||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||||
@@ -157,6 +169,7 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
<form action="/jobangebote/<%= j.id %>/delete" method="POST"
|
<form action="/jobangebote/<%= j.id %>/delete" method="POST"
|
||||||
onsubmit="return confirm('Jobangebot „<%= j.firma %> · <%= j.stelle %>“ löschen? Es kommt auf die Blacklist und wird nicht erneut eingespielt.');">
|
onsubmit="return confirm('Jobangebot „<%= j.firma %> · <%= j.stelle %>“ löschen? Es kommt auf die Blacklist und wird nicht erneut eingespielt.');">
|
||||||
|
<% if (favAnsicht) { %><input type="hidden" name="zurueck" value="favoriten"><% } %>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors w-full">
|
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors w-full">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
@@ -173,6 +186,7 @@
|
|||||||
Beschreibung / Details bearbeiten
|
Beschreibung / Details bearbeiten
|
||||||
</summary>
|
</summary>
|
||||||
<form action="/jobangebote/<%= j.id %>/bearbeiten" method="POST" class="mt-3 space-y-3">
|
<form action="/jobangebote/<%= j.id %>/bearbeiten" method="POST" class="mt-3 space-y-3">
|
||||||
|
<% if (favAnsicht) { %><input type="hidden" name="zurueck" value="favoriten"><% } %>
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
<label class="block text-xs">
|
<label class="block text-xs">
|
||||||
<span class="text-gray-500 dark:text-gray-400">Firma</span>
|
<span class="text-gray-500 dark:text-gray-400">Firma</span>
|
||||||
@@ -228,6 +242,17 @@
|
|||||||
</li>
|
</li>
|
||||||
<% }); %>
|
<% }); %>
|
||||||
</ul>
|
</ul>
|
||||||
|
<% } else if (favAnsicht) { %>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="w-14 h-14 mx-auto text-gray-300 dark:text-gray-600 mb-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Noch keine Jobangebote gemerkt.</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Klicke bei einem Angebot auf <strong>Merken</strong> — es erscheint dann hier
|
||||||
|
und wird aus der offenen Liste ausgeblendet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<svg class="w-14 h-14 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-14 h-14 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -15,15 +15,15 @@
|
|||||||
Zurück zu den Jobangeboten
|
Zurück zu den Jobangeboten
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Tabs: open offers vs. taken-over offers -->
|
<!-- Tabs: open offers · favorites · taken-over offers -->
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-6">
|
<%- include('partials/jobangebote-tabs', {
|
||||||
<a href="/jobangebote" class="px-3 py-1.5 rounded-md text-sm font-medium bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
aktiv: 'uebernommen',
|
||||||
Offen (<%= offenCount %>)
|
offenCount: offenCount,
|
||||||
</a>
|
favoritenCount: favoritenCount,
|
||||||
<a href="/jobangebote/uebernommen" class="px-3 py-1.5 rounded-md text-sm font-medium bg-blue-600 text-white shadow-sm">
|
uebernommenCount: uebernommenCount,
|
||||||
Übernommen (<%= uebernommen.length %>)
|
}) %>
|
||||||
</a>
|
|
||||||
</div>
|
<%- include('partials/favorit-script') %>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
|
||||||
@@ -43,7 +43,9 @@
|
|||||||
<% if (uebernommen && uebernommen.length) { %>
|
<% if (uebernommen && uebernommen.length) { %>
|
||||||
<ul class="mt-4 space-y-4">
|
<ul class="mt-4 space-y-4">
|
||||||
<% uebernommen.forEach(function(j){ %>
|
<% uebernommen.forEach(function(j){ %>
|
||||||
<li class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-4">
|
<li class="rounded-lg border px-4 py-4 <%= j.favorit
|
||||||
|
? 'border-amber-300 dark:border-amber-400/40 bg-amber-50/60 dark:bg-amber-500/5'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40' %>">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
<div class="flex flex-wrap items-center gap-2 mb-1">
|
||||||
@@ -100,6 +102,7 @@
|
|||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="shrink-0 flex flex-col gap-2 self-start">
|
<div class="shrink-0 flex flex-col gap-2 self-start">
|
||||||
|
<%- include('partials/favorit-button', { j: j }) %>
|
||||||
<% if (j.verknuepfte_bewerbung_id) { %>
|
<% if (j.verknuepfte_bewerbung_id) { %>
|
||||||
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
|
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
|
||||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||||
|
|||||||
+51
-21
@@ -54,22 +54,6 @@
|
|||||||
<form action="/jobsuche" method="POST" class="space-y-6">
|
<form action="/jobsuche" method="POST" class="space-y-6">
|
||||||
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
|
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
|
||||||
|
|
||||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Automatische Suche</h3>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
Ist sie aktiv, sucht die KI nach Zeitplan und legt neue Treffer als Jobangebote an.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<label class="inline-flex items-center gap-2 shrink-0">
|
|
||||||
<input type="checkbox" name="aktiv" value="1" <%= profil.aktiv ? 'checked' : '' %>
|
|
||||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">aktiv</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Wo gesucht wird</h3>
|
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Wo gesucht wird</h3>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">Modus und Orte.</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">Modus und Orte.</p>
|
||||||
@@ -157,15 +141,35 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Zeitplan</h3>
|
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Zeitplan</h3>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">Wann die automatische Suche laufen soll.</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Ist er aktiv, sucht die KI automatisch an den gewählten Tagen zur eingestellten Uhrzeit
|
||||||
|
und legt neue Treffer als Jobangebote an. Deaktivierst du ihn, läuft keine automatische
|
||||||
|
Suche mehr — die eingestellten Wochentage und die Uhrzeit bleiben aber erhalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2 shrink-0">
|
||||||
|
<input type="checkbox" name="aktiv" value="1" id="zeitplan_aktiv" <%= profil.aktiv ? 'checked' : '' %>
|
||||||
|
<%= !darfZeitplanAendern ? 'disabled' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 disabled:opacity-60">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">aktiv</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<% if (!darfZeitplanAendern) { %>
|
||||||
|
<p class="text-xs text-amber-700 dark:text-amber-300 mt-3">
|
||||||
|
Der Zeitplan (aktiv, Wochentage, Uhrzeit) wird vom Administrator verwaltet.
|
||||||
|
</p>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 mb-5">
|
<div id="zeitplan_felder" class="flex flex-wrap gap-2 mb-5 mt-5 <%= !profil.aktiv ? 'opacity-60' : '' %>">
|
||||||
<% wochentage.forEach(function (t) { %>
|
<% wochentage.forEach(function (t) { %>
|
||||||
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-gray-300 dark:border-gray-600 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40">
|
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-gray-300 dark:border-gray-600 text-sm <%= darfZeitplanAendern ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40' : 'opacity-70 cursor-not-allowed' %>">
|
||||||
<input type="checkbox" name="tage" value="<%= t.nr %>"
|
<input type="checkbox" name="tage" value="<%= t.nr %>"
|
||||||
<%= profil.zeitplan_tage.indexOf(t.nr) !== -1 ? 'checked' : '' %>
|
<%= profil.zeitplan_tage.indexOf(t.nr) !== -1 ? 'checked' : '' %>
|
||||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
|
<%= !darfZeitplanAendern ? 'disabled' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 disabled:opacity-60">
|
||||||
<span class="text-gray-700 dark:text-gray-300"><%= t.kurz %></span>
|
<span class="text-gray-700 dark:text-gray-300"><%= t.kurz %></span>
|
||||||
</label>
|
</label>
|
||||||
<% }); %>
|
<% }); %>
|
||||||
@@ -173,7 +177,8 @@
|
|||||||
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zeitplan_zeit">Uhrzeit</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zeitplan_zeit">Uhrzeit</label>
|
||||||
<input type="time" id="zeitplan_zeit" name="zeitplan_zeit" value="<%= profil.zeitplan_zeit %>"
|
<input type="time" id="zeitplan_zeit" name="zeitplan_zeit" value="<%= profil.zeitplan_zeit %>"
|
||||||
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
|
<%= !darfZeitplanAendern ? 'disabled' : '' %>
|
||||||
|
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm disabled:opacity-60">
|
||||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
Der Lauf startet innerhalb weniger Minuten nach dieser Uhrzeit.
|
Der Lauf startet innerhalb weniger Minuten nach dieser Uhrzeit.
|
||||||
</p>
|
</p>
|
||||||
@@ -203,6 +208,7 @@
|
|||||||
<a href="/jobangebote" class="text-blue-600 dark:text-blue-400 hover:underline">Jobangebote</a> an.
|
<a href="/jobangebote" class="text-blue-600 dark:text-blue-400 hover:underline">Jobangebote</a> an.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<% if (darfSuchlaufStarten) { %>
|
||||||
<form action="/jobsuche/start" method="POST" class="shrink-0">
|
<form action="/jobsuche/start" method="POST" class="shrink-0">
|
||||||
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
|
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
@@ -213,6 +219,11 @@
|
|||||||
Jetzt suchen
|
Jetzt suchen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<% } else if (!fremd) { %>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 shrink-0 text-right max-w-[12rem]">
|
||||||
|
Manuelle Suchläufe werden vom Administrator verwaltet.
|
||||||
|
</p>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="laeufe">
|
<div id="laeufe">
|
||||||
@@ -307,6 +318,25 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Zeitplan aktiv/deaktiv: stimmt die Sichtbarkeit der Wochentage + Uhrzeit
|
||||||
|
// mit dem Hauptschalter ab. Die Felder werden nur abgedunkelt, nicht
|
||||||
|
// deaktiviert — so bleiben die eingestellten Wochentage erhalten, wenn der
|
||||||
|
// Zeitplan später wieder aktiviert wird. Ist der Schalter gesperrt (Admin
|
||||||
|
// verwaltet den Zeitplan), greift die serverseitige Vorgabe unverändert.
|
||||||
|
(function () {
|
||||||
|
const aktiv = document.getElementById('zeitplan_aktiv');
|
||||||
|
if (!aktiv || aktiv.disabled) return;
|
||||||
|
const felder = document.getElementById('zeitplan_felder');
|
||||||
|
const zeit = document.getElementById('zeitplan_zeit');
|
||||||
|
function sync() {
|
||||||
|
const aus = !aktiv.checked;
|
||||||
|
if (felder) felder.classList.toggle('opacity-60', aus);
|
||||||
|
if (zeit) zeit.classList.toggle('opacity-60', aus);
|
||||||
|
}
|
||||||
|
sync();
|
||||||
|
aktiv.addEventListener('change', sync);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,47 +1,30 @@
|
|||||||
<%
|
<%
|
||||||
// Gemerkte Bewerbungen — Sektion ganz oben auf der Startseite. Zeigt alle
|
// Gemerkte Bewerbungen — Sektion auf der Startseite, direkt unter den
|
||||||
// favorisierten Bewerbungen unabhängig von ihrem Status. Wird nur gerendert,
|
// anstehenden Terminen. Zeigt alle favorisierten Bewerbungen unabhängig von
|
||||||
// wenn mindestens ein Favorit vorhanden ist.
|
// ihrem Status. Wird nur gerendert, wenn mindestens ein Favorit vorhanden ist.
|
||||||
|
//
|
||||||
|
// Platzsparend als Chip-Wolke: nur der Firmenname, nebeneinander (flex-wrap),
|
||||||
|
// jeder Chip ein Link zur Bewerbung. Status/Datum/Stelle entfallen hier — die
|
||||||
|
// Details stehen auf der Bewerbungsseite. Entmerken geht ebenfalls dort (Stern).
|
||||||
|
// Die id wird von der Such-Logik in index.ejs zum Ein-/Ausblenden verwendet.
|
||||||
%>
|
%>
|
||||||
<% if (bewerbungFavoriten && bewerbungFavoriten.length) { %>
|
<% if (bewerbungFavoriten && bewerbungFavoriten.length) { %>
|
||||||
<%- include('favorit-script') %>
|
<section id="bewerbungFavoritenCard" class="bg-white dark:bg-gray-800/60 rounded-xl border border-gray-200/80 dark:border-gray-700/60 shadow-sm mb-8 overflow-hidden">
|
||||||
<section id="bewerbungFavoritenCard" data-fav-card class="bg-white dark:bg-gray-800 rounded-lg shadow-md mb-8 overflow-hidden">
|
<div class="flex flex-wrap items-center gap-2 px-5 py-3.5 border-b border-gray-100 dark:border-gray-700/60">
|
||||||
<div class="flex flex-wrap items-center gap-2 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<svg class="h-4 w-4 text-amber-400 dark:text-amber-500" viewBox="0 0 24 24" fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/></svg>
|
||||||
<svg class="h-5 w-5 text-amber-500" viewBox="0 0 24 24" fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/></svg>
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-200">Gemerkte Bewerbungen</h2>
|
||||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Gemerkte Bewerbungen</h2>
|
<span class="ml-auto inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700/70 px-2 py-0.5 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
<span class="ml-auto inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:bg-amber-500/10 dark:text-amber-300">
|
|
||||||
<%= bewerbungFavoriten.length %>
|
<%= bewerbungFavoriten.length %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
|
<div class="flex flex-wrap gap-2 p-5">
|
||||||
<% bewerbungFavoriten.forEach(function(b){ %>
|
<% bewerbungFavoriten.forEach(function(b){ %>
|
||||||
<li data-fav-item class="flex flex-wrap items-start justify-between gap-3 px-6 py-4">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
|
||||||
<% if (b.status) { %>
|
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200"><%= b.status %></span>
|
|
||||||
<% } %>
|
|
||||||
<% if (b.datum) { %>
|
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"><%= new Date(b.datum).toLocaleDateString('de-DE') %></span>
|
|
||||||
<% } %>
|
|
||||||
</div>
|
|
||||||
<a href="/bewerbung/<%= b.id %>" class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words hover:underline">
|
|
||||||
<%= b.firma %>
|
|
||||||
</a>
|
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-200 break-words"><%= b.stelle %></p>
|
|
||||||
<% if (b.ort) { %>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5"><%= b.ort %></p>
|
|
||||||
<% } %>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0 flex flex-col gap-2 self-start items-end">
|
|
||||||
<a href="/bewerbung/<%= b.id %>"
|
<a href="/bewerbung/<%= b.id %>"
|
||||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
title="<%= b.firma %>"
|
||||||
Öffnen
|
class="gemerkt-chip inline-flex items-center max-w-[18rem] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700/70 bg-gray-50 dark:bg-gray-700/30 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-white dark:hover:bg-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-sm transition-all">
|
||||||
|
<span class="truncate"><%= b.firma %></span>
|
||||||
</a>
|
</a>
|
||||||
<%- include('favorit-button', { j: b, endpoint: '/api/bewerbungen/' + b.id + '/favorit' }) %>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<% }); %>
|
<% }); %>
|
||||||
</ul>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
<label for="einr-model" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Modell</label>
|
<label for="einr-model" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Modell</label>
|
||||||
<input type="text" id="einr-model" name="OLLAMA_MODEL"
|
<input type="text" id="einr-model" name="OLLAMA_MODEL"
|
||||||
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
value="<%= setupWizard.ollamaModel || 'gpt-oss:120b' %>">
|
value="<%= setupWizard.ollamaModel || 'glm-5.2:cloud' %>">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="einr-host" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Host</label>
|
<label for="einr-host" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Host</label>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
: ('/jobangebote/' + encodeURIComponent(favId) + '/favorit');
|
: ('/jobangebote/' + encodeURIComponent(favId) + '/favorit');
|
||||||
var favCompact = !!(typeof compact !== 'undefined' && compact);
|
var favCompact = !!(typeof compact !== 'undefined' && compact);
|
||||||
%>
|
%>
|
||||||
<button type="button" class="fav-star<%= favAktiv ? ' is-fav' : '' %> inline-flex items-center justify-center gap-1.5 rounded-md border <%= favCompact ? 'p-1.5' : 'px-3 py-1.5' %> text-xs font-medium transition-colors"
|
<button type="button" class="fav-star<%= favAktiv ? ' is-fav' : '' %> inline-flex items-center justify-center gap-1.5 rounded-md <%= favCompact ? 'p-1' : 'px-3 py-1.5 border' %> text-xs font-medium transition-colors"
|
||||||
data-favorit="<%= favId %>" data-aktiv="<%= favAktiv ? 1 : 0 %>" data-endpoint="<%= favEndpoint %>"
|
data-favorit="<%= favId %>" data-aktiv="<%= favAktiv ? 1 : 0 %>" data-endpoint="<%= favEndpoint %>"
|
||||||
title="<%= favAktiv ? 'Favorit entfernen' : 'Als Favorit merken' %>"
|
title="<%= favAktiv ? 'Favorit entfernen' : 'Als Favorit merken' %>"
|
||||||
aria-pressed="<%= favAktiv ? 'true' : 'false' %>">
|
aria-pressed="<%= favAktiv ? 'true' : 'false' %>">
|
||||||
|
|||||||
@@ -40,9 +40,10 @@
|
|||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
if (r.ok && r.json && r.json.success) {
|
if (r.ok && r.json && r.json.success) {
|
||||||
setzeBtn(btn, !!r.json.favorit);
|
setzeBtn(btn, !!r.json.favorit);
|
||||||
// Auf der Startseite: beim Entfernen die Zeile ausblenden
|
// Zeile ausblenden, sobald sie nicht mehr in die aktuelle
|
||||||
// und die ganze Sektion einräumen, wenn sie leer wird.
|
// Ansicht gehört: auf der Offen-Seite beim Merken (favorit=1),
|
||||||
if (!r.json.favorit) entferneFavoritZeile(btn);
|
// auf der Gemerkt-Seite / Startseite beim Entfernen (favorit=0).
|
||||||
|
entferneFavoritZeile(btn, !!r.json.favorit);
|
||||||
} else {
|
} else {
|
||||||
setzeBtn(btn, aktiv); // Server hat abgelehnt -> zurückrollen
|
setzeBtn(btn, aktiv); // Server hat abgelehnt -> zurückrollen
|
||||||
if (r.json && r.json.error) console.warn('Favorit:', r.json.error);
|
if (r.json && r.json.error) console.warn('Favorit:', r.json.error);
|
||||||
@@ -62,11 +63,20 @@
|
|||||||
if (lbl) lbl.textContent = aktiv ? 'Gemerkt' : 'Merken';
|
if (lbl) lbl.textContent = aktiv ? 'Gemerkt' : 'Merken';
|
||||||
}
|
}
|
||||||
|
|
||||||
function entferneFavoritZeile(btn) {
|
function entferneFavoritZeile(btn, istFavorit) {
|
||||||
var item = btn.closest('[data-fav-item]');
|
var item = btn.closest('[data-fav-item]');
|
||||||
if (!item) return; // nicht auf der Startseite -> nur optisch umschalten
|
if (!item) return; // keine ausblendbare Zeile -> nur optisch umschalten
|
||||||
var card = btn.closest('[data-fav-card]') || document.getElementById('favoritenCard');
|
var card = btn.closest('[data-fav-card]') || document.getElementById('favoritenCard');
|
||||||
|
// Offen-Seite: Zeile verschwindet beim Merken (favorit=1).
|
||||||
|
// Gemerkt-Seite / Startseite: Zeile verschwindet beim Entfernen (favorit=0).
|
||||||
|
var view = card && card.getAttribute('data-fav-view');
|
||||||
|
var entfernenBeiFavorit = (view === 'offen');
|
||||||
|
if (istFavorit !== entfernenBeiFavorit) return;
|
||||||
item.remove();
|
item.remove();
|
||||||
|
// Auf der Offen-Seite bleibt die Sektion samt Überschrift stehen —
|
||||||
|
// dort wird nur die einzelne Zeile entfernt. Auf der Gemerkt-Seite
|
||||||
|
// und Startseite räumen wir die leere Sektion ganz ein.
|
||||||
|
if (view === 'offen') return;
|
||||||
if (card && !card.querySelectorAll('[data-fav-item]').length) {
|
if (card && !card.querySelectorAll('[data-fav-item]').length) {
|
||||||
card.style.transition = 'opacity 0.2s ease';
|
card.style.transition = 'opacity 0.2s ease';
|
||||||
card.style.opacity = '0';
|
card.style.opacity = '0';
|
||||||
|
|||||||
@@ -3,6 +3,16 @@
|
|||||||
<p class="text-xs text-gray-400 dark:text-slate-500">
|
<p class="text-xs text-gray-400 dark:text-slate-500">
|
||||||
NextJobs · © nextjobs.cc <span id="currentYear"><%= new Date().getFullYear() %></span>
|
NextJobs · © nextjobs.cc <span id="currentYear"><%= new Date().getFullYear() %></span>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mt-2 text-xs">
|
||||||
|
<a href="/plugin/nextjobs-stellen-import.zip"
|
||||||
|
class="inline-flex items-center gap-1 text-gray-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 underline underline-offset-2"
|
||||||
|
title="Browser-Erweiterung (Chrome/Edge) herunterladen">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V4"></path>
|
||||||
|
</svg>
|
||||||
|
Browser-Plugin herunterladen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -20,4 +20,4 @@
|
|||||||
tailwind.config = { darkMode: 'class' };
|
tailwind.config = { darkMode: 'class' };
|
||||||
</script>
|
</script>
|
||||||
<!-- Custom CSS -->
|
<!-- Custom CSS -->
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
<link rel="stylesheet" href="/css/styles.css?v=<%= assetVersion %>">
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<%
|
||||||
|
// Tab-Leiste der Jobangebote-Seiten. `aktiv` ist einer von 'offen',
|
||||||
|
// 'favoriten', 'uebernommen'. Der Favoriten-Tab zeigt gemerkte Angebote quer
|
||||||
|
// über alle Status und wird nur eingeblendet, wenn es Favoriten gibt (oder er
|
||||||
|
// gerade offen ist) — leere Tabs braucht niemand.
|
||||||
|
var tabs = [
|
||||||
|
{ key: 'offen', href: '/jobangebote', label: 'Offen', count: offenCount },
|
||||||
|
{ key: 'favoriten', href: '/jobangebote/favoriten', label: 'Gemerkt', count: favoritenCount, stern: true },
|
||||||
|
{ key: 'uebernommen', href: '/jobangebote/uebernommen', label: 'Übernommen', count: uebernommenCount },
|
||||||
|
].filter(function (t) { return t.key !== 'favoriten' || t.count > 0 || aktiv === 'favoriten'; });
|
||||||
|
%>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mb-6">
|
||||||
|
<% tabs.forEach(function (t) { %>
|
||||||
|
<a href="<%= t.href %>"
|
||||||
|
<%= aktiv === t.key ? 'aria-current="page"' : '' %>
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors <%= aktiv === t.key
|
||||||
|
? 'bg-blue-600 text-white shadow-sm'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700' %>">
|
||||||
|
<% if (t.stern) { %>
|
||||||
|
<svg class="h-4 w-4 <%= aktiv === t.key ? 'text-white' : 'text-amber-500' %>" viewBox="0 0 24 24" fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/></svg>
|
||||||
|
<% } %>
|
||||||
|
<%= t.label %> (<%= t.count %>)
|
||||||
|
</a>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
+1
-1
@@ -57,7 +57,7 @@
|
|||||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-600 text-white">Empfangen</span>
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-600 text-white">Empfangen</span>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-200 truncate">Von: <%= e.from_addr || '(unbekannt)' %></span>
|
<span class="text-sm text-gray-700 dark:text-gray-200 truncate">Von: <%= e.from_addr || '(unbekannt)' %></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400 shrink-0"><%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE') : '' %></span>
|
<span class="text-xs text-gray-500 dark:text-gray-400 shrink-0"><%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : '' %></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
||||||
<% if (e.display_srcdoc) { %>
|
<% if (e.display_srcdoc) { %>
|
||||||
|
|||||||
@@ -48,7 +48,38 @@
|
|||||||
var val = werte[f.key] != null ? werte[f.key] : '';
|
var val = werte[f.key] != null ? werte[f.key] : '';
|
||||||
var inputType = f.secret ? 'password' : 'text';
|
var inputType = f.secret ? 'password' : 'text';
|
||||||
var inputClass = 'w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm pr-10';
|
var inputClass = 'w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm pr-10';
|
||||||
|
var gewaehlt = String(val).split(',').map(function(s){ return s.trim(); });
|
||||||
|
// Feste Optionen aus der Feld-Definition oder – bei „optionsFrom“ –
|
||||||
|
// die zur Renderzeit geladenen Listen (z. B. die eigenen Anlagen).
|
||||||
|
var optionen = f.options || (f.optionsFrom ? (auswahllisten[f.optionsFrom] || []) : []);
|
||||||
%>
|
%>
|
||||||
|
<% if (f.type === 'checkboxes') { %>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
<%= f.label %>
|
||||||
|
<code class="ml-1 text-[11px] font-normal text-gray-400 dark:text-gray-500"><%= f.key %></code>
|
||||||
|
</span>
|
||||||
|
<!-- Leerer Wert, damit „nichts angehakt“ auch gespeichert wird. -->
|
||||||
|
<input type="hidden" name="<%= f.key %>" value="">
|
||||||
|
<% if (!optionen.length) { %>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500"><%= f.leerHinweis || 'Keine Auswahl verfügbar.' %></p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="flex flex-wrap gap-x-6 gap-y-2">
|
||||||
|
<% optionen.forEach(function(o){ %>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<input type="checkbox" name="<%= f.key %>" value="<%= o.value %>"
|
||||||
|
<%= gewaehlt.includes(String(o.value)) ? 'checked' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600 text-blue-600">
|
||||||
|
<%= o.label %>
|
||||||
|
</label>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (f.help) { %>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2"><%= f.help %></p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% return; } %>
|
||||||
<div class="<%= f.secret ? 'sm:col-span-2' : '' %>">
|
<div class="<%= f.secret ? 'sm:col-span-2' : '' %>">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="cfg-<%= f.key %>">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="cfg-<%= f.key %>">
|
||||||
<%= f.label %>
|
<%= f.label %>
|
||||||
|
|||||||
+2
-2
@@ -238,7 +238,7 @@
|
|||||||
const maxS = Math.max(...byStatus.map(s => s.count));
|
const maxS = Math.max(...byStatus.map(s => s.count));
|
||||||
const statusColor = (s) => ({
|
const statusColor = (s) => ({
|
||||||
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1',
|
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1',
|
||||||
'Interessiert':'#8b5cf6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3',
|
'Interessiert':'#8b5cf6','Telefonat':'#f472b6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3',
|
||||||
'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316',
|
'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316',
|
||||||
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
|
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
|
||||||
}[s] || '#2563eb');
|
}[s] || '#2563eb');
|
||||||
@@ -315,7 +315,7 @@
|
|||||||
<% if (aktivitaet.length) {
|
<% if (aktivitaet.length) {
|
||||||
const statusColor = (s) => ({
|
const statusColor = (s) => ({
|
||||||
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1',
|
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1',
|
||||||
'Interessiert':'#8b5cf6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3',
|
'Interessiert':'#8b5cf6','Telefonat':'#f472b6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3',
|
||||||
'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316',
|
'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316',
|
||||||
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
|
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
|
||||||
}[s] || '#2563eb');
|
}[s] || '#2563eb');
|
||||||
|
|||||||
+16
-1
@@ -15,6 +15,14 @@
|
|||||||
Zurück zur Übersicht
|
Zurück zur Übersicht
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Abgewiesener Upload (zu groß, falscher Dateityp): ohne diesen Banner
|
||||||
|
käme die Seite kommentarlos ohne die Datei zurück. -->
|
||||||
|
<% if (typeof fehler !== 'undefined' && fehler) { %>
|
||||||
|
<div class="mb-6 rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20 p-4" role="alert">
|
||||||
|
<p class="text-sm text-red-800 dark:text-red-200"><%= fehler %></p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-white mb-2">Basis-Unterlagen (Vorlagen)</h1>
|
<h1 class="text-2xl font-bold text-gray-800 dark:text-white mb-2">Basis-Unterlagen (Vorlagen)</h1>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
Diese Unterlagen dienen der KI als Faktengrundlage. Beim Import einer Stelle über die Browser-Erweiterung
|
Diese Unterlagen dienen der KI als Faktengrundlage. Beim Import einer Stelle über die Browser-Erweiterung
|
||||||
@@ -240,7 +248,9 @@
|
|||||||
<form action="/anlagen" method="POST" enctype="multipart/form-data" class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4 space-y-3">
|
<form action="/anlagen" method="POST" enctype="multipart/form-data" class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4 space-y-3">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Datei (PDF)</label>
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Datei (PDF) · max. <%= typeof anhangMaxMb !== 'undefined' ? anhangMaxMb : 15 %> MB
|
||||||
|
</label>
|
||||||
<input type="file" name="datei" accept="application/pdf,image/*" required
|
<input type="file" name="datei" accept="application/pdf,image/*" required
|
||||||
class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
|
class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
|
||||||
</div>
|
</div>
|
||||||
@@ -340,6 +350,11 @@
|
|||||||
<p><strong>Klassisch mit Sidebar</strong> – zurückhaltend, monochrom, für klassische Bewerbungen (Standard).</p>
|
<p><strong>Klassisch mit Sidebar</strong> – zurückhaltend, monochrom, für klassische Bewerbungen (Standard).</p>
|
||||||
<p class="mt-1"><strong>Social Media</strong> – verspielt: getönte Seite, runde Karten, Herzchen, Schreibschrift.
|
<p class="mt-1"><strong>Social Media</strong> – verspielt: getönte Seite, runde Karten, Herzchen, Schreibschrift.
|
||||||
Bewusst auffällig – passend für kreative und Social-Media-Stellen, nicht für die Bank.</p>
|
Bewusst auffällig – passend für kreative und Social-Media-Stellen, nicht für die Bank.</p>
|
||||||
|
<p class="mt-1"><strong>Sunny</strong> – plakativ: weiße Seite, sehr große Überschriften, pastellige Farbbänder,
|
||||||
|
ein Foto, das aus dem Kopfband ragt. Auffällig, aber aufgeräumt – für Marketing, Medien und Design.</p>
|
||||||
|
<p class="mt-1"><strong>Tupfen</strong> – dezent mit Augenzwinkern: weiße Seite, Name groß und mittig, luftiger
|
||||||
|
Blocksatz – und verstreute Punkte in den Rändern als einzige Farbe. Der Lebenslauf greift sie als
|
||||||
|
Abschnittsmarker und Sprach-Skala wieder auf. Passt überall, wirkt trotzdem nicht wie von der Stange.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user