Compare commits

...
27 Commits
Author SHA1 Message Date
thomasandClaude 472259f189 Zeitplan: gesamten Zeitplan direkt in der Sektion deaktivierbar
aktiv-Hauptschalter aus der Sektion 'Automatische Suche' in die Sektion
'Zeitplan' verschoben, sodass der gesamte Zeitplan dort ein-/ausgeschaltet
werden kann. Wochentage + Uhrzeit werden beim Deaktivieren nur abgedunkelt,
die Einstellungen bleiben erhalten.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 18:26:00 +02:00
thomasandClaude 9ac946bc9e .dockerignore: extension/ wieder ins Image aufnehmen (Plugin-Download)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 09:47:16 +02:00
thomasandClaude ddf909502a Browser-Plugin: Multi-User/X-API-Key + Footer-Download
Plugin (Manifest V3) refactored für gehostete Multi-User-Instanz:
- Tracker-URL hardcoded https://nextjobs.cc (keine URL-Eingabe mehr)
- Authentifizierung ausschließlich über persönlichen REST-API-Key
  (Header X-API-Key), pro Benutzer in chrome.storage.sync
- Server: /api/indeed-import nutzt neuen requireApiKey-Middleware,
  ordnet Import dem Benutzerkonto mit passendem cfg:API_TOKEN zu

Download: /plugin/nextjobs-stellen-import.zip packt extension/ on the
fly (dep-freier STORE-Zip in lib/zipdir.js, deterministischer Timestamp),
route hinter requireAuth. Footer-Link im App-Footer. Dockerfile kopiert
extension/ ins Image.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 09:44:41 +02:00
thomasandClaude 1e9618c920 Gemerkte Bewerbungen: platzsparende Chip-Wolke statt Listenzeilen
Nur der Firmenname als klickbarer Chip, nebeneinander (flex-wrap), jeder
Chip öffnet die Bewerbung. Status/Datum/Stelle/Ort entfallen hier — die
Details stehen auf der Bewerbungsseite; entmerken ebenfalls dort (Stern).
Lange Firmennamen werden mit truncate abgeschnitten, der volle Name steht
im title-Tooltip. Header (Stern + Zähler) bleibt, id für Such-Einblendung
unverändert.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 09:19:15 +02:00
thomasandClaude a6a2fabf05 Modal-Scroll: Wrapper als definite-height Scroll-Container (Safari-safe)
Ursache: Tailwinds min-h-screen (min-height:100vh) am Flex-Wrapper wächst
in Safari nicht mit dem Panel – das Panel ragt sichtbar aus dem Bild, der
Container meldet aber keinen Overflow → nichts scrollt, Speichern-Button
ist unerreichbar (Chromium wächst dagegen mit, daher dort scheinbar ok).

Fix: Die innere .min-h-screen-Box wird zum Scroll-Container mit
DEFINITE height:100vh + overflow-y:auto (+ -webkit-overflow-scrolling:touch).
Der Backdrop hält overflow:hidden. margin:auto am Panel zentriert kurze
Modals und vermeidet Top-Clipping bei langen. Wirkt in Safari, Chrome,
Firefox, iOS und Android (verifiziert via Chromium desktop + iPhone-Touch).

Zusätzlich Cache-Busting für styles.css (?v=<mtime>) am <link>, damit ein
hart zwischengespeichertes altes Stylesheet nach einem Deploy garantiert
neu geladen wird.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 09:14:12 +02:00
thomasandClaude 811188f492 Modal-Scroll: Backdrop scrollt, Panel wächst natürlich (margin:auto)
Die max-height/overflow-Variante griff beim Bewerbung-hinzufügen-Formular
nicht zuverlässig. Stattdessen scrollt jetzt das Overlay selbst wie eine
Seite; das Panel wächst auf natürliche Höhe und wird über margin:auto
zentriert (kurzer Inhalt) bzw. ohne Top-Clipping erreichbar (langer Inhalt).
Close- und Speichern-Button bleiben beide erreichbar.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 08:55:20 +02:00
thomasandClaude 1c913b2b28 Admin: pro-Nutzer-Rechte für Suchlauf-Start und Zeitplan
- Neue users-Spalten darf_suchlauf_starten / darf_zeitplan_aendern
  (Default 0 = deaktiviert); idempotente ALTER-Migration für Bestands-DBs.
- Admin-Detailseite: zwei Toggles (Erlaubt/Gesperrt) je Nutzer.
- /jobsuche: ohne Recht ist „Jetzt suchen" ausgeblendet und der Zeitplan
  (aktiv/Tage/Uhrzeit) read-only; Speichern und manueller Start sind
  serverseitig abgesichert. Admin (fremd) ist nie beschränkt.
- Default-Zeitplan bleibt Mo–Fr 17:00 (suchprofil-Defaults).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 08:47:48 +02:00
thomasandClaude fa07dbf90a /bewerbung: Tabs Unterlagen/E-Mail + Admin-Nutzerdetailseite
- Bewerbungsdetail: Toggle zwischen Bewerbungsunterlagen und E-Mail-Korrespondenz
  (hash-basiert), weniger Scroll auf der Seite.
- Admin: Klick auf einen Nutzer öffnet neue Verwaltungsseite
  /admin/users/:id (Passwort, Anmelden als, Jobsuche, Admin-Status, Löschen).
- Admin-Tabelle: Löschen und Jobsuche aus den Aktionen entfernt
  (jetzt auf der Nutzerdetailseite).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 08:37:54 +02:00
thomasandClaude ada69eb482 /bewerbung/🆔 zweispaltiges Layout, dezente Eigene Anhänge
- Zweispalter auf >=lg (links: Bewerbungsdaten+Status+Termine,
  rechts: Unterlagen+Korrespondenz), vorher alles untereinander auf
  max-w-3xl. Seite deutlich kürzer, keine Grid-Row-Lücken dank
  unabhängiger Spalten-Container. Unter lg weiter einspaltig.
- Eigene Anhänge: amber durch neutral grau ersetzt, Papierclip durch
  Schloss-Icon ersetzt (signalisiert „nur für dich / nicht im PDF").
- Größte Textareas etwas kürzer (notizen 6->4, Begleit-E-Mail 12->10,
  Versand-Nachricht 10->8).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 08:23:37 +02:00
thomasandClaude 7d5dc582e4 Bewerbungen: Zeile öffnet /bewerbung/:id statt Inline-Aufklappen
Entfernt den ausklappbaren Detailbereich (Status-Verlauf, Notizen,
interne Notizen) unter jeder Bewerbungs-Zeile auf der Startseite. Ein
Klick auf die Zeile öffnet stattdessen die ganze Bewerbung unter
/bewerbung/:id. Die Aktions-Buttons (Merken, Bearbeiten, Löschen)
bleiben separat bedienbar.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 08:10:51 +02:00
thomasandClaude 1ba615fa80 UI fixes: modal scroll, Bewerbungen-Aktionen, E-Mail-Uhrzeit Berlin
- Modal „Bewerbung hinzufügen" lässt sich auf allen Bildschirmgrößen scrollen
  (nicht nur ≤640px), sonst war Speichern abgeschnitten
- Aktionen-Spalte der Bewerbungen von 4.5rem auf 5.5rem (passt zur
  Cluster-Breite, Icons wandern nicht mehr nach rechts)
- Merken-Stern im Compact-Modus ohne Rahmen
- E-Mail-Uhrzeiten in Postfach, Bewerbung und Reply-Quote in Europe/Berlin

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 08:02:37 +02:00
thomasandClaude 96fd0e282d Jobangebote: Gemerkte nicht mehr unter Offen anzeigen
Favorisierte Angebote erscheinen ausschließlich im Gemerkt-Tab und werden
aus der Offen-Liste, dem Offen-Zähler (Header-Badge) und dem Tab-Zähler
ausgeblendet. Auf der Offen-Seite wird die Zeile beim Merken live entfernt.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 00:01:50 +02:00
thomasandClaude d9ba8698e0 Termine: alle anstehenden auf Startseite + Bearbeiten; Status Telefonat
- Startseite zeigt alle anstehenden Termine (vorher auf 6 begrenzt)
- Termine lassen sich inline bearbeiten (CalDAV-Update, Neu-Anlage bei 404/412)
- Neuer Bewerbungsstatus Telefonat (Listen, Farben, API, PDF, Statistik)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-16 15:27:19 +02:00
thomasandClaude ce5fa1eab1 Startseite: Gemerkte Bewerbungen unter die anstehenden Termine, dezenter & modern
Favoriten-Sektion wird nicht mehr ganz oben gerendert, sondern direkt
unter der Terminkarte; bei aktiver Suche wird sie nun ebenfalls ausge-
blendet. Karte bewusst dezenter gehalten (Border statt Schatten,
kleinere Überschrift, gedämpfte Akzente) mit modernen weichen Kanten.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-15 10:55:20 +02:00
thomasandClaude Opus 4.8 8405b8c057 Tupfen: Ort/Datum im Anschreiben noch etwas tiefer
Abstand über der Ort/Datum-Zeile von 5,5 auf 9,4 Einheiten erhöht, damit
sie klar unter dem Empfängerblock steht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:58:55 +02:00
thomasandClaude Opus 4.8 ec2927cac5 Tupfen: mehr Luft über der Ort/Datum-Zeile im Anschreiben
Ort und Datum standen direkt unter der Kontaktspalte und lasen sich wie
eine weitere Kontaktzeile. Abstand von 1,6 auf 5,5 Einheiten erhöht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:54:47 +02:00
thomasandClaude Opus 4.8 bd7d21bfba Tupfen: Straße im Absenderblock über PLZ/Ort statt Komma-Zeile
Der rechte Absenderblock des Anschreibens ist ohnehin eine Zeilenspalte —
die Anschrift wird jetzt an Komma/Zeilenumbruch aufgebrochen, sodass die
Straße wie in einer Anschrift über der Stadt steht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:36:01 +02:00
thomasandClaude Opus 4.8 1dd4807ca6 Einstellungen: Vorauswahl für KI-Generierung (Dokumente + Anlagen)
Zwei neue, pro Benutzer gespeicherte Konfigurationswerte:
GEN_DOKUMENTE_DEFAULT (Standard: Anschreiben + Lebenslauf) und
GEN_ANLAGEN_DEFAULT (Standard: keine). Sie belegen die Häkchen auf der
Bewerbungsseite vor; eine bereits getroffene Dokumentenauswahl der
Bewerbung gewinnt weiterhin. Die REST-API nutzt die Vorauswahl, wenn
dokumente/anlagen im Body fehlen.

Die Einstellungsseite kennt dafür jetzt Checkbox-Gruppen, deren Optionen
auch erst zur Renderzeit feststehen dürfen (die eigenen Anlagen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:31:19 +02:00
thomasandClaude Opus 4.8 d20a265ebc Neues Layout "Tupfen": dezent, mit verstreuten Punkten
Weiße Seite, Name groß und mittig in Tinte, luftiger Blocksatz — und
korallfarbene Punkte in den Rändern, teils vom Seitenrand angeschnitten,
als einzige Farbe des Blattes. Der Lebenslauf greift das Motiv auf: die
Punkte markieren die Abschnitte, führen die Bullets und bilden als
Fünfer-Reihe die Sprach-Skala (gespeist aus niveauLevel(), also aus der
Formulierung des Lebenslaufs selbst).

Die Punkte skalieren bewusst nicht mit der Schrift: sie gehören zur Seite
wie die Ränder. Das Anschreiben reserviert dafür unten ein Band, in das
der Text nicht hineinläuft.

Der Brief behält das DIN-Anschriftenfeld, das der Vorlage fehlt — es sitzt
in deren zweispaltiger Meta-Zeile (Empfänger links, Datum und Kontakt
rechts), so bleibt die Komposition erhalten und die Bewerbung deutsch.

Dazu:
- neue Akzentfarbe "Koralle"
- write() kann optional Blocksatz. jsPDF stretcht eine Zeile nur, wenn es
  weiß, dass eine weitere folgt — der Absatz geht deshalb in einem Aufruf
  rüber, wodurch die Schlusszeile ungestretcht bleibt.
- Infobox unter /vorlagen beschreibt jetzt auch Sunny (fehlte) und Tupfen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:15:20 +02:00
thomasandClaude Opus 4.8 75813a38a4 Jobangebote: Favoriten-Funktion mit eigenem "Gemerkt"-Tab
Jobangebote lassen sich per Stern merken — eigene Spalte `favorit`,
getrennt von den Bewerbungs-Favoriten. Gemerkte Angebote stehen oben in
der offenen Liste und haben einen eigenen Tab, der Favoriten quer über
alle Status zeigt und ausgeblendet bleibt, solange es keine gibt.

Der Stern wird nur in der Weboberfläche gesetzt; ein erneutes Einspielen
desselben Angebots über die REST-API überschreibt ihn nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:45:04 +02:00
thomasandClaude cd30af3d69 Default OLLAMA_MODEL für neue Nutzer auf glm-5.2:cloud
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 20:16:10 +02:00
thomasandClaude Opus 4.8 0301a1c430 Sunny: Anschreiben dezenter — Farbe nur noch im Kopfband
Das Anschreiben war ein zweites Poster: Konfetti im Band, solides
Akzent-Badge, Betreff und Anrede beide in der Akzentfarbe, ein pastellener
Marker-Strich hinter dem Namen und eine Farbleiste als Fußzeile. Neben dem
Lebenslauf las sich das als Lärm, nicht als Set.

Jetzt trägt die Seite genau eine Farbfläche — das Kopfband —, alles darunter
ist Typografie:

- Kopfband ohne Konfetti, Name 27pt -> 22pt
- Rollen-Badge als weiße Pille mit Akzentschrift (der Lebenslauf behält sein
  solides Badge; die Umstellung sitzt in der Letter-Palette)
- Datum als schlichte rechtsbündige Zeile statt Pastell-Chip
- Betreff und Anrede in Tinte; die Bar über dem Betreff bleibt der einzige
  Farbakzent im Textkörper
- Signatur ohne Bild: Name über feinem Haarstrich statt Marker-Swipe
- Fußzeile: Haarlinie mit gedämpftem Kontakttext statt Farbleiste

suKonfetti/suDot hatten damit keinen Aufrufer mehr und fallen weg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:54:18 +02:00
thomasandClaude Opus 4.8 21608b8ca7 Sunny: Anschreiben als Gegenstück zum Poster-Lebenslauf gestalten
Der Brief trug bisher nur dieselbe Farbe wie der Lebenslauf, nicht dieselbe
Sprache. Jetzt bekommt er denselben Kopf: Name plakativ auf dem Farbband, Ziel-
position als solides Badge, Kontaktdaten als weiße Chips mit Icons rechts
gestapelt, dazu Konfetti in den Randstreifen. Das Datum ist ein Pastell-Chip
neben dem Anschriftfeld statt einer einsamen rechtsbündigen Zeile, der Betreff
bekommt Balken und Größe einer Sektionsüberschrift, die Anrede die Akzentfarbe,
die Anlagen werden zu Chips, und der getippte Name liegt auf einem Pastell-
Streifen wie mit dem Marker gezogen – der Ersatz für die fehlende Handschrift.

DIN-5008 bleibt unangetastet: Anschriftfeld, Datum, Betreff, Anrede, Fließtext,
Gruß, Unterschrift und Anlagen stehen, wo ein deutscher Leser sie erwartet.

Zwei Dinge, die erst im Render auffielen:

- Die Konfetti-Punkte saßen in der linken Rinne direkt neben dem Namen und lasen
  sich als Aufzählungszeichen davor statt als Deko. Sie liegen jetzt rechts; nur
  ein Funkeln bleibt links, tief im Band und weit weg vom Namen.
- Die Kontaktzeile der Fußzeile lief bei langer Anschrift plus langer E-Mail über
  das Band hinaus und schob sich über die Punkte. Sie skaliert jetzt passend und
  lässt notfalls die Anschrift weg, damit Telefon und E-Mail lesbar bleiben.

suBar las seine Farbe fest aus der Lebenslauf-Palette (t.rc), obwohl der Brief
t.lc benutzt – das ging nur gut, weil beide Werte zufällig gleich sind. Die
Palette wird jetzt übergeben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:35:23 +02:00
thomasandClaude Opus 4.8 ffaaafed7e Uploads: abgewiesene Dateien melden statt still zu verwerfen, Anlagen bis 30 MB
Eine zu große oder nicht erlaubte Datei wurde von multer abgewiesen, der Fehler
nur auf die Server-Konsole geloggt und der Browser kommentarlos auf /vorlagen
zurückgeleitet. Für den Benutzer sah das aus, als sei der Upload durchgelaufen,
die Anlage tauchte nur nirgends auf ("Upload error: File too large" im Log, die
Teildatei räumt multer selbst wieder weg). Betroffen waren alle drei Uploads der
Vorlagen-Seite: Anlagen, Unterschrift und Bewerbungsfoto.

Die Routen leiten jetzt mit ?fehler=… um – dasselbe Muster wie auf der Jobsuche –
und die Seite zeigt den Grund als Banner: zu groß (mit Limit), Dateityp nicht
erlaubt, oder keine Datei gewählt. Das Limit steht zusätzlich am Datei-Feld,
bevor jemand eine zu große Datei auswählt.

Limit für Anlagen von 15 auf 30 MB angehoben; gescannte Zeugnisse liegen
regelmäßig darüber. Die Größen sind Konstanten (ANHANG_MAX_MB / BILD_MAX_MB),
aus denen sowohl multer als auch die Meldung und der Hinweis im Formular lesen –
eine Meldung, die ein anderes Limit nennt als das durchgesetzte, wäre schlimmer
als gar keine. Der Bild-Filter für Unterschrift/Foto lehnt jetzt ebenfalls mit
einem Fehler ab statt die Datei still zu verwerfen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:47:24 +02:00
thomasandClaude Opus 4.8 1f2e299c55 Design: drittes Layout "Sunny" für Anschreiben und Lebenslauf
Plakatives Layout nach Vorlage eines Referenz-Lebenslaufs: weiße Seite,
Pastellband im Kopf mit überstehendem Foto, Name groß daneben, Zielposition
als solides Badge, überdimensionale Display-Überschriften unter Pastellbalken,
die abwechselnd links und rechts stehen, zweispaltige Einträge und Level-Balken
für die Sprachen.

Die Balkenlänge kommt aus dem im Lebenslauf genannten Niveau (Muttersprache,
C1, B1 …) – es wird kein Prozentwert erfunden, der nicht in den Unterlagen
steht. Studium, Berufsausbildung und Weiterbildungen laufen unter einer
gemeinsamen Überschrift "Ausbildung": drei Überschriften dieser Größe
hintereinander würden die Seite auffressen.

Neue Akzentfarbe "Beere" als Standard für das Layout. Die Layout-Weiche steckt
jetzt in einer Tabelle (CV_LAYOUT / LETTER_LAYOUT) statt in Ternären – ein
weiteres Layout ist eine Zeile dort plus seine Palette in lib/design.js.
Sidebar und Social bleiben unverändert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:20 +02:00
thomasandClaude 9a40f395c7 Anlagen: user_id aus req.user statt AsyncLocalStorage im multer-Callback
In multer/busboy-Upload-Callbacks ist der per-request AsyncLocalStorage-
Kontext nicht zuverlässig propagiert, sodass currentUserId() dort null
liefern kann und basis_anhaenge-/interne_anhaenge-Zeilen mit user_id=NULL
verwaisten (für den User unsichtbar). Die INSERTs nutzen jetzt uidFromReq
(= req.user.id mit uid()-Fallback) wie bereits userStorageDirForReq für
Signatur/Foto. Zwei bestehende verwaiste Zeilen wurden repariert.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 16:49:16 +02:00
thomasandClaude ce844412e5 Jobsuche: Agent pro Benutzer in isoliertem Docker-Container
Jeder Suchlauf läuft nun in einem frischen Container pro Benutzer, dessen
pro-Benutzer-Home als ~/.claude gemountet ist — Memories und Session-Contexte
liegen damit strikt getrennt pro Benutzer. Die Such-Skills sind Shared-Code
aus dem Image und werden im Container nur nach ~/.claude/skills verlinkt.

Der Host-Runner startet pro Lauf `docker run --rm` und führt bis zu
JOBSUCHE_MAX_PARALLEL (Default 4) Läufe parallel über verschiedene Benutzer
aus (jeder hat eigenen Ollama-Key = getrennte Rate-Limits). Der alte gemeinsame
~/.claude-Pfad wird vom Runner nicht mehr beschrieben.

- source/agent/: neues Agent-Image (Dockerfile + entrypoint + drei Skills)
- scripts/jobsuche-runner.js: agentStarten als docker run, ensureAgentDir, runPool
- scripts/jobsuche-runner.sh + bin/-Kopie: Image-Guard
- package.json: docker:build-agent (lokal, ohne Registry-Push)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 15:41:22 +02:00
52 changed files with 5268 additions and 918 deletions
-1
View File
@@ -1,6 +1,5 @@
node_modules node_modules
data data
extension
indeed_example.txt indeed_example.txt
.env .env
.env.* .env.*
+2 -2
View File
@@ -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
+1
View File
@@ -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
+54
View File
@@ -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"]
+9
View File
@@ -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 "$@"
+331
View File
@@ -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`(0112), `year`(YYYY), `status`, `art`, `label` (`Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`), `search` (Firma/Stelle), `limit`(1500, 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
View File
@@ -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, orts­gebundene 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. „23 Tage/
Woche vor Ort", „überwiegend Präsenz", „Home­office 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-Original­anzeige
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
(37). 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
+565
View File
@@ -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 1520 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 1015 verschiedene Queries ab statt 12.
**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
(37). 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.',
}); });
} }
})(); })();
+4 -7
View File
@@ -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": [
{ {
+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
+11
View File
@@ -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
View File
@@ -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,
}; };
+170 -45
View File
@@ -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: {
+1184 -13
View File
File diff suppressed because it is too large Load Diff
+14 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
flex wrapper then does NOT grow to fit the panel, the panel overflows visibly
("card geht aus dem Bild") but the container reports no overflow — so nothing
scrolls and the Save button is unreachable. A definite height + overflow
forces a real scroll container in every browser. The panel keeps margin:auto
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;
-webkit-overflow-scrolling: touch;
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) { @media (max-width: 640px) {
.max-w-lg, .fixed.inset-0 > .min-h-screen > .max-w-lg,
.max-w-md { .fixed.inset-0 > .min-h-screen > .max-w-md {
max-width: calc(100vw - 2rem) !important; max-width: calc(100vw - 2rem) !important;
max-height: calc(100vh - 2rem) !important;
overflow-y: auto !important;
margin: 0 !important;
}
.fixed.inset-0 > .min-h-screen {
align-items: flex-start !important;
} }
} }
+2 -1
View File
@@ -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
View File
@@ -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();
+11 -4
View File
@@ -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=$?
+526 -60
View File
@@ -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) { if (!req.file) {
const original = req.file.originalname || req.file.filename; return res.redirect(mitFehler('/vorlagen', 'Es wurde keine Datei ausgewählt.'));
const name = (req.body.name && req.body.name.trim())
? sanitizeInput(req.body.name.trim())
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
await dbRun(
'INSERT INTO basis_anhaenge (user_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
[uid(), name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
);
} }
const original = req.file.originalname || req.file.filename;
const name = (req.body.name && req.body.name.trim())
? sanitizeInput(req.body.name.trim())
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
await dbRun(
'INSERT INTO basis_anhaenge (user_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
[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) => {
res.render('settings', { try {
felder: config.FIELDS, // Optionen, die erst zur Laufzeit feststehen (Feld-Definition: optionsFrom).
werte: config.getAll(), const anlagen = await dbAll(
hideSettings: true, 'SELECT id, name, dateiname FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC',
}); [uid()]
);
res.render('settings', {
felder: config.FIELDS,
werte: config.getAll(),
auswahllisten: {
anlagen: anlagen.map((a) => ({ value: String(a.id), label: a.name || a.dateiname })),
},
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
+229
View File
@@ -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: MoFr 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
View File
@@ -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>
+1
View File
@@ -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',
+559 -426
View File
File diff suppressed because it is too large Load Diff
+21 -78
View File
@@ -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');
} }
+42 -17
View File
@@ -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">
Hier landen Stellen, die von Drittanbietersoftware über die <% if (favAnsicht) { %>
<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700">POST /api/v1/joboffers</code> Angebote, die du dir mit dem Stern gemerkt hast — unabhängig davon, ob sie
REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick noch offen oder bereits übernommen sind. Ein erneuter Klick auf den Stern
als Bewerbung-Entwurf übernehmen. Hier siehst du nur <strong>offene</strong> entfernt das Angebot wieder aus dieser Liste. Die Merkliste gilt nur für
Angebote — bereits übernommene findest du im Tab „Übernommen“. Jobangebote und ist von den gemerkten Bewerbungen getrennt.
<% } else { %>
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>
REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick
als Bewerbung-Entwurf übernehmen. Hier siehst du nur <strong>offene</strong>
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">
+13 -10
View File
@@ -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">
+52 -22
View File
@@ -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">
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Zeitplan</h3> <div class="flex items-start justify-between gap-4">
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">Wann die automatische Suche laufen soll.</p> <div>
<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">
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>
+20 -37
View File
@@ -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"> <a href="/bewerbung/<%= b.id %>"
<div class="min-w-0 flex-1"> title="<%= b.firma %>"
<div class="flex flex-wrap items-center gap-2 mb-1"> 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">
<% if (b.status) { %> <span class="truncate"><%= b.firma %></span>
<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> </a>
<% } %>
<% 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 %>"
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">
Öffnen
</a>
<%- include('favorit-button', { j: b, endpoint: '/api/bewerbungen/' + b.id + '/favorit' }) %>
</div>
</li>
<% }); %> <% }); %>
</ul> </div>
</section> </section>
<% } %> <% } %>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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' %>">
+15 -5
View File
@@ -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';
+10
View File
@@ -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 · &copy; nextjobs.cc <span id="currentYear"><%= new Date().getFullYear() %></span> NextJobs · &copy; 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>
+1 -1
View File
@@ -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 %>">
+25
View File
@@ -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
View File
@@ -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) { %>
+31
View File
@@ -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
View File
@@ -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
View File
@@ -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) &middot; 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">